From 68d6fde80d4c872f15407d2457de4db1fd4a9b73 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Fri, 10 Jul 2026 06:10:40 +0000 Subject: [PATCH] Show routes, TLS certs, and port-forwarding on services page - Add Routes model to services UI (paths, headers, IP whitelisting per route) - Show TLS cert info per service with inline provision/renew actions - Remove TLS Certificates section from dashboard (now on services page) - Make gateway router port list dynamic from config + VPN state - Add TODO for header validation in HAProxy config generation --- CLAUDE.md | 22 +- TODO.md | 13 + internal/api/v1/handlers.go | 81 +- internal/api/v1/handlers_cloudflare.go | 4 +- internal/secrets/secrets.go | 226 ++--- internal/secrets/secrets_test.go | 965 +++++----------------- web/src/components/DashboardComponent.tsx | 83 +- web/src/components/ServicesComponent.tsx | 231 +++++- web/src/services/api/networking.ts | 17 + 9 files changed, 570 insertions(+), 1072 deletions(-) create mode 100644 TODO.md diff --git a/CLAUDE.md b/CLAUDE.md index 5414fc2..64be923 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,6 +37,7 @@ make check # Lint + test ### Environment Variables - `WILD_CENTRAL_DATA_DIR` — Data directory (default: `/var/lib/wild-central`) +- `WILD_CENTRAL_PORT` — API listen port (default: `5055`) - `WILD_CENTRAL_NATS_PORT` — NATS listen port (default: `4222`) - `WILD_CENTRAL_DNSMASQ_CONFIG_PATH` — dnsmasq config path - `WILD_CENTRAL_HAPROXY_CONFIG_PATH` — HAProxy config path @@ -46,6 +47,15 @@ make check # Lint + test - `WILD_CENTRAL_STATIC_DIR` — Web UI static files directory - `WILD_CENTRAL_VITE_URL` — Vite dev server URL for frontend proxying +### Data Directory + +Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`: +- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API) +- `secrets.yaml` — API tokens and credentials +- `services/` — per-domain service registration files +- `nats/` — embedded NATS JetStream data +- `instances/` — Wild Cloud instance configs + ## Service Registration API The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure. @@ -62,15 +72,14 @@ DELETE /api/v1/services/{name} Deregister service ```json { - "name": "my-api", - "source": "wild-works", "domain": "my-api.payne.io", + "source": "wild-works", "backend": { "address": "192.168.8.60:9001", "type": "http", "health": "/health" }, - "reach": "internal", + "public": false, "tls": "terminate" } ``` @@ -81,8 +90,7 @@ DELETE /api/v1/services/{name} Deregister service - `http` — L7, Central terminates TLS with wildcard cert (Wild Works services) - `static` — L7, static file serving (Wild Works frontends) -### Reach levels +### Public -- `off` — localhost only -- `internal` — LAN-visible (DNS + proxy + TLS) -- `public` — internet-visible (+ tunnel or direct exposure) +- `false` (default) — LAN-visible only (DNS + proxy + TLS) +- `true` — internet-visible (+ DDNS or tunnel exposure) diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..fd593ce --- /dev/null +++ b/TODO.md @@ -0,0 +1,13 @@ +# TODO + +## Header validation in service registration + +A bad header value from a client (e.g., `Access-Control-Allow-Headers: Content-Type, Authorization, ...` with commas/spaces) can produce invalid HAProxy config syntax. When HAProxy config validation fails, the reconciler skips the write — but the bad service persists, so the next reconcile cycle fails identically. This blocks ALL config updates for ALL services until the bad registration is fixed manually. + +### Needed + +1. **Input validation at registration time** — reject or sanitize header values that would break HAProxy config. HAProxy header values with spaces/commas need quoting; values with special characters (newlines, NULs) should be rejected outright. + +2. **Graceful degradation in reconciliation** — if generating config for one service produces an invalid HAProxy config, skip that service and log a warning rather than failing the entire reconciliation. Approach: generate config, validate, if invalid → bisect to find the offending service → exclude it and regenerate. + +3. **Current workaround** — header values are Go-`%q`-quoted in the HAProxy config output. This handles spaces/commas but may produce backslash escapes that HAProxy doesn't understand for edge cases. diff --git a/internal/api/v1/handlers.go b/internal/api/v1/handlers.go index 7644216..4fc98de 100644 --- a/internal/api/v1/handlers.go +++ b/internal/api/v1/handlers.go @@ -68,7 +68,7 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) { version: version, allowedOrigins: allowedOrigins, config: configMgr, - secrets: secrets.NewManager(), + secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")), dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath), haproxy: haproxy.NewManager(haproxyConfigPath), nftables: nftables.NewManager(nftablesRulesPath), @@ -327,26 +327,12 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) { // 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) + secretsMap, err := api.secrets.GetAll() 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 { redactSecrets(secretsMap) @@ -357,8 +343,6 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) { // 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") @@ -371,36 +355,7 @@ func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) { 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 { + if err := api.secrets.MergeUpdate(updates); err != nil { respondError(w, http.StatusInternalServerError, "Failed to write secrets") return } @@ -488,45 +443,19 @@ func redactSecrets(m map[string]any) { // 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) + token, err := api.secrets.GetSecret("cloudflare.apiToken") 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) + zoneID, err := api.secrets.GetSecret("cloudflare.zoneId") 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 } diff --git a/internal/api/v1/handlers_cloudflare.go b/internal/api/v1/handlers_cloudflare.go index d9030ce..920a0c1 100644 --- a/internal/api/v1/handlers_cloudflare.go +++ b/internal/api/v1/handlers_cloudflare.go @@ -3,7 +3,6 @@ package v1 import ( "encoding/json" "net/http" - "path/filepath" ) type cloudflareZone struct { @@ -71,8 +70,7 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) { return } - secretsPath := filepath.Join(api.dataDir, "secrets.yaml") - if err := api.secrets.SetSecret(secretsPath, "cloudflare.zoneId", req.ZoneID); err != nil { + if err := api.secrets.SetSecret("cloudflare.zoneId", req.ZoneID); err != nil { respondError(w, http.StatusInternalServerError, "Failed to save zone ID") return } diff --git a/internal/secrets/secrets.go b/internal/secrets/secrets.go index d9f46d9..649f74d 100644 --- a/internal/secrets/secrets.go +++ b/internal/secrets/secrets.go @@ -1,82 +1,17 @@ package secrets import ( - "bytes" "crypto/rand" + "errors" "fmt" "math/big" - "os/exec" - "path/filepath" - "strings" + "os" "github.com/wild-cloud/wild-central/internal/storage" + "github.com/wild-cloud/wild-central/internal/tools" + "gopkg.in/yaml.v3" ) -// YQ provides a wrapper around the yq command-line tool -type YQ struct { - yqPath string -} - -// NewYQ creates a new YQ wrapper -func NewYQ() *YQ { - path, err := exec.LookPath("yq") - if err != nil { - path = "yq" - } - return &YQ{yqPath: path} -} - -// Get retrieves a value from a YAML file using a yq expression -func (y *YQ) Get(filePath, expression string) (string, error) { - cmd := exec.Command(y.yqPath, expression, filePath) - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String()) - } - return strings.TrimSpace(stdout.String()), nil -} - -// Set sets a value in a YAML file using a yq expression -func (y *YQ) Set(filePath, expression, value string) error { - if !strings.HasPrefix(expression, ".") { - expression = "." + expression - } - quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`)) - setExpr := fmt.Sprintf("%s = %s", expression, quotedValue) - cmd := exec.Command(y.yqPath, "-i", setExpr, filePath) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String()) - } - return nil -} - -// Delete removes a key from a YAML file -func (y *YQ) Delete(filePath, expression string) error { - delExpr := fmt.Sprintf("del(%s)", expression) - cmd := exec.Command(y.yqPath, "-i", delExpr, filePath) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String()) - } - return nil -} - -// Validate checks if a YAML file is valid -func (y *YQ) Validate(filePath string) error { - cmd := exec.Command(y.yqPath, "eval", ".", filePath) - var stderr bytes.Buffer - cmd.Stderr = &stderr - if err := cmd.Run(); err != nil { - return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String()) - } - return nil -} - const ( // DefaultSecretLength is 32 characters DefaultSecretLength = 32 @@ -84,19 +19,21 @@ const ( alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" ) -// Manager handles secret generation and storage +// Manager handles secret storage backed by a single YAML file. type Manager struct { - yq *YQ + secretsPath string + yq *tools.YQ } -// NewManager creates a new secrets manager -func NewManager() *Manager { +// NewManager creates a new secrets manager bound to the given file path. +func NewManager(secretsPath string) *Manager { return &Manager{ - yq: NewYQ(), + secretsPath: secretsPath, + yq: tools.NewYQ(), } } -// GenerateSecret generates a cryptographically secure random alphanumeric string +// GenerateSecret generates a cryptographically secure random alphanumeric string. func GenerateSecret(length int) (string, error) { if length <= 0 { length = DefaultSecretLength @@ -114,54 +51,17 @@ func GenerateSecret(length int) (string, error) { return string(result), nil } -// EnsureSecretsFile ensures a secrets file exists with proper structure and permissions -func (m *Manager) EnsureSecretsFile(dataDir, instanceName string) error { - secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml") - - // Check if secrets file already exists - if storage.FileExists(secretsPath) { - // Ensure proper permissions - if err := storage.EnsureFilePermissions(secretsPath, 0600); err != nil { - return err - } - return nil +// GetSecret retrieves a secret value by dot-notation key. +func (m *Manager) GetSecret(key string) (string, error) { + if !storage.FileExists(m.secretsPath) { + return "", fmt.Errorf("secrets file not found: %s", m.secretsPath) } - // Create minimal secrets structure - initialSecrets := `# Wild Cloud Instance Secrets -# WARNING: This file contains sensitive data. Keep secure! -cluster: - talosSecrets: "" - kubeconfig: "" -cloudflare: - token: "" -` - - // Ensure config directory exists - if err := storage.EnsureDir(filepath.Join(dataDir, "instances", instanceName, "config"), 0755); err != nil { - return err - } - - // Write secrets file with restrictive permissions (0600) - if err := storage.WriteFile(secretsPath, []byte(initialSecrets), 0600); err != nil { - return err - } - - return nil -} - -// GetSecret retrieves a secret value from a secrets file -func (m *Manager) GetSecret(secretsPath, key string) (string, error) { - if !storage.FileExists(secretsPath) { - return "", fmt.Errorf("secrets file not found: %s", secretsPath) - } - - value, err := m.yq.Get(secretsPath, fmt.Sprintf(".%s", key)) + value, err := m.yq.Get(m.secretsPath, fmt.Sprintf(".%s", key)) if err != nil { return "", fmt.Errorf("getting secret %s: %w", key, err) } - // yq returns "null" for non-existent keys if value == "" || value == "null" { return "", fmt.Errorf("secret not found: %s", key) } @@ -169,69 +69,75 @@ func (m *Manager) GetSecret(secretsPath, key string) (string, error) { return value, nil } -// SetSecret sets a secret value in a secrets file -func (m *Manager) SetSecret(secretsPath, key, value string) error { - if !storage.FileExists(secretsPath) { - return fmt.Errorf("secrets file not found: %s", secretsPath) +// SetSecret sets a secret value by dot-notation key. +func (m *Manager) SetSecret(key, value string) error { + if !storage.FileExists(m.secretsPath) { + return fmt.Errorf("secrets file not found: %s", m.secretsPath) } - // Acquire lock before modifying - lockPath := secretsPath + ".lock" + lockPath := m.secretsPath + ".lock" return storage.WithLock(lockPath, func() error { - // Don't wrap value in quotes - yq handles YAML quoting automatically - if err := m.yq.Set(secretsPath, fmt.Sprintf(".%s", key), value); err != nil { + if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil { return err } - // Ensure permissions remain secure after modification - return storage.EnsureFilePermissions(secretsPath, 0600) + return storage.EnsureFilePermissions(m.secretsPath, 0600) }) } -// EnsureSecret generates and sets a secret only if it doesn't exist (idempotent) -func (m *Manager) EnsureSecret(secretsPath, key string, length int) (string, error) { - if !storage.FileExists(secretsPath) { - return "", fmt.Errorf("secrets file not found: %s", secretsPath) +// DeleteSecret removes a secret by dot-notation key. +func (m *Manager) DeleteSecret(key string) error { + if !storage.FileExists(m.secretsPath) { + return fmt.Errorf("secrets file not found: %s", m.secretsPath) } - // Check if secret already exists - existingSecret, err := m.GetSecret(secretsPath, key) - if err == nil && existingSecret != "" && existingSecret != "null" { - // Secret already exists, return it - return existingSecret, nil - } + lockPath := m.secretsPath + ".lock" + return storage.WithLock(lockPath, func() error { + if err := m.yq.Delete(m.secretsPath, fmt.Sprintf(".%s", key)); err != nil { + return err + } + return storage.EnsureFilePermissions(m.secretsPath, 0600) + }) +} - // Generate new secret - secret, err := GenerateSecret(length) +// GetAll reads and returns the full secrets map. +func (m *Manager) GetAll() (map[string]any, error) { + data, err := os.ReadFile(m.secretsPath) if err != nil { - return "", err + if errors.Is(err, os.ErrNotExist) { + return map[string]any{}, nil + } + return nil, fmt.Errorf("reading secrets: %w", err) } - // Set the secret - if err := m.SetSecret(secretsPath, key, secret); err != nil { - return "", err + var secretsMap map[string]any + if err := yaml.Unmarshal(data, &secretsMap); err != nil { + return nil, fmt.Errorf("parsing secrets: %w", err) + } + if secretsMap == nil { + secretsMap = map[string]any{} } - return secret, nil + return secretsMap, nil } -// GenerateAndStoreSecret is a convenience function that generates a secret and stores it -func (m *Manager) GenerateAndStoreSecret(secretsPath, key string) (string, error) { - return m.EnsureSecret(secretsPath, key, DefaultSecretLength) -} - -// DeleteSecret removes a secret from a secrets file -func (m *Manager) DeleteSecret(secretsPath, key string) error { - if !storage.FileExists(secretsPath) { - return fmt.Errorf("secrets file not found: %s", secretsPath) - } - - // Acquire lock before modifying - lockPath := secretsPath + ".lock" +// MergeUpdate merges the provided key-value pairs into the existing secrets file. +func (m *Manager) MergeUpdate(updates map[string]any) error { + lockPath := m.secretsPath + ".lock" return storage.WithLock(lockPath, func() error { - if err := m.yq.Delete(secretsPath, fmt.Sprintf(".%s", key)); err != nil { + existing, err := m.GetAll() + if err != nil { return err } - // Ensure permissions remain secure after modification - return storage.EnsureFilePermissions(secretsPath, 0600) + + for key, value := range updates { + existing[key] = value + } + + yamlContent, err := yaml.Marshal(existing) + if err != nil { + return fmt.Errorf("marshaling secrets: %w", err) + } + + return storage.WriteFile(m.secretsPath, yamlContent, 0600) }) } diff --git a/internal/secrets/secrets_test.go b/internal/secrets/secrets_test.go index ed4ba0d..072f781 100644 --- a/internal/secrets/secrets_test.go +++ b/internal/secrets/secrets_test.go @@ -10,38 +10,17 @@ import ( "github.com/wild-cloud/wild-central/internal/storage" ) -// Test: GenerateSecret generates valid secrets func TestGenerateSecret(t *testing.T) { tests := []struct { name string length int want int }{ - { - name: "default length", - length: DefaultSecretLength, - want: DefaultSecretLength, - }, - { - name: "custom length 64", - length: 64, - want: 64, - }, - { - name: "custom length 128", - length: 128, - want: 128, - }, - { - name: "zero length defaults to DefaultSecretLength", - length: 0, - want: DefaultSecretLength, - }, - { - name: "negative length defaults to DefaultSecretLength", - length: -1, - want: DefaultSecretLength, - }, + {"default length", DefaultSecretLength, DefaultSecretLength}, + {"custom length 64", 64, 64}, + {"custom length 128", 128, 128}, + {"zero defaults", 0, DefaultSecretLength}, + {"negative defaults", -1, DefaultSecretLength}, } for _, tt := range tests { @@ -50,12 +29,9 @@ func TestGenerateSecret(t *testing.T) { if err != nil { t.Fatalf("GenerateSecret failed: %v", err) } - if len(secret) != tt.want { t.Errorf("got length %d, want %d", len(secret), tt.want) } - - // Verify only alphanumeric characters for _, c := range secret { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) { t.Errorf("non-alphanumeric character found: %c", c) @@ -65,151 +41,39 @@ func TestGenerateSecret(t *testing.T) { } } -// Test: GenerateSecret produces unique values func TestGenerateSecret_Uniqueness(t *testing.T) { const numSecrets = 100 - secrets := make(map[string]bool, numSecrets) + seen := make(map[string]bool, numSecrets) for i := 0; i < numSecrets; i++ { secret, err := GenerateSecret(32) if err != nil { t.Fatalf("GenerateSecret failed: %v", err) } - - if secrets[secret] { + if seen[secret] { t.Errorf("duplicate secret generated: %s", secret) } - secrets[secret] = true - } - - if len(secrets) != numSecrets { - t.Errorf("expected %d unique secrets, got %d", numSecrets, len(secrets)) + seen[secret] = true } } -// Test: NewManager creates manager successfully +func newTestManager(t *testing.T, initialYAML string) *Manager { + t.Helper() + tempDir := t.TempDir() + secretsPath := filepath.Join(tempDir, "secrets.yaml") + if err := storage.WriteFile(secretsPath, []byte(initialYAML), 0600); err != nil { + t.Fatalf("setup failed: %v", err) + } + return NewManager(secretsPath) +} + func TestNewManager(t *testing.T) { - m := NewManager() + m := NewManager("/tmp/test-secrets.yaml") if m == nil || m.yq == nil { t.Fatal("NewManager returned nil or Manager.yq is nil") } } -// Test: EnsureSecretsFile creates secrets file with proper structure and permissions -func TestEnsureSecretsFile(t *testing.T) { - const instanceName = "test-instance" - tests := []struct { - name string - setupFunc func(t *testing.T, dataDir string) - wantErr bool - errContains string - }{ - { - name: "creates secrets file when not exists", - setupFunc: nil, - wantErr: false, - }, - { - name: "returns nil when secrets file exists", - setupFunc: func(t *testing.T, dataDir string) { - secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml") - if err := os.MkdirAll(filepath.Dir(secretsPath), 0755); err != nil { - t.Fatalf("setup mkdir failed: %v", err) - } - content := `# Wild Cloud Instance Secrets -cluster: - talosSecrets: "" - kubeconfig: "" -certManager: - cloudflare: - apiToken: "" -` - if err := storage.WriteFile(secretsPath, []byte(content), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - }, - wantErr: false, - }, - { - name: "corrects permissions on existing file", - setupFunc: func(t *testing.T, dataDir string) { - secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml") - if err := os.MkdirAll(filepath.Dir(secretsPath), 0755); err != nil { - t.Fatalf("setup mkdir failed: %v", err) - } - content := `# Wild Cloud Instance Secrets -cluster: - talosSecrets: "existing-secret" - kubeconfig: "" -certManager: - cloudflare: - apiToken: "" -` - // Create with wrong permissions - if err := storage.WriteFile(secretsPath, []byte(content), 0644); err != nil { - t.Fatalf("setup failed: %v", err) - } - }, - wantErr: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - dataDir := t.TempDir() - m := NewManager() - - if tt.setupFunc != nil { - tt.setupFunc(t, dataDir) - } - - err := m.EnsureSecretsFile(dataDir, instanceName) - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } else if tt.errContains != "" && !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - // Verify secrets file exists - secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml") - if !storage.FileExists(secretsPath) { - t.Error("secrets file not created") - } - - // Verify permissions are 0600 (secure) - info, err := os.Stat(secretsPath) - if err != nil { - t.Fatalf("failed to stat secrets file: %v", err) - } - if info.Mode().Perm() != 0600 { - t.Errorf("expected permissions 0600, got %o", info.Mode().Perm()) - } - - // Verify file has expected structure - content, err := storage.ReadFile(secretsPath) - if err != nil { - t.Fatalf("failed to read secrets: %v", err) - } - contentStr := string(content) - requiredFields := []string{"cluster:", "cloudflare:"} - for _, field := range requiredFields { - if !strings.Contains(contentStr, field) { - t.Errorf("secrets missing required field: %s", field) - } - } - }) - } -} - -// Test: GetSecret retrieves secrets correctly func TestGetSecret(t *testing.T) { tests := []struct { name string @@ -221,47 +85,45 @@ func TestGetSecret(t *testing.T) { }{ { name: "get simple string value", - secretsYAML: `cluster: - talosSecrets: "my-secret-value" + secretsYAML: `cloudflare: + apiToken: "my-secret-token" `, - key: "cluster.talosSecrets", - want: "my-secret-value", - wantErr: false, + key: "cloudflare.apiToken", + want: "my-secret-token", }, { - name: "get nested value with dot notation", - secretsYAML: `certManager: - cloudflare: - apiToken: "cf-token-12345" + name: "get nested value", + secretsYAML: `cloudflare: + nested: + deep: "deep-value" `, - key: "certManager.cloudflare.apiToken", - want: "cf-token-12345", - wantErr: false, + key: "cloudflare.nested.deep", + want: "deep-value", }, { - name: "get non-existent key returns error", - secretsYAML: `cluster: - talosSecrets: "value" + name: "non-existent key returns error", + secretsYAML: `cloudflare: + apiToken: "value" `, key: "nonexistent", wantErr: true, errContains: "secret not found", }, { - name: "get empty string value returns error", - secretsYAML: `cluster: - talosSecrets: "" + name: "empty string returns error", + secretsYAML: `cloudflare: + apiToken: "" `, - key: "cluster.talosSecrets", + key: "cloudflare.apiToken", wantErr: true, errContains: "secret not found", }, { - name: "get null value returns error", - secretsYAML: `cluster: - talosSecrets: null + name: "null value returns error", + secretsYAML: `cloudflare: + apiToken: null `, - key: "cluster.talosSecrets", + key: "cloudflare.apiToken", wantErr: true, errContains: "secret not found", }, @@ -269,15 +131,8 @@ func TestGetSecret(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") - - if err := storage.WriteFile(secretsPath, []byte(tt.secretsYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - - m := NewManager() - got, err := m.GetSecret(secretsPath, tt.key) + m := newTestManager(t, tt.secretsYAML) + got, err := m.GetSecret(tt.key) if tt.wantErr { if err == nil { @@ -292,7 +147,6 @@ func TestGetSecret(t *testing.T) { t.Errorf("unexpected error: %v", err) return } - if got != tt.want { t.Errorf("got %q, want %q", got, tt.want) } @@ -300,286 +154,142 @@ func TestGetSecret(t *testing.T) { } } -// Test: GetSecret error cases -func TestGetSecret_Errors(t *testing.T) { - tests := []struct { - name string - setupFunc func(t *testing.T) string - key string - errContains string - }{ - { - name: "non-existent file", - setupFunc: func(t *testing.T) string { - return filepath.Join(t.TempDir(), "nonexistent.yaml") - }, - key: "cluster.talosSecrets", - errContains: "secrets file not found", - }, - { - name: "malformed yaml", - setupFunc: func(t *testing.T) string { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") - content := `invalid: yaml: [[[` - if err := storage.WriteFile(secretsPath, []byte(content), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - return secretsPath - }, - key: "cluster.talosSecrets", - errContains: "getting secret", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - secretsPath := tt.setupFunc(t) - m := NewManager() - - _, err := m.GetSecret(secretsPath, tt.key) - if err == nil { - t.Error("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) - } - }) +func TestGetSecret_NonExistentFile(t *testing.T) { + m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml")) + _, err := m.GetSecret("some.key") + if err == nil { + t.Error("expected error, got nil") + } else if !strings.Contains(err.Error(), "secrets file not found") { + t.Errorf("error %q does not contain 'secrets file not found'", err.Error()) } } -// Test: GetSecret does not leak secrets in error messages func TestGetSecret_NoSecretLeakage(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") - secretValue := "super-secret-password-12345" - secretsYAML := `cluster: - talosSecrets: "` + secretValue + `" -` - if err := storage.WriteFile(secretsPath, []byte(secretsYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } + m := newTestManager(t, `cloudflare: + apiToken: "`+secretValue+`" +`) - m := NewManager() - - // Try to get a non-existent key - error should not contain actual secret values - _, err := m.GetSecret(secretsPath, "nonexistent.key") + _, err := m.GetSecret("nonexistent.key") if err == nil { t.Fatal("expected error, got nil") } - - // Error message should not contain the secret value if strings.Contains(err.Error(), secretValue) { t.Errorf("error message leaked secret value: %v", err) } } -// Test: SetSecret sets secrets correctly func TestSetSecret(t *testing.T) { tests := []struct { name string initialYAML string key string value string - verifyFunc func(t *testing.T, secretsPath string) + wantValue string }{ { name: "set simple value", - initialYAML: `cluster: - talosSecrets: "" + initialYAML: `cloudflare: + apiToken: "" `, - key: "cluster.talosSecrets", - value: "new-secret-value", - verifyFunc: func(t *testing.T, secretsPath string) { - m := NewManager() - got, err := m.GetSecret(secretsPath, "cluster.talosSecrets") - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if got != "new-secret-value" { - t.Errorf("got %q, want %q", got, "new-secret-value") - } - }, - }, - { - name: "set nested value", - initialYAML: `certManager: - cloudflare: - apiToken: "" -`, - key: "certManager.cloudflare.apiToken", - value: "cf-token-xyz", - verifyFunc: func(t *testing.T, secretsPath string) { - m := NewManager() - got, err := m.GetSecret(secretsPath, "certManager.cloudflare.apiToken") - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if got != "cf-token-xyz" { - t.Errorf("got %q, want %q", got, "cf-token-xyz") - } - }, + key: "cloudflare.apiToken", + value: "new-token", + wantValue: "new-token", }, { name: "update existing value", - initialYAML: `cluster: - talosSecrets: "old-secret" + initialYAML: `cloudflare: + apiToken: "old-token" `, - key: "cluster.talosSecrets", - value: "new-secret", - verifyFunc: func(t *testing.T, secretsPath string) { - m := NewManager() - got, err := m.GetSecret(secretsPath, "cluster.talosSecrets") - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if got != "new-secret" { - t.Errorf("got %q, want %q", got, "new-secret") - } - }, + key: "cloudflare.apiToken", + value: "new-token", + wantValue: "new-token", }, { name: "create new nested path", - initialYAML: `cluster: {} + initialYAML: `cloudflare: {} `, - key: "cluster.newSecret", - value: "newValue", - verifyFunc: func(t *testing.T, secretsPath string) { - m := NewManager() - got, err := m.GetSecret(secretsPath, "cluster.newSecret") - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if got != "newValue" { - t.Errorf("got %q, want %q", got, "newValue") - } - }, + key: "cloudflare.newSecret", + value: "newValue", + wantValue: "newValue", }, { - name: "set value with special characters", - initialYAML: `cluster: - talosSecrets: "" + name: "value with special characters", + initialYAML: `cloudflare: + apiToken: "" `, - key: "cluster.talosSecrets", - value: `special"quotes'and\backslashes`, - verifyFunc: func(t *testing.T, secretsPath string) { - m := NewManager() - got, err := m.GetSecret(secretsPath, "cluster.talosSecrets") - if err != nil { - t.Fatalf("verify failed: %v", err) - } - if got != `special"quotes'and\backslashes` { - t.Errorf("got %q, want %q", got, `special"quotes'and\backslashes`) - } - }, + key: "cloudflare.apiToken", + value: `special"quotes'and\backslashes`, + wantValue: `special"quotes'and\backslashes`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") + m := newTestManager(t, tt.initialYAML) - if err := storage.WriteFile(secretsPath, []byte(tt.initialYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - - m := NewManager() - if err := m.SetSecret(secretsPath, tt.key, tt.value); err != nil { + if err := m.SetSecret(tt.key, tt.value); err != nil { t.Errorf("SetSecret failed: %v", err) return } - // Verify the value was set correctly - tt.verifyFunc(t, secretsPath) + got, err := m.GetSecret(tt.key) + if err != nil { + t.Fatalf("verify failed: %v", err) + } + if got != tt.wantValue { + t.Errorf("got %q, want %q", got, tt.wantValue) + } - // Verify permissions remain secure (0600) - info, err := os.Stat(secretsPath) + // Verify permissions remain 0600 + info, err := os.Stat(m.secretsPath) if err != nil { t.Fatalf("failed to stat secrets file: %v", err) } if info.Mode().Perm() != 0600 { - t.Errorf("permissions changed after SetSecret: got %o, want 0600", info.Mode().Perm()) + t.Errorf("permissions changed: got %o, want 0600", info.Mode().Perm()) } }) } } -// Test: SetSecret error cases -func TestSetSecret_Errors(t *testing.T) { - tests := []struct { - name string - setupFunc func(t *testing.T) string - key string - value string - errContains string - }{ - { - name: "non-existent file", - setupFunc: func(t *testing.T) string { - return filepath.Join(t.TempDir(), "nonexistent.yaml") - }, - key: "cluster.talosSecrets", - value: "secret", - errContains: "secrets file not found", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - secretsPath := tt.setupFunc(t) - m := NewManager() - - err := m.SetSecret(secretsPath, tt.key, tt.value) - if err == nil { - t.Error("expected error, got nil") - } else if !strings.Contains(err.Error(), tt.errContains) { - t.Errorf("error %q does not contain %q", err.Error(), tt.errContains) - } - }) +func TestSetSecret_NonExistentFile(t *testing.T) { + m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml")) + err := m.SetSecret("some.key", "value") + if err == nil { + t.Error("expected error, got nil") + } else if !strings.Contains(err.Error(), "secrets file not found") { + t.Errorf("error %q does not contain 'secrets file not found'", err.Error()) } } -// Test: SetSecret with concurrent access func TestSetSecret_ConcurrentAccess(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") + m := newTestManager(t, `counter: "0" +`) - initialYAML := `counter: "0" -` - if err := storage.WriteFile(secretsPath, []byte(initialYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - - m := NewManager() const numGoroutines = 10 - var wg sync.WaitGroup - errors := make(chan error, numGoroutines) + errs := make(chan error, numGoroutines) - // Launch multiple goroutines trying to write different values for i := 0; i < numGoroutines; i++ { wg.Add(1) go func(val int) { defer wg.Done() - key := "counter" value := string(rune('0' + val)) - if err := m.SetSecret(secretsPath, key, value); err != nil { - errors <- err + if err := m.SetSecret("counter", value); err != nil { + errs <- err } }(i) } wg.Wait() - close(errors) + close(errs) - // Check if any errors occurred - for err := range errors { + for err := range errs { t.Errorf("concurrent write error: %v", err) } - // Verify permissions remain secure after concurrent access - info, err := os.Stat(secretsPath) + info, err := os.Stat(m.secretsPath) if err != nil { t.Fatalf("failed to stat secrets file: %v", err) } @@ -587,8 +297,7 @@ func TestSetSecret_ConcurrentAccess(t *testing.T) { t.Errorf("permissions changed after concurrent writes: got %o, want 0600", info.Mode().Perm()) } - // Verify we can read a value (should be one of the written values) - value, err := m.GetSecret(secretsPath, "counter") + value, err := m.GetSecret("counter") if err != nil { t.Errorf("failed to read value after concurrent writes: %v", err) } @@ -597,377 +306,157 @@ func TestSetSecret_ConcurrentAccess(t *testing.T) { } } -// Test: EnsureSecret generates and sets secret only when not set -func TestEnsureSecret(t *testing.T) { - tests := []struct { - name string - initialYAML string - key string - length int - expectNew bool - }{ - { - name: "generates secret when empty string", - initialYAML: `cluster: - talosSecrets: "" -`, - key: "cluster.talosSecrets", - length: 32, - expectNew: true, - }, - { - name: "generates secret when null", - initialYAML: `cluster: - talosSecrets: null -`, - key: "cluster.talosSecrets", - length: 32, - expectNew: true, - }, - { - name: "does not generate when secret exists", - initialYAML: `cluster: - talosSecrets: "existing-secret" -`, - key: "cluster.talosSecrets", - length: 32, - expectNew: false, - }, - { - name: "generates secret for non-existent key", - initialYAML: `cluster: {} -`, - key: "cluster.newSecret", - length: 64, - expectNew: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") - - if err := storage.WriteFile(secretsPath, []byte(tt.initialYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - - m := NewManager() - - // Get initial value if exists - initialVal, _ := m.GetSecret(secretsPath, tt.key) - - // Call EnsureSecret - secret, err := m.EnsureSecret(secretsPath, tt.key, tt.length) - if err != nil { - t.Errorf("EnsureSecret failed: %v", err) - return - } - - // Verify secret is returned - if secret == "" { - t.Error("EnsureSecret returned empty secret") - } - - // Verify length - if tt.expectNew && len(secret) != tt.length { - t.Errorf("expected secret length %d, got %d", tt.length, len(secret)) - } - - // Get final value - finalVal, err := m.GetSecret(secretsPath, tt.key) - if err != nil { - t.Fatalf("GetSecret failed: %v", err) - } - - if tt.expectNew { - // Should have generated new secret - if initialVal != "" && finalVal == initialVal { - t.Errorf("expected new secret, got same value: %q", finalVal) - } - } else { - // Should have kept existing secret - if finalVal != initialVal { - t.Errorf("expected to keep existing secret %q, got %q", initialVal, finalVal) - } - } - - // Call EnsureSecret again - should be idempotent - secret2, err := m.EnsureSecret(secretsPath, tt.key, tt.length) - if err != nil { - t.Errorf("second EnsureSecret failed: %v", err) - return - } - - // Secret should not change on second call - if secret2 != secret { - t.Errorf("secret changed on second ensure: %q -> %q", secret, secret2) - } - }) - } -} - -// Test: GenerateAndStoreSecret convenience function -func TestGenerateAndStoreSecret(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") - - initialYAML := `cluster: - talosSecrets: "" -` - if err := storage.WriteFile(secretsPath, []byte(initialYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - - m := NewManager() - - // Generate and store secret - secret, err := m.GenerateAndStoreSecret(secretsPath, "cluster.talosSecrets") - if err != nil { - t.Fatalf("GenerateAndStoreSecret failed: %v", err) - } - - // Verify secret length matches default - if len(secret) != DefaultSecretLength { - t.Errorf("expected length %d, got %d", DefaultSecretLength, len(secret)) - } - - // Verify secret is stored - stored, err := m.GetSecret(secretsPath, "cluster.talosSecrets") - if err != nil { - t.Fatalf("GetSecret failed: %v", err) - } - - if stored != secret { - t.Errorf("stored secret %q does not match returned secret %q", stored, secret) - } -} - -// Test: DeleteSecret removes secrets correctly func TestDeleteSecret(t *testing.T) { - tests := []struct { - name string - initialYAML string - key string - wantErr bool - }{ - { - name: "delete existing secret", - initialYAML: `cluster: - talosSecrets: "secret-to-delete" - kubeconfig: "other-secret" -`, - key: "cluster.talosSecrets", - wantErr: false, - }, - { - name: "delete nested secret", - initialYAML: `certManager: - cloudflare: - apiToken: "token-to-delete" -`, - key: "certManager.cloudflare.apiToken", - wantErr: false, - }, + m := newTestManager(t, `cloudflare: + apiToken: "token-to-delete" + zoneId: "keep-this" +`) + + // Verify secret exists + _, err := m.GetSecret("cloudflare.apiToken") + if err != nil { + t.Fatalf("secret should exist before deletion: %v", err) } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "secrets.yaml") - - if err := storage.WriteFile(secretsPath, []byte(tt.initialYAML), 0600); err != nil { - t.Fatalf("setup failed: %v", err) - } - - m := NewManager() - - // Verify secret exists before deletion - _, err := m.GetSecret(secretsPath, tt.key) - if err != nil { - t.Fatalf("secret should exist before deletion: %v", err) - } - - // Delete secret - err = m.DeleteSecret(secretsPath, tt.key) - if tt.wantErr { - if err == nil { - t.Error("expected error, got nil") - } - return - } - - if err != nil { - t.Errorf("unexpected error: %v", err) - return - } - - // Verify secret no longer exists - _, err = m.GetSecret(secretsPath, tt.key) - if err == nil { - t.Error("secret should not exist after deletion") - } - - // Verify permissions remain secure (0600) - info, err := os.Stat(secretsPath) - if err != nil { - t.Fatalf("failed to stat secrets file: %v", err) - } - if info.Mode().Perm() != 0600 { - t.Errorf("permissions changed after DeleteSecret: got %o, want 0600", info.Mode().Perm()) - } - }) - } -} - -// Test: DeleteSecret error cases -func TestDeleteSecret_Errors(t *testing.T) { - t.Run("non-existent file", func(t *testing.T) { - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "nonexistent.yaml") - - m := NewManager() - err := m.DeleteSecret(secretsPath, "cluster.talosSecrets") - - if err == nil { - t.Error("expected error, got nil") - } else if !strings.Contains(err.Error(), "secrets file not found") { - t.Errorf("error %q does not contain 'secrets file not found'", err.Error()) - } - }) -} - -// Test: File permissions are always 0600 -func TestEnsureSecretsFile_FilePermissions(t *testing.T) { - const instanceName = "test-instance" - tempDir := t.TempDir() - m := NewManager() - - if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil { - t.Fatalf("EnsureSecretsFile failed: %v", err) + if err := m.DeleteSecret("cloudflare.apiToken"); err != nil { + t.Errorf("DeleteSecret failed: %v", err) + return } - secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml") - info, err := os.Stat(secretsPath) + // Verify deleted + _, err = m.GetSecret("cloudflare.apiToken") + if err == nil { + t.Error("secret should not exist after deletion") + } + + // Verify other secret untouched + kept, err := m.GetSecret("cloudflare.zoneId") + if err != nil { + t.Errorf("other secret should still exist: %v", err) + } + if kept != "keep-this" { + t.Errorf("other secret changed: got %q", kept) + } + + // Verify permissions + info, err := os.Stat(m.secretsPath) if err != nil { t.Fatalf("failed to stat secrets file: %v", err) } - - // Verify file has 0600 permissions (read/write for owner only) if info.Mode().Perm() != 0600 { - t.Errorf("expected permissions 0600, got %o", info.Mode().Perm()) + t.Errorf("permissions changed: got %o, want 0600", info.Mode().Perm()) } } -// Test: Idempotent secrets creation -func TestEnsureSecretsFile_Idempotent(t *testing.T) { - const instanceName = "test-instance" - tempDir := t.TempDir() - m := NewManager() - - // First call creates secrets - if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil { - t.Fatalf("first EnsureSecretsFile failed: %v", err) - } - - secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml") - firstContent, err := storage.ReadFile(secretsPath) - if err != nil { - t.Fatalf("failed to read secrets: %v", err) - } - - // Second call should not modify secrets - if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil { - t.Fatalf("second EnsureSecretsFile failed: %v", err) - } - - secondContent, err := storage.ReadFile(secretsPath) - if err != nil { - t.Fatalf("failed to read secrets: %v", err) - } - - if string(firstContent) != string(secondContent) { - t.Error("secrets content changed on second call") +func TestDeleteSecret_NonExistentFile(t *testing.T) { + m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml")) + err := m.DeleteSecret("some.key") + if err == nil { + t.Error("expected error, got nil") + } else if !strings.Contains(err.Error(), "secrets file not found") { + t.Errorf("error %q does not contain 'secrets file not found'", err.Error()) } } -// Test: Secrets structure contains required fields -func TestEnsureSecretsFile_RequiredFields(t *testing.T) { - const instanceName = "test-instance" - tempDir := t.TempDir() - m := NewManager() +func TestGetAll(t *testing.T) { + m := newTestManager(t, `cloudflare: + apiToken: "my-token" + zoneId: "zone-123" +topLevel: "value" +`) - if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil { - t.Fatalf("EnsureSecretsFile failed: %v", err) - } - - secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml") - content, err := storage.ReadFile(secretsPath) + all, err := m.GetAll() if err != nil { - t.Fatalf("failed to read secrets: %v", err) + t.Fatalf("GetAll failed: %v", err) } - contentStr := string(content) - requiredFields := []string{ - "cluster:", - "talosSecrets:", - "kubeconfig:", - "cloudflare:", - "token:", + if all["topLevel"] != "value" { + t.Errorf("expected topLevel=value, got %v", all["topLevel"]) } - for _, field := range requiredFields { - if !strings.Contains(contentStr, field) { - t.Errorf("secrets missing required field: %s", field) - } + cf, ok := all["cloudflare"].(map[string]any) + if !ok { + t.Fatalf("expected cloudflare to be a map, got %T", all["cloudflare"]) } - - // Verify warning comment exists - if !strings.Contains(contentStr, "WARNING") || !strings.Contains(contentStr, "sensitive") { - t.Error("secrets file missing security warning comment") + if cf["apiToken"] != "my-token" { + t.Errorf("expected apiToken=my-token, got %v", cf["apiToken"]) } } -// Test: Secrets are more restrictive than config -func TestSecretsPermissions_MoreRestrictiveThanConfig(t *testing.T) { - const instanceName = "test-instance" - tempDir := t.TempDir() - secretsPath := filepath.Join(tempDir, "instances", instanceName, "config", "secrets.yaml") - configPath := filepath.Join(tempDir, "config.yaml") - - // Create secrets file - m := NewManager() - if err := m.EnsureSecretsFile(tempDir, instanceName); err != nil { - t.Fatalf("EnsureSecretsFile failed: %v", err) - } - - // Create config file (typically 0644) - configContent := `baseDomain: "example.com"` - if err := storage.WriteFile(configPath, []byte(configContent), 0644); err != nil { - t.Fatalf("failed to write config: %v", err) - } - - // Check permissions - secretsInfo, err := os.Stat(secretsPath) +func TestGetAll_NonExistentFile(t *testing.T) { + m := NewManager(filepath.Join(t.TempDir(), "nonexistent.yaml")) + all, err := m.GetAll() if err != nil { - t.Fatalf("failed to stat secrets: %v", err) + t.Fatalf("expected no error for missing file, got: %v", err) } - - configInfo, err := os.Stat(configPath) - if err != nil { - t.Fatalf("failed to stat config: %v", err) - } - - secretsPerm := secretsInfo.Mode().Perm() - configPerm := configInfo.Mode().Perm() - - // Secrets (0600) should be more restrictive than config (0644) - if secretsPerm >= configPerm { - t.Errorf("secrets permissions %o should be more restrictive than config %o", secretsPerm, configPerm) - } - - // Secrets should not be group or world readable - if secretsPerm&0077 != 0 { - t.Errorf("secrets file should not have group/world permissions, got %o", secretsPerm) + if len(all) != 0 { + t.Errorf("expected empty map, got %v", all) + } +} + +func TestMergeUpdate(t *testing.T) { + m := newTestManager(t, `cloudflare: + apiToken: "original" +existing: "keep" +`) + + err := m.MergeUpdate(map[string]any{ + "cloudflare": map[string]any{ + "apiToken": "updated", + }, + "newKey": "newValue", + }) + if err != nil { + t.Fatalf("MergeUpdate failed: %v", err) + } + + all, err := m.GetAll() + if err != nil { + t.Fatalf("GetAll failed: %v", err) + } + + if all["existing"] != "keep" { + t.Errorf("existing key changed: got %v", all["existing"]) + } + if all["newKey"] != "newValue" { + t.Errorf("expected newKey=newValue, got %v", all["newKey"]) + } + + cf := all["cloudflare"].(map[string]any) + if cf["apiToken"] != "updated" { + t.Errorf("expected apiToken=updated, got %v", cf["apiToken"]) + } + + // Verify permissions + info, err := os.Stat(m.secretsPath) + if err != nil { + t.Fatalf("failed to stat: %v", err) + } + if info.Mode().Perm() != 0600 { + t.Errorf("permissions changed: got %o, want 0600", info.Mode().Perm()) + } +} + +func TestMergeUpdate_CreatesFileContent(t *testing.T) { + // Start with empty file + m := newTestManager(t, ``) + + err := m.MergeUpdate(map[string]any{ + "cloudflare": map[string]any{ + "apiToken": "new-token", + }, + }) + if err != nil { + t.Fatalf("MergeUpdate failed: %v", err) + } + + all, err := m.GetAll() + if err != nil { + t.Fatalf("GetAll failed: %v", err) + } + + cf := all["cloudflare"].(map[string]any) + if cf["apiToken"] != "new-token" { + t.Errorf("expected apiToken=new-token, got %v", cf["apiToken"]) } } diff --git a/web/src/components/DashboardComponent.tsx b/web/src/components/DashboardComponent.tsx index 6d3d446..f825ada 100644 --- a/web/src/components/DashboardComponent.tsx +++ b/web/src/components/DashboardComponent.tsx @@ -7,13 +7,13 @@ import { Alert, AlertDescription } from './ui/alert'; import { Badge } from './ui/badge'; import { LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, - RefreshCw, BookOpen, Globe, Shield, Router, Server, RotateCw, Key, Plus, X, + RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X, } from 'lucide-react'; import { useCloudflare } from '../hooks/useCloudflare'; import { useDdns } from '../hooks/useDdns'; import { useCentralStatus } from '../hooks/useCentralStatus'; -import { useCert } from '../hooks/useCert'; import { useConfig } from '../hooks'; +import { useVpn } from '../hooks/useVpn'; import { secretsApi } from '../services/api'; import { usePageHelp } from '../hooks/usePageHelp'; import type { DdnsRecordStatus } from '../services/api/networking'; @@ -22,8 +22,8 @@ export function DashboardComponent() { const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { verification, isLoading: cfLoading } = useCloudflare(); const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns(); - const { status: certStatus } = useCert(); const { config: globalConfig, updateConfig } = useConfig(); + const { config: vpnConfig } = useVpn(); const queryClient = useQueryClient(); const { data: globalSecrets } = useQuery({ @@ -52,10 +52,8 @@ export function DashboardComponent() { // Warning conditions const cfTokenMissing = !cfLoading && !verification?.tokenConfigured; const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid; - const certExpiring = certStatus?.certs?.some(c => c.cert.exists && (c.cert.daysLeft ?? 999) < 14); - const certMissing = certStatus?.certs?.some(c => !c.cert.exists); const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError; - const hasWarnings = cfTokenMissing || cfTokenInvalid || certExpiring || certMissing || ddnsFailed; + const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed; usePageHelp({ title: 'What is the Dashboard?', @@ -103,18 +101,6 @@ export function DashboardComponent() { Cloudflare API token is invalid. Check your token in Cloudflare settings. )} - {certMissing && ( - - - One or more TLS certificates are missing. Services may not be reachable over HTTPS. - - )} - {certExpiring && !certMissing && ( - - - A TLS certificate expires within 14 days. Consider renewing soon. - - )} {ddnsFailed && ( @@ -426,62 +412,39 @@ export function DashboardComponent() { )} - {/* 4. TLS Certificates */} - {certStatus?.certs && certStatus.certs.length > 0 && ( - -
-
- -
TLS Certificates
-
- {(() => { - const allExist = certStatus.certs!.every(c => c.cert.exists); - const someExist = certStatus.certs!.some(c => c.cert.exists); - if (allExist) { - return All valid; - } else if (someExist) { - return Partial; - } else { - return Missing; - } - })()} -
-
- {certStatus.certs!.map((entry) => ( -
- {entry.domain} - {entry.cert.exists ? ( - - Valid ({entry.cert.daysLeft}d) - - ) : ( - Missing - )} -
- ))} -
-
- )} - - {/* 5. Gateway Router */} + {/* 4. Gateway Router */}
Gateway Router
-

