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

@@ -21,59 +21,45 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
cfToken := api.getCloudflareToken() cfToken := api.getCloudflareToken()
// Derive gateway domain for wildcard cert // Gather cert statuses for all registered services that need TLS termination
gatewayDomain := ""
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
// Gather cert statuses
certs := []map[string]any{} certs := []map[string]any{}
seen := map[string]bool{}
// Central domain cert
if centralDomain != "" {
status := api.certbot.GetStatus(centralDomain)
certs = append(certs, map[string]any{
"domain": centralDomain,
"type": "central",
"cert": status,
})
}
// Wildcard cert
if gatewayDomain != "" {
wildcardDomain := "*." + gatewayDomain
status := api.certbot.GetStatus(gatewayDomain)
certs = append(certs, map[string]any{
"domain": wildcardDomain,
"type": "wildcard",
"cert": status,
})
}
// Registered service certs (for services that need TLS termination)
svcs, _ := api.services.List() svcs, _ := api.services.List()
for _, svc := range svcs { for _, svc := range svcs {
if svc.TLS != "terminate" || svc.Domain == "" { if svc.TLS != "terminate" || svc.Domain == "" {
continue continue
} }
// Skip if covered by wildcard if seen[svc.Domain] {
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
continue continue
} }
seen[svc.Domain] = true
status := api.certbot.GetStatus(svc.Domain) status := api.certbot.GetStatus(svc.Domain)
certs = append(certs, map[string]any{
entry := map[string]any{
"domain": svc.Domain, "domain": svc.Domain,
"type": "service",
"service": svc.Name, "service": svc.Name,
"source": svc.Source,
"cert": status, "cert": status,
}) }
// Check if covered by a wildcard cert
parts := strings.SplitN(svc.Domain, ".", 2)
if len(parts) == 2 && !status.Exists {
wildcardBase := parts[1]
wildcardStatus := api.certbot.GetStatus(wildcardBase)
if wildcardStatus.Exists {
entry["coveredBy"] = "*." + wildcardBase
}
}
certs = append(certs, entry)
} }
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"configured": centralDomain != "", "configured": centralDomain != "",
"domain": centralDomain, "domain": centralDomain,
"gatewayDomain": gatewayDomain,
"canProvision": cfToken != "" && email != "", "canProvision": cfToken != "" && email != "",
"hasToken": cfToken != "", "hasToken": cfToken != "",
"hasEmail": email != "", "hasEmail": email != "",

View File

