package v1 import ( "fmt" "io" "log/slog" "net/http" "os" "path/filepath" "syscall" "time" gocontext "context" "github.com/gorilla/mux" "gopkg.in/yaml.v3" "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/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/services" "github.com/wild-cloud/wild-central/internal/sse" "github.com/wild-cloud/wild-central/internal/storage" "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 config *config.Manager secrets *secrets.Manager dnsmasq *dnsmasq.ConfigGenerator haproxy *haproxy.Manager nftables *nftables.Manager ddns *ddns.Runner crowdsec *crowdsec.Manager vpn *wireguard.Manager certbot *certbot.Manager services *services.Manager // Service 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) { configMgr := config.NewManager() if err := configMgr.EnsureGlobalConfig(dataDir); err != nil { return nil, fmt.Errorf("failed to ensure global config: %w", err) } // 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, config: configMgr, secrets: secrets.NewManager(), 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(""), services: services.NewManager(dataDir), sseManager: sseManager, } // Wire up service registration reconciliation: when services change, // regenerate dnsmasq DNS entries and HAProxy routes. api.services.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 } // RegisterSelf registers Wild Central's own UI as a service so it gets // DNS + proxy + TLS via its own networking stack. Idempotent — safe to // call on every startup. Skips silently if no domain is configured. func (api *API) RegisterSelf(port int) { globalConfigPath := filepath.Join(api.dataDir, "config.yaml") globalCfg, err := config.LoadGlobalConfig(globalConfigPath) if err != nil { return } domain := globalCfg.Cloud.Central.Domain if domain == "" { return } svc := services.Service{ Source: "wild-central", Domain: domain, Backend: services.Backend{ Address: fmt.Sprintf("127.0.0.1:%d", port), Type: services.BackendHTTP, }, Reach: services.ReachInternal, TLS: services.TLSTerminate, } if err := api.services.Register(svc); err != nil { slog.Warn("failed to self-register", "domain", domain, "error", err) } else { slog.Info("registered wild-central", "domain", domain) } } // StartDDNS loads DDNS config and starts the background goroutine. func (api *API) StartDDNS(ctx gocontext.Context) { api.ctx = ctx api.reloadDDNSIfEnabled() } // reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine. func (api *API) reloadDDNSIfEnabled() { globalConfigPath := filepath.Join(api.dataDir, "config.yaml") globalCfg, err := config.LoadGlobalConfig(globalConfigPath) if err != nil || !globalCfg.Cloud.DDNS.Enabled { api.ddns.Stop() return } apiToken := api.getCloudflareToken() cfg := ddns.Config{ Enabled: true, APIToken: apiToken, Records: globalCfg.Cloud.DDNS.Records, IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes, } api.ddns.Start(api.ctx, cfg) } // 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) // Global Configuration r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET") r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).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") // dnsmasq management 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") r.HandleFunc("/api/v1/dnsmasq/dhcp/leases", api.DnsmasqDHCPLeases).Methods("GET") r.HandleFunc("/api/v1/dnsmasq/dhcp/static", api.DnsmasqDHCPAddStatic).Methods("POST") r.HandleFunc("/api/v1/dnsmasq/dhcp/static/{mac}", api.DnsmasqDHCPDeleteStatic).Methods("DELETE") // HAProxy ingress 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") // nftables firewall management r.HandleFunc("/api/v1/nftables/status", api.NftablesStatus).Methods("GET") r.HandleFunc("/api/v1/nftables/apply", api.NftablesApply).Methods("POST") r.HandleFunc("/api/v1/nftables/interfaces", api.NftablesGetInterfaces).Methods("GET") // 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") // DDNS management r.HandleFunc("/api/v1/ddns/status", api.DDNSStatus).Methods("GET") r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).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/{name}", api.CrowdSecDeleteMachine).Methods("DELETE") r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET") 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") // Service registration — domain is the unique key r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET") r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST") r.HandleFunc("/api/v1/services/deregister", api.ServicesDeregisterBySource).Methods("DELETE") r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesGet).Methods("GET") r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesUpdate).Methods("PATCH") r.HandleFunc("/api/v1/services/{domain:.+}", api.ServicesDeregister).Methods("DELETE") // SSE events r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET") } // --- Global Config/Secrets handlers --- // GetGlobalConfig returns the global config wrapped in { configured, config }. func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) { configPath := filepath.Join(api.dataDir, "config.yaml") data, err := os.ReadFile(configPath) if err != nil { respondJSON(w, http.StatusOK, map[string]any{ "configured": false, }) return } var configMap map[string]any if err := yaml.Unmarshal(data, &configMap); err != nil { respondError(w, http.StatusInternalServerError, "Failed to parse config") return } respondJSON(w, http.StatusOK, map[string]any{ "configured": true, "config": configMap, }) } // UpdateGlobalConfig updates the global config func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { configPath := filepath.Join(api.dataDir, "config.yaml") 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 } // Read existing config existingContent, err := storage.ReadFile(configPath) if err != nil && !os.IsNotExist(err) { respondError(w, http.StatusInternalServerError, "Failed to read existing config") return } var existingConfig map[string]any if len(existingContent) > 0 { if err := yaml.Unmarshal(existingContent, &existingConfig); err != nil { respondError(w, http.StatusBadRequest, "Failed to parse existing config") return } } else { existingConfig = make(map[string]any) } for key, value := range updates { existingConfig[key] = value } yamlContent, err := yaml.Marshal(existingConfig) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to marshal YAML") return } lockPath := configPath + ".lock" if err := storage.WithLock(lockPath, func() error { return storage.WriteFile(configPath, yamlContent, 0644) }); err != nil { respondError(w, http.StatusInternalServerError, "Failed to write config") return } slog.Info("global config updated") // Reload DDNS if config changed go api.reloadDDNSIfEnabled() respondMessage(w, http.StatusOK, "Config updated successfully") } // GetGlobalSecrets returns the global secrets (redacted by default) func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) { secretsPath := filepath.Join(api.dataDir, "secrets.yaml") data, err := os.ReadFile(secretsPath) if err != nil { if os.IsNotExist(err) { respondJSON(w, http.StatusOK, map[string]any{}) return } respondError(w, http.StatusInternalServerError, "Failed to read secrets") return } var secretsMap map[string]any if err := yaml.Unmarshal(data, &secretsMap); err != nil { respondError(w, http.StatusInternalServerError, "Failed to parse secrets") return } if secretsMap == nil { secretsMap = map[string]any{} } 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) { secretsPath := filepath.Join(api.dataDir, "secrets.yaml") 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 } existingContent, err := storage.ReadFile(secretsPath) if err != nil && !os.IsNotExist(err) { respondError(w, http.StatusInternalServerError, "Failed to read existing secrets") return } var existing map[string]any if len(existingContent) > 0 { if err := yaml.Unmarshal(existingContent, &existing); err != nil { respondError(w, http.StatusBadRequest, "Failed to parse existing secrets") return } } else { existing = make(map[string]any) } for key, value := range updates { existing[key] = value } yamlContent, err := yaml.Marshal(existing) if err != nil { respondError(w, http.StatusInternalServerError, "Failed to marshal YAML") return } lockPath := secretsPath + ".lock" if err := storage.WithLock(lockPath, func() error { return storage.WriteFile(secretsPath, yamlContent, 0600) }); 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) pendingRestart := false if info, err := os.Stat(filepath.Join(dataDir, "config.yaml")); err == nil { pendingRestart = info.ModTime().After(startTime) } respondJSON(w, http.StatusOK, map[string]any{ "status": "running", "version": api.version, "uptime": uptime.String(), "uptimeSeconds": int(uptime.Seconds()), "dataDir": dataDir, "pendingRestart": pendingRestart, }) } // 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 { secretsPath := filepath.Join(api.dataDir, "secrets.yaml") data, err := os.ReadFile(secretsPath) if err != nil { return "" } var secretsMap map[string]any if err := yaml.Unmarshal(data, &secretsMap); err != nil { return "" } cloudflare, ok := secretsMap["cloudflare"].(map[string]any) if !ok { return "" } token, _ := cloudflare["apiToken"].(string) return token } // getCloudflareZoneID reads the Cloudflare Zone ID from global secrets func (api *API) getCloudflareZoneID() string { secretsPath := filepath.Join(api.dataDir, "secrets.yaml") data, err := os.ReadFile(secretsPath) if err != nil { return "" } var secretsMap map[string]any if err := yaml.Unmarshal(data, &secretsMap); err != nil { return "" } cloudflare, ok := secretsMap["cloudflare"].(map[string]any) if !ok { return "" } zoneID, _ := cloudflare["zoneId"].(string) 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, }) }