feat: Extract Wild Central as standalone Go service

Extract the Central networking functionality from wild-cloud/api into
a standalone service. Wild Central manages DNS (dnsmasq), gateway
(HAProxy), firewall (nftables), VPN (WireGuard), TLS (certbot),
security (CrowdSec), and DDNS — all the network appliance concerns.

All Cloud-specific code (instances, clusters, nodes, apps, backups,
operations, kubectl/talosctl tooling) has been removed. The API struct
and route registration contain only Central endpoints. Tests updated
to match the new API signature.

Builds and all tests pass.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-08 23:31:16 +00:00
commit beb643f76f
52 changed files with 12814 additions and 0 deletions

498
internal/api/v1/handlers.go Normal file
View File

@@ -0,0 +1,498 @@
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/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
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-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,
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(""),
sseManager: sseManager,
}
return api, nil
}
func envOrDefault(key, defaultVal string) string {
if v := os.Getenv(key); v != "" {
slog.Info("using custom config path", "key", key, "path", v)
return v
}
return defaultVal
}
// StartDDNS loads DDNS config and starts the background goroutine.
func (api *API) StartDDNS(ctx gocontext.Context) {
api.ctx = ctx
api.reloadDDNSIfEnabled()
}
// reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine.
func (api *API) reloadDDNSIfEnabled() {
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
api.ddns.Stop()
return
}
apiToken := api.getCloudflareToken()
cfg := ddns.Config{
Enabled: true,
APIToken: apiToken,
Records: globalCfg.Cloud.DDNS.Records,
IntervalMinutes: globalCfg.Cloud.DDNS.IntervalMinutes,
}
api.ddns.Start(api.ctx, cfg)
}
// StartCentralStatusBroadcaster starts periodic broadcasting of central status
func (api *API) StartCentralStatusBroadcaster(startTime time.Time) {
go func() {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
api.broadcastCentralStatusEvent(startTime)
for range ticker.C {
api.broadcastCentralStatusEvent(startTime)
}
}()
}
// RegisterRoutes registers all Central API routes
func (api *API) RegisterRoutes(r *mux.Router) {
r.Use(RequestLoggingMiddleware)
// Global Configuration
r.HandleFunc("/api/v1/config", api.GetGlobalConfig).Methods("GET")
r.HandleFunc("/api/v1/config", api.UpdateGlobalConfig).Methods("PUT")
// Global Secrets
r.HandleFunc("/api/v1/secrets", api.GetGlobalSecrets).Methods("GET")
r.HandleFunc("/api/v1/secrets", api.UpdateGlobalSecrets).Methods("PUT")
// Daemon control
r.HandleFunc("/api/v1/daemon/restart", api.DaemonRestart).Methods("POST")
// Network detection
r.HandleFunc("/api/v1/network/info", api.NetworkInfoHandler).Methods("GET")
r.HandleFunc("/api/v1/network/resolve", api.NetworkResolveHandler).Methods("GET")
// dnsmasq management
r.HandleFunc("/api/v1/dnsmasq/status", api.DnsmasqStatus).Methods("GET")
r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqGetConfig).Methods("GET")
r.HandleFunc("/api/v1/dnsmasq/config", api.DnsmasqWriteConfig).Methods("PUT")
r.HandleFunc("/api/v1/dnsmasq/restart", api.DnsmasqRestart).Methods("POST")
r.HandleFunc("/api/v1/dnsmasq/generate", api.DnsmasqGenerate).Methods("POST")
r.HandleFunc("/api/v1/dnsmasq/dhcp/leases", api.DnsmasqDHCPLeases).Methods("GET")
r.HandleFunc("/api/v1/dnsmasq/dhcp/static", api.DnsmasqDHCPAddStatic).Methods("POST")
r.HandleFunc("/api/v1/dnsmasq/dhcp/static/{mac}", api.DnsmasqDHCPDeleteStatic).Methods("DELETE")
// HAProxy ingress management
r.HandleFunc("/api/v1/haproxy/status", api.HaproxyStatus).Methods("GET")
r.HandleFunc("/api/v1/haproxy/config", api.HaproxyGetConfig).Methods("GET")
r.HandleFunc("/api/v1/haproxy/generate", api.HaproxyGenerate).Methods("POST")
r.HandleFunc("/api/v1/haproxy/restart", api.HaproxyRestart).Methods("POST")
r.HandleFunc("/api/v1/haproxy/stats", api.HaproxyStats).Methods("GET")
// nftables firewall management
r.HandleFunc("/api/v1/nftables/status", api.NftablesStatus).Methods("GET")
r.HandleFunc("/api/v1/nftables/apply", api.NftablesApply).Methods("POST")
r.HandleFunc("/api/v1/nftables/interfaces", api.NftablesGetInterfaces).Methods("GET")
// WireGuard VPN management
r.HandleFunc("/api/v1/vpn/status", api.VpnStatus).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", api.VpnGetConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/config", api.VpnUpdateConfig).Methods("PUT")
r.HandleFunc("/api/v1/vpn/keygen", api.VpnGenerateKeypair).Methods("POST")
r.HandleFunc("/api/v1/vpn/apply", api.VpnApply).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers", api.VpnListPeers).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers", api.VpnAddPeer).Methods("POST")
r.HandleFunc("/api/v1/vpn/peers/{id}/config", api.VpnGetPeerConfig).Methods("GET")
r.HandleFunc("/api/v1/vpn/peers/{id}", api.VpnDeletePeer).Methods("DELETE")
// TLS certificate management
r.HandleFunc("/api/v1/cert/status", api.CertStatus).Methods("GET")
r.HandleFunc("/api/v1/cert/provision", api.CertProvision).Methods("POST")
r.HandleFunc("/api/v1/cert/renew", api.CertRenew).Methods("POST")
// DDNS management
r.HandleFunc("/api/v1/ddns/status", api.DDNSStatus).Methods("GET")
r.HandleFunc("/api/v1/ddns/trigger", api.DDNSTrigger).Methods("POST")
// CrowdSec LAPI management
r.HandleFunc("/api/v1/crowdsec/status", api.CrowdSecStatus).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/summary", api.CrowdSecGetSummary).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/alerts", api.CrowdSecGetAlerts).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecGetDecisions).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/decisions", api.CrowdSecAddDecision).Methods("POST")
r.HandleFunc("/api/v1/crowdsec/decisions/{id}", api.CrowdSecDeleteDecision).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/decisions/ip/{ip}", api.CrowdSecDeleteDecisionByIP).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/machines", api.CrowdSecGetMachines).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/machines/{name}", api.CrowdSecDeleteMachine).Methods("DELETE")
r.HandleFunc("/api/v1/crowdsec/bouncers", api.CrowdSecGetBouncers).Methods("GET")
r.HandleFunc("/api/v1/crowdsec/bouncers/{name}", api.CrowdSecDeleteBouncer).Methods("DELETE")
// SSE events
r.HandleFunc("/api/v1/events", api.GlobalEventStream).Methods("GET")
}
// --- Global Config/Secrets handlers ---
// GetGlobalConfig returns the global config
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
configPath := filepath.Join(api.dataDir, "config.yaml")
data, err := os.ReadFile(configPath)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to read config")
return
}
var configMap map[string]any
if err := yaml.Unmarshal(data, &configMap); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to parse config")
return
}
respondJSON(w, http.StatusOK, configMap)
}
// UpdateGlobalConfig updates the global config
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
configPath := filepath.Join(api.dataDir, "config.yaml")
body, err := io.ReadAll(r.Body)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to read request body")
return
}
var updates map[string]any
if err := yaml.Unmarshal(body, &updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid YAML format")
return
}
// Read existing config
existingContent, err := storage.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
respondError(w, http.StatusInternalServerError, "Failed to read existing config")
return
}
var existingConfig map[string]any
if len(existingContent) > 0 {
if err := yaml.Unmarshal(existingContent, &existingConfig); err != nil {
respondError(w, http.StatusBadRequest, "Failed to parse existing config")
return
}
} else {
existingConfig = make(map[string]any)
}
for key, value := range updates {
existingConfig[key] = value
}
yamlContent, err := yaml.Marshal(existingConfig)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to marshal YAML")
return
}
lockPath := configPath + ".lock"
if err := storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, yamlContent, 0644)
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to write config")
return
}
slog.Info("global config updated")
// Reload DDNS if config changed
go api.reloadDDNSIfEnabled()
respondMessage(w, http.StatusOK, "Config updated successfully")
}
// GetGlobalSecrets returns the global secrets (redacted by default)
func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
data, err := os.ReadFile(secretsPath)
if err != nil {
if os.IsNotExist(err) {
respondJSON(w, http.StatusOK, map[string]any{})
return
}
respondError(w, http.StatusInternalServerError, "Failed to read secrets")
return
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to parse secrets")
return
}
if secretsMap == nil {
secretsMap = map[string]any{}
}
showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw {
for key := range secretsMap {
secretsMap[key] = "********"
}
}
respondJSON(w, http.StatusOK, secretsMap)
}
// UpdateGlobalSecrets updates the global secrets
func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
body, err := io.ReadAll(r.Body)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to read request body")
return
}
var updates map[string]any
if err := yaml.Unmarshal(body, &updates); err != nil {
respondError(w, http.StatusBadRequest, "Invalid YAML format")
return
}
existingContent, err := storage.ReadFile(secretsPath)
if err != nil && !os.IsNotExist(err) {
respondError(w, http.StatusInternalServerError, "Failed to read existing secrets")
return
}
var existing map[string]any
if len(existingContent) > 0 {
if err := yaml.Unmarshal(existingContent, &existing); err != nil {
respondError(w, http.StatusBadRequest, "Failed to parse existing secrets")
return
}
} else {
existing = make(map[string]any)
}
for key, value := range updates {
existing[key] = value
}
yamlContent, err := yaml.Marshal(existing)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to marshal YAML")
return
}
lockPath := secretsPath + ".lock"
if err := storage.WithLock(lockPath, func() error {
return storage.WriteFile(secretsPath, yamlContent, 0600)
}); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to write secrets")
return
}
slog.Info("global secrets updated")
// Reload DDNS if secrets changed (may contain API tokens)
go api.reloadDDNSIfEnabled()
respondMessage(w, http.StatusOK, "Secrets updated successfully")
}
// --- Utility handlers ---
// StatusHandler returns daemon status information
func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime time.Time, dataDir string) {
uptime := time.Since(startTime)
pendingRestart := false
if info, err := os.Stat(filepath.Join(dataDir, "config.yaml")); err == nil {
pendingRestart = info.ModTime().After(startTime)
}
respondJSON(w, http.StatusOK, map[string]any{
"status": "running",
"version": api.version,
"uptime": uptime.String(),
"uptimeSeconds": int(uptime.Seconds()),
"dataDir": dataDir,
"pendingRestart": pendingRestart,
})
}
// DaemonRestart triggers a graceful self-restart via SIGTERM
func (api *API) DaemonRestart(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusAccepted, map[string]any{"message": "Restarting Wild Central..."})
go func() {
time.Sleep(300 * time.Millisecond)
if err := syscall.Kill(syscall.Getpid(), syscall.SIGTERM); err != nil {
slog.Error("failed to send SIGTERM to self", "error", err)
}
}()
}
// NetworkInfoHandler returns detected network configuration
func (api *API) NetworkInfoHandler(w http.ResponseWriter, r *http.Request) {
info, err := network.DetectNetworkInfo()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to detect network info: %v", err))
return
}
respondJSON(w, http.StatusOK, info)
}
// NetworkResolveHandler resolves a domain to an IP
func (api *API) NetworkResolveHandler(w http.ResponseWriter, r *http.Request) {
domain := r.URL.Query().Get("domain")
if domain == "" {
respondError(w, http.StatusBadRequest, "domain parameter is required")
return
}
ip, err := network.ResolveDomain(domain)
if err != nil {
respondJSON(w, http.StatusOK, map[string]any{
"success": false,
"error": err.Error(),
})
return
}
respondJSON(w, http.StatusOK, map[string]any{
"success": true,
"ip": ip,
})
}
// getCloudflareToken reads the Cloudflare API token from global secrets
func (api *API) getCloudflareToken() string {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
data, err := os.ReadFile(secretsPath)
if err != nil {
return ""
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return ""
}
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
if !ok {
return ""
}
token, _ := cloudflare["apiToken"].(string)
return token
}
// getCloudflareZoneID reads the Cloudflare Zone ID from global secrets
func (api *API) getCloudflareZoneID() string {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
data, err := os.ReadFile(secretsPath)
if err != nil {
return ""
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return ""
}
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
if !ok {
return ""
}
zoneID, _ := cloudflare["zoneId"].(string)
return zoneID
}
// respondAccepted writes a 202 Accepted response for async operations
func respondAccepted(w http.ResponseWriter, operationID, message string) {
respondJSON(w, http.StatusAccepted, map[string]string{
"operation_id": operationID,
"message": message,
})
}

