feat: Add Certificates page + stop auto-provisioning certs

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) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:26:48 +00:00
parent 9eea061e9b
commit 447c7e51a3
4 changed files with 233 additions and 62 deletions

View File

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

View File

@@ -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' },
];

View File

@@ -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<string | null>(null);
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.
</p>
),
});
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 (
<Card className="p-8 text-center">
<Loader2 className="h-12 w-12 text-primary mx-auto mb-4 animate-spin" />
<p className="text-muted-foreground">Loading certificate status...</p>
</Card>
);
}
const allExist = certs.length > 0 && certs.every((c: any) => c.cert?.exists);
const someExist = certs.some((c: any) => c.cert?.exists);
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 bg-primary/10 rounded-lg">
<Shield className="h-6 w-6 text-primary" />
</div>
<div>
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
<p className="text-muted-foreground">
Manage HTTPS certificates for your 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>
</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.'}
</AlertDescription>
</Alert>
)}
{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>
<p className="text-muted-foreground">
Register services with Wild Central to see their certificate status 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>
</div>
</CardHeader>
<CardContent>
<div className="border rounded-lg divide-y">
{certs.map((entry: any) => {
const cert = entry.cert;
const exists = cert?.exists;
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>
</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="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" />
)}
Provision
</Button>
)}
</>
)}
</div>
</div>
);
})}
</div>
</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

@@ -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: <DdnsPage /> },
{ path: 'lan-dns', element: <LanDnsPage /> },
{ path: 'vpn', element: <VpnPage /> },
{ path: 'certificates', element: <CertificatesPage /> },
],
},
{