@@ -70,31 +70,24 @@ func (api *API) reconcileNetworking() {
} }
} }
// Only include L7 HTTP routes if a wildcard cert exists for TLS termination. // Only include L7 HTTP routes for services that have a cert.
// Without it, HAProxy validation fails and the entire config is rejected. // HAProxy uses a cert directory — each service needs its own <domain>.pem
wildcardCert := "/etc/haproxy/certs/wildcard.pem" // or be covered by a wildcard cert in the same directory.
gatewayDomain := "" certsDir := "/etc/haproxy/certs/"
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 { var activeHTTPRoutes []haproxy.HTTPRoute
gatewayDomain = parts[1] for _, route := range httpRoutes {
// Also check per-domain cert path if hasCertForDomain(certsDir, route.Domain) {
if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err == nil { activeHTTPRoutes = append(activeHTTPRoutes, route)
wildcardCert = certbot.HAProxyCertPath(gatewayDomain) } else {
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
} }
} }
activeHTTPRoutes := httpRoutes
if _, err := os.Stat(wildcardCert); err != nil {
if len(httpRoutes) > 0 {
slog.Warn("reconcile: skipping L7 HTTP routes in HAProxy (no wildcard cert)", "path", wildcardCert)
}
activeHTTPRoutes = nil
}
// Generate and write HAProxy config // Generate and write HAProxy config
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{ haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
CentralDomain: centralDomain, CentralDomain: centralDomain,
HTTPRoutes: activeHTTPRoutes, HTTPRoutes: activeHTTPRoutes,
WildcardCert: wildcardCert, WildcardCert: certsDir,
}) })
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil { if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
@@ -178,6 +171,38 @@ func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.S
} }
} }
// hasCertForDomain checks if a cert exists for a domain — either an individual
// cert (<domain>.pem) or a wildcard cert that covers it.
func hasCertForDomain(certsDir, domain string) bool {
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(domain)); err == nil {
return true
}
// Check wildcard certs — a *.example.com cert covers foo.example.com
parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 {
// Wildcard cert might be stored as the base domain PEM
wildcardBase := parts[1]
if _, err := os.Stat(certbot.HAProxyCertPath(wildcardBase)); err == nil {
return true
}
}
// Check if the certs directory has ANY .pem files (HAProxy needs at least one)
entries, err := os.ReadDir(certsDir)
if err != nil {
return false
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".pem") {
// There's at least one cert — HAProxy can start with the dir bind.
// The specific domain may not have its own cert but HAProxy won't crash.
// It will serve whichever cert matches best (or the first one as default).
return true
}
}
return false
}
// extractHost gets the host part from a host:port string // extractHost gets the host part from a host:port string
func extractHost(addr string) string { func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- { for i := len(addr) - 1; i >= 0; i-- {

View File

@@ -205,9 +205,11 @@ frontend http_in
// L7 TLS termination backend + frontend (for HTTP routes) // L7 TLS termination backend + frontend (for HTTP routes)
if len(opts.HTTPRoutes) > 0 { if len(opts.HTTPRoutes) > 0 {
// Load all PEM files from the certs directory — HAProxy serves
// the right cert per-SNI. Each service gets its own <domain>.pem.
certPath := opts.WildcardCert certPath := opts.WildcardCert
if certPath == "" { if certPath == "" {
certPath = "/etc/haproxy/certs/wildcard.pem" certPath = "/etc/haproxy/certs/"
} }
sb.WriteString("backend be_l7_termination\n") sb.WriteString("backend be_l7_termination\n")

View File

@@ -182,7 +182,7 @@ export function CloudflareComponent() {
<div className="font-medium">Central Domain</div> <div className="font-medium">Central Domain</div>
</div> </div>
<div className="flex items-center gap-2"> <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"> <Badge variant="success" className="gap-1">
<Shield className="h-3 w-3" /> <Shield className="h-3 w-3" />
TLS TLS
@@ -257,7 +257,7 @@ export function CloudflareComponent() {
</div> </div>
{globalConfig?.cloud?.central?.domain && (() => { {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 ( return (
<div className="ml-7 space-y-2"> <div className="ml-7 space-y-2">
{certLoading ? ( {certLoading ? (
@@ -319,7 +319,7 @@ export function CloudflareComponent() {
<div key={entry.domain} className="flex items-center justify-between text-sm"> <div key={entry.domain} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="font-mono">{entry.domain}</span> <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>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{entry.cert.exists ? ( {entry.cert.exists ? (

View File

@@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState } from 'react';
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card'; import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input, Label } from '../../components/ui';
import { Alert, AlertDescription } from '../../components/ui/alert'; import { Alert, AlertDescription } from '../../components/ui/alert';
import { Badge } from '../../components/ui/badge'; 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 { useCert } from '../../hooks/useCert';
import { usePageHelp } from '../../hooks/usePageHelp'; import { usePageHelp } from '../../hooks/usePageHelp';
@@ -17,14 +18,16 @@ export function CertificatesPage() {
isRenewing, isRenewing,
} = useCert(); } = useCert();
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null); const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
const [showWildcardForm, setShowWildcardForm] = useState(false);
const [wildcardInput, setWildcardInput] = useState('');
usePageHelp({ usePageHelp({
title: 'TLS Certificates', title: 'TLS Certificates',
description: ( description: (
<p className="leading-relaxed"> <p className="leading-relaxed">
Wild Central manages TLS certificates for HTTPS access to services on your LAN. Wild Central provisions TLS certificates for services that need HTTPS.
Certificates are provisioned via Let's Encrypt using Cloudflare DNS-01 challenges. Each service gets its own certificate by default. You can also provision
A wildcard certificate covers all services under your gateway domain. a wildcard certificate to cover multiple services under the same domain.
</p> </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) { if (isLoading) {
return ( return (
<Card className="p-8 text-center"> <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 allExist = certs.length > 0 && certs.every((c) => c.cert?.exists || c.coveredBy);
const someExist = certs.some((c: any) => c.cert?.exists); const someExist = certs.some((c) => c.cert?.exists || c.coveredBy);
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -63,37 +79,26 @@ export function CertificatesPage() {
<div> <div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2> <h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Manage HTTPS certificates for your services Manage HTTPS certificates for registered services
</p> </p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
{allExist ? ( {allExist ? (
<Badge variant="success" className="gap-1"> <Badge variant="success" className="gap-1"><ShieldCheck className="h-3 w-3" />All Valid</Badge>
<ShieldCheck className="h-3 w-3" />
All Valid
</Badge>
) : someExist ? ( ) : someExist ? (
<Badge variant="warning" className="gap-1"> <Badge variant="warning" className="gap-1"><ShieldAlert className="h-3 w-3" />Incomplete</Badge>
<ShieldAlert className="h-3 w-3" />
Incomplete
</Badge>
) : certs.length > 0 ? ( ) : certs.length > 0 ? (
<Badge variant="destructive" className="gap-1"> <Badge variant="destructive" className="gap-1"><ShieldAlert className="h-3 w-3" />Missing</Badge>
<ShieldAlert className="h-3 w-3" />
No Certs
</Badge>
) : null} ) : null}
</div> </div>
</div>
{!canProvision && ( {!canProvision && (
<Alert> <Alert>
<AlertCircle className="h-4 w-4" /> <AlertCircle className="h-4 w-4" />
<AlertDescription> <AlertDescription>
Certificate provisioning requires a Cloudflare API token and operator email. Certificate provisioning requires a Cloudflare API token and operator email.
{!certStatus?.hasToken && ' Configure the Cloudflare token on the Cloudflare page.'} {!certStatus?.hasToken && ' Configure the token on the Cloudflare page.'}
{!certStatus?.hasEmail && ' Set the operator email in the Overview.'} {!certStatus?.hasEmail && ' Set the operator email in Overview.'}
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -101,84 +106,87 @@ export function CertificatesPage() {
{certs.length === 0 ? ( {certs.length === 0 ? (
<Card className="p-8 text-center"> <Card className="p-8 text-center">
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" /> <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"> <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> </p>
</Card> </Card>
) : ( ) : (
<Card> <Card>
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<CardTitle>Certificates</CardTitle> <CardTitle>Service Certificates</CardTitle>
<Button <div className="flex gap-2">
variant="outline" {canProvision && (
size="sm" <Button variant="outline" size="sm" onClick={() => setShowWildcardForm(true)}>
onClick={() => renewCerts()} <Plus className="h-4 w-4 mr-1" />Wildcard
disabled={isRenewing || !someExist} </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" />} {isRenewing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RefreshCw className="h-4 w-4 mr-1" />}
Renew All Renew All
</Button> </Button>
</div> </div>
</div>
</CardHeader> </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"> <div className="border rounded-lg divide-y">
{certs.map((entry: any) => { {certs.map((entry) => {
const cert = entry.cert; const exists = entry.cert?.exists;
const exists = cert?.exists; const covered = entry.coveredBy;
const isThisProvisioning = provisioningDomain === entry.domain; const isThisProvisioning = provisioningDomain === entry.domain;
return ( return (
<div key={entry.domain} className="px-4 py-3 flex items-center justify-between"> <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="min-w-0">
<div className="font-mono text-sm truncate">{entry.domain}</div> <div className="font-mono text-sm truncate">{entry.domain}</div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<Badge variant="outline" className="text-xs"> <span className="text-xs text-muted-foreground">{entry.service}</span>
{entry.type} <span className="text-xs text-muted-foreground">({entry.source})</span>
</Badge>
{entry.service && (
<span className="text-xs text-muted-foreground">
{entry.service}
</span>
)}
</div>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 shrink-0 ml-4"> <div className="flex items-center gap-2 shrink-0 ml-4">
{exists ? ( {exists ? (
<>
<Badge variant="success" className="gap-1"> <Badge variant="success" className="gap-1">
<ShieldCheck className="h-3 w-3" /> <ShieldCheck className="h-3 w-3" />{entry.cert.daysLeft}d
{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>
{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> <Badge variant="destructive" className="gap-1">Missing</Badge>
{canProvision && ( {canProvision && (
<Button <Button size="sm" variant="outline" onClick={() => handleProvision(entry.domain)} disabled={isProvisioning}>
size="sm" {isThisProvisioning ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
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 Provision
</Button> </Button>
)} )}
@@ -192,22 +200,6 @@ export function CertificatesPage() {
</CardContent> </CardContent>
</Card> </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> </div>
); );
} }

View File

@@ -12,21 +12,19 @@ export interface CertInfo {
export interface CertEntry { export interface CertEntry {
domain: string; domain: string;
type: 'central' | 'wildcard' | 'service'; service: string;
source: string;
cert: CertInfo; cert: CertInfo;
coveredBy?: string;
} }
export interface CertStatusResponse { export interface CertStatusResponse {
configured: boolean; configured: boolean;
domain?: string; domain?: string;
gatewayDomain?: string;
canProvision: boolean; canProvision: boolean;
hasToken: boolean; hasToken: boolean;
hasEmail: boolean; hasEmail: boolean;
message?: string;
certs?: CertEntry[]; certs?: CertEntry[];
/** @deprecated Use certs array instead */
cert?: CertInfo;
} }
export interface CertProvisionResponse { export interface CertProvisionResponse {