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
This commit is contained in:
2026-07-10 06:10:40 +00:00
parent e78a1d548a
commit 68d6fde80d
9 changed files with 570 additions and 1072 deletions

View File

@@ -37,6 +37,7 @@ make check # Lint + test
### Environment Variables ### Environment Variables
- `WILD_CENTRAL_DATA_DIR` — Data directory (default: `/var/lib/wild-central`) - `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_NATS_PORT` — NATS listen port (default: `4222`)
- `WILD_CENTRAL_DNSMASQ_CONFIG_PATH` — dnsmasq config path - `WILD_CENTRAL_DNSMASQ_CONFIG_PATH` — dnsmasq config path
- `WILD_CENTRAL_HAPROXY_CONFIG_PATH` — HAProxy 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_STATIC_DIR` — Web UI static files directory
- `WILD_CENTRAL_VITE_URL` — Vite dev server URL for frontend proxying - `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 ## Service Registration API
The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure. 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 ```json
{ {
"name": "my-api",
"source": "wild-works",
"domain": "my-api.payne.io", "domain": "my-api.payne.io",
"source": "wild-works",
"backend": { "backend": {
"address": "192.168.8.60:9001", "address": "192.168.8.60:9001",
"type": "http", "type": "http",
"health": "/health" "health": "/health"
}, },
"reach": "internal", "public": false,
"tls": "terminate" "tls": "terminate"
} }
``` ```
@@ -81,8 +90,7 @@ DELETE /api/v1/services/{name} Deregister service
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services) - `http` — L7, Central terminates TLS with wildcard cert (Wild Works services)
- `static` — L7, static file serving (Wild Works frontends) - `static` — L7, static file serving (Wild Works frontends)
### Reach levels ### Public
- `off` — localhost only - `false` (default) — LAN-visible only (DNS + proxy + TLS)
- `internal` — LAN-visible (DNS + proxy + TLS) - `true` — internet-visible (+ DDNS or tunnel exposure)
- `public` — internet-visible (+ tunnel or direct exposure)

13
TODO.md Normal file
View File

@@ -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.

View File

@@ -68,7 +68,7 @@ func NewAPI(dataDir, version string, allowedOrigins []string) (*API, error) {
version: version, version: version,
allowedOrigins: allowedOrigins, allowedOrigins: allowedOrigins,
config: configMgr, config: configMgr,
secrets: secrets.NewManager(), secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")),
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath), dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
haproxy: haproxy.NewManager(haproxyConfigPath), haproxy: haproxy.NewManager(haproxyConfigPath),
nftables: nftables.NewManager(nftablesRulesPath), 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) // GetGlobalSecrets returns the global secrets (redacted by default)
func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) { func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml") secretsMap, err := api.secrets.GetAll()
data, err := os.ReadFile(secretsPath)
if err != nil { if err != nil {
if os.IsNotExist(err) {
respondJSON(w, http.StatusOK, map[string]any{})
return
}
respondError(w, http.StatusInternalServerError, "Failed to read secrets") respondError(w, http.StatusInternalServerError, "Failed to read secrets")
return 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" showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw { if !showRaw {
redactSecrets(secretsMap) redactSecrets(secretsMap)
@@ -357,8 +343,6 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
// UpdateGlobalSecrets updates the global secrets // UpdateGlobalSecrets updates the global secrets
func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) { func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
body, err := io.ReadAll(r.Body) body, err := io.ReadAll(r.Body)
if err != nil { if err != nil {
respondError(w, http.StatusBadRequest, "Failed to read request body") respondError(w, http.StatusBadRequest, "Failed to read request body")
@@ -371,36 +355,7 @@ func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
return return
} }
existingContent, err := storage.ReadFile(secretsPath) if err := api.secrets.MergeUpdate(updates); err != nil {
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 {
respondError(w, http.StatusInternalServerError, "Failed to write secrets") respondError(w, http.StatusInternalServerError, "Failed to write secrets")
return return
} }
@@ -488,45 +443,19 @@ func redactSecrets(m map[string]any) {
// getCloudflareToken reads the Cloudflare API token from global secrets // getCloudflareToken reads the Cloudflare API token from global secrets
func (api *API) getCloudflareToken() string { func (api *API) getCloudflareToken() string {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml") token, err := api.secrets.GetSecret("cloudflare.apiToken")
data, err := os.ReadFile(secretsPath)
if err != nil { if err != nil {
return "" 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 return token
} }
// getCloudflareZoneID reads the Cloudflare Zone ID from global secrets // getCloudflareZoneID reads the Cloudflare Zone ID from global secrets
func (api *API) getCloudflareZoneID() string { func (api *API) getCloudflareZoneID() string {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml") zoneID, err := api.secrets.GetSecret("cloudflare.zoneId")
data, err := os.ReadFile(secretsPath)
if err != nil { if err != nil {
return "" 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 return zoneID
} }

View File

@@ -3,7 +3,6 @@ package v1
import ( import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"path/filepath"
) )
type cloudflareZone struct { type cloudflareZone struct {
@@ -71,8 +70,7 @@ func (api *API) CloudflareSetZoneID(w http.ResponseWriter, r *http.Request) {
return return
} }
secretsPath := filepath.Join(api.dataDir, "secrets.yaml") if err := api.secrets.SetSecret("cloudflare.zoneId", req.ZoneID); err != nil {
if err := api.secrets.SetSecret(secretsPath, "cloudflare.zoneId", req.ZoneID); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to save zone ID") respondError(w, http.StatusInternalServerError, "Failed to save zone ID")
return return
} }

View File

@@ -1,82 +1,17 @@
package secrets package secrets
import ( import (
"bytes"
"crypto/rand" "crypto/rand"
"errors"
"fmt" "fmt"
"math/big" "math/big"
"os/exec" "os"
"path/filepath"
"strings"
"github.com/wild-cloud/wild-central/internal/storage" "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 ( const (
// DefaultSecretLength is 32 characters // DefaultSecretLength is 32 characters
DefaultSecretLength = 32 DefaultSecretLength = 32
@@ -84,19 +19,21 @@ const (
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
) )
// Manager handles secret generation and storage // Manager handles secret storage backed by a single YAML file.
type Manager struct { type Manager struct {
yq *YQ secretsPath string
yq *tools.YQ
} }
// NewManager creates a new secrets manager // NewManager creates a new secrets manager bound to the given file path.
func NewManager() *Manager { func NewManager(secretsPath string) *Manager {
return &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) { func GenerateSecret(length int) (string, error) {
if length <= 0 { if length <= 0 {
length = DefaultSecretLength length = DefaultSecretLength
@@ -114,54 +51,17 @@ func GenerateSecret(length int) (string, error) {
return string(result), nil return string(result), nil
} }
// EnsureSecretsFile ensures a secrets file exists with proper structure and permissions // GetSecret retrieves a secret value by dot-notation key.
func (m *Manager) EnsureSecretsFile(dataDir, instanceName string) error { func (m *Manager) GetSecret(key string) (string, error) {
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml") if !storage.FileExists(m.secretsPath) {
return "", fmt.Errorf("secrets file not found: %s", m.secretsPath)
// 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
} }
// Create minimal secrets structure value, err := m.yq.Get(m.secretsPath, fmt.Sprintf(".%s", key))
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))
if err != nil { if err != nil {
return "", fmt.Errorf("getting secret %s: %w", key, err) return "", fmt.Errorf("getting secret %s: %w", key, err)
} }
// yq returns "null" for non-existent keys
if value == "" || value == "null" { if value == "" || value == "null" {
return "", fmt.Errorf("secret not found: %s", key) return "", fmt.Errorf("secret not found: %s", key)
} }
@@ -169,69 +69,75 @@ func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
return value, nil return value, nil
} }
// SetSecret sets a secret value in a secrets file // SetSecret sets a secret value by dot-notation key.
func (m *Manager) SetSecret(secretsPath, key, value string) error { func (m *Manager) SetSecret(key, value string) error {
if !storage.FileExists(secretsPath) { if !storage.FileExists(m.secretsPath) {
return fmt.Errorf("secrets file not found: %s", secretsPath) return fmt.Errorf("secrets file not found: %s", m.secretsPath)
} }
// Acquire lock before modifying lockPath := m.secretsPath + ".lock"
lockPath := secretsPath + ".lock"
return storage.WithLock(lockPath, func() error { return storage.WithLock(lockPath, func() error {
// Don't wrap value in quotes - yq handles YAML quoting automatically if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
if err := m.yq.Set(secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
return err return err
} }
// Ensure permissions remain secure after modification return storage.EnsureFilePermissions(m.secretsPath, 0600)
return storage.EnsureFilePermissions(secretsPath, 0600)
}) })
} }
// EnsureSecret generates and sets a secret only if it doesn't exist (idempotent) // DeleteSecret removes a secret by dot-notation key.
func (m *Manager) EnsureSecret(secretsPath, key string, length int) (string, error) { func (m *Manager) DeleteSecret(key string) error {
if !storage.FileExists(secretsPath) { if !storage.FileExists(m.secretsPath) {
return "", fmt.Errorf("secrets file not found: %s", secretsPath) return fmt.Errorf("secrets file not found: %s", m.secretsPath)
} }
// Check if secret already exists lockPath := m.secretsPath + ".lock"
existingSecret, err := m.GetSecret(secretsPath, key) return storage.WithLock(lockPath, func() error {
if err == nil && existingSecret != "" && existingSecret != "null" { if err := m.yq.Delete(m.secretsPath, fmt.Sprintf(".%s", key)); err != nil {
// Secret already exists, return it return err
return existingSecret, nil
} }
return storage.EnsureFilePermissions(m.secretsPath, 0600)
})
}
// Generate new secret // GetAll reads and returns the full secrets map.
secret, err := GenerateSecret(length) func (m *Manager) GetAll() (map[string]any, error) {
data, err := os.ReadFile(m.secretsPath)
if err != nil { 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 var secretsMap map[string]any
if err := m.SetSecret(secretsPath, key, secret); err != nil { if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return "", err 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 // MergeUpdate merges the provided key-value pairs into the existing secrets file.
func (m *Manager) GenerateAndStoreSecret(secretsPath, key string) (string, error) { func (m *Manager) MergeUpdate(updates map[string]any) error {
return m.EnsureSecret(secretsPath, key, DefaultSecretLength) lockPath := m.secretsPath + ".lock"
}
// 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"
return storage.WithLock(lockPath, func() error { 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 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)
}) })
} }

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,13 @@ import { Alert, AlertDescription } from './ui/alert';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2, 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'; } from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare'; import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns'; import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus'; import { useCentralStatus } from '../hooks/useCentralStatus';
import { useCert } from '../hooks/useCert';
import { useConfig } from '../hooks'; import { useConfig } from '../hooks';
import { useVpn } from '../hooks/useVpn';
import { secretsApi } from '../services/api'; import { secretsApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp'; import { usePageHelp } from '../hooks/usePageHelp';
import type { DdnsRecordStatus } from '../services/api/networking'; import type { DdnsRecordStatus } from '../services/api/networking';
@@ -22,8 +22,8 @@ export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus(); const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare(); const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns(); const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { status: certStatus } = useCert();
const { config: globalConfig, updateConfig } = useConfig(); const { config: globalConfig, updateConfig } = useConfig();
const { config: vpnConfig } = useVpn();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({ const { data: globalSecrets } = useQuery({
@@ -52,10 +52,8 @@ export function DashboardComponent() {
// Warning conditions // Warning conditions
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured; const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid; 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 ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
const hasWarnings = cfTokenMissing || cfTokenInvalid || certExpiring || certMissing || ddnsFailed; const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed;
usePageHelp({ usePageHelp({
title: 'What is the Dashboard?', title: 'What is the Dashboard?',
@@ -103,18 +101,6 @@ export function DashboardComponent() {
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription> <AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
</Alert> </Alert>
)} )}
{certMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>One or more TLS certificates are missing. Services may not be reachable over HTTPS.</AlertDescription>
</Alert>
)}
{certExpiring && !certMissing && (
<Alert variant="warning">
<AlertCircle className="h-4 w-4" />
<AlertDescription>A TLS certificate expires within 14 days. Consider renewing soon.</AlertDescription>
</Alert>
)}
{ddnsFailed && ( {ddnsFailed && (
<Alert variant="error"> <Alert variant="error">
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
@@ -426,62 +412,39 @@ export function DashboardComponent() {
)} )}
</Card> </Card>
{/* 4. TLS Certificates */} {/* 4. Gateway Router */}
{certStatus?.certs && certStatus.certs.length > 0 && (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">TLS Certificates</div>
</div>
{(() => {
const allExist = certStatus.certs!.every(c => c.cert.exists);
const someExist = certStatus.certs!.some(c => c.cert.exists);
if (allExist) {
return <Badge variant="success" className="gap-1"><CheckCircle className="h-3 w-3" />All valid</Badge>;
} else if (someExist) {
return <Badge variant="warning" className="gap-1"><AlertCircle className="h-3 w-3" />Partial</Badge>;
} else {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Missing</Badge>;
}
})()}
</div>
<div className="space-y-2 ml-7">
{certStatus.certs!.map((entry) => (
<div key={entry.domain} className="flex items-center justify-between text-sm">
<span className="font-mono">{entry.domain}</span>
{entry.cert.exists ? (
<span className="text-xs text-green-600 dark:text-green-400">
Valid ({entry.cert.daysLeft}d)
</span>
) : (
<span className="text-xs text-red-500">Missing</span>
)}
</div>
))}
</div>
</Card>
)}
{/* 5. Gateway Router */}
<Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10"> <Card className="p-4 border-l-4 border-l-amber-500 bg-gradient-to-r from-amber-50/50 to-orange-50/50 dark:from-amber-950/10 dark:to-orange-950/10">
<div className="flex items-center gap-2 mb-3"> <div className="flex items-center gap-2 mb-3">
<Router className="h-5 w-5 text-amber-500" /> <Router className="h-5 w-5 text-amber-500" />
<div className="font-medium">Gateway Router</div> <div className="font-medium">Gateway Router</div>
</div> </div>
<div className="space-y-2 ml-7 text-sm text-muted-foreground"> <div className="space-y-2 ml-7 text-sm text-muted-foreground">
<p>Ensure your router is configured for Wild Central to work correctly:</p> <p>Your LAN router should be configured to work with Wild Central:</p>
<ul className="list-disc list-inside space-y-1 text-xs"> <ul className="list-disc list-inside space-y-1 text-xs">
<li> <li>
Forward DNS to Wild Central Set the router's DNS server to Wild Central
{globalConfig?.cloud?.dnsmasq?.ip && ( {globalConfig?.cloud?.dnsmasq?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1"> <span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
{globalConfig.cloud.dnsmasq.ip} {globalConfig.cloud.dnsmasq.ip}
</span> </span>
)} )}
</li> </li>
<li>Forward ports <span className="font-mono">443</span>, <span className="font-mono">80</span>, <span className="font-mono">51820</span> to Wild Central</li> <li>
<li>Set Wild Central as the primary DNS server for your LAN</li> 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) => (
<span key={i}>
{i > 0 && ', '}
<span className="font-mono">{p.port}</span>/{p.proto}
{p.label && <span className="text-muted-foreground/60"> ({p.label})</span>}
</span>
))}
</li>
</ul> </ul>
</div> </div>
</Card> </Card>

View File

@@ -9,13 +9,14 @@ import { Switch } from './ui/switch';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
import { import {
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
Plus, Trash2, X, ChevronDown, ChevronUp, Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
} from 'lucide-react'; } from 'lucide-react';
import { useHaproxy } from '../hooks/useHaproxy'; import { useHaproxy } from '../hooks/useHaproxy';
import { useServices } from '../hooks/useServices'; import { useServices } from '../hooks/useServices';
import { useCert } from '../hooks/useCert'; import { useCert } from '../hooks/useCert';
import { usePageHelp } from '../hooks/usePageHelp'; import { usePageHelp } from '../hooks/usePageHelp';
import type { RegisteredService } from '../services/api/networking'; import type { RegisteredService } from '../services/api/networking';
import type { CertEntry } from '../services/api/cert';
import { servicesApi, haproxyApi } from '../services/api/networking'; import { servicesApi, haproxyApi } from '../services/api/networking';
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) { 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<string>): 'ok' | 'error' | 'na' { function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na'; const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
return certDomains.has(service.domain) ? 'ok' : 'error'; 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() { export function ServicesComponent() {
@@ -43,7 +79,17 @@ export function ServicesComponent() {
restart: restartHaproxy, isRestarting: isHaproxyRestarting, restart: restartHaproxy, isRestarting: isHaproxyRestarting,
} = useHaproxy(); } = useHaproxy();
const { services, isLoading: isServicesLoading } = useServices(); 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<Set<string>>(new Set()); const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
@@ -197,7 +243,8 @@ export function ServicesComponent() {
const isExpanded = expandedDomains.has(service.domain); const isExpanded = expandedDomains.has(service.domain);
const tlsStatus = getTlsStatus(service, certDomains); const tlsStatus = getTlsStatus(service, certDomains);
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const; 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 ( return (
<Card key={service.domain}> <Card key={service.domain}>
@@ -208,6 +255,9 @@ export function ServicesComponent() {
<span className="font-mono text-sm font-medium truncate"> <span className="font-mono text-sm font-medium truncate">
{service.subdomains ? `*.${service.domain}` : service.domain} {service.subdomains ? `*.${service.domain}` : service.domain}
</span> </span>
{routeCount > 1 && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{routeCount} routes</span>
)}
</div> </div>
<div className="flex items-center gap-3 shrink-0"> <div className="flex items-center gap-3 shrink-0">
<StatusDot status="ok" label="DNS" /> <StatusDot status="ok" label="DNS" />
@@ -223,6 +273,7 @@ export function ServicesComponent() {
<CardContent className="pt-0 pb-4 px-4 space-y-4"> <CardContent className="pt-0 pb-4 px-4 space-y-4">
{/* Controls */} {/* Controls */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2"> <div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
{!hasRoutes(service) && (
<div> <div>
<Label className="text-xs text-muted-foreground">Backend</Label> <Label className="text-xs text-muted-foreground">Backend</Label>
<Input <Input
@@ -239,6 +290,7 @@ export function ServicesComponent() {
}} }}
/> />
</div> </div>
)}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
@@ -261,19 +313,142 @@ export function ServicesComponent() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
checked={!isPassthrough} checked={!isPassthrough}
onCheckedChange={v => updateMutation.mutate({ onCheckedChange={v => {
if (hasRoutes(service)) {
updateMutation.mutate({
domain: service.domain,
updates: { tls: v ? 'terminate' : 'passthrough' },
});
} else {
updateMutation.mutate({
domain: service.domain, domain: service.domain,
updates: { updates: {
tls: v ? 'terminate' : 'passthrough', tls: v ? 'terminate' : 'passthrough',
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' }, backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
}, },
})} });
}
}}
disabled={updateMutation.isPending} disabled={updateMutation.isPending}
/> />
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label> <Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
</div> </div>
</div> </div>
{/* Routes detail */}
{hasRoutes(service) && (
<div className="space-y-2">
<Label className="text-xs text-muted-foreground flex items-center gap-1">
<Route className="h-3 w-3" />Routes
</Label>
{service.routes!.map((route, i) => (
<div key={i} className="bg-muted/50 rounded-md p-3 space-y-2 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-mono text-xs">
{route.paths?.length ? route.paths.join(', ') : '/*'}
</span>
<Input
defaultValue={route.backend.address}
className="h-7 font-mono text-xs w-44"
onBlur={e => {
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'] }));
}
}}
/>
</div>
{route.headers && Object.keys(route.headers.response ?? {}).length > 0 && (
<div className="text-xs space-y-0.5">
<span className="text-muted-foreground flex items-center gap-1">
<FileText className="h-3 w-3" />Response headers
</span>
{Object.entries(route.headers.response!).map(([k, v]) => (
<div key={k} className="font-mono pl-4 text-muted-foreground">
{k}: <span className="text-foreground">{v}</span>
</div>
))}
</div>
)}
{route.headers && Object.keys(route.headers.request ?? {}).length > 0 && (
<div className="text-xs space-y-0.5">
<span className="text-muted-foreground flex items-center gap-1">
<FileText className="h-3 w-3" />Request headers
</span>
{Object.entries(route.headers.request!).map(([k, v]) => (
<div key={k} className="font-mono pl-4 text-muted-foreground">
{k}: <span className="text-foreground">{v}</span>
</div>
))}
</div>
)}
{route.ipAllow && route.ipAllow.length > 0 && (
<div className="text-xs">
<span className="text-muted-foreground flex items-center gap-1">
<Shield className="h-3 w-3" />IP allow: <span className="font-mono text-foreground">{route.ipAllow.join(', ')}</span>
</span>
</div>
)}
</div>
))}
</div>
)}
{/* TLS cert info */}
{!isPassthrough && (() => {
const cert = findCert(service.domain, certStatus?.certs ?? []);
if (!cert) {
const provisionDomain = service.subdomains ? `*.${service.domain}` : service.domain;
return (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-red-500">
<AlertCircle className="h-3.5 w-3.5" />
No TLS certificate
</div>
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
disabled={provisionMutation.isPending}
onClick={() => provisionMutation.mutate(provisionDomain)}>
{provisionMutation.isPending
? <Loader2 className="h-3 w-3 animate-spin" />
: <Plus className="h-3 w-3" />}
Provision {provisionDomain}
</Button>
</div>
);
}
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 (
<div className="flex items-center justify-between">
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle className="h-3.5 w-3.5 text-green-500" />
{isWildcard ? `*.${cert.domain}` : cert.domain}
</span>
<span>Expires {expiry} ({daysLeft}d)</span>
{cert.cert.issuerCN && <span className="truncate max-w-48">{cert.cert.issuerCN}</span>}
</div>
{daysLeft < 30 && (
<Button variant="outline" size="sm" className="h-7 text-xs gap-1"
disabled={renewMutation.isPending}
onClick={() => renewMutation.mutate()}>
{renewMutation.isPending
? <Loader2 className="h-3 w-3 animate-spin" />
: <RotateCw className="h-3 w-3" />}
Renew
</Button>
)}
</div>
);
})()}
{/* Source + deregister */} {/* Source + deregister */}
<div className="flex items-center justify-between text-xs text-muted-foreground"> <div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Source: {service.source}</span> <span>Source: {service.source}</span>

View File

@@ -2,6 +2,22 @@ import { apiClient } from './client';
// Services // Services
export interface HeaderConfig {
request?: Record<string, string>;
response?: Record<string, string>;
}
export interface Route {
paths?: string[];
backend: {
address: string;
type: string;
health?: string;
};
headers?: HeaderConfig;
ipAllow?: string[];
}
export interface RegisteredService { export interface RegisteredService {
domain: string; domain: string;
source: string; source: string;
@@ -13,6 +29,7 @@ export interface RegisteredService {
}; };
public: boolean; public: boolean;
tls?: string; tls?: string;
routes?: Route[];
} }
export const servicesApi = { export const servicesApi = {