feat: Per-service certs with opt-in wildcard provisioning
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) <noreply@anthropic.com>
This commit is contained in:
@@ -21,59 +21,45 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
cfToken := api.getCloudflareToken()
|
cfToken := api.getCloudflareToken()
|
||||||
|
|
||||||
// Derive gateway domain for wildcard cert
|
// Gather cert statuses for all registered services that need TLS termination
|
||||||
gatewayDomain := ""
|
|
||||||
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
|
|
||||||
gatewayDomain = parts[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gather cert statuses
|
|
||||||
certs := []map[string]any{}
|
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()
|
svcs, _ := api.services.List()
|
||||||
for _, svc := range svcs {
|
for _, svc := range svcs {
|
||||||
if svc.TLS != "terminate" || svc.Domain == "" {
|
if svc.TLS != "terminate" || svc.Domain == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Skip if covered by wildcard
|
if seen[svc.Domain] {
|
||||||
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
seen[svc.Domain] = true
|
||||||
|
|
||||||
status := api.certbot.GetStatus(svc.Domain)
|
status := api.certbot.GetStatus(svc.Domain)
|
||||||
certs = append(certs, map[string]any{
|
|
||||||
|
entry := map[string]any{
|
||||||
"domain": svc.Domain,
|
"domain": svc.Domain,
|
||||||
"type": "service",
|
|
||||||
"service": svc.Name,
|
"service": svc.Name,
|
||||||
|
"source": svc.Source,
|
||||||
"cert": status,
|
"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{
|
respondJSON(w, http.StatusOK, map[string]any{
|
||||||
"configured": centralDomain != "",
|
"configured": centralDomain != "",
|
||||||
"domain": centralDomain,
|
"domain": centralDomain,
|
||||||
"gatewayDomain": gatewayDomain,
|
|
||||||
"canProvision": cfToken != "" && email != "",
|
"canProvision": cfToken != "" && email != "",
|
||||||
"hasToken": cfToken != "",
|
"hasToken": cfToken != "",
|
||||||
"hasEmail": email != "",
|
"hasEmail": email != "",
|
||||||
|
|||||||
@@ -70,31 +70,24 @@ func (api *API) reconcileNetworking() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only include L7 HTTP routes if a wildcard cert exists for TLS termination.
|
// Only include L7 HTTP routes for services that have a cert.
|
||||||
// Without it, HAProxy validation fails and the entire config is rejected.
|
// HAProxy uses a cert directory — each service needs its own <domain>.pem
|
||||||
wildcardCert := "/etc/haproxy/certs/wildcard.pem"
|
// or be covered by a wildcard cert in the same directory.
|
||||||
gatewayDomain := ""
|
certsDir := "/etc/haproxy/certs/"
|
||||||
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
|
var activeHTTPRoutes []haproxy.HTTPRoute
|
||||||
gatewayDomain = parts[1]
|
for _, route := range httpRoutes {
|
||||||
// Also check per-domain cert path
|
if hasCertForDomain(certsDir, route.Domain) {
|
||||||
if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err == nil {
|
activeHTTPRoutes = append(activeHTTPRoutes, route)
|
||||||
wildcardCert = certbot.HAProxyCertPath(gatewayDomain)
|
} 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
|
// Generate and write HAProxy config
|
||||||
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
|
||||||
CentralDomain: centralDomain,
|
CentralDomain: centralDomain,
|
||||||
HTTPRoutes: activeHTTPRoutes,
|
HTTPRoutes: activeHTTPRoutes,
|
||||||
WildcardCert: wildcardCert,
|
WildcardCert: certsDir,
|
||||||
})
|
})
|
||||||
|
|
||||||
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
|
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 (<domain>.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
|
// extractHost gets the host part from a host:port string
|
||||||
func extractHost(addr string) string {
|
func extractHost(addr string) string {
|
||||||
for i := len(addr) - 1; i >= 0; i-- {
|
for i := len(addr) - 1; i >= 0; i-- {
|
||||||
|
|||||||
@@ -205,9 +205,11 @@ frontend http_in
|
|||||||
|
|
||||||
// L7 TLS termination backend + frontend (for HTTP routes)
|
// L7 TLS termination backend + frontend (for HTTP routes)
|
||||||
if len(opts.HTTPRoutes) > 0 {
|
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 <domain>.pem.
|
||||||
certPath := opts.WildcardCert
|
certPath := opts.WildcardCert
|
||||||
if certPath == "" {
|
if certPath == "" {
|
||||||
certPath = "/etc/haproxy/certs/wildcard.pem"
|
certPath = "/etc/haproxy/certs/"
|
||||||
}
|
}
|
||||||
|
|
||||||
sb.WriteString("backend be_l7_termination\n")
|
sb.WriteString("backend be_l7_termination\n")
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ export function CloudflareComponent() {
|
|||||||
<div className="font-medium">Central Domain</div>
|
<div className="font-medium">Central Domain</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{(certStatus?.certs?.some(c => c.cert.exists) || certStatus?.cert?.exists) && (
|
{certStatus?.certs?.some(c => c.cert.exists) && (
|
||||||
<Badge variant="success" className="gap-1">
|
<Badge variant="success" className="gap-1">
|
||||||
<Shield className="h-3 w-3" />
|
<Shield className="h-3 w-3" />
|
||||||
TLS
|
TLS
|
||||||
@@ -257,7 +257,7 @@ export function CloudflareComponent() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{globalConfig?.cloud?.central?.domain && (() => {
|
{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 (
|
return (
|
||||||
<div className="ml-7 space-y-2">
|
<div className="ml-7 space-y-2">
|
||||||
{certLoading ? (
|
{certLoading ? (
|
||||||
@@ -319,7 +319,7 @@ export function CloudflareComponent() {
|
|||||||
<div key={entry.domain} className="flex items-center justify-between text-sm">
|
<div key={entry.domain} className="flex items-center justify-between text-sm">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono">{entry.domain}</span>
|
<span className="font-mono">{entry.domain}</span>
|
||||||
<Badge variant="outline" className="text-xs">{entry.type}</Badge>
|
<Badge variant="outline" className="text-xs">{entry.source}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{entry.cert.exists ? (
|
{entry.cert.exists ? (
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
|
import { Card, CardHeader, CardTitle, CardContent } from '../../components/ui/card';
|
||||||
import { Button } from '../../components/ui/button';
|
import { Button } from '../../components/ui/button';
|
||||||
|
import { Input, Label } from '../../components/ui';
|
||||||
import { Alert, AlertDescription } from '../../components/ui/alert';
|
import { Alert, AlertDescription } from '../../components/ui/alert';
|
||||||
import { Badge } from '../../components/ui/badge';
|
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 { useCert } from '../../hooks/useCert';
|
||||||
import { usePageHelp } from '../../hooks/usePageHelp';
|
import { usePageHelp } from '../../hooks/usePageHelp';
|
||||||
|
|
||||||
@@ -17,14 +18,16 @@ export function CertificatesPage() {
|
|||||||
isRenewing,
|
isRenewing,
|
||||||
} = useCert();
|
} = useCert();
|
||||||
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
|
const [provisioningDomain, setProvisioningDomain] = useState<string | null>(null);
|
||||||
|
const [showWildcardForm, setShowWildcardForm] = useState(false);
|
||||||
|
const [wildcardInput, setWildcardInput] = useState('');
|
||||||
|
|
||||||
usePageHelp({
|
usePageHelp({
|
||||||
title: 'TLS Certificates',
|
title: 'TLS Certificates',
|
||||||
description: (
|
description: (
|
||||||
<p className="leading-relaxed">
|
<p className="leading-relaxed">
|
||||||
Wild Central manages TLS certificates for HTTPS access to services on your LAN.
|
Wild Central provisions TLS certificates for services that need HTTPS.
|
||||||
Certificates are provisioned via Let's Encrypt using Cloudflare DNS-01 challenges.
|
Each service gets its own certificate by default. You can also provision
|
||||||
A wildcard certificate covers all services under your gateway domain.
|
a wildcard certificate to cover multiple services under the same domain.
|
||||||
</p>
|
</p>
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
@@ -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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
@@ -50,8 +66,8 @@ export function CertificatesPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allExist = certs.length > 0 && certs.every((c: any) => c.cert?.exists);
|
const allExist = certs.length > 0 && certs.every((c) => c.cert?.exists || c.coveredBy);
|
||||||
const someExist = certs.some((c: any) => c.cert?.exists);
|
const someExist = certs.some((c) => c.cert?.exists || c.coveredBy);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -63,37 +79,26 @@ export function CertificatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
|
<h2 className="text-2xl font-semibold">TLS Certificates</h2>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Manage HTTPS certificates for your services
|
Manage HTTPS certificates for registered services
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{allExist ? (
|
{allExist ? (
|
||||||
<Badge variant="success" className="gap-1">
|
<Badge variant="success" className="gap-1"><ShieldCheck className="h-3 w-3" />All Valid</Badge>
|
||||||
<ShieldCheck className="h-3 w-3" />
|
|
||||||
All Valid
|
|
||||||
</Badge>
|
|
||||||
) : someExist ? (
|
) : someExist ? (
|
||||||
<Badge variant="warning" className="gap-1">
|
<Badge variant="warning" className="gap-1"><ShieldAlert className="h-3 w-3" />Incomplete</Badge>
|
||||||
<ShieldAlert className="h-3 w-3" />
|
|
||||||
Incomplete
|
|
||||||
</Badge>
|
|
||||||
) : certs.length > 0 ? (
|
) : certs.length > 0 ? (
|
||||||
<Badge variant="destructive" className="gap-1">
|
<Badge variant="destructive" className="gap-1"><ShieldAlert className="h-3 w-3" />Missing</Badge>
|
||||||
<ShieldAlert className="h-3 w-3" />
|
|
||||||
No Certs
|
|
||||||
</Badge>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{!canProvision && (
|
{!canProvision && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertCircle className="h-4 w-4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
Certificate provisioning requires a Cloudflare API token and operator email.
|
Certificate provisioning requires a Cloudflare API token and operator email.
|
||||||
{!certStatus?.hasToken && ' Configure the Cloudflare token on the Cloudflare page.'}
|
{!certStatus?.hasToken && ' Configure the token on the Cloudflare page.'}
|
||||||
{!certStatus?.hasEmail && ' Set the operator email in the Overview.'}
|
{!certStatus?.hasEmail && ' Set the operator email in Overview.'}
|
||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -101,84 +106,87 @@ export function CertificatesPage() {
|
|||||||
{certs.length === 0 ? (
|
{certs.length === 0 ? (
|
||||||
<Card className="p-8 text-center">
|
<Card className="p-8 text-center">
|
||||||
<Shield className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
<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>
|
<h3 className="text-lg font-medium mb-2">No Services Registered</h3>
|
||||||
<p className="text-muted-foreground">
|
<p className="text-muted-foreground">
|
||||||
Register services with Wild Central to see their certificate status here.
|
Register services with Wild Central to manage their certificates here.
|
||||||
</p>
|
</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<CardTitle>Certificates</CardTitle>
|
<CardTitle>Service Certificates</CardTitle>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
variant="outline"
|
{canProvision && (
|
||||||
size="sm"
|
<Button variant="outline" size="sm" onClick={() => setShowWildcardForm(true)}>
|
||||||
onClick={() => renewCerts()}
|
<Plus className="h-4 w-4 mr-1" />Wildcard
|
||||||
disabled={isRenewing || !someExist}
|
</Button>
|
||||||
>
|
)}
|
||||||
|
<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" />}
|
{isRenewing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <RefreshCw className="h-4 w-4 mr-1" />}
|
||||||
Renew All
|
Renew All
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-3">
|
||||||
|
{showWildcardForm && (
|
||||||
|
<div className="p-3 border rounded-lg bg-muted/50 space-y-2">
|
||||||
|
<Label>Provision Wildcard Certificate</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex items-center gap-1 flex-1">
|
||||||
|
<span className="text-sm text-muted-foreground">*.</span>
|
||||||
|
<Input
|
||||||
|
value={wildcardInput}
|
||||||
|
onChange={(e) => setWildcardInput(e.target.value)}
|
||||||
|
placeholder="cloud.payne.io"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" onClick={handleProvisionWildcard} disabled={isProvisioning || !wildcardInput}>
|
||||||
|
{provisioningDomain?.startsWith('*.') ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plus className="h-4 w-4 mr-1" />}
|
||||||
|
Provision
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => { setShowWildcardForm(false); setWildcardInput(''); }}>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
A wildcard cert covers all subdomains. E.g., *.cloud.payne.io covers app1.cloud.payne.io, app2.cloud.payne.io, etc.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="border rounded-lg divide-y">
|
<div className="border rounded-lg divide-y">
|
||||||
{certs.map((entry: any) => {
|
{certs.map((entry) => {
|
||||||
const cert = entry.cert;
|
const exists = entry.cert?.exists;
|
||||||
const exists = cert?.exists;
|
const covered = entry.coveredBy;
|
||||||
const isThisProvisioning = provisioningDomain === entry.domain;
|
const isThisProvisioning = provisioningDomain === entry.domain;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={entry.domain} className="px-4 py-3 flex items-center justify-between">
|
<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="min-w-0">
|
||||||
<div className="font-mono text-sm truncate">{entry.domain}</div>
|
<div className="font-mono text-sm truncate">{entry.domain}</div>
|
||||||
<div className="flex items-center gap-2 mt-0.5">
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
<Badge variant="outline" className="text-xs">
|
<span className="text-xs text-muted-foreground">{entry.service}</span>
|
||||||
{entry.type}
|
<span className="text-xs text-muted-foreground">({entry.source})</span>
|
||||||
</Badge>
|
|
||||||
{entry.service && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{entry.service}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 shrink-0 ml-4">
|
<div className="flex items-center gap-2 shrink-0 ml-4">
|
||||||
{exists ? (
|
{exists ? (
|
||||||
<>
|
|
||||||
<Badge variant="success" className="gap-1">
|
<Badge variant="success" className="gap-1">
|
||||||
<ShieldCheck className="h-3 w-3" />
|
<ShieldCheck className="h-3 w-3" />{entry.cert.daysLeft}d
|
||||||
{cert.daysLeft}d
|
</Badge>
|
||||||
|
) : covered ? (
|
||||||
|
<Badge variant="outline" className="gap-1 text-green-600 border-green-300">
|
||||||
|
<CheckCircle className="h-3 w-3" />{covered}
|
||||||
</Badge>
|
</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>
|
<Badge variant="destructive" className="gap-1">Missing</Badge>
|
||||||
{canProvision && (
|
{canProvision && (
|
||||||
<Button
|
<Button size="sm" variant="outline" onClick={() => handleProvision(entry.domain)} disabled={isProvisioning}>
|
||||||
size="sm"
|
{isThisProvisioning ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : <Plus className="h-3 w-3 mr-1" />}
|
||||||
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
|
Provision
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -192,22 +200,6 @@ export function CertificatesPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,19 @@ export interface CertInfo {
|
|||||||
|
|
||||||
export interface CertEntry {
|
export interface CertEntry {
|
||||||
domain: string;
|
domain: string;
|
||||||
type: 'central' | 'wildcard' | 'service';
|
service: string;
|
||||||
|
source: string;
|
||||||
cert: CertInfo;
|
cert: CertInfo;
|
||||||
|
coveredBy?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CertStatusResponse {
|
export interface CertStatusResponse {
|
||||||
configured: boolean;
|
configured: boolean;
|
||||||
domain?: string;
|
domain?: string;
|
||||||
gatewayDomain?: string;
|
|
||||||
canProvision: boolean;
|
canProvision: boolean;
|
||||||
hasToken: boolean;
|
hasToken: boolean;
|
||||||
hasEmail: boolean;
|
hasEmail: boolean;
|
||||||
message?: string;
|
|
||||||
certs?: CertEntry[];
|
certs?: CertEntry[];
|
||||||
/** @deprecated Use certs array instead */
|
|
||||||
cert?: CertInfo;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CertProvisionResponse {
|
export interface CertProvisionResponse {
|
||||||
|
|||||||
Reference in New Issue
Block a user