package v1 import ( "fmt" "net/http" "os" "strings" "github.com/wild-cloud/wild-central/internal/certbot" "github.com/wild-cloud/wild-central/internal/config" "github.com/wild-cloud/wild-central/internal/services" ) // 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 != services.TLSTerminate || 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.Domain, "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) } // Include standalone certs from disk (e.g. wildcard certs) not tied to a service. certsDir := "/etc/haproxy/certs/" if entries, err := os.ReadDir(certsDir); err == nil { for _, e := range entries { if !strings.HasSuffix(e.Name(), ".pem") { continue } domain := strings.TrimSuffix(e.Name(), ".pem") if seen[domain] { continue } seen[domain] = true status := api.certbot.GetStatus(domain) if !status.Exists { // PEM file exists but openssl couldn't parse it — skip continue } // Use the certbot live directory to confirm this is a certbot-managed cert source := "standalone" certLive := certbot.CertPath(domain) if _, err := os.Stat(certLive); err == nil { source = "certbot" } certs = append(certs, map[string]any{ "domain": domain, "source": source, "cert": status, }) } } 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 }