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 (
|
||||
"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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user