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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user