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:
@@ -21,59 +21,45 @@ 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 != "",
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,37 +79,26 @@ 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>
|
||||
<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>
|
||||
<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>
|
||||
<Badge variant="destructive" className="gap-1"><ShieldAlert className="h-3 w-3" />Missing</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.'}
|
||||
{!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}
|
||||
>
|
||||
<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>
|
||||
<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
|
||||
<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>
|
||||
{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" />
|
||||
)}
|
||||
<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