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:
@@ -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
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
213
web/src/router/pages/CertificatesPage.tsx
Normal file
213
web/src/router/pages/CertificatesPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user