You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
304 lines
7.4 KiB
304 lines
7.4 KiB
package main |
|
|
|
import ( |
|
"crypto" |
|
"crypto/ecdsa" |
|
"crypto/elliptic" |
|
"crypto/rand" |
|
"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/lego" |
|
"github.com/go-acme/lego/v4/registration" |
|
"lol.mleku.dev/chk" |
|
"lol.mleku.dev/log" |
|
) |
|
|
|
// CertManager handles certificate acquisition and renewal. |
|
type CertManager struct { |
|
cfg *Config |
|
client *lego.Client |
|
user *User |
|
certPath string |
|
keyPath string |
|
metaPath string |
|
} |
|
|
|
// User implements the lego registration.User interface. |
|
type User struct { |
|
Email string |
|
Registration *registration.Resource |
|
key crypto.PrivateKey |
|
} |
|
|
|
func (u *User) GetEmail() string { |
|
return u.Email |
|
} |
|
|
|
func (u *User) GetRegistration() *registration.Resource { |
|
return u.Registration |
|
} |
|
|
|
func (u *User) GetPrivateKey() crypto.PrivateKey { |
|
return u.key |
|
} |
|
|
|
// CertMetadata stores certificate metadata. |
|
type CertMetadata struct { |
|
Domain string `json:"domain"` |
|
Domains []string `json:"domains"` |
|
NotBefore time.Time `json:"not_before"` |
|
NotAfter time.Time `json:"not_after"` |
|
Issuer string `json:"issuer"` |
|
RenewedAt time.Time `json:"renewed_at"` |
|
} |
|
|
|
// NewCertManager creates a new certificate manager. |
|
func NewCertManager(cfg *Config) (*CertManager, error) { |
|
// Create output directory |
|
domainDir := filepath.Join(cfg.OutputDir, cfg.BaseDomain()) |
|
if err := os.MkdirAll(domainDir, 0755); chk.E(err) { |
|
return nil, fmt.Errorf("failed to create output directory: %w", err) |
|
} |
|
|
|
// Generate or load account private key |
|
privateKey, err := loadOrCreateAccountKey(cfg) |
|
if chk.E(err) { |
|
return nil, fmt.Errorf("failed to load/create account key: %w", err) |
|
} |
|
|
|
user := &User{ |
|
Email: cfg.Email, |
|
key: privateKey, |
|
} |
|
|
|
// Create lego config |
|
legoCfg := lego.NewConfig(user) |
|
legoCfg.CADirURL = cfg.ACMEServerURL() |
|
legoCfg.Certificate.KeyType = certcrypto.EC256 |
|
|
|
// Create lego client |
|
client, err := lego.NewClient(legoCfg) |
|
if chk.E(err) { |
|
return nil, fmt.Errorf("failed to create ACME client: %w", err) |
|
} |
|
|
|
// Set up DNS provider |
|
dnsProvider, err := NewDNSProvider(cfg.DNSProvider) |
|
if chk.E(err) { |
|
return nil, err |
|
} |
|
|
|
if err := client.Challenge.SetDNS01Provider(dnsProvider); chk.E(err) { |
|
return nil, fmt.Errorf("failed to set DNS provider: %w", err) |
|
} |
|
|
|
// Register account if needed |
|
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) |
|
if err != nil { |
|
// Try to recover existing registration |
|
reg, err = client.Registration.ResolveAccountByKey() |
|
if chk.E(err) { |
|
return nil, fmt.Errorf("failed to register account: %w", err) |
|
} |
|
} |
|
user.Registration = reg |
|
|
|
return &CertManager{ |
|
cfg: cfg, |
|
client: client, |
|
user: user, |
|
certPath: filepath.Join(domainDir, "cert.pem"), |
|
keyPath: filepath.Join(domainDir, "key.pem"), |
|
metaPath: filepath.Join(domainDir, "metadata.json"), |
|
}, nil |
|
} |
|
|
|
// EnsureCertificate obtains a certificate if none exists or if it needs renewal. |
|
func (m *CertManager) EnsureCertificate() error { |
|
// Check if certificate exists and is valid |
|
if m.certificateExists() { |
|
needsRenewal, err := m.needsRenewal() |
|
if chk.E(err) { |
|
log.W.F("failed to check renewal status, will obtain new cert: %v", err) |
|
} else if !needsRenewal { |
|
log.I.F("certificate is valid, no renewal needed") |
|
return nil |
|
} |
|
log.I.F("certificate needs renewal") |
|
} |
|
|
|
return m.obtainCertificate() |
|
} |
|
|
|
// CheckRenewal checks if the certificate needs renewal and renews if needed. |
|
func (m *CertManager) CheckRenewal() error { |
|
if !m.certificateExists() { |
|
return m.obtainCertificate() |
|
} |
|
|
|
needsRenewal, err := m.needsRenewal() |
|
if chk.E(err) { |
|
return err |
|
} |
|
|
|
if needsRenewal { |
|
log.I.F("certificate expiring soon, renewing...") |
|
return m.obtainCertificate() |
|
} |
|
|
|
log.D.F("certificate still valid, no renewal needed") |
|
return nil |
|
} |
|
|
|
func (m *CertManager) certificateExists() bool { |
|
_, err := os.Stat(m.certPath) |
|
return err == nil |
|
} |
|
|
|
func (m *CertManager) needsRenewal() (bool, error) { |
|
certPEM, err := os.ReadFile(m.certPath) |
|
if chk.E(err) { |
|
return true, err |
|
} |
|
|
|
block, _ := pem.Decode(certPEM) |
|
if block == nil { |
|
return true, fmt.Errorf("failed to decode certificate PEM") |
|
} |
|
|
|
cert, err := x509.ParseCertificate(block.Bytes) |
|
if chk.E(err) { |
|
return true, err |
|
} |
|
|
|
// Check if certificate expires within RenewDays |
|
renewTime := time.Now().Add(time.Duration(m.cfg.RenewDays) * 24 * time.Hour) |
|
return cert.NotAfter.Before(renewTime), nil |
|
} |
|
|
|
func (m *CertManager) obtainCertificate() error { |
|
log.I.F("obtaining certificate for %s", m.cfg.Domain) |
|
|
|
request := certificate.ObtainRequest{ |
|
Domains: []string{m.cfg.Domain, m.cfg.BaseDomain()}, |
|
Bundle: true, |
|
} |
|
|
|
certificates, err := m.client.Certificate.Obtain(request) |
|
if chk.E(err) { |
|
return fmt.Errorf("failed to obtain certificate: %w", err) |
|
} |
|
|
|
// Write certificate chain |
|
if err := os.WriteFile(m.certPath, certificates.Certificate, 0644); chk.E(err) { |
|
return fmt.Errorf("failed to write certificate: %w", err) |
|
} |
|
|
|
// Write private key with restricted permissions |
|
if err := os.WriteFile(m.keyPath, certificates.PrivateKey, 0600); chk.E(err) { |
|
return fmt.Errorf("failed to write private key: %w", err) |
|
} |
|
|
|
// Write issuer certificate if available |
|
if len(certificates.IssuerCertificate) > 0 { |
|
issuerPath := filepath.Join(filepath.Dir(m.certPath), "issuer.pem") |
|
if err := os.WriteFile(issuerPath, certificates.IssuerCertificate, 0644); chk.E(err) { |
|
log.W.F("failed to write issuer certificate: %v", err) |
|
} |
|
} |
|
|
|
// Write metadata |
|
if err := m.writeMetadata(certificates.Certificate); chk.E(err) { |
|
log.W.F("failed to write metadata: %v", err) |
|
} |
|
|
|
log.I.F("certificate obtained successfully for %s", m.cfg.Domain) |
|
log.I.F(" cert: %s", m.certPath) |
|
log.I.F(" key: %s", m.keyPath) |
|
|
|
return nil |
|
} |
|
|
|
func (m *CertManager) writeMetadata(certPEM []byte) error { |
|
block, _ := pem.Decode(certPEM) |
|
if block == nil { |
|
return fmt.Errorf("failed to decode certificate for metadata") |
|
} |
|
|
|
cert, err := x509.ParseCertificate(block.Bytes) |
|
if chk.E(err) { |
|
return err |
|
} |
|
|
|
meta := CertMetadata{ |
|
Domain: m.cfg.Domain, |
|
Domains: cert.DNSNames, |
|
NotBefore: cert.NotBefore, |
|
NotAfter: cert.NotAfter, |
|
Issuer: cert.Issuer.CommonName, |
|
RenewedAt: time.Now(), |
|
} |
|
|
|
data, err := json.MarshalIndent(meta, "", " ") |
|
if chk.E(err) { |
|
return err |
|
} |
|
|
|
return os.WriteFile(m.metaPath, data, 0644) |
|
} |
|
|
|
func loadOrCreateAccountKey(cfg *Config) (crypto.PrivateKey, error) { |
|
keyPath := cfg.AccountKeyPath |
|
if keyPath == "" { |
|
keyPath = filepath.Join(cfg.OutputDir, "account.key") |
|
} |
|
|
|
// Try to load existing key |
|
if data, err := os.ReadFile(keyPath); err == nil { |
|
block, _ := pem.Decode(data) |
|
if block != nil { |
|
key, err := x509.ParseECPrivateKey(block.Bytes) |
|
if err == nil { |
|
log.D.F("loaded existing account key from %s", keyPath) |
|
return key, nil |
|
} |
|
} |
|
} |
|
|
|
// Generate new key |
|
log.I.F("generating new account key") |
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) |
|
if chk.E(err) { |
|
return nil, err |
|
} |
|
|
|
// Save key |
|
keyBytes, err := x509.MarshalECPrivateKey(key) |
|
if chk.E(err) { |
|
return nil, err |
|
} |
|
|
|
keyPEM := pem.EncodeToMemory(&pem.Block{ |
|
Type: "EC PRIVATE KEY", |
|
Bytes: keyBytes, |
|
}) |
|
|
|
if err := os.MkdirAll(filepath.Dir(keyPath), 0755); chk.E(err) { |
|
return nil, err |
|
} |
|
|
|
if err := os.WriteFile(keyPath, keyPEM, 0600); chk.E(err) { |
|
return nil, err |
|
} |
|
|
|
log.I.F("saved new account key to %s", keyPath) |
|
return key, nil |
|
}
|
|
|