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:
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertProvision provisions a TLS certificate for the central domain using DNS-01.
|
// 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 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 && <> · {certStatus.cert.issuerCN}</>}
|
{centralCert.cert.issuerCN && <> · {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 */}
|
||||||
|
|||||||
@@ -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'] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 }> {
|
||||||
|
|||||||
Reference in New Issue
Block a user