637 lines
17 KiB
Go
637 lines
17 KiB
Go
package internal
|
||
|
||
import (
|
||
"crypto"
|
||
"crypto/ecdsa"
|
||
"crypto/elliptic"
|
||
"crypto/rand"
|
||
"crypto/x509"
|
||
"encoding/json"
|
||
"encoding/pem"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"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
|
||
// - Let’s Encrypt production by default (from config fallback)
|
||
// - Cloudflare DNS-01 only
|
||
func NewACMEManager() (*ACMEManager, error) {
|
||
// Pull effective (main-only) certificate settings.
|
||
email := config.GetString("Certificates.email")
|
||
dataRoot := config.GetString("Certificates.data_root")
|
||
caDirURL := config.GetString("Certificates.ca_dir_url")
|
||
|
||
// 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()
|
||
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) (*certificate.Resource, error) {
|
||
rcfg, err := buildDomainRuntimeConfig(domainKey)
|
||
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 := 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(domainKey string) (*DomainRuntimeConfig, error) {
|
||
domainCfg, exists := domainStore.Get(domainKey)
|
||
if !exists {
|
||
return nil, fmt.Errorf("domain config not found for %q", domainKey)
|
||
}
|
||
|
||
domainName := domainCfg.GetString("Domain.domain_name")
|
||
|
||
email := config.GetString("Certificates.email")
|
||
|
||
// domain override data_root can be blank -> main fallback
|
||
dataRoot, err := EffectiveString(domainCfg, "Certificates.data_root")
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
caDirURL := config.GetString("Certificates.ca_dir_url")
|
||
|
||
expiry := domainCfg.GetInt("Certificates.expiry")
|
||
|
||
renewPeriod := domainCfg.GetInt("Certificates.renew_period")
|
||
|
||
requestMethod := domainCfg.GetString("Certificates.request_method")
|
||
|
||
subdomains := domainCfg.GetString("Certificates.subdomains")
|
||
subdomainArray := parseCSVLines(subdomains)
|
||
|
||
return &DomainRuntimeConfig{
|
||
DomainName: domainName,
|
||
Email: email,
|
||
DataRoot: dataRoot,
|
||
CADirURL: caDirURL,
|
||
ExpiryDays: expiry,
|
||
RenewPeriod: renewPeriod,
|
||
RequestMethod: requestMethod,
|
||
Subdomains: subdomainArray,
|
||
}, nil
|
||
}
|
||
|
||
func parseCSVLines(raw string) []string {
|
||
// supports comma-separated and newline-separated lists
|
||
fields := strings.FieldsFunc(raw, func(r rune) bool {
|
||
return r == ',' || r == '\n' || r == '\r'
|
||
})
|
||
out := make([]string, 0, len(fields))
|
||
for _, f := range fields {
|
||
s := strings.TrimSpace(f)
|
||
if s != "" {
|
||
out = append(out, s)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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() (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.GetString("Cloudflare.cf_email")
|
||
cfAPIKey := config.GetString("Cloudflare.cf_api_key")
|
||
|
||
// 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 := fileExists(accountJSON)
|
||
keyExists := 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 := 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 := 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 := 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
|
||
}
|