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
- `WILD_CENTRAL_DATA_DIR` — Data directory (default: `/var/lib/wild-central`)
- `WILD_CENTRAL_PORT` — API listen port (default: `5055`)
- `WILD_CENTRAL_NATS_PORT` — NATS listen port (default: `4222`)
- `WILD_CENTRAL_DNSMASQ_CONFIG_PATH` — dnsmasq config path
- `WILD_CENTRAL_HAPROXY_CONFIG_PATH` — HAProxy config path
@@ -46,6 +47,15 @@ make check # Lint + test
- `WILD_CENTRAL_STATIC_DIR` — Web UI static files directory
- `WILD_CENTRAL_VITE_URL` — Vite dev server URL for frontend proxying
### Data Directory
Runtime state is persisted in `{WILD_CENTRAL_DATA_DIR}/`:
- `state.yaml` — operator, domain, firewall, DDNS, DHCP settings (managed via API)
- `secrets.yaml` — API tokens and credentials
- `services/` — per-domain service registration files
- `nats/` — embedded NATS JetStream data
- `instances/` — Wild Cloud instance configs
## Service Registration API
The key abstraction. Services register with Central to get DNS, proxy, TLS, and public exposure.
@@ -62,15 +72,14 @@ DELETE /api/v1/services/{name} Deregister service
```json
{
"name": "my-api",
"source": "wild-works",
"domain": "my-api.payne.io",
"source": "wild-works",
"backend": {
"address": "192.168.8.60:9001",
"type": "http",
"health": "/health"
},
"reach": "internal",
"public": false,
"tls": "terminate"
}
```
@@ -81,8 +90,7 @@ DELETE /api/v1/services/{name} Deregister service
- `http` — L7, Central terminates TLS with wildcard cert (Wild Works services)
- `static` — L7, static file serving (Wild Works frontends)
### Reach levels
### Public
- `off` — localhost only
- `internal` — LAN-visible (DNS + proxy + TLS)
- `public` — internet-visible (+ tunnel or direct exposure)
- `false` (default) — LAN-visible only (DNS + proxy + TLS)
- `true` — internet-visible (+ DDNS or tunnel exposure)

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,
allowedOrigins: allowedOrigins,
config: configMgr,
secrets: secrets.NewManager(),
secrets: secrets.NewManager(filepath.Join(dataDir, "secrets.yaml")),
dnsmasq: dnsmasq.NewConfigGenerator(dnsmasqConfigPath),
haproxy: haproxy.NewManager(haproxyConfigPath),
nftables: nftables.NewManager(nftablesRulesPath),
@@ -327,26 +327,12 @@ func (api *API) UpdateGlobalConfig(w http.ResponseWriter, r *http.Request) {
// GetGlobalSecrets returns the global secrets (redacted by default)
func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
data, err := os.ReadFile(secretsPath)
secretsMap, err := api.secrets.GetAll()
if err != nil {
if os.IsNotExist(err) {
respondJSON(w, http.StatusOK, map[string]any{})
return
}
respondError(w, http.StatusInternalServerError, "Failed to read secrets")
return
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to parse secrets")
return
}
if secretsMap == nil {
secretsMap = map[string]any{}
}
showRaw := r.URL.Query().Get("raw") == "true"
if !showRaw {
redactSecrets(secretsMap)
@@ -357,8 +343,6 @@ func (api *API) GetGlobalSecrets(w http.ResponseWriter, r *http.Request) {
// UpdateGlobalSecrets updates the global secrets
func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
body, err := io.ReadAll(r.Body)
if err != nil {
respondError(w, http.StatusBadRequest, "Failed to read request body")
@@ -371,36 +355,7 @@ func (api *API) UpdateGlobalSecrets(w http.ResponseWriter, r *http.Request) {
return
}
existingContent, err := storage.ReadFile(secretsPath)
if err != nil && !os.IsNotExist(err) {
respondError(w, http.StatusInternalServerError, "Failed to read existing secrets")
return
}
var existing map[string]any
if len(existingContent) > 0 {
if err := yaml.Unmarshal(existingContent, &existing); err != nil {
respondError(w, http.StatusBadRequest, "Failed to parse existing secrets")
return
}
} else {
existing = make(map[string]any)
}
for key, value := range updates {
existing[key] = value
}
yamlContent, err := yaml.Marshal(existing)
if err != nil {
respondError(w, http.StatusInternalServerError, "Failed to marshal YAML")
return
}
lockPath := secretsPath + ".lock"
if err := storage.WithLock(lockPath, func() error {
return storage.WriteFile(secretsPath, yamlContent, 0600)
}); err != nil {
if err := api.secrets.MergeUpdate(updates); err != nil {
respondError(w, http.StatusInternalServerError, "Failed to write secrets")
return
}
@@ -488,45 +443,19 @@ func redactSecrets(m map[string]any) {
// getCloudflareToken reads the Cloudflare API token from global secrets
func (api *API) getCloudflareToken() string {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
data, err := os.ReadFile(secretsPath)
token, err := api.secrets.GetSecret("cloudflare.apiToken")
if err != nil {
return ""
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return ""
}
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
if !ok {
return ""
}
token, _ := cloudflare["apiToken"].(string)
return token
}
// getCloudflareZoneID reads the Cloudflare Zone ID from global secrets
func (api *API) getCloudflareZoneID() string {
secretsPath := filepath.Join(api.dataDir, "secrets.yaml")
data, err := os.ReadFile(secretsPath)
zoneID, err := api.secrets.GetSecret("cloudflare.zoneId")
if err != nil {
return ""
}
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return ""
}
cloudflare, ok := secretsMap["cloudflare"].(map[string]any)
if !ok {
return ""
}
zoneID, _ := cloudflare["zoneId"].(string)
return zoneID
}

View File

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

View File

@@ -1,82 +1,17 @@
package secrets
import (
"bytes"
"crypto/rand"
"errors"
"fmt"
"math/big"
"os/exec"
"path/filepath"
"strings"
"os"
"github.com/wild-cloud/wild-central/internal/storage"
"github.com/wild-cloud/wild-central/internal/tools"
"gopkg.in/yaml.v3"
)
// YQ provides a wrapper around the yq command-line tool
type YQ struct {
yqPath string
}
// NewYQ creates a new YQ wrapper
func NewYQ() *YQ {
path, err := exec.LookPath("yq")
if err != nil {
path = "yq"
}
return &YQ{yqPath: path}
}
// Get retrieves a value from a YAML file using a yq expression
func (y *YQ) Get(filePath, expression string) (string, error) {
cmd := exec.Command(y.yqPath, expression, filePath)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("yq get failed: %w, stderr: %s", err, stderr.String())
}
return strings.TrimSpace(stdout.String()), nil
}
// Set sets a value in a YAML file using a yq expression
func (y *YQ) Set(filePath, expression, value string) error {
if !strings.HasPrefix(expression, ".") {
expression = "." + expression
}
quotedValue := fmt.Sprintf(`"%s"`, strings.ReplaceAll(value, `"`, `\"`))
setExpr := fmt.Sprintf("%s = %s", expression, quotedValue)
cmd := exec.Command(y.yqPath, "-i", setExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq set failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Delete removes a key from a YAML file
func (y *YQ) Delete(filePath, expression string) error {
delExpr := fmt.Sprintf("del(%s)", expression)
cmd := exec.Command(y.yqPath, "-i", delExpr, filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yq delete failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
// Validate checks if a YAML file is valid
func (y *YQ) Validate(filePath string) error {
cmd := exec.Command(y.yqPath, "eval", ".", filePath)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("yaml validation failed: %w, stderr: %s", err, stderr.String())
}
return nil
}
const (
// DefaultSecretLength is 32 characters
DefaultSecretLength = 32
@@ -84,19 +19,21 @@ const (
alphanumeric = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
)
// Manager handles secret generation and storage
// Manager handles secret storage backed by a single YAML file.
type Manager struct {
yq *YQ
secretsPath string
yq *tools.YQ
}
// NewManager creates a new secrets manager
func NewManager() *Manager {
// NewManager creates a new secrets manager bound to the given file path.
func NewManager(secretsPath string) *Manager {
return &Manager{
yq: NewYQ(),
secretsPath: secretsPath,
yq: tools.NewYQ(),
}
}
// GenerateSecret generates a cryptographically secure random alphanumeric string
// GenerateSecret generates a cryptographically secure random alphanumeric string.
func GenerateSecret(length int) (string, error) {
if length <= 0 {
length = DefaultSecretLength
@@ -114,54 +51,17 @@ func GenerateSecret(length int) (string, error) {
return string(result), nil
}
// EnsureSecretsFile ensures a secrets file exists with proper structure and permissions
func (m *Manager) EnsureSecretsFile(dataDir, instanceName string) error {
secretsPath := filepath.Join(dataDir, "instances", instanceName, "config", "secrets.yaml")
// Check if secrets file already exists
if storage.FileExists(secretsPath) {
// Ensure proper permissions
if err := storage.EnsureFilePermissions(secretsPath, 0600); err != nil {
return err
}
return nil
// GetSecret retrieves a secret value by dot-notation key.
func (m *Manager) GetSecret(key string) (string, error) {
if !storage.FileExists(m.secretsPath) {
return "", fmt.Errorf("secrets file not found: %s", m.secretsPath)
}
// Create minimal secrets structure
initialSecrets := `# Wild Cloud Instance Secrets
# WARNING: This file contains sensitive data. Keep secure!
cluster:
talosSecrets: ""
kubeconfig: ""
cloudflare:
token: ""
`
// Ensure config directory exists
if err := storage.EnsureDir(filepath.Join(dataDir, "instances", instanceName, "config"), 0755); err != nil {
return err
}
// Write secrets file with restrictive permissions (0600)
if err := storage.WriteFile(secretsPath, []byte(initialSecrets), 0600); err != nil {
return err
}
return nil
}
// GetSecret retrieves a secret value from a secrets file
func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
if !storage.FileExists(secretsPath) {
return "", fmt.Errorf("secrets file not found: %s", secretsPath)
}
value, err := m.yq.Get(secretsPath, fmt.Sprintf(".%s", key))
value, err := m.yq.Get(m.secretsPath, fmt.Sprintf(".%s", key))
if err != nil {
return "", fmt.Errorf("getting secret %s: %w", key, err)
}
// yq returns "null" for non-existent keys
if value == "" || value == "null" {
return "", fmt.Errorf("secret not found: %s", key)
}
@@ -169,69 +69,75 @@ func (m *Manager) GetSecret(secretsPath, key string) (string, error) {
return value, nil
}
// SetSecret sets a secret value in a secrets file
func (m *Manager) SetSecret(secretsPath, key, value string) error {
if !storage.FileExists(secretsPath) {
return fmt.Errorf("secrets file not found: %s", secretsPath)
// SetSecret sets a secret value by dot-notation key.
func (m *Manager) SetSecret(key, value string) error {
if !storage.FileExists(m.secretsPath) {
return fmt.Errorf("secrets file not found: %s", m.secretsPath)
}
// Acquire lock before modifying
lockPath := secretsPath + ".lock"
lockPath := m.secretsPath + ".lock"
return storage.WithLock(lockPath, func() error {
// Don't wrap value in quotes - yq handles YAML quoting automatically
if err := m.yq.Set(secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
if err := m.yq.Set(m.secretsPath, fmt.Sprintf(".%s", key), value); err != nil {
return err
}
// Ensure permissions remain secure after modification
return storage.EnsureFilePermissions(secretsPath, 0600)
return storage.EnsureFilePermissions(m.secretsPath, 0600)
})
}
// EnsureSecret generates and sets a secret only if it doesn't exist (idempotent)
func (m *Manager) EnsureSecret(secretsPath, key string, length int) (string, error) {
if !storage.FileExists(secretsPath) {
return "", fmt.Errorf("secrets file not found: %s", secretsPath)
// DeleteSecret removes a secret by dot-notation key.
func (m *Manager) DeleteSecret(key string) error {
if !storage.FileExists(m.secretsPath) {
return fmt.Errorf("secrets file not found: %s", m.secretsPath)
}
// Check if secret already exists
existingSecret, err := m.GetSecret(secretsPath, key)
if err == nil && existingSecret != "" && existingSecret != "null" {
// Secret already exists, return it
return existingSecret, nil
lockPath := m.secretsPath + ".lock"
return storage.WithLock(lockPath, func() error {
if err := m.yq.Delete(m.secretsPath, fmt.Sprintf(".%s", key)); err != nil {
return err
}
return storage.EnsureFilePermissions(m.secretsPath, 0600)
})
}
// Generate new secret
secret, err := GenerateSecret(length)
// GetAll reads and returns the full secrets map.
func (m *Manager) GetAll() (map[string]any, error) {
data, err := os.ReadFile(m.secretsPath)
if err != nil {
return "", err
if errors.Is(err, os.ErrNotExist) {
return map[string]any{}, nil
}
return nil, fmt.Errorf("reading secrets: %w", err)
}
// Set the secret
if err := m.SetSecret(secretsPath, key, secret); err != nil {
return "", err
var secretsMap map[string]any
if err := yaml.Unmarshal(data, &secretsMap); err != nil {
return nil, fmt.Errorf("parsing secrets: %w", err)
}
if secretsMap == nil {
secretsMap = map[string]any{}
}
return secret, nil
return secretsMap, nil
}
// GenerateAndStoreSecret is a convenience function that generates a secret and stores it
func (m *Manager) GenerateAndStoreSecret(secretsPath, key string) (string, error) {
return m.EnsureSecret(secretsPath, key, DefaultSecretLength)
}
// DeleteSecret removes a secret from a secrets file
func (m *Manager) DeleteSecret(secretsPath, key string) error {
if !storage.FileExists(secretsPath) {
return fmt.Errorf("secrets file not found: %s", secretsPath)
}
// Acquire lock before modifying
lockPath := secretsPath + ".lock"
// MergeUpdate merges the provided key-value pairs into the existing secrets file.
func (m *Manager) MergeUpdate(updates map[string]any) error {
lockPath := m.secretsPath + ".lock"
return storage.WithLock(lockPath, func() error {
if err := m.yq.Delete(secretsPath, fmt.Sprintf(".%s", key)); err != nil {
existing, err := m.GetAll()
if err != nil {
return err
}
// Ensure permissions remain secure after modification
return storage.EnsureFilePermissions(secretsPath, 0600)
for key, value := range updates {
existing[key] = value
}
yamlContent, err := yaml.Marshal(existing)
if err != nil {
return fmt.Errorf("marshaling secrets: %w", err)
}
return storage.WriteFile(m.secretsPath, yamlContent, 0600)
})
}

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 {
LayoutDashboard, Cloud, CheckCircle, XCircle, AlertCircle, Loader2,
RefreshCw, BookOpen, Globe, Shield, Router, Server, RotateCw, Key, Plus, X,
RefreshCw, BookOpen, Globe, Router, Server, RotateCw, Key, Plus, X,
} from 'lucide-react';
import { useCloudflare } from '../hooks/useCloudflare';
import { useDdns } from '../hooks/useDdns';
import { useCentralStatus } from '../hooks/useCentralStatus';
import { useCert } from '../hooks/useCert';
import { useConfig } from '../hooks';
import { useVpn } from '../hooks/useVpn';
import { secretsApi } from '../services/api';
import { usePageHelp } from '../hooks/usePageHelp';
import type { DdnsRecordStatus } from '../services/api/networking';
@@ -22,8 +22,8 @@ export function DashboardComponent() {
const { data: centralStatus, isLoading: statusLoading, restart: restartDaemon, isRestarting } = useCentralStatus();
const { verification, isLoading: cfLoading } = useCloudflare();
const { status: ddnsStatus, isLoadingStatus: ddnsLoading, trigger: triggerDdns, isTriggering } = useDdns();
const { status: certStatus } = useCert();
const { config: globalConfig, updateConfig } = useConfig();
const { config: vpnConfig } = useVpn();
const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({
@@ -52,10 +52,8 @@ export function DashboardComponent() {
// Warning conditions
const cfTokenMissing = !cfLoading && !verification?.tokenConfigured;
const cfTokenInvalid = !cfLoading && verification?.tokenConfigured && !verification?.tokenValid;
const certExpiring = certStatus?.certs?.some(c => c.cert.exists && (c.cert.daysLeft ?? 999) < 14);
const certMissing = certStatus?.certs?.some(c => !c.cert.exists);
const ddnsFailed = ddnsStatus?.enabled && ddnsStatus?.lastError;
const hasWarnings = cfTokenMissing || cfTokenInvalid || certExpiring || certMissing || ddnsFailed;
const hasWarnings = cfTokenMissing || cfTokenInvalid || ddnsFailed;
usePageHelp({
title: 'What is the Dashboard?',
@@ -103,18 +101,6 @@ export function DashboardComponent() {
<AlertDescription>Cloudflare API token is invalid. Check your token in Cloudflare settings.</AlertDescription>
</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 && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
@@ -426,62 +412,39 @@ export function DashboardComponent() {
)}
</Card>
{/* 4. TLS Certificates */}
{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 */}
{/* 4. 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">
<div className="flex items-center gap-2 mb-3">
<Router className="h-5 w-5 text-amber-500" />
<div className="font-medium">Gateway Router</div>
</div>
<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">
<li>
Forward DNS to Wild Central
Set the router's DNS server to Wild Central
{globalConfig?.cloud?.dnsmasq?.ip && (
<span className="font-mono bg-muted px-1.5 py-0.5 rounded ml-1">
{globalConfig.cloud.dnsmasq.ip}
</span>
)}
</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>Set Wild Central as the primary DNS server for your LAN</li>
<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>
</div>
</Card>

View File

@@ -9,13 +9,14 @@ import { Switch } from './ui/switch';
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible';
import {
Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw,
Plus, Trash2, X, ChevronDown, ChevronUp,
Plus, Trash2, X, ChevronDown, ChevronUp, Route, Shield, FileText,
} from 'lucide-react';
import { useHaproxy } from '../hooks/useHaproxy';
import { useServices } from '../hooks/useServices';
import { useCert } from '../hooks/useCert';
import { usePageHelp } from '../hooks/usePageHelp';
import type { RegisteredService } from '../services/api/networking';
import type { CertEntry } from '../services/api/cert';
import { servicesApi, haproxyApi } from '../services/api/networking';
function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: string }) {
@@ -30,8 +31,43 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st
}
function getTlsStatus(service: RegisteredService, certDomains: Set<string>): 'ok' | 'error' | 'na' {
if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na';
return certDomains.has(service.domain) ? 'ok' : 'error';
const backendType = service.routes?.length ? service.routes[0].backend.type : service.backend.type;
if (service.tls === 'passthrough' || backendType === 'tcp-passthrough') return 'na';
// Check exact match or wildcard coverage
if (certDomains.has(service.domain)) return 'ok';
const dotIdx = service.domain.indexOf('.');
if (dotIdx > 0) {
const parent = service.domain.slice(dotIdx + 1);
if (certDomains.has(parent)) return 'ok';
}
return 'error';
}
function findCert(domain: string, certs: CertEntry[]): CertEntry | undefined {
// Exact match first
const exact = certs.find(c => c.cert.exists && c.domain === domain);
if (exact) return exact;
// Wildcard: check parent domain
const dotIdx = domain.indexOf('.');
if (dotIdx > 0) {
const parent = domain.slice(dotIdx + 1);
return certs.find(c => c.cert.exists && c.domain === parent);
}
return undefined;
}
function hasRoutes(service: RegisteredService): boolean {
return (service.routes?.length ?? 0) > 0;
}
function getBackendAddress(service: RegisteredService): string {
if (hasRoutes(service)) return service.routes![0].backend.address;
return service.backend.address;
}
function getBackendType(service: RegisteredService): string {
if (hasRoutes(service)) return service.routes![0].backend.type;
return service.backend.type;
}
export function ServicesComponent() {
@@ -43,7 +79,17 @@ export function ServicesComponent() {
restart: restartHaproxy, isRestarting: isHaproxyRestarting,
} = useHaproxy();
const { services, isLoading: isServicesLoading } = useServices();
const { status: certStatus } = useCert();
const { status: certStatus, provision: provisionCert, isProvisioning, renew: renewCert, isRenewing } = useCert();
const provisionMutation = useMutation({
mutationFn: (domain: string) => provisionCert(domain),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
});
const renewMutation = useMutation({
mutationFn: () => renewCert(),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cert'] }),
});
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(new Set());
@@ -197,7 +243,8 @@ export function ServicesComponent() {
const isExpanded = expandedDomains.has(service.domain);
const tlsStatus = getTlsStatus(service, certDomains);
const ddnsStatus2 = service.public ? 'ok' as const : 'na' as const;
const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough';
const isPassthrough = service.tls === 'passthrough' || getBackendType(service) === 'tcp-passthrough';
const routeCount = service.routes?.length ?? 0;
return (
<Card key={service.domain}>
@@ -208,6 +255,9 @@ export function ServicesComponent() {
<span className="font-mono text-sm font-medium truncate">
{service.subdomains ? `*.${service.domain}` : service.domain}
</span>
{routeCount > 1 && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded">{routeCount} routes</span>
)}
</div>
<div className="flex items-center gap-3 shrink-0">
<StatusDot status="ok" label="DNS" />
@@ -223,6 +273,7 @@ export function ServicesComponent() {
<CardContent className="pt-0 pb-4 px-4 space-y-4">
{/* Controls */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-3 py-2">
{!hasRoutes(service) && (
<div>
<Label className="text-xs text-muted-foreground">Backend</Label>
<Input
@@ -239,6 +290,7 @@ export function ServicesComponent() {
}}
/>
</div>
)}
<div className="flex items-center gap-2">
<Switch
@@ -261,19 +313,142 @@ export function ServicesComponent() {
<div className="flex items-center gap-2">
<Switch
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,
updates: {
tls: v ? 'terminate' : 'passthrough',
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
},
})}
});
}
}}
disabled={updateMutation.isPending}
/>
<Label className="text-sm">{isPassthrough ? 'TLS Passthrough' : 'Central TLS'}</Label>
</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 */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>Source: {service.source}</span>

View File

@@ -2,6 +2,22 @@ import { apiClient } from './client';
// 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 {
domain: string;
source: string;
@@ -13,6 +29,7 @@ export interface RegisteredService {
};
public: boolean;
tls?: string;
routes?: Route[];
}
export const servicesApi = {