Certbot stores wildcard certs under the base domain (e.g., *.example.com → /etc/letsencrypt/live/example.com/), but the path helpers were using the raw wildcard domain. This caused deploy hooks to reference nonexistent paths, silently failing and leaving certs unrenewable.
211 lines
6.9 KiB
Go
211 lines
6.9 KiB
Go
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"`
|
|
Wildcard bool `json:"wildcard,omitempty"`
|
|
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")
|
|
}
|
|
// Defense-in-depth: reject domains with shell metacharacters.
|
|
// Domain validation at the API boundary should already prevent this.
|
|
for _, c := range domain {
|
|
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '.' || c == '*') {
|
|
return fmt.Errorf("invalid domain for cert provisioning: %q", domain)
|
|
}
|
|
}
|
|
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))
|
|
}
|
|
|
|
// Verify the deploy hook created a valid PEM file
|
|
info, statErr := os.Stat(haproxyPEM)
|
|
if statErr != nil || info.Size() == 0 {
|
|
return fmt.Errorf("cert provisioned but HAProxy PEM is empty or missing: %s", haproxyPEM)
|
|
}
|
|
|
|
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 certificate metadata
|
|
cmd := exec.Command("openssl", "x509", "-in", pemPath, "-noout", "-enddate", "-issuer", "-subject")
|
|
out, err := cmd.Output()
|
|
if err != nil {
|
|
return status
|
|
}
|
|
|
|
parseCertOutput(string(out), status)
|
|
|
|
return status
|
|
}
|
|
|
|
// parseCertOutput parses the output of `openssl x509 -noout -enddate -issuer -subject`
|
|
// and populates the given CertStatus.
|
|
func parseCertOutput(output string, status *CertStatus) {
|
|
for line := range strings.SplitSeq(output, "\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=")
|
|
}
|
|
if strings.HasPrefix(line, "subject=") {
|
|
if strings.Contains(line, "CN=*.") || strings.Contains(line, "CN = *.") {
|
|
status.Wildcard = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// certName returns the name certbot uses to store a certificate.
|
|
// Certbot strips the "*." prefix from wildcard domains, so
|
|
// *.example.com is stored under "example.com".
|
|
func certName(domain string) string {
|
|
return strings.TrimPrefix(domain, "*.")
|
|
}
|
|
|
|
// CertPath returns the fullchain.pem path for a domain.
|
|
func CertPath(domain string) string {
|
|
return fmt.Sprintf("/etc/letsencrypt/live/%s/fullchain.pem", certName(domain))
|
|
}
|
|
|
|
// KeyPath returns the privkey.pem path for a domain.
|
|
func KeyPath(domain string) string {
|
|
return fmt.Sprintf("/etc/letsencrypt/live/%s/privkey.pem", certName(domain))
|
|
}
|
|
|
|
// HAProxyCertPath returns the combined PEM path for HAProxy TLS termination.
|
|
func HAProxyCertPath(domain string) string {
|
|
return fmt.Sprintf("/etc/haproxy/certs/%s.pem", certName(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...)
|
|
if len(combined) == 0 {
|
|
return fmt.Errorf("combined cert+key is empty for %s", domain)
|
|
}
|
|
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)
|
|
}
|