From 98385449b479fc27e272d245c396d3b97bc00dc6 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 23:50:05 +0000 Subject: [PATCH] feat: Service cards with inline toggles + updated docs Service cards now have: - Public/Private toggle (switches reach) - Subdomains toggle (include *.domain) - TLS badge (passthrough vs terminate, read-only for now) - Inline status details (DNS, Proxy, TLS status) - Deregister button - Add Service form with toggles instead of dropdowns The card speaks the user's language (public/private, subdomains on/off) not implementation details (tcp-passthrough vs http, reach: internal). Also updated docs/registrations.md: - Added "User-facing concepts" section mapping API fields to toggles - Added batch deregister endpoint - Added backend.health field - Cleaner examples Co-Authored-By: Claude Opus 4.6 (1M context) --- docs/registrations.md | 106 +++++------- web/src/components/ServicesComponent.tsx | 211 ++++++++++++----------- 2 files changed, 146 insertions(+), 171 deletions(-) diff --git a/docs/registrations.md b/docs/registrations.md index cb099d8..82a1ee9 100644 --- a/docs/registrations.md +++ b/docs/registrations.md @@ -10,6 +10,7 @@ GET /api/v1/services List all registrations GET /api/v1/services/{domain} Get registration details PATCH /api/v1/services/{domain} Update a registration DELETE /api/v1/services/{domain} Remove a registration +DELETE /api/v1/services/deregister?source=X&backend=Y Batch cleanup ``` The domain is the unique key. One registration per domain. @@ -22,48 +23,51 @@ The domain is the unique key. One registration per domain. | `source` | string | no | `"manual"` | Who registered this: `wild-cloud`, `wild-works`, `manual`. | | `backend.address` | string | yes | — | Target host:port (e.g., `192.168.8.240:443`). | | `backend.type` | string | yes | — | `tcp-passthrough` or `http`. | +| `backend.health` | string | no | — | Health check path for L7 services (e.g., `/health`). | | `subdomains` | bool | no | `false` | If true, also routes `*.domain` traffic to this backend. | | `reach` | string | yes | — | `internal` or `public`. | -| `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on type. | +| `tls` | string | no | inferred | `passthrough` or `terminate`. Defaults based on backend type. | -### `backend.type` +## User-facing concepts -**`tcp-passthrough`** — Layer 4. Central passes TLS traffic through to the backend without terminating it. The backend handles its own TLS (e.g., a k8s cluster with traefik and its own wildcard cert). HAProxy routes by SNI. +The API fields map to three user-visible controls: -**`http`** — Layer 7. Central terminates TLS (using a certbot-provisioned certificate) and reverse-proxies HTTP to the backend. HAProxy routes by Host header. +### Public / Private -### `subdomains` +`reach: "public"` or `reach: "internal"` -Controls whether `*.domain` traffic is also routed to this backend. +- **Private** (internal): Domain resolves only on the LAN. No public DNS record. Accessible only from your local network or VPN. +- **Public**: Domain resolves on the LAN AND has a public DNS A record. Accessible from the internet through Central's HAProxy. -- **`false`** (default): Only exact-match `domain` is routed. Use for custom domains like `payne.io` or `civilsociety.dev` where you only want that specific hostname. -- **`true`**: Both `domain` and `*.domain` are routed. Use for instance primary domains like `cloud.payne.io` where apps are served at subdomains (`matrix.cloud.payne.io`, `vaultwarden.cloud.payne.io`). +### Subdomains -### `reach` +`subdomains: true` or `subdomains: false` -Controls DNS visibility and external access. +- **Off** (default): Only the exact domain is routed (`payne.io` routes, but `foo.payne.io` does not). +- **On**: Both the domain and all subdomains are routed (`cloud.payne.io` AND `matrix.cloud.payne.io`, `vaultwarden.cloud.payne.io`, etc.). -- **`internal`**: Domain resolves only on the LAN (dnsmasq `local=/` + `address=/`). Not added to DDNS. Not routable from the internet. -- **`public`**: Domain resolves on the LAN (dnsmasq `address=/`) AND externally via DDNS A record. Routable from the internet through Central's HAProxy. +### TLS handling -### `tls` +`tls: "passthrough"` or `tls: "terminate"` -- **`passthrough`**: Central does not terminate TLS — the backend handles it. Default for `tcp-passthrough`. -- **`terminate`**: Central provisions a TLS certificate (via certbot + Cloudflare DNS-01) and terminates TLS at HAProxy. Default for `http`. +- **Passthrough**: Central forwards encrypted traffic directly to the backend. The backend handles its own TLS certificates (e.g., a k8s cluster with traefik). Maps to `backend.type: "tcp-passthrough"`. +- **Terminate**: Central provisions a TLS certificate and handles HTTPS. Traffic is decrypted at Central and proxied as HTTP to the backend. Maps to `backend.type: "http"`. ## What Central does per registration -| | `tcp-passthrough` | `http` | -|---|---|---| -| **Internal DNS** | `address=//` — LAN clients connect directly to the backend | `address=//` — LAN clients connect to Central's HAProxy | -| **DNS (internal reach)** | Also `local=//` — prevents upstream DNS forwarding | Same | -| **External DNS (public reach)** | DDNS A record at Cloudflare | Same | -| **HAProxy** | L4 SNI → backend. `subdomains: true` adds `*.domain` matching. | L7 Host header → reverse proxy to backend. Always exact match. | -| **TLS** | Passthrough — backend handles TLS | Terminate — Central provisions cert via certbot | +When a service is registered, Central automatically: + +| Effect | Passthrough | Terminate | +|--------|-------------|-----------| +| **LAN DNS** | `address=//` — direct to backend | `address=//` — through Central | +| **LAN DNS (private)** | Also `local=//` — prevents upstream forwarding | Same | +| **Public DNS** | DDNS A record if public | Same | +| **Proxy** | L4 SNI passthrough. Subdomains adds `*.domain` matching. | L7 HTTP reverse proxy by Host header. | +| **TLS** | Backend handles — Central passes through | Central provisions cert via Let's Encrypt | ## Examples -### Wild Cloud k8s instance (primary domain with subdomains) +### k8s cluster (passthrough, public, with subdomains) ```json { @@ -75,30 +79,22 @@ Controls DNS visibility and external access. } ``` -Result: -- DNS: `cloud.payne.io` → 192.168.8.240, `*.cloud.payne.io` → 192.168.8.240 -- HAProxy: SNI `cloud.payne.io` and `*.cloud.payne.io` → passthrough to 192.168.8.240:443 -- DDNS: A record for `cloud.payne.io` → public IP -- TLS: handled by k8s traefik (passthrough) +Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI + wildcard *.cloud.payne.io, TLS passthrough. -### Wild Cloud k8s instance (internal-only domain) +### Internal web app (terminate, private) ```json { - "domain": "internal.cloud.payne.io", + "domain": "wild-cloud.payne.io", "source": "wild-cloud", - "backend": {"address": "192.168.8.240:443", "type": "tcp-passthrough"}, - "subdomains": true, + "backend": {"address": "127.0.0.1:5055", "type": "http"}, "reach": "internal" } ``` -Result: -- DNS: `local=/internal.cloud.payne.io/` + `address=/internal.cloud.payne.io/192.168.8.240` -- No DDNS record (internal only) -- HAProxy: SNI matching for internal domain (in case traffic comes through Central) +Central creates: LAN DNS → Central IP (with `local=/`), HAProxy L7 reverse proxy, TLS cert via certbot. No public DNS. -### Custom app domain (exact match, no subdomains) +### Custom domain (passthrough, public, exact match) ```json { @@ -110,36 +106,14 @@ Result: } ``` -Result: -- DNS: `address=/payne.io/192.168.8.240` -- HAProxy: SNI exact match `payne.io` only (NOT `*.payne.io`) -- DDNS: A record for `payne.io` → public IP - -### Wild Cloud app (HTTP reverse proxy, internal only) - -```json -{ - "domain": "wild-cloud.payne.io", - "source": "wild-cloud", - "backend": {"address": "127.0.0.1:5055", "type": "http"}, - "reach": "internal" -} -``` - -Result: -- DNS: `local=/wild-cloud.payne.io/` + `address=/wild-cloud.payne.io/192.168.8.151` -- HAProxy: L7 Host `wild-cloud.payne.io` → reverse proxy to 127.0.0.1:5055 -- TLS: Central provisions cert via certbot -- No DDNS (internal only) +Central creates: LAN DNS → 192.168.8.240, public DNS A record, HAProxy L4 SNI exact match only. No wildcard. ## What is NOT a registration -Central's own services are driven by Central config, not registrations: +Central's own services come from config, not the registration API: -- **Central's UI domain** (`cloud.central.domain` in config) -- **VPN** (`cloud.vpn.*` in config) — WireGuard endpoint, firewall port -- **Firewall rules** (`cloud.nftables.*` in config) -- **DHCP** (`cloud.dnsmasq.dhcp.*` in config) -- **CrowdSec**, certbot credentials, DDNS provider config - -These are managed through Central's own UI and config file. They don't go through the registration API. +- Central's UI domain (`cloud.central.domain`) +- VPN (`cloud.vpn.*`) +- Firewall rules (`cloud.nftables.*`) +- DHCP (`cloud.dnsmasq.dhcp.*`) +- CrowdSec, certbot credentials, DDNS provider config diff --git a/web/src/components/ServicesComponent.tsx b/web/src/components/ServicesComponent.tsx index 457dd6e..d262c6f 100644 --- a/web/src/components/ServicesComponent.tsx +++ b/web/src/components/ServicesComponent.tsx @@ -6,11 +6,12 @@ import { Input, Label } from './ui'; import { Alert, AlertDescription } from './ui/alert'; import { Textarea } from './ui/textarea'; import { Badge } from './ui/badge'; +import { Switch } from './ui/switch'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Collapsible, CollapsibleTrigger, CollapsibleContent } from './ui/collapsible'; import { Globe, Loader2, AlertCircle, CheckCircle, Play, RotateCw, - Plus, Trash2, X, ChevronDown, ChevronRight, ChevronUp, + Plus, Trash2, X, ChevronDown, ChevronUp, } from 'lucide-react'; import { useConfig } from '../hooks'; import { useHaproxy } from '../hooks/useHaproxy'; @@ -33,24 +34,10 @@ function StatusDot({ status, label }: { status: 'ok' | 'error' | 'na'; label: st } function getTlsStatus(service: RegisteredService, certDomains: Set): 'ok' | 'error' | 'na' { - // Passthrough: Central doesn't handle TLS — grey (not applicable) if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'na'; - // Terminate: Central handles TLS — green if cert exists, red if missing return certDomains.has(service.domain) ? 'ok' : 'error'; } -function getProxyLabel(service: RegisteredService): string { - if (service.backend.type === 'tcp-passthrough') { - return `L4 SNI passthrough${service.subdomains ? ' + wildcard' : ''}`; - } - return `L7 HTTP reverse proxy`; -} - -function getTlsLabel(service: RegisteredService, certDomains: Set): string { - if (service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough') return 'passthrough (backend handles)'; - return certDomains.has(service.domain) ? 'terminate (cert provisioned)' : 'terminate (cert missing)'; -} - export function ServicesComponent() { const queryClient = useQueryClient(); const { config: globalConfig } = useConfig(); @@ -69,12 +56,12 @@ export function ServicesComponent() { const [advancedConfig, setAdvancedConfig] = useState(''); const [loadingAdvanced, setLoadingAdvanced] = useState(false); - // Add service form state + // Add form state const [newDomain, setNewDomain] = useState(''); const [newBackendAddr, setNewBackendAddr] = useState(''); - const [newBackendType, setNewBackendType] = useState('tcp-passthrough'); const [newReach, setNewReach] = useState('public'); const [newSubdomains, setNewSubdomains] = useState(false); + const [newTls, setNewTls] = useState('passthrough'); const certDomains = new Set( (certStatus?.certs ?? []).filter(c => c.cert.exists).map(c => c.domain) @@ -82,15 +69,20 @@ export function ServicesComponent() { const publicIp = ddnsStatus?.currentIP; const registerMutation = useMutation({ - mutationFn: (svc: { domain: string; backend: { address: string; type: string }; reach: string; subdomains: boolean; source: string }) => - servicesApi.register(svc), + mutationFn: (svc: Record) => servicesApi.register(svc as any), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['services'] }); setShowAddForm(false); - setNewDomain(''); setNewBackendAddr(''); setNewBackendType('tcp-passthrough'); setNewReach('public'); setNewSubdomains(false); + setNewDomain(''); setNewBackendAddr(''); setNewReach('public'); setNewSubdomains(false); setNewTls('passthrough'); }, }); + const updateMutation = useMutation({ + mutationFn: ({ domain, updates }: { domain: string; updates: Record }) => + servicesApi.register({ ...(services.find(s => s.domain === domain) ?? {}), ...updates } as any), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), + }); + const deregisterMutation = useMutation({ mutationFn: (domain: string) => servicesApi.deregister(domain), onSuccess: () => queryClient.invalidateQueries({ queryKey: ['services'] }), @@ -100,22 +92,27 @@ export function ServicesComponent() { title: 'Services', description: (