Ensure your router is configured for Wild Central to work correctly:

+

Your LAN router should be configured to work with Wild Central:

  • - Forward DNS to Wild Central + Set the router's DNS server to Wild Central {globalConfig?.cloud?.dnsmasq?.ip && ( {globalConfig.cloud.dnsmasq.ip} )}
  • -
  • Forward ports 443, 80, 51820 to Wild Central
  • -
  • Set Wild Central as the primary DNS server for your LAN
  • +
  • + Port-forward to Wild Central:{' '} + {[ + { port: 443, proto: 'TCP', label: 'HTTPS' }, + { port: 80, proto: 'TCP', label: 'HTTP' }, + ...(vpnConfig?.enabled ? [{ port: vpnConfig.listenPort || 51820, proto: 'UDP', label: 'VPN' }] : []), + ...((globalConfig?.cloud?.nftables?.extraPorts ?? []) as { port: number; protocol?: string; label?: string }[]) + .map(ep => ({ port: ep.port, proto: (ep.protocol || 'tcp').toUpperCase(), label: ep.label || '' })), + ].map((p, i) => ( + + {i > 0 && ', '} + {p.port}/{p.proto} + {p.label && ({p.label})} + + ))} +
diff --git a/web/src/components/ServicesComponent.tsx b/web/src/components/ServicesComponent.tsx index d7c3de6..8bb670b 100644 --- a/web/src/components/ServicesComponent.tsx +++ b/web/src/components/ServicesComponent.tsx @@ -9,13 +9,14 @@ import { Switch } from './ui/switch'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, - Plus, Trash2, X, ChevronDown, ChevronUp, + Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText, } from 'lucide-react'; import { useHaproxy } from '../hooks/useHaproxy'; import { useServices } from '../hooks/useServices'; import { useCert } from '../hooks/useCert'; import { usePageHelp } from '../hooks/usePageHelp'; import type { RegisteredService } from '../services/api/networking'; +import type { CertEntry } from '../services/api/cert'; import { servicesApi, haproxyApi } from '../services/api/networking'; function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) { @@ -30,8 +31,43 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st } function getTlsStatus(service: RegisteredService, certDomains: Set): 'ok' | 'error' | 'na' { - if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na'; - return certDomains.has(service.domain) ? 'ok' : 'error'; + const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type; + if (service.tls === 'passthrough' || backendType === 'tcp-passthrough') return 'na'; + // Check exact match or wildcard coverage + if (certDomains.has(service.domain)) return 'ok'; + const dotIdx = service.domain.indexOf('.'); + if (dotIdx > 0) { + const parent = service.domain.slice(dotIdx + 1); + if (certDomains.has(parent)) return 'ok'; + } + return 'error'; +} + +function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined { + // Exact match first + const exact = certs.find(c => c.cert.exists && c.domain === domain); + if (exact) return exact; + // Wildcard: check parent domain + const dotIdx = domain.indexOf('.'); + if (dotIdx > 0) { + const parent = domain.slice(dotIdx + 1); + return certs.find(c => c.cert.exists && c.domain === parent); + } + return undefined; +} + +function hasRoutes(service: RegisteredService): boolean { + return (service.routes?.length ?? 0) > 0; +} + +function getBackendAddress(service: RegisteredService): string { + if (hasRoutes(service)) return service.routes![0].backend.address; + return service.backend.address; +} + +function getBackendType(service: RegisteredService): string { + if (hasRoutes(service)) return service.routes![0].backend.type; + return service.backend.type; } export function ServicesComponent() { @@ -43,7 +79,17 @@ export function ServicesComponent() { restart: restartHaproxy, isRestarting: isHaproxyRestarting, } = useHaproxy(); const { services, isLoading: isServicesLoading } = useServices(); - const { status: certStatus } = useCert(); + const { status: certStatus, provision: provisionCert, isProvisioning, renew: renewCert, isRenewing } = useCert(); + + const provisionMutation = useMutation({ + mutationFn: (domain: string) => provisionCert(domain), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }), + }); + + const renewMutation = useMutation({ + mutationFn: () => renewCert(), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }), + }); const [expandedDomains, setExpandedDomains] = useState>(new Set()); @@ -197,7 +243,8 @@ export function ServicesComponent() { const isExpanded = expandedDomains.has(service.domain); const tlsStatus = getTlsStatus(service, certDomains); const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const; - const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough'; + const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough'; + const routeCount = service.routes?.length ?? 0; return ( @@ -208,6 +255,9 @@ export function ServicesComponent() { {service.subdomains ? `*.${service.domain}` : service.domain} + {routeCount > 1 && ( + {routeCount} routes + )}
@@ -223,22 +273,24 @@ export function ServicesComponent() { {/* Controls */}
-
- - { - const val = e.target.value.trim(); - if (val && val !== service.backend.address) { - updateMutation.mutate({ - domain: service.domain, - updates: { backend: { ...service.backend, address: val } }, - }); - } - }} - /> -
+ {!hasRoutes(service) && ( +
+ + { + const val = e.target.value.trim(); + if (val && val !== service.backend.address) { + updateMutation.mutate({ + domain: service.domain, + updates: { backend: { ...service.backend, address: val } }, + }); + } + }} + /> +
+ )}
updateMutation.mutate({ - domain: service.domain, - updates: { - tls: v ? 'terminate' : 'passthrough', - backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' }, - }, - })} + onCheckedChange={v => { + if (hasRoutes(service)) { + updateMutation.mutate({ + domain: service.domain, + updates: { tls: v ? 'terminate' : 'passthrough' }, + }); + } else { + updateMutation.mutate({ + domain: service.domain, + updates: { + tls: v ? 'terminate' : 'passthrough', + backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' }, + }, + }); + } + }} disabled={updateMutation.isPending} />
+ {/* Routes detail */} + {hasRoutes(service) && ( +
+ + {service.routes!.map((route, i) => ( +
+
+ + {route.paths?.length ? route.paths.join(', ') : '/*'} + + { + const val = e.target.value.trim(); + if (val && val !== route.backend.address) { + const updatedRoutes = [...service.routes!]; + updatedRoutes[i] = { ...route, backend: { ...route.backend, address: val } }; + servicesApi.register({ ...service, backend: undefined as any, routes: updatedRoutes } as any) + .then(() => queryClient.invalidateQueries({ queryKey: ['services'] })); + } + }} + /> +
+ + {route.headers && Object.keys(route.headers.response ?? {}).length > 0 && ( +
+ + Response headers + + {Object.entries(route.headers.response!).map(([k, v]) => ( +
+ {k}: {v} +
+ ))} +
+ )} + + {route.headers && Object.keys(route.headers.request ?? {}).length > 0 && ( +
+ + Request headers + + {Object.entries(route.headers.request!).map(([k, v]) => ( +
+ {k}: {v} +
+ ))} +
+ )} + + {route.ipAllow && route.ipAllow.length > 0 && ( +
+ + IP allow: {route.ipAllow.join(', ')} + +
+ )} +
+ ))} +
+ )} + + {/* TLS cert info */} + {!isPassthrough && (() => { + const cert = findCert(service.domain, certStatus?.certs ?? []); + if (!cert) { + const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain; + return ( +
+
+ + No TLS certificate +
+ +
+ ); + } + const isWildcard = cert.domain !== service.domain; + const daysLeft = cert.cert.daysLeft ?? 0; + const expiry = cert.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : 'unknown'; + return ( +
+
+ + + {isWildcard ? `*.${cert.domain}` : cert.domain} + + Expires {expiry} ({daysLeft}d) + {cert.cert.issuerCN && {cert.cert.issuerCN}} +
+ {daysLeft < 30 && ( + + )} +
+ ); + })()} + {/* Source + deregister */}
Source: {service.source} diff --git a/web/src/services/api/networking.ts b/web/src/services/api/networking.ts index 13e0135..9d95809 100644 --- a/web/src/services/api/networking.ts +++ b/web/src/services/api/networking.ts @@ -2,6 +2,22 @@ import { apiClient } from './client'; // Services +export interface HeaderConfig { + request?: Record; + response?: Record; +} + +export interface Route { + paths?: string[]; + backend: { + address: string; + type: string; + health?: string; + }; + headers?: HeaderConfig; + ipAllow?: string[]; +} + export interface RegisteredService { domain: string; source: string; @@ -13,6 +29,7 @@ export interface RegisteredService { }; public: boolean; tls?: string; + routes?: Route[]; } export const servicesApi = {