feat: Per-service certs with opt-in wildcard provisioning

Remove automatic wildcard cert assumption. Each service gets its own
cert by default. Wildcards are user-initiated via the Certificates page.

Backend:
- HAProxy L7 frontend uses cert directory (/etc/haproxy/certs/)
  instead of single wildcard file — loads all PEMs, serves by SNI
- Cert status API shows every registered service individually with
  coveredBy field when a wildcard cert covers the domain
- Reconciliation filters L7 routes to services that have a cert
- No auto-provisioning in reconciliation (just warnings)

Frontend:
- Certificates page shows per-service cert status
- "Provision" button for individual certs
- "Add Wildcard" form for opt-in wildcard provisioning
- Fixed CloudflareComponent type errors from cert API changes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:47:00 +00:00
parent 447c7e51a3
commit 7e8f660d11
6 changed files with 170 additions and 167 deletions

View File

@@ -1,9 +1,10 @@
import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input, Label } from '../../components/ui';
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 { Shield, Loader2, CheckCircle, AlertCircle, RefreshCw, Plus, ShieldCheck, ShieldAlert, X } from 'lucide-react';
import { useCert } from '../../hooks/useCert';
import { usePageHelp } from '../../hooks/usePageHelp';
@@ -17,14 +18,16 @@ export function CertificatesPage() {
isRenewing,
} = useCert();
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
const [showWildcardForm, setShowWildcardForm] = useState(false);
const [wildcardInput, setWildcardInput] = useState('');
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.
Wild Central provisions TLS certificates for services that need HTTPS.
Each service gets its own certificate by default. You can also provision
a wildcard certificate to cover multiple services under the same domain.
</p>
),
});
@@ -41,6 +44,19 @@ export function CertificatesPage() {
}
};
const handleProvisionWildcard = async () => {
if (!wildcardInput) return;
const domain = wildcardInput.startsWith('*.') ? wildcardInput : `*.${wildcardInput}`;
setProvisioningDomain(domain);
try {
await provisionCert(domain);
setShowWildcardForm(false);
setWildcardInput('');
} finally {
setProvisioningDomain(null);
}
};
if (isLoading) {
return (
<Card className="p-8 text-center">
@@ -50,8 +66,8 @@ export function CertificatesPage() {
);
}
const allExist = certs.length > 0 && certs.every((c: any) => c.cert?.exists);
const someExist = certs.some((c: any) => c.cert?.exists);
const allExist = certs.length > 0 && certs.every((c) => c.cert?.exists || c.coveredBy);
const someExist = certs.some((c) => c.cert?.exists || c.coveredBy);
return (
<div className="space-y-6">
@@ -63,28 +79,17 @@ export function CertificatesPage() {
<div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground">
Manage HTTPS certificates for your services
Manage HTTPS certificates for registered 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>
{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" />Missing</Badge>
) : null}
</div>
{!canProvision && (
@@ -92,8 +97,8 @@ export function CertificatesPage() {
<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.'}
{!certStatus?.hasToken && ' Configure the token on the Cloudflare page.'}
{!certStatus?.hasEmail && ' Set the operator email in Overview.'}
</AlertDescription>
</Alert>
)}
@@ -101,84 +106,87 @@ export function CertificatesPage() {
{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>
<h3 className="text-lg font-medium mb-2">No Services Registered</h3>
<p className="text-muted-foreground">
Register services with Wild Central to see their certificate status here.
Register services with Wild Central to manage their certificates 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>
<CardTitle>Service Certificates</CardTitle>
<div className="flex gap-2">
{canProvision && (
<Button variant="outline" size="sm" onClick={() => setShowWildcardForm(true)}>
<Plus className="h-4 w-4 mr-1" />Wildcard
</Button>
)}
<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>
</div>
</CardHeader>
<CardContent>
<CardContent className="space-y-3">
{showWildcardForm && (
<div className="p-3 border rounded-lg bg-muted/50 space-y-2">
<Label>Provision Wildcard Certificate</Label>
<div className="flex gap-2">
<div className="flex items-center gap-1 flex-1">
<span className="text-sm text-muted-foreground">*.</span>
<Input
value={wildcardInput}
onChange={(e) => setWildcardInput(e.target.value)}
placeholder="cloud.payne.io"
className="font-mono"
/>
</div>
<Button size="sm" onClick={handleProvisionWildcard} disabled={isProvisioning || !wildcardInput}>
{provisioningDomain?.startsWith('*.') ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
Provision
</Button>
<Button size="sm" variant="ghost" onClick={() => { setShowWildcardForm(false); setWildcardInput(''); }}>
<X className="h-4 w-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
A wildcard cert covers all subdomains. E.g., *.cloud.payne.io covers app1.cloud.payne.io, app2.cloud.payne.io, etc.
</p>
</div>
)}
<div className="border rounded-lg divide-y">
{certs.map((entry: any) => {
const cert = entry.cert;
const exists = cert?.exists;
{certs.map((entry) => {
const exists = entry.cert?.exists;
const covered = entry.coveredBy;
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 className="min-w-0">
<div className="font-mono text-sm truncate">{entry.domain}</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-muted-foreground">{entry.service}</span>
<span className="text-xs text-muted-foreground">({entry.source})</span>
</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="success" className="gap-1">
<ShieldCheck className="h-3 w-3" />{entry.cert.daysLeft}d
</Badge>
) : covered ? (
<Badge variant="outline" className="gap-1 text-green-600 border-green-300">
<CheckCircle className="h-3 w-3" />{covered}
</Badge>
) : (
<>
<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" />
)}
<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>
)}
@@ -192,22 +200,6 @@ export function CertificatesPage() {
</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>
);
}