// Package acmecert obtains and renews Let's Encrypt certificates via DNS-01 or HTTP-01, // as an admin-configurable alternative to the self-signed certificate tlsutil generates // by default. main.go runs up to two independent Manager instances at once (one per // challenge type, reading from separate ini sections and writing to separate cert/key // files) so a DNS-01 cert and an HTTP-01 cert can be obtained simultaneously and // assigned to different listeners — see [TLS]'s *_cert settings. package acmecert import ( "context" "crypto/x509" "encoding/pem" "fmt" "os" "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/registration" "gopkg.in/ini.v1" "mailgoserver/internal/tlsutil" "mailgoserver/internal/toolbox" ) // renewalThreshold mirrors the standard ACME-client convention (certbot/lego CLI): // renew once a certificate is within 30 days of its (90-day, for Let's Encrypt) expiry. // Used for every case except shortLivedRenewalThreshold below. const renewalThreshold = 30 * 24 * time.Hour // shortLivedRenewalThreshold applies only to HTTP-01 with include_ip set, which forces // Let's Encrypt's "shortlived" profile (~6 day validity — see obtain()'s Profile // handling). Using the normal 30-day threshold there would mean the cert looks "due for // renewal" on literally every single renewal check from the moment it's issued, // hammering the ACME API every 12h instead of renewing roughly once every ~5 days as // intended — a 1-day buffer before expiry keeps a comfortable margin without that. const shortLivedRenewalThreshold = 24 * time.Hour // Status is a read-only snapshot of the current Let's Encrypt configuration and the // last renewal attempt, for the admin settings page. type Status struct { Enabled bool Staging bool ChallengeType string // "dns-01" or "http-01" Domains []string Provider string IncludeIP bool NotAfter time.Time // parsed live from CertFile each call — never cached LastAttempt time.Time // zero value = no attempt yet this process run LastError string // empty if the last attempt succeeded, or none has run yet } // Manager obtains and renews certificates for one configured domain set, using one // fixed challenge type read from one fixed ini section (both set once at construction, // via New — never toggled at runtime, since main.go runs one Manager per challenge // type). ChallengeType is "dns-01" or "http-01". type Manager struct { Cfg *ini.File Section string ChallengeType string CertFile, KeyFile string DataDir string Reloader *tlsutil.CertReloader Logger *toolbox.Logger // HTTP01Server is the long-lived challenge responder (see http01server.go) this // Manager hands token/keyAuth pairs to during an obtain. Only set (by main.go) on // the HTTP-01 Manager instance; nil on the DNS-01 one, which never uses it. HTTP01Server *HTTP01Server mu sync.Mutex lastAttempt time.Time lastError string } func New(cfg *ini.File, section, challengeType, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager { return &Manager{ Cfg: cfg, Section: section, ChallengeType: challengeType, CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger, } } func (m *Manager) section() *ini.Section { return m.Cfg.Section(m.Section) } func (m *Manager) domains() []string { raw := m.section().Key("domains").String() if raw == "" { return nil } parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { // Lowercased: DNS names are case-insensitive, and Let's Encrypt's order // response always comes back lowercased regardless of what was submitted — // lego's RFC 8555 §7.4 compliance check then compares the two verbatim, so a // mixed-case domain (e.g. an ISP-assigned "adsl-1-2-3-4.example.ISP.COM" // reverse-DNS hostname) fails with a spurious "order identifiers have been // modified" error unless normalized before submission. Confirmed live: a user // hit exactly this with an uppercase-suffixed rDNS hostname. if p = strings.ToLower(strings.TrimSpace(p)); p != "" { out = append(out, p) } } return out } // Status returns a snapshot of the current configuration plus the last obtain/renew // attempt's outcome. In-memory only (no DB/file persistence) — this is informational // status for the settings page, not an audit log; it resets on restart, which is an // accepted simplification. func (m *Manager) Status() Status { m.mu.Lock() s := Status{ Enabled: m.section().Key("enabled").MustBool(false), Staging: m.section().Key("staging").MustBool(false), ChallengeType: m.ChallengeType, Domains: m.domains(), LastAttempt: m.lastAttempt, LastError: m.lastError, } if m.ChallengeType == "http-01" { httpPort := m.Cfg.Section("Server").Key("HTTP_LETSENCRYPT_PORT").MustString("80") s.Provider = "HTTP-01 (port " + httpPort + ")" s.IncludeIP = m.section().Key("include_ip").MustBool(false) } else { s.Provider = m.section().Key("dns_provider").String() } m.mu.Unlock() if cert, err := readLeafCertificate(m.CertFile); err == nil { s.NotAfter = cert.NotAfter } return s } // NeedsRenewal reports whether the certificate currently at CertFile is within // renewalThreshold (30 days) — or shortLivedRenewalThreshold (1 day) for HTTP-01 with // include_ip, since that cert is only valid ~6 days to begin with — of expiry, // unreadable/unparseable (nothing usable there to keep), or is still the self-signed // placeholder tlsutil generates by default (recognized by its Issuer CN — see // tlsutil.GenerateSelfSignedCert — since a freshly-generated one has ~1 year left and // would otherwise never look like it "needs" replacing by the first real certificate). // This is a pure disk-state check with no dependency on in-memory process state, so it // gives the same correct answer whether this is the first check after boot or the // hundredth — restarting the process must never by itself trigger a redundant // re-obtain of an already-valid, already-real certificate. func (m *Manager) NeedsRenewal() (bool, error) { cert, err := readLeafCertificate(m.CertFile) if err != nil { return true, nil } if cert.Issuer.CommonName == "localhost" { return true, nil } threshold := renewalThreshold if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) { threshold = shortLivedRenewalThreshold } return time.Until(cert.NotAfter) < threshold, nil } func readLeafCertificate(certFile string) (*x509.Certificate, error) { raw, err := os.ReadFile(certFile) if err != nil { return nil, err } block, _ := pem.Decode(raw) if block == nil { return nil, fmt.Errorf("acmecert: no PEM block found in %s", certFile) } return x509.ParseCertificate(block.Bytes) } // Enabled reports whether this manager's ini section has 'enabled = true'. func (m *Manager) Enabled() bool { return m.section().Key("enabled").MustBool(false) } // ObtainOrRenew requests a certificate for the configured domains and, on success, // writes it to CertFile/KeyFile and hot-reloads the live TLS listeners. Used for both // first issuance and renewal — lego's Obtain covers both identically, so there is no // separate renewal code path. A no-op (nil error) if this manager isn't enabled. On // any failure, the cert/key files on disk are left untouched — whatever was already // serving (self-signed or a previous ACME cert) keeps working. func (m *Manager) ObtainOrRenew(ctx context.Context) error { if !m.Enabled() { return nil } err := m.obtain(ctx) m.mu.Lock() m.lastAttempt = time.Now() if err != nil { m.lastError = err.Error() } else { m.lastError = "" } m.mu.Unlock() return err } func (m *Manager) obtain(ctx context.Context) error { identifiers, err := m.resolveIdentifiers(ctx) if err != nil { return err } m.Logger.Info("%s: starting obtain/renew for %s", m.ChallengeType, strings.Join(identifiers, ", ")) email := m.section().Key("contact_email").String() user, err := loadOrCreateAccount(m.DataDir, email) if err != nil { return fmt.Errorf("load ACME account: %w", err) } config := lego.NewConfig(user) config.Certificate.KeyType = certcrypto.EC256 if m.section().Key("staging").MustBool(false) { config.CADirURL = lego.LEDirectoryStaging } client, err := lego.NewClient(config) if err != nil { return fmt.Errorf("create ACME client: %w", err) } if m.ChallengeType == "http-01" { if m.HTTP01Server == nil { return fmt.Errorf("HTTP-01 challenge server is not running (enable [LetsEncryptHTTP] and restart)") } if err := client.Challenge.SetHTTP01Provider(m.HTTP01Server); err != nil { return fmt.Errorf("set HTTP-01 provider: %w", err) } } else { provider, err := buildDNSProvider(m.Cfg) if err != nil { return fmt.Errorf("configure DNS provider: %w", err) } if err := client.Challenge.SetDNS01Provider(provider); err != nil { return fmt.Errorf("set DNS-01 provider: %w", err) } } if user.Registration == nil { reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { return fmt.Errorf("register ACME account: %w", err) } user.Registration = reg if err := saveRegistration(m.DataDir, reg); err != nil { m.Logger.Error("save ACME account registration: %v", err) } } req := certificate.ObtainRequest{Domains: identifiers, Bundle: true} if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) { // Let's Encrypt's default profile rejects IP identifiers outright ("Default // profile does not permit IP address identifiers") — only the "shortlived" // profile currently supports them (mixed with DNS names too), at the cost of a // much shorter (~6 day) validity. NeedsRenewal's 30-day threshold already // treats that as "always needs renewal," which is exactly right here — it'll // just get renewed on essentially every 12h tick instead of sitting idle for // weeks, which is the correct behavior for a cert this short-lived. req.Profile = "shortlived" } cert, err := client.Certificate.Obtain(req) if err != nil { return fmt.Errorf("obtain certificate: %w", err) } if err := os.WriteFile(m.CertFile, cert.Certificate, 0o644); err != nil { return fmt.Errorf("write certificate: %w", err) } if err := os.WriteFile(m.KeyFile, cert.PrivateKey, 0o600); err != nil { return fmt.Errorf("write private key: %w", err) } if err := m.Reloader.Reload(); err != nil { return fmt.Errorf("reload TLS certificate: %w", err) } m.Logger.Info("Let's Encrypt certificate obtained for %s", strings.Join(identifiers, ", ")) return nil } // resolveIdentifiers builds the domain/IP list to request a certificate for: the // configured domains, plus (for http-01 with include_ip set) one IP address — lego's // ACME client auto-detects an IP-shaped string in this list and requests it as an // RFC 8738 IP identifier rather than a DNS identifier. The IP is either the manual // override or, if that's blank, autodetected via DetectWANIP. See obtain()'s Profile // handling: an IP identifier needs Let's Encrypt's "shortlived" profile, which does // support mixing DNS names and an IP in one order. func (m *Manager) resolveIdentifiers(ctx context.Context) ([]string, error) { identifiers := m.domains() if m.ChallengeType == "http-01" && m.section().Key("include_ip").MustBool(false) { ip := m.section().Key("ip_override").String() if ip == "" { detected, err := DetectWANIP(ctx) if err != nil { return nil, fmt.Errorf("autodetect WAN IP: %w", err) } ip = detected } identifiers = append(identifiers, ip) } if len(identifiers) == 0 { return nil, fmt.Errorf("acmecert: no domains configured") } return identifiers, nil }