Removes Wild Cloud cruft.

This commit is contained in:
2026-07-11 23:05:17 +00:00
parent f9d87ff975
commit ac66ba653d
26 changed files with 745 additions and 1410 deletions

View File

@@ -36,7 +36,6 @@ type API struct {
version string
allowedOrigins []string
ctx gocontext.Context // parent context for restartable goroutines
config *config.Manager
secrets *secrets.Manager
dnsmasq *dnsmasq.ConfigGenerator
haproxy *haproxy.Manager
@@ -52,8 +51,6 @@ type API struct {
// NewAPI creates a new Central API handler with all dependencies
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
configMgr := config.NewManager()
// Determine config paths from env or defaults
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf")
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
@@ -66,7 +63,6 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
dataDir: dataDir,
version: version,
allowedOrigins: allowedOrigins,
config: configMgr,
secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")),
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
haproxy: haproxy.NewManager(haproxyConfigPath),
@@ -240,10 +236,6 @@ func (api *API) RegisterRoutes(r *mux.Router) {
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")

View File

@@ -2,11 +2,7 @@ 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.
@@ -16,12 +12,10 @@ func (api *API) HaproxyStatus(w http.ResponseWriter, r *http.Request) {
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,
})
}
@@ -53,64 +47,18 @@ func (api *API) HaproxyRestart(w http.ResponseWriter, r *http.Request) {
// 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()
api.reconcileNetworking()
content, err := api.haproxy.ReadConfig()
if err != nil {
respondError(w, http.StatusInternalServerError, err.Error())
return
content = ""
}
respondJSON(w, http.StatusOK, map[string]any{
"message": "HAProxy configuration generated and applied successfully",
"routes": routes,
"customRoutes": len(customRoutes),
"config": configContent,
"message": "HAProxy configuration regenerated from registered domains",
"config": content,
})
}
// 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.LoadState(api.statePath())
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()
@@ -121,10 +69,4 @@ func (api *API) HaproxyStats(w http.ResponseWriter, r *http.Request) {
respondJSON(w, http.StatusOK, map[string]any{"backends": stats})
}
// buildInstanceRoutes returns haproxy.InstanceRoute entries.
// TODO: Instance routes will be populated via service registration from Wild Cloud instances.
// For now, returns an empty list — Central doesn't manage instances directly.
func (api *API) buildInstanceRoutes() ([]haproxy.InstanceRoute, error) {
return nil, nil
}

View File

@@ -120,8 +120,10 @@ func TestHaproxyGenerate_MissingState(t *testing.T) {
api.HaproxyGenerate(w, req)
if w.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d: %s", w.Code, w.Body.String())
// Missing state.yaml is handled gracefully — reconciliation proceeds
// with empty config, generating a minimal HAProxy config.
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
}

View File

@@ -68,12 +68,6 @@ func (api *API) syncNftablesOnly(globalCfg *config.State) {
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})
@@ -81,7 +75,7 @@ func (api *API) syncNftablesOnly(globalCfg *config.State) {
extraTCP, extraUDP := config.SplitExtraPorts(nftCfg.ExtraPorts)
extraUDP = append(extraUDP, api.vpnAutoUDPPorts()...)
ports := api.haproxy.GetListenPorts(routes, customRoutes)
ports := api.haproxy.GetListenPorts(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)

View File

@@ -60,13 +60,13 @@ func TestReconciliation_HAProxyConfigFromDomains(t *testing.T) {
t.Fatalf("List failed: %v", err)
}
var instanceRoutes []haproxy.InstanceRoute
var instanceRoutes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
instanceRoutes = append(instanceRoutes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),
@@ -390,13 +390,13 @@ func TestReconciliation_DNSOnlyNoGateway(t *testing.T) {
doms, _ := api.domains.List()
// Build HAProxy routes — dns-only should be absent
var instanceRoutes []haproxy.InstanceRoute
var instanceRoutes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.Backend.Type {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
instanceRoutes = append(instanceRoutes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.Backend.Address),

View File

@@ -79,13 +79,13 @@ func (api *API) reconcileNetworking() {
}
// Build HAProxy routes from registered domains
var instanceRoutes []haproxy.InstanceRoute
var l4Routes []haproxy.L4Route
var httpRoutes []haproxy.HTTPRoute
for _, dom := range doms {
switch dom.EffectiveBackendType() {
case domains.BackendTCPPassthrough:
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
l4Routes = append(l4Routes, haproxy.L4Route{
Name: dom.DomainName,
Domain: dom.DomainName,
BackendIP: extractHost(dom.EffectiveBackendAddress()),
@@ -143,7 +143,7 @@ func (api *API) reconcileNetworking() {
HTTPRoutes: activeHTTPRoutes,
CertsDir: certsDir,
}
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, genOpts)
haproxyCfg := api.haproxy.GenerateWithOpts(l4Routes, nil, genOpts)
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
// Validation failed — identify and exclude broken domains, then retry
@@ -157,8 +157,8 @@ func (api *API) reconcileNetworking() {
exclude[d] = true
}
var filteredInstances []haproxy.InstanceRoute
for _, r := range instanceRoutes {
var filteredInstances []haproxy.L4Route
for _, r := range l4Routes {
if !exclude[r.Domain] {
filteredInstances = append(filteredInstances, r)
}
@@ -243,7 +243,7 @@ func (api *API) reconcileNetworking() {
slog.Info("reconcile: networking updated",
"domains", len(doms),
"l4Routes", len(instanceRoutes),
"l4Routes", len(l4Routes),
"l7Routes", len(httpRoutes),
)
}

View File

@@ -0,0 +1,58 @@
package v1
import (
"os"
"path/filepath"
"testing"
)
func TestIsValidCertFile_Valid(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "test.pem")
if err := os.WriteFile(path, []byte("--- cert content ---"), 0600); err != nil {
t.Fatal(err)
}
if !isValidCertFile(path) {
t.Error("expected valid cert file to return true")
}
}
func TestIsValidCertFile_Empty(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "empty.pem")
if err := os.WriteFile(path, nil, 0600); err != nil {
t.Fatal(err)
}
if isValidCertFile(path) {
t.Error("expected 0-byte cert file to return false")
}
}
func TestIsValidCertFile_Missing(t *testing.T) {
if isValidCertFile("/nonexistent/path.pem") {
t.Error("expected missing file to return false")
}
}
func TestHasCertForDomain_DirectMatch(t *testing.T) {
tmp := t.TempDir()
certsDir := filepath.Join(tmp, "certs")
os.MkdirAll(certsDir, 0700)
// hasCertForDomain checks certbot.HAProxyCertPath which is hardcoded to /etc/haproxy/certs/.
// We test isValidCertFile directly instead since hasCertForDomain uses absolute paths.
path := filepath.Join(certsDir, "example.com.pem")
os.WriteFile(path, []byte("cert data"), 0600)
if !isValidCertFile(path) {
t.Error("expected direct cert match to be valid")
}
}
func TestHasCertForDomain_ZeroByteShouldFail(t *testing.T) {
tmp := t.TempDir()
path := filepath.Join(tmp, "bad.pem")
os.WriteFile(path, nil, 0600)
if isValidCertFile(path) {
t.Error("0-byte cert should not be considered valid")
}
}

View File

@@ -80,41 +80,4 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler {
})
}
// contextKey is a type for context keys to avoid collisions.
type contextKey string
// Context keys for request values.
const (
InstanceNameKey contextKey = "instanceName"
AppNameKey contextKey = "appName"
NodeNameKey contextKey = "nodeName"
)
// GetInstanceName returns the instance name from request context.
// Falls back to mux.Vars if not in context (for backward compatibility during migration).
func GetInstanceName(r *http.Request) string {
if name, ok := r.Context().Value(InstanceNameKey).(string); ok {
return name
}
return mux.Vars(r)["name"]
}
// GetAppName returns the app name from request context.
// Falls back to mux.Vars if not in context.
func GetAppName(r *http.Request) string {
if name, ok := r.Context().Value(AppNameKey).(string); ok {
return name
}
return mux.Vars(r)["app"]
}
// GetNodeName returns the node name from request context.
// Falls back to mux.Vars if not in context.
func GetNodeName(r *http.Request) string {
if name, ok := r.Context().Value(NodeNameKey).(string); ok {
return name
}
return mux.Vars(r)["node"]
}

View File

@@ -1,80 +0,0 @@
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')
}