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>
567 lines
18 KiB
Go
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,
|
|
})
|
|
}
|