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/Keys/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 // - Let’s 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 }