From 447c7e51a3cbeee8805fc1c93352e14ea09d1550 Mon Sep 17 00:00:00 2001 From: Paul Payne Date: Thu, 9 Jul 2026 14:26:48 +0000 Subject: [PATCH] feat: Add Certificates page + stop auto-provisioning certs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New dedicated Certificates page in Wild Central UI showing: - All tracked certs (central, wildcard, per-service) - Status (valid with days remaining, or missing) - Per-domain Provision button - Renew All button Cert provisioning is now user-initiated only — reconciliation logs warnings about missing certs but does NOT auto-provision. This prevents unexpected wildcard cert requests on startup. Co-Authored-By: Claude Opus 4.6 (1M context) --- internal/api/v1/helpers.go | 77 ++------ web/src/components/AppSidebar.tsx | 3 +- web/src/router/pages/CertificatesPage.tsx | 213 ++++++++++++++++++++++ web/src/router/routes.tsx | 2 + 4 files changed, 233 insertions(+), 62 deletions(-) create mode 100644 web/src/router/pages/CertificatesPage.tsx diff --git a/internal/api/v1/helpers.go b/internal/api/v1/helpers.go index 94338cd..de39555 100644 --- a/internal/api/v1/helpers.go +++ b/internal/api/v1/helpers.go @@ -146,79 +146,34 @@ func (api *API) reconcileNetworking() { ) } -// ensureTLSCerts checks that TLS certificates exist for HTTP services -// that need Central to terminate TLS. Uses wildcard certs where possible. +// ensureTLSCerts logs which certificates are missing for registered services. +// Does NOT auto-provision — cert provisioning is user-initiated via the +// Certificates page. This just logs warnings so the operator knows what's needed. func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.Service) { - // Determine the gateway domain from central domain (e.g., central.payne.io → payne.io) - centralDomain := globalCfg.Cloud.Central.Domain gatewayDomain := "" - if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 { - gatewayDomain = parts[1] // e.g., "payne.io" + if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 { + gatewayDomain = parts[1] } - // Check if we have a Cloudflare token for cert provisioning - cfToken := api.getCloudflareToken() - if cfToken == "" { - slog.Debug("reconcile/tls: no Cloudflare token, skipping cert provisioning") - return - } - - email := globalCfg.Operator.Email - if email == "" { - slog.Debug("reconcile/tls: no operator email, skipping cert provisioning") - return - } - - // Check if wildcard cert exists for the gateway domain - wildcardCertPath := "/etc/haproxy/certs/wildcard.pem" - if gatewayDomain != "" { - wildcardDomain := "*." + gatewayDomain - // Check if cert exists at the standard wildcard path or per-domain path - certPath := certbot.HAProxyCertPath(gatewayDomain) - if _, err := os.Stat(certPath); err == nil { - // Per-domain cert exists, use it as wildcard - slog.Debug("reconcile/tls: wildcard cert exists", "domain", gatewayDomain, "path", certPath) - } else if _, err := os.Stat(wildcardCertPath); err == nil { - slog.Debug("reconcile/tls: wildcard cert exists at default path") - } else { - // No wildcard cert — provision one - slog.Info("reconcile/tls: provisioning wildcard cert", "domain", wildcardDomain) - if err := api.certbot.EnsureCredentials(cfToken); err != nil { - slog.Error("reconcile/tls: failed to write certbot credentials", "error", err) - return - } - if err := api.certbot.Provision(wildcardDomain, email); err != nil { - slog.Warn("reconcile/tls: failed to provision wildcard cert", "domain", wildcardDomain, "error", err) - // Fall through — try per-domain certs - } - } - } - - // For services NOT under the gateway domain, provision individual certs for _, svc := range svcs { if svc.TLS != services.TLSTerminate || svc.Domain == "" { continue } - // Check if this domain is covered by the gateway wildcard + // Check if covered by wildcard if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) { - continue // Covered by wildcard - } - - // Check if per-domain cert exists - certPath := certbot.HAProxyCertPath(svc.Domain) - if _, err := os.Stat(certPath); err == nil { - continue // Cert already exists - } - - // Provision individual cert - slog.Info("reconcile/tls: provisioning cert", "domain", svc.Domain) - if err := api.certbot.EnsureCredentials(cfToken); err != nil { - slog.Error("reconcile/tls: failed to write certbot credentials", "error", err) + wildcardCert := certbot.HAProxyCertPath(gatewayDomain) + if _, err := os.Stat(wildcardCert); err != nil { + slog.Warn("reconcile/tls: no wildcard cert for gateway domain — provision via Certificates page", + "service", svc.Name, "domain", svc.Domain, "needed", "*."+gatewayDomain) + } continue } - if err := api.certbot.Provision(svc.Domain, email); err != nil { - slog.Warn("reconcile/tls: failed to provision cert", "domain", svc.Domain, "error", err) + + // Check individual cert + if _, err := os.Stat(certbot.HAProxyCertPath(svc.Domain)); err != nil { + slog.Warn("reconcile/tls: no cert for service — provision via Certificates page", + "service", svc.Name, "domain", svc.Domain) } } } diff --git a/web/src/components/AppSidebar.tsx b/web/src/components/AppSidebar.tsx index 72aa4fb..f09e863 100644 --- a/web/src/components/AppSidebar.tsx +++ b/web/src/components/AppSidebar.tsx @@ -1,5 +1,5 @@ import { NavLink } from 'react-router'; -import { Sun, Moon, Monitor, Shield, Lock, Network, ShieldAlert, Router, Cloud, Wifi, Server } from 'lucide-react'; +import { Sun, Moon, Monitor, Shield, ShieldCheck, Lock, Network, ShieldAlert, Router, Cloud, Wifi, Server } from 'lucide-react'; import { Sidebar, SidebarContent, @@ -59,6 +59,7 @@ export function AppSidebar() { { to: '/central/ingress', icon: Network, label: 'Ingress Proxy' }, { to: '/central/firewall', icon: Shield, label: 'Firewall' }, { to: '/central/vpn', icon: Lock, label: 'VPN' }, + { to: '/central/certificates', icon: ShieldCheck, label: 'Certificates' }, { to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' }, { to: '/central/dhcp', icon: Wifi, label: 'DHCP' }, ]; diff --git a/web/src/router/pages/CertificatesPage.tsx b/web/src/router/pages/CertificatesPage.tsx new file mode 100644 index 0000000..dff2713 --- /dev/null +++ b/web/src/router/pages/CertificatesPage.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card'; +import { Button } from '../../components/ui/button'; +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 { useCert } from '../../hooks/useCert'; +import { usePageHelp } from '../../hooks/usePageHelp'; + +export function CertificatesPage() { + const { + status: certStatus, + isLoading, + provision: provisionCert, + isProvisioning, + renew: renewCerts, + isRenewing, + } = useCert(); + const [provisioningDomain, setProvisioningDomain] = useState(null); + + 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. +

