362 lines
10 KiB
Go
362 lines
10 KiB
Go
|
|
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
|
||
|
|
}
|