feat: Enhanced cert status API + TLS certificates UI

API changes:
- /api/v1/cert/status now returns all relevant certs (central,
  wildcard, per-service) with existence status and expiry
- /api/v1/cert/provision accepts ?domain= param for per-domain
  provisioning
- Removed stale syncHAProxy references, uses reconcileNetworking

UI changes:
- Added TLS Certificates card to Cloudflare page showing cert status
  for each domain with provision/renew actions
- Updated useCert hook and cert API types for new response format

Next: extract into dedicated Certificates page.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 14:23:08 +00:00
parent 7d5e4f4e29
commit 9eea061e9b
4 changed files with 239 additions and 80 deletions

View File

@@ -3,33 +3,93 @@ package v1
import ( import (
"fmt" "fmt"
"net/http" "net/http"
"strings"
"github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/config"
) )
// CertStatus returns the TLS certificate status for the central domain. // CertStatus returns TLS certificate status for all relevant domains:
// the central domain, the wildcard cert, and any registered service domains.
func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) { func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
domain := api.getCentralDomain() centralDomain := api.getCentralDomain()
if domain == "" {
respondJSON(w, http.StatusOK, map[string]any{ globalCfg, _ := config.LoadGlobalConfig(api.getGlobalConfigPath())
"configured": false, email := ""
"message": "No central domain configured", if globalCfg != nil {
}) email = globalCfg.Operator.Email
return
} }
status := api.certbot.GetStatus(domain)
respondJSON(w, http.StatusOK, map[string]any{ cfToken := api.getCloudflareToken()
"configured": true,
"domain": domain, // Derive gateway domain for wildcard cert
gatewayDomain := ""
if parts := strings.SplitN(centralDomain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
}
// Gather cert statuses
certs := []map[string]any{}
// Central domain cert
if centralDomain != "" {
status := api.certbot.GetStatus(centralDomain)
certs = append(certs, map[string]any{
"domain": centralDomain,
"type": "central",
"cert": status, "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()
for _, svc := range svcs {
if svc.TLS != "terminate" || svc.Domain == "" {
continue
}
// Skip if covered by wildcard
if gatewayDomain != "" && strings.HasSuffix(svc.Domain, "."+gatewayDomain) {
continue
}
status := api.certbot.GetStatus(svc.Domain)
certs = append(certs, map[string]any{
"domain": svc.Domain,
"type": "service",
"service": svc.Name,
"cert": status,
})
}
respondJSON(w, http.StatusOK, map[string]any{
"configured": centralDomain != "",
"domain": centralDomain,
"gatewayDomain": gatewayDomain,
"canProvision": cfToken != "" && email != "",
"hasToken": cfToken != "",
"hasEmail": email != "",
"certs": certs,
})
} }
// CertProvision provisions a TLS certificate for the central domain using DNS-01. // CertProvision provisions a TLS certificate for a domain using DNS-01.
// Query param ?domain=... specifies the domain. Defaults to the central domain.
func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) { func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
domain := api.getCentralDomain() domain := r.URL.Query().Get("domain")
if domain == "" { if domain == "" {
respondError(w, http.StatusBadRequest, "No central domain configured in global config") domain = api.getCentralDomain()
}
if domain == "" {
respondError(w, http.StatusBadRequest, "No domain specified and no central domain configured")
return return
} }
@@ -44,34 +104,36 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) {
return return
} }
// Get Cloudflare API token
token := api.getCloudflareToken() token := api.getCloudflareToken()
if token == "" { if token == "" {
respondError(w, http.StatusBadRequest, "Cloudflare API token not configured — set it on the Cloudflare page") respondError(w, http.StatusBadRequest, "Cloudflare API token not configured")
return return
} }
// Write credentials file for certbot
if err := api.certbot.EnsureCredentials(token); err != nil { if err := api.certbot.EnsureCredentials(token); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write credentials: %v", err)) respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write credentials: %v", err))
return return
} }
// Provision the certificate. The deploy hook builds the HAProxy PEM and reloads HAProxy.
if err := api.certbot.Provision(domain, email); err != nil { if err := api.certbot.Provision(domain, email); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioning failed: %v", err)) respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioning failed: %v", err))
return return
} }
// Regenerate HAProxy config to ensure central domain SNI routing is active // If this was a wildcard cert, also build HAProxy PEM
if _, _, _, err := api.syncHAProxy(); err != nil { if strings.HasPrefix(domain, "*.") {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioned but HAProxy config update failed: %v", err)) baseDomain := strings.TrimPrefix(domain, "*.")
return _ = api.certbot.BuildHAProxyCert(baseDomain)
} else {
_ = api.certbot.BuildHAProxyCert(domain)
} }
// Trigger reconciliation so HAProxy picks up the new cert
go api.reconcileNetworking()
status := api.certbot.GetStatus(domain) status := api.certbot.GetStatus(domain)
respondJSON(w, http.StatusOK, map[string]any{ respondJSON(w, http.StatusOK, map[string]any{
"message": "Certificate provisioned successfully", "message": fmt.Sprintf("Certificate provisioned for %s", domain),
"cert": status, "cert": status,
}) })
} }
@@ -83,13 +145,15 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
return return
} }
// Rebuild HAProxy PEM for any renewed certs // Rebuild HAProxy PEMs
domain := api.getCentralDomain() domain := api.getCentralDomain()
if domain != "" { if domain != "" {
_ = api.certbot.BuildHAProxyCert(domain) _ = api.certbot.BuildHAProxyCert(domain)
_, _, _, _ = api.syncHAProxy()
} }
// Trigger reconciliation
go api.reconcileNetworking()
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"}) respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
} }
@@ -101,3 +165,4 @@ func (api *API) getCentralDomain() string {
} }
return globalCfg.Cloud.Central.Domain return globalCfg.Cloud.Central.Domain
} }

