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:
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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-- {
|
||||
|
||||
@@ -205,9 +205,11 @@ frontend http_in
|
||||
|
||||
// L7 TLS termination backend + frontend (for HTTP routes)
|
||||
if len(opts.HTTPRoutes) > 0 {
|
||||
// Load all PEM files from the certs directory — HAProxy serves
|
||||
// the right cert per-SNI. Each service gets its own <domain>.pem.
|
||||
certPath := opts.WildcardCert
|
||||
if certPath == "" {
|
||||
certPath = "/etc/haproxy/certs/wildcard.pem"
|
||||
certPath = "/etc/haproxy/certs/"
|
||||
}
|
||||
|
||||
sb.WriteString("backend be_l7_termination\n")
|
||||
|
||||
Reference in New Issue
Block a user