Files
wild-central/internal/api/v1/handlers_certbot.go
Paul Payne 7e8f660d11 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>
2026-07-09 14:47:00 +00:00

155 lines
4.2 KiB
Go

package v1
import (
"fmt"
"net/http"
"strings"
"github.com/wild-cloud/wild-central/internal/config"
)
// 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) {
centralDomain := api.getCentralDomain()
globalCfg, _ := config.LoadGlobalConfig(api.getGlobalConfigPath())
email := ""
if globalCfg != nil {
email = globalCfg.Operator.Email
}
cfToken := api.getCloudflareToken()
// Gather cert statuses for all registered services that need TLS termination
certs := []map[string]any{}
seen := map[string]bool{}
svcs, _ := api.services.List()
for _, svc := range svcs {
if svc.TLS != "terminate" || svc.Domain == "" {
continue
}
if seen[svc.Domain] {
continue
}
seen[svc.Domain] = true
status := api.certbot.GetStatus(svc.Domain)
entry := map[string]any{
"domain": svc.Domain,
"service": svc.Name,
"source": svc.Source,
"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{
"configured": centralDomain != "",
"domain": centralDomain,
"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) {
domain := r.URL.Query().Get("domain")
if domain == "" {
domain = api.getCentralDomain()
}
if domain == "" {
respondError(w, http.StatusBadRequest, "No domain specified and no central domain configured")
return
}
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
if err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to load config: %v", err))
return
}
email := globalCfg.Operator.Email
if email == "" {
respondError(w, http.StatusBadRequest, "Operator email not configured")
return
}
token := api.getCloudflareToken()
if token == "" {
respondError(w, http.StatusBadRequest, "Cloudflare API token not configured")
return
}
if err := api.certbot.EnsureCredentials(token); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write credentials: %v", err))
return
}
if err := api.certbot.Provision(domain, email); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate provisioning 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": fmt.Sprintf("Certificate provisioned for %s", domain),
"cert": status,
})
}
// CertRenew renews all certbot-managed certificates.
func (api *API) CertRenew(w http.ResponseWriter, r *http.Request) {
if err := api.certbot.Renew(); err != nil {
respondError(w, http.StatusInternalServerError, fmt.Sprintf("Certificate renewal failed: %v", err))
return
}
// Rebuild HAProxy PEMs
domain := api.getCentralDomain()
if domain != "" {
_ = api.certbot.BuildHAProxyCert(domain)
}
// Trigger reconciliation
go api.reconcileNetworking()
respondJSON(w, http.StatusOK, map[string]string{"message": "Certificates renewed"})
}
// getCentralDomain returns the configured central domain or empty string.
func (api *API) getCentralDomain() string {
globalCfg, err := config.LoadGlobalConfig(api.getGlobalConfigPath())
if err != nil {
return ""
}
return globalCfg.Cloud.Central.Domain
}