View File

@@ -0,0 +1,103 @@
package v1
import (
"fmt"
"net/http"
"github.com/wild-cloud/wild-central/internal/config"
)
// CertStatus returns the TLS certificate status for the central domain.
func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
domain := api.getCentralDomain()
if domain == "" {
respondJSON(w, http.StatusOK, map[string]any{
"configured": false,
"message": "No central domain configured",
})
return
}
status := api.certbot.GetStatus(domain)
respondJSON(w, http.StatusOK, map[string]any{
"configured": true,
"domain": domain,
"cert": status,
})
}
// CertProvision provisions a TLS certificate for the central domain using DNS-01.
func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
domain := api.getCentralDomain()
if domain == "" {
respondError(w, http.StatusBadRequest, "No central domain configured in global config")
return
}
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load config: %v", err))
return
}
email := globalCfg.Operator.Email
if email == "" {
respondError(w, http.StatusBadRequest, "Operator email not configured")
return
}
// Get Cloudflare API token
token := api.getCloudflareToken()
if token == "" {
respondError(w, http.StatusBadRequest, "Cloudflare API token not configured — set it on the Cloudflare page")
return
}
// Write credentials file for certbot
if err := api.certbot.EnsureCredentials(token); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write credentials: %v", err))
return
}
// Provision the certificate. The deploy hook builds the HAProxy PEM and reloads HAProxy.
if err := api.certbot.Provision(domain, email); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioning failed: %v", err))
return
}
// Regenerate HAProxy config to ensure central domain SNI routing is active
if _, _, _, err := api.syncHAProxy(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioned but HAProxy config update failed: %v", err))
return
}
status := api.certbot.GetStatus(domain)
respondJSON(w, http.StatusOK, map[string]any{
"message": "Certificate provisioned successfully",
"cert": status,
})
}
// CertRenew renews all certbot-managed certificates.
func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
if err := api.certbot.Renew(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate renewal failed: %v", err))
return
}
// Rebuild HAProxy PEM for any renewed certs
domain := api.getCentralDomain()
if domain != "" {
_ = api.certbot.BuildHAProxyCert(domain)
_, _, _, _ = api.syncHAProxy()
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
}
// getCentralDomain returns the configured central domain or empty string.
func (api *API) getCentralDomain() string {
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
if err != nil {
return ""
}
return globalCfg.Cloud.Central.Domain
}

View File

