diff --git a/internal/api/v1/handlers_dnsmasq.go b/internal/api/v1/handlers_dnsmasq.go index a6ce875..7b3c595 100644 --- a/internal/api/v1/handlers_dnsmasq.go +++ b/internal/api/v1/handlers_dnsmasq.go @@ -60,7 +60,7 @@ func (api *API) DnsmasqRestart(w http.ResponseWriter, r *http.Request) { // DnsmasqGenerate generates the dnsmasq configuration from global config. // Query param ?overwrite=true will write the config and restart the service. -// Instance-specific DNS records are managed via service registration (not here). +// Domain-specific DNS records are managed via domain registration and reconciliation. func (api *API) DnsmasqGenerate(w http.ResponseWriter, r *http.Request) { overwrite := r.URL.Query().Get("overwrite") == "true" @@ -172,7 +172,7 @@ func writeConfigFile(path, content string) error { } // updateDnsmasqConfig regenerates the main dnsmasq config from global config and restarts. -// Instance-specific DNS records are managed via service registration (not here). +// Domain-specific DNS records are managed via domain registration and reconciliation. func (api *API) updateDnsmasqConfig() error { globalConfigPath := api.statePath() globalCfg, err := config.LoadState(globalConfigPath) diff --git a/internal/api/v1/middleware.go b/internal/api/v1/middleware.go index 5bdca2b..18e7611 100644 --- a/internal/api/v1/middleware.go +++ b/internal/api/v1/middleware.go @@ -62,14 +62,8 @@ func RequestLoggingMiddleware(next http.Handler) http.Handler { // Add route params if present vars := mux.Vars(r) - if name := vars["name"]; name != "" { - attrs = append(attrs, "instance", name) - } - if app := vars["app"]; app != "" { - attrs = append(attrs, "app", app) - } - if node := vars["node"]; node != "" { - attrs = append(attrs, "node", node) + if domain := vars["domain"]; domain != "" { + attrs = append(attrs, "domain", domain) } if sw.status >= 400 { diff --git a/internal/certbot/manager.go b/internal/certbot/manager.go index 8c77840..5a9d183 100644 --- a/internal/certbot/manager.go +++ b/internal/certbot/manager.go @@ -13,6 +13,7 @@ import ( type CertStatus struct { Exists bool `json:"exists"` Domain string `json:"domain"` + Wildcard bool `json:"wildcard,omitempty"` Expiry time.Time `json:"expiry,omitempty"` CertPath string `json:"certPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` @@ -118,8 +119,8 @@ func (m *Manager) GetStatus(domain string) *CertStatus { status.CertPath = pemPath status.KeyPath = pemPath - // Parse expiry from the certificate - cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer") + // Parse certificate metadata + cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer", "-subject") out, err := cmd.Output() if err != nil { return status @@ -140,6 +141,11 @@ func (m *Manager) GetStatus(domain string) *CertStatus { if strings.HasPrefix(line, "issuer=") { status.IssuerCN = strings.TrimPrefix(line, "issuer=") } + if strings.HasPrefix(line, "subject=") { + if strings.Contains(line, "CN = *.") { + status.Wildcard = true + } + } } return status diff --git a/internal/config/config.go b/internal/config/config.go index 7c20dec..4f2f9a5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -147,13 +147,4 @@ func SaveState(config *State, configPath string) error { return os.WriteFile(configPath, data, 0644) } -// IsEmpty checks if the configuration is empty or uninitialized -func (c *State) IsEmpty() bool { - if c == nil { - return true - } - - // Check if essential fields are empty - return c.Cloud.Central.Domain == "" && c.Operator.Email == "" -} diff --git a/internal/config/config_test.go b/internal/config/config_test.go index cff8963..e988914 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -236,63 +236,6 @@ func TestSaveState_CreatesDirectory(t *testing.T) { } } -// Test: State.IsEmpty checks if config is empty -func TestState_IsEmpty(t *testing.T) { - tests := []struct { - name string - config *State - want bool - }{ - { - name: "nil config is empty", - config: nil, - want: true, - }, - { - name: "default config is empty", - config: &State{}, - want: true, - }, - { - name: "config with only central domain is not empty", - config: func() *State { - cfg := &State{} - cfg.Cloud.Central.Domain = "central.example.com" - return cfg - }(), - want: false, - }, - { - name: "config with only operator email is not empty", - config: func() *State { - cfg := &State{} - cfg.Operator.Email = "admin@example.com" - return cfg - }(), - want: false, - }, - { - name: "config with all fields is not empty", - config: func() *State { - cfg := &State{} - cfg.Cloud.Central.Domain = "central.example.com" - cfg.Operator.Email = "admin@example.com" - return cfg - }(), - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := tt.config.IsEmpty() - if got != tt.want { - t.Errorf("IsEmpty() = %v, want %v", got, tt.want) - } - }) - } -} - // Test: Round-trip save and load preserves data func TestState_RoundTrip(t *testing.T) { tempDir := t.TempDir() diff --git a/internal/dnsmasq/config.go b/internal/dnsmasq/config.go index 9475a26..7b5933b 100644 --- a/internal/dnsmasq/config.go +++ b/internal/dnsmasq/config.go @@ -100,19 +100,6 @@ log-dhcp ) } -// WriteConfig writes the dnsmasq configuration to the specified path -func (g *ConfigGenerator) WriteConfig(cfg *config.State, entries []DNSEntry, configPath string) error { - configContent := g.Generate(cfg, entries) - - slog.Info("writing dnsmasq config", "component", "dnsmasq", "path", configPath) - - if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil { - return fmt.Errorf("writing dnsmasq config: %w", err) - } - - return nil -} - // RestartService reloads the dnsmasq service (SIGHUP — no downtime). // Falls back to restart if reload fails. func (g *ConfigGenerator) RestartService() error { @@ -223,6 +210,95 @@ func (g *ConfigGenerator) UpdateConfig(cfg *config.State, entries []DNSEntry, re return nil } +// GenerateMainConfig creates the main dnsmasq configuration with global settings. +// Used by the DnsmasqGenerate handler to preview/apply the base config independently +// of domain-specific DNS entries (which are handled by Generate + UpdateConfig). +func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string { + dnsIP, err := network.GetWildCentralIP() + if err != nil { + slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err) + dnsIP = "" + } + + var sb strings.Builder + + fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main) +# This file contains global settings. Domain-specific DNS entries are +# generated by reconciliation into the monolithic config. + +# Basic Settings +listen-address=%s +bind-interfaces +domain-needed +bogus-priv +no-resolv + +# Upstream DNS servers +server=1.1.1.1 +server=8.8.8.8 + +# Logging +log-queries +log-dhcp +`, dnsIP) + + // DHCP section (only when explicitly enabled) + if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled { + dhcp := cfg.Cloud.Dnsmasq.DHCP + sb.WriteString("\n# DHCP Configuration\n") + + leaseTime := dhcp.LeaseTime + if leaseTime == "" { + leaseTime = "24h" + } + fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime) + + gateway := dhcp.Gateway + if gateway != "" { + fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) + } + if dnsIP != "" { + fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP) + } + + for _, lease := range dhcp.StaticLeases { + if lease.Hostname != "" { + fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname) + } else { + fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP) + } + } + } + + return sb.String() +} + +// ValidateConfig tests a dnsmasq configuration file for syntax errors +func (g *ConfigGenerator) ValidateConfig(configPath string) error { + cmd := exec.Command("dnsmasq", "--test", "-C", configPath) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output)) + } + if !strings.Contains(string(output), "syntax check OK") { + return fmt.Errorf("config validation did not report OK: %s", string(output)) + } + return nil +} + +// ReloadService sends a SIGHUP to dnsmasq to reload configuration. +// Lighter weight than a full restart. Falls back to RestartService on failure. +func (g *ConfigGenerator) ReloadService() error { + cmd := exec.Command("systemctl", "reload", "dnsmasq.service") + _, err := cmd.CombinedOutput() + if err != nil { + slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err) + return g.RestartService() + } + slog.Info("dnsmasq service reloaded", "component", "dnsmasq") + return nil +} + // ConfigureSystemDNS configures systemd-resolved to use the local dnsmasq server // This should only be called on first start of dnsmasq func (g *ConfigGenerator) ConfigureSystemDNS() error { diff --git a/internal/dnsmasq/config_modular.go b/internal/dnsmasq/config_modular.go deleted file mode 100644 index c2cc7e2..0000000 --- a/internal/dnsmasq/config_modular.go +++ /dev/null @@ -1,106 +0,0 @@ -package dnsmasq - -import ( - "fmt" - "log/slog" - "os/exec" - "strings" - - "github.com/wild-cloud/wild-central/internal/config" - "github.com/wild-cloud/wild-central/internal/network" -) - -// GenerateMainConfig creates the main dnsmasq configuration with global settings -// and a conf-dir directive to include per-domain configs -func (g *ConfigGenerator) GenerateMainConfig(cfg *config.State) string { - // Get the Wild Central IP address - dnsIP, err := network.GetWildCentralIP() - if err != nil { - slog.Error("failed to detect Wild Central IP", "component", "dnsmasq", "error", err) - dnsIP = "" - } - - var sb strings.Builder - - fmt.Fprintf(&sb, `# Wild Cloud DNS Configuration (Main) -# This file contains global settings. Domain-specific DNS entries are -# generated by reconciliation into the monolithic config. - -# Basic Settings -listen-address=%s -bind-interfaces -domain-needed -bogus-priv -no-resolv - -# Upstream DNS servers -server=1.1.1.1 -server=8.8.8.8 - -# Logging -log-queries -log-dhcp -`, dnsIP) - - // DHCP section (only when explicitly enabled) - if cfg != nil && cfg.Cloud.Dnsmasq.DHCP.Enabled { - dhcp := cfg.Cloud.Dnsmasq.DHCP - sb.WriteString("\n# DHCP Configuration\n") - - leaseTime := dhcp.LeaseTime - if leaseTime == "" { - leaseTime = "24h" - } - fmt.Fprintf(&sb, "dhcp-range=%s,%s,%s\n", dhcp.RangeStart, dhcp.RangeEnd, leaseTime) - - gateway := dhcp.Gateway - if gateway != "" { - fmt.Fprintf(&sb, "dhcp-option=3,%s\n", gateway) // default gateway - } - if dnsIP != "" { - fmt.Fprintf(&sb, "dhcp-option=6,%s\n", dnsIP) // DNS server - } - - for _, lease := range dhcp.StaticLeases { - if lease.Hostname != "" { - fmt.Fprintf(&sb, "dhcp-host=%s,%s,%s\n", lease.MAC, lease.IP, lease.Hostname) - } else { - fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", lease.MAC, lease.IP) - } - } - } - - return sb.String() -} - -// ValidateConfig tests a dnsmasq configuration file for syntax errors -func (g *ConfigGenerator) ValidateConfig(configPath string) error { - // Use dnsmasq --test to validate the configuration - cmd := exec.Command("dnsmasq", "--test", "-C", configPath) - output, err := cmd.CombinedOutput() - if err != nil { - return fmt.Errorf("config validation failed: %w (output: %s)", err, string(output)) - } - - // Check if output contains "syntax check OK" - if !strings.Contains(string(output), "syntax check OK") { - return fmt.Errorf("config validation did not report OK: %s", string(output)) - } - - return nil -} - -// ReloadService sends a SIGHUP to dnsmasq to reload configuration -// This is lighter weight than a full restart -func (g *ConfigGenerator) ReloadService() error { - // Use systemctl reload which sends SIGHUP - cmd := exec.Command("systemctl", "reload", "dnsmasq.service") - _, err := cmd.CombinedOutput() - if err != nil { - // If reload fails, try restart as fallback - slog.Error("reload failed, attempting restart", "component", "dnsmasq", "error", err) - return g.RestartService() - } - slog.Info("dnsmasq service reloaded", "component", "dnsmasq") - return nil -} diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index 68e697f..93e6ca8 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -84,14 +84,6 @@ type GenerateOpts struct { CertsDir string // directory containing per-domain PEM files for L7 TLS } -// Generate creates a complete HAProxy configuration from instance routes, custom routes, -// and optional config. Uses L4 TCP mode with SNI inspection for HTTPS — -// TLS terminates at each k8s cluster's traefik. HTTP routes add an L7 frontend -// where Central terminates TLS with a wildcard cert and reverse-proxies by Host header. -func (m *Manager) Generate(instances []L4Route, custom []CustomRoute, centralDomain ...string) string { - return m.GenerateWithOpts(instances, custom, GenerateOpts{}) -} - // GenerateWithOpts creates a complete HAProxy configuration with full options. func (m *Manager) GenerateWithOpts(instances []L4Route, custom []CustomRoute, opts GenerateOpts) string { var sb strings.Builder diff --git a/internal/haproxy/config_test.go b/internal/haproxy/config_test.go index 1953016..98aefac 100644 --- a/internal/haproxy/config_test.go +++ b/internal/haproxy/config_test.go @@ -68,7 +68,7 @@ func TestGetListenPorts_IncludesCustom(t *testing.T) { func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) { m := NewManager("") - out := m.Generate(nil, nil) + out := m.GenerateWithOpts(nil, nil, GenerateOpts{}) for _, want := range []string{ "global\n", @@ -86,7 +86,7 @@ func TestGenerate_AlwaysIncludesBoilerplate(t *testing.T) { func TestGenerate_NoInstances_NoHTTPSFrontend(t *testing.T) { m := NewManager("") - out := m.Generate(nil, nil) + out := m.GenerateWithOpts(nil, nil, GenerateOpts{}) if strings.Contains(out, "frontend https_in") { t.Errorf("https_in frontend should be absent when no instances configured") } @@ -97,7 +97,7 @@ func TestGenerate_WithInstance_SNIACLs(t *testing.T) { instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, } - out := m.Generate(instances, nil) + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) // With subdomains:true, both ACL lines for OR-matching if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") { @@ -116,7 +116,7 @@ func TestGenerate_WithInstance_Backend(t *testing.T) { instances := []L4Route{ {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"}, } - out := m.Generate(instances, nil) + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) if !strings.Contains(out, "backend be_https_payne_cloud") { t.Errorf("expected backend block, got:\n%s", out) @@ -132,7 +132,7 @@ func TestGenerate_MultipleInstances(t *testing.T) { {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20"}, {Name: "test-cloud", Domain: "cloud2.payne.io", BackendIP: "192.168.8.30"}, } - out := m.Generate(instances, nil) + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) if !strings.Contains(out, "be_https_payne_cloud") { t.Errorf("expected payne-cloud backend, got:\n%s", out) @@ -154,7 +154,7 @@ func TestGenerate_WithSubdomains(t *testing.T) { {Name: "payne-cloud", Domain: "cloud.payne.io", BackendIP: "192.168.8.20", Subdomains: true}, {Name: "payne-io", Domain: "payne.io", BackendIP: "192.168.8.20", Subdomains: false}, } - out := m.Generate(instances, nil) + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) // cloud.payne.io with subdomains:true should have wildcard ACL if !strings.Contains(out, "req_ssl_sni -m end .cloud.payne.io") { @@ -207,7 +207,7 @@ func TestGenerate_CustomRoute(t *testing.T) { custom := []CustomRoute{ {Name: "civil_ssh", Port: 2222, Backend: "192.168.8.10:22"}, } - out := m.Generate(nil, custom) + out := m.GenerateWithOpts(nil, custom, GenerateOpts{}) if !strings.Contains(out, "frontend fe_civil_ssh") { t.Errorf("expected custom frontend, got:\n%s", out) @@ -417,7 +417,7 @@ func TestGenerateWithOpts_BackwardCompatible(t *testing.T) { } // Using old Generate function should still work - out := m.Generate(instances, nil) + out := m.GenerateWithOpts(instances, nil, GenerateOpts{}) if !strings.Contains(out, "be_https_test") { t.Errorf("backward compat: expected instance backend, got:\n%s", out) } @@ -538,7 +538,7 @@ func TestGenerate_CustomRoutePorts(t *testing.T) { {Name: "mqtt", Port: 1883, Backend: "192.168.1.20:1883"}, } - out := m.Generate(nil, custom) + out := m.GenerateWithOpts(nil, custom, GenerateOpts{}) if !strings.Contains(out, "bind *:2222") { t.Errorf("expected bind on port 2222, got:\n%s", out) diff --git a/internal/sse/watchers.go b/internal/sse/watchers.go deleted file mode 100644 index d3dec80..0000000 --- a/internal/sse/watchers.go +++ /dev/null @@ -1,616 +0,0 @@ -package sse - -import ( - "bufio" - "context" - "encoding/json" - "fmt" - "log/slog" - "os/exec" - "strings" - "sync" - "time" -) - -// WatcherManager manages kubectl and talos watchers per instance -type WatcherManager struct { - watchers map[string]*InstanceWatchers - mu sync.RWMutex - sseManager *Manager -} - -// InstanceWatchers holds all watchers for a single instance -type InstanceWatchers struct { - kubectl *KubectlWatcher - talos *TalosWatcher -} - -// NewWatcherManager creates a new watcher manager -func NewWatcherManager(sseManager *Manager) *WatcherManager { - return &WatcherManager{ - watchers: make(map[string]*InstanceWatchers), - sseManager: sseManager, - } -} - -// StartWatchers starts watchers for an instance -func (wm *WatcherManager) StartWatchers(instanceName, kubeconfig, talosconfig, nodeIP string) error { - wm.mu.Lock() - defer wm.mu.Unlock() - - // Stop existing watchers if any - if existing, ok := wm.watchers[instanceName]; ok { - existing.kubectl.Stop() - if existing.talos != nil { - existing.talos.Stop() - } - } - - // Start kubectl watcher - kubectlWatcher := NewKubectlWatcher(instanceName, kubeconfig, wm.sseManager) - if err := kubectlWatcher.Start(); err != nil { - return fmt.Errorf("failed to start kubectl watcher: %w", err) - } - - // Start talos watcher (optional, can be nil if not configured) - var talosWatcher *TalosWatcher - if talosconfig != "" && nodeIP != "" { - talosWatcher = NewTalosWatcher(instanceName, talosconfig, nodeIP, wm.sseManager) - if err := talosWatcher.Start(); err != nil { - kubectlWatcher.Stop() - return fmt.Errorf("failed to start talos watcher: %w", err) - } - } - - wm.watchers[instanceName] = &InstanceWatchers{ - kubectl: kubectlWatcher, - talos: talosWatcher, - } - - return nil -} - -// StopWatchers stops watchers for an instance -func (wm *WatcherManager) StopWatchers(instanceName string) { - wm.mu.Lock() - defer wm.mu.Unlock() - - if watchers, ok := wm.watchers[instanceName]; ok { - watchers.kubectl.Stop() - if watchers.talos != nil { - watchers.talos.Stop() - } - delete(wm.watchers, instanceName) - } -} - -// KubectlWatcher watches Kubernetes resources using kubectl --watch -type KubectlWatcher struct { - instanceName string - kubeconfig string - sseManager *Manager - ctx context.Context - cancel context.CancelFunc - wg sync.WaitGroup -} - -// NewKubectlWatcher creates a new kubectl watcher -func NewKubectlWatcher(instanceName string, kubeconfig string, manager *Manager) *KubectlWatcher { - ctx, cancel := context.WithCancel(context.Background()) - return &KubectlWatcher{ - instanceName: instanceName, - kubeconfig: kubeconfig, - sseManager: manager, - ctx: ctx, - cancel: cancel, - } -} - -// Start begins watching Kubernetes resources -func (w *KubectlWatcher) Start() error { - // Watch pods - w.wg.Add(1) - go w.watchResource("pods", w.parsePodEvent) - - // Watch deployments - w.wg.Add(1) - go w.watchResource("deployments", w.parseDeploymentEvent) - - // Watch services - w.wg.Add(1) - go w.watchResource("services", w.parseServiceEvent) - - slog.Info("started kubectl watchers", "component", "sse", "instance", w.instanceName) - return nil -} - -// watchResource watches a specific Kubernetes resource type -func (w *KubectlWatcher) watchResource(resourceType string, parser func([]byte, string)) { - defer w.wg.Done() - - for { - select { - case <-w.ctx.Done(): - return - default: - } - - // Start kubectl watch command - cmd := exec.CommandContext( - w.ctx, - "kubectl", - "--kubeconfig", w.kubeconfig, - "get", resourceType, - "--all-namespaces", - "--watch", - "-o", "json", - ) - - stdout, err := cmd.StdoutPipe() - if err != nil { - slog.Error("failed to create stdout pipe", "component", "sse", "resource", resourceType, "error", err) - w.handleWatchError(resourceType) - continue - } - - if err := cmd.Start(); err != nil { - slog.Error("failed to start watch", "component", "sse", "resource", resourceType, "error", err) - w.handleWatchError(resourceType) - continue - } - - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - parser([]byte(line), resourceType) - } - - if err := scanner.Err(); err != nil { - slog.Error("watch scanner error", "component", "sse", "resource", resourceType, "error", err) - } - - _ = cmd.Wait() - - // If context not cancelled, restart after a delay - if w.ctx.Err() == nil { - slog.Info("restarting watcher", "component", "sse", "resource", resourceType, "instance", w.instanceName) - time.Sleep(5 * time.Second) - } - } -} - -// parsePodEvent parses pod watch events -func (w *KubectlWatcher) parsePodEvent(data []byte, resourceType string) { - var event struct { - Type string `json:"type"` // ADDED, MODIFIED, DELETED - Object struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Status struct { - Phase string `json:"phase"` - Conditions []struct { - Type string `json:"type"` - Status string `json:"status"` - } `json:"conditions"` - ContainerStatuses []struct { - Name string `json:"name"` - Ready bool `json:"ready"` - State struct { - Running any `json:"running"` - Waiting any `json:"waiting"` - Terminated any `json:"terminated"` - } `json:"state"` - } `json:"containerStatuses"` - } `json:"status"` - } `json:"object"` - } - - // Try parsing as watch event first - if err := json.Unmarshal(data, &event); err == nil && event.Type != "" { - eventType := "" - switch event.Type { - case "ADDED": - eventType = "pod:added" - case "MODIFIED": - eventType = "pod:modified" - case "DELETED": - eventType = "pod:deleted" - default: - return - } - - w.sseManager.Broadcast(&Event{ - Type: eventType, - InstanceName: w.instanceName, - Data: map[string]any{ - "name": event.Object.Metadata.Name, - "namespace": event.Object.Metadata.Namespace, - "phase": event.Object.Status.Phase, - "ready": w.isPodReady(event.Object.Status.ContainerStatuses), - }, - Metadata: map[string]any{ - "namespace": event.Object.Metadata.Namespace, - "app": event.Object.Metadata.Labels["app"], - }, - }) - return - } - - // Try parsing as initial pod object (from initial list) - var pod struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Status struct { - Phase string `json:"phase"` - ContainerStatuses []struct { - Ready bool `json:"ready"` - } `json:"containerStatuses"` - } `json:"status"` - } - - if err := json.Unmarshal(data, &pod); err == nil && pod.Metadata.Name != "" { - w.sseManager.Broadcast(&Event{ - Type: "pod:added", - InstanceName: w.instanceName, - Data: map[string]any{ - "name": pod.Metadata.Name, - "namespace": pod.Metadata.Namespace, - "phase": pod.Status.Phase, - "ready": w.isPodReady(pod.Status.ContainerStatuses), - }, - Metadata: map[string]any{ - "namespace": pod.Metadata.Namespace, - "app": pod.Metadata.Labels["app"], - }, - }) - } -} - -// parseDeploymentEvent parses deployment watch events -func (w *KubectlWatcher) parseDeploymentEvent(data []byte, resourceType string) { - var deployment struct { - Type string `json:"type"` - Object struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Status struct { - Replicas int32 `json:"replicas"` - UpdatedReplicas int32 `json:"updatedReplicas"` - ReadyReplicas int32 `json:"readyReplicas"` - AvailableReplicas int32 `json:"availableReplicas"` - } `json:"status"` - } `json:"object"` - // Also handle direct deployment objects - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Status struct { - Replicas int32 `json:"replicas"` - UpdatedReplicas int32 `json:"updatedReplicas"` - ReadyReplicas int32 `json:"readyReplicas"` - AvailableReplicas int32 `json:"availableReplicas"` - } `json:"status"` - } - - if err := json.Unmarshal(data, &deployment); err != nil { - return - } - - // Determine if it's a watch event or direct object - var name, namespace, eventType string - var labels map[string]string - var status struct { - Replicas int32 - ReadyReplicas int32 - } - - if deployment.Type != "" { - // Watch event - switch deployment.Type { - case "ADDED": - eventType = "deployment:added" - case "MODIFIED": - eventType = "deployment:modified" - case "DELETED": - eventType = "deployment:deleted" - default: - return - } - name = deployment.Object.Metadata.Name - namespace = deployment.Object.Metadata.Namespace - labels = deployment.Object.Metadata.Labels - status.Replicas = deployment.Object.Status.Replicas - status.ReadyReplicas = deployment.Object.Status.ReadyReplicas - } else if deployment.Metadata.Name != "" { - // Direct object (initial list) - eventType = "deployment:added" - name = deployment.Metadata.Name - namespace = deployment.Metadata.Namespace - labels = deployment.Metadata.Labels - status.Replicas = deployment.Status.Replicas - status.ReadyReplicas = deployment.Status.ReadyReplicas - } else { - return - } - - w.sseManager.Broadcast(&Event{ - Type: eventType, - InstanceName: w.instanceName, - Data: map[string]any{ - "name": name, - "namespace": namespace, - "replicas": status.Replicas, - "readyReplicas": status.ReadyReplicas, - "ready": status.Replicas == status.ReadyReplicas && status.Replicas > 0, - }, - Metadata: map[string]any{ - "namespace": namespace, - "app": labels["app"], - }, - }) -} - -// parseServiceEvent parses service watch events -func (w *KubectlWatcher) parseServiceEvent(data []byte, resourceType string) { - var service struct { - Type string `json:"type"` - Object struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Spec struct { - Type string `json:"type"` - ClusterIP string `json:"clusterIP"` - Ports []struct { - Port int32 `json:"port"` - } `json:"ports"` - } `json:"spec"` - } `json:"object"` - // Also handle direct service objects - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - Labels map[string]string `json:"labels"` - } `json:"metadata"` - Spec struct { - Type string `json:"type"` - ClusterIP string `json:"clusterIP"` - Ports []struct { - Port int32 `json:"port"` - } `json:"ports"` - } `json:"spec"` - } - - if err := json.Unmarshal(data, &service); err != nil { - return - } - - // Determine if it's a watch event or direct object - var name, namespace, serviceType, eventType string - var labels map[string]string - - if service.Type != "" { - // Watch event - switch service.Type { - case "ADDED": - eventType = "service:added" - case "MODIFIED": - eventType = "service:modified" - case "DELETED": - eventType = "service:deleted" - default: - return - } - name = service.Object.Metadata.Name - namespace = service.Object.Metadata.Namespace - labels = service.Object.Metadata.Labels - serviceType = service.Object.Spec.Type - } else if service.Metadata.Name != "" { - // Direct object (initial list) - eventType = "service:added" - name = service.Metadata.Name - namespace = service.Metadata.Namespace - labels = service.Metadata.Labels - serviceType = service.Spec.Type - } else { - return - } - - w.sseManager.Broadcast(&Event{ - Type: eventType, - InstanceName: w.instanceName, - Data: map[string]any{ - "name": name, - "namespace": namespace, - "type": serviceType, - }, - Metadata: map[string]any{ - "namespace": namespace, - "app": labels["app"], - }, - }) -} - -// isPodReady checks if all containers in a pod are ready -func (w *KubectlWatcher) isPodReady(containerStatuses any) bool { - switch statuses := containerStatuses.(type) { - case []struct { - Name string `json:"name"` - Ready bool `json:"ready"` - State struct { - Running any `json:"running"` - Waiting any `json:"waiting"` - Terminated any `json:"terminated"` - } `json:"state"` - }: - if len(statuses) == 0 { - return false - } - for _, status := range statuses { - if !status.Ready { - return false - } - } - return true - case []struct { - Ready bool `json:"ready"` - }: - if len(statuses) == 0 { - return false - } - for _, status := range statuses { - if !status.Ready { - return false - } - } - return true - default: - return false - } -} - -// handleWatchError handles errors in watching resources -func (w *KubectlWatcher) handleWatchError(resourceType string) { - // Send error event - w.sseManager.Broadcast(&Event{ - Type: "k8s:watch:error", - InstanceName: w.instanceName, - Data: map[string]any{ - "resource": resourceType, - "error": "Watch process failed, restarting", - }, - }) -} - -// Stop stops the watcher -func (w *KubectlWatcher) Stop() { - w.cancel() - w.wg.Wait() - slog.Info("stopped kubectl watchers", "component", "sse", "instance", w.instanceName) -} - -// TalosWatcher watches Talos events using talosctl -type TalosWatcher struct { - instanceName string - talosconfig string - nodeIP string - sseManager *Manager - ctx context.Context - cancel context.CancelFunc -} - -// NewTalosWatcher creates a new Talos watcher -func NewTalosWatcher(instanceName, talosconfig, nodeIP string, manager *Manager) *TalosWatcher { - ctx, cancel := context.WithCancel(context.Background()) - return &TalosWatcher{ - instanceName: instanceName, - talosconfig: talosconfig, - nodeIP: nodeIP, - sseManager: manager, - ctx: ctx, - cancel: cancel, - } -} - -// Start begins watching Talos events -func (w *TalosWatcher) Start() error { - go w.watchEvents() - slog.Info("started talos watcher", "component", "sse", "instance", w.instanceName) - return nil -} - -// watchEvents watches Talos events -func (w *TalosWatcher) watchEvents() { - for { - select { - case <-w.ctx.Done(): - return - default: - } - - cmd := exec.CommandContext( - w.ctx, - "talosctl", - "--talosconfig", w.talosconfig, - "--nodes", w.nodeIP, - "events", - "--tail", "0", // Don't show historical events - "--follow", - ) - - stdout, err := cmd.StdoutPipe() - if err != nil { - slog.Error("failed to create stdout pipe for talos events", "component", "sse", "instance", w.instanceName, "nodeIP", w.nodeIP, "error", err) - time.Sleep(10 * time.Second) - continue - } - - if err := cmd.Start(); err != nil { - slog.Error("failed to start talos event watch", "component", "sse", "instance", w.instanceName, "nodeIP", w.nodeIP, "error", err) - time.Sleep(10 * time.Second) - continue - } - - scanner := bufio.NewScanner(stdout) - for scanner.Scan() { - line := scanner.Text() - if line == "" { - continue - } - - // Parse Talos event output - // Talos events typically look like: - // 172.20.0.2: time.syncd: 2024-01-15T10:30:45.123Z: NTP time sync successful - if strings.Contains(line, "Service") { - w.sseManager.Broadcast(&Event{ - Type: "talos:service:status", - InstanceName: w.instanceName, - Data: map[string]any{ - "node": w.nodeIP, - "message": line, - }, - }) - } else if strings.Contains(line, "Health") || strings.Contains(line, "health") { - w.sseManager.Broadcast(&Event{ - Type: "talos:node:health", - InstanceName: w.instanceName, - Data: map[string]any{ - "node": w.nodeIP, - "message": line, - }, - }) - } - } - - _ = cmd.Wait() - - // If context not cancelled, restart after a delay - if w.ctx.Err() == nil { - slog.Info("restarting talos watcher", "component", "sse", "instance", w.instanceName) - time.Sleep(10 * time.Second) - } - } -} - -// Stop stops the watcher -func (w *TalosWatcher) Stop() { - w.cancel() - slog.Info("stopped talos watcher", "component", "sse", "instance", w.instanceName) -} diff --git a/internal/sse/watchers_test.go b/internal/sse/watchers_test.go deleted file mode 100644 index 9c6808c..0000000 --- a/internal/sse/watchers_test.go +++ /dev/null @@ -1,434 +0,0 @@ -package sse - -import ( - "fmt" - "testing" - "time" -) - -func TestKubectlWatcherJSONParsing(t *testing.T) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - // Test valid pod JSON parsing - validPodJSON := `{ - "type": "ADDED", - "object": { - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "name": "test-pod", - "namespace": "default", - "labels": { - "app": "test-app" - } - }, - "status": { - "phase": "Running", - "conditions": [] - } - } - }` - - // Register a client to receive events - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Parse the JSON - watcher.parsePodEvent([]byte(validPodJSON), "test-instance") - - // Should receive parsed event - select { - case event := <-client.Channel: - if event.Type != "pod:added" { - t.Errorf("Expected event type 'pod:added', got '%s'", event.Type) - } - if podData, ok := event.Data.(map[string]any); ok { - if podData["name"] != "test-pod" { - t.Errorf("Expected pod name 'test-pod', got '%v'", podData["name"]) - } - if podData["namespace"] != "default" { - t.Errorf("Expected namespace 'default', got '%v'", podData["namespace"]) - } - } else { - t.Error("Event data is not a map") - } - case <-time.After(100 * time.Millisecond): - t.Error("Did not receive pod event") - } -} - -func TestKubectlWatcherEventTypeMapping(t *testing.T) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - testCases := []struct { - watchType string - expectedType string - }{ - {"ADDED", "pod:added"}, - {"MODIFIED", "pod:modified"}, - {"DELETED", "pod:deleted"}, - } - - for _, tc := range testCases { - t.Run(tc.watchType, func(t *testing.T) { - // Register a client - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - podJSON := fmt.Sprintf(`{ - "type": "%s", - "object": { - "kind": "Pod", - "metadata": { - "name": "test-pod", - "namespace": "default" - } - } - }`, tc.watchType) - - // Parse the event - watcher.parsePodEvent([]byte(podJSON), "test-instance") - - // Check received event type - select { - case event := <-client.Channel: - if event.Type != tc.expectedType { - t.Errorf("Expected event type '%s', got '%s'", tc.expectedType, event.Type) - } - case <-time.After(100 * time.Millisecond): - t.Error("Did not receive event") - } - }) - } -} - -func TestDeploymentEventParsing(t *testing.T) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - deploymentJSON := `{ - "type": "MODIFIED", - "object": { - "apiVersion": "apps/v1", - "kind": "Deployment", - "metadata": { - "name": "test-deployment", - "namespace": "default" - }, - "spec": { - "replicas": 3 - }, - "status": { - "replicas": 3, - "readyReplicas": 2, - "availableReplicas": 2 - } - } - }` - - // Register a client - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Parse deployment event - watcher.parseDeploymentEvent([]byte(deploymentJSON), "test-instance") - - // Check received event - select { - case event := <-client.Channel: - if event.Type != "deployment:modified" { - t.Errorf("Expected event type 'deployment:modified', got '%s'", event.Type) - } - if deployData, ok := event.Data.(map[string]any); ok { - if deployData["name"] != "test-deployment" { - t.Errorf("Expected deployment name 'test-deployment', got '%v'", deployData["name"]) - } - if deployData["readyReplicas"] != int32(2) { - t.Errorf("Expected 2 ready replicas, got '%v'", deployData["readyReplicas"]) - } - } - case <-time.After(100 * time.Millisecond): - t.Error("Did not receive deployment event") - } -} - -func TestServiceEventParsing(t *testing.T) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - serviceJSON := `{ - "type": "ADDED", - "object": { - "apiVersion": "v1", - "kind": "Service", - "metadata": { - "name": "test-service", - "namespace": "default" - }, - "spec": { - "type": "LoadBalancer", - "clusterIP": "10.96.0.1", - "ports": [ - { - "port": 80, - "targetPort": 8080, - "protocol": "TCP" - } - ] - }, - "status": { - "loadBalancer": { - "ingress": [ - { - "ip": "192.168.1.100" - } - ] - } - } - } - }` - - // Register a client - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Parse service event - watcher.parseServiceEvent([]byte(serviceJSON), "test-instance") - - // Check received event - select { - case event := <-client.Channel: - if event.Type != "service:added" { - t.Errorf("Expected event type 'service:added', got '%s'", event.Type) - } - if serviceData, ok := event.Data.(map[string]any); ok { - if serviceData["name"] != "test-service" { - t.Errorf("Expected service name 'test-service', got '%v'", serviceData["name"]) - } - if serviceData["type"] != "LoadBalancer" { - t.Errorf("Expected service type 'LoadBalancer', got '%v'", serviceData["type"]) - } - } - case <-time.After(100 * time.Millisecond): - t.Error("Did not receive service event") - } -} - -// TestTalosEventParsing is commented out as TalosWatcher doesn't expose parsing methods -// The actual parsing happens internally in the watchEvents method -/* -func TestTalosEventParsing(t *testing.T) { - manager := NewManager() - watcher := &TalosWatcher{ - instanceName: "test-instance", - talosconfig: "/tmp/test-talosconfig", - nodeIP: "192.168.1.10", - sseManager: manager, - } - - // Test machine config event - machineConfigEvent := `seq:task message:update successful node:192.168.1.10 type:MachineConfig` - - // Register a client - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Parse talos event - watcher.parseTalosEventLine(machineConfigEvent) - - // Check received event - select { - case event := <-client.Channel: - if event.Type != "talos.MachineConfig" { - t.Errorf("Expected event type 'talos.MachineConfig', got '%s'", event.Type) - } - if talosData, ok := event.Data.(map[string]any); ok { - if talosData["message"] != "update successful" { - t.Errorf("Expected message 'update successful', got '%v'", talosData["message"]) - } - if talosData["node"] != "192.168.1.10" { - t.Errorf("Expected node '192.168.1.10', got '%v'", talosData["node"]) - } - } - case <-time.After(100 * time.Millisecond): - t.Error("Did not receive talos event") - } -} -*/ - -func TestWatcherManagerLifecycle(t *testing.T) { - sseManager := NewManager() - watcherManager := NewWatcherManager(sseManager) - - // Test starting watchers for an instance - err := watcherManager.StartWatchers("test-instance", "/tmp/kubeconfig", "/tmp/talosconfig", "192.168.1.10") - if err != nil { - // This will likely fail because the configs don't exist, but we can test the manager state - t.Logf("Expected error starting watchers with non-existent configs: %v", err) - } - - // Check if instance is registered (even if watchers failed to start) - watcherManager.mu.RLock() - _, exists := watcherManager.watchers["test-instance"] - watcherManager.mu.RUnlock() - - if !exists { - t.Error("Instance should be registered even if watchers fail to start") - } - - // Test stopping watchers - watcherManager.StopWatchers("test-instance") - - // Check instance is removed - watcherManager.mu.RLock() - _, exists = watcherManager.watchers["test-instance"] - watcherManager.mu.RUnlock() - - if exists { - t.Error("Instance should be removed after stopping watchers") - } -} - -func TestWatcherManagerMultipleInstances(t *testing.T) { - sseManager := NewManager() - watcherManager := NewWatcherManager(sseManager) - - // Start watchers for multiple instances - _ = watcherManager.StartWatchers("instance-1", "/tmp/kubeconfig1", "/tmp/talosconfig1", "192.168.1.11") - _ = watcherManager.StartWatchers("instance-2", "/tmp/kubeconfig2", "/tmp/talosconfig2", "192.168.1.12") - - // Check both instances are registered - watcherManager.mu.RLock() - count := len(watcherManager.watchers) - watcherManager.mu.RUnlock() - - if count != 2 { - t.Errorf("Expected 2 instances registered, got %d", count) - } - - // Stop watchers for each instance - watcherManager.StopWatchers("instance-1") - watcherManager.StopWatchers("instance-2") - - // Check all instances are removed - watcherManager.mu.RLock() - count = len(watcherManager.watchers) - watcherManager.mu.RUnlock() - - if count != 0 { - t.Errorf("Expected 0 instances after stopping all, got %d", count) - } -} - -func TestInvalidJSONHandling(t *testing.T) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - // Register a client - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Try parsing invalid JSON - invalidJSON := `{invalid json}` - watcher.parsePodEvent([]byte(invalidJSON), "test-instance") - - // Should not receive any event - select { - case <-client.Channel: - t.Error("Should not receive event for invalid JSON") - case <-time.After(50 * time.Millisecond): - // Expected - no event for invalid JSON - } -} - -func TestEmptyEventHandling(t *testing.T) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - // Register a client - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Try parsing empty JSON - emptyJSON := `{}` - watcher.parsePodEvent([]byte(emptyJSON), "test-instance") - - // Should not crash, might not produce event - select { - case <-client.Channel: - // If we get an event, make sure it doesn't crash - t.Log("Received event for empty JSON (acceptable)") - case <-time.After(50 * time.Millisecond): - // Also acceptable - no event for empty JSON - } -} - -func BenchmarkJSONParsing(b *testing.B) { - manager := NewManager() - watcher := &KubectlWatcher{ - instanceName: "test-instance", - kubeconfig: "/tmp/test-kubeconfig", - sseManager: manager, - } - - podJSON := `{ - "type": "ADDED", - "object": { - "apiVersion": "v1", - "kind": "Pod", - "metadata": { - "name": "test-pod", - "namespace": "default", - "labels": { - "app": "test-app" - } - }, - "status": { - "phase": "Running" - } - } - }` - - // Register a client to consume events - client := manager.RegisterClient("test-instance", EventFilters{}) - defer manager.UnregisterClient(client) - - // Consume events in background - go func() { - for range client.Channel { - // Consume events - } - }() - - // Benchmark JSON parsing and event creation - b.ResetTimer() - for i := 0; i < b.N; i++ { - watcher.parsePodEvent([]byte(podJSON), "test-instance") - } -} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index 49c19ad..3ae832f 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -108,3 +108,4 @@ func EnsureFilePermissions(path string, perm os.FileMode) error { } return nil } + diff --git a/web/src/App.css b/web/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/web/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/web/src/components/domains/DomainCard.tsx b/web/src/components/domains/DomainCard.tsx index 17096b5..a8bec75 100644 --- a/web/src/components/domains/DomainCard.tsx +++ b/web/src/components/domains/DomainCard.tsx @@ -154,7 +154,7 @@ function MobileControls({ {hasCert ? ( <> - TLS: {cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain} + TLS: {(cert!.cert.wildcard || cert!.coveredBy) ? `*.${cert!.domain}` : cert!.domain} {cert!.cert.daysLeft != null && ({cert!.cert.daysLeft}d)} ) : ( diff --git a/web/src/components/domains/DomainTopology.tsx b/web/src/components/domains/DomainTopology.tsx index 440f047..c3bfed5 100644 --- a/web/src/components/domains/DomainTopology.tsx +++ b/web/src/components/domains/DomainTopology.tsx @@ -7,7 +7,7 @@ import { import '@xyflow/react/dist/style.css'; import { Globe, Wifi, Lock, Shield, ShieldAlert, Router, - AlertCircle, Loader2, Plus, RotateCw, Pencil, + AlertCircle, Loader2, Plus, RotateCw, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; @@ -57,6 +57,68 @@ export interface DomainTopologyProps { const handleStyle = { opacity: 0, width: 4, height: 4 }; +function BackendNode({ data, leftHandle, rightHandle, bottomHandle }: { + data: Record; + leftHandle: React.ReactNode; + rightHandle: React.ReactNode; + bottomHandle: React.ReactNode; +}) { + const onChangeBackend = data.onChangeBackend as ((addr: string) => void) | undefined; + const hasRoutes = data.hasRoutes as boolean; + const editable = !!onChangeBackend && !hasRoutes; + + const [editing, setEditing] = useState(false); + const inputRef = useRef(null); + + const handleSave = () => { + const val = inputRef.current?.value.trim(); + if (val && onChangeBackend) onChangeBackend(val); + setEditing(false); + }; + + return ( + <> + {leftHandle} + setEditing(true) : undefined} + tooltip={editable && !editing ? 'Click to edit backend address' : undefined} + > + {editing && ( +
e.stopPropagation()}> + { + if (e.key === 'Enter') handleSave(); + if (e.key === 'Escape') setEditing(false); + }} + /> +
+ + +
+
+ )} +
+ {rightHandle} + {bottomHandle} + + ); +} + function FlowNode({ data }: NodeProps) { const v = data.variant as string; @@ -172,6 +234,7 @@ function FlowNode({ data }: NodeProps) { if (v === 'tls') { const status = data.status as NodeStatus; const certDomain = data.certDomain as string | undefined; + const isWildcard = data.isWildcardCert as boolean; const daysLeft = data.daysLeft as number; const hasCert = data.hasCert as boolean; const provisionDomain = data.provisionDomain as string; @@ -192,7 +255,7 @@ function FlowNode({ data }: NodeProps) { tooltip={ hasCert ? (
-
Certificate: {certDomain}
+
Certificate: {certDomain} ({isWildcard ? 'wildcard' : 'single domain'})
Expires: {expiry ?? 'unknown'} ({daysLeft}d)
{issuer &&
Issuer: {issuer}
}
@@ -230,63 +293,7 @@ function FlowNode({ data }: NodeProps) { } if (v === 'backend') { - const onChangeBackend = data.onChangeBackend as ((addr: string) => void) | undefined; - const hasRoutes = data.hasRoutes as boolean; - const editable = !!onChangeBackend && !hasRoutes; - - const [editing, setEditing] = useState(false); - const inputRef = useRef(null); - - const handleSave = () => { - const val = inputRef.current?.value.trim(); - if (val && onChangeBackend) onChangeBackend(val); - setEditing(false); - }; - - return ( - <> - {leftHandle} - setEditing(true) : undefined} - tooltip={editable ? 'Click to edit backend address' : undefined} - > - {editable && !editing && ( - - )} - {editing && ( -
e.stopPropagation()}> - { - if (e.key === 'Enter') handleSave(); - if (e.key === 'Escape') setEditing(false); - }} - /> -
- - -
-
- )} -
- {rightHandle} - {bottomHandle} - - ); + return ; } if (v === 'lan') { @@ -474,7 +481,8 @@ export function DomainTopology(props: DomainTopologyProps) { // TLS const hasCert = !!cert?.cert.exists; const tlsStatus: NodeStatus = hasCert ? 'ok' : 'error'; - const certDomain = hasCert ? (cert!.coveredBy ? `*.${cert!.domain}` : cert!.domain) : undefined; + const isWildcardCert = !!(cert?.cert.wildcard || cert?.coveredBy); + const certDomain = hasCert ? (isWildcardCert ? `*.${cert!.domain}` : cert!.domain) : undefined; const daysLeft = cert?.cert.daysLeft ?? 0; const provisionDomain = domain.subdomains ? `*.${domain.domain}` : domain.domain; @@ -556,7 +564,7 @@ export function DomainTopology(props: DomainTopologyProps) { id: 'tls', type: 'topology', position: { x: tlsX, y: pipelineY }, data: { - variant: 'tls', status: tlsStatus, certDomain, daysLeft, hasCert, provisionDomain, + variant: 'tls', status: tlsStatus, certDomain, isWildcardCert, daysLeft, hasCert, provisionDomain, expiry: cert?.cert.expiry ? new Date(cert.cert.expiry).toLocaleDateString() : undefined, issuer: cert?.cert.issuerCN, isProvisioningCert, isRenewingCert, onProvisionCert, onRenewCert, diff --git a/web/src/components/domains/TopologyNode.tsx b/web/src/components/domains/TopologyNode.tsx index 099cea6..27d91fb 100644 --- a/web/src/components/domains/TopologyNode.tsx +++ b/web/src/components/domains/TopologyNode.tsx @@ -57,18 +57,20 @@ export const TopologyNode = forwardRef( className, )} > -
- {icon && {icon}} -
-
- - {label} + {label && ( +
+ {icon && {icon}} +
+
+ + {label} +
+ {sublabel && ( +
{sublabel}
+ )}
- {sublabel && ( -
{sublabel}
- )}
-
+ )} {children}
); diff --git a/web/src/services/api/cert.ts b/web/src/services/api/cert.ts index 86d4387..4e07017 100644 --- a/web/src/services/api/cert.ts +++ b/web/src/services/api/cert.ts @@ -3,6 +3,7 @@ import { apiClient } from './client'; export interface CertInfo { exists: boolean; domain?: string; + wildcard?: boolean; expiry?: string; certPath?: string; keyPath?: string; diff --git a/web/src/services/api/dnsmasq.ts b/web/src/services/api/dnsmasq.ts index 2863756..32fd6d8 100644 --- a/web/src/services/api/dnsmasq.ts +++ b/web/src/services/api/dnsmasq.ts @@ -1,9 +1,5 @@ import { apiClient } from './client'; - -export interface DnsmasqStatus { - running: boolean; - status?: string; -} +import type { DnsmasqStatus } from '../../types'; export const dnsmasqApi = { async getStatus(): Promise { diff --git a/web/src/services/api/index.ts b/web/src/services/api/index.ts index c681e99..04a9bad 100644 --- a/web/src/services/api/index.ts +++ b/web/src/services/api/index.ts @@ -9,5 +9,5 @@ export { certApi } from './cert'; export type { CertStatusResponse, CertInfo } from './cert'; export { cloudflareApi } from './cloudflare'; export type { CloudflareVerifyResponse, CloudflareZone } from './cloudflare'; -export { operatorApi, centralDomainApi, dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings'; +export { dnsSettingsApi, ddnsConfigApi, dhcpConfigApi, nftablesConfigApi, haproxyRoutesApi } from './settings'; export type { OperatorSettings, CentralDomainSettings, DNSSettings, DDNSConfig, DHCPConfig, NftablesConfig, HAProxyCustomRoute, HAProxyRoutes } from './settings'; diff --git a/web/src/types/index.ts b/web/src/types/index.ts index 9a6ad0b..8c814dd 100644 --- a/web/src/types/index.ts +++ b/web/src/types/index.ts @@ -10,10 +10,6 @@ export interface Message { type: 'info' | 'success' | 'error'; } -export interface LoadingState { - [key: string]: boolean; -} - export interface Messages { [key: string]: Message; } diff --git a/web/src/utils/formatters.ts b/web/src/utils/formatters.ts deleted file mode 100644 index 6028b36..0000000 --- a/web/src/utils/formatters.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const formatTimestamp = (timestamp: string): string => { - return new Date(timestamp).toLocaleString(); -}; \ No newline at end of file