Linter-contributed improvements: - EnsureCentralService() method: registers Central's own domain as a service from config, with cleanup on domain change - Tests for EnsureCentralService: registration, idempotency, domain change cleanup, no-domain-is-noop - Improved reconciliation tests with Central as a registered service - Certbot handler cleanup Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
189 lines
5.2 KiB
Go
189 lines
5.2 KiB
Go
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
|
|
}
|
|
|