362 lines
10 KiB
Go
Raw Normal View History

2026-03-12 20:51:38 -07:00
package acme
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
// Client wraps the certbot CLI for Let's Encrypt ACME certificate management.
type Client struct {
Email string
Staging bool
Webroot string
AccountDir string
}
// CertInfo holds parsed certificate metadata.
type CertInfo struct {
Domain string `json:"domain"`
CertPath string `json:"cert_path"`
KeyPath string `json:"key_path"`
ChainPath string `json:"chain_path"`
ExpiresAt time.Time `json:"expires_at"`
Issuer string `json:"issuer"`
DaysLeft int `json:"days_left"`
}
// domainRegex validates domain names (basic RFC 1123 hostname check).
var domainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
// NewClient creates a new ACME client.
func NewClient(email string, staging bool, webroot, accountDir string) *Client {
return &Client{
Email: email,
Staging: staging,
Webroot: webroot,
AccountDir: accountDir,
}
}
// validateDomain checks that a domain name is syntactically valid before passing
// it to certbot. This prevents command injection and catches obvious typos.
func validateDomain(domain string) error {
if domain == "" {
return fmt.Errorf("domain name is empty")
}
if len(domain) > 253 {
return fmt.Errorf("domain name too long: %d characters (max 253)", len(domain))
}
if !domainRegex.MatchString(domain) {
return fmt.Errorf("invalid domain name: %q", domain)
}
return nil
}
// Issue requests a new certificate from Let's Encrypt for the given domain
// using the webroot challenge method.
func (c *Client) Issue(domain string) (*CertInfo, error) {
if err := validateDomain(domain); err != nil {
return nil, fmt.Errorf("issue: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return nil, fmt.Errorf("issue: %w", err)
}
// Ensure webroot directory exists
if err := os.MkdirAll(c.Webroot, 0755); err != nil {
return nil, fmt.Errorf("issue: create webroot: %w", err)
}
args := []string{
"certonly", "--webroot",
"-w", c.Webroot,
"-d", domain,
"--non-interactive",
"--agree-tos",
"-m", c.Email,
}
if c.Staging {
args = append(args, "--staging")
}
cmd := exec.Command("certbot", args...)
out, err := cmd.CombinedOutput()
if err != nil {
return nil, fmt.Errorf("certbot certonly failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return c.GetCertInfo(domain)
}
// Renew renews the certificate for a specific domain.
func (c *Client) Renew(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("renew: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("renew: %w", err)
}
cmd := exec.Command("certbot", "renew",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot renew failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// RenewAll renews all certificates managed by certbot that are due for renewal.
func (c *Client) RenewAll() (string, error) {
if err := c.EnsureCertbotInstalled(); err != nil {
return "", fmt.Errorf("renew all: %w", err)
}
cmd := exec.Command("certbot", "renew", "--non-interactive")
out, err := cmd.CombinedOutput()
output := string(out)
if err != nil {
return output, fmt.Errorf("certbot renew --all failed: %s: %w", strings.TrimSpace(output), err)
}
return output, nil
}
// Revoke revokes the certificate for a given domain.
func (c *Client) Revoke(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("revoke: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("revoke: %w", err)
}
cmd := exec.Command("certbot", "revoke",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot revoke failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// Delete removes a certificate and its renewal configuration from certbot.
func (c *Client) Delete(domain string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := c.EnsureCertbotInstalled(); err != nil {
return fmt.Errorf("delete: %w", err)
}
cmd := exec.Command("certbot", "delete",
"--cert-name", domain,
"--non-interactive",
)
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("certbot delete failed: %s: %w", strings.TrimSpace(string(out)), err)
}
return nil
}
// ListCerts scans /etc/letsencrypt/live/ and parses each certificate to return
// metadata including expiry dates and issuer information.
func (c *Client) ListCerts() ([]CertInfo, error) {
liveDir := "/etc/letsencrypt/live"
entries, err := os.ReadDir(liveDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // No certs directory yet
}
return nil, fmt.Errorf("list certs: read live dir: %w", err)
}
var certs []CertInfo
for _, entry := range entries {
if !entry.IsDir() {
continue
}
domain := entry.Name()
// Skip the README directory certbot sometimes creates
if domain == "README" {
continue
}
info, err := c.GetCertInfo(domain)
if err != nil {
// Log but skip certs we can't parse
continue
}
certs = append(certs, *info)
}
return certs, nil
}
// GetCertInfo reads and parses the X.509 certificate at the standard Let's
// Encrypt live path for a domain, returning structured metadata.
func (c *Client) GetCertInfo(domain string) (*CertInfo, error) {
if err := validateDomain(domain); err != nil {
return nil, fmt.Errorf("get cert info: %w", err)
}
liveDir := filepath.Join("/etc/letsencrypt/live", domain)
certPath := filepath.Join(liveDir, "fullchain.pem")
keyPath := filepath.Join(liveDir, "privkey.pem")
chainPath := filepath.Join(liveDir, "chain.pem")
data, err := os.ReadFile(certPath)
if err != nil {
return nil, fmt.Errorf("get cert info: read cert: %w", err)
}
block, _ := pem.Decode(data)
if block == nil {
return nil, fmt.Errorf("get cert info: no PEM block found in %s", certPath)
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return nil, fmt.Errorf("get cert info: parse x509: %w", err)
}
daysLeft := int(time.Until(cert.NotAfter).Hours() / 24)
return &CertInfo{
Domain: domain,
CertPath: certPath,
KeyPath: keyPath,
ChainPath: chainPath,
ExpiresAt: cert.NotAfter,
Issuer: cert.Issuer.CommonName,
DaysLeft: daysLeft,
}, nil
}
// EnsureCertbotInstalled checks whether certbot is available in PATH. If not,
// it attempts to install it via apt-get.
func (c *Client) EnsureCertbotInstalled() error {
if _, err := exec.LookPath("certbot"); err == nil {
return nil // Already installed
}
// Attempt to install via apt-get
cmd := exec.Command("apt-get", "update", "-qq")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get update failed: %s: %w", strings.TrimSpace(string(out)), err)
}
cmd = exec.Command("apt-get", "install", "-y", "-qq", "certbot")
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("apt-get install certbot failed: %s: %w", strings.TrimSpace(string(out)), err)
}
// Verify installation succeeded
if _, err := exec.LookPath("certbot"); err != nil {
return fmt.Errorf("certbot still not found after installation attempt")
}
return nil
}
// GenerateSelfSigned creates a self-signed X.509 certificate and private key
// for testing or as a fallback when Let's Encrypt is unavailable.
func (c *Client) GenerateSelfSigned(domain, certPath, keyPath string) error {
if err := validateDomain(domain); err != nil {
return fmt.Errorf("generate self-signed: %w", err)
}
// Ensure output directories exist
if err := os.MkdirAll(filepath.Dir(certPath), 0755); err != nil {
return fmt.Errorf("generate self-signed: create cert dir: %w", err)
}
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); err != nil {
return fmt.Errorf("generate self-signed: create key dir: %w", err)
}
// Generate ECDSA P-256 private key
privKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return fmt.Errorf("generate self-signed: generate key: %w", err)
}
// Build the certificate template
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return fmt.Errorf("generate self-signed: serial number: %w", err)
}
notBefore := time.Now()
notAfter := notBefore.Add(365 * 24 * time.Hour) // 1 year
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
CommonName: domain,
Organization: []string{"Setec Security Labs"},
},
DNSNames: []string{domain},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
// Self-sign the certificate
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &privKey.PublicKey, privKey)
if err != nil {
return fmt.Errorf("generate self-signed: create cert: %w", err)
}
// Write certificate PEM
certFile, err := os.OpenFile(certPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return fmt.Errorf("generate self-signed: write cert: %w", err)
}
defer certFile.Close()
if err := pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER}); err != nil {
return fmt.Errorf("generate self-signed: encode cert PEM: %w", err)
}
// Write private key PEM
keyDER, err := x509.MarshalECPrivateKey(privKey)
if err != nil {
return fmt.Errorf("generate self-signed: marshal key: %w", err)
}
keyFile, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("generate self-signed: write key: %w", err)
}
defer keyFile.Close()
if err := pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}); err != nil {
return fmt.Errorf("generate self-signed: encode key PEM: %w", err)
}
return nil
}