@@ -0,0 +1,174 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"github.com/gorilla/mux"
)
// CrowdSecStatus returns whether CrowdSec is running on Wild Central
// and lists registered machines and bouncers.
func (api *API) CrowdSecStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.crowdsec.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get CrowdSec status: %v", err))
return
}
respondJSON(w, http.StatusOK, status)
}
// CrowdSecGetSummary returns aggregated ban counts by scenario across all active decisions.
// This includes CAPI community bans — can return tens of thousands of entries.
func (api *API) CrowdSecGetSummary(w http.ResponseWriter, r *http.Request) {
summary, err := api.crowdsec.GetBanSummary()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get ban summary: %v", err))
return
}
respondJSON(w, http.StatusOK, summary)
}
// CrowdSecGetDecisions returns active ban/captcha decisions
func (api *API) CrowdSecGetDecisions(w http.ResponseWriter, r *http.Request) {
decisions, err := api.crowdsec.GetDecisions()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get decisions: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"decisions": decisions})
}
// CrowdSecDeleteDecision removes a ban decision by numeric ID
func (api *API) CrowdSecDeleteDecision(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id, err := strconv.Atoi(vars["id"])
if err != nil {
respondError(w, http.StatusBadRequest, "invalid decision ID")
return
}
if err := api.crowdsec.DeleteDecision(id); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Decision deleted"})
}
// CrowdSecAddDecision adds a manual ban or allow decision for an IP.
// Body: { "ip": "1.2.3.4", "type": "ban", "reason": "manual", "duration": "24h" }
func (api *API) CrowdSecAddDecision(w http.ResponseWriter, r *http.Request) {
var req struct {
IP string `json:"ip"`
Type string `json:"type"`
Reason string `json:"reason"`
Duration string `json:"duration"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, "invalid request body")
return
}
if req.IP == "" {
respondError(w, http.StatusBadRequest, "ip is required")
return
}
if req.Type != "ban" && req.Type != "allow" {
respondError(w, http.StatusBadRequest, "type must be 'ban' or 'allow'")
return
}
if req.Reason == "" {
req.Reason = "manual"
}
if req.Duration == "" {
req.Duration = "24h"
}
if err := api.crowdsec.AddDecision(req.IP, req.Type, req.Reason, req.Duration); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": fmt.Sprintf("Decision added: %s %s for %s", req.Type, req.IP, req.Duration)})
}
// CrowdSecDeleteDecisionByIP removes all decisions for a specific IP address
func (api *API) CrowdSecDeleteDecisionByIP(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
ip := vars["ip"]
if ip == "" {
respondError(w, http.StatusBadRequest, "ip is required")
return
}
if err := api.crowdsec.DeleteDecisionByIP(ip); err != nil {
// cscli exits non-zero when there's nothing to delete — treat as success
if strings.Contains(err.Error(), "no decision") || strings.Contains(err.Error(), "0 decision") {
respondJSON(w, http.StatusOK, map[string]string{"message": "No active decision for " + ip})
return
}
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete decision: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Decisions removed for " + ip})
}
// CrowdSecGetAlerts returns recent detection events from registered agents.
// Query param: limit (default 50)
func (api *API) CrowdSecGetAlerts(w http.ResponseWriter, r *http.Request) {
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if n, err := strconv.Atoi(l); err == nil && n > 0 && n <= 200 {
limit = n
}
}
alerts, err := api.crowdsec.GetAlerts(limit)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get alerts: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"alerts": alerts})
}
// CrowdSecGetMachines returns all registered CrowdSec agent machines
func (api *API) CrowdSecGetMachines(w http.ResponseWriter, r *http.Request) {
machines, err := api.crowdsec.GetMachines()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get machines: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"machines": machines})
}
// CrowdSecDeleteMachine removes a registered machine
func (api *API) CrowdSecDeleteMachine(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := api.crowdsec.DeleteMachine(name); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete machine: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Machine deleted"})
}
// CrowdSecGetBouncers returns all registered bouncers
func (api *API) CrowdSecGetBouncers(w http.ResponseWriter, r *http.Request) {
bouncers, err := api.crowdsec.GetBouncers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get bouncers: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"bouncers": bouncers})
}
// CrowdSecDeleteBouncer removes a registered bouncer
func (api *API) CrowdSecDeleteBouncer(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
name := vars["name"]
if err := api.crowdsec.DeleteBouncer(name); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to delete bouncer: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Bouncer deleted"})
}
// Note: CrowdSecProvision (instance-specific provisioning) has been removed.
// Instance provisioning will be handled by the Wild Cloud API, not Wild Central.

View File

@@ -0,0 +1,29 @@
package v1
import (
"net/http"
)
// DDNSStatus returns the current DDNS status (last IP, last update, last error)
func (api *API) DDNSStatus(w http.ResponseWriter, r *http.Request) {
if api.ddns == nil {
respondJSON(w, http.StatusOK, map[string]any{
"enabled": false,
"message": "DDNS not configured",
})
return
}
respondJSON(w, http.StatusOK, api.ddns.GetStatus())
}
// DDNSTrigger forces an immediate DDNS IP check and update
func (api *API) DDNSTrigger(w http.ResponseWriter, r *http.Request) {
if api.ddns == nil {
respondError(w, http.StatusServiceUnavailable, "DDNS not configured")
return
}
api.ddns.Trigger()
respondJSON(w, http.StatusOK, map[string]string{
"message": "DDNS update triggered",
})
}

View File

@@ -0,0 +1,83 @@
package v1
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
// TestDDNSStatus_ReturnsOK verifies the status endpoint returns 200 with the
// correct JSON structure. A freshly-created runner (not yet started) reports
// enabled=false.
func TestDDNSStatus_ReturnsOK(t *testing.T) {
api, _ := setupTestAPI(t)
req := httptest.NewRequest("GET", "/api/v1/ddns/status", nil)
w := httptest.NewRecorder()
api.DDNSStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
// enabled must be present and false for an unstarted runner
enabled, exists := resp["enabled"]
if !exists {
t.Error("expected 'enabled' field in response")
}
if b, _ := enabled.(bool); b {
t.Error("expected enabled=false for unstarted runner")
}
}
// TestDDNSStatus_HasRequiredFields verifies all status fields are present in the
// JSON response.
func TestDDNSStatus_HasRequiredFields(t *testing.T) {
api, _ := setupTestAPI(t)
req := httptest.NewRequest("GET", "/api/v1/ddns/status", nil)
w := httptest.NewRecorder()
api.DDNSStatus(w, req)
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
for _, field := range []string{"enabled", "currentIP", "lastChecked", "lastUpdated"} {
if _, ok := resp[field]; !ok {
t.Errorf("missing expected field %q in status response", field)
}
}
}
// TestDDNSTrigger_Returns200 verifies the trigger endpoint sends to the runner's
// channel and returns a success message.
func TestDDNSTrigger_Returns200(t *testing.T) {
api, _ := setupTestAPI(t)
req := httptest.NewRequest("POST", "/api/v1/ddns/trigger", nil)
w := httptest.NewRecorder()
api.DDNSTrigger(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["message"] == nil {
t.Error("expected message field in response")
}
}

View File

@@ -0,0 +1,404 @@
package v1
import (
"bufio"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/gorilla/mux"
"gopkg.in/yaml.v3"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/storage"
)
// DnsmasqStatus returns the status of the dnsmasq service
func (api *API) DnsmasqStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.dnsmasq.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get dnsmasq status: %v", err))
return
}
// Always return 200 OK with status in body - let client handle inactive status
respondJSON(w, http.StatusOK, status)
}
// DnsmasqGetConfig returns the current dnsmasq configuration
func (api *API) DnsmasqGetConfig(w http.ResponseWriter, r *http.Request) {
configContent, err := api.dnsmasq.ReadConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read dnsmasq config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{
"config_file": api.dnsmasq.GetConfigPath(),
"content": configContent,
})
}
// DnsmasqRestart restarts the dnsmasq service
func (api *API) DnsmasqRestart(w http.ResponseWriter, r *http.Request) {
if err := api.dnsmasq.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart dnsmasq: %v", err))
return
}
// Broadcast SSE event
api.broadcastDnsmasqEvent("dnsmasq:restart", "dnsmasq service restarted")
respondJSON(w, http.StatusOK, map[string]string{
"message": "dnsmasq service restarted successfully",
})
}
// DnsmasqGenerate generates the dnsmasq configuration from global config.
// Query param ?overwrite=true will write the config and restart the service.
// Instance-specific DNS records are managed via service registration (not here).
func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) {
overwrite := r.URL.Query().Get("overwrite") == "true"
// Load global config
globalConfigPath := api.getGlobalConfigPath()
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load global config: %v", err))
return
}
// Generate dnsmasq config
configContent := api.dnsmasq.GenerateMainConfig(globalCfg)
if overwrite {
// Check if this is the first time dnsmasq is being started
status, err := api.dnsmasq.GetStatus()
isFirstStart := err != nil || status.Status != "active"
// Update main dnsmasq configuration
slog.Info("updating dnsmasq main configuration")
// Write the main config
tempFile := api.dnsmasq.GetConfigPath() + ".tmp"
if err := os.WriteFile(tempFile, []byte(configContent), 0644); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write temp config: %v", err))
return
}
// Validate the config
if err := api.dnsmasq.ValidateConfig(tempFile); err != nil {
os.Remove(tempFile)
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Config validation failed: %v", err))
return
}
// Install the new config
if err := os.Rename(tempFile, api.dnsmasq.GetConfigPath()); err != nil {
os.Remove(tempFile)
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to install config: %v", err))
return
}
// Reload dnsmasq
if err := api.dnsmasq.ReloadService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to reload dnsmasq: %v", err))
return
}
// Configure system DNS to use local dnsmasq on first start
if isFirstStart {
if err := api.dnsmasq.ConfigureSystemDNS(); err != nil {
slog.Error("failed to configure system DNS", "error", err)
}
}
// Broadcast SSE event
api.broadcastDnsmasqEvent("dnsmasq:config", "dnsmasq configuration updated and applied")
respondJSON(w, http.StatusOK, map[string]any{
"message": "dnsmasq configuration generated and applied successfully",
"config": configContent,
})
} else {
respondJSON(w, http.StatusOK, map[string]any{
"message": "dnsmasq configuration generated (preview mode)",
"config": configContent,
})
}
}
// DnsmasqWriteConfig writes custom config content to the dnsmasq config file
func (api *API) DnsmasqWriteConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
Content string `json:"content"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
if req.Content == "" {
respondError(w, http.StatusBadRequest, "Config content is required")
return
}
// Write the config directly using the dnsmasq config generator's WriteConfig
configPath := api.dnsmasq.GetConfigPath()
if err := writeConfigFile(configPath, req.Content); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err))
return
}
// Broadcast SSE event
api.broadcastDnsmasqEvent("dnsmasq:config", "dnsmasq configuration written")
respondJSON(w, http.StatusOK, map[string]string{
"message": "dnsmasq configuration written successfully",
})
}
// writeConfigFile writes content to a file
func writeConfigFile(path, content string) error {
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
return fmt.Errorf("writing config: %w", err)
}
return nil
}
// updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts.
// Instance-specific DNS records are managed via service registration (not here).
func (api *API) updateDnsmasqConfig() error {
globalConfigPath := api.getGlobalConfigPath()
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
if err != nil {
return fmt.Errorf("loading global config: %w", err)
}
// Regenerate and write dnsmasq config with restart (no instance configs)
return api.dnsmasq.UpdateConfig(globalCfg, nil, true)
}
// getGlobalConfigPath returns the path to the global config file
func (api *API) getGlobalConfigPath() string {
return api.dataDir + "/config.yaml"
}
// DHCPLease represents an active DHCP lease from the dnsmasq leases file
type DHCPLease struct {
Expiry time.Time `json:"expiry"`
MAC string `json:"mac"`
IP string `json:"ip"`
Hostname string `json:"hostname"`
}
// DnsmasqDHCPLeases returns active DHCP leases from /var/lib/misc/dnsmasq.leases
func (api *API) DnsmasqDHCPLeases(w http.ResponseWriter, r *http.Request) {
const leasesFile = "/var/lib/misc/dnsmasq.leases"
f, err := os.Open(leasesFile)
if err != nil {
if os.IsNotExist(err) {
respondJSON(w, http.StatusOK, map[string]any{"leases": []DHCPLease{}})
return
}
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read leases file: %v", err))
return
}
defer f.Close()
var leases []DHCPLease
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
// Format: <unix-expiry> <mac> <ip> <hostname> <client-id>
parts := strings.Fields(line)
if len(parts) < 4 {
continue
}
var expiry time.Time
if ts, err := time.Parse("1504605645", parts[0]); err == nil {
expiry = ts
}
hostname := parts[3]
if hostname == "*" {
hostname = ""
}
leases = append(leases, DHCPLease{
Expiry: expiry,
MAC: parts[1],
IP: parts[2],
Hostname: hostname,
})
}
if err := scanner.Err(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to parse leases file: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"leases": leases})
}
// DnsmasqDHCPAddStatic adds a static DHCP lease to global config and regenerates dnsmasq
func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) {
var req config.DHCPStaticLease
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
if req.MAC == "" || req.IP == "" {
respondError(w, http.StatusBadRequest, "mac and ip are required")
return
}
globalConfigPath := api.getGlobalConfigPath()
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
return
}
if err := api.updateDnsmasqConfig(); err != nil {
slog.Error("dnsmasq regeneration failed after adding static lease", "error", err)
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Static lease added successfully"})
}
// DnsmasqDHCPDeleteStatic removes a static DHCP lease from global config and regenerates dnsmasq
func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request) {
mac := mux.Vars(r)["mac"]
if mac == "" {
respondError(w, http.StatusBadRequest, "mac address is required")
return
}
globalConfigPath := api.getGlobalConfigPath()
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
return
}
if err := api.updateDnsmasqConfig(); err != nil {
slog.Error("dnsmasq regeneration failed after removing static lease", "error", err)
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Static lease removed successfully"})
}
// addDHCPStaticLease adds or replaces a static lease entry in the global config file
func (api *API) addDHCPStaticLease(configPath string, lease config.DHCPStaticLease) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing config: %w", err)
}
if cfg == nil {
cfg = make(map[string]any)
}
// Navigate/create the path cloud.dnsmasq.dhcp.staticLeases
cloud := getOrCreateMap(cfg, "cloud")
dnsmasqMap := getOrCreateMap(cloud, "dnsmasq")
dhcpMap := getOrCreateMap(dnsmasqMap, "dhcp")
leases, _ := dhcpMap["staticLeases"].([]any)
// Replace existing entry with same MAC, or append
newEntry := map[string]any{"mac": lease.MAC, "ip": lease.IP}
if lease.Hostname != "" {
newEntry["hostname"] = lease.Hostname
}
replaced := false
for i, l := range leases {
if m, ok := l.(map[string]any); ok {
if m["mac"] == lease.MAC {
leases[i] = newEntry
replaced = true
break
}
}
}
if !replaced {
leases = append(leases, newEntry)
}
dhcpMap["staticLeases"] = leases
out, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, out, 0644)
})
}
// removeDHCPStaticLease removes a static lease entry by MAC from the global config file
func (api *API) removeDHCPStaticLease(configPath string, mac string) error {
data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("reading config: %w", err)
}
var cfg map[string]any
if err := yaml.Unmarshal(data, &cfg); err != nil {
return fmt.Errorf("parsing config: %w", err)
}
cloud, _ := cfg["cloud"].(map[string]any)
if cloud == nil {
return nil
}
dnsmasqMap, _ := cloud["dnsmasq"].(map[string]any)
if dnsmasqMap == nil {
return nil
}
dhcpMap, _ := dnsmasqMap["dhcp"].(map[string]any)
if dhcpMap == nil {
return nil
}
leases, _ := dhcpMap["staticLeases"].([]any)
var filtered []any
for _, l := range leases {
if m, ok := l.(map[string]any); ok {
if m["mac"] != mac {
filtered = append(filtered, l)
}
}
}
dhcpMap["staticLeases"] = filtered
out, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling config: %w", err)
}
lockPath := configPath + ".lock"
return storage.WithLock(lockPath, func() error {
return storage.WriteFile(configPath, out, 0644)
})
}
// getOrCreateMap returns the map at key in parent, creating it if absent
func getOrCreateMap(parent map[string]any, key string) map[string]any {
if v, ok := parent[key].(map[string]any); ok {
return v
}
m := make(map[string]any)
parent[key] = m
return m
}

