first commit

This commit is contained in:
2026-08-09 18:03:09 +01:00
commit d7ca591b76
169 changed files with 51272 additions and 0 deletions
+227
View File
@@ -0,0 +1,227 @@
package tlsutil
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"log/slog"
"sync"
"time"
"gomail/internal/acme"
"gomail/internal/crypto"
"gomail/internal/db"
)
// renewalMargin is how far before expiry a certificate is renewed.
const renewalMargin = 30 * 24 * time.Hour
// ACMEManager obtains and caches ACME certificates per domain, encrypted at
// rest (same HKDF-per-record scheme as everything else), and serves them
// via a SNI-aware tls.Config.GetCertificate callback so a single listener
// can present the right certificate for whichever domain a client connects
// to. A background loop renews any certificate within renewalMargin of
// expiry.
type ACMEManager struct {
database *db.DB
mk *crypto.MasterKey
directoryURL string
contactEmail string
responder *acme.ChallengeResponder
mu sync.RWMutex
cache map[string]*tls.Certificate
}
func NewACMEManager(database *db.DB, mk *crypto.MasterKey, directoryURL, contactEmail string, responder *acme.ChallengeResponder) *ACMEManager {
return &ACMEManager{
database: database, mk: mk, directoryURL: directoryURL, contactEmail: contactEmail,
responder: responder, cache: make(map[string]*tls.Certificate),
}
}
// TLSConfig returns a tls.Config whose GetCertificate looks up the right
// cert per SNI, obtaining one on first use if none is cached yet.
func (m *ACMEManager) TLSConfig() *tls.Config {
return &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
return m.CertificateFor(hello.ServerName)
},
}
}
// CertificateFor returns a cached certificate for domain, obtaining one via
// ACME (and caching it, in memory and encrypted in the DB) if not already
// cached or if the cached one is expired/near expiry.
func (m *ACMEManager) CertificateFor(domain string) (*tls.Certificate, error) {
m.mu.RLock()
cached, ok := m.cache[domain]
m.mu.RUnlock()
if ok {
return cached, nil
}
if stored, err := m.loadFromDB(domain); err == nil {
m.mu.Lock()
m.cache[domain] = stored
m.mu.Unlock()
return stored, nil
}
cert, err := m.obtainAndStore(domain)
if err != nil {
return nil, err
}
return cert, nil
}
func (m *ACMEManager) loadFromDB(domain string) (*tls.Certificate, error) {
row, err := m.database.GetTLSCert(domain)
if err != nil {
return nil, err
}
if row.CertPEMEnc == nil || row.KeyPEMEnc == nil {
return nil, fmt.Errorf("no cert material stored for %s", domain)
}
if row.ExpiresAt != nil && time.Now().UTC().After(row.ExpiresAt.Add(-renewalMargin)) {
return nil, fmt.Errorf("stored cert for %s is expired or near expiry", domain)
}
certPEM, err := crypto.Decrypt(m.mk, row.ID, "tls-cert", row.CertPEMEnc)
if err != nil {
return nil, fmt.Errorf("decrypting cert: %w", err)
}
keyPEM, err := crypto.Decrypt(m.mk, row.ID, "tls-key", row.KeyPEMEnc)
if err != nil {
return nil, fmt.Errorf("decrypting key: %w", err)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("parsing stored cert/key: %w", err)
}
return &cert, nil
}
func (m *ACMEManager) obtainAndStore(domain string) (*tls.Certificate, error) {
accountKey, err := m.loadOrCreateAccountKey(domain)
if err != nil {
return nil, fmt.Errorf("account key: %w", err)
}
slog.Info("obtaining ACME certificate", "domain", domain, "directory", m.directoryURL)
certPEM, keyPEM, err := acme.Obtain(m.directoryURL, m.contactEmail, []string{domain}, accountKey, m.responder)
if err != nil {
return nil, fmt.Errorf("ACME obtain for %s: %w", domain, err)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return nil, fmt.Errorf("parsing obtained cert/key: %w", err)
}
var expiresAt *time.Time
if len(cert.Certificate) > 0 {
if leaf, err := x509.ParseCertificate(cert.Certificate[0]); err == nil {
expiresAt = &leaf.NotAfter
}
}
existing, _ := m.database.GetTLSCert(domain)
recordID := domain
if existing != nil {
recordID = existing.ID
}
encCert, err := crypto.Encrypt(m.mk, recordID, "tls-cert", certPEM)
if err != nil {
return nil, fmt.Errorf("encrypting cert: %w", err)
}
encKey, err := crypto.Encrypt(m.mk, recordID, "tls-key", keyPEM)
if err != nil {
return nil, fmt.Errorf("encrypting key: %w", err)
}
if err := m.database.UpsertTLSCert(&db.TLSCert{
ID: recordID, Domain: domain, CertPEMEnc: encCert, KeyPEMEnc: encKey, ExpiresAt: expiresAt,
}); err != nil {
return nil, fmt.Errorf("storing cert: %w", err)
}
m.mu.Lock()
m.cache[domain] = &cert
m.mu.Unlock()
slog.Info("ACME certificate obtained and stored", "domain", domain, "expires_at", expiresAt)
return &cert, nil
}
func (m *ACMEManager) loadOrCreateAccountKey(domain string) (*acme.AccountKey, error) {
row, err := m.database.GetTLSCert(domain)
if err == nil && row.ACMEAccountKeyEnc != nil {
plain, decErr := crypto.Decrypt(m.mk, row.ID, "acme-account-key", row.ACMEAccountKeyEnc)
if decErr == nil {
if key, parseErr := acme.ParseAccountKeyPEM(plain); parseErr == nil {
return key, nil
}
}
}
key, err := acme.GenerateAccountKey()
if err != nil {
return nil, err
}
keyPEM, err := key.MarshalPEM()
if err != nil {
return nil, err
}
recordID := domain
if row != nil {
recordID = row.ID
}
encKey, err := crypto.Encrypt(m.mk, recordID, "acme-account-key", keyPEM)
if err != nil {
return nil, err
}
if err := m.database.SetACMEAccountKey(domain, encKey); err != nil {
return nil, err
}
return key, nil
}
// StartRenewalLoop runs a background check (default: daily) and renews any
// domain whose cached/stored certificate is within renewalMargin of expiry.
// domains is the full set this instance is responsible for — typically all
// active hosted domains plus the server's own hostname.
func (m *ACMEManager) StartRenewalLoop(ctx context.Context, domains []string, checkInterval time.Duration) {
ticker := time.NewTicker(checkInterval)
defer ticker.Stop()
checkAndRenew := func() {
for _, domain := range domains {
row, err := m.database.GetTLSCert(domain)
needsRenewal := err != nil || row.ExpiresAt == nil || time.Now().UTC().After(row.ExpiresAt.Add(-renewalMargin))
if !needsRenewal {
continue
}
slog.Info("renewing ACME certificate", "domain", domain)
m.mu.Lock()
delete(m.cache, domain) // force re-obtain, not a stale in-memory hit
m.mu.Unlock()
if _, err := m.obtainAndStore(domain); err != nil {
slog.Error("ACME renewal failed", "domain", domain, "err", err)
}
}
}
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
checkAndRenew()
}
}
}
+114
View File
@@ -0,0 +1,114 @@
// Package tlsutil provides certificate loading: LoadOrGenerate for the
// file/self-signed paths (this file), and ACMEManager (acme_manager.go) for
// real Let's Encrypt-style issuance via internal/acme. The self-signed
// generator here remains the fallback for tls.mode "off" or "file" without
// a cert on disk yet — genuinely necessary for local dev/testing, not a
// placeholder for a missing feature.
package tlsutil
import (
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"fmt"
"log/slog"
"math/big"
"net"
"time"
)
// LoadOrGenerate returns a tls.Config for the given mode:
// - "file": load cert/key from disk paths
// - anything else ("acme" not yet implemented, "off"): generate a self-signed
// cert so STARTTLS/IMAPS/etc. still work during development. Logs a loud
// warning since this is never appropriate for production.
func LoadOrGenerate(mode, hostname, certFile, keyFile string, minVersion uint16) (*tls.Config, error) {
var cert tls.Certificate
var err error
switch mode {
case "file":
cert, err = tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return nil, fmt.Errorf("loading TLS cert/key: %w", err)
}
case "acme":
// Reaching here (rather than the real ACMEManager path in main.go)
// means mode=="acme" but no acme_domains were configured — a real
// ACME client exists (internal/acme, wired in main.go), it's just
// not usable without knowing which domain(s) to request a cert for.
slog.Warn("TLS mode is 'acme' but no acme_domains are configured — "+
"generating a SELF-SIGNED certificate instead. Set tls.acme_domains "+
"in config.yaml to enable real Let's Encrypt issuance.",
"hostname", hostname)
cert, err = generateSelfSigned(hostname)
if err != nil {
return nil, fmt.Errorf("generating self-signed cert: %w", err)
}
default:
slog.Warn("TLS mode is 'off' — generating a SELF-SIGNED certificate. "+
"This is fine for local testing but MUST NOT be used in production; "+
"set tls.mode to 'acme' (with acme_domains configured) or 'file'.",
"mode", mode, "hostname", hostname)
cert, err = generateSelfSigned(hostname)
if err != nil {
return nil, fmt.Errorf("generating self-signed cert: %w", err)
}
}
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: minVersion,
ServerName: hostname,
}, nil
}
// ParseMinVersion converts the config string ("TLS12"/"TLS13") to the
// crypto/tls constant.
func ParseMinVersion(s string) uint16 {
if s == "TLS13" {
return tls.VersionTLS13
}
return tls.VersionTLS12
}
func generateSelfSigned(hostname string) (tls.Certificate, error) {
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return tls.Certificate{}, err
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return tls.Certificate{}, err
}
template := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: hostname, Organization: []string{"GoMail (self-signed, dev only)"}},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
IsCA: true,
BasicConstraintsValid: true,
}
if ip := net.ParseIP(hostname); ip != nil {
template.IPAddresses = []net.IP{ip}
} else {
template.DNSNames = []string{hostname}
}
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
return tls.Certificate{
Certificate: [][]byte{derBytes},
PrivateKey: priv,
}, nil
}