Replace the passive "log warning about missing certs" approach with active auto-provisioning: - When Cloudflare token and operator email are configured, automatically provision missing certs via certbot DNS-01 during reconciliation - When credentials aren't available, log actionable guidance (no longer references a non-existent "Certificates page") - Track TLS health in reconciler Health struct (ok/degraded with missing cert list) - Broadcast tls:recovered SSE event when all certs become available - Add CertManager interface and GetCloudflareToken callback to reconciler The convergence loop (5 min) continuously retries failed provisions, so transient DNS-01 failures self-heal on the next tick.
639 lines
24 KiB
Go
639 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
|
|
}
|
|
|
|
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) {
|
|
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
|
|
|
|
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,
|
|
})
|
|
}
|