191 lines
6.0 KiB
Go
191 lines
6.0 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")
|
|
}
|
|
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
|
|
}
|
|
|
|
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=")
|
|
}
|
|
if strings.HasPrefix(line, "subject=") {
|
|
if strings.Contains(line, "CN = *.") {
|
|
status.Wildcard = true
|
|
}
|
|
}
|
|
}
|
|
|
|
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...)
|
|
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)
|
|
}
|