diff --git a/internal/api/v1/handlers_certbot.go b/internal/api/v1/handlers_certbot.go index a756382..039641d 100644 --- a/internal/api/v1/handlers_certbot.go +++ b/internal/api/v1/handlers_certbot.go @@ -3,33 +3,93 @@ package v1 import ( "fmt" "net/http" + "strings" "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) { - domain := api.getCentralDomain() - if domain == "" { - respondJSON(w, http.StatusOK, map[string]any{ - "configured": false, - "message": "No central domain configured", - }) - return + centralDomain := api.getCentralDomain() + + globalCfg, _ := config.LoadGlobalConfig(api.getGlobalConfigPath()) + email := "" + if globalCfg != nil { + email = globalCfg.Operator.Email } - status := api.certbot.GetStatus(domain) + + cfToken := api.getCloudflareToken() + + // 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, + }) + } + + // 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": true, - "domain": domain, - "cert": status, + "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) { - domain := api.getCentralDomain() + domain := r.URL.Query().Get("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 } @@ -44,34 +104,36 @@ func (api *API) CertProvision(w http.ResponseWriter, r *http.Request) { return } - // Get Cloudflare API token token := api.getCloudflareToken() 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 } - // Write credentials file for certbot if err := api.certbot.EnsureCredentials(token); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write credentials: %v", err)) return } - // Provision the certificate. The deploy hook builds the HAProxy PEM and reloads HAProxy. if err := api.certbot.Provision(domain, email); err != nil { respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioning failed: %v", err)) return } - // Regenerate HAProxy config to ensure central domain SNI routing is active - if _, _, _, err := api.syncHAProxy(); err != nil { - respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioned but HAProxy config update failed: %v", err)) - return + // If this was a wildcard cert, also build HAProxy PEM + if strings.HasPrefix(domain, "*.") { + baseDomain := strings.TrimPrefix(domain, "*.") + _ = 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) respondJSON(w, http.StatusOK, map[string]any{ - "message": "Certificate provisioned successfully", + "message": fmt.Sprintf("Certificate provisioned for %s", domain), "cert": status, }) } @@ -83,13 +145,15 @@ func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) { return } - // Rebuild HAProxy PEM for any renewed certs + // Rebuild HAProxy PEMs domain := api.getCentralDomain() if domain != "" { _ = api.certbot.BuildHAProxyCert(domain) - _, _, _, _ = api.syncHAProxy() } + // Trigger reconciliation + go api.reconcileNetworking() + respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"}) } @@ -101,3 +165,4 @@ func (api *API) getCentralDomain() string { } return globalCfg.Cloud.Central.Domain } + diff --git a/web/src/components/CloudflareComponent.tsx b/web/src/components/CloudflareComponent.tsx index c05638a..0a60eb9 100644 --- a/web/src/components/CloudflareComponent.tsx +++ b/web/src/components/CloudflareComponent.tsx @@ -20,7 +20,7 @@ export function CloudflareComponent() { const { verification, isLoading: isVerifying, refetch: reverify } = useCloudflare(); const { config: globalConfig, updateConfig: updateGlobalConfig, isUpdating } = useConfig(); 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 { data: globalSecrets } = useQuery({ @@ -182,7 +182,7 @@ export function CloudflareComponent() {
Central Domain
- {certStatus?.cert?.exists && ( + {(certStatus?.certs?.some(c => c.cert.exists) || certStatus?.cert?.exists) && ( TLS @@ -256,52 +256,126 @@ export function CloudflareComponent() { )}
- {globalConfig?.cloud?.central?.domain && ( -
- {certLoading ? ( - - ) : certStatus?.cert?.exists ? ( -
-
- - Valid TLS certificate + {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 ( +
+ {certLoading ? ( + + ) : centralCert?.cert.exists ? ( +
+
+ + Valid TLS certificate +
+
+ Expires in {centralCert.cert.daysLeft} days + {centralCert.cert.issuerCN && <> · {centralCert.cert.issuerCN}} +
-
- Expires in {certStatus.cert.daysLeft} days - {certStatus.cert.issuerCN && <> · {certStatus.cert.issuerCN}} + ) : ( +
+
+ + No TLS certificate +
+ + {provisionError && ( + + + {(provisionError as Error).message} + + )}
-
- ) : ( -
-
- - No TLS certificate -
- - {provisionError && ( - - - {(provisionError as Error).message} - - )} -
- )} -
- )} + )} +
+ ); + })()}
)} + {/* TLS Certificates Card */} + {certStatus?.certs && certStatus.certs.length > 0 && ( + +
+
+ +
TLS Certificates
+
+
+
+ {certStatus.certs.map((entry) => ( +
+
+ {entry.domain} + {entry.type} +
+
+ {entry.cert.exists ? ( + + + Valid ({entry.cert.daysLeft}d) + + ) : ( + <> + + + Missing + + {certStatus.canProvision && ( + + )} + + )} +
+
+ ))} + {provisionError && ( + + + {(provisionError as Error).message} + + )} +
+ +
+
+
+ )} + {/* Verification Checklist Card */}
@@ -399,15 +473,22 @@ export function CloudflareComponent() {
- TLS Certificate + TLS Certificates
- {certStatus?.cert?.exists - ? - Valid ({certStatus.cert.daysLeft}d left) - - : certStatus?.configured - ? Not provisioned - : Not configured} + {(() => { + const certs = certStatus?.certs; + const allExist = certs && certs.length > 0 && certs.every(c => c.cert.exists); + const someExist = certs && certs.some(c => c.cert.exists); + if (allExist) { + return All valid; + } else if (someExist) { + return Partial; + } else if (certStatus?.configured) { + return Not provisioned; + } else { + return Not configured; + } + })()}
{/* ExternalDNS note */} diff --git a/web/src/hooks/useCert.ts b/web/src/hooks/useCert.ts index fb1ff4d..6509c2f 100644 --- a/web/src/hooks/useCert.ts +++ b/web/src/hooks/useCert.ts @@ -12,7 +12,7 @@ export function useCert() { }); const provisionMutation = useMutation({ - mutationFn: () => certApi.provision(), + mutationFn: (domain?: string) => certApi.provision(domain), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['cert', 'status'] }); }, diff --git a/web/src/services/api/cert.ts b/web/src/services/api/cert.ts index db5597d..0c5855e 100644 --- a/web/src/services/api/cert.ts +++ b/web/src/services/api/cert.ts @@ -2,7 +2,7 @@ import { apiClient } from './client'; export interface CertInfo { exists: boolean; - domain: string; + domain?: string; expiry?: string; certPath?: string; keyPath?: string; @@ -10,10 +10,22 @@ export interface CertInfo { issuerCN?: string; } +export interface CertEntry { + domain: string; + type: 'central' | 'wildcard' | 'service'; + cert: CertInfo; +} + export interface CertStatusResponse { configured: boolean; domain?: string; + gatewayDomain?: string; + canProvision: boolean; + hasToken: boolean; + hasEmail: boolean; message?: string; + certs?: CertEntry[]; + /** @deprecated Use certs array instead */ cert?: CertInfo; } @@ -27,8 +39,9 @@ export const certApi = { return apiClient.get('/api/v1/cert/status'); }, - async provision(): Promise { - return apiClient.post('/api/v1/cert/provision', {}); + async provision(domain?: string): Promise { + const query = domain ? `?domain=${encodeURIComponent(domain)}` : ''; + return apiClient.post(`/api/v1/cert/provision${query}`, {}); }, async renew(): Promise<{ message: string }> {