Files
wild-central/internal/api/v1/handlers.go
Paul Payne 57a597fe5a feat: Self-register Wild Central on startup
On startup, Wild Central registers its own domain (from config
cloud.central.domain) as an HTTP service with itself. This creates
the DNS entry, proxy route, and TLS cert needed to access the
Central UI via its domain name.

Registration is idempotent — safe on every restart. Skips silently
if no domain is configured. Logs a warning if registration fails.

Verified: after startup, /api/v1/services shows wild-central
registered with domain central.payne.io.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-09 05:40:27 +00:00

567 lines
18 KiB
Go

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
}
// 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
}
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{
Name: "wild-central",
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
r.HandleFunc("/api/v1/services", api.ServicesListAll).Methods("GET")
r.HandleFunc("/api/v1/services", api.ServicesRegister).Methods("POST")
r.HandleFunc("/api/v1/services/{name}", api.ServicesGet).Methods("GET")
r.HandleFunc("/api/v1/services/{name}", api.ServicesUpdate).Methods("PATCH")
r.HandleFunc("/api/v1/services/{name}", 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,
})
}