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:
498
internal/api/v1/handlers.go
Normal file
498
internal/api/v1/handlers.go
Normal 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,
|
||||
})
|
||||
}
|
||||
103
internal/api/v1/handlers_certbot.go
Normal file
103
internal/api/v1/handlers_certbot.go
Normal 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
|
||||
}
|
||||
174
internal/api/v1/handlers_crowdsec.go
Normal file
174
internal/api/v1/handlers_crowdsec.go
Normal 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.
|
||||
29
internal/api/v1/handlers_ddns.go
Normal file
29
internal/api/v1/handlers_ddns.go
Normal 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",
|
||||
})
|
||||
}
|
||||
83
internal/api/v1/handlers_ddns_test.go
Normal file
83
internal/api/v1/handlers_ddns_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
404
internal/api/v1/handlers_dnsmasq.go
Normal file
404
internal/api/v1/handlers_dnsmasq.go
Normal 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
|
||||
}
|
||||
144
internal/api/v1/handlers_dnsmasq_test.go
Normal file
144
internal/api/v1/handlers_dnsmasq_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
130
internal/api/v1/handlers_haproxy.go
Normal file
130
internal/api/v1/handlers_haproxy.go
Normal 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
|
||||
}
|
||||
|
||||
140
internal/api/v1/handlers_haproxy_test.go
Normal file
140
internal/api/v1/handlers_haproxy_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
121
internal/api/v1/handlers_nftables.go
Normal file
121
internal/api/v1/handlers_nftables.go
Normal 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})
|
||||
}
|
||||
130
internal/api/v1/handlers_nftables_test.go
Normal file
130
internal/api/v1/handlers_nftables_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
148
internal/api/v1/handlers_sse.go
Normal file
148
internal/api/v1/handlers_sse.go
Normal 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
|
||||
}
|
||||
157
internal/api/v1/handlers_wireguard.go
Normal file
157
internal/api/v1/handlers_wireguard.go
Normal 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,
|
||||
}
|
||||
}
|
||||
77
internal/api/v1/helpers.go
Normal file
77
internal/api/v1/helpers.go
Normal 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)
|
||||
}
|
||||
120
internal/api/v1/middleware.go
Normal file
120
internal/api/v1/middleware.go
Normal 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"]
|
||||
}
|
||||
|
||||
36
internal/api/v1/response.go
Normal file
36
internal/api/v1/response.go
Normal 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})
|
||||
}
|
||||
|
||||
15
internal/api/v1/test_helpers_test.go
Normal file
15
internal/api/v1/test_helpers_test.go
Normal 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
|
||||
}
|
||||
80
internal/api/v1/validation.go
Normal file
80
internal/api/v1/validation.go
Normal 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')
|
||||
}
|
||||
Reference in New Issue
Block a user