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,63 +21,49 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
cfToken := api.getCloudflareToken()
// Derive gateway domain for wildcard cert
gatewayDomain := ""
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
// Gather cert statuses
// Gather cert statuses for all registered services that need TLS termination
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()
for _, svc := range svcs {
if svc.TLS != "terminate" || svc.Domain == "" {
continue
}
// Skip if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
if seen[svc.Domain] {
continue
}
seen[svc.Domain] = true
status := api.certbot.GetStatus(svc.Domain)
certs = append(certs, map[string]any{
entry := map[string]any{
"domain": svc.Domain,
"type": "service",
"service": svc.Name,
"source": svc.Source,
"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{
"configured": centralDomain != "",
"domain": centralDomain,
"gatewayDomain": gatewayDomain,
"canProvision": cfToken != "" && email != "",
"hasToken": cfToken != "",
"hasEmail": email != "",
"certs": certs,
"configured": centralDomain != "",
"domain": centralDomain,
"canProvision": cfToken != "" && email != "",
"hasToken": cfToken != "",
"hasEmail": email != "",
"certs": certs,
})
}

View File

@@ -70,31 +70,24 @@ func (api *API) reconcileNetworking() {
}
}
// Only include L7 HTTP routes if a wildcard cert exists for TLS termination.
// Without it, HAProxy validation fails and the entire config is rejected.
wildcardCert := "/etc/haproxy/certs/wildcard.pem"
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
// Also check per-domain cert path
if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err == nil {
wildcardCert = certbot.HAProxyCertPath(gatewayDomain)
// Only include L7 HTTP routes for services that have a cert.
// HAProxy uses a cert directory — each service needs its own <domain>.pem
// or be covered by a wildcard cert in the same directory.
certsDir := "/etc/haproxy/certs/"
var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes {
if hasCertForDomain(certsDir, route.Domain) {
activeHTTPRoutes = append(activeHTTPRoutes, route)
} 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
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
CentralDomain: centralDomain,
HTTPRoutes: activeHTTPRoutes,
WildcardCert: wildcardCert,
WildcardCert: certsDir,
})
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
func extractHost(addr string) string {
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)
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
if certPath == "" {
certPath = "/etc/haproxy/certs/wildcard.pem"
certPath = "/etc/haproxy/certs/"
}
sb.WriteString("backend be_l7_termination\n")

View File

@@ -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 ? (

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>
);
}

View File

@@ -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 {