added IMAP, LetsEncrypt, update layout
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateAccountGeneratesAndPersistsKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
user1, err := loadOrCreateAccount(dir, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user1.Registration != nil {
|
||||
t.Fatal("expected no registration on a brand-new account")
|
||||
}
|
||||
if user1.GetPrivateKey() == nil {
|
||||
t.Fatal("expected a generated private key")
|
||||
}
|
||||
|
||||
// Reload: must reuse the same key, not generate a new one.
|
||||
user2, err := loadOrCreateAccount(dir, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyBytes1, _ := os.ReadFile(accountKeyPath(dir))
|
||||
if len(keyBytes1) == 0 {
|
||||
t.Fatal("expected a persisted key file")
|
||||
}
|
||||
// Re-reading shouldn't rewrite the file with different bytes.
|
||||
keyBytes2, _ := os.ReadFile(accountKeyPath(dir))
|
||||
if string(keyBytes1) != string(keyBytes2) {
|
||||
t.Fatal("expected the same key to be reused across loads")
|
||||
}
|
||||
_ = user2
|
||||
}
|
||||
|
||||
func TestSaveRegistrationRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
reg := ®istration.Resource{URI: "https://example.com/acme/acct/123"}
|
||||
if err := saveRegistration(dir, reg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
user, err := loadOrCreateAccount(dir, "admin@example.com")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if user.Registration == nil || user.Registration.URI != reg.URI {
|
||||
t.Fatalf("expected registration to round-trip, got %+v", user.Registration)
|
||||
}
|
||||
}
|
||||
|
||||
// writeFixtureCert writes a minimal self-signed cert with the given expiry to certFile
|
||||
// (no matching key needed — NeedsRenewal only reads the cert).
|
||||
func writeFixtureCert(t *testing.T, certFile string, notAfter time.Time) {
|
||||
t.Helper()
|
||||
priv, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: notAfter,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
if err := os.WriteFile(certFile, pemBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRenewal(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(dir, "server.crt")}
|
||||
|
||||
writeFixtureCert(t, mgr.CertFile, time.Now().Add(200*24*time.Hour))
|
||||
if needs, err := mgr.NeedsRenewal(); err != nil || needs {
|
||||
t.Fatalf("expected NeedsRenewal=false for a cert expiring in 200 days, got %v (err=%v)", needs, err)
|
||||
}
|
||||
|
||||
writeFixtureCert(t, mgr.CertFile, time.Now().Add(5*24*time.Hour))
|
||||
if needs, err := mgr.NeedsRenewal(); err != nil || !needs {
|
||||
t.Fatalf("expected NeedsRenewal=true for a cert expiring in 5 days, got %v (err=%v)", needs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNeedsRenewalMissingCertIsTrue(t *testing.T) {
|
||||
mgr := &Manager{Cfg: ini.Empty(), CertFile: filepath.Join(t.TempDir(), "does-not-exist.crt")}
|
||||
needs, err := mgr.NeedsRenewal()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !needs {
|
||||
t.Fatal("expected NeedsRenewal=true when no certificate exists yet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSProviderUnknownName(t *testing.T) {
|
||||
cfg := ini.Empty()
|
||||
cfg.Section("LetsEncrypt").Key("dns_provider").SetValue("not-a-real-provider")
|
||||
if _, err := buildDNSProvider(cfg); err == nil {
|
||||
t.Fatal("expected an error for an unknown DNS provider name")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSProviderDigitalOceanRequiresToken(t *testing.T) {
|
||||
cfg := ini.Empty()
|
||||
cfg.Section("LetsEncrypt").Key("dns_provider").SetValue("digitalocean")
|
||||
// AuthToken deliberately left blank — DigitalOcean's constructor validates this
|
||||
// locally (no network call) and errors immediately.
|
||||
if _, err := buildDNSProvider(cfg); err == nil {
|
||||
t.Fatal("expected an error when digitalocean_api_token is blank")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildDNSProviderCloudflare(t *testing.T) {
|
||||
cfg := ini.Empty()
|
||||
sec := cfg.Section("LetsEncrypt")
|
||||
sec.Key("dns_provider").SetValue("cloudflare")
|
||||
sec.Key("cloudflare_api_token").SetValue("fake-token-for-local-construction-only")
|
||||
provider, err := buildDNSProvider(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("expected local provider construction to succeed without a network call, got: %v", err)
|
||||
}
|
||||
if provider == nil {
|
||||
t.Fatal("expected a non-nil provider")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Package acmecert obtains and renews Let's Encrypt certificates via the DNS-01
|
||||
// challenge, as an admin-configurable alternative to the self-signed certificate
|
||||
// tlsutil generates by default. Obtained certificates are written to the same
|
||||
// cert/key file paths the self-signed generator already uses, so the SMTP/IMAP TLS
|
||||
// listeners (via tlsutil.CertReloader) never need to know which produced the active
|
||||
// certificate.
|
||||
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.
|
||||
const renewalThreshold = 30 * 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
|
||||
Domains []string
|
||||
Provider string
|
||||
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.
|
||||
type Manager struct {
|
||||
Cfg *ini.File
|
||||
CertFile, KeyFile string
|
||||
DataDir string
|
||||
Reloader *tlsutil.CertReloader
|
||||
Logger *toolbox.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
lastAttempt time.Time
|
||||
lastError string
|
||||
}
|
||||
|
||||
func New(cfg *ini.File, certFile, keyFile, dataDir string, reloader *tlsutil.CertReloader, logger *toolbox.Logger) *Manager {
|
||||
return &Manager{Cfg: cfg, CertFile: certFile, KeyFile: keyFile, DataDir: dataDir, Reloader: reloader, Logger: logger}
|
||||
}
|
||||
|
||||
func (m *Manager) section() *ini.Section { return m.Cfg.Section("LetsEncrypt") }
|
||||
|
||||
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 {
|
||||
if p = 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),
|
||||
Domains: m.domains(),
|
||||
Provider: m.section().Key("dns_provider").String(),
|
||||
LastAttempt: m.lastAttempt,
|
||||
LastError: m.lastError,
|
||||
}
|
||||
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 30 days
|
||||
// of expiry (or unreadable/unparseable, which is treated as "yes" — nothing usable is
|
||||
// there to keep). This is a pure expiry check; it makes no attempt to distinguish a
|
||||
// self-signed cert from an ACME-obtained one (see the caller in main.go's renewal
|
||||
// ticker for how the very-first-check case is handled instead).
|
||||
func (m *Manager) NeedsRenewal() (bool, error) {
|
||||
cert, err := readLeafCertificate(m.CertFile)
|
||||
if err != nil {
|
||||
return true, nil
|
||||
}
|
||||
return time.Until(cert.NotAfter) < renewalThreshold, 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)
|
||||
}
|
||||
|
||||
// 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 Let's Encrypt 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.section().Key("enabled").MustBool(false) {
|
||||
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 {
|
||||
domains := m.domains()
|
||||
if len(domains) == 0 {
|
||||
return fmt.Errorf("acmecert: no domains configured")
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
cert, err := client.Certificate.Obtain(certificate.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
})
|
||||
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(domains, ", "))
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge"
|
||||
"github.com/go-acme/lego/v4/providers/dns/cloudflare"
|
||||
"github.com/go-acme/lego/v4/providers/dns/digitalocean"
|
||||
"github.com/go-acme/lego/v4/providers/dns/gcloud"
|
||||
"github.com/go-acme/lego/v4/providers/dns/route53"
|
||||
"gopkg.in/ini.v1"
|
||||
)
|
||||
|
||||
// buildDNSProvider constructs the lego DNS-01 provider selected by [LetsEncrypt]
|
||||
// dns_provider. A plain switch is the right amount of structure for a fixed set of
|
||||
// providers — no pluggable registry needed.
|
||||
func buildDNSProvider(cfg *ini.File) (challenge.Provider, error) {
|
||||
sec := cfg.Section("LetsEncrypt")
|
||||
switch name := sec.Key("dns_provider").String(); name {
|
||||
case "cloudflare":
|
||||
c := cloudflare.NewDefaultConfig()
|
||||
c.AuthToken = sec.Key("cloudflare_api_token").String()
|
||||
return cloudflare.NewDNSProviderConfig(c)
|
||||
case "route53":
|
||||
c := route53.NewDefaultConfig()
|
||||
c.AccessKeyID = sec.Key("route53_access_key_id").String()
|
||||
c.SecretAccessKey = sec.Key("route53_secret_access_key").String()
|
||||
c.Region = sec.Key("route53_region").String()
|
||||
c.HostedZoneID = sec.Key("route53_hosted_zone_id").String()
|
||||
return route53.NewDNSProviderConfig(c)
|
||||
case "digitalocean":
|
||||
c := digitalocean.NewDefaultConfig()
|
||||
c.AuthToken = sec.Key("digitalocean_api_token").String()
|
||||
return digitalocean.NewDNSProviderConfig(c)
|
||||
case "gcloud":
|
||||
project := sec.Key("gcloud_project").String()
|
||||
if saPath := sec.Key("gcloud_service_account_json_path").String(); saPath != "" {
|
||||
keyBytes, err := os.ReadFile(saPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read gcloud service account file: %w", err)
|
||||
}
|
||||
return gcloud.NewDNSProviderServiceAccountKey(keyBytes)
|
||||
}
|
||||
return gcloud.NewDNSProviderCredentials(project)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown or unset Let's Encrypt DNS provider %q", name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package acmecert
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
)
|
||||
|
||||
// acmeUser implements registration.User. Its private key and (once registered)
|
||||
// registration resource are persisted to disk under a data directory, mirroring the
|
||||
// generate-if-missing file pattern already used by mailstore.LoadOrCreateMasterKey and
|
||||
// tlsutil.GenerateSelfSignedCert — so the ACME account survives restarts and is never
|
||||
// re-registered unnecessarily.
|
||||
type acmeUser struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *acmeUser) GetEmail() string { return u.Email }
|
||||
func (u *acmeUser) GetRegistration() *registration.Resource { return u.Registration }
|
||||
func (u *acmeUser) GetPrivateKey() crypto.PrivateKey { return u.key }
|
||||
|
||||
func accountKeyPath(dataDir string) string { return filepath.Join(dataDir, "account.key") }
|
||||
func accountRegPath(dataDir string) string { return filepath.Join(dataDir, "account.json") }
|
||||
|
||||
// loadOrCreateAccount loads the persisted ACME account key/registration from dataDir,
|
||||
// generating a fresh key if none exists yet. A nil Registration means no account has
|
||||
// been registered with the CA yet — the caller is responsible for registering and then
|
||||
// calling saveRegistration.
|
||||
func loadOrCreateAccount(dataDir, email string) (*acmeUser, error) {
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := loadOrCreateAccountKey(dataDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
user := &acmeUser{Email: email, key: key}
|
||||
if regBytes, err := os.ReadFile(accountRegPath(dataDir)); err == nil {
|
||||
var reg registration.Resource
|
||||
if err := json.Unmarshal(regBytes, ®); err == nil {
|
||||
user.Registration = ®
|
||||
}
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func loadOrCreateAccountKey(dataDir string) (crypto.PrivateKey, error) {
|
||||
path := accountKeyPath(dataDir)
|
||||
if pemBytes, err := os.ReadFile(path); err == nil {
|
||||
return certcrypto.ParsePEMPrivateKey(pemBytes)
|
||||
}
|
||||
|
||||
key, err := certcrypto.GeneratePrivateKey(certcrypto.EC256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.WriteFile(path, certcrypto.PEMEncode(key), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// saveRegistration persists the account's registration resource so future runs don't
|
||||
// re-register with the CA.
|
||||
func saveRegistration(dataDir string, reg *registration.Resource) error {
|
||||
data, err := json.Marshal(reg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(accountRegPath(dataDir), data, 0o600)
|
||||
}
|
||||
Reference in New Issue
Block a user