351 lines
9.7 KiB
Go
351 lines
9.7 KiB
Go
package tls
|
|
|
|
import (
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/elliptic"
|
|
"crypto/rand"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/go-acme/lego/v4/certcrypto"
|
|
"github.com/go-acme/lego/v4/certificate"
|
|
"github.com/go-acme/lego/v4/challenge"
|
|
"github.com/go-acme/lego/v4/challenge/http01"
|
|
"github.com/go-acme/lego/v4/lego"
|
|
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
|
"github.com/go-acme/lego/v4/providers/dns/digitalocean"
|
|
"github.com/go-acme/lego/v4/providers/dns/hetzner"
|
|
"github.com/go-acme/lego/v4/providers/dns/route53"
|
|
"github.com/go-acme/lego/v4/registration"
|
|
)
|
|
|
|
// ACME manages Let's Encrypt certificate issuance and renewal.
|
|
type ACME struct {
|
|
cfg ACMEConfig
|
|
client *lego.Client
|
|
account *acmeAccount
|
|
cacheDir string
|
|
}
|
|
|
|
// ACMEConfig holds all ACME-related settings from app config.
|
|
type ACMEConfig struct {
|
|
Email string
|
|
CacheDir string
|
|
Staging bool
|
|
Domains []string // e.g. ["example.com", "*.example.com"]
|
|
Mode string // "dns01" | "http01"
|
|
DNSProvider string // "cloudflare" | "route53" | "digitalocean" | "hetzner" | ...
|
|
}
|
|
|
|
// acmeAccount implements lego's registration.User interface.
|
|
type acmeAccount struct {
|
|
Email string `json:"email"`
|
|
Registration *registration.Resource `json:"registration"`
|
|
key crypto.PrivateKey
|
|
keyPEM []byte
|
|
}
|
|
|
|
func (a *acmeAccount) GetEmail() string { return a.Email }
|
|
func (a *acmeAccount) GetRegistration() *registration.Resource { return a.Registration }
|
|
func (a *acmeAccount) GetPrivateKey() crypto.PrivateKey { return a.key }
|
|
|
|
// NewACME initialises the ACME client. Does not yet obtain a cert.
|
|
// Call ObtainOrRenew() to get/refresh the certificate.
|
|
func NewACME(cfg ACMEConfig) (*ACME, error) {
|
|
if cfg.Email == "" {
|
|
return nil, fmt.Errorf("ACME_EMAIL required for Let's Encrypt")
|
|
}
|
|
if len(cfg.Domains) == 0 {
|
|
return nil, fmt.Errorf("ACME_DOMAINS must not be empty")
|
|
}
|
|
if err := os.MkdirAll(cfg.CacheDir, 0700); err != nil {
|
|
return nil, fmt.Errorf("acme cache dir: %w", err)
|
|
}
|
|
|
|
a := &ACME{cfg: cfg, cacheDir: cfg.CacheDir}
|
|
|
|
acc, err := a.loadOrCreateAccount()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme account: %w", err)
|
|
}
|
|
a.account = acc
|
|
|
|
legoConfig := lego.NewConfig(a.account)
|
|
if cfg.Staging {
|
|
legoConfig.CADirURL = lego.LEDirectoryStaging
|
|
} else {
|
|
legoConfig.CADirURL = lego.LEDirectoryProduction
|
|
}
|
|
legoConfig.Certificate.KeyType = certcrypto.RSA2048
|
|
|
|
client, err := lego.NewClient(legoConfig)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lego client: %w", err)
|
|
}
|
|
a.client = client
|
|
|
|
// Configure challenge provider.
|
|
if err := a.setProvider(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Register account if not already registered.
|
|
if acc.Registration == nil {
|
|
reg, err := client.Registration.Register(registration.RegisterOptions{
|
|
TermsOfServiceAgreed: true,
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme register: %w", err)
|
|
}
|
|
a.account.Registration = reg
|
|
if err := a.saveAccount(); err != nil {
|
|
return nil, fmt.Errorf("save acme account: %w", err)
|
|
}
|
|
}
|
|
|
|
return a, nil
|
|
}
|
|
|
|
// setProvider configures the ACME challenge based on cfg.Mode and cfg.DNSProvider.
|
|
func (a *ACME) setProvider() error {
|
|
switch a.cfg.Mode {
|
|
case "http01":
|
|
// Lego manages an ephemeral HTTP server on port 80.
|
|
return a.client.Challenge.SetHTTP01Provider(http01.NewProviderServer("", "80"))
|
|
|
|
case "dns01":
|
|
provider, err := a.buildDNSProvider()
|
|
if err != nil {
|
|
return fmt.Errorf("dns01 provider %q: %w", a.cfg.DNSProvider, err)
|
|
}
|
|
return a.client.Challenge.SetDNS01Provider(provider)
|
|
|
|
default:
|
|
return fmt.Errorf("unknown ACME mode %q (want dns01 or http01)", a.cfg.Mode)
|
|
}
|
|
}
|
|
|
|
// buildDNSProvider returns the lego DNS provider for cfg.DNSProvider.
|
|
// Credentials are read from env vars set by config.exportProviderEnv().
|
|
func (a *ACME) buildDNSProvider() (challenge.Provider, error) {
|
|
switch a.cfg.DNSProvider {
|
|
case "cloudflare":
|
|
return cloudflare.NewDNSProvider()
|
|
|
|
case "route53":
|
|
return route53.NewDNSProvider()
|
|
|
|
case "digitalocean":
|
|
return digitalocean.NewDNSProvider()
|
|
|
|
case "hetzner":
|
|
return hetzner.NewDNSProvider()
|
|
|
|
default:
|
|
// Generic: lego supports 90+ providers; if the user sets the correct
|
|
// env vars and the provider name matches a lego provider, it WILL work
|
|
// through the lego plugin system. For unlisted providers, document that
|
|
// the user must set env vars matching the lego provider docs.
|
|
return nil, fmt.Errorf(
|
|
"provider %q not built-in; set lego env vars and use a supported provider name.\n"+
|
|
"Built-in: cloudflare, route53, digitalocean, hetzner.\n"+
|
|
"Full list: https://go-acme.github.io/lego/dns/",
|
|
a.cfg.DNSProvider,
|
|
)
|
|
}
|
|
}
|
|
|
|
// ObtainOrRenew returns a valid *tls.Certificate, obtaining or renewing as needed.
|
|
// Uses cached cert on disk if it has >30 days remaining.
|
|
func (a *ACME) ObtainOrRenew() (*tls.Certificate, error) {
|
|
cached, err := a.loadCachedCert()
|
|
if err == nil && cached != nil {
|
|
return cached, nil
|
|
}
|
|
|
|
return a.obtain()
|
|
}
|
|
|
|
// obtain requests a new certificate from Let's Encrypt.
|
|
func (a *ACME) obtain() (*tls.Certificate, error) {
|
|
req := certificate.ObtainRequest{
|
|
Domains: a.cfg.Domains,
|
|
Bundle: true, // include full chain in cert PEM
|
|
}
|
|
|
|
res, err := a.client.Certificate.Obtain(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("acme obtain %v: %w", a.cfg.Domains, err)
|
|
}
|
|
|
|
if err := a.saveCert(res); err != nil {
|
|
// Non-fatal: cert is in memory, just can't persist.
|
|
fmt.Printf("[acme] WARNING: could not cache cert: %v\n", err)
|
|
}
|
|
|
|
return parseCert(res.Certificate, res.PrivateKey)
|
|
}
|
|
|
|
// RenewalLoop blocks forever, renewing the cert 30 days before expiry.
|
|
// manager.UpdateCert() is called on each renewal to hot-swap the TLS config.
|
|
// Call as a goroutine. stopCh receives when it should quit.
|
|
func (a *ACME) RenewalLoop(manager *Manager, stopCh <-chan struct{}) {
|
|
ticker := time.NewTicker(12 * time.Hour)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-stopCh:
|
|
return
|
|
case <-ticker.C:
|
|
cached, err := a.loadCachedCert()
|
|
if err != nil || cached == nil {
|
|
// No cert or corrupt cache — re-obtain.
|
|
cert, err := a.obtain()
|
|
if err != nil {
|
|
fmt.Printf("[acme] renewal obtain error: %v\n", err)
|
|
continue
|
|
}
|
|
manager.UpdateCert(cert)
|
|
fmt.Printf("[acme] cert renewed for %v\n", a.cfg.Domains)
|
|
}
|
|
// loadCachedCert returns nil if cert expires in < 30 days → triggers re-obtain above.
|
|
}
|
|
}
|
|
}
|
|
|
|
// ---- Cert persistence ----
|
|
|
|
func (a *ACME) certPath() string { return filepath.Join(a.cacheDir, "cert.pem") }
|
|
func (a *ACME) keyPath() string { return filepath.Join(a.cacheDir, "key.pem") }
|
|
func (a *ACME) accPath() string { return filepath.Join(a.cacheDir, "account.json") }
|
|
func (a *ACME) accKeyPath() string { return filepath.Join(a.cacheDir, "account.key") }
|
|
|
|
func (a *ACME) saveCert(res *certificate.Resource) error {
|
|
if err := os.WriteFile(a.certPath(), res.Certificate, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(a.keyPath(), res.PrivateKey, 0600)
|
|
}
|
|
|
|
// loadCachedCert returns the cached cert if it has >30 days remaining, nil otherwise.
|
|
func (a *ACME) loadCachedCert() (*tls.Certificate, error) {
|
|
certPEM, err := os.ReadFile(a.certPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
keyPEM, err := os.ReadFile(a.keyPath())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
cert, err := parseCert(certPEM, keyPEM)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// Check expiry — renew if <30 days left.
|
|
if cert.Leaf != nil && time.Until(cert.Leaf.NotAfter) < 30*24*time.Hour {
|
|
return nil, nil // signal: needs renewal
|
|
}
|
|
// Parse leaf if not already parsed.
|
|
if cert.Leaf == nil {
|
|
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
if err == nil && time.Until(leaf.NotAfter) < 30*24*time.Hour {
|
|
return nil, nil
|
|
}
|
|
}
|
|
|
|
return cert, nil
|
|
}
|
|
|
|
func parseCert(certPEM, keyPEM []byte) (*tls.Certificate, error) {
|
|
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse cert: %w", err)
|
|
}
|
|
// Pre-parse leaf for expiry checks.
|
|
if len(cert.Certificate) > 0 {
|
|
leaf, err := x509.ParseCertificate(cert.Certificate[0])
|
|
if err == nil {
|
|
cert.Leaf = leaf
|
|
}
|
|
}
|
|
return &cert, nil
|
|
}
|
|
|
|
// ---- Account persistence ----
|
|
|
|
func (a *ACME) loadOrCreateAccount() (*acmeAccount, error) {
|
|
// Try loading existing account.
|
|
accData, errAcc := os.ReadFile(a.accPath())
|
|
keyData, errKey := os.ReadFile(a.accKeyPath())
|
|
|
|
if errAcc == nil && errKey == nil {
|
|
acc := &acmeAccount{}
|
|
if err := json.Unmarshal(accData, acc); err != nil {
|
|
return nil, fmt.Errorf("parse account: %w", err)
|
|
}
|
|
key, err := parseECKey(keyData)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse account key: %w", err)
|
|
}
|
|
acc.key = key
|
|
acc.keyPEM = keyData
|
|
return acc, nil
|
|
}
|
|
|
|
// Generate new account key.
|
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gen account key: %w", err)
|
|
}
|
|
keyPEM, err := encodeECKey(key)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
acc := &acmeAccount{
|
|
Email: a.cfg.Email,
|
|
key: key,
|
|
keyPEM: keyPEM,
|
|
}
|
|
return acc, nil
|
|
}
|
|
|
|
func (a *ACME) saveAccount() error {
|
|
data, err := json.MarshalIndent(a.account, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := os.WriteFile(a.accPath(), data, 0600); err != nil {
|
|
return err
|
|
}
|
|
return os.WriteFile(a.accKeyPath(), a.account.keyPEM, 0600)
|
|
}
|
|
|
|
// ---- EC key helpers ----
|
|
|
|
func encodeECKey(key *ecdsa.PrivateKey) ([]byte, error) {
|
|
der, err := x509.MarshalECPrivateKey(key)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal ec key: %w", err)
|
|
}
|
|
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
|
|
}
|
|
|
|
func parseECKey(pemData []byte) (*ecdsa.PrivateKey, error) {
|
|
block, _ := pem.Decode(pemData)
|
|
if block == nil {
|
|
return nil, fmt.Errorf("no PEM block in account key")
|
|
}
|
|
return x509.ParseECPrivateKey(block.Bytes)
|
|
}
|