314 lines
12 KiB
Go
314 lines
12 KiB
Go
// 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
|
|
}
|