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:
@@ -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' },
|
||||
];
|
||||
|
||||
213
web/src/router/pages/CertificatesPage.tsx
Normal file
213
web/src/router/pages/CertificatesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user