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
|
// ensureTLSCerts logs which certificates are missing for registered services.
|
||||||
// that need Central to terminate TLS. Uses wildcard certs where possible.
|
// 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) {
|
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 := ""
|
gatewayDomain := ""
|
||||||
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
|
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
||||||
gatewayDomain = parts[1] // e.g., "payne.io"
|
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 {
|
for _, svc := range svcs {
|
||||||
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
|
if svc.TLS != services.TLSTerminate || svc.Domain == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this domain is covered by the gateway wildcard
|
// Check if covered by wildcard
|
||||||
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
|
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",
|
||||||
// Check if per-domain cert exists
|
"service", svc.Name, "domain", svc.Domain, "needed", "*."+gatewayDomain)
|
||||||
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
|
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 { 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 {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
SidebarContent,
|
SidebarContent,
|
||||||
@@ -59,6 +59,7 @@ export function AppSidebar() {
|
|||||||
{ to: '/central/ingress', icon: Network, label: 'Ingress Proxy' },
|
{ to: '/central/ingress', icon: Network, label: 'Ingress Proxy' },
|
||||||
{ to: '/central/firewall', icon: Shield, label: 'Firewall' },
|
{ to: '/central/firewall', icon: Shield, label: 'Firewall' },
|
||||||
{ to: '/central/vpn', icon: Lock, label: 'VPN' },
|
{ to: '/central/vpn', icon: Lock, label: 'VPN' },
|
||||||
|
{ to: '/central/certificates', icon: ShieldCheck, label: 'Certificates' },
|
||||||
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' },
|
{ to: '/central/crowdsec', icon: ShieldAlert, label: 'CrowdSec' },
|
||||||
{ to: '/central/dhcp', icon: Wifi, label: 'DHCP' },
|
{ 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 { CrowdSecPage } from './pages/CrowdSecPage';
|
||||||
import { VpnPage } from './pages/VpnPage';
|
import { VpnPage } from './pages/VpnPage';
|
||||||
import { CloudflarePage } from './pages/CloudflarePage';
|
import { CloudflarePage } from './pages/CloudflarePage';
|
||||||
|
import { CertificatesPage } from './pages/CertificatesPage';
|
||||||
import { DdnsPage } from './pages/DdnsPage';
|
import { DdnsPage } from './pages/DdnsPage';
|
||||||
import { LanDnsPage } from './pages/LanDnsPage';
|
import { LanDnsPage } from './pages/LanDnsPage';
|
||||||
|
|
||||||
@@ -32,6 +33,7 @@ export const routes: RouteObject[] = [
|
|||||||
{ path: 'ddns', element: <DdnsPage /> },
|
{ path: 'ddns', element: <DdnsPage /> },
|
||||||
{ path: 'lan-dns', element: <LanDnsPage /> },
|
{ path: 'lan-dns', element: <LanDnsPage /> },
|
||||||
{ path: 'vpn', element: <VpnPage /> },
|
{ path: 'vpn', element: <VpnPage /> },
|
||||||
|
{ path: 'certificates', element: <CertificatesPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user