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:
@@ -182,7 +182,7 @@ export function CloudflareComponent() {
|
||||
<div className="font-medium">Central Domain</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{(certStatus?.certs?.some(c => c.cert.exists) || certStatus?.cert?.exists) && (
|
||||
{certStatus?.certs?.some(c => c.cert.exists) && (
|
||||
<Badge variant="success" className="gap-1">
|
||||
<Shield className="h-3 w-3" />
|
||||
TLS
|
||||
@@ -257,7 +257,7 @@ export function CloudflareComponent() {
|
||||
</div>
|
||||
|
||||
{globalConfig?.cloud?.central?.domain && (() => {
|
||||
const centralCert = certStatus?.certs?.find(c => c.type === 'central') ?? (certStatus?.cert ? { domain: certStatus.domain ?? '', type: 'central' as const, cert: certStatus.cert } : undefined);
|
||||
const centralCert = certStatus?.certs?.find(c => c.domain === globalConfig?.cloud?.central?.domain);
|
||||
return (
|
||||
<div className="ml-7 space-y-2">
|
||||
{certLoading ? (
|
||||
@@ -319,7 +319,7 @@ export function CloudflareComponent() {
|
||||
<div key={entry.domain} className="flex items-center justify-between text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono">{entry.domain}</span>
|
||||
<Badge variant="outline" className="text-xs">{entry.type}</Badge>
|
||||
<Badge variant="outline" className="text-xs">{entry.source}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{entry.cert.exists ? (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,19 @@ export interface CertInfo {
|
||||
|
||||
export interface CertEntry {
|
||||
domain: string;
|
||||
type: 'central' | 'wildcard' | 'service';
|
||||
service: string;
|
||||
source: string;
|
||||
cert: CertInfo;
|
||||
coveredBy?: string;
|
||||
}
|
||||
|
||||
export interface CertStatusResponse {
|
||||
configured: boolean;
|
||||
domain?: string;
|
||||
gatewayDomain?: string;
|
||||
canProvision: boolean;
|
||||
hasToken: boolean;
|
||||
hasEmail: boolean;
|
||||
message?: string;
|
||||
certs?: CertEntry[];
|
||||
/** @deprecated Use certs array instead */
|
||||
cert?: CertInfo;
|
||||
}
|
||||
|
||||
export interface CertProvisionResponse {
|
||||
|
||||
Reference in New Issue
Block a user