When a service is registered, updated, or deregistered, Central now automatically regenerates HAProxy routes and dnsmasq DNS entries to match the current set of registered services. - L4 tcp-passthrough services (Wild Cloud) → HAProxy SNI routes - L7 http services (Wild Works) → HAProxy Host-based reverse proxy - All services with reach != off → dnsmasq DNS entries - SSE events broadcast on config changes - Reconciliation runs asynchronously (non-blocking) This closes the loop: service registration is no longer inert — it actually updates the networking stack. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
513 lines
17 KiB
Go
513 lines
17 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
|
|
}
|
|
|
|
// 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")
|
|
|
|
// 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
|
|
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 {
|
|
respondError(w, http.StatusInternalServerError, "Failed to read config")
|
|
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, 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 {
|
|
for key := range secretsMap {
|
|
secretsMap[key] = "********"
|
|
}
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
})
|
|
}
|