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:
@@ -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-- {
|
||||
|
||||
Reference in New Issue
Block a user