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

@@ -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,22 +273,24 @@ 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">
<div>
<Label className="text-xs text-muted-foreground">Backend</Label>
<Input
defaultValue={service.backend.address}
className="mt-0.5 h-8 font-mono text-sm w-48"
onBlur={e => {
const val = e.target.value.trim();
if (val && val !== service.backend.address) {
updateMutation.mutate({
domain: service.domain,
updates: { backend: { ...service.backend, address: val } },
});
}
}}
/>
</div>
{!hasRoutes(service) && (
<div>
<Label className="text-xs text-muted-foreground">Backend</Label>
<Input
defaultValue={service.backend.address}
className="mt-0.5 h-8 font-mono text-sm w-48"
onBlur={e => {
const val = e.target.value.trim();
if (val && val !== service.backend.address) {
updateMutation.mutate({
domain: service.domain,
updates: { backend: { ...service.backend, address: val } },
});
}
}}
/>
</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({
domain: service.domain,
updates: {
tls: v ? 'terminate' : 'passthrough',
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
},
})}
onCheckedChange={v => {
if (hasRoutes(service)) {
updateMutation.mutate({
domain: service.domain,
updates: { tls: v ? 'terminate' : 'passthrough' },
});
} else {
updateMutation.mutate({
domain: service.domain,
updates: {
tls: v ? 'terminate' : 'passthrough',
backend: { ...service.backend, type: v ? 'http' : 'tcp-passthrough' },
},
});
}
}}
disabled={updateMutation.isPending}
/>
<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>