feat: Add Certificates page + stop auto-provisioning certs

New dedicated Certificates page in Wild Central UI showing:
- All tracked certs (central, wildcard, per-service)
- Status (valid with days remaining, or missing)
- Per-domain Provision button
- Renew All button

Cert provisioning is now user-initiated only — reconciliation logs
warnings about missing certs but does NOT auto-provision. This
prevents unexpected wildcard cert requests on startup.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:26:48 +00:00
parent 9eea061e9b
commit 447c7e51a3
4 changed files with 233 additions and 62 deletions

View File

@@ -1,5 +1,5 @@
import { NavLink } from 'react-router';
import { Sun, Moon, Monitor, Shield, Lock, Network, ShieldAlert, Router, Cloud, Wifi, Server } from 'lucide-react';
import { Sun, Moon, Monitor, Shield, ShieldCheck, Lock, Network, ShieldAlert, Router, Cloud, Wifi, Server } from 'lucide-react';
import {
Sidebar,
SidebarContent,
@@ -59,6 +59,7 @@ export function AppSidebar() {
{ to: '/central/ingress', icon: Network, label: 'Ingress Proxy' },
{ to: '/central/firewall', icon: Shield, label: 'Firewall' },
{ to: '/central/vpn', icon: Lock, label: 'VPN' },
{ to: '/central/certificates', icon: ShieldCheck, label: 'Certificates' },
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' },
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP' },
];

View File

@@ -0,0 +1,213 @@
import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Alert, AlertDescription } from '../../components/ui/alert';
import { Badge } from '../../components/ui/badge';
import { Shield, Loader2, CheckCircle, AlertCircle, RefreshCw, Plus, ShieldCheck, ShieldAlert } from 'lucide-react';
import { useCert } from '../../hooks/useCert';
import { usePageHelp } from '../../hooks/usePageHelp';
export function CertificatesPage() {
const {
status: certStatus,
isLoading,
provision: provisionCert,
isProvisioning,
renew: renewCerts,
isRenewing,
} = useCert();
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
usePageHelp({
title: 'TLS Certificates',
description: (
<p className="leading-relaxed">
Wild Central manages TLS certificates for HTTPS access to services on your LAN.
Certificates are provisioned via Let's Encrypt using Cloudflare DNS-01 challenges.
A wildcard certificate covers all services under your gateway domain.
</p>
),
});
const certs = certStatus?.certs ?? [];
const canProvision = certStatus?.canProvision ?? false;
const handleProvision = async (domain: string) => {
setProvisioningDomain(domain);
try {
await provisionCert(domain);
} finally {
setProvisioningDomain(null);
}
};
if (isLoading) {
return (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading certificate status...</p>
</Card>
);
}
const allExist = certs.length > 0 && certs.every((c: any) => c.cert?.exists);
const someExist = certs.some((c: any) => c.cert?.exists);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground">
Manage HTTPS certificates for your services
</p>
</div>
</div>
<div className="flex items-center gap-2">
{allExist ? (
<Badge variant="success" className="gap-1">
<ShieldCheck className="h-3 w-3" />
All Valid
</Badge>
) : someExist ? (
<Badge variant="warning" className="gap-1">
<ShieldAlert className="h-3 w-3" />
Incomplete
</Badge>
) : certs.length > 0 ? (
<Badge variant="destructive" className="gap-1">
<ShieldAlert className="h-3 w-3" />
No Certs
</Badge>
) : null}
</div>
</div>
{!canProvision && (
<Alert>
<AlertCircle className="h-4 w-4" />
<AlertDescription>
Certificate provisioning requires a Cloudflare API token and operator email.
{!certStatus?.hasToken && ' Configure the Cloudflare token on the Cloudflare page.'}
{!certStatus?.hasEmail && ' Set the operator email in the Overview.'}
</AlertDescription>
</Alert>
)}
{certs.length === 0 ? (
<Card className="p-8 text-center">
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No Certificates Tracked</h3>
<p className="text-muted-foreground">
Register services with Wild Central to see their certificate status here.
</p>
</Card>
) : (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Certificates</CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => renewCerts()}
disabled={isRenewing || !someExist}
>
{isRenewing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RefreshCw className="h-4 w-4 mr-1" />}
Renew All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg divide-y">
{certs.map((entry: any) => {
const cert = entry.cert;
const exists = cert?.exists;
const isThisProvisioning = provisioningDomain === entry.domain;
return (
<div key={entry.domain} className="px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3 min-w-0">
{exists ? (
<CheckCircle className="h-4 w-4 text-green-500 shrink-0" />
) : (
<AlertCircle className="h-4 w-4 text-red-500 shrink-0" />
)}
<div className="min-w-0">
<div className="font-mono text-sm truncate">{entry.domain}</div>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-xs">
{entry.type}
</Badge>
{entry.service && (
<span className="text-xs text-muted-foreground">
{entry.service}
</span>
)}
</div>
</div>
</div>
<div className="flex items-center gap-2 shrink-0 ml-4">
{exists ? (
<>
<Badge variant="success" className="gap-1">
<ShieldCheck className="h-3 w-3" />
{cert.daysLeft}d
</Badge>
{cert.issuerCN && (
<span className="text-xs text-muted-foreground hidden sm:inline">
{cert.issuerCN.split('CN=').pop()}
</span>
)}
</>
) : (
<>
<Badge variant="destructive" className="gap-1">Missing</Badge>
{canProvision && (
<Button
size="sm"
variant="outline"
onClick={() => handleProvision(entry.domain)}
disabled={isProvisioning}
>
{isThisProvisioning ? (
<Loader2 className="h-3 w-3 animate-spin mr-1" />
) : (
<Plus className="h-3 w-3 mr-1" />
)}
Provision
</Button>
)}
</>
)}
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
{certStatus?.gatewayDomain && (
<Card className="p-4 bg-gradient-to-r from-cyan-50 to-blue-50 dark:from-cyan-900/20 dark:to-blue-900/20 border-0">
<div className="flex items-start gap-3">
<Shield className="h-5 w-5 text-cyan-600 mt-0.5" />
<div className="text-sm space-y-1">
<p className="font-medium">How certificates work</p>
<p className="text-muted-foreground">
A wildcard certificate for <span className="font-mono">*.{certStatus.gatewayDomain}</span> covers
all services under that domain. Services outside this domain get individual certificates.
Certificates are provisioned via Let's Encrypt and auto-renewed by certbot.
</p>
</div>
</div>
</Card>
)}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { DnsPage } from './pages/DnsPage';
import { CrowdSecPage } from './pages/CrowdSecPage';
import { VpnPage } from './pages/VpnPage';
import { CloudflarePage } from './pages/CloudflarePage';
import { CertificatesPage } from './pages/CertificatesPage';
import { DdnsPage } from './pages/DdnsPage';
import { LanDnsPage } from './pages/LanDnsPage';
@@ -32,6 +33,7 @@ export const routes: RouteObject[] = [
{ path: 'ddns', element: <DdnsPage /> },
{ path: 'lan-dns', element: <LanDnsPage /> },
{ path: 'vpn', element: <VpnPage /> },
{ path: 'certificates', element: <CertificatesPage /> },
],
},
{