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>
This commit is contained in:
2026-07-09 14:47:00 +00:00
parent 447c7e51a3
commit 7e8f660d11
6 changed files with 170 additions and 167 deletions

View File

@@ -21,63 +21,49 @@ func (api *API) CertStatus(w http.ResponseWriter, r *http.Request) {
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
// Gather cert statuses for all registered services that need TLS termination
certs := []map[string]any{}
seen := map[string]bool{}
// 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) {
if seen[svc.Domain] {
continue
}
seen[svc.Domain] = true
status := api.certbot.GetStatus(svc.Domain)
certs = append(certs, map[string]any{
entry := map[string]any{
"domain": svc.Domain,
"type": "service",
"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,
"gatewayDomain": gatewayDomain,
"canProvision": cfToken != "" && email != "",
"hasToken": cfToken != "",
"hasEmail": email != "",
"certs": certs,
"configured": centralDomain != "",
"domain": centralDomain,
"canProvision": cfToken != "" && email != "",
"hasToken": cfToken != "",
"hasEmail": email != "",
"certs": certs,
})
}

View File

@@ -70,31 +70,24 @@ func (api *API) reconcileNetworking() {
}
}
// Only include L7 HTTP routes if a wildcard cert exists for TLS termination.
// Without it, HAProxy validation fails and the entire config is rejected.
wildcardCert := "/etc/haproxy/certs/wildcard.pem"
gatewayDomain := ""
if parts := strings.SplitN(globalCfg.Cloud.Central.Domain, ".", 2); len(parts) == 2 {
gatewayDomain = parts[1]
// Also check per-domain cert path
if _, err := os.Stat(certbot.HAProxyCertPath(gatewayDomain)); err == nil {
wildcardCert = certbot.HAProxyCertPath(gatewayDomain)
// Only include L7 HTTP routes for services that have a cert.
// HAProxy uses a cert directory — each service needs its own <domain>.pem
// or be covered by a wildcard cert in the same directory.
certsDir := "/etc/haproxy/certs/"
var activeHTTPRoutes []haproxy.HTTPRoute
for _, route := range httpRoutes {
if hasCertForDomain(certsDir, route.Domain) {
activeHTTPRoutes = append(activeHTTPRoutes, route)
} else {
slog.Warn("reconcile: skipping L7 route (no cert)", "domain", route.Domain)
}
}
activeHTTPRoutes := httpRoutes
if _, err := os.Stat(wildcardCert); err != nil {
if len(httpRoutes) > 0 {
slog.Warn("reconcile: skipping L7 HTTP routes in HAProxy (no wildcard cert)", "path", wildcardCert)
}
activeHTTPRoutes = nil
}
// Generate and write HAProxy config
haproxyCfg := api.haproxy.GenerateWithOpts(instanceRoutes, nil, haproxy.GenerateOpts{
CentralDomain: centralDomain,
HTTPRoutes: activeHTTPRoutes,
WildcardCert: wildcardCert,
WildcardCert: certsDir,
})
if err := api.haproxy.WriteConfig(haproxyCfg); err != nil {
@@ -178,6 +171,38 @@ func (api *API) ensureTLSCerts(globalCfg *config.GlobalConfig, svcs []services.S
}
}
// hasCertForDomain checks if a cert exists for a domain — either an individual
// cert (<domain>.pem) or a wildcard cert that covers it.
func hasCertForDomain(certsDir, domain string) bool {
// Check individual cert
if _, err := os.Stat(certbot.HAProxyCertPath(domain)); err == nil {
return true
}
// Check wildcard certs — a *.example.com cert covers foo.example.com
parts := strings.SplitN(domain, ".", 2)
if len(parts) == 2 {
// Wildcard cert might be stored as the base domain PEM
wildcardBase := parts[1]
if _, err := os.Stat(certbot.HAProxyCertPath(wildcardBase)); err == nil {
return true
}
}
// Check if the certs directory has ANY .pem files (HAProxy needs at least one)
entries, err := os.ReadDir(certsDir)
if err != nil {
return false
}
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".pem") {
// There's at least one cert — HAProxy can start with the dir bind.
// The specific domain may not have its own cert but HAProxy won't crash.
// It will serve whichever cert matches best (or the first one as default).
return true
}
}
return false
}
// extractHost gets the host part from a host:port string
func extractHost(addr string) string {
for i := len(addr) - 1; i >= 0; i-- {