package v1 import ( "fmt" "io" "log/slog" "net/http" "os" "os/exec" "path/filepath" "strings" "syscall" "time" gocontext "context" "github.com/gorilla/mux" "gopkg.in/yaml.v3" "github.com/wild-cloud/wild-central/internal/authelia" "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/crowdsec" "github.com/wild-cloud/wild-central/internal/ddns" "github.com/wild-cloud/wild-central/internal/dnsfilter" "github.com/wild-cloud/wild-central/internal/dnsmasq" "github.com/wild-cloud/wild-central/internal/haproxy" "github.com/wild-cloud/wild-central/internal/network" "github.com/wild-cloud/wild-central/internal/nftables" "github.com/wild-cloud/wild-central/internal/secrets" "github.com/wild-cloud/wild-central/internal/domains" "github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/wireguard" ) // API holds all dependencies for Central API handlers type API struct { dataDir string version string allowedOrigins []string ctx gocontext.Context // parent context for restartable goroutines secrets *secrets.Manager dnsmasq *dnsmasq.ConfigGenerator haproxy *haproxy.Manager nftables *nftables.Manager ddns *ddns.Runner crowdsec *crowdsec.Manager vpn *wireguard.Manager certbot *certbot.Manager authelia *authelia.Manager // Authelia authentication service manager dnsFilter *dnsfilter.Manager // DNS filtering manager dnsFilterRunner *dnsfilter.Runner // DNS filter background update runner domains *domains.Manager // Domain registration manager sseManager *sse.Manager // SSE manager for real-time events port int // Running API port (for config-driven routes) } // NewAPI creates a new Central API handler with all dependencies func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { // Determine config paths from env or defaults dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf") haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg") nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-cloud.nft") vpnConfigPath := envOrDefault("WILD_CENTRAL_VPN_CONFIG_PATH", "/etc/wireguard/wg0.conf") sseManager := sse.NewManager() api := &API{ dataDir: dataDir, version: version, allowedOrigins: allowedOrigins, secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")), dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath), haproxy: haproxy.NewManager(haproxyConfigPath), nftables: nftables.NewManager(nftablesRulesPath), ddns: ddns.NewRunner(), crowdsec: crowdsec.NewManager(), vpn: wireguard.NewManager(dataDir, vpnConfigPath), certbot: certbot.NewManager(""), authelia: authelia.NewManager(filepath.Join(dataDir, "authelia")), dnsFilter: dnsfilter.NewManager(filepath.Join(dataDir, "dns-filter")), domains: domains.NewManager(dataDir), sseManager: sseManager, } // The compiled blocklist must be readable by dnsmasq which runs as its // own user. Use /var/lib/wild-central/dns-filter/ (world-readable) rather // than the data dir which may be under a user home with 0750 permissions. filterDir := envOrDefault("WILD_CENTRAL_FILTER_DIR", "/var/lib/wild-central/dns-filter") api.dnsFilter.SetHostsFilePath(filepath.Join(filterDir, "blocked.conf")) // Wire up domain registration reconciliation: when domains change, // regenerate dnsmasq DNS entries and HAProxy routes. api.domains.SetReconcileFn(api.reconcileNetworking) return api, nil } // SetPort records the running API port for config-driven HAProxy routes. func (api *API) SetPort(port int) { api.port = port } func (api *API) getRunningPort() int { if api.port > 0 { return api.port } return 5055 } func envOrDefault(key, defaultVal string) string { if v := os.Getenv(key); v != "" { slog.Info("using custom config path", "key", key, "path", v) return v } return defaultVal } // StartDDNS launches the DDNS background goroutine with a params callback // that reads fresh state (token, public domains, interval) on each tick. func (api *API) StartDDNS(ctx gocontext.Context) { api.ctx = ctx api.ddns.Start(ctx, api.ddnsParams) } // StartDNSFilter launches the DNS filter background goroutine if enabled. func (api *API) StartDNSFilter(ctx gocontext.Context) { api.dnsFilterRunner = dnsfilter.NewRunner(api.dnsFilter, func() { if err := api.dnsmasq.ReloadService(); err != nil { slog.Warn("dns-filter: failed to reload dnsmasq after update", "error", err) } api.broadcastDNSFilterEvent("dns-filter:updated", "DNS filter lists updated") }) state, err := config.LoadState(api.statePath()) if err != nil || !state.Cloud.DNSFilter.Enabled { return } _ = api.dnsFilter.EnsureDataDir() interval := state.Cloud.DNSFilter.IntervalHours if interval <= 0 { interval = 24 } api.dnsFilterRunner.Start(ctx, interval) } // ddnsParams returns the current DDNS parameters derived from runtime state. // Called by the DDNS runner on each tick — always returns fresh values. func (api *API) ddnsParams() ddns.Params { state, err := config.LoadState(api.statePath()) if err != nil || !state.Cloud.DDNS.Enabled { return ddns.Params{} } doms, _ := api.domains.List() var records []string for _, dom := range doms { if dom.Public && dom.DomainName != "" { records = append(records, dom.DomainName) } } return ddns.Params{ APIToken: api.getCloudflareToken(), Records: records, IntervalMinutes: state.Cloud.DDNS.IntervalMinutes, } } // reloadDDNSIfEnabled starts the runner if DDNS is enabled, or stops it if disabled. // When already running, triggers an immediate check with fresh params. func (api *API) reloadDDNSIfEnabled() { state, _ := config.LoadState(api.statePath()) if state == nil || !state.Cloud.DDNS.Enabled { api.ddns.Stop() return } if !api.ddns.GetStatus().Enabled { api.ddns.Start(api.ctx, api.ddnsParams) } else { api.ddns.Trigger() } } // StartCentralStatusBroadcaster starts periodic broadcasting of central status func (api *API) StartCentralStatusBroadcaster(startTime time.Time) { go func() { ticker := time.NewTicker(30 * time.Second) defer ticker.Stop() api.broadcastCentralStatusEvent(startTime) for range ticker.C { api.broadcastCentralStatusEvent(startTime) } }() } // RegisterRoutes registers all Central API routes func (api *API) RegisterRoutes(r *mux.Router) { r.Use(RequestLoggingMiddleware) // Resource-oriented settings (persisted in state.yaml) r.HandleFunc("/api/v1/operator", api.GetOperator).Methods("GET") r.HandleFunc("/api/v1/operator", api.UpdateOperator).Methods("PUT") r.HandleFunc("/api/v1/central-domain", api.GetCentralDomain).Methods("GET") r.HandleFunc("/api/v1/central-domain", api.UpdateCentralDomain).Methods("PUT") // Global Secrets r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET") r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT") // Daemon control r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST") // Network detection r.HandleFunc("/api/v1/network/info", api.NetworkInfoHandler).Methods("GET") r.HandleFunc("/api/v1/network/resolve", api.NetworkResolveHandler).Methods("GET") // DNS (dnsmasq daemon) r.HandleFunc("/api/v1/dns/settings", api.GetDNSSettings).Methods("GET") r.HandleFunc("/api/v1/dns/settings", api.UpdateDNSSettings).Methods("PUT") r.HandleFunc("/api/v1/dnsmasq/status", api.DnsmasqStatus).Methods("GET") r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqGetConfig).Methods("GET") r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqWriteConfig).Methods("PUT") r.HandleFunc("/api/v1/dnsmasq/restart", api.DnsmasqRestart).Methods("POST") r.HandleFunc("/api/v1/dnsmasq/generate", api.DnsmasqGenerate).Methods("POST") // DHCP (feature, backed by dnsmasq) r.HandleFunc("/api/v1/dhcp/config", api.GetDHCPConfig).Methods("GET") r.HandleFunc("/api/v1/dhcp/config", api.UpdateDHCPConfig).Methods("PUT") r.HandleFunc("/api/v1/dhcp/leases", api.DnsmasqDHCPLeases).Methods("GET") r.HandleFunc("/api/v1/dhcp/static", api.DnsmasqDHCPAddStatic).Methods("POST") r.HandleFunc("/api/v1/dhcp/static/{mac}", api.DnsmasqDHCPDeleteStatic).Methods("DELETE") // HAProxy gateway management r.HandleFunc("/api/v1/haproxy/status", api.HaproxyStatus).Methods("GET") r.HandleFunc("/api/v1/haproxy/config", api.HaproxyGetConfig).Methods("GET") r.HandleFunc("/api/v1/haproxy/generate", api.HaproxyGenerate).Methods("POST") r.HandleFunc("/api/v1/haproxy/restart", api.HaproxyRestart).Methods("POST") r.HandleFunc("/api/v1/haproxy/stats", api.HaproxyStats).Methods("GET") r.HandleFunc("/api/v1/haproxy/routes", api.GetHAProxyRoutes).Methods("GET") r.HandleFunc("/api/v1/haproxy/routes", api.UpdateHAProxyRoutes).Methods("PUT") // nftables firewall management r.HandleFunc("/api/v1/nftables/status", api.NftablesStatus).Methods("GET") r.HandleFunc("/api/v1/nftables/config", api.GetNftablesConfig).Methods("GET") r.HandleFunc("/api/v1/nftables/config", api.UpdateNftablesConfig).Methods("PUT") r.HandleFunc("/api/v1/nftables/apply", api.NftablesApply).Methods("POST") r.HandleFunc("/api/v1/nftables/interfaces", api.NftablesGetInterfaces).Methods("GET") // DDNS management r.HandleFunc("/api/v1/ddns/config", api.GetDDNSConfig).Methods("GET") r.HandleFunc("/api/v1/ddns/config", api.UpdateDDNSConfig).Methods("PUT") r.HandleFunc("/api/v1/ddns/status", api.DDNSStatus).Methods("GET") r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST") // WireGuard VPN management r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET") r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET") r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT") r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST") r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST") r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET") r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST") r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET") r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE") // TLS certificate management r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET") r.HandleFunc("/api/v1/cert/provision", api.CertProvision).Methods("POST") r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST") // CrowdSec LAPI management r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET") r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET") r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET") r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET") r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST") r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE") r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE") r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET") r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecAddMachine).Methods("POST") r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE") r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET") r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecAddBouncer).Methods("POST") r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE") // Cloudflare management r.HandleFunc("/api/v1/cloudflare/verify", api.CloudflareVerify).Methods("GET") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareGetZoneID).Methods("GET") r.HandleFunc("/api/v1/cloudflare/zone", api.CloudflareSetZoneID).Methods("POST") // Authelia authentication management r.HandleFunc("/api/v1/authelia/status", api.AutheliaStatus).Methods("GET") r.HandleFunc("/api/v1/authelia/config", api.AutheliaGetConfig).Methods("GET") r.HandleFunc("/api/v1/authelia/config", api.AutheliaUpdateConfig).Methods("PUT") r.HandleFunc("/api/v1/authelia/restart", api.AutheliaRestart).Methods("POST") r.HandleFunc("/api/v1/authelia/users", api.AutheliaListUsers).Methods("GET") r.HandleFunc("/api/v1/authelia/users", api.AutheliaCreateUser).Methods("POST") r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaUpdateUser).Methods("PUT") r.HandleFunc("/api/v1/authelia/users/{username}", api.AutheliaDeleteUser).Methods("DELETE") r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaListOIDCClients).Methods("GET") r.HandleFunc("/api/v1/authelia/oidc/clients", api.AutheliaCreateOIDCClient).Methods("POST") r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaUpdateOIDCClient).Methods("PUT") r.HandleFunc("/api/v1/authelia/oidc/clients/{clientId}", api.AutheliaDeleteOIDCClient).Methods("DELETE") // DNS Filtering r.HandleFunc("/api/v1/dns-filter/status", api.DNSFilterStatus).Methods("GET") r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterGetConfig).Methods("GET") r.HandleFunc("/api/v1/dns-filter/config", api.DNSFilterUpdateConfig).Methods("PUT") r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterGetLists).Methods("GET") r.HandleFunc("/api/v1/dns-filter/lists", api.DNSFilterAddList).Methods("POST") r.HandleFunc("/api/v1/dns-filter/lists/upload", api.DNSFilterUploadList).Methods("POST") r.HandleFunc("/api/v1/dns-filter/lists/suggested", api.DNSFilterGetSuggested).Methods("GET") r.HandleFunc("/api/v1/dns-filter/lists/{id}", api.DNSFilterRemoveList).Methods("DELETE") r.HandleFunc("/api/v1/dns-filter/lists/{id}/toggle", api.DNSFilterToggleList).Methods("PUT") r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterGetCustomEntries).Methods("GET") r.HandleFunc("/api/v1/dns-filter/custom", api.DNSFilterSetCustomEntry).Methods("POST") r.HandleFunc("/api/v1/dns-filter/custom/{domain:.+}", api.DNSFilterRemoveCustomEntry).Methods("DELETE") r.HandleFunc("/api/v1/dns-filter/update", api.DNSFilterTriggerUpdate).Methods("POST") // Domain registration — domain is the unique key r.HandleFunc("/api/v1/domains", api.DomainsListAll).Methods("GET") r.HandleFunc("/api/v1/domains", api.DomainsRegister).Methods("POST") r.HandleFunc("/api/v1/domains/deregister", api.DomainsDeregisterBySource).Methods("DELETE") r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsGet).Methods("GET") r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsUpdate).Methods("PATCH") r.HandleFunc("/api/v1/domains/{domain:.+}", api.DomainsDeregister).Methods("DELETE") // SSE events r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET") } // --- Secrets handlers --- // GetGlobalSecrets returns the global secrets (redacted by default) func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) { secretsMap, err := api.secrets.GetAll() if err != nil { respondError(w, http.StatusInternalServerError, "Failed to read secrets") return } showRaw := r.URL.Query().Get("raw") == "true" if !showRaw { redactSecrets(secretsMap) } respondJSON(w, http.StatusOK, secretsMap) } // UpdateGlobalSecrets updates the global secrets func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { respondError(w, http.StatusBadRequest, "Failed to read request body") return } var updates map[string]any if err := yaml.Unmarshal(body, &updates); err != nil { respondError(w, http.StatusBadRequest, "Invalid YAML format") return } if err := api.secrets.MergeUpdate(updates); err != nil { respondError(w, http.StatusInternalServerError, "Failed to write secrets") return } slog.Info("global secrets updated") // Reload DDNS if secrets changed (may contain API tokens) go api.reloadDDNSIfEnabled() respondMessage(w, http.StatusOK, "Secrets updated successfully") } // --- Utility handlers --- // StatusHandler returns daemon status information func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime time.Time, dataDir string) { uptime := time.Since(startTime) respondJSON(w, http.StatusOK, map[string]any{ "status": "running", "version": api.version, "uptime": uptime.String(), "uptimeSeconds": int(uptime.Seconds()), "dataDir": dataDir, "daemons": getDaemonStatus(), }) } // getDaemonStatus checks whether each managed daemon is active and gets its version. func getDaemonStatus() map[string]map[string]any { type daemonInfo struct { unit string verCmd []string // command to get version verParse func(string) string // extract version from output } firstWord := func(s string) string { if i := strings.IndexByte(s, ' '); i > 0 { return s[:i] } return strings.TrimSpace(s) } daemons := map[string]daemonInfo{ "dnsmasq": { unit: "dnsmasq.service", verCmd: []string{"dnsmasq", "--version"}, verParse: func(out string) string { // "Dnsmasq version 2.90 ..." if i := strings.Index(out, "version "); i >= 0 { return firstWord(out[i+8:]) } return "" }, }, "haproxy": { unit: "haproxy.service", verCmd: []string{"haproxy", "-v"}, verParse: func(out string) string { // "HAProxy version 2.8.5-1 ..." if i := strings.Index(out, "version "); i >= 0 { return firstWord(out[i+8:]) } return "" }, }, "wireguard": { unit: "wg-quick@wg0.service", verCmd: []string{"wg", "--version"}, verParse: func(out string) string { // "wireguard-tools v1.0.20210914 - ..." if i := strings.Index(out, " v"); i >= 0 { return firstWord(out[i+1:]) } return "" }, }, "crowdsec": { unit: "crowdsec.service", verCmd: []string{"cscli", "version", "--raw"}, verParse: func(out string) string { return firstWord(out) }, }, "authelia": { unit: "authelia.service", verCmd: []string{"authelia", "--version"}, verParse: func(out string) string { // "authelia version 4.x.x" or just "4.x.x" if after, found := strings.CutPrefix(out, "authelia version "); found { return firstWord(after) } return firstWord(out) }, }, } result := make(map[string]map[string]any, len(daemons)+1) for name, info := range daemons { err := exec.Command("systemctl", "is-active", "--quiet", info.unit).Run() entry := map[string]any{"active": err == nil} if out, verErr := exec.Command(info.verCmd[0], info.verCmd[1:]...).Output(); verErr == nil { if v := info.verParse(string(out)); v != "" { entry["version"] = v } } result[name] = entry } // nftables has no persistent service — check if the wild-cloud table exists nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-cloud").Run() nftEntry := map[string]any{"active": nftErr == nil} if out, verErr := exec.Command("nft", "--version").Output(); verErr == nil { // "nftables v1.0.9 (Old Doc Yak #3)" s := string(out) if i := strings.Index(s, " v"); i >= 0 { nftEntry["version"] = firstWord(s[i+1:]) } } result["nftables"] = nftEntry return result } // DaemonRestart triggers a graceful self-restart via SIGTERM func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."}) go func() { time.Sleep(300 * time.Millisecond) if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil { slog.Error("failed to send SIGTERM to self", "error", err) } }() } // NetworkInfoHandler returns detected network configuration func (api *API) NetworkInfoHandler(w http.ResponseWriter, r *http.Request) { info, err := network.DetectNetworkInfo() if err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to detect network info: %v", err)) return } respondJSON(w, http.StatusOK, info) } // NetworkResolveHandler resolves a domain to an IP func (api *API) NetworkResolveHandler(w http.ResponseWriter, r *http.Request) { domain := r.URL.Query().Get("domain") if domain == "" { respondError(w, http.StatusBadRequest, "domain parameter is required") return } ip, err := network.ResolveDomain(domain) if err != nil { respondJSON(w, http.StatusOK, map[string]any{ "success": false, "error": err.Error(), }) return } respondJSON(w, http.StatusOK, map[string]any{ "success": true, "ip": ip, }) } // redactSecrets replaces leaf string values with "********" while preserving // map structure so the UI can check which keys exist. func redactSecrets(m map[string]any) { for key, val := range m { if nested, ok := val.(map[string]any); ok { redactSecrets(nested) } else { m[key] = "********" } } } // getCloudflareToken reads the Cloudflare API token from global secrets func (api *API) getCloudflareToken() string { token, err := api.secrets.GetSecret("cloudflare.apiToken") if err != nil { return "" } return token } // getCloudflareZoneID reads the Cloudflare Zone ID from global secrets func (api *API) getCloudflareZoneID() string { zoneID, err := api.secrets.GetSecret("cloudflare.zoneId") if err != nil { return "" } return zoneID } // respondAccepted writes a 202 Accepted response for async operations func respondAccepted(w http.ResponseWriter, operationID, message string) { respondJSON(w, http.StatusAccepted, map[string]string{ "operation_id": operationID, "message": message, }) }