package certbot import ( "fmt" "os" "os/exec" "path/filepath" "strings" "time" ) // CertStatus represents the current state of a TLS certificate. type CertStatus struct { Exists bool `json:"exists"` Domain string `json:"domain"` Expiry time.Time `json:"expiry,omitempty"` CertPath string `json:"certPath,omitempty"` KeyPath string `json:"keyPath,omitempty"` DaysLeft int `json:"daysLeft,omitempty"` IssuerCN string `json:"issuerCN,omitempty"` } // Manager handles TLS certificate provisioning via certbot. type Manager struct { credsPath string // path to cloudflare credentials ini file } // NewManager creates a new certbot manager. // credsPath is the path to the Cloudflare API credentials file used for DNS-01 challenges. func NewManager(credsPath string) *Manager { if credsPath == "" { credsPath = "/etc/letsencrypt/cloudflare.ini" } return &Manager{credsPath: credsPath} } // EnsureCredentials writes the Cloudflare API token to the credentials file // for certbot's DNS-01 challenge. Safe to call repeatedly. func (m *Manager) EnsureCredentials(apiToken string) error { if apiToken == "" { return fmt.Errorf("cloudflare API token is empty") } dir := filepath.Dir(m.credsPath) if err := os.MkdirAll(dir, 0700); err != nil { return fmt.Errorf("create credentials dir: %w", err) } content := fmt.Sprintf("dns_cloudflare_api_token = %s\n", apiToken) if err := os.WriteFile(m.credsPath, []byte(content), 0600); err != nil { return fmt.Errorf("write credentials: %w", err) } return nil } // Provision requests a new TLS certificate for the given domain using DNS-01 via Cloudflare. // A deploy hook builds the combined PEM for HAProxy and reloads HAProxy automatically. func (m *Manager) Provision(domain, email string) error { if domain == "" { return fmt.Errorf("domain is required") } if email == "" { return fmt.Errorf("email is required") } if _, err := os.Stat(m.credsPath); err != nil { return fmt.Errorf("cloudflare credentials not found at %s; configure the Cloudflare API token first", m.credsPath) } // Deploy hook runs as root (certbot runs via sudo), so it can read the cert files. // It builds the combined PEM for HAProxy and reloads the service. haproxyPEM := HAProxyCertPath(domain) deployHook := fmt.Sprintf( "mkdir -p %s && cat %s %s > %s && systemctl reload haproxy 2>/dev/null || true", filepath.Dir(haproxyPEM), CertPath(domain), KeyPath(domain), haproxyPEM, ) cmd := exec.Command("sudo", "certbot", "certonly", "--dns-cloudflare", "--dns-cloudflare-credentials", m.credsPath, "-d", domain, "--non-interactive", "--agree-tos", "-m", email, "--deploy-hook", deployHook, ) out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("certbot: %w\n%s", err, string(out)) } return nil } // Renew renews all certificates managed by certbot. func (m *Manager) Renew() error { cmd := exec.Command("sudo", "certbot", "renew", "--non-interactive") out, err := cmd.CombinedOutput() if err != nil { return fmt.Errorf("certbot renew: %w\n%s", err, string(out)) } return nil } // GetStatus returns the TLS certificate status for a domain. // Reads the HAProxy combined PEM (which we own) rather than certbot's root-owned files. func (m *Manager) GetStatus(domain string) *CertStatus { status := &CertStatus{Domain: domain} pemPath := HAProxyCertPath(domain) if _, err := os.Stat(pemPath); err != nil { return status } status.Exists = true status.CertPath = pemPath status.KeyPath = pemPath // Parse expiry from the certificate cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer") out, err := cmd.Output() if err != nil { return status } for line := range strings.SplitSeq(string(out), "\n") { line = strings.TrimSpace(line) if strings.HasPrefix(line, "notAfter=") { dateStr := strings.TrimPrefix(line, "notAfter=") if t, err := time.Parse("Jan 2 15:04:05 2006 GMT", dateStr); err == nil { status.Expiry = t status.DaysLeft = int(time.Until(t).Hours() / 24) } else if t, err := time.Parse("Jan 2 15:04:05 2006 GMT", dateStr); err == nil { status.Expiry = t status.DaysLeft = int(time.Until(t).Hours() / 24) } } if strings.HasPrefix(line, "issuer=") { status.IssuerCN = strings.TrimPrefix(line, "issuer=") } } return status } // CertPath returns the fullchain.pem path for a domain. func CertPath(domain string) string { return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", domain) } // KeyPath returns the privkey.pem path for a domain. func KeyPath(domain string) string { return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", domain) } // HAProxyCertPath returns the combined PEM path for HAProxy TLS termination. func HAProxyCertPath(domain string) string { return fmt.Sprintf("/etc/haproxy/certs/%s.pem", domain) } // BuildHAProxyCert concatenates fullchain.pem + privkey.pem into a single PEM // file that HAProxy can use for TLS termination. // Uses sudo to read certbot's root-owned files. func (m *Manager) BuildHAProxyCert(domain string) error { certData, err := exec.Command("sudo", "cat", CertPath(domain)).Output() if err != nil { return fmt.Errorf("read fullchain: %w", err) } keyData, err := exec.Command("sudo", "cat", KeyPath(domain)).Output() if err != nil { return fmt.Errorf("read privkey: %w", err) } combined := append(certData, keyData...) outPath := HAProxyCertPath(domain) if err := os.MkdirAll(filepath.Dir(outPath), 0700); err != nil { return fmt.Errorf("create haproxy certs dir: %w", err) } return os.WriteFile(outPath, combined, 0600) }