Files
wild-central/internal/api/v1/handlers.go
Paul Payne 60f3ca4a3a Security hardening: input validation, secrets protection, NATS auth
Config injection prevention:
- Add FQDN validation for domain names (RFC 1123) in Register/Update —
  rejects newlines, spaces, shell metacharacters that could inject into
  HAProxy/dnsmasq configs
- Add backend address validation (valid host:port format, valid IP or
  hostname, port 1-65535). DNS-only backends allow bare IPs.
- Add header key/value validation — keys must be HTTP token chars only,
  values must not contain newlines or NULs
- Add WireGuard peer name validation (alphanumeric + hyphens + underscores)
- Add defense-in-depth domain validation in certbot Provision()

Secrets protection:
- Remove ?raw=true bypass on GET /api/v1/secrets — secrets are now always
  redacted in API responses regardless of query parameters
- Update test to verify redaction cannot be bypassed

NATS authentication:
- Generate random auth token on first startup, store in secrets.yaml
- Pass token to embedded NATS server via Authorization option
- Internal client connects with the same token
- External NATS clients (Wild Cloud) must now authenticate

Security headers:
- Add X-Content-Type-Options: nosniff
- Add X-Frame-Options: DENY
- Add Cache-Control: no-store
2026-07-14 12:38:17 +00:00

649 lines
24 KiB
Go

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/domains"
"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/reconcile"
"github.com/wild-cloud/wild-central/internal/secrets"
"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
reconciler *reconcile.Reconciler
secrets *secrets.Manager
dnsmasq *dnsmasq.Manager
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.NewManager(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"))
// Build the reconciler — the core domain→config→daemon pipeline
api.reconciler = &reconcile.Reconciler{
Domains: api.domains,
HAProxy: api.haproxy,
DNS: api.dnsmasq,
Auth: api.authelia,
DDNS: api.ddns,
DNSFilter: api.dnsFilter,
SSE: sseManager,
StatePath: filepath.Join(dataDir, "state.yaml"),
GenerateAutheliaConfig: api.generateAutheliaConfig,
Certs: api.certbot,
GetCloudflareToken: api.getCloudflareToken,
}
// Wire up domain registration reconciliation: when domains change,
// regenerate dnsmasq DNS entries and HAProxy routes.
api.domains.SetReconcileFn(api.reconciler.Reconcile)
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("failed to reload dnsmasq after update", "component", "dns-filter", "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
// ReconcileHealth returns the health state from the last reconciliation.
func (api *API) ReconcileHealth(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, api.reconciler.GetHealth())
}
// CheckPrerequisites verifies that required daemons are available on the system.
// Non-fatal — logs errors for required and info for optional missing binaries.
func (api *API) CheckPrerequisites() {
for _, bin := range []string{"dnsmasq", "haproxy"} {
if _, err := exec.LookPath(bin); err != nil {
slog.Error("required daemon not found", "component", "startup", "binary", bin)
}
}
for _, bin := range []string{"wg", "authelia", "cscli", "cloudflared", "nft"} {
if _, err := exec.LookPath(bin); err != nil {
slog.Info("optional daemon not found (some features unavailable)", "component", "startup", "binary", bin)
}
}
}
// StartConvergenceLoop starts a periodic reconciliation loop that continuously
// drives the system toward desired state. Catches config drift, daemon crashes,
// and transient failures.
func (api *API) StartConvergenceLoop(ctx gocontext.Context, interval time.Duration) {
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
api.reconciler.Reconcile()
}
}
}()
slog.Info("convergence loop started", "component", "reconcile", "interval", interval)
}
func (api *API) RegisterRoutes(r *mux.Router) {
r.Use(RequestLoggingMiddleware)
// Health
r.HandleFunc("/api/v1/health/reconcile", api.ReconcileHealth).Methods("GET")
// 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
vpn := &vpnHandlers{vpn: api.vpn, statePath: api.statePath(), syncNftables: api.syncNftablesOnly}
r.HandleFunc("/api/v1/vpn/status", vpn.Status).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", vpn.GetConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", vpn.UpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/vpn/keygen", vpn.GenerateKeypair).Methods("POST")
r.HandleFunc("/api/v1/vpn/apply", vpn.Apply).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers", vpn.ListPeers).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers", vpn.AddPeer).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers/{id}/config", vpn.GetPeerConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers/{id}", vpn.DeletePeer).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
csec := &crowdsecHandlers{crowdsec: api.crowdsec}
r.HandleFunc("/api/v1/crowdsec/status", csec.Status).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/summary", csec.GetSummary).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/alerts", csec.GetAlerts).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", csec.GetDecisions).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", csec.AddDecision).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", csec.DeleteDecision).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", csec.DeleteDecisionByIP).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/machines", csec.GetMachines).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/machines", csec.AddMachine).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/machines/{name}", csec.DeleteMachine).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.GetBouncers).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/bouncers", csec.AddBouncer).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", csec.DeleteBouncer).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
}
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
// certbot is a CLI tool, not a daemon — check if installed
certbotEntry := map[string]any{"active": false}
if _, err := exec.LookPath("certbot"); err == nil {
certbotEntry["active"] = true
if out, verErr := exec.Command("certbot", "--version").Output(); verErr == nil {
// "certbot 2.11.0"
s := strings.TrimSpace(string(out))
if _, after, found := strings.Cut(s, " "); found {
certbotEntry["version"] = after
}
}
}
result["certbot"] = certbotEntry
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,
})
}