View File

@@ -0,0 +1,144 @@
package v1
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/storage"
"gopkg.in/yaml.v3"
)
func setupTestDnsmasq(t *testing.T) (*API, string) {
tmpDir := t.TempDir()
dnsmasqConfigPath := filepath.Join(tmpDir, "dnsmasq.conf")
t.Setenv("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", dnsmasqConfigPath)
api, err := NewAPI(tmpDir, "", nil)
if err != nil {
t.Fatalf("Failed to create test API: %v", err)
}
return api, tmpDir
}
func TestDnsmasqGenerate(t *testing.T) {
api, tmpDir := setupTestDnsmasq(t)
globalConfig := config.GlobalConfig{}
globalConfig.Cloud.Router.IP = "192.168.1.1"
configPath := filepath.Join(tmpDir, "config.yaml")
configData, _ := yaml.Marshal(globalConfig)
if err := storage.WriteFile(configPath, configData, 0644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/v1/dnsmasq/generate", nil)
w := httptest.NewRecorder()
api.DnsmasqGenerate(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if cfg, ok := resp["config"].(string); !ok || cfg == "" {
t.Fatal("Expected generated config in response")
}
}
func TestDnsmasqWriteConfig(t *testing.T) {
api, _ := setupTestDnsmasq(t)
customConfig := `# Custom dnsmasq config
interface=wlan0
listen-address=192.168.2.1
`
reqBody := map[string]string{
"content": customConfig,
}
reqData, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/v1/dnsmasq/config", bytes.NewBuffer(reqData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
api.DnsmasqWriteConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
configPath := api.dnsmasq.GetConfigPath()
content, err := os.ReadFile(configPath)
if err != nil {
t.Fatalf("Failed to read written config: %v", err)
}
if string(content) != customConfig {
t.Fatalf("Config content mismatch.\nExpected:\n%s\nGot:\n%s", customConfig, string(content))
}
}
func TestDnsmasqWriteConfig_EmptyContent(t *testing.T) {
api, _ := setupTestDnsmasq(t)
reqBody := map[string]string{
"content": "",
}
reqData, _ := json.Marshal(reqBody)
req := httptest.NewRequest("PUT", "/api/v1/dnsmasq/config", bytes.NewBuffer(reqData))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
api.DnsmasqWriteConfig(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("Expected status 400, got %d", w.Code)
}
}
func TestDnsmasqGetConfig(t *testing.T) {
api, _ := setupTestDnsmasq(t)
configPath := api.dnsmasq.GetConfigPath()
testConfig := "# Test config\ninterface=eth0\n"
if err := os.MkdirAll(filepath.Dir(configPath), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(configPath, []byte(testConfig), 0644); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("GET", "/api/v1/dnsmasq/config", nil)
w := httptest.NewRecorder()
api.DnsmasqGetConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
content, ok := resp["content"].(string)
if !ok || content != testConfig {
t.Fatalf("Expected config content: %s, got: %s", testConfig, content)
}
}

View File

@@ -0,0 +1,130 @@
package v1
import (
"fmt"
"log/slog"
"net/http"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/haproxy"
)
// HaproxyStatus returns the status of the HAProxy service and current instance routes.
func (api *API) HaproxyStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.haproxy.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get HAProxy status: %v", err))
return
}
routes, _ := api.buildInstanceRoutes()
respondJSON(w, http.StatusOK, map[string]any{
"status": status.Status,
"pid": status.PID,
"configFile": status.ConfigFile,
"routes": routes,
})
}
// HaproxyGetConfig returns the current HAProxy configuration file contents
func (api *API) HaproxyGetConfig(w http.ResponseWriter, r *http.Request) {
content, err := api.haproxy.ReadConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to read HAProxy config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{
"configFile": api.haproxy.GetConfigPath(),
"content": content,
})
}
// HaproxyRestart restarts the HAProxy service
func (api *API) HaproxyRestart(w http.ResponseWriter, r *http.Request) {
if err := api.haproxy.RestartService(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to restart HAProxy: %v", err))
return
}
api.broadcastHaproxyEvent("haproxy:restart", "HAProxy service restarted")
respondJSON(w, http.StatusOK, map[string]string{
"message": "HAProxy service restarted successfully",
})
}
// HaproxyGenerate regenerates HAProxy config from all WC instance data and custom routes,
// then updates nftables to match. Both operations happen together so ports stay in sync.
func (api *API) HaproxyGenerate(w http.ResponseWriter, r *http.Request) {
routes, customRoutes, configContent, err := api.syncHAProxy()
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
}
respondJSON(w, http.StatusOK, map[string]any{
"message": "HAProxy configuration generated and applied successfully",
"routes": routes,
"customRoutes": len(customRoutes),
"config": configContent,
})
}
// syncHAProxy regenerates and applies the HAProxy and nftables configuration.
// Returns the routes, custom routes, and config content on success.
func (api *API) syncHAProxy() ([]haproxy.InstanceRoute, []haproxy.CustomRoute, string, error) {
routes, err := api.buildInstanceRoutes()
if err != nil {
return nil, nil, "", fmt.Errorf("failed to list instances: %w", err)
}
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
if err != nil {
return nil, nil, "", fmt.Errorf("failed to load global config: %w", err)
}
var customRoutes []haproxy.CustomRoute
for _, cr := range globalCfg.Cloud.HAProxy.CustomRoutes {
customRoutes = append(customRoutes, haproxy.CustomRoute{
Name: cr.Name,
Port: cr.Port,
Backend: cr.Backend,
})
}
configContent := api.haproxy.Generate(routes, customRoutes, globalCfg.Cloud.Central.Domain)
if err := api.haproxy.WriteConfig(configContent); err != nil {
return nil, nil, "", fmt.Errorf("failed to write HAProxy config: %w", err)
}
if err := api.haproxy.ReloadService(); err != nil {
return nil, nil, "", fmt.Errorf("failed to reload HAProxy: %w", err)
}
extraTCP, extraUDP := config.SplitExtraPorts(globalCfg.Cloud.Nftables.ExtraPorts)
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
ports := api.haproxy.GetListenPorts(routes, customRoutes)
nftContent := api.nftables.Generate(ports, extraTCP, extraUDP, globalCfg.Cloud.Nftables.WANInterface)
if err := api.nftables.WriteRules(nftContent); err != nil {
slog.Error("failed to update nftables rules", "component", "haproxy-sync", "error", err)
} else if err := api.nftables.ApplyRules(); err != nil {
slog.Error("failed to apply nftables rules", "component", "haproxy-sync", "error", err)
}
api.broadcastHaproxyEvent("haproxy:config", "HAProxy configuration updated and applied")
return routes, customRoutes, configContent, nil
}
// HaproxyStats returns live per-backend connection stats from the HAProxy stats socket
func (api *API) HaproxyStats(w http.ResponseWriter, r *http.Request) {
stats, err := api.haproxy.GetStats()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get HAProxy stats: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"backends": stats})
}
// buildInstanceRoutes returns haproxy.InstanceRoute entries.
// TODO: Instance routes will be populated via service registration from Wild Cloud instances.
// For now, returns an empty list — Central doesn't manage instances directly.
func (api *API) buildInstanceRoutes() ([]haproxy.InstanceRoute, error) {
return nil, nil
}

