Files
certman/server/acme_manager.go
Steven Tracey 18f414e474
All checks were successful
Build (artifact) / build (push) Has been skipped
[CI-SKIP] Fixed module name
2026-03-16 23:03:08 +01:00

619 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server
import (
"crypto"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"git.nevets.tech/Steven/certman/common"
"github.com/go-acme/lego/v4/certcrypto"
"github.com/go-acme/lego/v4/certificate"
"github.com/go-acme/lego/v4/lego"
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
"github.com/go-acme/lego/v4/registration"
)
// ---------------------------------------------
// Thread safety for your domain config map
// (assumes you already have these globals elsewhere)
// ---------------------------------------------
// var MU sync.RWMutex
// var domainConfigs map[string]*ezconf.Configuration
// var config *ezconf.Configuration
//
// func getDomainConfig(domain string) (*ezconf.Configuration, bool) { ... }
// ---------------------------------------------
// ACME account User (file-backed)
// ---------------------------------------------
type fileUser struct {
Email string `json:"email"`
Registration *registration.Resource `json:"registration,omitempty"`
privateKey crypto.PrivateKey `json:"-"`
}
func (u *fileUser) GetEmail() string { return u.Email }
func (u *fileUser) GetRegistration() *registration.Resource { return u.Registration }
func (u *fileUser) GetPrivateKey() crypto.PrivateKey { return u.privateKey }
// ---------------------------------------------
// Manager config + manager
// ---------------------------------------------
type ACMEManager struct {
MU sync.Mutex // serializes lego Client ops + account writes
Client *lego.Client
User *fileUser
// root dirs
dataRoot string // e.g. /var/local/certman
accountRoot string // e.g. /var/local/certman/accounts
CertsRoot string // e.g. /var/local/certman/certificates
}
// DomainRuntimeConfig has domain-specific runtime settings derived from main+domain config.
type DomainRuntimeConfig struct {
DomainName string
Email string
DataRoot string
CADirURL string
ExpiryDays int
RenewPeriod int
RequestMethod string
Subdomains []string
}
type StoredCertMeta struct {
DomainKey string `json:"domain_key"`
Domains []string `json:"domains"`
CertURL string `json:"cert_url,omitempty"`
CertStableURL string `json:"cert_stable_url,omitempty"`
LastIssued time.Time `json:"last_issued,omitempty"`
LastRenewed time.Time `json:"last_renewed,omitempty"`
}
// ---------------------------------------------
// Public API
// ---------------------------------------------
// NewACMEManager initializes a long-lived lego Client using:
// - file-backed account
// - persistent ECDSA P-256 account key
// - Lets Encrypt production by default (from config fallback)
// - Cloudflare DNS-01 only
func NewACMEManager(config *common.AppConfig) (*ACMEManager, error) {
// Pull effective (main-only) certificate settings.
email := config.Certificates.Email
dataRoot := config.Certificates.DataRoot
caDirURL := config.Certificates.CADirURL
// Build manager paths
mgr := &ACMEManager{
dataRoot: dataRoot,
accountRoot: filepath.Join(dataRoot, "accounts"),
CertsRoot: filepath.Join(dataRoot, "certificates"),
}
if err := os.MkdirAll(mgr.accountRoot, 0o700); err != nil {
return nil, fmt.Errorf("create account root: %w", err)
}
if err := os.MkdirAll(mgr.CertsRoot, 0o700); err != nil {
return nil, fmt.Errorf("create certs root: %w", err)
}
// Create/load file-backed account User
user, err := loadOrCreateACMEUser(mgr.accountRoot, email)
if err != nil {
return nil, fmt.Errorf("load/create acme User: %w", err)
}
// Cloudflare provider (DNS-01 only).
// lego Cloudflare provider expects env vars (CLOUDFLARE_EMAIL/CLOUDFLARE_API_KEY or tokens). :contentReference[oaicite:2]{index=2}
restoreEnv, err := setCloudflareEnvFromMainConfig(config)
if err != nil {
return nil, err
}
defer restoreEnv()
legoCfg := lego.NewConfig(user)
legoCfg.CADirURL = caDirURL
// Secure cert key type for issued certs (ECDSA P-256)
legoCfg.Certificate.KeyType = certcrypto.EC256
client, err := lego.NewClient(legoCfg)
if err != nil {
return nil, fmt.Errorf("lego.NewClient: %w", err)
}
cfProvider, err := cloudflare.NewDNSProvider()
if err != nil {
return nil, fmt.Errorf("cloudflare dns provider: %w", err)
}
if err := client.Challenge.SetDNS01Provider(cfProvider); err != nil {
return nil, fmt.Errorf("set dns-01 provider: %w", err)
}
mgr.Client = client
mgr.User = user
// Register account only on first run
if mgr.User.Registration == nil {
reg, err := mgr.Client.Registration.Register(registration.RegisterOptions{
TermsOfServiceAgreed: true,
})
if err != nil {
return nil, fmt.Errorf("acme registration: %w", err)
}
mgr.User.Registration = reg
if err := saveACMEUser(mgr.accountRoot, mgr.User); err != nil {
return nil, fmt.Errorf("save acme User registration: %w", err)
}
}
return mgr, nil
}
// ObtainForDomain obtains a new cert for a configured domain and saves it to disk.
func (m *ACMEManager) ObtainForDomain(domainKey string, config *common.AppConfig, domainConfig *common.DomainConfig) (*certificate.Resource, error) {
rcfg, err := buildDomainRuntimeConfig(config, domainConfig)
if err != nil {
return nil, err
}
domains := buildDomainList(rcfg.DomainName, rcfg.Subdomains)
if len(domains) == 0 {
return nil, fmt.Errorf("no domains built for %q", domainKey)
}
req := certificate.ObtainRequest{
Domains: domains,
Bundle: true,
}
m.MU.Lock()
defer m.MU.Unlock()
res, err := m.Client.Certificate.Obtain(req)
if err != nil {
return nil, fmt.Errorf("obtain %q: %w", domainKey, err)
}
if err := m.saveCertFiles(domainKey, res, true); err != nil {
return nil, fmt.Errorf("save cert files for %q: %w", domainKey, err)
}
return res, nil
}
// RenewForDomain renews an existing stored cert for a domain key.
func (m *ACMEManager) RenewForDomain(domainKey string) (*certificate.Resource, error) {
m.MU.Lock()
defer m.MU.Unlock()
existing, err := m.loadStoredResource(domainKey)
if err != nil {
return nil, fmt.Errorf("load stored resource for %q: %w", domainKey, err)
}
// RenewWithOptions is preferred in newer lego versions.
renewed, err := m.Client.Certificate.RenewWithOptions(*existing, &certificate.RenewOptions{
Bundle: true,
})
if err != nil {
return nil, fmt.Errorf("renew %q: %w", domainKey, err)
}
if err := m.saveCertFiles(domainKey, renewed, false); err != nil {
return nil, fmt.Errorf("save renewed cert files for %q: %w", domainKey, err)
}
return renewed, nil
}
// GetCertPaths returns disk paths for the domain's cert material.
func (m *ACMEManager) GetCertPaths(domainKey string) (certPEM, keyPEM string) {
base := common.SanitizeDomainKey(domainKey)
dir := filepath.Join(m.CertsRoot, base)
return filepath.Join(dir, base+".crt"),
filepath.Join(dir, base+".key")
}
// ---------------------------------------------
// Domain runtime config assembly
// ---------------------------------------------
func buildDomainRuntimeConfig(config *common.AppConfig, domainConfig *common.DomainConfig) (*DomainRuntimeConfig, error) {
domainName := domainConfig.Domain.DomainName
email := config.Certificates.Email
// domain override data_root can be blank -> main fallback
var dataRoot string
if domainConfig.Certificates.DataRoot == "" {
dataRoot = config.Certificates.DataRoot
} else {
dataRoot = domainConfig.Certificates.DataRoot
}
caDirURL := config.Certificates.CADirURL
expiry := domainConfig.Certificates.Expiry
renewPeriod := domainConfig.Certificates.RenewPeriod
requestMethod := domainConfig.Certificates.RequestMethod
subdomainArray := domainConfig.Certificates.SubDomains
return &DomainRuntimeConfig{
DomainName: domainName,
Email: email,
DataRoot: dataRoot,
CADirURL: caDirURL,
ExpiryDays: expiry,
RenewPeriod: renewPeriod,
RequestMethod: requestMethod,
Subdomains: subdomainArray,
}, nil
}
// If subdomains contains ["www","api"], returns ["example.com","www.example.com","api.example.com"].
// If a subdomain entry looks like a full FQDN already, it is used as-is.
func buildDomainList(baseDomain string, subs []string) []string {
seen := map[string]struct{}{}
add := func(d string) {
d = strings.TrimSpace(strings.ToLower(d))
if d == "" {
return
}
if _, ok := seen[d]; ok {
return
}
seen[d] = struct{}{}
}
add(baseDomain)
for _, s := range subs {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if s == "*" {
add("*." + baseDomain)
continue
}
if strings.HasPrefix(s, "*.") && !strings.HasSuffix(s, "."+baseDomain) {
rest := strings.TrimPrefix(s, "*.")
if rest != "" {
add("*." + rest + "." + baseDomain)
continue
}
}
if strings.Contains(s, ".") {
add(s)
continue
}
add(s + "." + baseDomain)
}
out := make([]string, 0, len(seen))
for d := range seen {
out = append(out, d)
}
return out
}
// ---------------------------------------------
// Cloudflare env setup from main config
// ---------------------------------------------
func setCloudflareEnvFromMainConfig(config *common.AppConfig) (restore func(), err error) {
// Your current defaults show legacy email + API key fields.
// Prefer API tokens in the future if you add them. Cloudflare provider supports both styles. :contentReference[oaicite:3]{index=3}
cfEmail := config.Cloudflare.CFEmail
cfAPIKey := config.Cloudflare.CFAPIKey
// Save prior env values so we can restore them after provider creation.
prevEmail, hadEmail := os.LookupEnv("CLOUDFLARE_EMAIL")
prevKey, hadKey := os.LookupEnv("CLOUDFLARE_API_KEY")
if err := os.Setenv("CLOUDFLARE_EMAIL", cfEmail); err != nil {
return nil, err
}
if err := os.Setenv("CLOUDFLARE_API_KEY", cfAPIKey); err != nil {
return nil, err
}
restore = func() {
if hadEmail {
_ = os.Setenv("CLOUDFLARE_EMAIL", prevEmail)
} else {
_ = os.Unsetenv("CLOUDFLARE_EMAIL")
}
if hadKey {
_ = os.Setenv("CLOUDFLARE_API_KEY", prevKey)
} else {
_ = os.Unsetenv("CLOUDFLARE_API_KEY")
}
}
return restore, nil
}
// ---------------------------------------------
// File-backed ACME account persistence
// ---------------------------------------------
func loadOrCreateACMEUser(accountRoot, email string) (*fileUser, error) {
if err := os.MkdirAll(accountRoot, 0o700); err != nil {
return nil, err
}
accountJSON := filepath.Join(accountRoot, "account.json")
accountKey := filepath.Join(accountRoot, "account.key.pem")
jsonExists := common.FileExists(accountJSON)
keyExists := common.FileExists(accountKey)
switch {
case jsonExists && keyExists:
return loadACMEUser(accountRoot)
case !jsonExists && !keyExists:
return createACMEUser(accountRoot, email)
default:
return nil, fmt.Errorf("inconsistent account state (need both %s and %s)", accountJSON, accountKey)
}
}
func createACMEUser(accountRoot, email string) (*fileUser, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate ECDSA P-256 account key: %w", err)
}
u := &fileUser{
Email: email,
privateKey: priv,
}
if err := saveACMEUser(accountRoot, u); err != nil {
return nil, err
}
return u, nil
}
func loadACMEUser(accountRoot string) (*fileUser, error) {
keyPEM, err := os.ReadFile(filepath.Join(accountRoot, "account.key.pem"))
if err != nil {
return nil, err
}
priv, err := parseECPrivateKeyPEM(keyPEM)
if err != nil {
return nil, err
}
raw, err := os.ReadFile(filepath.Join(accountRoot, "account.json"))
if err != nil {
return nil, err
}
var tmp struct {
Email string `json:"email"`
Registration *registration.Resource `json:"registration,omitempty"`
}
if err := json.Unmarshal(raw, &tmp); err != nil {
return nil, err
}
return &fileUser{
Email: tmp.Email,
Registration: tmp.Registration,
privateKey: priv,
}, nil
}
func saveACMEUser(accountRoot string, u *fileUser) error {
if u == nil {
return errors.New("nil User")
}
if err := os.MkdirAll(accountRoot, 0o700); err != nil {
return err
}
keyPEM, err := marshalECPrivateKeyPEM(u.privateKey)
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(accountRoot, "account.key.pem"), keyPEM, 0o600); err != nil {
return err
}
tmp := struct {
Email string `json:"email"`
Registration *registration.Resource `json:"registration,omitempty"`
}{
Email: u.Email,
Registration: u.Registration,
}
data, err := json.MarshalIndent(tmp, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(accountRoot, "account.json"), data, 0o600); err != nil {
return err
}
return nil
}
func marshalECPrivateKeyPEM(pk crypto.PrivateKey) ([]byte, error) {
ec, ok := pk.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("expected *ecdsa.PrivateKey, got %T", pk)
}
der, err := x509.MarshalECPrivateKey(ec)
if err != nil {
return nil, err
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der}), nil
}
func parseECPrivateKeyPEM(data []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("invalid PEM")
}
if block.Type != "EC PRIVATE KEY" {
return nil, fmt.Errorf("unexpected PEM type %q", block.Type)
}
return x509.ParseECPrivateKey(block.Bytes)
}
// ---------------------------------------------
// Certificate storage / loading
// ---------------------------------------------
type storedResource struct {
Domain string `json:"domain"`
Domains []string `json:"domains"`
CertURL string `json:"cert_url,omitempty"`
CertStableURL string `json:"cert_stable_url,omitempty"`
Certificate []byte `json:"certificate,omitempty"`
PrivateKey []byte `json:"private_key,omitempty"`
IssuerCert []byte `json:"issuer_certificate,omitempty"`
CSR []byte `json:"csr,omitempty"`
}
func (m *ACMEManager) saveCertFiles(domainKey string, res *certificate.Resource, isNew bool) error {
if res == nil {
return errors.New("nil certificate resource")
}
base := common.SanitizeDomainKey(domainKey)
dir := filepath.Join(m.CertsRoot, base)
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
if len(res.PrivateKey) > 0 {
if err := os.WriteFile(filepath.Join(dir, base+".key"), res.PrivateKey, 0o600); err != nil {
return err
}
}
if len(res.IssuerCertificate) > 0 {
fullchain := append(append([]byte{}, res.Certificate...), res.IssuerCertificate...)
if err := os.WriteFile(filepath.Join(dir, base+".crt"), fullchain, 0o600); err != nil {
return err
}
} else if len(res.Certificate) > 0 {
// fallback fullchain = cert only
if err := os.WriteFile(filepath.Join(dir, base+".pem"), res.Certificate, 0o600); err != nil {
return err
}
}
sr := storedResource{
Domain: domainKey,
Domains: append([]string(nil), res.Domain),
CertURL: res.CertURL,
CertStableURL: res.CertStableURL,
Certificate: res.Certificate,
PrivateKey: res.PrivateKey,
IssuerCert: res.IssuerCertificate,
CSR: res.CSR,
}
js, err := json.MarshalIndent(sr, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, base+".json"), js, 0o600); err != nil {
return err
}
// metadata
meta := StoredCertMeta{
DomainKey: domainKey,
Domains: append([]string(nil), res.Domain),
CertURL: res.CertURL,
CertStableURL: res.CertStableURL,
}
now := time.Now().UTC()
if old, err := m.loadMeta(domainKey); err == nil && !old.LastIssued.IsZero() {
meta.LastIssued = old.LastIssued
}
if isNew {
if meta.LastIssued.IsZero() {
meta.LastIssued = now
}
} else {
meta.LastRenewed = now
if meta.LastIssued.IsZero() {
meta.LastIssued = now
}
}
metaRaw, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, base+".meta.json"), metaRaw, 0o600); err != nil {
return err
}
return nil
}
func (m *ACMEManager) loadStoredResource(domainKey string) (*certificate.Resource, error) {
base := common.SanitizeDomainKey(domainKey)
dir := filepath.Join(m.CertsRoot, base)
raw, err := os.ReadFile(filepath.Join(dir, base+".json"))
if err != nil {
return nil, err
}
var sr storedResource
if err := json.Unmarshal(raw, &sr); err != nil {
return nil, err
}
return &certificate.Resource{
Domain: sr.Domain,
CertURL: sr.CertURL,
CertStableURL: sr.CertStableURL,
Certificate: sr.Certificate,
PrivateKey: sr.PrivateKey,
IssuerCertificate: sr.IssuerCert,
CSR: sr.CSR,
}, nil
}
func (m *ACMEManager) loadMeta(domainKey string) (*StoredCertMeta, error) {
base := common.SanitizeDomainKey(domainKey)
dir := filepath.Join(m.CertsRoot, base)
raw, err := os.ReadFile(filepath.Join(dir, base+".meta.json"))
if err != nil {
return nil, err
}
var meta StoredCertMeta
if err := json.Unmarshal(raw, &meta); err != nil {
return nil, err
}
return &meta, nil
}