+ ), + }); + + const certs = certStatus?.certs ?? []; + const canProvision = certStatus?.canProvision ?? false; + + const handleProvision = async (domain: string) => { + setProvisioningDomain(domain); + try { + await provisionCert(domain); + } finally { + setProvisioningDomain(null); + } + }; + + if (isLoading) { + return ( + + +

Loading certificate status...

+
+ ); + } + + const allExist = certs.length > 0 && certs.every((c: any) => c.cert?.exists); + const someExist = certs.some((c: any) => c.cert?.exists); + + return ( +
+
+
+
+ +
+
+

TLS Certificates

+

+ Manage HTTPS certificates for your services +

+
+
+
+ {allExist ? ( + + + All Valid + + ) : someExist ? ( + + + Incomplete + + ) : certs.length > 0 ? ( + + + No Certs + + ) : null} +
+
+ + {!canProvision && ( + + + + 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.'} + + + )} + + {certs.length === 0 ? ( + + +

No Certificates Tracked

+

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

+
+ ) : ( + + +
+ Certificates + +
+
+ +
+ {certs.map((entry: any) => { + const cert = entry.cert; + const exists = cert?.exists; + const isThisProvisioning = provisioningDomain === entry.domain; + + return ( +
+
+ {exists ? ( + + ) : ( + + )} +
+
{entry.domain}
+
+ + {entry.type} + + {entry.service && ( + + {entry.service} + + )} +
+
+
+
+ {exists ? ( + <> + + + {cert.daysLeft}d + + {cert.issuerCN && ( + + {cert.issuerCN.split('CN=').pop()} + + )} + + ) : ( + <> + Missing + {canProvision && ( + + )} + + )} +
+
+ ); + })} +
+
+
+ )} + + {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/router/routes.tsx b/web/src/router/routes.tsx index 07c546c..ed24f1f 100644 --- a/web/src/router/routes.tsx +++ b/web/src/router/routes.tsx @@ -10,6 +10,7 @@ import { DnsPage } from './pages/DnsPage'; import { CrowdSecPage } from './pages/CrowdSecPage'; import { VpnPage } from './pages/VpnPage'; import { CloudflarePage } from './pages/CloudflarePage'; +import { CertificatesPage } from './pages/CertificatesPage'; import { DdnsPage } from './pages/DdnsPage'; import { LanDnsPage } from './pages/LanDnsPage'; @@ -32,6 +33,7 @@ export const routes: RouteObject[] = [ { path: 'ddns', element: }, { path: 'lan-dns', element: }, { path: 'vpn', element: }, + { path: 'certificates', element: }, ], }, {