Server and Client daemons functional
This commit is contained in:
672
acme_manager.go
Normal file
672
acme_manager.go
Normal file
@@ -0,0 +1,672 @@
|
||||
// acme_manager.go
|
||||
package main
|
||||
|
||||
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, err := config.GetAsStringErr("Certificates.email")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dataRoot, err := config.GetAsStringErr("Certificates.data_root")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
caDirURL, err := config.GetAsStringErr("Certificates.ca_dir_url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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 := getDomainConfig(domainKey)
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("domain config not found for %q", domainKey)
|
||||
}
|
||||
|
||||
domainName, err := domainCfg.GetAsStringErr("Domain.domain_name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
email, err := config.GetAsStringErr("Certificates.email")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// domain override data_root can be blank -> main fallback
|
||||
dataRoot, err := getEffectiveString(domainCfg, "Certificates.data_root")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
caDirURL, err := config.GetAsStringErr("Certificates.ca_dir_url")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
expiry, err := domainCfg.GetAsIntErr("Certificates.expiry")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
renewPeriod, err := domainCfg.GetAsIntErr("Certificates.renew_period")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
requestMethod := ""
|
||||
if k, err := domainCfg.GetKey("Certificates.request_method"); err == nil && k != nil {
|
||||
requestMethod = strings.TrimSpace(k.String())
|
||||
}
|
||||
|
||||
subdomains := []string{}
|
||||
if k, err := domainCfg.GetKey("Certificates.subdomains"); err == nil && k != nil {
|
||||
subdomains = parseCSVLines(k.String())
|
||||
}
|
||||
|
||||
return &DomainRuntimeConfig{
|
||||
DomainName: domainName,
|
||||
Email: email,
|
||||
DataRoot: dataRoot,
|
||||
CADirURL: caDirURL,
|
||||
ExpiryDays: expiry,
|
||||
RenewPeriod: renewPeriod,
|
||||
RequestMethod: requestMethod,
|
||||
Subdomains: subdomains,
|
||||
}, 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, err := config.GetAsStringErr("Cloudflare.cf_email")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfAPIKey, err := config.GetAsStringErr("Cloudflare.cf_api_key")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user