It's state, not config.
This commit is contained in:
@@ -17,7 +17,7 @@ Wild Central runs on a device on your LAN (e.g., a Raspberry Pi) and manages:
|
|||||||
|
|
||||||
## Two sources of truth
|
## Two sources of truth
|
||||||
|
|
||||||
**Central config** drives Central's own services — its UI domain, VPN, firewall, DHCP. These are managed through Central's web UI and config file.
|
**Central state** drives Central's own services — its UI domain, VPN, firewall, DHCP. These are managed through Central's web UI and state file.
|
||||||
|
|
||||||
**Service registrations** drive external consumer services. Wild Cloud registers its k8s instance domains. Wild Works registers its program services. Each registration is a domain that needs routing through Central's networking stack.
|
**Service registrations** drive external consumer services. Wild Cloud registers its k8s instance domains. Wild Works registers its program services. Each registration is a domain that needs routing through Central's networking stack.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package v1
|
package v1
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -54,10 +55,6 @@ type API struct {
|
|||||||
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
|
||||||
configMgr := config.NewManager()
|
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
|
// Determine config paths from env or defaults
|
||||||
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf")
|
dnsmasqConfigPath := envOrDefault("WILD_CENTRAL_DNSMASQ_CONFIG_PATH", "/etc/dnsmasq.d/wild-cloud.conf")
|
||||||
haproxyConfigPath := envOrDefault("WILD_CENTRAL_HAPROXY_CONFIG_PATH", "/etc/haproxy/haproxy.cfg")
|
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.
|
// reloadDDNSIfEnabled re-reads DDNS config and secrets and restarts the DDNS goroutine.
|
||||||
func (api *API) reloadDDNSIfEnabled() {
|
func (api *API) reloadDDNSIfEnabled() {
|
||||||
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
|
globalCfg, err := config.LoadState(api.statePath())
|
||||||
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
|
||||||
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
|
if err != nil || !globalCfg.Cloud.DDNS.Enabled {
|
||||||
api.ddns.Stop()
|
api.ddns.Stop()
|
||||||
return
|
return
|
||||||
@@ -246,9 +242,9 @@ func (api *API) RegisterRoutes(r *mux.Router) {
|
|||||||
|
|
||||||
// --- Global Config/Secrets handlers ---
|
// --- 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) {
|
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)
|
data, err := os.ReadFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondJSON(w, http.StatusOK, map[string]any{
|
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) {
|
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)
|
body, err := io.ReadAll(r.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -285,10 +281,10 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read existing config
|
// Read existing state (may not exist yet)
|
||||||
existingContent, err := storage.ReadFile(configPath)
|
existingContent, err := storage.ReadFile(configPath)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
respondError(w, http.StatusInternalServerError, "Failed to read existing config")
|
respondError(w, http.StatusInternalServerError, "Failed to read existing state")
|
||||||
return
|
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) {
|
func (api *API) StatusHandler(w http.ResponseWriter, r *http.Request, startTime time.Time, dataDir string) {
|
||||||
uptime := time.Since(startTime)
|
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{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"status": "running",
|
"status": "running",
|
||||||
"version": api.version,
|
"version": api.version,
|
||||||
"uptime": uptime.String(),
|
"uptime": uptime.String(),
|
||||||
"uptimeSeconds": int(uptime.Seconds()),
|
"uptimeSeconds": int(uptime.Seconds()),
|
||||||
"dataDir": dataDir,
|
"dataDir": dataDir,
|
||||||
"pendingRestart": pendingRestart,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import (
|
|||||||
func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
centralDomain := api.getCentralDomain()
|
centralDomain := api.getCentralDomain()
|
||||||
|
|
||||||
globalCfg, _ := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
globalCfg, _ := config.LoadState(api.statePath())
|
||||||
email := ""
|
email := ""
|
||||||
if globalCfg != nil {
|
if globalCfg != nil {
|
||||||
email = globalCfg.Operator.Email
|
email = globalCfg.Operator.Email
|
||||||
@@ -113,7 +113,7 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
globalCfg, err := config.LoadState(api.statePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load config: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load config: %v", err))
|
||||||
return
|
return
|
||||||
@@ -179,7 +179,7 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// getCentralDomain returns the configured central domain or empty string.
|
// getCentralDomain returns the configured central domain or empty string.
|
||||||
func (api *API) getCentralDomain() string {
|
func (api *API) getCentralDomain() string {
|
||||||
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
|
globalCfg, err := config.LoadState(api.statePath())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
data, _ := yaml.Marshal(cfg)
|
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)
|
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -62,8 +62,8 @@ func TestGetGlobalConfig_ReturnsWrappedResponse(t *testing.T) {
|
|||||||
func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) {
|
func TestGetGlobalConfig_EmptyReturnsNotConfigured(t *testing.T) {
|
||||||
api, tmpDir := setupTestAPI(t)
|
api, tmpDir := setupTestAPI(t)
|
||||||
|
|
||||||
// Remove the default config that EnsureGlobalConfig created
|
// Ensure no state file exists
|
||||||
os.Remove(filepath.Join(tmpDir, "config.yaml"))
|
os.Remove(filepath.Join(tmpDir, "state.yaml"))
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
req := httptest.NewRequest("GET", "/api/v1/config", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
@@ -168,7 +168,7 @@ func TestUpdateGlobalConfig(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Verify file was written
|
// 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
|
var cfg map[string]any
|
||||||
yaml.Unmarshal(data, &cfg)
|
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"
|
overwrite := r.URL.Query().Get("overwrite") == "true"
|
||||||
|
|
||||||
// Load global config
|
// Load global config
|
||||||
globalConfigPath := api.getGlobalConfigPath()
|
globalConfigPath := api.statePath()
|
||||||
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
globalCfg, err := config.LoadState(globalConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load global config: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load global config: %v", err))
|
||||||
return
|
return
|
||||||
@@ -174,8 +174,8 @@ func writeConfigFile(path, content string) error {
|
|||||||
// updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts.
|
// updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts.
|
||||||
// Instance-specific DNS records are managed via service registration (not here).
|
// Instance-specific DNS records are managed via service registration (not here).
|
||||||
func (api *API) updateDnsmasqConfig() error {
|
func (api *API) updateDnsmasqConfig() error {
|
||||||
globalConfigPath := api.getGlobalConfigPath()
|
globalConfigPath := api.statePath()
|
||||||
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
globalCfg, err := config.LoadState(globalConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("loading global config: %w", err)
|
return fmt.Errorf("loading global config: %w", err)
|
||||||
}
|
}
|
||||||
@@ -184,9 +184,9 @@ func (api *API) updateDnsmasqConfig() error {
|
|||||||
return api.dnsmasq.UpdateConfig(globalCfg, nil, true)
|
return api.dnsmasq.UpdateConfig(globalCfg, nil, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getGlobalConfigPath returns the path to the global config file
|
// statePath returns the path to the global state file
|
||||||
func (api *API) getGlobalConfigPath() string {
|
func (api *API) statePath() string {
|
||||||
return api.dataDir + "/config.yaml"
|
return api.dataDir + "/state.yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
// DHCPLease represents an active DHCP lease from the dnsmasq leases file
|
// 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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfigPath := api.getGlobalConfigPath()
|
globalConfigPath := api.statePath()
|
||||||
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
|
if err := api.addDHCPStaticLease(globalConfigPath, req); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to add static lease: %v", err))
|
||||||
return
|
return
|
||||||
@@ -280,7 +280,7 @@ func (api *API) DnsmasqDHCPDeleteStatic(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfigPath := api.getGlobalConfigPath()
|
globalConfigPath := api.statePath()
|
||||||
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
|
if err := api.removeDHCPStaticLease(globalConfigPath, mac); err != nil {
|
||||||
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
|
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to remove static lease: %v", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ func TestDnsmasqGenerate(t *testing.T) {
|
|||||||
api, tmpDir := setupTestDnsmasq(t)
|
api, tmpDir := setupTestDnsmasq(t)
|
||||||
|
|
||||||
globalConfig := config.GlobalConfig{}
|
globalConfig := config.GlobalConfig{}
|
||||||
globalConfig.Cloud.Router.IP = "192.168.1.1"
|
globalConfig.Cloud.Central.Domain = "central.example.com"
|
||||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
configPath := filepath.Join(tmpDir, "state.yaml")
|
||||||
configData, _ := yaml.Marshal(globalConfig)
|
configData, _ := yaml.Marshal(globalConfig)
|
||||||
if err := storage.WriteFile(configPath, configData, 0644); err != nil {
|
if err := storage.WriteFile(configPath, configData, 0644); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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 {
|
if err != nil {
|
||||||
return nil, nil, "", fmt.Errorf("failed to load global config: %w", err)
|
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) {
|
func TestHaproxyGenerate_MissingGlobalConfig(t *testing.T) {
|
||||||
api, tmpDir := setupTestHaproxy(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)
|
req := httptest.NewRequest("POST", "/api/v1/haproxy/generate", nil)
|
||||||
w := httptest.NewRecorder()
|
w := httptest.NewRecorder()
|
||||||
|
|||||||
@@ -75,10 +75,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
})
|
})
|
||||||
case services.BackendHTTP:
|
case services.BackendHTTP:
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: svc.Domain,
|
||||||
Domain: svc.Domain,
|
Domain: svc.Domain,
|
||||||
Backend: svc.Backend.Address,
|
Routes: []haproxy.HTTPRouteBackend{{
|
||||||
HealthPath: svc.Backend.Health,
|
Backend: svc.Backend.Address,
|
||||||
|
HealthPath: svc.Backend.Health,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -88,7 +90,6 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
Domain: "central.payne.io",
|
Domain: "central.payne.io",
|
||||||
Source: "central",
|
Source: "central",
|
||||||
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
Backend: services.Backend{Address: "127.0.0.1:5055", Type: services.BackendHTTP},
|
||||||
// Public defaults to false
|
|
||||||
TLS: services.TLSTerminate,
|
TLS: services.TLSTerminate,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
t.Fatalf("Register central failed: %v", err)
|
t.Fatalf("Register central failed: %v", err)
|
||||||
@@ -102,10 +103,12 @@ func TestReconciliation_HAProxyConfigFromServices(t *testing.T) {
|
|||||||
for _, svc := range svcs {
|
for _, svc := range svcs {
|
||||||
if svc.Backend.Type == services.BackendHTTP {
|
if svc.Backend.Type == services.BackendHTTP {
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: svc.Domain,
|
||||||
Domain: svc.Domain,
|
Domain: svc.Domain,
|
||||||
Backend: svc.Backend.Address,
|
Routes: []haproxy.HTTPRouteBackend{{
|
||||||
HealthPath: svc.Backend.Health,
|
Backend: svc.Backend.Address,
|
||||||
|
HealthPath: svc.Backend.Health,
|
||||||
|
}},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -299,9 +302,11 @@ func TestReconciliation_CentralDomainInHAProxy(t *testing.T) {
|
|||||||
for _, svc := range svcs {
|
for _, svc := range svcs {
|
||||||
if svc.Backend.Type == services.BackendHTTP {
|
if svc.Backend.Type == services.BackendHTTP {
|
||||||
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: svc.Domain,
|
||||||
Domain: svc.Domain,
|
Domain: svc.Domain,
|
||||||
Backend: svc.Backend.Address,
|
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.
|
// writeTestConfig writes a minimal global config with the given Central domain.
|
||||||
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
|
func writeTestConfig(t *testing.T, dataDir, centralDomain string) {
|
||||||
t.Helper()
|
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)
|
content := fmt.Sprintf("cloud:\n central:\n domain: %s\n", centralDomain)
|
||||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
||||||
t.Fatalf("write config: %v", err)
|
t.Fatalf("write config: %v", err)
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ package v1
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
|
|
||||||
@@ -48,6 +50,22 @@ func (api *API) ServicesRegister(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
if err := api.services.Register(svc); err != nil {
|
||||||
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err))
|
respondError(w, http.StatusBadRequest, fmt.Sprintf("Failed to register service: %v", err))
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func (api *API) VpnUpdateConfig(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Resync nftables so the VPN listen port is automatically allowed/removed
|
// 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)
|
go api.syncNftablesOnly(globalCfg)
|
||||||
}
|
}
|
||||||
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
|
respondJSON(w, http.StatusOK, map[string]string{"message": "VPN configuration saved"})
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -72,10 +71,9 @@ func (api *API) reconcileNetworking() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
globalConfigPath := filepath.Join(api.dataDir, "config.yaml")
|
globalCfg, err := config.LoadState(api.statePath())
|
||||||
globalCfg, err := config.LoadGlobalConfig(globalConfigPath)
|
|
||||||
if err != nil {
|
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{}
|
globalCfg = &config.GlobalConfig{}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,20 +82,30 @@ func (api *API) reconcileNetworking() {
|
|||||||
var httpRoutes []haproxy.HTTPRoute
|
var httpRoutes []haproxy.HTTPRoute
|
||||||
|
|
||||||
for _, svc := range svcs {
|
for _, svc := range svcs {
|
||||||
switch svc.Backend.Type {
|
switch svc.EffectiveBackendType() {
|
||||||
case services.BackendTCPPassthrough:
|
case services.BackendTCPPassthrough:
|
||||||
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
instanceRoutes = append(instanceRoutes, haproxy.InstanceRoute{
|
||||||
Name: svc.Domain,
|
Name: svc.Domain,
|
||||||
Domain: svc.Domain,
|
Domain: svc.Domain,
|
||||||
BackendIP: extractHost(svc.Backend.Address),
|
BackendIP: extractHost(svc.EffectiveBackendAddress()),
|
||||||
Subdomains: svc.Subdomains,
|
Subdomains: svc.Subdomains,
|
||||||
})
|
})
|
||||||
case services.BackendHTTP:
|
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{
|
httpRoutes = append(httpRoutes, haproxy.HTTPRoute{
|
||||||
Name: svc.Domain,
|
Name: svc.Domain,
|
||||||
Domain: svc.Domain,
|
Domain: svc.Domain,
|
||||||
Backend: svc.Backend.Address,
|
Subdomains: svc.Subdomains,
|
||||||
HealthPath: svc.Backend.Health,
|
Routes: routeBackends,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -151,9 +159,9 @@ func (api *API) reconcileNetworking() {
|
|||||||
|
|
||||||
// Choose the DNS target IP based on service type
|
// Choose the DNS target IP based on service type
|
||||||
dnsIP := centralIP
|
dnsIP := centralIP
|
||||||
if svc.Backend.Type == services.BackendTCPPassthrough {
|
if svc.EffectiveBackendType() == services.BackendTCPPassthrough {
|
||||||
// k8s instances: LAN clients connect directly to the k8s LB
|
// k8s instances: LAN clients connect directly to the k8s LB
|
||||||
dnsIP = extractHost(svc.Backend.Address)
|
dnsIP = extractHost(svc.EffectiveBackendAddress())
|
||||||
}
|
}
|
||||||
|
|
||||||
ic := config.InstanceConfig{}
|
ic := config.InstanceConfig{}
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ func DeepMerge(dst, src map[string]any) map[string]any {
|
|||||||
// Assembles apps.* from config/apps/*/config.yaml files.
|
// Assembles apps.* from config/apps/*/config.yaml files.
|
||||||
func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]any, error) {
|
func LoadMergedInstanceConfig(dataDir, instanceName string) (map[string]any, error) {
|
||||||
instanceConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
|
instanceConfigPath := filepath.Join(dataDir, "instances", instanceName, "config", "instance.yaml")
|
||||||
globalConfigPath := filepath.Join(dataDir, "config.yaml")
|
globalConfigPath := filepath.Join(dataDir, "state.yaml")
|
||||||
|
|
||||||
instanceData, err := os.ReadFile(instanceConfigPath)
|
instanceData, err := os.ReadFile(instanceConfigPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -9,36 +9,35 @@ import (
|
|||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Test: LoadGlobalConfig loads valid configuration
|
// Test: LoadState loads valid state
|
||||||
func TestLoadGlobalConfig(t *testing.T) {
|
func TestLoadState(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
configYAML string
|
stateYAML string
|
||||||
verify func(t *testing.T, config *GlobalConfig)
|
verify func(t *testing.T, config *GlobalConfig)
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "loads complete configuration",
|
name: "loads complete state",
|
||||||
configYAML: `operator:
|
stateYAML: `operator:
|
||||||
email: "admin@example.com"
|
email: "admin@example.com"
|
||||||
cloud:
|
cloud:
|
||||||
router:
|
central:
|
||||||
ip: "192.168.1.254"
|
domain: "central.example.com"
|
||||||
dynamicDns: "example.dyndns.org"
|
|
||||||
`,
|
`,
|
||||||
verify: func(t *testing.T, config *GlobalConfig) {
|
verify: func(t *testing.T, config *GlobalConfig) {
|
||||||
if config.Operator.Email != "admin@example.com" {
|
if config.Operator.Email != "admin@example.com" {
|
||||||
t.Error("operator email not loaded correctly")
|
t.Error("operator email not loaded correctly")
|
||||||
}
|
}
|
||||||
if config.Cloud.Router.IP != "192.168.1.254" {
|
if config.Cloud.Central.Domain != "central.example.com" {
|
||||||
t.Error("router IP not loaded correctly")
|
t.Error("central domain not loaded correctly")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "loads minimal configuration",
|
name: "loads minimal state",
|
||||||
configYAML: `operator:
|
stateYAML: `operator:
|
||||||
email: "admin@example.com"
|
email: "admin@example.com"
|
||||||
`,
|
`,
|
||||||
verify: func(t *testing.T, config *GlobalConfig) {
|
verify: func(t *testing.T, config *GlobalConfig) {
|
||||||
@@ -49,9 +48,8 @@ cloud:
|
|||||||
wantErr: false,
|
wantErr: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "loads empty configuration",
|
name: "loads empty state",
|
||||||
configYAML: `{}
|
stateYAML: "{}\n",
|
||||||
`,
|
|
||||||
verify: func(t *testing.T, config *GlobalConfig) {
|
verify: func(t *testing.T, config *GlobalConfig) {
|
||||||
if config.Operator.Email != "" {
|
if config.Operator.Email != "" {
|
||||||
t.Error("expected empty operator email")
|
t.Error("expected empty operator email")
|
||||||
@@ -64,13 +62,13 @@ cloud:
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := filepath.Join(tempDir, "config.yaml")
|
statePath := filepath.Join(tempDir, "state.yaml")
|
||||||
|
|
||||||
if err := os.WriteFile(configPath, []byte(tt.configYAML), 0644); err != nil {
|
if err := os.WriteFile(statePath, []byte(tt.stateYAML), 0644); err != nil {
|
||||||
t.Fatalf("setup failed: %v", err)
|
t.Fatalf("setup failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
config, err := LoadGlobalConfig(configPath)
|
config, err := LoadState(statePath)
|
||||||
if tt.wantErr {
|
if tt.wantErr {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error, got nil")
|
t.Error("expected error, got nil")
|
||||||
@@ -94,8 +92,8 @@ cloud:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: LoadGlobalConfig error cases
|
// Test: LoadState error cases
|
||||||
func TestLoadGlobalConfig_Errors(t *testing.T) {
|
func TestLoadState_Errors(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
setupFunc func(t *testing.T) string
|
setupFunc func(t *testing.T) string
|
||||||
@@ -112,12 +110,12 @@ func TestLoadGlobalConfig_Errors(t *testing.T) {
|
|||||||
name: "invalid yaml",
|
name: "invalid yaml",
|
||||||
setupFunc: func(t *testing.T) string {
|
setupFunc: func(t *testing.T) string {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := filepath.Join(tempDir, "config.yaml")
|
statePath := filepath.Join(tempDir, "state.yaml")
|
||||||
content := `invalid: yaml: [[[`
|
content := `invalid: yaml: [[[`
|
||||||
if err := os.WriteFile(configPath, []byte(content), 0644); err != nil {
|
if err := os.WriteFile(statePath, []byte(content), 0644); err != nil {
|
||||||
t.Fatalf("setup failed: %v", err)
|
t.Fatalf("setup failed: %v", err)
|
||||||
}
|
}
|
||||||
return configPath
|
return statePath
|
||||||
},
|
},
|
||||||
errContains: "parsing config file",
|
errContains: "parsing config file",
|
||||||
},
|
},
|
||||||
@@ -125,8 +123,8 @@ func TestLoadGlobalConfig_Errors(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
configPath := tt.setupFunc(t)
|
statePath := tt.setupFunc(t)
|
||||||
_, err := LoadGlobalConfig(configPath)
|
_, err := LoadState(statePath)
|
||||||
|
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Error("expected error, got nil")
|
t.Error("expected error, got nil")
|
||||||
@@ -137,42 +135,41 @@ func TestLoadGlobalConfig_Errors(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: SaveGlobalConfig saves configuration correctly
|
// Test: SaveState saves state correctly
|
||||||
func TestSaveGlobalConfig(t *testing.T) {
|
func TestSaveState(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
config *GlobalConfig
|
config *GlobalConfig
|
||||||
verify func(t *testing.T, configPath string)
|
verify func(t *testing.T, statePath string)
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "saves complete configuration",
|
name: "saves complete state",
|
||||||
config: func() *GlobalConfig {
|
config: func() *GlobalConfig {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &GlobalConfig{}
|
||||||
cfg.Operator.Email = "admin@example.com"
|
cfg.Operator.Email = "admin@example.com"
|
||||||
cfg.Cloud.Router.IP = "192.168.1.254"
|
cfg.Cloud.Central.Domain = "central.example.com"
|
||||||
cfg.Cloud.Router.DynamicDns = "example.dyndns.org"
|
|
||||||
return cfg
|
return cfg
|
||||||
}(),
|
}(),
|
||||||
verify: func(t *testing.T, configPath string) {
|
verify: func(t *testing.T, statePath string) {
|
||||||
content, err := os.ReadFile(configPath)
|
content, err := os.ReadFile(statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to read saved config: %v", err)
|
t.Fatalf("failed to read saved state: %v", err)
|
||||||
}
|
}
|
||||||
contentStr := string(content)
|
contentStr := string(content)
|
||||||
if !strings.Contains(contentStr, "admin@example.com") {
|
if !strings.Contains(contentStr, "admin@example.com") {
|
||||||
t.Error("saved config missing operator email")
|
t.Error("saved state missing operator email")
|
||||||
}
|
}
|
||||||
if !strings.Contains(contentStr, "192.168.1.254") {
|
if !strings.Contains(contentStr, "central.example.com") {
|
||||||
t.Error("saved config missing router IP")
|
t.Error("saved state missing central domain")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "saves empty configuration",
|
name: "saves empty state",
|
||||||
config: &GlobalConfig{},
|
config: &GlobalConfig{},
|
||||||
verify: func(t *testing.T, configPath string) {
|
verify: func(t *testing.T, statePath string) {
|
||||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
if _, err := os.Stat(statePath); os.IsNotExist(err) {
|
||||||
t.Error("config file not created")
|
t.Error("state file not created")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -181,63 +178,63 @@ func TestSaveGlobalConfig(t *testing.T) {
|
|||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := filepath.Join(tempDir, "subdir", "config.yaml")
|
statePath := filepath.Join(tempDir, "subdir", "state.yaml")
|
||||||
|
|
||||||
err := SaveGlobalConfig(tt.config, configPath)
|
err := SaveState(tt.config, statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("SaveGlobalConfig failed: %v", err)
|
t.Errorf("SaveState failed: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file exists
|
// Verify file exists
|
||||||
if _, err := os.Stat(configPath); err != nil {
|
if _, err := os.Stat(statePath); err != nil {
|
||||||
t.Errorf("config file not created: %v", err)
|
t.Errorf("state file not created: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file permissions
|
// Verify file permissions
|
||||||
info, err := os.Stat(configPath)
|
info, err := os.Stat(statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to stat config file: %v", err)
|
t.Fatalf("failed to stat state file: %v", err)
|
||||||
}
|
}
|
||||||
if info.Mode().Perm() != 0644 {
|
if info.Mode().Perm() != 0644 {
|
||||||
t.Errorf("expected permissions 0644, got %v", info.Mode().Perm())
|
t.Errorf("expected permissions 0644, got %v", info.Mode().Perm())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify content can be loaded back
|
// Verify content can be loaded back
|
||||||
loadedConfig, err := LoadGlobalConfig(configPath)
|
loadedConfig, err := LoadState(statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("failed to reload saved config: %v", err)
|
t.Errorf("failed to reload saved state: %v", err)
|
||||||
} else if loadedConfig == nil {
|
} else if loadedConfig == nil {
|
||||||
t.Error("loaded config is nil")
|
t.Error("loaded config is nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
if tt.verify != nil {
|
if tt.verify != nil {
|
||||||
tt.verify(t, configPath)
|
tt.verify(t, statePath)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: SaveGlobalConfig creates directory
|
// Test: SaveState creates directory
|
||||||
func TestSaveGlobalConfig_CreatesDirectory(t *testing.T) {
|
func TestSaveState_CreatesDirectory(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := filepath.Join(tempDir, "nested", "dirs", "config.yaml")
|
statePath := filepath.Join(tempDir, "nested", "dirs", "state.yaml")
|
||||||
|
|
||||||
config := &GlobalConfig{}
|
config := &GlobalConfig{}
|
||||||
err := SaveGlobalConfig(config, configPath)
|
err := SaveState(config, statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("SaveGlobalConfig failed: %v", err)
|
t.Fatalf("SaveState failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify nested directories were created
|
// Verify nested directories were created
|
||||||
if _, err := os.Stat(filepath.Dir(configPath)); err != nil {
|
if _, err := os.Stat(filepath.Dir(statePath)); err != nil {
|
||||||
t.Errorf("directory not created: %v", err)
|
t.Errorf("directory not created: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify file exists
|
// Verify file exists
|
||||||
if _, err := os.Stat(configPath); err != nil {
|
if _, err := os.Stat(statePath); err != nil {
|
||||||
t.Errorf("config file not created: %v", err)
|
t.Errorf("state file not created: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,10 +256,10 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
|
|||||||
want: true,
|
want: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "config with only router IP is not empty",
|
name: "config with only central domain is not empty",
|
||||||
config: func() *GlobalConfig {
|
config: func() *GlobalConfig {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &GlobalConfig{}
|
||||||
cfg.Cloud.Router.IP = "192.168.1.254"
|
cfg.Cloud.Central.Domain = "central.example.com"
|
||||||
return cfg
|
return cfg
|
||||||
}(),
|
}(),
|
||||||
want: false,
|
want: false,
|
||||||
@@ -280,7 +277,7 @@ func TestGlobalConfig_IsEmpty(t *testing.T) {
|
|||||||
name: "config with all fields is not empty",
|
name: "config with all fields is not empty",
|
||||||
config: func() *GlobalConfig {
|
config: func() *GlobalConfig {
|
||||||
cfg := &GlobalConfig{}
|
cfg := &GlobalConfig{}
|
||||||
cfg.Cloud.Router.IP = "192.168.1.254"
|
cfg.Cloud.Central.Domain = "central.example.com"
|
||||||
cfg.Operator.Email = "admin@example.com"
|
cfg.Operator.Email = "admin@example.com"
|
||||||
return cfg
|
return cfg
|
||||||
}(),
|
}(),
|
||||||
@@ -482,34 +479,30 @@ func TestSaveCloudConfig(t *testing.T) {
|
|||||||
// Test: Round-trip save and load preserves data
|
// Test: Round-trip save and load preserves data
|
||||||
func TestGlobalConfig_RoundTrip(t *testing.T) {
|
func TestGlobalConfig_RoundTrip(t *testing.T) {
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
configPath := filepath.Join(tempDir, "config.yaml")
|
statePath := filepath.Join(tempDir, "state.yaml")
|
||||||
|
|
||||||
// Create config with all fields
|
// Create config with all fields
|
||||||
original := &GlobalConfig{}
|
original := &GlobalConfig{}
|
||||||
original.Operator.Email = "admin@example.com"
|
original.Operator.Email = "admin@example.com"
|
||||||
original.Cloud.Router.IP = "192.168.1.254"
|
original.Cloud.Central.Domain = "central.example.com"
|
||||||
original.Cloud.Router.DynamicDns = "example.dyndns.org"
|
|
||||||
|
|
||||||
// Save config
|
// Save
|
||||||
if err := SaveGlobalConfig(original, configPath); err != nil {
|
if err := SaveState(original, statePath); err != nil {
|
||||||
t.Fatalf("SaveGlobalConfig failed: %v", err)
|
t.Fatalf("SaveState failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load config
|
// Load
|
||||||
loaded, err := LoadGlobalConfig(configPath)
|
loaded, err := LoadState(statePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("LoadGlobalConfig failed: %v", err)
|
t.Fatalf("LoadState failed: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify all fields match
|
// Verify all fields match
|
||||||
if loaded.Operator.Email != original.Operator.Email {
|
if loaded.Operator.Email != original.Operator.Email {
|
||||||
t.Errorf("email mismatch: got %q, want %q", loaded.Operator.Email, original.Operator.Email)
|
t.Errorf("email mismatch: got %q, want %q", loaded.Operator.Email, original.Operator.Email)
|
||||||
}
|
}
|
||||||
if loaded.Cloud.Router.IP != original.Cloud.Router.IP {
|
if loaded.Cloud.Central.Domain != original.Cloud.Central.Domain {
|
||||||
t.Errorf("router IP mismatch: got %q, want %q", loaded.Cloud.Router.IP, original.Cloud.Router.IP)
|
t.Errorf("central domain mismatch: got %q, want %q", loaded.Cloud.Central.Domain, original.Cloud.Central.Domain)
|
||||||
}
|
|
||||||
if loaded.Cloud.Router.DynamicDns != original.Cloud.Router.DynamicDns {
|
|
||||||
t.Errorf("dynamic DNS mismatch: got %q, want %q", loaded.Cloud.Router.DynamicDns, original.Cloud.Router.DynamicDns)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,7 +554,7 @@ func TestDeepMerge(t *testing.T) {
|
|||||||
name: "nested maps merge recursively",
|
name: "nested maps merge recursively",
|
||||||
dst: map[string]any{
|
dst: map[string]any{
|
||||||
"cloud": map[string]any{
|
"cloud": map[string]any{
|
||||||
"router": map[string]any{"ip": "192.168.1.1"},
|
"central": map[string]any{"domain": "central.example.com"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
src: map[string]any{
|
src: map[string]any{
|
||||||
@@ -571,8 +564,8 @@ func TestDeepMerge(t *testing.T) {
|
|||||||
},
|
},
|
||||||
expected: map[string]any{
|
expected: map[string]any{
|
||||||
"cloud": map[string]any{
|
"cloud": map[string]any{
|
||||||
"router": map[string]any{"ip": "192.168.1.1"},
|
"central": map[string]any{"domain": "central.example.com"},
|
||||||
"domain": "example.com",
|
"domain": "example.com",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -620,11 +613,11 @@ func TestLoadMergedInstanceConfig(t *testing.T) {
|
|||||||
instanceConfigDir := filepath.Join(dataDir, "instances", instanceName, "config")
|
instanceConfigDir := filepath.Join(dataDir, "instances", instanceName, "config")
|
||||||
os.MkdirAll(instanceConfigDir, 0755)
|
os.MkdirAll(instanceConfigDir, 0755)
|
||||||
|
|
||||||
globalConfig := `operator:
|
globalState := `operator:
|
||||||
email: test@example.com
|
email: test@example.com
|
||||||
cloud:
|
cloud:
|
||||||
router:
|
central:
|
||||||
ip: 192.168.1.1
|
domain: central.example.com
|
||||||
`
|
`
|
||||||
instanceConfig := `cluster:
|
instanceConfig := `cluster:
|
||||||
name: test
|
name: test
|
||||||
@@ -635,7 +628,7 @@ cloud:
|
|||||||
domain: cloud.example.com
|
domain: cloud.example.com
|
||||||
`
|
`
|
||||||
|
|
||||||
os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte(globalConfig), 0644)
|
os.WriteFile(filepath.Join(dataDir, "state.yaml"), []byte(globalState), 0644)
|
||||||
os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte(instanceConfig), 0644)
|
os.WriteFile(filepath.Join(instanceConfigDir, "instance.yaml"), []byte(instanceConfig), 0644)
|
||||||
|
|
||||||
merged, err := LoadMergedInstanceConfig(dataDir, instanceName)
|
merged, err := LoadMergedInstanceConfig(dataDir, instanceName)
|
||||||
@@ -648,12 +641,12 @@ cloud:
|
|||||||
t.Fatal("missing cloud key in merged config")
|
t.Fatal("missing cloud key in merged config")
|
||||||
}
|
}
|
||||||
|
|
||||||
router, ok := cloud["router"].(map[string]any)
|
central, ok := cloud["central"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("missing cloud.router key — global config not merged")
|
t.Fatal("missing cloud.central key — global state not merged")
|
||||||
}
|
}
|
||||||
if router["ip"] != "192.168.1.1" {
|
if central["domain"] != "central.example.com" {
|
||||||
t.Errorf("cloud.router.ip = %v, want 192.168.1.1", router["ip"])
|
t.Errorf("cloud.central.domain = %v, want central.example.com", central["domain"])
|
||||||
}
|
}
|
||||||
|
|
||||||
if cloud["domain"] != "cloud.example.com" {
|
if cloud["domain"] != "cloud.example.com" {
|
||||||
@@ -670,14 +663,14 @@ cloud:
|
|||||||
|
|
||||||
operator, ok := merged["operator"].(map[string]any)
|
operator, ok := merged["operator"].(map[string]any)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("missing operator key — global config not merged")
|
t.Fatal("missing operator key — global state not merged")
|
||||||
}
|
}
|
||||||
if operator["email"] != "test@example.com" {
|
if operator["email"] != "test@example.com" {
|
||||||
t.Errorf("operator.email = %v, want test@example.com", operator["email"])
|
t.Errorf("operator.email = %v, want test@example.com", operator["email"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadMergedInstanceConfig_NoGlobalConfig(t *testing.T) {
|
func TestLoadMergedInstanceConfig_NoGlobalState(t *testing.T) {
|
||||||
dataDir := t.TempDir()
|
dataDir := t.TempDir()
|
||||||
instanceConfigDir := filepath.Join(dataDir, "instances", "test", "config")
|
instanceConfigDir := filepath.Join(dataDir, "instances", "test", "config")
|
||||||
os.MkdirAll(instanceConfigDir, 0755)
|
os.MkdirAll(instanceConfigDir, 0755)
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ package config
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/wild-cloud/wild-central/internal/network"
|
|
||||||
"github.com/wild-cloud/wild-central/internal/storage"
|
"github.com/wild-cloud/wild-central/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -89,41 +87,6 @@ func NewManager() *Manager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EnsureGlobalConfig ensures a global config file exists with proper structure
|
|
||||||
func (m *Manager) EnsureGlobalConfig(dataDir string) error {
|
|
||||||
configPath := filepath.Join(dataDir, "config.yaml")
|
|
||||||
|
|
||||||
// Check if config already exists
|
|
||||||
if storage.FileExists(configPath) {
|
|
||||||
// Validate existing config
|
|
||||||
if err := m.yq.Validate(configPath); err != nil {
|
|
||||||
return fmt.Errorf("invalid config file: %w", err)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create config structure with detected defaults
|
|
||||||
initialConfig := &GlobalConfig{}
|
|
||||||
|
|
||||||
// Detect network configuration
|
|
||||||
netInfo, err := network.DetectNetworkInfo()
|
|
||||||
if err != nil {
|
|
||||||
slog.Info("network detection failed, using defaults", "component", "config", "error", err)
|
|
||||||
} else {
|
|
||||||
// Set detected values
|
|
||||||
initialConfig.Cloud.Router.IP = netInfo.Gateway
|
|
||||||
slog.Info("detected network", "component", "config", "gateway", netInfo.Gateway, "interface", netInfo.PrimaryInterface)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
if err := storage.EnsureDir(dataDir, 0755); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save config using the model's save function
|
|
||||||
return SaveGlobalConfig(initialConfig, configPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureInstanceConfig ensures an instance config file exists with proper structure
|
// EnsureInstanceConfig ensures an instance config file exists with proper structure
|
||||||
func (m *Manager) EnsureInstanceConfig(name string, dataDir string) error {
|
func (m *Manager) EnsureInstanceConfig(name string, dataDir string) error {
|
||||||
configPath := filepath.Join(dataDir, "instances", name, "config", "instance.yaml")
|
configPath := filepath.Join(dataDir, "instances", name, "config", "instance.yaml")
|
||||||
|
|||||||
@@ -62,11 +62,7 @@ log-dhcp
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
|
fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime)
|
||||||
|
|
||||||
// Gateway: use explicit DHCP gateway, fall back to router IP
|
|
||||||
gateway := dhcp.Gateway
|
gateway := dhcp.Gateway
|
||||||
if gateway == "" {
|
|
||||||
gateway = cfg.Cloud.Router.IP
|
|
||||||
}
|
|
||||||
if gateway != "" {
|
if gateway != "" {
|
||||||
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway
|
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
|||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.LeaseTime = "12h"
|
cfg.Cloud.Dnsmasq.DHCP.LeaseTime = "12h"
|
||||||
cfg.Cloud.Router.IP = "192.168.8.1"
|
cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.1"
|
||||||
|
|
||||||
out := g.GenerateMainConfig(cfg)
|
out := g.GenerateMainConfig(cfg)
|
||||||
|
|
||||||
@@ -221,24 +221,23 @@ func TestGenerateMainConfig_DHCPEnabled(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: DHCP gateway falls back to router IP when not set
|
// Test: DHCP without gateway omits dhcp-option=3
|
||||||
func TestGenerateMainConfig_DHCPGatewayFallback(t *testing.T) {
|
func TestGenerateMainConfig_DHCPNoGateway(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.GlobalConfig{}
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
cfg.Cloud.Dnsmasq.DHCP.Enabled = true
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
cfg.Cloud.Router.IP = "192.168.8.1"
|
// No gateway set
|
||||||
// Gateway not set — should fall back to router IP
|
|
||||||
|
|
||||||
out := g.GenerateMainConfig(cfg)
|
out := g.GenerateMainConfig(cfg)
|
||||||
|
|
||||||
if !strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
if strings.Contains(out, "dhcp-option=3,") {
|
||||||
t.Errorf("expected router IP as fallback gateway, got:\n%s", out)
|
t.Errorf("expected no gateway dhcp-option when gateway not set, got:\n%s", out)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: DHCP explicit gateway takes precedence over router IP
|
// Test: DHCP explicit gateway is used
|
||||||
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
||||||
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
g := NewConfigGenerator("/tmp/test-dnsmasq.conf")
|
||||||
cfg := &config.GlobalConfig{}
|
cfg := &config.GlobalConfig{}
|
||||||
@@ -246,16 +245,12 @@ func TestGenerateMainConfig_DHCPExplicitGateway(t *testing.T) {
|
|||||||
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
cfg.Cloud.Dnsmasq.DHCP.RangeStart = "192.168.8.100"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
cfg.Cloud.Dnsmasq.DHCP.RangeEnd = "192.168.8.200"
|
||||||
cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.254"
|
cfg.Cloud.Dnsmasq.DHCP.Gateway = "192.168.8.254"
|
||||||
cfg.Cloud.Router.IP = "192.168.8.1"
|
|
||||||
|
|
||||||
out := g.GenerateMainConfig(cfg)
|
out := g.GenerateMainConfig(cfg)
|
||||||
|
|
||||||
if !strings.Contains(out, "dhcp-option=3,192.168.8.254") {
|
if !strings.Contains(out, "dhcp-option=3,192.168.8.254") {
|
||||||
t.Errorf("expected explicit gateway in dhcp-option=3, got:\n%s", out)
|
t.Errorf("expected explicit gateway in dhcp-option=3, got:\n%s", out)
|
||||||
}
|
}
|
||||||
if strings.Contains(out, "dhcp-option=3,192.168.8.1") {
|
|
||||||
t.Errorf("router IP should not appear as gateway when explicit gateway set, got:\n%s", out)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test: DHCP static leases appear in output
|
// Test: DHCP static leases appear in output
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface CreateConfigResponse {
|
|||||||
/**
|
/**
|
||||||
* Hook for managing Wild Central global configuration
|
* Hook for managing Wild Central global configuration
|
||||||
* Endpoint: /api/v1/config
|
* Endpoint: /api/v1/config
|
||||||
* File: {dataDir}/config.yaml
|
* File: {dataDir}/state.yaml
|
||||||
*/
|
*/
|
||||||
export const useConfig = () => {
|
export const useConfig = () => {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|||||||
Reference in New Issue
Block a user