- Each service represents a domain that Wild Central manages. When a service is registered, - Central creates DNS entries, proxy routes, and optionally provisions TLS certificates - and public DNS records. + Each service is a domain that Wild Central manages. Central provides DNS resolution, + proxy routing, TLS certificates, and public DNS records based on how you configure each service.

), }); + const toggleExpand = (domain: string) => { + setExpandedDomains(prev => { + const next = new Set(prev); + if (next.has(domain)) next.delete(domain); else next.add(domain); + return next; + }); + }; + const handleShowAdvanced = async () => { if (!showAdvanced) { setLoadingAdvanced(true); try { const result = await haproxyApi.getConfig(); setAdvancedConfig(result.content || ''); - } catch { - setAdvancedConfig('# Could not load HAProxy config'); - } + } catch { setAdvancedConfig('# Could not load HAProxy config'); } setLoadingAdvanced(false); } setShowAdvanced(!showAdvanced); @@ -123,7 +120,7 @@ export function ServicesComponent() { return (
- {/* Page header */} + {/* Header */}
@@ -135,15 +132,14 @@ export function ServicesComponent() {
- {/* Add Service Form */} + {/* Add Form */} {showAddForm && ( - +
@@ -153,44 +149,35 @@ export function ServicesComponent() { setNewBackendAddr(e.target.value)} placeholder="192.168.8.240:443" className="mt-1 font-mono" />
-
- - + - L4 TCP Passthrough - L7 HTTP Reverse Proxy - - -
-
- -
-
- @@ -207,15 +194,12 @@ export function ServicesComponent() { )} - {/* Empty state */} + {/* Empty */} {!isServicesLoading && services.length === 0 && (

No Services

-

- Services appear here when Wild Cloud or Wild Works registers domains, - or add one manually. -

+

Services appear when Wild Cloud or Wild Works registers domains, or add one manually.

)} @@ -224,32 +208,18 @@ export function ServicesComponent() { const isExpanded = expandedDomains.has(service.domain); const tlsStatus = getTlsStatus(service, certDomains); const ddnsStatus2 = service.reach === 'public' ? 'ok' as const : 'na' as const; + const isPassthrough = service.tls === 'passthrough' || service.backend.type === 'tcp-passthrough'; + const backendHost = service.backend.address.split(':')[0]; return ( - - + {/* Expanded — controls + status */} {isExpanded && ( - -
+ + {/* Controls */} +
+
+ +
{service.backend.address}
+
+ +
+ updateMutation.mutate({ domain: service.domain, updates: { reach: v ? 'public' : 'internal' } })} + disabled={updateMutation.isPending} + /> + +
+ +
+ updateMutation.mutate({ domain: service.domain, updates: { subdomains: v } })} + disabled={updateMutation.isPending} + /> + +
+ +
+ + + {isPassthrough ? 'Passthrough' : 'Terminate'} + +
+
+ + {/* Status details */} +
DNS (LAN) - {service.backend.type === 'tcp-passthrough' - ? `→ ${service.backend.address.split(':')[0]}` - : `→ ${globalConfig?.cloud?.dnsmasq?.ip ?? '(Central IP)'}`} + {service.domain} → {isPassthrough ? backendHost : (globalConfig?.cloud?.dnsmasq?.ip ?? 'Central')}
{service.reach === 'public' && ( @@ -279,28 +282,26 @@ export function ServicesComponent() { )}
Proxy - {getProxyLabel(service)} + + {isPassthrough ? 'L4 SNI' : 'L7 HTTP'}{service.subdomains ? ' + wildcard' : ''} +
TLS - {getTlsLabel(service, certDomains)} + + {isPassthrough ? 'backend handles' : (certDomains.has(service.domain) ? 'cert provisioned' : 'cert missing')} +
-
-
- Backend: {service.backend.address} - Source: {service.source} -
-
@@ -341,7 +342,7 @@ export function ServicesComponent() { {haproxyGenerateError && ( {(haproxyGenerateError as Error).message} )} -