From 7e8f660d11263398da90a5dc2865f7f4adf5bb87 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 14:47:00 +0000 Subject: [PATCH] feat: Per-service certs with opt-in wildcard provisioning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- internal/api/v1/handlers_certbot.go | 68 +++----- internal/api/v1/helpers.go | 61 +++++-- internal/haproxy/config.go | 4 +- web/src/components/CloudflareComponent.tsx | 6 +- web/src/router/pages/CertificatesPage.tsx | 190 ++++++++++----------- web/src/services/api/cert.ts | 8 +- 6 files changed, 170 insertions(+), 167 deletions(-) diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index 039641d..b00d92b 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -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, }) } diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index de39555..274e060 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -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 .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 (.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-- { diff --git a/internal/haproxy/config.go b/internal/haproxy/config.go index 1499cb2..885b847 100644 --- a/internal/haproxy/config.go +++ b/internal/haproxy/config.go @@ -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 .pem. certPath := opts.WildcardCert if certPath == "" { - certPath = "/etc/haproxy/certs/wildcard.pem" + certPath = "/etc/haproxy/certs/" } sb.WriteString("backend be_l7_termination\n") diff --git a/web/src/components/CloudflareComponent.tsx b/web/src/components/CloudflareComponent.tsx index 0a60eb9..7a08904 100644 --- a/web/src/components/CloudflareComponent.tsx +++ b/web/src/components/CloudflareComponent.tsx @@ -182,7 +182,7 @@ export function CloudflareComponent() {
Central Domain
- {(certStatus?.certs?.some(c => c.cert.exists) || certStatus?.cert?.exists) && ( + {certStatus?.certs?.some(c => c.cert.exists) && ( TLS @@ -257,7 +257,7 @@ export function CloudflareComponent() {
{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 (
{certLoading ? ( @@ -319,7 +319,7 @@ export function CloudflareComponent() {
{entry.domain} - {entry.type} + {entry.source}
{entry.cert.exists ? ( diff --git a/web/src/router/pages/CertificatesPage.tsx b/web/src/router/pages/CertificatesPage.tsx index dff2713..e9d5f7f 100644 --- a/web/src/router/pages/CertificatesPage.tsx +++ b/web/src/router/pages/CertificatesPage.tsx @@ -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(null); + const [showWildcardForm, setShowWildcardForm] = useState(false); + const [wildcardInput, setWildcardInput] = useState(''); usePageHelp({ title: 'TLS Certificates', description: (

- 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.

), }); @@ -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 ( @@ -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 (
@@ -63,28 +79,17 @@ export function CertificatesPage() {

TLS Certificates

- Manage HTTPS certificates for your services + Manage HTTPS certificates for registered services

-
- {allExist ? ( - - - All Valid - - ) : someExist ? ( - - - Incomplete - - ) : certs.length > 0 ? ( - - - No Certs - - ) : null} -
+ {allExist ? ( + All Valid + ) : someExist ? ( + Incomplete + ) : certs.length > 0 ? ( + Missing + ) : null}
{!canProvision && ( @@ -92,8 +97,8 @@ export function CertificatesPage() { 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.'} )} @@ -101,84 +106,87 @@ export function CertificatesPage() { {certs.length === 0 ? ( -

No Certificates Tracked

+

No Services Registered

- Register services with Wild Central to see their certificate status here. + Register services with Wild Central to manage their certificates here.

) : (
- Certificates - + Service Certificates +
+ {canProvision && ( + + )} + +
- + + {showWildcardForm && ( +
+ +
+
+ *. + setWildcardInput(e.target.value)} + placeholder="cloud.payne.io" + className="font-mono" + /> +
+ + +
+

+ A wildcard cert covers all subdomains. E.g., *.cloud.payne.io covers app1.cloud.payne.io, app2.cloud.payne.io, etc. +

+
+ )} +
- {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 (
-
- {exists ? ( - - ) : ( - - )} -
-
{entry.domain}
-
- - {entry.type} - - {entry.service && ( - - {entry.service} - - )} -
+
+
{entry.domain}
+
+ {entry.service} + ({entry.source})
{exists ? ( - <> - - - {cert.daysLeft}d - - {cert.issuerCN && ( - - {cert.issuerCN.split('CN=').pop()} - - )} - + + {entry.cert.daysLeft}d + + ) : covered ? ( + + {covered} + ) : ( <> Missing {canProvision && ( - )} @@ -192,22 +200,6 @@ export function CertificatesPage() { )} - - {certStatus?.gatewayDomain && ( - -
- -
-

How certificates work

-

- A wildcard certificate for *.{certStatus.gatewayDomain} 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. -

-
-
-
- )}
); } diff --git a/web/src/services/api/cert.ts b/web/src/services/api/cert.ts index 0c5855e..8cf2057 100644 --- a/web/src/services/api/cert.ts +++ b/web/src/services/api/cert.ts @@ -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 {