It's state, not config.
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -54,10 +55,6 @@ type API struct {
|
||||
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-cloud.conf")
|
||||
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
|
||||
@@ -118,8 +115,7 @@ func (api *API) StartDDNS(ctx gocontext.Context) {
|
||||
|
||||
// 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)
|
||||
globalCfg, err := config.LoadState(api.statePath())
|
||||
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
|
||||
api.ddns.Stop()
|
||||
return
|
||||
@@ -246,9 +242,9 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
||||
|
||||
// --- Global Config/Secrets handlers ---
|
||||
|
||||
// GetGlobalConfig returns the global config wrapped in { configured, config }.
|
||||
// GetGlobalConfig returns the global state wrapped in { configured, config }.
|
||||
func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configPath := filepath.Join(api.dataDir, "config.yaml")
|
||||
configPath := api.statePath()
|
||||
data, err := os.ReadFile(configPath)
|
||||
if err != nil {
|
||||
respondJSON(w, http.StatusOK, map[string]any{
|
||||
@@ -269,9 +265,9 @@ func (api *API) GetGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateGlobalConfig updates the global config
|
||||
// UpdateGlobalConfig updates the global state
|
||||
func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
configPath := filepath.Join(api.dataDir, "config.yaml")
|
||||
configPath := api.statePath()
|
||||
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
@@ -285,10 +281,10 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing config
|
||||
// Read existing state (may not exist yet)
|
||||
existingContent, err := storage.ReadFile(configPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read existing config")
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
respondError(w, http.StatusInternalServerError, "Failed to read existing state")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -423,18 +419,12 @@ func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
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,
|
||||
"status": "running",
|
||||
"version": api.version,
|
||||
"uptime": uptime.String(),
|
||||
"uptimeSeconds": int(uptime.Seconds()),
|
||||
"dataDir": dataDir,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import (
|
||||
func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
||||
centralDomain := api.getCentralDomain()
|
||||
|
||||
globalCfg, _ := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
||||
globalCfg, _ := config.LoadState(api.statePath())
|
||||
email := ""
|
||||
if globalCfg != nil {
|
||||
email = globalCfg.Operator.Email
|
||||
@@ -113,7 +113,7 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
||||
globalCfg, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load config: %v", err))
|
||||
return
|
||||
@@ -179,7 +179,7 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// getCentralDomain returns the configured central domain or empty string.
|
||||
func (api *API) getCentralDomain() string {
|
||||
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
||||
globalCfg, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
||||
},
|
||||
}
|
||||
data, _ := yaml.Marshal(cfg)
|
||||
storage.WriteFile(filepath.Join(tmpDir, "config.yaml"), data, 0644)
|
||||
storage.WriteFile(filepath.Join(tmpDir, "state.yaml"), data, 0644)
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -62,8 +62,8 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
||||
func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) {
|
||||
api, tmpDir := setupTestAPI(t)
|
||||
|
||||
// Remove the default config that EnsureGlobalConfig created
|
||||
os.Remove(filepath.Join(tmpDir, "config.yaml"))
|
||||
// Ensure no state file exists
|
||||
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -168,7 +168,7 @@ func TestUpdateGlobalConfig(t *testing.T) {
|
||||
}
|
||||
|
||||
// Verify file was written
|
||||
data, _ := os.ReadFile(filepath.Join(tmpDir, "config.yaml"))
|
||||
data, _ := os.ReadFile(filepath.Join(tmpDir, "state.yaml"))
|
||||
var cfg map[string]any
|
||||
yaml.Unmarshal(data, &cfg)
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ 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)
|
||||
globalConfigPath := api.statePath()
|
||||
globalCfg, err := config.LoadState(globalConfigPath)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load global config: %v", err))
|
||||
return
|
||||
@@ -174,8 +174,8 @@ func writeConfigFile(path, content string) error {
|
||||
// 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)
|
||||
globalConfigPath := api.statePath()
|
||||
globalCfg, err := config.LoadState(globalConfigPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading global config: %w", err)
|
||||
}
|
||||
@@ -184,9 +184,9 @@ func (api *API) updateDnsmasqConfig() error {
|
||||
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"
|
||||
// statePath returns the path to the global state file
|
||||
func (api *API) statePath() string {
|
||||
return api.dataDir + "/state.yaml"
|
||||
}
|
||||
|
||||
// DHCPLease represents an active DHCP lease from the dnsmasq leases file
|
||||
@@ -259,7 +259,7 @@ func (api *API) DnsmasqDHCPAddStatic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
globalConfigPath := api.getGlobalConfigPath()
|
||||
globalConfigPath := api.statePath()
|
||||
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
|
||||
return
|
||||
@@ -280,7 +280,7 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
globalConfigPath := api.getGlobalConfigPath()
|
||||
globalConfigPath := api.statePath()
|
||||
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
|
||||
return
|
||||
|
||||
@@ -32,8 +32,8 @@ 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")
|
||||
globalConfig.Cloud.Central.Domain = "central.example.com"
|
||||
configPath := filepath.Join(tmpDir, "state.yaml")
|
||||
configData, _ := yaml.Marshal(globalConfig)
|
||||
if err := storage.WriteFile(configPath, configData, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -74,7 +74,7 @@ func (api *API) syncHAProxy() ([]haproxy.InstanceRoute, []haproxy.CustomRoute, s
|
||||
return nil, nil, "", fmt.Errorf("failed to list instances: %w", err)
|
||||
}
|
||||
|
||||
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
||||
globalCfg, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
return nil, nil, "", fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ func TestHaproxyStats_WhenSocketMissing(t *testing.T) {
|
||||
func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) {
|
||||
api, tmpDir := setupTestHaproxy(t)
|
||||
|
||||
os.Remove(filepath.Join(tmpDir, "config.yaml"))
|
||||
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/haproxy/generate", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -75,10 +75,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
})
|
||||
case services.BackendHTTP:
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Backend: svc.Backend.Address,
|
||||
HealthPath: svc.Backend.Health,
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Routes: []haproxy.HTTPRouteBackend{{
|
||||
Backend: svc.Backend.Address,
|
||||
HealthPath: svc.Backend.Health,
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -88,7 +90,6 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
Domain: "central.payne.io",
|
||||
Source: "central",
|
||||
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
||||
// Public defaults to false
|
||||
TLS: services.TLSTerminate,
|
||||
}); err != nil {
|
||||
t.Fatalf("Register central failed: %v", err)
|
||||
@@ -102,10 +103,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
||||
for _, svc := range svcs {
|
||||
if svc.Backend.Type == services.BackendHTTP {
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Backend: svc.Backend.Address,
|
||||
HealthPath: svc.Backend.Health,
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Routes: []haproxy.HTTPRouteBackend{{
|
||||
Backend: svc.Backend.Address,
|
||||
HealthPath: svc.Backend.Health,
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -299,9 +302,11 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
||||
for _, svc := range svcs {
|
||||
if svc.Backend.Type == services.BackendHTTP {
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Backend: svc.Backend.Address,
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Routes: []haproxy.HTTPRouteBackend{{
|
||||
Backend: svc.Backend.Address,
|
||||
}},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -376,7 +381,7 @@ func TestReconciliation_TCPPassthroughDNSTarget(t *testing.T) {
|
||||
// writeTestConfig writes a minimal global config with the given Central domain.
|
||||
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
|
||||
t.Helper()
|
||||
configPath := filepath.Join(dataDir, "config.yaml")
|
||||
configPath := filepath.Join(dataDir, "state.yaml")
|
||||
content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain)
|
||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
|
||||
@@ -3,7 +3,9 @@ package v1
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
|
||||
@@ -48,6 +50,22 @@ func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Validate routes
|
||||
for i, r := range svc.Routes {
|
||||
for _, p := range r.Paths {
|
||||
if !strings.HasPrefix(p, "/") {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: path prefix must start with /: %q", i, p))
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, cidr := range r.IPAllow {
|
||||
if _, _, err := net.ParseCIDR(cidr); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Route %d: invalid CIDR in ipAllow: %q", i, cidr))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := api.services.Register(svc); err != nil {
|
||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err))
|
||||
return
|
||||
|
||||
@@ -54,7 +54,7 @@ func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// Resync nftables so the VPN listen port is automatically allowed/removed
|
||||
if globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath()); err == nil {
|
||||
if globalCfg, err := config.LoadState(api.statePath()); err == nil {
|
||||
go api.syncNftablesOnly(globalCfg)
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -72,10 +71,9 @@ func (api *API) reconcileNetworking() {
|
||||
return
|
||||
}
|
||||
|
||||
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
|
||||
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
||||
globalCfg, err := config.LoadState(api.statePath())
|
||||
if err != nil {
|
||||
slog.Warn("reconcile: failed to load global config, using empty", "error", err)
|
||||
slog.Warn("reconcile: failed to load state, using empty", "error", err)
|
||||
globalCfg = &config.GlobalConfig{}
|
||||
}
|
||||
|
||||
@@ -84,20 +82,30 @@ func (api *API) reconcileNetworking() {
|
||||
var httpRoutes []haproxy.HTTPRoute
|
||||
|
||||
for _, svc := range svcs {
|
||||
switch svc.Backend.Type {
|
||||
switch svc.EffectiveBackendType() {
|
||||
case services.BackendTCPPassthrough:
|
||||
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
BackendIP: extractHost(svc.Backend.Address),
|
||||
BackendIP: extractHost(svc.EffectiveBackendAddress()),
|
||||
Subdomains: svc.Subdomains,
|
||||
})
|
||||
case services.BackendHTTP:
|
||||
var routeBackends []haproxy.HTTPRouteBackend
|
||||
for _, r := range svc.EffectiveRoutes() {
|
||||
routeBackends = append(routeBackends, haproxy.HTTPRouteBackend{
|
||||
Paths: r.Paths,
|
||||
Backend: r.Backend.Address,
|
||||
HealthPath: r.Backend.Health,
|
||||
Headers: r.Headers,
|
||||
IPAllow: r.IPAllow,
|
||||
})
|
||||
}
|
||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||
Name: svc.Domain,
|
||||
Domain: svc.Domain,
|
||||
Backend: svc.Backend.Address,
|
||||
HealthPath: svc.Backend.Health,
|
||||
Subdomains: svc.Subdomains,
|
||||
Routes: routeBackends,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -151,9 +159,9 @@ func (api *API) reconcileNetworking() {
|
||||
|
||||
// Choose the DNS target IP based on service type
|
||||
dnsIP := centralIP
|
||||
if svc.Backend.Type == services.BackendTCPPassthrough {
|
||||
if svc.EffectiveBackendType() == services.BackendTCPPassthrough {
|
||||
// k8s instances: LAN clients connect directly to the k8s LB
|
||||
dnsIP = extractHost(svc.Backend.Address)
|
||||
dnsIP = extractHost(svc.EffectiveBackendAddress())
|
||||
}
|
||||
|
||||
ic := config.InstanceConfig{}
|
||||
|
||||
Reference in New Issue
Block a user