Files
wild-central/internal/api/v1/handlers.go
Paul Payne 03ce1f3c42 feat: Add Integrations page with API token management
New Integrations page in Wild Central UI for managing API tokens.
Provides token display, copy, and rotation so Wild Cloud and other
services can easily retrieve their authentication credentials.

- GET /api/v1/integrations returns current API token
- POST /api/v1/integrations/api-token/rotate generates a new token
- Frontend with show/hide toggle, copy button, and usage instructions
- Added to sidebar navigation under Central section
2026-08-01 23:25:42 +00:00

659 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-central.conf")
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
nftablesRulesPath := envOrDefault("WILD_CENTRAL_NFTABLES_RULES_PATH", "/etc/nftables.d/wild-central.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, bearerToken string, devMode bool) {
r.Use(RequestLoggingMiddleware)
// Bearer token authentication — protects all /api/ routes.
// Dev mode skips auth for ease of development.
if !devMode && bearerToken != "" {
r.Use(BearerAuthMiddleware(bearerToken))
}
// Health (accessible even with auth — middleware exempts /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")
// Integrations (API token management)
r.HandleFunc("/api/v1/integrations", api.GetIntegrations).Methods("GET")
r.HandleFunc("/api/v1/integrations/api-token/rotate", api.RotateAPIToken).Methods("POST")
// 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-central table exists
nftErr := exec.Command("sudo", "nft", "list", "table", "inet", "wild-central").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,
})
}