View File

@@ -20,7 +20,7 @@ export function CloudflareComponent() {
const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare(); const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare();
const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig(); const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig();
const { status: ddnsStatus } = useDdns(); const { status: ddnsStatus } = useDdns();
const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError } = useCert(); const { status: certStatus, isLoading: certLoading, provision: provisionCert, isProvisioning, provisionError, renew: renewCerts, isRenewing } = useCert();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: globalSecrets } = useQuery({ const { data: globalSecrets } = useQuery({
@@ -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?.cert?.exists && ( {(certStatus?.certs?.some(c => c.cert.exists) || certStatus?.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
@@ -256,19 +256,21 @@ 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);
return (
<div className="ml-7 space-y-2"> <div className="ml-7 space-y-2">
{certLoading ? ( {certLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : certStatus?.cert?.exists ? ( ) : centralCert?.cert.exists ? (
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<div className="flex items-center gap-2 text-green-600 dark:text-green-400"> <div className="flex items-center gap-2 text-green-600 dark:text-green-400">
<Shield className="h-4 w-4" /> <Shield className="h-4 w-4" />
<span>Valid TLS certificate</span> <span>Valid TLS certificate</span>
</div> </div>
<div className="text-xs text-muted-foreground"> <div className="text-xs text-muted-foreground">
Expires in {certStatus.cert.daysLeft} days Expires in {centralCert.cert.daysLeft} days
{certStatus.cert.issuerCN && <> &middot; {certStatus.cert.issuerCN}</>} {centralCert.cert.issuerCN && <> &middot; {centralCert.cert.issuerCN}</>}
</div> </div>
</div> </div>
) : ( ) : (
@@ -280,7 +282,7 @@ export function CloudflareComponent() {
<Button <Button
size="sm" size="sm"
onClick={async () => { onClick={async () => {
try { await provisionCert(); } catch { /* error shown below */ } try { await provisionCert(centralCert?.domain); } catch { /* error shown below */ }
}} }}
disabled={isProvisioning} disabled={isProvisioning}
className="gap-1" className="gap-1"
@@ -297,11 +299,83 @@ export function CloudflareComponent() {
</div> </div>
)} )}
</div> </div>
)} );
})()}
</div> </div>
)} )}
</Card> </Card>
{/* TLS Certificates Card */}
{certStatus?.certs && certStatus.certs.length > 0 && (
<Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-blue-500" />
<div className="font-medium">TLS Certificates</div>
</div>
</div>
<div className="space-y-3 ml-7">
{certStatus.certs.map((entry) => (
<div key={entry.domain} className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2">
<span className="font-mono">{entry.domain}</span>
<Badge variant="outline" className="text-xs">{entry.type}</Badge>
</div>
<div className="flex items-center gap-2">
{entry.cert.exists ? (
<Badge variant="success" className="gap-1">
<CheckCircle className="h-3 w-3" />
Valid ({entry.cert.daysLeft}d)
</Badge>
) : (
<>
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
Missing
</Badge>
{certStatus.canProvision && (
<Button
variant="outline"
size="sm"
onClick={async () => {
try { await provisionCert(entry.domain); } catch { /* error shown in alerts */ }
}}
disabled={isProvisioning}
className="gap-1 h-7 text-xs"
>
{isProvisioning ? <Loader2 className="h-3 w-3 animate-spin" /> : <Shield className="h-3 w-3" />}
Provision
</Button>
)}
</>
)}
</div>
</div>
))}
{provisionError && (
<Alert variant="error">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-xs">{(provisionError as Error).message}</AlertDescription>
</Alert>
)}
<div className="pt-2">
<Button
variant="outline"
size="sm"
onClick={async () => {
try { await renewCerts(); } catch { /* error shown in alerts */ }
}}
disabled={isRenewing}
className="gap-1"
>
{isRenewing ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
{isRenewing ? 'Renewing...' : 'Renew All'}
</Button>
</div>
</div>
</Card>
)}
{/* Verification Checklist Card */} {/* Verification Checklist Card */}
<Card className="p-4 border-l-4 border-l-blue-500"> <Card className="p-4 border-l-4 border-l-blue-500">
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
@@ -399,15 +473,22 @@ export function CloudflareComponent() {
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Shield className="h-4 w-4 text-muted-foreground" /> <Shield className="h-4 w-4 text-muted-foreground" />
<span>TLS Certificate</span> <span>TLS Certificates</span>
</div> </div>
{certStatus?.cert?.exists {(() => {
? <Badge variant="success" className="text-xs"> const certs = certStatus?.certs;
Valid ({certStatus.cert.daysLeft}d left) const allExist = certs && certs.length > 0 && certs.every(c => c.cert.exists);
</Badge> const someExist = certs && certs.some(c => c.cert.exists);
: certStatus?.configured if (allExist) {
? <Badge variant="secondary" className="text-xs">Not provisioned</Badge> return <Badge variant="success" className="text-xs">All valid</Badge>;
: <Badge variant="secondary" className="text-xs">Not configured</Badge>} } else if (someExist) {
return <Badge variant="warning" className="text-xs">Partial</Badge>;
} else if (certStatus?.configured) {
return <Badge variant="secondary" className="text-xs">Not provisioned</Badge>;
} else {
return <Badge variant="secondary" className="text-xs">Not configured</Badge>;
}
})()}
</div> </div>
{/* ExternalDNS note */} {/* ExternalDNS note */}

View File

@@ -12,7 +12,7 @@ export function useCert() {
}); });
const provisionMutation = useMutation({ const provisionMutation = useMutation({
mutationFn: () => certApi.provision(), mutationFn: (domain?: string) => certApi.provision(domain),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['cert', 'status'] }); queryClient.invalidateQueries({ queryKey: ['cert', 'status'] });
}, },

