mirror of
https://github.com/mjl-/mox.git
synced 2024-12-27 08:53:48 +03:00
ad51ffc365
ideally both account & admin web pages should be on non-public ips (e.g. a wireguard tunnel). but during setup, users may not have that set up, and they may want to configure the admin/account pages on their public ip's. the auth rate limiting should make it less of issue. users can now also only put the account web page publicly available. useful for if you're the admin and you have a vpn connection, but your other/external users do not have a vpn into your mail server. to make the account page more easily findable, the http root serves the account page. the admin page is still at /admin/, to prevent clash with potential account pages, but if no account page is present, you are helpfully redirected from / to /admin/. this also adds a prometheus metric counting how often auth attempts have been rate limited.
927 lines
28 KiB
Go
927 lines
28 KiB
Go
package mox
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/ed25519"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
|
|
"github.com/mjl-/sconf"
|
|
|
|
"github.com/mjl-/mox/autotls"
|
|
"github.com/mjl-/mox/config"
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/moxio"
|
|
"github.com/mjl-/mox/mtasts"
|
|
"github.com/mjl-/mox/smtp"
|
|
)
|
|
|
|
var xlog = mlog.New("mox")
|
|
|
|
// Config paths are set early in program startup. They will point to files in
|
|
// the same directory.
|
|
var (
|
|
ConfigStaticPath string
|
|
ConfigDynamicPath string
|
|
Conf = Config{Log: map[string]mlog.Level{"": mlog.LevelError}}
|
|
)
|
|
|
|
// Config as used in the code, a processed version of what is in the config file.
|
|
//
|
|
// Use methods to lookup a domain/account/address in the dynamic configuration.
|
|
type Config struct {
|
|
Static config.Static // Does not change during the lifetime of a running instance.
|
|
|
|
logMutex sync.Mutex // For accessing the log levels.
|
|
Log map[string]mlog.Level
|
|
|
|
dynamicMutex sync.Mutex
|
|
Dynamic config.Dynamic // Can only be accessed directly by tests. Use methods on Config for locked access.
|
|
dynamicMtime time.Time
|
|
DynamicLastCheck time.Time // For use by quickstart only to skip checks.
|
|
// From correctly-cased full address (localpart@domain) to account and
|
|
// address. Domains are IDNA names in utf8.
|
|
accountDestinations map[string]AccountDestination
|
|
}
|
|
|
|
type AccountDestination struct {
|
|
Localpart smtp.Localpart
|
|
Account string
|
|
Destination config.Destination
|
|
}
|
|
|
|
// LogLevelSet sets a new log level for pkg. An empty pkg sets the default log
|
|
// value that is used if no explicit log level is configured for a package.
|
|
// This change is ephemeral, no config file is changed.
|
|
func (c *Config) LogLevelSet(pkg string, level mlog.Level) {
|
|
c.logMutex.Lock()
|
|
defer c.logMutex.Unlock()
|
|
l := c.copyLogLevels()
|
|
l[pkg] = level
|
|
c.Log = l
|
|
xlog.Print("log level changed", mlog.Field("pkg", pkg), mlog.Field("level", mlog.LevelStrings[level]))
|
|
mlog.SetConfig(c.Log)
|
|
}
|
|
|
|
// LogLevelRemove removes a configured log level for a package.
|
|
func (c *Config) LogLevelRemove(pkg string) {
|
|
c.logMutex.Lock()
|
|
defer c.logMutex.Unlock()
|
|
l := c.copyLogLevels()
|
|
delete(l, pkg)
|
|
c.Log = l
|
|
xlog.Print("log level cleared", mlog.Field("pkg", pkg))
|
|
mlog.SetConfig(c.Log)
|
|
}
|
|
|
|
// copyLogLevels returns a copy of c.Log, for modifications.
|
|
// must be called with log lock held.
|
|
func (c *Config) copyLogLevels() map[string]mlog.Level {
|
|
m := map[string]mlog.Level{}
|
|
for pkg, level := range c.Log {
|
|
m[pkg] = level
|
|
}
|
|
return m
|
|
}
|
|
|
|
// LogLevels returns a copy of the current log levels.
|
|
func (c *Config) LogLevels() map[string]mlog.Level {
|
|
c.logMutex.Lock()
|
|
defer c.logMutex.Unlock()
|
|
return c.copyLogLevels()
|
|
}
|
|
|
|
func (c *Config) withDynamicLock(fn func()) {
|
|
c.dynamicMutex.Lock()
|
|
defer c.dynamicMutex.Unlock()
|
|
now := time.Now()
|
|
if now.Sub(c.DynamicLastCheck) > time.Second {
|
|
c.DynamicLastCheck = now
|
|
if fi, err := os.Stat(ConfigDynamicPath); err != nil {
|
|
xlog.Errorx("stat domains config", err)
|
|
} else if !fi.ModTime().Equal(c.dynamicMtime) {
|
|
if errs := c.loadDynamic(); len(errs) > 0 {
|
|
xlog.Errorx("loading domains config", errs[0], mlog.Field("errors", errs))
|
|
} else {
|
|
xlog.Info("domains config reloaded")
|
|
c.dynamicMtime = fi.ModTime()
|
|
}
|
|
}
|
|
}
|
|
fn()
|
|
}
|
|
|
|
// must be called with dynamic lock held.
|
|
func (c *Config) loadDynamic() []error {
|
|
d, mtime, accDests, err := ParseDynamicConfig(context.Background(), ConfigDynamicPath, c.Static)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Dynamic = d
|
|
c.dynamicMtime = mtime
|
|
c.accountDestinations = accDests
|
|
return nil
|
|
}
|
|
|
|
func (c *Config) Domains() (l []string) {
|
|
c.withDynamicLock(func() {
|
|
for name := range c.Dynamic.Domains {
|
|
l = append(l, name)
|
|
}
|
|
})
|
|
sort.Slice(l, func(i, j int) bool {
|
|
return l[i] < l[j]
|
|
})
|
|
return l
|
|
}
|
|
|
|
func (c *Config) Accounts() (l []string) {
|
|
c.withDynamicLock(func() {
|
|
for name := range c.Dynamic.Accounts {
|
|
l = append(l, name)
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) DomainLocalparts(d dns.Domain) map[smtp.Localpart]string {
|
|
suffix := "@" + d.Name()
|
|
m := map[smtp.Localpart]string{}
|
|
c.withDynamicLock(func() {
|
|
for addr, ad := range c.accountDestinations {
|
|
if strings.HasSuffix(addr, suffix) {
|
|
m[ad.Localpart] = ad.Account
|
|
}
|
|
}
|
|
})
|
|
return m
|
|
}
|
|
|
|
func (c *Config) Domain(d dns.Domain) (dom config.Domain, ok bool) {
|
|
c.withDynamicLock(func() {
|
|
dom, ok = c.Dynamic.Domains[d.Name()]
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) Account(name string) (acc config.Account, ok bool) {
|
|
c.withDynamicLock(func() {
|
|
acc, ok = c.Dynamic.Accounts[name]
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) AccountDestination(addr string) (accDests AccountDestination, ok bool) {
|
|
c.withDynamicLock(func() {
|
|
accDests, ok = c.accountDestinations[addr]
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) allowACMEHosts() {
|
|
// todo future: reset the allowed hosts for autoconfig & mtasts when loading new list.
|
|
for _, l := range c.Static.Listeners {
|
|
if l.TLS == nil || l.TLS.ACME == "" {
|
|
continue
|
|
}
|
|
m := c.Static.ACME[l.TLS.ACME].Manager
|
|
for _, dom := range c.Dynamic.Domains {
|
|
|
|
if l.AutoconfigHTTPS.Enabled {
|
|
if d, err := dns.ParseDomain("autoconfig." + dom.Domain.ASCII); err != nil {
|
|
xlog.Errorx("parsing autoconfig domain", err, mlog.Field("domain", dom.Domain))
|
|
} else {
|
|
m.AllowHostname(d)
|
|
}
|
|
|
|
if d, err := dns.ParseDomain("autodiscover." + dom.Domain.ASCII); err != nil {
|
|
xlog.Errorx("parsing autodiscover domain", err, mlog.Field("domain", dom.Domain))
|
|
} else {
|
|
m.AllowHostname(d)
|
|
}
|
|
}
|
|
|
|
if l.MTASTSHTTPS.Enabled && dom.MTASTS != nil {
|
|
d, err := dns.ParseDomain("mta-sts." + dom.Domain.ASCII)
|
|
if err != nil {
|
|
xlog.Errorx("parsing mta-sts domain", err, mlog.Field("domain", dom.Domain))
|
|
continue
|
|
}
|
|
m.AllowHostname(d)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// todo future: write config parsing & writing code that can read a config and remembers the exact tokens including newlines and comments, and can write back a modified file. the goal is to be able to write a config file automatically (after changing fields through the ui), but not loose comments and whitespace, to still get useful diffs for storing the config in a version control system.
|
|
|
|
// must be called with lock held.
|
|
func writeDynamic(ctx context.Context, c config.Dynamic) error {
|
|
accDests, errs := prepareDynamicConfig(ctx, ConfigDynamicPath, Conf.Static, &c)
|
|
if len(errs) > 0 {
|
|
return errs[0]
|
|
}
|
|
|
|
var b bytes.Buffer
|
|
err := sconf.Write(&b, c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
f, err := os.OpenFile(ConfigDynamicPath, os.O_WRONLY, 0660)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() {
|
|
if f != nil {
|
|
f.Close()
|
|
}
|
|
}()
|
|
buf := b.Bytes()
|
|
if _, err := f.Write(buf); err != nil {
|
|
return fmt.Errorf("write domains.conf: %v", err)
|
|
}
|
|
if err := f.Truncate(int64(len(buf))); err != nil {
|
|
return fmt.Errorf("truncate domains.conf after write: %v", err)
|
|
}
|
|
if err := f.Sync(); err != nil {
|
|
return fmt.Errorf("sync domains.conf after write: %v", err)
|
|
}
|
|
if err := moxio.SyncDir(filepath.Dir(ConfigDynamicPath)); err != nil {
|
|
return fmt.Errorf("sync dir of domains.conf after write: %v", err)
|
|
}
|
|
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
return fmt.Errorf("stat after writing domains.conf: %v", err)
|
|
}
|
|
|
|
Conf.dynamicMtime = fi.ModTime()
|
|
Conf.DynamicLastCheck = time.Now()
|
|
Conf.Dynamic = c
|
|
Conf.accountDestinations = accDests
|
|
|
|
Conf.allowACMEHosts()
|
|
|
|
return nil
|
|
}
|
|
|
|
// MustLoadConfig loads the config, quitting on errors.
|
|
func MustLoadConfig() {
|
|
errs := LoadConfig(context.Background())
|
|
if len(errs) > 1 {
|
|
xlog.Error("loading config file: multiple errors")
|
|
for _, err := range errs {
|
|
xlog.Errorx("config error", err)
|
|
}
|
|
xlog.Fatal("stopping after multiple config errors")
|
|
} else if len(errs) == 1 {
|
|
xlog.Fatalx("loading config file", errs[0])
|
|
}
|
|
}
|
|
|
|
// LoadConfig attempts to parse and load a config, returning any errors
|
|
// encountered.
|
|
func LoadConfig(ctx context.Context) []error {
|
|
c, errs := ParseConfig(ctx, ConfigStaticPath, false)
|
|
if len(errs) > 0 {
|
|
return errs
|
|
}
|
|
|
|
mlog.SetConfig(c.Log)
|
|
SetConfig(c)
|
|
return nil
|
|
}
|
|
|
|
// SetConfig sets a new config. Not to be used during normal operation.
|
|
func SetConfig(c *Config) {
|
|
// Cannot just assign *c to Conf, it would copy the mutex.
|
|
Conf = Config{c.Static, sync.Mutex{}, c.Log, sync.Mutex{}, c.Dynamic, c.dynamicMtime, c.DynamicLastCheck, c.accountDestinations}
|
|
}
|
|
|
|
// ParseConfig parses the static config at path p. If checkOnly is true, no
|
|
// changes are made, such as registering ACME identities.
|
|
func ParseConfig(ctx context.Context, p string, checkOnly bool) (c *Config, errs []error) {
|
|
c = &Config{
|
|
Static: config.Static{
|
|
DataDir: ".",
|
|
},
|
|
}
|
|
|
|
f, err := os.Open(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) && os.Getenv("MOXCONF") == "" {
|
|
return nil, []error{fmt.Errorf("open config file: %v (hint: use mox -config ... or set MOXCONF=...)", err)}
|
|
}
|
|
return nil, []error{fmt.Errorf("open config file: %v", err)}
|
|
}
|
|
defer f.Close()
|
|
if err := sconf.Parse(f, &c.Static); err != nil {
|
|
return nil, []error{fmt.Errorf("parsing %s: %v", p, err)}
|
|
}
|
|
|
|
if xerrs := PrepareStaticConfig(ctx, p, c, checkOnly); len(xerrs) > 0 {
|
|
return nil, xerrs
|
|
}
|
|
|
|
pp := filepath.Join(filepath.Dir(p), "domains.conf")
|
|
c.Dynamic, c.dynamicMtime, c.accountDestinations, errs = ParseDynamicConfig(ctx, pp, c.Static)
|
|
|
|
if !checkOnly {
|
|
c.allowACMEHosts()
|
|
}
|
|
|
|
return c, errs
|
|
}
|
|
|
|
// PrepareStaticConfig parses the static config file and prepares data structures
|
|
// for starting mox. If checkOnly is set no substantial changes are made, like
|
|
// creating an ACME registration.
|
|
func PrepareStaticConfig(ctx context.Context, configFile string, config *Config, checkOnly bool) (errs []error) {
|
|
addErrorf := func(format string, args ...any) {
|
|
errs = append(errs, fmt.Errorf(format, args...))
|
|
}
|
|
|
|
c := &config.Static
|
|
|
|
// check that mailbox is in unicode NFC normalized form.
|
|
checkMailboxNormf := func(mailbox string, format string, args ...any) {
|
|
s := norm.NFC.String(mailbox)
|
|
if mailbox != s {
|
|
msg := fmt.Sprintf(format, args...)
|
|
addErrorf("%s: mailbox %q is not in NFC normalized form, should be %q", msg, mailbox, s)
|
|
}
|
|
}
|
|
|
|
// Post-process logging config.
|
|
if logLevel, ok := mlog.Levels[c.LogLevel]; ok {
|
|
config.Log = map[string]mlog.Level{"": logLevel}
|
|
} else {
|
|
addErrorf("invalid log level %q", c.LogLevel)
|
|
}
|
|
for pkg, s := range c.PackageLogLevels {
|
|
if logLevel, ok := mlog.Levels[s]; ok {
|
|
config.Log[pkg] = logLevel
|
|
} else {
|
|
addErrorf("invalid package log level %q", s)
|
|
}
|
|
}
|
|
|
|
hostname, err := dns.ParseDomain(c.Hostname)
|
|
if err != nil {
|
|
addErrorf("parsing hostname: %s", err)
|
|
} else if hostname.Name() != c.Hostname {
|
|
addErrorf("hostname must be in IDNA form %q", hostname.Name())
|
|
}
|
|
c.HostnameDomain = hostname
|
|
|
|
for name, acme := range c.ACME {
|
|
if checkOnly {
|
|
continue
|
|
}
|
|
acmeDir := dataDirPath(configFile, c.DataDir, "acme")
|
|
os.MkdirAll(acmeDir, 0770)
|
|
manager, err := autotls.Load(name, acmeDir, acme.ContactEmail, acme.DirectoryURL, Shutdown)
|
|
if err != nil {
|
|
addErrorf("loading ACME identity for %q: %s", name, err)
|
|
}
|
|
acme.Manager = manager
|
|
c.ACME[name] = acme
|
|
}
|
|
|
|
var haveUnspecifiedSMTPListener bool
|
|
for name, l := range c.Listeners {
|
|
if l.Hostname != "" {
|
|
d, err := dns.ParseDomain(l.Hostname)
|
|
if err != nil {
|
|
addErrorf("bad listener hostname %q: %s", l.Hostname, err)
|
|
}
|
|
l.HostnameDomain = d
|
|
}
|
|
if l.TLS != nil {
|
|
if l.TLS.ACME != "" && len(l.TLS.KeyCerts) != 0 {
|
|
addErrorf("listener %q: cannot have ACME and static key/certificates", name)
|
|
} else if l.TLS.ACME != "" {
|
|
acme, ok := c.ACME[l.TLS.ACME]
|
|
if !ok {
|
|
addErrorf("listener %q: unknown ACME provider %q", name, l.TLS.ACME)
|
|
}
|
|
|
|
// If only checking, we don't have an acme manager, so set an empty tls config to
|
|
// continue without errors.
|
|
var tlsconfig *tls.Config
|
|
if checkOnly {
|
|
tlsconfig = &tls.Config{}
|
|
} else {
|
|
tlsconfig = acme.Manager.TLSConfig.Clone()
|
|
l.TLS.ACMEConfig = acme.Manager.ACMETLSConfig
|
|
|
|
// SMTP STARTTLS connections are commonly made without SNI, because certificates
|
|
// often aren't validated.
|
|
hostname := c.HostnameDomain
|
|
if l.Hostname != "" {
|
|
hostname = l.HostnameDomain
|
|
}
|
|
getCert := tlsconfig.GetCertificate
|
|
tlsconfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
|
if hello.ServerName == "" {
|
|
hello.ServerName = hostname.ASCII
|
|
}
|
|
return getCert(hello)
|
|
}
|
|
}
|
|
l.TLS.Config = tlsconfig
|
|
} else if len(l.TLS.KeyCerts) != 0 {
|
|
if err := loadTLSKeyCerts(configFile, "listener "+name, l.TLS); err != nil {
|
|
addErrorf("%w", err)
|
|
}
|
|
} else {
|
|
addErrorf("listener %q: cannot have TLS config without ACME and without static keys/certificates", name)
|
|
}
|
|
|
|
// TLS 1.2 was introduced in 2008. TLS <1.2 was deprecated by ../rfc/8996:31 and ../rfc/8997:66 in 2021.
|
|
var minVersion uint16 = tls.VersionTLS12
|
|
if l.TLS.MinVersion != "" {
|
|
versions := map[string]uint16{
|
|
"TLSv1.0": tls.VersionTLS10,
|
|
"TLSv1.1": tls.VersionTLS11,
|
|
"TLSv1.2": tls.VersionTLS12,
|
|
"TLSv1.3": tls.VersionTLS13,
|
|
}
|
|
v, ok := versions[l.TLS.MinVersion]
|
|
if !ok {
|
|
addErrorf("listener %q: unknown TLS mininum version %q", name, l.TLS.MinVersion)
|
|
}
|
|
minVersion = v
|
|
}
|
|
if l.TLS.Config != nil {
|
|
l.TLS.Config.MinVersion = minVersion
|
|
}
|
|
if l.TLS.ACMEConfig != nil {
|
|
l.TLS.ACMEConfig.MinVersion = minVersion
|
|
}
|
|
} else if l.IMAPS.Enabled || l.SMTP.Enabled && !l.SMTP.NoSTARTTLS || l.Submissions.Enabled || l.Submission.Enabled && !l.Submission.NoRequireSTARTTLS || l.AccountHTTPS.Enabled || l.AdminHTTPS.Enabled || l.AutoconfigHTTPS.Enabled || l.MTASTSHTTPS.Enabled {
|
|
addErrorf("listener %q requires TLS, but does not specify tls config", name)
|
|
}
|
|
if l.AutoconfigHTTPS.Enabled && (!l.IMAP.Enabled && !l.IMAPS.Enabled || !l.Submission.Enabled && !l.Submissions.Enabled) {
|
|
addErrorf("listener %q with autoconfig enabled must have SMTP submission or submissions and IMAP or IMAPS enabled", name)
|
|
}
|
|
if l.SMTP.Enabled {
|
|
if len(l.IPs) == 0 {
|
|
haveUnspecifiedSMTPListener = true
|
|
}
|
|
for _, ipstr := range l.IPs {
|
|
ip := net.ParseIP(ipstr)
|
|
if ip == nil {
|
|
addErrorf("listener %q has invalid IP %q", name, ipstr)
|
|
continue
|
|
}
|
|
if ip.IsUnspecified() {
|
|
haveUnspecifiedSMTPListener = true
|
|
break
|
|
}
|
|
if len(c.SpecifiedSMTPListenIPs) >= 2 {
|
|
haveUnspecifiedSMTPListener = true
|
|
} else if len(c.SpecifiedSMTPListenIPs) > 0 && (c.SpecifiedSMTPListenIPs[0].To4() == nil) == (ip.To4() == nil) {
|
|
haveUnspecifiedSMTPListener = true
|
|
} else {
|
|
c.SpecifiedSMTPListenIPs = append(c.SpecifiedSMTPListenIPs, ip)
|
|
}
|
|
}
|
|
}
|
|
for _, s := range l.SMTP.DNSBLs {
|
|
d, err := dns.ParseDomain(s)
|
|
if err != nil {
|
|
addErrorf("listener %q has invalid DNSBL zone %q", name, s)
|
|
continue
|
|
}
|
|
l.SMTP.DNSBLZones = append(l.SMTP.DNSBLZones, d)
|
|
}
|
|
c.Listeners[name] = l
|
|
}
|
|
if haveUnspecifiedSMTPListener {
|
|
c.SpecifiedSMTPListenIPs = nil
|
|
}
|
|
|
|
for _, mb := range c.DefaultMailboxes {
|
|
checkMailboxNormf(mb, "default mailbox")
|
|
}
|
|
|
|
// Load CA certificate pool.
|
|
if c.TLS.CA != nil {
|
|
if c.TLS.CA.AdditionalToSystem {
|
|
var err error
|
|
c.TLS.CertPool, err = x509.SystemCertPool()
|
|
if err != nil {
|
|
addErrorf("fetching system CA cert pool: %v", err)
|
|
}
|
|
} else {
|
|
c.TLS.CertPool = x509.NewCertPool()
|
|
}
|
|
for _, certfile := range c.TLS.CA.CertFiles {
|
|
p := configDirPath(configFile, certfile)
|
|
pemBuf, err := os.ReadFile(p)
|
|
if err != nil {
|
|
addErrorf("reading TLS CA cert file: %v", err)
|
|
continue
|
|
} else if !c.TLS.CertPool.AppendCertsFromPEM(pemBuf) {
|
|
// todo: can we check more fully if we're getting some useful data back?
|
|
addErrorf("no CA certs added from %q", p)
|
|
}
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
// PrepareDynamicConfig parses the dynamic config file given a static file.
|
|
func ParseDynamicConfig(ctx context.Context, dynamicPath string, static config.Static) (c config.Dynamic, mtime time.Time, accDests map[string]AccountDestination, errs []error) {
|
|
addErrorf := func(format string, args ...any) {
|
|
errs = append(errs, fmt.Errorf(format, args...))
|
|
}
|
|
|
|
f, err := os.Open(dynamicPath)
|
|
if err != nil {
|
|
addErrorf("parsing domains config: %v", err)
|
|
return
|
|
}
|
|
defer f.Close()
|
|
fi, err := f.Stat()
|
|
if err != nil {
|
|
addErrorf("stat domains config: %v", err)
|
|
}
|
|
if err := sconf.Parse(f, &c); err != nil {
|
|
addErrorf("parsing dynamic config file: %v", err)
|
|
return
|
|
}
|
|
|
|
accDests, errs = prepareDynamicConfig(ctx, dynamicPath, static, &c)
|
|
return c, fi.ModTime(), accDests, errs
|
|
}
|
|
|
|
func prepareDynamicConfig(ctx context.Context, dynamicPath string, static config.Static, c *config.Dynamic) (accDests map[string]AccountDestination, errs []error) {
|
|
log := xlog.WithContext(ctx)
|
|
|
|
addErrorf := func(format string, args ...any) {
|
|
errs = append(errs, fmt.Errorf(format, args...))
|
|
}
|
|
|
|
// check that mailbox is in unicode NFC normalized form.
|
|
checkMailboxNormf := func(mailbox string, format string, args ...any) {
|
|
s := norm.NFC.String(mailbox)
|
|
if mailbox != s {
|
|
msg := fmt.Sprintf(format, args...)
|
|
addErrorf("%s: mailbox %q is not in NFC normalized form, should be %q", msg, mailbox, s)
|
|
}
|
|
}
|
|
|
|
// Validate postmaster account exists.
|
|
if _, ok := c.Accounts[static.Postmaster.Account]; !ok {
|
|
addErrorf("postmaster account %q does not exist", static.Postmaster.Account)
|
|
}
|
|
checkMailboxNormf(static.Postmaster.Mailbox, "postmaster mailbox")
|
|
|
|
var haveSTSListener bool
|
|
for _, l := range static.Listeners {
|
|
if l.MTASTSHTTPS.Enabled {
|
|
haveSTSListener = true
|
|
break
|
|
}
|
|
}
|
|
|
|
// Validate domains.
|
|
for d, domain := range c.Domains {
|
|
dnsdomain, err := dns.ParseDomain(d)
|
|
if err != nil {
|
|
addErrorf("bad domain %q: %s", d, err)
|
|
} else if dnsdomain.Name() != d {
|
|
addErrorf("domain %q must be specified in IDNA form, %q", d, dnsdomain.Name())
|
|
}
|
|
|
|
domain.Domain = dnsdomain
|
|
|
|
for _, sign := range domain.DKIM.Sign {
|
|
if _, ok := domain.DKIM.Selectors[sign]; !ok {
|
|
addErrorf("selector %q for signing is missing in domain %q", sign, d)
|
|
}
|
|
}
|
|
for name, sel := range domain.DKIM.Selectors {
|
|
seld, err := dns.ParseDomain(name)
|
|
if err != nil {
|
|
addErrorf("bad selector %q: %s", name, err)
|
|
} else if seld.Name() != name {
|
|
addErrorf("selector %q must be specified in IDNA form, %q", name, seld.Name())
|
|
}
|
|
sel.Domain = seld
|
|
|
|
if sel.Expiration != "" {
|
|
exp, err := time.ParseDuration(sel.Expiration)
|
|
if err != nil {
|
|
addErrorf("selector %q has invalid expiration %q: %v", name, sel.Expiration, err)
|
|
} else {
|
|
sel.ExpirationSeconds = int(exp / time.Second)
|
|
}
|
|
}
|
|
|
|
sel.HashEffective = sel.Hash
|
|
switch sel.HashEffective {
|
|
case "":
|
|
sel.HashEffective = "sha256"
|
|
case "sha1":
|
|
log.Error("using sha1 with DKIM is deprecated as not secure enough, switch to sha256")
|
|
case "sha256":
|
|
default:
|
|
addErrorf("unsupported hash %q for selector %q in domain %q", sel.HashEffective, name, d)
|
|
}
|
|
|
|
pemBuf, err := os.ReadFile(configDirPath(dynamicPath, sel.PrivateKeyFile))
|
|
if err != nil {
|
|
addErrorf("reading private key for selector %q in domain %q: %s", name, d, err)
|
|
continue
|
|
}
|
|
p, _ := pem.Decode(pemBuf)
|
|
if p == nil {
|
|
addErrorf("private key for selector %q in domain %q has no PEM block", name, d)
|
|
continue
|
|
}
|
|
key, err := x509.ParsePKCS8PrivateKey(p.Bytes)
|
|
if err != nil {
|
|
addErrorf("parsing private key for selector %q in domain %q: %s", name, d, err)
|
|
continue
|
|
}
|
|
switch k := key.(type) {
|
|
case *rsa.PrivateKey:
|
|
if k.N.BitLen() < 1024 {
|
|
// ../rfc/6376:757
|
|
// Let's help user do the right thing.
|
|
addErrorf("rsa keys should be >= 1024 bits")
|
|
}
|
|
sel.Key = k
|
|
case ed25519.PrivateKey:
|
|
if sel.HashEffective != "sha256" {
|
|
addErrorf("hash algorithm %q is not supported with ed25519, only sha256 is", sel.HashEffective)
|
|
}
|
|
sel.Key = k
|
|
default:
|
|
addErrorf("private key type %T not yet supported, at selector %q in domain %q", key, name, d)
|
|
}
|
|
|
|
if len(sel.Headers) == 0 {
|
|
// ../rfc/6376:2139
|
|
// ../rfc/6376:2203
|
|
// ../rfc/6376:2212
|
|
// By default we seal signed headers, and we sign user-visible headers to
|
|
// prevent/limit reuse of previously signed messages: All addressing fields, date
|
|
// and subject, message-referencing fields, parsing instructions (content-type).
|
|
sel.HeadersEffective = strings.Split("From,To,Cc,Bcc,Reply-To,References,In-Reply-To,Subject,Date,Message-Id,Content-Type", ",")
|
|
} else {
|
|
var from bool
|
|
for _, h := range sel.Headers {
|
|
from = from || strings.EqualFold(h, "From")
|
|
// ../rfc/6376:2269
|
|
if strings.EqualFold(h, "DKIM-Signature") || strings.EqualFold(h, "Received") || strings.EqualFold(h, "Return-Path") {
|
|
log.Error("DKIM-signing header %q is recommended against as it may be modified in transit")
|
|
}
|
|
}
|
|
if !from {
|
|
addErrorf("From-field must always be DKIM-signed")
|
|
}
|
|
sel.HeadersEffective = sel.Headers
|
|
}
|
|
|
|
domain.DKIM.Selectors[name] = sel
|
|
}
|
|
|
|
if domain.MTASTS != nil {
|
|
if !haveSTSListener {
|
|
addErrorf("MTA-STS enabled for domain %q, but there is no listener for MTASTS", d)
|
|
}
|
|
sts := domain.MTASTS
|
|
if sts.PolicyID == "" {
|
|
addErrorf("invalid empty MTA-STS PolicyID")
|
|
}
|
|
switch sts.Mode {
|
|
case mtasts.ModeNone, mtasts.ModeTesting, mtasts.ModeEnforce:
|
|
default:
|
|
addErrorf("invalid mtasts mode %q", sts.Mode)
|
|
}
|
|
}
|
|
|
|
c.Domains[d] = domain
|
|
}
|
|
|
|
// Post-process email addresses for fast lookups.
|
|
accDests = map[string]AccountDestination{}
|
|
for accName, acc := range c.Accounts {
|
|
var err error
|
|
acc.DNSDomain, err = dns.ParseDomain(acc.Domain)
|
|
if err != nil {
|
|
addErrorf("parsing domain %q for account %q: %s", acc.Domain, accName, err)
|
|
}
|
|
|
|
if strings.EqualFold(acc.RejectsMailbox, "Inbox") {
|
|
addErrorf("account %q: cannot set RejectsMailbox to inbox, messages will be removed automatically from the rejects mailbox", accName)
|
|
}
|
|
checkMailboxNormf(acc.RejectsMailbox, "account %q", accName)
|
|
|
|
if acc.AutomaticJunkFlags.JunkMailboxRegexp != "" {
|
|
r, err := regexp.Compile(acc.AutomaticJunkFlags.JunkMailboxRegexp)
|
|
if err != nil {
|
|
addErrorf("invalid JunkMailboxRegexp regular expression: %v", err)
|
|
}
|
|
acc.JunkMailbox = r
|
|
}
|
|
if acc.AutomaticJunkFlags.NeutralMailboxRegexp != "" {
|
|
r, err := regexp.Compile(acc.AutomaticJunkFlags.NeutralMailboxRegexp)
|
|
if err != nil {
|
|
addErrorf("invalid NeutralMailboxRegexp regular expression: %v", err)
|
|
}
|
|
acc.NeutralMailbox = r
|
|
}
|
|
if acc.AutomaticJunkFlags.NotJunkMailboxRegexp != "" {
|
|
r, err := regexp.Compile(acc.AutomaticJunkFlags.NotJunkMailboxRegexp)
|
|
if err != nil {
|
|
addErrorf("invalid NotJunkMailboxRegexp regular expression: %v", err)
|
|
}
|
|
acc.NotJunkMailbox = r
|
|
}
|
|
c.Accounts[accName] = acc
|
|
|
|
for addrName, dest := range acc.Destinations {
|
|
checkMailboxNormf(dest.Mailbox, "account %q, destination %q", accName, addrName)
|
|
|
|
for i, rs := range dest.Rulesets {
|
|
checkMailboxNormf(rs.Mailbox, "account %q, destination %q, ruleset %d", accName, addrName, i+1)
|
|
|
|
n := 0
|
|
|
|
if rs.SMTPMailFromRegexp != "" {
|
|
n++
|
|
r, err := regexp.Compile(rs.SMTPMailFromRegexp)
|
|
if err != nil {
|
|
addErrorf("invalid SMTPMailFrom regular expression: %v", err)
|
|
}
|
|
c.Accounts[accName].Destinations[addrName].Rulesets[i].SMTPMailFromRegexpCompiled = r
|
|
}
|
|
if rs.VerifiedDomain != "" {
|
|
n++
|
|
d, err := dns.ParseDomain(rs.VerifiedDomain)
|
|
if err != nil {
|
|
addErrorf("invalid VerifiedDomain: %v", err)
|
|
}
|
|
c.Accounts[accName].Destinations[addrName].Rulesets[i].VerifiedDNSDomain = d
|
|
}
|
|
|
|
var hdr [][2]*regexp.Regexp
|
|
for k, v := range rs.HeadersRegexp {
|
|
n++
|
|
if strings.ToLower(k) != k {
|
|
addErrorf("header field %q must only have lower case characters", k)
|
|
}
|
|
if strings.ToLower(v) != v {
|
|
addErrorf("header value %q must only have lower case characters", v)
|
|
}
|
|
rk, err := regexp.Compile(k)
|
|
if err != nil {
|
|
addErrorf("invalid rule header regexp %q: %v", k, err)
|
|
}
|
|
rv, err := regexp.Compile(v)
|
|
if err != nil {
|
|
addErrorf("invalid rule header regexp %q: %v", v, err)
|
|
}
|
|
hdr = append(hdr, [...]*regexp.Regexp{rk, rv})
|
|
}
|
|
c.Accounts[accName].Destinations[addrName].Rulesets[i].HeadersRegexpCompiled = hdr
|
|
|
|
if n == 0 {
|
|
addErrorf("ruleset must have at least one rule")
|
|
}
|
|
|
|
if rs.ListAllowDomain != "" {
|
|
d, err := dns.ParseDomain(rs.ListAllowDomain)
|
|
if err != nil {
|
|
addErrorf("invalid ListAllowDomain %q: %v", rs.ListAllowDomain, err)
|
|
}
|
|
c.Accounts[accName].Destinations[addrName].Rulesets[i].ListAllowDNSDomain = d
|
|
}
|
|
}
|
|
|
|
var address smtp.Address
|
|
localpart, err := smtp.ParseLocalpart(addrName)
|
|
if err != nil && errors.Is(err, smtp.ErrBadLocalpart) {
|
|
address, err = smtp.ParseAddress(addrName)
|
|
if err != nil {
|
|
addErrorf("invalid email address %q in account %q", addrName, accName)
|
|
continue
|
|
} else if _, ok := c.Domains[address.Domain.Name()]; !ok {
|
|
addErrorf("unknown domain for address %q in account %q", addrName, accName)
|
|
continue
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
addErrorf("invalid localpart %q in account %q", addrName, accName)
|
|
continue
|
|
}
|
|
address = smtp.NewAddress(localpart, acc.DNSDomain)
|
|
if _, ok := c.Domains[acc.DNSDomain.Name()]; !ok {
|
|
addErrorf("unknown domain %q for account %q", acc.DNSDomain.Name(), accName)
|
|
continue
|
|
}
|
|
}
|
|
addrFull := address.Pack(true)
|
|
if _, ok := accDests[addrFull]; ok {
|
|
addErrorf("duplicate destination address %q", addrFull)
|
|
}
|
|
accDests[addrFull] = AccountDestination{address.Localpart, accName, dest}
|
|
}
|
|
}
|
|
|
|
// Set DMARC destinations.
|
|
for d, domain := range c.Domains {
|
|
dmarc := domain.DMARC
|
|
if dmarc == nil {
|
|
continue
|
|
}
|
|
if _, ok := c.Accounts[dmarc.Account]; !ok {
|
|
addErrorf("DMARC account %q does not exist", dmarc.Account)
|
|
}
|
|
lp, err := smtp.ParseLocalpart(dmarc.Localpart)
|
|
if err != nil {
|
|
addErrorf("invalid DMARC localpart %q: %s", dmarc.Localpart, err)
|
|
}
|
|
if lp.IsInternational() {
|
|
// ../rfc/8616:234
|
|
addErrorf("DMARC localpart %q is an internationalized address, only conventional ascii-only address possible for interopability", lp)
|
|
}
|
|
domain.DMARC.ParsedLocalpart = lp
|
|
c.Domains[d] = domain
|
|
addrFull := smtp.NewAddress(lp, domain.Domain).String()
|
|
dest := config.Destination{
|
|
Mailbox: dmarc.Mailbox,
|
|
DMARCReports: true,
|
|
}
|
|
checkMailboxNormf(dmarc.Mailbox, "DMARC mailbox for account %q", dmarc.Account)
|
|
accDests[addrFull] = AccountDestination{lp, dmarc.Account, dest}
|
|
}
|
|
|
|
// Set TLSRPT destinations.
|
|
for d, domain := range c.Domains {
|
|
tlsrpt := domain.TLSRPT
|
|
if tlsrpt == nil {
|
|
continue
|
|
}
|
|
if _, ok := c.Accounts[tlsrpt.Account]; !ok {
|
|
addErrorf("TLSRPT account %q does not exist", tlsrpt.Account)
|
|
}
|
|
lp, err := smtp.ParseLocalpart(tlsrpt.Localpart)
|
|
if err != nil {
|
|
addErrorf("invalid TLSRPT localpart %q: %s", tlsrpt.Localpart, err)
|
|
}
|
|
if lp.IsInternational() {
|
|
// Does not appear documented in ../rfc/8460, but similar to DMARC it makes sense
|
|
// to keep this ascii-only addresses.
|
|
addErrorf("TLSRPT localpart %q is an internationalized address, only conventional ascii-only address allowed for interopability", lp)
|
|
}
|
|
domain.TLSRPT.ParsedLocalpart = lp
|
|
c.Domains[d] = domain
|
|
addrFull := smtp.NewAddress(lp, domain.Domain).String()
|
|
dest := config.Destination{
|
|
Mailbox: tlsrpt.Mailbox,
|
|
TLSReports: true,
|
|
}
|
|
checkMailboxNormf(tlsrpt.Mailbox, "TLSRPT mailbox for account %q", tlsrpt.Account)
|
|
accDests[addrFull] = AccountDestination{lp, tlsrpt.Account, dest}
|
|
}
|
|
return
|
|
}
|
|
|
|
func loadTLSKeyCerts(configFile, kind string, ctls *config.TLS) error {
|
|
certs := []tls.Certificate{}
|
|
for _, kp := range ctls.KeyCerts {
|
|
certPath := configDirPath(configFile, kp.CertFile)
|
|
keyPath := configDirPath(configFile, kp.KeyFile)
|
|
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("tls config for %q: parsing x509 key pair: %v", kind, err)
|
|
}
|
|
certs = append(certs, cert)
|
|
}
|
|
ctls.Config = &tls.Config{
|
|
Certificates: certs,
|
|
}
|
|
return nil
|
|
}
|