View File

@@ -0,0 +1,140 @@
package v1
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
func setupTestHaproxy(t *testing.T) (*API, string) {
tmpDir := t.TempDir()
haproxyPath := filepath.Join(tmpDir, "haproxy.cfg")
t.Setenv("WILD_CENTRAL_HAPROXY_CONFIG_PATH", haproxyPath)
t.Setenv("WILD_CENTRAL_NFTABLES_RULES_PATH", filepath.Join(tmpDir, "wild-central.nft"))
api, err := NewAPI(tmpDir, "", nil)
if err != nil {
t.Fatalf("failed to create test API: %v", err)
}
api.haproxy.SetSocketPath(filepath.Join(tmpDir, "haproxy.sock"))
return api, tmpDir
}
func TestHaproxyStatus_ReturnsStatus(t *testing.T) {
api, _ := setupTestHaproxy(t)
req := httptest.NewRequest("GET", "/api/v1/haproxy/status", nil)
w := httptest.NewRecorder()
api.HaproxyStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["status"]; !ok {
t.Error("expected 'status' field in response")
}
}
func TestHaproxyGetConfig_WhenFileExists(t *testing.T) {
api, tmpDir := setupTestHaproxy(t)
configPath := filepath.Join(tmpDir, "haproxy.cfg")
content := "# test haproxy config\nglobal\n daemon\n"
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
t.Fatalf("failed to write config: %v", err)
}
req := httptest.NewRequest("GET", "/api/v1/haproxy/config", nil)
w := httptest.NewRecorder()
api.HaproxyGetConfig(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if got, _ := resp["content"].(string); got != content {
t.Errorf("content mismatch: got %q, want %q", got, content)
}
}
func TestHaproxyGetConfig_WhenFileMissing(t *testing.T) {
api, _ := setupTestHaproxy(t)
req := httptest.NewRequest("GET", "/api/v1/haproxy/config", nil)
w := httptest.NewRecorder()
api.HaproxyGetConfig(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestHaproxyStats_WhenSocketMissing(t *testing.T) {
api, _ := setupTestHaproxy(t)
req := httptest.NewRequest("GET", "/api/v1/haproxy/stats", nil)
w := httptest.NewRecorder()
api.HaproxyStats(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
backends, ok := resp["backends"].([]any)
if !ok {
t.Fatalf("expected 'backends' array, got %T: %v", resp["backends"], resp["backends"])
}
if len(backends) != 0 {
t.Errorf("expected empty backends, got %d", len(backends))
}
}
func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) {
api, tmpDir := setupTestHaproxy(t)
os.Remove(filepath.Join(tmpDir, "config.yaml"))
req := httptest.NewRequest("POST", "/api/v1/haproxy/generate", nil)
w := httptest.NewRecorder()
api.HaproxyGenerate(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
}
}
func TestHaproxyGenerate_NoServices(t *testing.T) {
api, _ := setupTestHaproxy(t)
req := httptest.NewRequest("POST", "/api/v1/haproxy/generate", nil)
w := httptest.NewRecorder()
api.HaproxyGenerate(w, req)
// 200 on device, 500 in CI (no systemctl)
if w.Code != http.StatusOK && w.Code != http.StatusInternalServerError {
t.Errorf("unexpected status %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -0,0 +1,121 @@
package v1
import (
"fmt"
"log/slog"
"net"
"net/http"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/haproxy"
)
// NftablesStatus returns the current wild-cloud nftables table contents
func (api *API) NftablesStatus(w http.ResponseWriter, r *http.Request) {
rules, err := api.nftables.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get nftables status: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{
"rulesFile": api.nftables.GetRulesPath(),
"rules": rules,
})
}
// NftablesApply reapplies the current nftables rules file to the kernel
func (api *API) NftablesApply(w http.ResponseWriter, r *http.Request) {
if err := api.nftables.ApplyRules(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to apply nftables rules: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "nftables rules applied successfully",
})
}
// NetworkInterface describes a host network interface for WAN interface selection
type NetworkInterface struct {
Name string `json:"name"`
Addresses []string `json:"addresses"`
}
// vpnAutoUDPPorts returns the WireGuard listen port if VPN is enabled,
// so it can be automatically included in nftables UDP rules.
func (api *API) vpnAutoUDPPorts() []int {
cfg, err := api.vpn.GetConfig()
if err != nil || !cfg.Enabled {
return nil
}
return []int{cfg.ListenPort}
}
// syncNftablesOnly regenerates and applies nftables rules from the current global config
// without touching HAProxy. Called when firewall settings change independently.
// Runs errors are logged only — the caller does not wait for completion.
func (api *API) syncNftablesOnly(globalCfg *config.GlobalConfig) {
nftCfg := globalCfg.Cloud.Nftables
// Explicitly disabled: flush the wild-cloud table
if nftCfg.Enabled != nil && !*nftCfg.Enabled {
if err := api.nftables.WriteDisabledRules(); err != nil {
slog.Error("failed to write disabled nftables rules", "component", "nftables-sync", "error", err)
return
}
if err := api.nftables.ApplyRules(); err != nil {
slog.Error("failed to apply disabled nftables rules", "component", "nftables-sync", "error", err)
}
return
}
routes, err := api.buildInstanceRoutes()
if err != nil {
slog.Error("failed to build instance routes for nftables sync", "component", "nftables-sync", "error", err)
return
}
var customRoutes []haproxy.CustomRoute
for _, cr := range globalCfg.Cloud.HAProxy.CustomRoutes {
customRoutes = append(customRoutes, haproxy.CustomRoute{Name: cr.Name, Port: cr.Port, Backend: cr.Backend})
}
extraTCP, extraUDP := config.SplitExtraPorts(nftCfg.ExtraPorts)
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
ports := api.haproxy.GetListenPorts(routes, customRoutes)
nftContent := api.nftables.Generate(ports, extraTCP, extraUDP, nftCfg.WANInterface)
if err := api.nftables.WriteRules(nftContent); err != nil {
slog.Error("failed to write nftables rules", "component", "nftables-sync", "error", err)
return
}
if err := api.nftables.ApplyRules(); err != nil {
slog.Error("failed to apply nftables rules", "component", "nftables-sync", "error", err)
}
}
// NftablesGetInterfaces returns non-loopback network interfaces for WAN interface selection
func (api *API) NftablesGetInterfaces(w http.ResponseWriter, r *http.Request) {
ifaces, err := net.Interfaces()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list interfaces: %v", err))
return
}
result := make([]NetworkInterface, 0)
for _, iface := range ifaces {
// Skip loopback and interfaces that are down
if iface.Flags&net.FlagLoopback != 0 || iface.Flags&net.FlagUp == 0 {
continue
}
addrs, _ := iface.Addrs()
addrStrs := make([]string, 0, len(addrs))
for _, a := range addrs {
addrStrs = append(addrStrs, a.String())
}
result = append(result, NetworkInterface{
Name: iface.Name,
Addresses: addrStrs,
})
}
respondJSON(w, http.StatusOK, map[string]any{"interfaces": result})
}

View File

@@ -0,0 +1,130 @@
package v1
import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)
func setupTestNftables(t *testing.T) (*API, string) {
tmpDir := t.TempDir()
t.Setenv("WILD_CENTRAL_HAPROXY_CONFIG_PATH", filepath.Join(tmpDir, "haproxy.cfg"))
t.Setenv("WILD_CENTRAL_NFTABLES_RULES_PATH", filepath.Join(tmpDir, "wild-central.nft"))
api, err := NewAPI(tmpDir, "", nil)
if err != nil {
t.Fatalf("failed to create test API: %v", err)
}
return api, tmpDir
}
// TestNftablesStatus_ReturnsOK verifies the status endpoint returns 200.
// GetStatus() runs `nft list table inet wild-cloud` and silently returns an
// empty string if nft is not installed or the table doesn't exist, so this
// endpoint always returns 200.
func TestNftablesStatus_ReturnsOK(t *testing.T) {
api, tmpDir := setupTestNftables(t)
req := httptest.NewRequest("GET", "/api/v1/nftables/status", nil)
w := httptest.NewRecorder()
api.NftablesStatus(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if _, ok := resp["rulesFile"]; !ok {
t.Error("expected 'rulesFile' field in response")
}
if _, ok := resp["rules"]; !ok {
t.Error("expected 'rules' field in response")
}
rulesFile, _ := resp["rulesFile"].(string)
expected := filepath.Join(tmpDir, "wild-central.nft")
if rulesFile != expected {
t.Errorf("expected rulesFile=%q, got %q", expected, rulesFile)
}
}
// TestNftablesGetInterfaces_ReturnsOK verifies the interfaces endpoint returns 200
// with a valid interfaces list. Uses the real net.Interfaces() so we just check
// structure — the test host will always have at least one interface (lo is excluded,
// but any other up interface counts; if only loopback exists the list can be empty).
func TestNftablesGetInterfaces_ReturnsOK(t *testing.T) {
api, _ := setupTestNftables(t)
req := httptest.NewRequest("GET", "/api/v1/nftables/interfaces", nil)
w := httptest.NewRecorder()
api.NftablesGetInterfaces(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
ifaces, ok := resp["interfaces"]
if !ok {
t.Fatal("expected 'interfaces' field in response")
}
// Each entry must have a name and addresses array
for _, entry := range ifaces.([]any) {
iface := entry.(map[string]any)
name, hasName := iface["name"].(string)
if !hasName || name == "" {
t.Errorf("interface entry missing non-empty name: %v", iface)
}
if _, hasAddrs := iface["addresses"]; !hasAddrs {
t.Errorf("interface %q missing addresses field", name)
}
// Loopback must not appear
if name == "lo" {
t.Errorf("loopback interface 'lo' should be excluded")
}
}
}
// TestNftablesApply_ServiceUnavailable verifies the handler returns 500 when
// the nftables reload service is not available (expected in test environments).
func TestNftablesApply_ServiceUnavailable(t *testing.T) {
api, _ := setupTestNftables(t)
req := httptest.NewRequest("POST", "/api/v1/nftables/apply", nil)
w := httptest.NewRecorder()
api.NftablesApply(w, req)
// ApplyRules calls `systemctl start wild-cloud-nftables-reload.service`
// which requires polkit/root — always fails in test environments.
if w.Code != http.StatusInternalServerError {
// If it happened to succeed (running on the actual Wild Central device
// with the right polkit rules), that's also valid.
if w.Code != http.StatusOK {
t.Errorf("unexpected status %d: %s", w.Code, w.Body.String())
}
return
}
var resp map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid JSON: %v", err)
}
if resp["error"] == nil {
t.Error("expected 'error' field in 500 response")
}
}

View File

@@ -0,0 +1,148 @@
package v1
import (
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/wild-cloud/wild-central/internal/sse"
)
// sendSSEEvent writes an SSE event to the response writer
func sendSSEEvent(w http.ResponseWriter, event *sse.Event) error {
// Set event ID
if _, err := fmt.Fprintf(w, "id: %s\n", event.ID); err != nil {
return err
}
// Set event type
if _, err := fmt.Fprintf(w, "event: %s\n", event.Type); err != nil {
return err
}
// Set retry interval (in milliseconds)
if _, err := fmt.Fprintf(w, "retry: 5000\n"); err != nil {
return err
}
// Marshal event data to JSON
data, err := json.Marshal(event)
if err != nil {
return fmt.Errorf("failed to marshal event: %w", err)
}
// Write data field
if _, err := fmt.Fprintf(w, "data: %s\n\n", data); err != nil {
return err
}
return nil
}
// GlobalEventStream handles SSE connections for ALL events (global and instance-specific)
func (api *API) GlobalEventStream(w http.ResponseWriter, r *http.Request) {
// 1. Parse event filters from query parameters
filters := sse.EventFilters{
EventTypes: parseQueryList(r.URL.Query().Get("types")),
Namespaces: parseQueryList(r.URL.Query().Get("namespaces")),
Apps: parseQueryList(r.URL.Query().Get("apps")),
}
// 2. Set SSE headers
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no") // Disable nginx buffering
// 3. Register client with SSE manager
// Use "*" as a special instance name to receive ALL events
client := api.sseManager.RegisterClient("*", filters)
defer api.sseManager.UnregisterClient(client)
// 4. Send initial connected event
connectedEvent := &sse.Event{
Type: "connected",
InstanceName: "global",
Data: map[string]any{
"message": "Successfully connected to global event stream",
"filters": filters,
},
}
if err := sendSSEEvent(w, connectedEvent); err != nil {
slog.Error("failed to send SSE connected event", "stream", "global", "error", err)
return
}
// 5. Flush immediately to establish connection
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
// 6. Send heartbeat and handle events
heartbeatInterval := 30 // seconds
heartbeatTicker := time.NewTicker(time.Duration(heartbeatInterval) * time.Second)
defer heartbeatTicker.Stop()
for {
select {
case <-client.Context.Done():
// Client disconnected
return
case <-r.Context().Done():
// Request cancelled
return
case event := <-client.Channel:
// Send event to client
if err := sendSSEEvent(w, event); err != nil {
slog.Error("failed to send SSE event", "stream", "global", "error", err)
return
}
// Flush after each event
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
case <-heartbeatTicker.C:
// Send heartbeat to keep connection alive
heartbeatEvent := &sse.Event{
Type: "heartbeat",
InstanceName: "global",
Data: map[string]any{
"timestamp": time.Now().Unix(),
},
}
if err := sendSSEEvent(w, heartbeatEvent); err != nil {
slog.Error("failed to send SSE heartbeat", "stream", "global", "error", err)
return
}
// Flush after heartbeat
if flusher, ok := w.(http.Flusher); ok {
flusher.Flush()
}
}
}
}
// parseQueryList parses comma-separated query parameter into slice
func parseQueryList(param string) []string {
if param == "" {
return nil
}
parts := strings.Split(param, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
result = append(result, trimmed)
}
}
return result
}

View File

@@ -0,0 +1,157 @@
package v1
import (
"encoding/json"
"fmt"
"net/http"
"github.com/gorilla/mux"
"github.com/wild-cloud/wild-central/internal/config"
"github.com/wild-cloud/wild-central/internal/wireguard"
)
// VpnStatus returns the current WireGuard interface status.
func (api *API) VpnStatus(w http.ResponseWriter, r *http.Request) {
status, err := api.vpn.GetStatus()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN status: %v", err))
return
}
respondJSON(w, http.StatusOK, status)
}
// VpnGetConfig returns the server interface configuration and public key.
func (api *API) VpnGetConfig(w http.ResponseWriter, r *http.Request) {
cfg, err := api.vpn.GetConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get VPN config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{
"enabled": cfg.Enabled,
"listenPort": cfg.ListenPort,
"address": cfg.Address,
"endpoint": cfg.Endpoint,
"dns": cfg.DNS,
"lanCIDR": cfg.LanCIDR,
"publicKey": api.vpn.GetPublicKey(),
})
}
// VpnUpdateConfig updates the server interface configuration.
func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
var req wireguard.Config
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
respondError(w, http.StatusBadRequest, fmt.Sprintf("Invalid request: %v", err))
return
}
if req.ListenPort == 0 {
req.ListenPort = 51820
}
if err := api.vpn.SaveConfig(&req); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to save VPN config: %v", err))
return
}
// Resync nftables so the VPN listen port is automatically allowed/removed
if globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath()); err == nil {
go api.syncNftablesOnly(globalCfg)
}
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
}
// VpnGenerateKeypair generates a new server keypair.
func (api *API) VpnGenerateKeypair(w http.ResponseWriter, r *http.Request) {
if err := api.vpn.GenerateKeypair(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate keypair: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"message": "Server keypair generated",
"publicKey": api.vpn.GetPublicKey(),
})
}
// VpnApply writes the wg0.conf and brings the WireGuard interface up.
func (api *API) VpnApply(w http.ResponseWriter, r *http.Request) {
if err := api.vpn.Apply(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to apply VPN config: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "WireGuard interface applied successfully"})
}
// VpnListPeers returns all configured peers (without private keys).
func (api *API) VpnListPeers(w http.ResponseWriter, r *http.Request) {
peers, err := api.vpn.ListPeers()
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to list peers: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]any{"peers": redactPeers(peers)})
}
// VpnAddPeer adds a new peer, auto-generating a keypair and assigning an IP.
func (api *API) VpnAddPeer(w http.ResponseWriter, r *http.Request) {
var req struct {
Name string `json:"name"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Name == "" {
respondError(w, http.StatusBadRequest, "name is required")
return
}
peer, err := api.vpn.AddPeer(req.Name)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add peer: %v", err))
return
}
respondJSON(w, http.StatusCreated, redactPeer(peer))
}
// VpnGetPeerConfig returns the wg-quick config text for a peer (used by clients and QR generation).
func (api *API) VpnGetPeerConfig(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
text, err := api.vpn.GeneratePeerConfigText(id)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to generate peer config: %v", err))
return
}
peer, err := api.vpn.GetPeer(id)
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to get peer: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{
"name": peer.Name,
"config": text,
})
}
// VpnDeletePeer removes a peer by ID.
func (api *API) VpnDeletePeer(w http.ResponseWriter, r *http.Request) {
id := mux.Vars(r)["id"]
if err := api.vpn.DeletePeer(id); err != nil {
respondError(w, http.StatusNotFound, fmt.Sprintf("Failed to delete peer: %v", err))
return
}
respondJSON(w, http.StatusOK, map[string]string{"message": "Peer deleted"})
}
// redactPeers strips private keys from a peer list before sending to clients.
func redactPeers(peers []*wireguard.Peer) []map[string]any {
out := make([]map[string]any, len(peers))
for i, p := range peers {
out[i] = redactPeer(p)
}
return out
}
func redactPeer(p *wireguard.Peer) map[string]any {
return map[string]any{
"id": p.ID,
"name": p.Name,
"publicKey": p.PublicKey,
"allowedIPs": p.AllowedIPs,
"createdAt": p.CreatedAt,
}
}

View File

@@ -0,0 +1,77 @@
package v1
import (
"fmt"
"time"
"github.com/wild-cloud/wild-central/internal/sse"
)
// broadcastDnsmasqEvent broadcasts SSE events for dnsmasq status changes
func (api *API) broadcastDnsmasqEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
// Get current dnsmasq status
status, err := api.dnsmasq.GetStatus()
if err != nil {
status = nil
}
event := &sse.Event{
ID: fmt.Sprintf("dnsmasq-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global", // dnsmasq is global/central-level, not instance-specific
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
"status": status,
},
}
api.sseManager.Broadcast(event)
}
// broadcastHaproxyEvent broadcasts SSE events for HAProxy status changes
func (api *API) broadcastHaproxyEvent(eventType string, message string) {
if api.sseManager == nil {
return
}
event := &sse.Event{
ID: fmt.Sprintf("haproxy-%d", time.Now().UnixNano()),
Type: eventType,
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"message": message,
},
}
api.sseManager.Broadcast(event)
}
// broadcastCentralStatusEvent broadcasts SSE events for central status changes
func (api *API) broadcastCentralStatusEvent(startTime time.Time) {
if api.sseManager == nil {
return
}
uptime := time.Since(startTime)
event := &sse.Event{
ID: fmt.Sprintf("central-%d", time.Now().UnixNano()),
Type: "central:status",
InstanceName: "global",
Timestamp: time.Now(),
Data: map[string]any{
"status": "running",
"version": api.version,
"uptime": uptime.String(),
"uptimeSeconds": int(uptime.Seconds()),
},
}
api.sseManager.Broadcast(event)
}

View File

@@ -0,0 +1,120 @@
package v1
import (
"bufio"
"fmt"
"log/slog"
"net"
"net/http"
"strings"
"time"
"github.com/gorilla/mux"
)
// statusResponseWriter wraps http.ResponseWriter to capture the status code.
// Implements http.Hijacker so WebSocket upgrades work through the proxy.
type statusResponseWriter struct {
http.ResponseWriter
status int
}
func (w *statusResponseWriter) WriteHeader(code int) {
w.status = code
w.ResponseWriter.WriteHeader(code)
}
func (w *statusResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
if hj, ok := w.ResponseWriter.(http.Hijacker); ok {
return hj.Hijack()
}
return nil, nil, fmt.Errorf("upstream ResponseWriter does not implement http.Hijacker")
}
func (w *statusResponseWriter) Flush() {
if fl, ok := w.ResponseWriter.(http.Flusher); ok {
fl.Flush()
}
}
// RequestLoggingMiddleware logs method, path, status, and duration for each request.
// Long-lived connections (SSE, WebSocket) are excluded.
func RequestLoggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
// Skip SSE and WebSocket endpoints (long-lived connections)
if strings.HasSuffix(path, "/events") || strings.HasSuffix(path, "/ws") || strings.HasSuffix(path, "/stream") {
next.ServeHTTP(w, r)
return
}
start := time.Now()
sw := &statusResponseWriter{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(sw, r)
attrs := []any{
"status", sw.status,
"method", r.Method,
"path", path,
"duration", time.Since(start),
}
// Add route params if present
vars := mux.Vars(r)
if name := vars["name"]; name != "" {
attrs = append(attrs, "instance", name)
}
if app := vars["app"]; app != "" {
attrs = append(attrs, "app", app)
}
if node := vars["node"]; node != "" {
attrs = append(attrs, "node", node)
}
if sw.status >= 400 {
slog.Error("request", attrs...)
} else {
slog.Info("request", attrs...)
}
})
}
// contextKey is a type for context keys to avoid collisions.
type contextKey string
// Context keys for request values.
const (
InstanceNameKey contextKey = "instanceName"
AppNameKey contextKey = "appName"
NodeNameKey contextKey = "nodeName"
)
// GetInstanceName returns the instance name from request context.
// Falls back to mux.Vars if not in context (for backward compatibility during migration).
func GetInstanceName(r *http.Request) string {
if name, ok := r.Context().Value(InstanceNameKey).(string); ok {
return name
}
return mux.Vars(r)["name"]
}
// GetAppName returns the app name from request context.
// Falls back to mux.Vars if not in context.
func GetAppName(r *http.Request) string {
if name, ok := r.Context().Value(AppNameKey).(string); ok {
return name
}
return mux.Vars(r)["app"]
}
// GetNodeName returns the node name from request context.
// Falls back to mux.Vars if not in context.
func GetNodeName(r *http.Request) string {
if name, ok := r.Context().Value(NodeNameKey).(string); ok {
return name
}
return mux.Vars(r)["node"]
}

View File

@@ -0,0 +1,36 @@
package v1
import (
"encoding/json"
"net/http"
)
// APIResponse is the standard response envelope for all API endpoints.
// All successful responses wrap data in the Data field.
// Error responses use the Error field.
// Message-only responses use the Message field.
type APIResponse struct {
Data any `json:"data,omitempty"`
Message string `json:"message,omitempty"`
Error string `json:"error,omitempty"`
}
// respondJSON writes a JSON response with the given status code.
// This is the low-level helper that all other response helpers use.
func respondJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(data)
}
// respondMessage writes a success response with only a message (no data).
// Use this for operations that succeed but don't return meaningful data.
func respondMessage(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, APIResponse{Message: message})
}
// respondError writes an error response with the given status code and message.
func respondError(w http.ResponseWriter, status int, message string) {
respondJSON(w, status, APIResponse{Error: message})
}

View File

@@ -0,0 +1,15 @@
package v1
import "testing"
// setupTestAPI creates a minimal API for testing Central handlers.
func setupTestAPI(t *testing.T) (*API, string) {
tmpDir := t.TempDir()
api, err := NewAPI(tmpDir, "", nil)
if err != nil {
t.Fatalf("Failed to create test API: %v", err)
}
return api, tmpDir
}

View File

@@ -0,0 +1,80 @@
package v1
import (
"fmt"
"regexp"
"strings"
)
const (
maxInstanceNameLength = 63 // Kubernetes DNS label limit (RFC 1123)
maxNodeIdentifierLength = 253 // RFC 1035 hostname limit
)
// validNamePattern matches valid resource names.
// Names must:
// - Start and end with a lowercase letter or number
// - Contain only lowercase letters, numbers, and hyphens
// - Be between 1 and 63 characters
// This follows Kubernetes naming conventions for consistency.
var validNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]*[a-z0-9])?$`)
// ValidateName validates a resource name (instance, app, node, etc.).
// Returns an error if the name is invalid.
func ValidateName(name string) error {
if name == "" {
return fmt.Errorf("name is required")
}
if len(name) > maxInstanceNameLength {
return fmt.Errorf("name must be %d characters or less", maxInstanceNameLength)
}
if !validNamePattern.MatchString(name) {
return fmt.Errorf("name must contain only lowercase letters, numbers, and hyphens, and must start and end with a letter or number")
}
return nil
}
// ValidateInstanceName validates an instance name with a user-friendly error message.
func ValidateInstanceName(name string) error {
if err := ValidateName(name); err != nil {
return fmt.Errorf("invalid instance name: %w", err)
}
return nil
}
// ValidateAppName validates an app name with a user-friendly error message.
func ValidateAppName(name string) error {
if err := ValidateName(name); err != nil {
return fmt.Errorf("invalid app name: %w", err)
}
return nil
}
// ValidateServiceName validates a service name with a user-friendly error message.
func ValidateServiceName(name string) error {
if err := ValidateName(name); err != nil {
return fmt.Errorf("invalid service name: %w", err)
}
return nil
}
// ValidateNodeIdentifier validates a node identifier (hostname or IP).
// Node identifiers have slightly relaxed rules since they can be IPs.
func ValidateNodeIdentifier(identifier string) error {
if identifier == "" {
return fmt.Errorf("node identifier is required")
}
if len(identifier) > maxNodeIdentifierLength {
return fmt.Errorf("node identifier must be %d characters or less", maxNodeIdentifierLength)
}
// Basic check for path traversal
if containsPathTraversal(identifier) {
return fmt.Errorf("invalid node identifier: contains invalid characters")
}
return nil
}
// containsPathTraversal checks if a string contains path traversal patterns.
func containsPathTraversal(s string) bool {
return strings.ContainsAny(s, "/\\") || strings.Contains(s, "..") || strings.ContainsRune(s, '\x00')
}