View File

@@ -2,7 +2,7 @@ import { apiClient } from './client';
export interface CertInfo { export interface CertInfo {
exists: boolean; exists: boolean;
domain: string; domain?: string;
expiry?: string; expiry?: string;
certPath?: string; certPath?: string;
keyPath?: string; keyPath?: string;
@@ -10,10 +10,22 @@ export interface CertInfo {
issuerCN?: string; issuerCN?: string;
} }
export interface CertEntry {
domain: string;
type: 'central' | 'wildcard' | 'service';
cert: CertInfo;
}
export interface CertStatusResponse { export interface CertStatusResponse {
configured: boolean; configured: boolean;
domain?: string; domain?: string;
gatewayDomain?: string;
canProvision: boolean;
hasToken: boolean;
hasEmail: boolean;
message?: string; message?: string;
certs?: CertEntry[];
/** @deprecated Use certs array instead */
cert?: CertInfo; cert?: CertInfo;
} }
@@ -27,8 +39,9 @@ export const certApi = {
return apiClient.get('/api/v1/cert/status'); return apiClient.get('/api/v1/cert/status');
}, },
async provision(): Promise<CertProvisionResponse> { async provision(domain?: string): Promise<CertProvisionResponse> {
return apiClient.post('/api/v1/cert/provision', {}); const query = domain ? `?domain=${encodeURIComponent(domain)}` : '';
return apiClient.post(`/api/v1/cert/provision${query}`, {});
}, },
async renew(): Promise<{ message: string }> { async renew(): Promise<{ message: string }> {