mirror of
https://github.com/mjl-/mox.git
synced 2024-12-25 16:03:48 +03:00
1964 lines
64 KiB
Go
1964 lines
64 KiB
Go
package mox
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto"
|
|
"crypto/ecdsa"
|
|
"crypto/ed25519"
|
|
"crypto/elliptic"
|
|
cryptorand "crypto/rand"
|
|
"crypto/rsa"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/base64"
|
|
"encoding/pem"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"os/user"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/text/unicode/norm"
|
|
|
|
"github.com/mjl-/autocert"
|
|
|
|
"github.com/mjl-/sconf"
|
|
|
|
"github.com/mjl-/mox/autotls"
|
|
"github.com/mjl-/mox/config"
|
|
"github.com/mjl-/mox/dkim"
|
|
"github.com/mjl-/mox/dns"
|
|
"github.com/mjl-/mox/message"
|
|
"github.com/mjl-/mox/mlog"
|
|
"github.com/mjl-/mox/moxio"
|
|
"github.com/mjl-/mox/mtasts"
|
|
"github.com/mjl-/mox/smtp"
|
|
)
|
|
|
|
var pkglog = mlog.New("mox", nil)
|
|
|
|
// Pedantic enables stricter parsing.
|
|
var Pedantic bool
|
|
|
|
// 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]slog.Level{"": slog.LevelError}}
|
|
)
|
|
|
|
var ErrConfig = errors.New("config error")
|
|
|
|
// Set by packages webadmin, webaccount, webmail, webapisrv to prevent cyclic dependencies.
|
|
var NewWebadminHandler = func(basePath string, isForwarded bool) http.Handler { return nopHandler }
|
|
var NewWebaccountHandler = func(basePath string, isForwarded bool) http.Handler { return nopHandler }
|
|
var NewWebmailHandler = func(maxMsgSize int64, basePath string, isForwarded bool, accountPath string) http.Handler {
|
|
return nopHandler
|
|
}
|
|
var NewWebapiHandler = func(maxMsgSize int64, basePath string, isForwarded bool) http.Handler { return nopHandler }
|
|
|
|
var nopHandler = http.HandlerFunc(nil)
|
|
|
|
// 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]slog.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 canonical full address (localpart@domain, lower-cased when
|
|
// case-insensitive, stripped of catchall separator) to account and address.
|
|
// Domains are IDNA names in utf8. Dynamic config lock must be held when accessing.
|
|
AccountDestinationsLocked map[string]AccountDestination
|
|
|
|
// Like AccountDestinationsLocked, but for aliases.
|
|
aliases map[string]config.Alias
|
|
}
|
|
|
|
type AccountDestination struct {
|
|
Catchall bool // If catchall destination for its domain.
|
|
Localpart smtp.Localpart // In original casing as written in config file.
|
|
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(log mlog.Log, pkg string, level slog.Level) {
|
|
c.logMutex.Lock()
|
|
defer c.logMutex.Unlock()
|
|
l := c.copyLogLevels()
|
|
l[pkg] = level
|
|
c.Log = l
|
|
log.Print("log level changed", slog.String("pkg", pkg), slog.Any("level", mlog.LevelStrings[level]))
|
|
mlog.SetConfig(c.Log)
|
|
}
|
|
|
|
// LogLevelRemove removes a configured log level for a package.
|
|
func (c *Config) LogLevelRemove(log mlog.Log, pkg string) {
|
|
c.logMutex.Lock()
|
|
defer c.logMutex.Unlock()
|
|
l := c.copyLogLevels()
|
|
delete(l, pkg)
|
|
c.Log = l
|
|
log.Print("log level cleared", slog.String("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]slog.Level {
|
|
m := map[string]slog.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]slog.Level {
|
|
c.logMutex.Lock()
|
|
defer c.logMutex.Unlock()
|
|
return c.copyLogLevels()
|
|
}
|
|
|
|
// DynamicLockUnlock locks the dynamic config, will try updating the latest state
|
|
// from disk, and return an unlock function. Should be called as "defer
|
|
// Conf.DynamicLockUnlock()()".
|
|
func (c *Config) DynamicLockUnlock() func() {
|
|
c.dynamicMutex.Lock()
|
|
now := time.Now()
|
|
if now.Sub(c.DynamicLastCheck) > time.Second {
|
|
c.DynamicLastCheck = now
|
|
if fi, err := os.Stat(ConfigDynamicPath); err != nil {
|
|
pkglog.Errorx("stat domains config", err)
|
|
} else if !fi.ModTime().Equal(c.dynamicMtime) {
|
|
if errs := c.loadDynamic(); len(errs) > 0 {
|
|
pkglog.Errorx("loading domains config", errs[0], slog.Any("errors", errs))
|
|
} else {
|
|
pkglog.Info("domains config reloaded")
|
|
c.dynamicMtime = fi.ModTime()
|
|
}
|
|
}
|
|
}
|
|
return c.dynamicMutex.Unlock
|
|
}
|
|
|
|
func (c *Config) withDynamicLock(fn func()) {
|
|
defer c.DynamicLockUnlock()()
|
|
fn()
|
|
}
|
|
|
|
// must be called with dynamic lock held.
|
|
func (c *Config) loadDynamic() []error {
|
|
d, mtime, accDests, aliases, err := ParseDynamicConfig(context.Background(), pkglog, ConfigDynamicPath, c.Static)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.Dynamic = d
|
|
c.dynamicMtime = mtime
|
|
c.AccountDestinationsLocked = accDests
|
|
c.aliases = aliases
|
|
c.allowACMEHosts(pkglog, true)
|
|
return nil
|
|
}
|
|
|
|
// DynamicConfig returns a shallow copy of the dynamic config. Must not be modified.
|
|
func (c *Config) DynamicConfig() (config config.Dynamic) {
|
|
c.withDynamicLock(func() {
|
|
config = c.Dynamic // Shallow copy.
|
|
})
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// DomainLocalparts returns a mapping of encoded localparts to account names for a
|
|
// domain, and encoded localparts to aliases. An empty localpart is a catchall
|
|
// destination for a domain.
|
|
func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]config.Alias) {
|
|
suffix := "@" + d.Name()
|
|
m := map[string]string{}
|
|
aliases := map[string]config.Alias{}
|
|
c.withDynamicLock(func() {
|
|
for addr, ad := range c.AccountDestinationsLocked {
|
|
if strings.HasSuffix(addr, suffix) {
|
|
if ad.Catchall {
|
|
m[""] = ad.Account
|
|
} else {
|
|
m[ad.Localpart.String()] = ad.Account
|
|
}
|
|
}
|
|
}
|
|
for addr, a := range c.aliases {
|
|
if strings.HasSuffix(addr, suffix) {
|
|
aliases[a.LocalpartStr] = a
|
|
}
|
|
}
|
|
})
|
|
return m, aliases
|
|
}
|
|
|
|
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) (accDest AccountDestination, alias *config.Alias, ok bool) {
|
|
c.withDynamicLock(func() {
|
|
accDest, ok = c.AccountDestinationsLocked[addr]
|
|
if !ok {
|
|
var a config.Alias
|
|
a, ok = c.aliases[addr]
|
|
if ok {
|
|
alias = &a
|
|
}
|
|
}
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) Routes(accountName string, domain dns.Domain) (accountRoutes, domainRoutes, globalRoutes []config.Route) {
|
|
c.withDynamicLock(func() {
|
|
acc := c.Dynamic.Accounts[accountName]
|
|
accountRoutes = acc.Routes
|
|
|
|
dom := c.Dynamic.Domains[domain.Name()]
|
|
domainRoutes = dom.Routes
|
|
|
|
globalRoutes = c.Dynamic.Routes
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) IsClientSettingsDomain(d dns.Domain) (is bool) {
|
|
c.withDynamicLock(func() {
|
|
_, is = c.Dynamic.ClientSettingDomains[d]
|
|
})
|
|
return
|
|
}
|
|
|
|
func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
|
|
for _, l := range c.Static.Listeners {
|
|
if l.TLS == nil || l.TLS.ACME == "" {
|
|
continue
|
|
}
|
|
|
|
m := c.Static.ACME[l.TLS.ACME].Manager
|
|
hostnames := map[dns.Domain]struct{}{}
|
|
|
|
hostnames[c.Static.HostnameDomain] = struct{}{}
|
|
if l.HostnameDomain.ASCII != "" {
|
|
hostnames[l.HostnameDomain] = struct{}{}
|
|
}
|
|
|
|
for _, dom := range c.Dynamic.Domains {
|
|
// Do not allow TLS certificates for domains for which we only accept DMARC/TLS
|
|
// reports as external party.
|
|
if dom.ReportsOnly {
|
|
continue
|
|
}
|
|
|
|
if l.AutoconfigHTTPS.Enabled && !l.AutoconfigHTTPS.NonTLS {
|
|
if d, err := dns.ParseDomain("autoconfig." + dom.Domain.ASCII); err != nil {
|
|
log.Errorx("parsing autoconfig domain", err, slog.Any("domain", dom.Domain))
|
|
} else {
|
|
hostnames[d] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if l.MTASTSHTTPS.Enabled && dom.MTASTS != nil && !l.MTASTSHTTPS.NonTLS {
|
|
d, err := dns.ParseDomain("mta-sts." + dom.Domain.ASCII)
|
|
if err != nil {
|
|
log.Errorx("parsing mta-sts domain", err, slog.Any("domain", dom.Domain))
|
|
} else {
|
|
hostnames[d] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if dom.ClientSettingsDomain != "" {
|
|
hostnames[dom.ClientSettingsDNSDomain] = struct{}{}
|
|
}
|
|
}
|
|
|
|
if l.WebserverHTTPS.Enabled {
|
|
for from := range c.Dynamic.WebDNSDomainRedirects {
|
|
hostnames[from] = struct{}{}
|
|
}
|
|
for _, wh := range c.Dynamic.WebHandlers {
|
|
hostnames[wh.DNSDomain] = struct{}{}
|
|
}
|
|
}
|
|
|
|
public := c.Static.Listeners["public"]
|
|
ips := public.IPs
|
|
if len(public.NATIPs) > 0 {
|
|
ips = public.NATIPs
|
|
}
|
|
if public.IPsNATed {
|
|
ips = nil
|
|
}
|
|
m.SetAllowedHostnames(log, dns.StrictResolver{Pkg: "autotls", Log: log.Logger}, hostnames, ips, checkACMEHosts)
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
|
|
// WriteDynamicLocked prepares an updated internal state for the new dynamic
|
|
// config, then writes it to disk and activates it.
|
|
//
|
|
// Returns ErrConfig if the configuration is not valid.
|
|
//
|
|
// Must be called with config lock held.
|
|
func WriteDynamicLocked(ctx context.Context, log mlog.Log, c config.Dynamic) error {
|
|
accDests, aliases, errs := prepareDynamicConfig(ctx, log, ConfigDynamicPath, Conf.Static, &c)
|
|
if len(errs) > 0 {
|
|
errstrs := make([]string, len(errs))
|
|
for i, err := range errs {
|
|
errstrs[i] = err.Error()
|
|
}
|
|
return fmt.Errorf("%w: %s", ErrConfig, strings.Join(errstrs, "; "))
|
|
}
|
|
|
|
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 {
|
|
err := f.Close()
|
|
log.Check(err, "closing file after error")
|
|
}
|
|
}()
|
|
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(log, 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)
|
|
}
|
|
|
|
if err := f.Close(); err != nil {
|
|
return fmt.Errorf("close written domains.conf: %v", err)
|
|
}
|
|
f = nil
|
|
|
|
Conf.dynamicMtime = fi.ModTime()
|
|
Conf.DynamicLastCheck = time.Now()
|
|
Conf.Dynamic = c
|
|
Conf.AccountDestinationsLocked = accDests
|
|
Conf.aliases = aliases
|
|
|
|
Conf.allowACMEHosts(log, true)
|
|
|
|
return nil
|
|
}
|
|
|
|
// MustLoadConfig loads the config, quitting on errors.
|
|
func MustLoadConfig(doLoadTLSKeyCerts, checkACMEHosts bool) {
|
|
errs := LoadConfig(context.Background(), pkglog, doLoadTLSKeyCerts, checkACMEHosts)
|
|
if len(errs) > 1 {
|
|
pkglog.Error("loading config file: multiple errors")
|
|
for _, err := range errs {
|
|
pkglog.Errorx("config error", err)
|
|
}
|
|
pkglog.Fatal("stopping after multiple config errors")
|
|
} else if len(errs) == 1 {
|
|
pkglog.Fatalx("loading config file", errs[0])
|
|
}
|
|
}
|
|
|
|
// LoadConfig attempts to parse and load a config, returning any errors
|
|
// encountered.
|
|
func LoadConfig(ctx context.Context, log mlog.Log, doLoadTLSKeyCerts, checkACMEHosts bool) []error {
|
|
Shutdown, ShutdownCancel = context.WithCancel(context.Background())
|
|
Context, ContextCancel = context.WithCancel(context.Background())
|
|
|
|
c, errs := ParseConfig(ctx, log, ConfigStaticPath, false, doLoadTLSKeyCerts, checkACMEHosts)
|
|
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.AccountDestinationsLocked, c.aliases}
|
|
|
|
// If we have non-standard CA roots, use them for all HTTPS requests.
|
|
if Conf.Static.TLS.CertPool != nil {
|
|
http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{
|
|
RootCAs: Conf.Static.TLS.CertPool,
|
|
}
|
|
}
|
|
|
|
SetPedantic(c.Static.Pedantic)
|
|
}
|
|
|
|
// Set pedantic in all packages.
|
|
func SetPedantic(p bool) {
|
|
dkim.Pedantic = p
|
|
dns.Pedantic = p
|
|
message.Pedantic = p
|
|
smtp.Pedantic = p
|
|
Pedantic = p
|
|
}
|
|
|
|
// ParseConfig parses the static config at path p. If checkOnly is true, no changes
|
|
// are made, such as registering ACME identities. If doLoadTLSKeyCerts is true,
|
|
// the TLS KeyCerts configuration is loaded and checked. This is used during the
|
|
// quickstart in the case the user is going to provide their own certificates.
|
|
// If checkACMEHosts is true, the hosts allowed for acme are compared with the
|
|
// explicitly configured ips we are listening on.
|
|
func ParseConfig(ctx context.Context, log mlog.Log, p string, checkOnly, doLoadTLSKeyCerts, checkACMEHosts 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, log, p, c, checkOnly, doLoadTLSKeyCerts); len(xerrs) > 0 {
|
|
return nil, xerrs
|
|
}
|
|
|
|
pp := filepath.Join(filepath.Dir(p), "domains.conf")
|
|
c.Dynamic, c.dynamicMtime, c.AccountDestinationsLocked, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
|
|
|
|
if !checkOnly {
|
|
c.allowACMEHosts(log, checkACMEHosts)
|
|
}
|
|
|
|
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, log mlog.Log, configFile string, conf *Config, checkOnly, doLoadTLSKeyCerts bool) (errs []error) {
|
|
addErrorf := func(format string, args ...any) {
|
|
errs = append(errs, fmt.Errorf(format, args...))
|
|
}
|
|
|
|
c := &conf.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 {
|
|
conf.Log = map[string]slog.Level{"": logLevel}
|
|
} else {
|
|
addErrorf("invalid log level %q", c.LogLevel)
|
|
}
|
|
for pkg, s := range c.PackageLogLevels {
|
|
if logLevel, ok := mlog.Levels[s]; ok {
|
|
conf.Log[pkg] = logLevel
|
|
} else {
|
|
addErrorf("invalid package log level %q", s)
|
|
}
|
|
}
|
|
|
|
if c.User == "" {
|
|
c.User = "mox"
|
|
}
|
|
u, err := user.Lookup(c.User)
|
|
if err != nil {
|
|
uid, err := strconv.ParseUint(c.User, 10, 32)
|
|
if err != nil {
|
|
addErrorf("parsing unknown user %s as uid: %v (hint: add user mox with \"useradd -d $PWD mox\" or specify a different username on the quickstart command-line)", c.User, err)
|
|
} else {
|
|
// We assume the same gid as uid.
|
|
c.UID = uint32(uid)
|
|
c.GID = uint32(uid)
|
|
}
|
|
} else {
|
|
if uid, err := strconv.ParseUint(u.Uid, 10, 32); err != nil {
|
|
addErrorf("parsing uid %s: %v", u.Uid, err)
|
|
} else {
|
|
c.UID = uint32(uid)
|
|
}
|
|
if gid, err := strconv.ParseUint(u.Gid, 10, 32); err != nil {
|
|
addErrorf("parsing gid %s: %v", u.Gid, err)
|
|
} else {
|
|
c.GID = uint32(gid)
|
|
}
|
|
}
|
|
|
|
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 unicode form %q instead of %q", hostname.Name(), c.Hostname)
|
|
}
|
|
c.HostnameDomain = hostname
|
|
|
|
if c.HostTLSRPT.Account != "" {
|
|
tlsrptLocalpart, err := smtp.ParseLocalpart(c.HostTLSRPT.Localpart)
|
|
if err != nil {
|
|
addErrorf("invalid localpart %q for host tlsrpt: %v", c.HostTLSRPT.Localpart, err)
|
|
} else if tlsrptLocalpart.IsInternational() {
|
|
// Does not appear documented in ../rfc/8460, but similar to DMARC it makes sense
|
|
// to keep this ascii-only addresses.
|
|
addErrorf("host TLSRPT localpart %q is an internationalized address, only conventional ascii-only address allowed for interopability", tlsrptLocalpart)
|
|
}
|
|
c.HostTLSRPT.ParsedLocalpart = tlsrptLocalpart
|
|
}
|
|
|
|
// Return private key for host name for use with an ACME. Used to return the same
|
|
// private key as pre-generated for use with DANE, with its public key in DNS.
|
|
// We only use this key for Listener's that have this ACME configured, and for
|
|
// which the effective listener host name (either specific to the listener, or the
|
|
// global name) is requested. Other host names can get a fresh private key, they
|
|
// don't appear in DANE records.
|
|
//
|
|
// - run 0: only use listener with explicitly matching host name in listener
|
|
// (default quickstart config does not set it).
|
|
// - run 1: only look at public listener (and host matching mox host name)
|
|
// - run 2: all listeners (and host matching mox host name)
|
|
findACMEHostPrivateKey := func(acmeName, host string, keyType autocert.KeyType, run int) crypto.Signer {
|
|
for listenerName, l := range Conf.Static.Listeners {
|
|
if l.TLS == nil || l.TLS.ACME != acmeName {
|
|
continue
|
|
}
|
|
if run == 0 && host != l.HostnameDomain.ASCII {
|
|
continue
|
|
}
|
|
if run == 1 && listenerName != "public" || host != Conf.Static.HostnameDomain.ASCII {
|
|
continue
|
|
}
|
|
switch keyType {
|
|
case autocert.KeyRSA2048:
|
|
if len(l.TLS.HostPrivateRSA2048Keys) == 0 {
|
|
continue
|
|
}
|
|
return l.TLS.HostPrivateRSA2048Keys[0]
|
|
case autocert.KeyECDSAP256:
|
|
if len(l.TLS.HostPrivateECDSAP256Keys) == 0 {
|
|
continue
|
|
}
|
|
return l.TLS.HostPrivateECDSAP256Keys[0]
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
// Make a function for an autocert.Manager.GetPrivateKey, using findACMEHostPrivateKey.
|
|
makeGetPrivateKey := func(acmeName string) func(host string, keyType autocert.KeyType) (crypto.Signer, error) {
|
|
return func(host string, keyType autocert.KeyType) (crypto.Signer, error) {
|
|
key := findACMEHostPrivateKey(acmeName, host, keyType, 0)
|
|
if key == nil {
|
|
key = findACMEHostPrivateKey(acmeName, host, keyType, 1)
|
|
}
|
|
if key == nil {
|
|
key = findACMEHostPrivateKey(acmeName, host, keyType, 2)
|
|
}
|
|
if key != nil {
|
|
log.Debug("found existing private key for certificate for host",
|
|
slog.String("acmename", acmeName),
|
|
slog.String("host", host),
|
|
slog.Any("keytype", keyType))
|
|
return key, nil
|
|
}
|
|
log.Debug("generating new private key for certificate for host",
|
|
slog.String("acmename", acmeName),
|
|
slog.String("host", host),
|
|
slog.Any("keytype", keyType))
|
|
switch keyType {
|
|
case autocert.KeyRSA2048:
|
|
return rsa.GenerateKey(cryptorand.Reader, 2048)
|
|
case autocert.KeyECDSAP256:
|
|
return ecdsa.GenerateKey(elliptic.P256(), cryptorand.Reader)
|
|
default:
|
|
return nil, fmt.Errorf("unrecognized requested key type %v", keyType)
|
|
}
|
|
}
|
|
}
|
|
for name, acme := range c.ACME {
|
|
var eabKeyID string
|
|
var eabKey []byte
|
|
if acme.ExternalAccountBinding != nil {
|
|
eabKeyID = acme.ExternalAccountBinding.KeyID
|
|
p := configDirPath(configFile, acme.ExternalAccountBinding.KeyFile)
|
|
buf, err := os.ReadFile(p)
|
|
if err != nil {
|
|
addErrorf("reading external account binding key for acme provider %q: %s", name, err)
|
|
} else {
|
|
dec := make([]byte, base64.RawURLEncoding.DecodedLen(len(buf)))
|
|
n, err := base64.RawURLEncoding.Decode(dec, buf)
|
|
if err != nil {
|
|
addErrorf("parsing external account binding key as base64 for acme provider %q: %s", name, err)
|
|
} else {
|
|
eabKey = dec[:n]
|
|
}
|
|
}
|
|
}
|
|
|
|
if checkOnly {
|
|
continue
|
|
}
|
|
|
|
acmeDir := dataDirPath(configFile, c.DataDir, "acme")
|
|
os.MkdirAll(acmeDir, 0770)
|
|
manager, err := autotls.Load(name, acmeDir, acme.ContactEmail, acme.DirectoryURL, eabKeyID, eabKey, makeGetPrivateKey(name), Shutdown.Done())
|
|
if err != nil {
|
|
addErrorf("loading ACME identity for %q: %s", name, err)
|
|
}
|
|
acme.Manager = manager
|
|
|
|
// Help configurations from older quickstarts.
|
|
if acme.IssuerDomainName == "" && acme.DirectoryURL == "https://acme-v02.api.letsencrypt.org/directory" {
|
|
acme.IssuerDomainName = "letsencrypt.org"
|
|
}
|
|
|
|
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 or with missing ACME definition, we don't have an acme manager,
|
|
// so set an empty tls config to continue.
|
|
var tlsconfig, tlsconfigFallback *tls.Config
|
|
if checkOnly || acme.Manager == nil {
|
|
tlsconfig = &tls.Config{}
|
|
tlsconfigFallback = &tls.Config{}
|
|
} else {
|
|
hostname := c.HostnameDomain
|
|
if l.Hostname != "" {
|
|
hostname = l.HostnameDomain
|
|
}
|
|
// If SNI is absent, we will use the listener hostname, but reject connections with
|
|
// an SNI hostname that is not allowlisted.
|
|
// Incoming SMTP deliveries use tlsconfigFallback for interoperability. TLS
|
|
// connections for unknown SNI hostnames fall back to a certificate for the
|
|
// listener hostname instead of causing the TLS connection to fail.
|
|
tlsconfig = acme.Manager.TLSConfig(hostname, true, false)
|
|
tlsconfigFallback = acme.Manager.TLSConfig(hostname, true, true)
|
|
l.TLS.ACMEConfig = acme.Manager.ACMETLSConfig
|
|
}
|
|
l.TLS.Config = tlsconfig
|
|
l.TLS.ConfigFallback = tlsconfigFallback
|
|
} else if len(l.TLS.KeyCerts) != 0 {
|
|
if doLoadTLSKeyCerts {
|
|
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)
|
|
}
|
|
for _, privKeyFile := range l.TLS.HostPrivateKeyFiles {
|
|
keyPath := configDirPath(configFile, privKeyFile)
|
|
privKey, err := loadPrivateKeyFile(keyPath)
|
|
if err != nil {
|
|
addErrorf("listener %q: parsing host private key for DANE and ACME certificates: %v", name, err)
|
|
continue
|
|
}
|
|
switch k := privKey.(type) {
|
|
case *rsa.PrivateKey:
|
|
if k.N.BitLen() != 2048 {
|
|
log.Error("need rsa key with 2048 bits, for host private key for DANE/ACME certificates, ignoring",
|
|
slog.String("listener", name),
|
|
slog.String("file", keyPath),
|
|
slog.Int("bits", k.N.BitLen()))
|
|
continue
|
|
}
|
|
l.TLS.HostPrivateRSA2048Keys = append(l.TLS.HostPrivateRSA2048Keys, k)
|
|
case *ecdsa.PrivateKey:
|
|
if k.Curve != elliptic.P256() {
|
|
log.Error("unrecognized ecdsa curve for host private key for DANE/ACME certificates, ignoring", slog.String("listener", name), slog.String("file", keyPath))
|
|
continue
|
|
}
|
|
l.TLS.HostPrivateECDSAP256Keys = append(l.TLS.HostPrivateECDSAP256Keys, k)
|
|
default:
|
|
log.Error("unrecognized key type for host private key for DANE/ACME certificates, ignoring",
|
|
slog.String("listener", name),
|
|
slog.String("file", keyPath),
|
|
slog.String("keytype", fmt.Sprintf("%T", privKey)))
|
|
continue
|
|
}
|
|
}
|
|
if l.TLS.ACME != "" && (len(l.TLS.HostPrivateRSA2048Keys) == 0) != (len(l.TLS.HostPrivateECDSAP256Keys) == 0) {
|
|
log.Warn("uncommon configuration with either only an RSA 2048 or ECDSA P256 host private key for DANE/ACME certificates; this ACME implementation can retrieve certificates for both type of keys, it is recommended to set either both or none; continuing")
|
|
}
|
|
|
|
// 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.ConfigFallback != nil {
|
|
l.TLS.ConfigFallback.MinVersion = minVersion
|
|
}
|
|
if l.TLS.ACMEConfig != nil {
|
|
l.TLS.ACMEConfig.MinVersion = minVersion
|
|
}
|
|
} else {
|
|
var needsTLS []string
|
|
needtls := func(s string, v bool) {
|
|
if v {
|
|
needsTLS = append(needsTLS, s)
|
|
}
|
|
}
|
|
needtls("IMAPS", l.IMAPS.Enabled)
|
|
needtls("SMTP", l.SMTP.Enabled && !l.SMTP.NoSTARTTLS)
|
|
needtls("Submissions", l.Submissions.Enabled)
|
|
needtls("Submission", l.Submission.Enabled && !l.Submission.NoRequireSTARTTLS)
|
|
needtls("AccountHTTPS", l.AccountHTTPS.Enabled)
|
|
needtls("AdminHTTPS", l.AdminHTTPS.Enabled)
|
|
needtls("AutoconfigHTTPS", l.AutoconfigHTTPS.Enabled && !l.AutoconfigHTTPS.NonTLS)
|
|
needtls("MTASTSHTTPS", l.MTASTSHTTPS.Enabled && !l.MTASTSHTTPS.NonTLS)
|
|
needtls("WebserverHTTPS", l.WebserverHTTPS.Enabled)
|
|
if len(needsTLS) > 0 {
|
|
addErrorf("listener %q does not specify tls config, but requires tls for %s", name, strings.Join(needsTLS, ", "))
|
|
}
|
|
}
|
|
if l.AutoconfigHTTPS.Enabled && l.MTASTSHTTPS.Enabled && l.AutoconfigHTTPS.Port == l.MTASTSHTTPS.Port && l.AutoconfigHTTPS.NonTLS != l.MTASTSHTTPS.NonTLS {
|
|
addErrorf("listener %q tries to enable autoconfig and mta-sts enabled on same port but with both http and https", 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)
|
|
}
|
|
if l.IPsNATed && len(l.NATIPs) > 0 {
|
|
addErrorf("listener %q has both IPsNATed and NATIPs (remove deprecated IPsNATed)", name)
|
|
}
|
|
for _, ipstr := range l.NATIPs {
|
|
ip := net.ParseIP(ipstr)
|
|
if ip == nil {
|
|
addErrorf("listener %q has invalid ip %q", name, ipstr)
|
|
} else if ip.IsUnspecified() || ip.IsLoopback() {
|
|
addErrorf("listener %q has NAT ip that is the unspecified or loopback address %s", name, ipstr)
|
|
}
|
|
}
|
|
checkPath := func(kind string, enabled bool, path string) {
|
|
if enabled && path != "" && !strings.HasPrefix(path, "/") {
|
|
addErrorf("listener %q has %s with path %q that must start with a slash", name, kind, path)
|
|
}
|
|
}
|
|
checkPath("AccountHTTP", l.AccountHTTP.Enabled, l.AccountHTTP.Path)
|
|
checkPath("AccountHTTPS", l.AccountHTTPS.Enabled, l.AccountHTTPS.Path)
|
|
checkPath("AdminHTTP", l.AdminHTTP.Enabled, l.AdminHTTP.Path)
|
|
checkPath("AdminHTTPS", l.AdminHTTPS.Enabled, l.AdminHTTPS.Path)
|
|
c.Listeners[name] = l
|
|
}
|
|
if haveUnspecifiedSMTPListener {
|
|
c.SpecifiedSMTPListenIPs = nil
|
|
}
|
|
|
|
var zerouse config.SpecialUseMailboxes
|
|
if len(c.DefaultMailboxes) > 0 && (c.InitialMailboxes.SpecialUse != zerouse || len(c.InitialMailboxes.Regular) > 0) {
|
|
addErrorf("cannot have both DefaultMailboxes and InitialMailboxes")
|
|
}
|
|
// DefaultMailboxes is deprecated.
|
|
for _, mb := range c.DefaultMailboxes {
|
|
checkMailboxNormf(mb, "default mailbox")
|
|
}
|
|
checkSpecialUseMailbox := func(nameOpt string) {
|
|
if nameOpt != "" {
|
|
checkMailboxNormf(nameOpt, "special-use initial mailbox")
|
|
if strings.EqualFold(nameOpt, "inbox") {
|
|
addErrorf("initial mailbox cannot be set to Inbox (Inbox is always created)")
|
|
}
|
|
}
|
|
}
|
|
checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Archive)
|
|
checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Draft)
|
|
checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Junk)
|
|
checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Sent)
|
|
checkSpecialUseMailbox(c.InitialMailboxes.SpecialUse.Trash)
|
|
for _, name := range c.InitialMailboxes.Regular {
|
|
checkMailboxNormf(name, "regular initial mailbox")
|
|
if strings.EqualFold(name, "inbox") {
|
|
addErrorf("initial regular mailbox cannot be set to Inbox (Inbox is always created)")
|
|
}
|
|
}
|
|
|
|
checkTransportSMTP := func(name string, isTLS bool, t *config.TransportSMTP) {
|
|
var err error
|
|
t.DNSHost, err = dns.ParseDomain(t.Host)
|
|
if err != nil {
|
|
addErrorf("transport %s: bad host %s: %v", name, t.Host, err)
|
|
}
|
|
|
|
if isTLS && t.STARTTLSInsecureSkipVerify {
|
|
addErrorf("transport %s: cannot have STARTTLSInsecureSkipVerify with immediate TLS")
|
|
}
|
|
if isTLS && t.NoSTARTTLS {
|
|
addErrorf("transport %s: cannot have NoSTARTTLS with immediate TLS")
|
|
}
|
|
|
|
if t.Auth == nil {
|
|
return
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, m := range t.Auth.Mechanisms {
|
|
if seen[m] {
|
|
addErrorf("transport %s: duplicate authentication mechanism %s", name, m)
|
|
}
|
|
seen[m] = true
|
|
switch m {
|
|
case "SCRAM-SHA-256-PLUS":
|
|
case "SCRAM-SHA-256":
|
|
case "SCRAM-SHA-1-PLUS":
|
|
case "SCRAM-SHA-1":
|
|
case "CRAM-MD5":
|
|
case "PLAIN":
|
|
default:
|
|
addErrorf("transport %s: unknown authentication mechanism %s", name, m)
|
|
}
|
|
}
|
|
|
|
t.Auth.EffectiveMechanisms = t.Auth.Mechanisms
|
|
if len(t.Auth.EffectiveMechanisms) == 0 {
|
|
t.Auth.EffectiveMechanisms = []string{"SCRAM-SHA-256-PLUS", "SCRAM-SHA-256", "SCRAM-SHA-1-PLUS", "SCRAM-SHA-1", "CRAM-MD5"}
|
|
}
|
|
}
|
|
|
|
checkTransportSocks := func(name string, t *config.TransportSocks) {
|
|
_, _, err := net.SplitHostPort(t.Address)
|
|
if err != nil {
|
|
addErrorf("transport %s: bad address %s: %v", name, t.Address, err)
|
|
}
|
|
for _, ipstr := range t.RemoteIPs {
|
|
ip := net.ParseIP(ipstr)
|
|
if ip == nil {
|
|
addErrorf("transport %s: bad ip %s", name, ipstr)
|
|
} else {
|
|
t.IPs = append(t.IPs, ip)
|
|
}
|
|
}
|
|
t.Hostname, err = dns.ParseDomain(t.RemoteHostname)
|
|
if err != nil {
|
|
addErrorf("transport %s: bad hostname %s: %v", name, t.RemoteHostname, err)
|
|
}
|
|
}
|
|
|
|
checkTransportDirect := func(name string, t *config.TransportDirect) {
|
|
if t.DisableIPv4 && t.DisableIPv6 {
|
|
addErrorf("transport %s: both IPv4 and IPv6 are disabled, enable at least one", name)
|
|
}
|
|
t.IPFamily = "ip"
|
|
if t.DisableIPv4 {
|
|
t.IPFamily = "ip6"
|
|
}
|
|
if t.DisableIPv6 {
|
|
t.IPFamily = "ip4"
|
|
}
|
|
}
|
|
|
|
for name, t := range c.Transports {
|
|
n := 0
|
|
if t.Submissions != nil {
|
|
n++
|
|
checkTransportSMTP(name, true, t.Submissions)
|
|
}
|
|
if t.Submission != nil {
|
|
n++
|
|
checkTransportSMTP(name, false, t.Submission)
|
|
}
|
|
if t.SMTP != nil {
|
|
n++
|
|
checkTransportSMTP(name, false, t.SMTP)
|
|
}
|
|
if t.Socks != nil {
|
|
n++
|
|
checkTransportSocks(name, t.Socks)
|
|
}
|
|
if t.Direct != nil {
|
|
n++
|
|
checkTransportDirect(name, t.Direct)
|
|
}
|
|
if n > 1 {
|
|
addErrorf("transport %s: cannot have multiple methods in a transport", name)
|
|
}
|
|
}
|
|
|
|
// 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, log mlog.Log, dynamicPath string, static config.Static) (c config.Dynamic, mtime time.Time, accDests map[string]AccountDestination, aliases map[string]config.Alias, 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, aliases, errs = prepareDynamicConfig(ctx, log, dynamicPath, static, &c)
|
|
return c, fi.ModTime(), accDests, aliases, errs
|
|
}
|
|
|
|
func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, static config.Static, c *config.Dynamic) (accDests map[string]AccountDestination, aliases map[string]config.Alias, errs []error) {
|
|
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")
|
|
|
|
accDests = map[string]AccountDestination{}
|
|
aliases = map[string]config.Alias{}
|
|
|
|
// Validate host TLSRPT account/address.
|
|
if static.HostTLSRPT.Account != "" {
|
|
if _, ok := c.Accounts[static.HostTLSRPT.Account]; !ok {
|
|
addErrorf("host tlsrpt account %q does not exist", static.HostTLSRPT.Account)
|
|
}
|
|
checkMailboxNormf(static.HostTLSRPT.Mailbox, "host tlsrpt mailbox")
|
|
|
|
// Localpart has been parsed already.
|
|
|
|
addrFull := smtp.NewAddress(static.HostTLSRPT.ParsedLocalpart, static.HostnameDomain).String()
|
|
dest := config.Destination{
|
|
Mailbox: static.HostTLSRPT.Mailbox,
|
|
HostTLSReports: true,
|
|
}
|
|
accDests[addrFull] = AccountDestination{false, static.HostTLSRPT.ParsedLocalpart, static.HostTLSRPT.Account, dest}
|
|
}
|
|
|
|
var haveSTSListener, haveWebserverListener bool
|
|
for _, l := range static.Listeners {
|
|
if l.MTASTSHTTPS.Enabled {
|
|
haveSTSListener = true
|
|
}
|
|
if l.WebserverHTTP.Enabled || l.WebserverHTTPS.Enabled {
|
|
haveWebserverListener = true
|
|
}
|
|
}
|
|
|
|
checkRoutes := func(descr string, routes []config.Route) {
|
|
parseRouteDomains := func(l []string) []string {
|
|
var r []string
|
|
for _, e := range l {
|
|
if e == "." {
|
|
r = append(r, e)
|
|
continue
|
|
}
|
|
prefix := ""
|
|
if strings.HasPrefix(e, ".") {
|
|
prefix = "."
|
|
e = e[1:]
|
|
}
|
|
d, err := dns.ParseDomain(e)
|
|
if err != nil {
|
|
addErrorf("%s: invalid domain %s: %v", descr, e, err)
|
|
}
|
|
r = append(r, prefix+d.ASCII)
|
|
}
|
|
return r
|
|
}
|
|
|
|
for i := range routes {
|
|
routes[i].FromDomainASCII = parseRouteDomains(routes[i].FromDomain)
|
|
routes[i].ToDomainASCII = parseRouteDomains(routes[i].ToDomain)
|
|
var ok bool
|
|
routes[i].ResolvedTransport, ok = static.Transports[routes[i].Transport]
|
|
if !ok {
|
|
addErrorf("%s: route references undefined transport %s", descr, routes[i].Transport)
|
|
}
|
|
}
|
|
}
|
|
|
|
checkRoutes("global routes", c.Routes)
|
|
|
|
// Validate domains.
|
|
c.ClientSettingDomains = map[dns.Domain]struct{}{}
|
|
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 %s must be specified in unicode form, %s", d, dnsdomain.Name())
|
|
}
|
|
|
|
domain.Domain = dnsdomain
|
|
|
|
if domain.ClientSettingsDomain != "" {
|
|
csd, err := dns.ParseDomain(domain.ClientSettingsDomain)
|
|
if err != nil {
|
|
addErrorf("bad client settings domain %q: %s", domain.ClientSettingsDomain, err)
|
|
}
|
|
domain.ClientSettingsDNSDomain = csd
|
|
c.ClientSettingDomains[csd] = struct{}{}
|
|
}
|
|
|
|
for _, sign := range domain.DKIM.Sign {
|
|
if _, ok := domain.DKIM.Selectors[sign]; !ok {
|
|
addErrorf("selector %s for signing is missing in domain %s", 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 unicode 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 %s", sel.HashEffective, name, d)
|
|
}
|
|
|
|
pemBuf, err := os.ReadFile(configDirPath(dynamicPath, sel.PrivateKeyFile))
|
|
if err != nil {
|
|
addErrorf("reading private key for selector %s in domain %s: %s", name, d, err)
|
|
continue
|
|
}
|
|
p, _ := pem.Decode(pemBuf)
|
|
if p == nil {
|
|
addErrorf("private key for selector %s in domain %s has no PEM block", name, d)
|
|
continue
|
|
}
|
|
key, err := x509.ParsePKCS8PrivateKey(p.Bytes)
|
|
if err != nil {
|
|
addErrorf("parsing private key for selector %s in domain %s: %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
|
|
sel.Algorithm = fmt.Sprintf("rsa-%d", k.N.BitLen())
|
|
case ed25519.PrivateKey:
|
|
if sel.HashEffective != "sha256" {
|
|
addErrorf("hash algorithm %q is not supported with ed25519, only sha256 is", sel.HashEffective)
|
|
}
|
|
sel.Key = k
|
|
sel.Algorithm = "ed25519"
|
|
default:
|
|
addErrorf("private key type %T not yet supported, at selector %s in domain %s", 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)
|
|
}
|
|
}
|
|
|
|
checkRoutes("routes for domain", domain.Routes)
|
|
|
|
c.Domains[d] = domain
|
|
}
|
|
|
|
// To determine ReportsOnly.
|
|
domainHasAddress := map[string]bool{}
|
|
|
|
// Validate email addresses.
|
|
for accName, acc := range c.Accounts {
|
|
var err error
|
|
acc.DNSDomain, err = dns.ParseDomain(acc.Domain)
|
|
if err != nil {
|
|
addErrorf("parsing domain %s 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
|
|
}
|
|
|
|
if acc.JunkFilter != nil {
|
|
params := acc.JunkFilter.Params
|
|
if params.MaxPower < 0 || params.MaxPower > 0.5 {
|
|
addErrorf("junk filter MaxPower must be >= 0 and < 0.5")
|
|
}
|
|
if params.TopWords < 0 {
|
|
addErrorf("junk filter TopWords must be >= 0")
|
|
}
|
|
if params.IgnoreWords < 0 || params.IgnoreWords > 0.5 {
|
|
addErrorf("junk filter IgnoreWords must be >= 0 and < 0.5")
|
|
}
|
|
if params.RareWords < 0 {
|
|
addErrorf("junk filter RareWords must be >= 0")
|
|
}
|
|
}
|
|
|
|
acc.ParsedFromIDLoginAddresses = make([]smtp.Address, len(acc.FromIDLoginAddresses))
|
|
for i, s := range acc.FromIDLoginAddresses {
|
|
a, err := smtp.ParseAddress(s)
|
|
if err != nil {
|
|
addErrorf("invalid fromid login address %q in account %q: %v", s, accName, err)
|
|
}
|
|
// We check later on if address belongs to account.
|
|
dom, ok := c.Domains[a.Domain.Name()]
|
|
if !ok {
|
|
addErrorf("unknown domain in fromid login address %q for account %q", s, accName)
|
|
} else if dom.LocalpartCatchallSeparator == "" {
|
|
addErrorf("localpart catchall separator not configured for domain for fromid login address %q for account %q", s, accName)
|
|
}
|
|
acc.ParsedFromIDLoginAddresses[i] = a
|
|
}
|
|
|
|
// Clear any previously derived state.
|
|
acc.Aliases = nil
|
|
|
|
c.Accounts[accName] = acc
|
|
|
|
if acc.OutgoingWebhook != nil {
|
|
u, err := url.Parse(acc.OutgoingWebhook.URL)
|
|
if err == nil && (u.Scheme != "http" && u.Scheme != "https") {
|
|
err = errors.New("scheme must be http or https")
|
|
}
|
|
if err != nil {
|
|
addErrorf("parsing outgoing hook url %q in account %q: %v", acc.OutgoingWebhook.URL, accName, err)
|
|
}
|
|
|
|
// note: outgoing hook events are in ../queue/hooks.go, ../mox-/config.go, ../queue.go and ../webapi/gendoc.sh. keep in sync.
|
|
outgoingHookEvents := []string{"delivered", "suppressed", "delayed", "failed", "relayed", "expanded", "canceled", "unrecognized"}
|
|
for _, e := range acc.OutgoingWebhook.Events {
|
|
if !slices.Contains(outgoingHookEvents, e) {
|
|
addErrorf("unknown outgoing hook event %q", e)
|
|
}
|
|
}
|
|
}
|
|
if acc.IncomingWebhook != nil {
|
|
u, err := url.Parse(acc.IncomingWebhook.URL)
|
|
if err == nil && (u.Scheme != "http" && u.Scheme != "https") {
|
|
err = errors.New("scheme must be http or https")
|
|
}
|
|
if err != nil {
|
|
addErrorf("parsing incoming hook url %q in account %q: %v", acc.IncomingWebhook.URL, accName, err)
|
|
}
|
|
}
|
|
|
|
// todo deprecated: only localpart as keys for Destinations, we are replacing them with full addresses. if domains.conf is written, we won't have to do this again.
|
|
replaceLocalparts := map[string]string{}
|
|
|
|
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.MsgFromRegexp != "" {
|
|
n++
|
|
r, err := regexp.Compile(rs.MsgFromRegexp)
|
|
if err != nil {
|
|
addErrorf("invalid MsgFrom regular expression: %v", err)
|
|
}
|
|
c.Accounts[accName].Destinations[addrName].Rulesets[i].MsgFromRegexpCompiled = 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.IsForward && rs.ListAllowDomain != "" {
|
|
addErrorf("ruleset cannot have both IsForward and ListAllowDomain")
|
|
}
|
|
if rs.IsForward {
|
|
if rs.SMTPMailFromRegexp == "" || rs.VerifiedDomain == "" {
|
|
addErrorf("ruleset with IsForward must have both SMTPMailFromRegexp and VerifiedDomain too")
|
|
}
|
|
}
|
|
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
|
|
}
|
|
|
|
checkMailboxNormf(rs.AcceptRejectsToMailbox, "account %q, destination %q, ruleset %d, rejects mailbox", accName, addrName, i+1)
|
|
if strings.EqualFold(rs.AcceptRejectsToMailbox, "inbox") {
|
|
addErrorf("account %q, destination %q, ruleset %d: AcceptRejectsToMailbox cannot be set to Inbox", accName, addrName, i+1)
|
|
}
|
|
}
|
|
|
|
// Catchall destination for domain.
|
|
if strings.HasPrefix(addrName, "@") {
|
|
d, err := dns.ParseDomain(addrName[1:])
|
|
if err != nil {
|
|
addErrorf("parsing domain %q in account %q", addrName[1:], accName)
|
|
continue
|
|
} else if _, ok := c.Domains[d.Name()]; !ok {
|
|
addErrorf("unknown domain for address %q in account %q", addrName, accName)
|
|
continue
|
|
}
|
|
domainHasAddress[d.Name()] = true
|
|
addrFull := "@" + d.Name()
|
|
if _, ok := accDests[addrFull]; ok {
|
|
addErrorf("duplicate canonicalized catchall destination address %s", addrFull)
|
|
}
|
|
accDests[addrFull] = AccountDestination{true, "", accName, dest}
|
|
continue
|
|
}
|
|
|
|
// todo deprecated: remove support for parsing destination as just a localpart instead full address.
|
|
var address smtp.Address
|
|
if localpart, err := smtp.ParseLocalpart(addrName); 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 %s for account %q", acc.DNSDomain.Name(), accName)
|
|
continue
|
|
}
|
|
replaceLocalparts[addrName] = address.Pack(true)
|
|
}
|
|
|
|
origLP := address.Localpart
|
|
dc := c.Domains[address.Domain.Name()]
|
|
domainHasAddress[address.Domain.Name()] = true
|
|
lp := CanonicalLocalpart(address.Localpart, dc)
|
|
if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(address.Localpart), dc.LocalpartCatchallSeparator) {
|
|
addErrorf("localpart of address %s includes domain catchall separator %s", address, dc.LocalpartCatchallSeparator)
|
|
} else {
|
|
address.Localpart = lp
|
|
}
|
|
addrFull := address.Pack(true)
|
|
if _, ok := accDests[addrFull]; ok {
|
|
addErrorf("duplicate canonicalized destination address %s", addrFull)
|
|
}
|
|
accDests[addrFull] = AccountDestination{false, origLP, accName, dest}
|
|
}
|
|
|
|
for lp, addr := range replaceLocalparts {
|
|
dest, ok := acc.Destinations[lp]
|
|
if !ok {
|
|
addErrorf("could not find localpart %q to replace with address in destinations", lp)
|
|
} else {
|
|
log.Warn(`deprecation warning: support for account destination addresses specified as just localpart ("username") instead of full email address will be removed in the future; update domains.conf, for each Account, for each Destination, ensure each key is an email address by appending "@" and the default domain for the account`,
|
|
slog.Any("localpart", lp),
|
|
slog.Any("address", addr),
|
|
slog.String("account", accName))
|
|
acc.Destinations[addr] = dest
|
|
delete(acc.Destinations, lp)
|
|
}
|
|
}
|
|
|
|
// Now that all addresses are parsed, check if all fromid login addresses match
|
|
// configured addresses.
|
|
for i, a := range acc.ParsedFromIDLoginAddresses {
|
|
// For domain catchall.
|
|
if _, ok := accDests["@"+a.Domain.Name()]; ok {
|
|
continue
|
|
}
|
|
dc := c.Domains[a.Domain.Name()]
|
|
a.Localpart = CanonicalLocalpart(a.Localpart, dc)
|
|
if _, ok := accDests[a.Pack(true)]; !ok {
|
|
addErrorf("fromid login address %q for account %q does not match its destination addresses", acc.FromIDLoginAddresses[i], accName)
|
|
}
|
|
}
|
|
|
|
checkRoutes("routes for account", acc.Routes)
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
addrdom := domain.Domain
|
|
if dmarc.Domain != "" {
|
|
addrdom, err = dns.ParseDomain(dmarc.Domain)
|
|
if err != nil {
|
|
addErrorf("DMARC domain %q: %s", dmarc.Domain, err)
|
|
} else if _, ok := c.Domains[addrdom.Name()]; !ok {
|
|
addErrorf("unknown domain %q for DMARC address in domain %q", addrdom, d)
|
|
}
|
|
}
|
|
if addrdom == domain.Domain {
|
|
domainHasAddress[addrdom.Name()] = true
|
|
}
|
|
|
|
domain.DMARC.ParsedLocalpart = lp
|
|
domain.DMARC.DNSDomain = addrdom
|
|
c.Domains[d] = domain
|
|
addrFull := smtp.NewAddress(lp, addrdom).String()
|
|
dest := config.Destination{
|
|
Mailbox: dmarc.Mailbox,
|
|
DMARCReports: true,
|
|
}
|
|
checkMailboxNormf(dmarc.Mailbox, "DMARC mailbox for account %q", dmarc.Account)
|
|
accDests[addrFull] = AccountDestination{false, 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)
|
|
}
|
|
addrdom := domain.Domain
|
|
if tlsrpt.Domain != "" {
|
|
addrdom, err = dns.ParseDomain(tlsrpt.Domain)
|
|
if err != nil {
|
|
addErrorf("TLSRPT domain %q: %s", tlsrpt.Domain, err)
|
|
} else if _, ok := c.Domains[addrdom.Name()]; !ok {
|
|
addErrorf("unknown domain %q for TLSRPT address in domain %q", tlsrpt.Domain, d)
|
|
}
|
|
}
|
|
if addrdom == domain.Domain {
|
|
domainHasAddress[addrdom.Name()] = true
|
|
}
|
|
|
|
domain.TLSRPT.ParsedLocalpart = lp
|
|
domain.TLSRPT.DNSDomain = addrdom
|
|
c.Domains[d] = domain
|
|
addrFull := smtp.NewAddress(lp, addrdom).String()
|
|
dest := config.Destination{
|
|
Mailbox: tlsrpt.Mailbox,
|
|
DomainTLSReports: true,
|
|
}
|
|
checkMailboxNormf(tlsrpt.Mailbox, "TLSRPT mailbox for account %q", tlsrpt.Account)
|
|
accDests[addrFull] = AccountDestination{false, lp, tlsrpt.Account, dest}
|
|
}
|
|
|
|
// Set ReportsOnly for domains, based on whether we have seen addresses (possibly
|
|
// from DMARC or TLS reporting).
|
|
for d, domain := range c.Domains {
|
|
domain.ReportsOnly = !domainHasAddress[domain.Domain.Name()]
|
|
c.Domains[d] = domain
|
|
}
|
|
|
|
// Aliases, per domain. Also add references to accounts.
|
|
for d, domain := range c.Domains {
|
|
for lpstr, a := range domain.Aliases {
|
|
var err error
|
|
a.LocalpartStr = lpstr
|
|
var clp smtp.Localpart
|
|
lp, err := smtp.ParseLocalpart(lpstr)
|
|
if err != nil {
|
|
addErrorf("domain %q: parsing localpart %q for alias: %v", d, lpstr, err)
|
|
continue
|
|
} else if domain.LocalpartCatchallSeparator != "" && strings.Contains(string(lp), domain.LocalpartCatchallSeparator) {
|
|
addErrorf("domain %q: alias %q contains localpart catchall separator", d, a.LocalpartStr)
|
|
continue
|
|
} else {
|
|
clp = CanonicalLocalpart(lp, domain)
|
|
}
|
|
|
|
addr := smtp.NewAddress(clp, domain.Domain).Pack(true)
|
|
if _, ok := aliases[addr]; ok {
|
|
addErrorf("domain %q: duplicate alias address %q", d, addr)
|
|
continue
|
|
}
|
|
if _, ok := accDests[addr]; ok {
|
|
addErrorf("domain %q: alias %q already present as regular address", d, addr)
|
|
continue
|
|
}
|
|
if len(a.Addresses) == 0 {
|
|
// Not currently possible, Addresses isn't optional.
|
|
addErrorf("domain %q: alias %q needs at least one destination address", d, addr)
|
|
continue
|
|
}
|
|
a.ParsedAddresses = make([]config.AliasAddress, 0, len(a.Addresses))
|
|
seen := map[string]bool{}
|
|
for _, destAddr := range a.Addresses {
|
|
da, err := smtp.ParseAddress(destAddr)
|
|
if err != nil {
|
|
addErrorf("domain %q: parsing destination address %q in alias %q: %v", d, destAddr, addr, err)
|
|
continue
|
|
}
|
|
dastr := da.Pack(true)
|
|
accDest, ok := accDests[dastr]
|
|
if !ok {
|
|
addErrorf("domain %q: alias %q references non-existent address %q", d, addr, destAddr)
|
|
continue
|
|
}
|
|
if seen[dastr] {
|
|
addErrorf("domain %q: alias %q has duplicate address %q", d, addr, destAddr)
|
|
continue
|
|
}
|
|
seen[dastr] = true
|
|
aa := config.AliasAddress{Address: da, AccountName: accDest.Account, Destination: accDest.Destination}
|
|
a.ParsedAddresses = append(a.ParsedAddresses, aa)
|
|
}
|
|
a.Domain = domain.Domain
|
|
c.Domains[d].Aliases[lpstr] = a
|
|
aliases[addr] = a
|
|
|
|
for _, aa := range a.ParsedAddresses {
|
|
acc := c.Accounts[aa.AccountName]
|
|
var addrs []string
|
|
if a.ListMembers {
|
|
addrs = make([]string, len(a.ParsedAddresses))
|
|
for i := range a.ParsedAddresses {
|
|
addrs[i] = a.ParsedAddresses[i].Address.Pack(true)
|
|
}
|
|
}
|
|
// Keep the non-sensitive fields.
|
|
accAlias := config.Alias{
|
|
PostPublic: a.PostPublic,
|
|
ListMembers: a.ListMembers,
|
|
AllowMsgFrom: a.AllowMsgFrom,
|
|
LocalpartStr: a.LocalpartStr,
|
|
Domain: a.Domain,
|
|
}
|
|
acc.Aliases = append(acc.Aliases, config.AddressAlias{SubscriptionAddress: aa.Address.Pack(true), Alias: accAlias, MemberAddresses: addrs})
|
|
c.Accounts[aa.AccountName] = acc
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check webserver configs.
|
|
if (len(c.WebDomainRedirects) > 0 || len(c.WebHandlers) > 0) && !haveWebserverListener {
|
|
addErrorf("WebDomainRedirects or WebHandlers configured but no listener with WebserverHTTP or WebserverHTTPS enabled")
|
|
}
|
|
|
|
c.WebDNSDomainRedirects = map[dns.Domain]dns.Domain{}
|
|
for from, to := range c.WebDomainRedirects {
|
|
fromdom, err := dns.ParseDomain(from)
|
|
if err != nil {
|
|
addErrorf("parsing domain for redirect %s: %v", from, err)
|
|
}
|
|
todom, err := dns.ParseDomain(to)
|
|
if err != nil {
|
|
addErrorf("parsing domain for redirect %s: %v", to, err)
|
|
} else if fromdom == todom {
|
|
addErrorf("will not redirect domain %s to itself", todom)
|
|
}
|
|
var zerodom dns.Domain
|
|
if _, ok := c.WebDNSDomainRedirects[fromdom]; ok && fromdom != zerodom {
|
|
addErrorf("duplicate redirect domain %s", from)
|
|
}
|
|
c.WebDNSDomainRedirects[fromdom] = todom
|
|
}
|
|
|
|
for i := range c.WebHandlers {
|
|
wh := &c.WebHandlers[i]
|
|
|
|
if wh.LogName == "" {
|
|
wh.Name = fmt.Sprintf("%d", i)
|
|
} else {
|
|
wh.Name = wh.LogName
|
|
}
|
|
|
|
dom, err := dns.ParseDomain(wh.Domain)
|
|
if err != nil {
|
|
addErrorf("webhandler %s %s: parsing domain: %v", wh.Domain, wh.PathRegexp, err)
|
|
}
|
|
wh.DNSDomain = dom
|
|
|
|
if !strings.HasPrefix(wh.PathRegexp, "^") {
|
|
addErrorf("webhandler %s %s: path regexp must start with a ^", wh.Domain, wh.PathRegexp)
|
|
}
|
|
re, err := regexp.Compile(wh.PathRegexp)
|
|
if err != nil {
|
|
addErrorf("webhandler %s %s: compiling regexp: %v", wh.Domain, wh.PathRegexp, err)
|
|
}
|
|
wh.Path = re
|
|
|
|
var n int
|
|
if wh.WebStatic != nil {
|
|
n++
|
|
ws := wh.WebStatic
|
|
if ws.StripPrefix != "" && !strings.HasPrefix(ws.StripPrefix, "/") {
|
|
addErrorf("webstatic %s %s: prefix to strip %s must start with a slash", wh.Domain, wh.PathRegexp, ws.StripPrefix)
|
|
}
|
|
for k := range ws.ResponseHeaders {
|
|
xk := k
|
|
k := strings.TrimSpace(xk)
|
|
if k != xk || k == "" {
|
|
addErrorf("webstatic %s %s: bad header %q", wh.Domain, wh.PathRegexp, xk)
|
|
}
|
|
}
|
|
}
|
|
if wh.WebRedirect != nil {
|
|
n++
|
|
wr := wh.WebRedirect
|
|
if wr.BaseURL != "" {
|
|
u, err := url.Parse(wr.BaseURL)
|
|
if err != nil {
|
|
addErrorf("webredirect %s %s: parsing redirect url %s: %v", wh.Domain, wh.PathRegexp, wr.BaseURL, err)
|
|
}
|
|
switch u.Path {
|
|
case "", "/":
|
|
u.Path = "/"
|
|
default:
|
|
addErrorf("webredirect %s %s: BaseURL must have empty path", wh.Domain, wh.PathRegexp, wr.BaseURL)
|
|
}
|
|
wr.URL = u
|
|
}
|
|
if wr.OrigPathRegexp != "" && wr.ReplacePath != "" {
|
|
re, err := regexp.Compile(wr.OrigPathRegexp)
|
|
if err != nil {
|
|
addErrorf("webredirect %s %s: compiling regexp %s: %v", wh.Domain, wh.PathRegexp, wr.OrigPathRegexp, err)
|
|
}
|
|
wr.OrigPath = re
|
|
} else if wr.OrigPathRegexp != "" || wr.ReplacePath != "" {
|
|
addErrorf("webredirect %s %s: must have either both OrigPathRegexp and ReplacePath, or neither", wh.Domain, wh.PathRegexp)
|
|
} else if wr.BaseURL == "" {
|
|
addErrorf("webredirect %s %s: must at least one of BaseURL and OrigPathRegexp+ReplacePath", wh.Domain, wh.PathRegexp)
|
|
}
|
|
if wr.StatusCode != 0 && (wr.StatusCode < 300 || wr.StatusCode >= 400) {
|
|
addErrorf("webredirect %s %s: invalid redirect status code %d", wh.Domain, wh.PathRegexp, wr.StatusCode)
|
|
}
|
|
}
|
|
if wh.WebForward != nil {
|
|
n++
|
|
wf := wh.WebForward
|
|
u, err := url.Parse(wf.URL)
|
|
if err != nil {
|
|
addErrorf("webforward %s %s: parsing url %s: %v", wh.Domain, wh.PathRegexp, wf.URL, err)
|
|
}
|
|
wf.TargetURL = u
|
|
|
|
for k := range wf.ResponseHeaders {
|
|
xk := k
|
|
k := strings.TrimSpace(xk)
|
|
if k != xk || k == "" {
|
|
addErrorf("webforward %s %s: bad header %q", wh.Domain, wh.PathRegexp, xk)
|
|
}
|
|
}
|
|
}
|
|
if wh.WebInternal != nil {
|
|
n++
|
|
wi := wh.WebInternal
|
|
if !strings.HasPrefix(wi.BasePath, "/") || !strings.HasSuffix(wi.BasePath, "/") {
|
|
addErrorf("webinternal %s %s: base path %q must start and end with /", wh.Domain, wh.PathRegexp, wi.BasePath)
|
|
}
|
|
// todo: we could make maxMsgSize and accountPath configurable
|
|
const isForwarded = false
|
|
switch wi.Service {
|
|
case "admin":
|
|
wi.Handler = NewWebadminHandler(wi.BasePath, isForwarded)
|
|
case "account":
|
|
wi.Handler = NewWebaccountHandler(wi.BasePath, isForwarded)
|
|
case "webmail":
|
|
accountPath := ""
|
|
wi.Handler = NewWebmailHandler(config.DefaultMaxMsgSize, wi.BasePath, isForwarded, accountPath)
|
|
case "webapi":
|
|
wi.Handler = NewWebapiHandler(config.DefaultMaxMsgSize, wi.BasePath, isForwarded)
|
|
default:
|
|
addErrorf("webinternal %s %s: unknown service %q", wh.Domain, wh.PathRegexp, wi.Service)
|
|
}
|
|
wi.Handler = SafeHeaders(http.StripPrefix(wi.BasePath[:len(wi.BasePath)-1], wi.Handler))
|
|
}
|
|
if n != 1 {
|
|
addErrorf("webhandler %s %s: must have exactly one handler, not %d", wh.Domain, wh.PathRegexp, n)
|
|
}
|
|
}
|
|
|
|
c.MonitorDNSBLZones = nil
|
|
for _, s := range c.MonitorDNSBLs {
|
|
d, err := dns.ParseDomain(s)
|
|
if err != nil {
|
|
addErrorf("invalid monitor dnsbl zone %s: %v", s, err)
|
|
continue
|
|
}
|
|
if slices.Contains(c.MonitorDNSBLZones, d) {
|
|
addErrorf("duplicate zone %s in monitor dnsbl zones", d)
|
|
continue
|
|
}
|
|
c.MonitorDNSBLZones = append(c.MonitorDNSBLZones, d)
|
|
}
|
|
|
|
return
|
|
}
|
|
|
|
func loadPrivateKeyFile(keyPath string) (crypto.Signer, error) {
|
|
keyBuf, err := os.ReadFile(keyPath)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("reading host private key: %v", err)
|
|
}
|
|
b, _ := pem.Decode(keyBuf)
|
|
if b == nil {
|
|
return nil, fmt.Errorf("parsing pem block for private key: %v", err)
|
|
}
|
|
var privKey any
|
|
switch b.Type {
|
|
case "PRIVATE KEY":
|
|
privKey, err = x509.ParsePKCS8PrivateKey(b.Bytes)
|
|
case "RSA PRIVATE KEY":
|
|
privKey, err = x509.ParsePKCS1PrivateKey(b.Bytes)
|
|
case "EC PRIVATE KEY":
|
|
privKey, err = x509.ParseECPrivateKey(b.Bytes)
|
|
default:
|
|
err = fmt.Errorf("unknown pem type %q", b.Type)
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parsing private key: %v", err)
|
|
}
|
|
if k, ok := privKey.(crypto.Signer); ok {
|
|
return k, nil
|
|
}
|
|
return nil, fmt.Errorf("parsed private key not a crypto.Signer, but %T", privKey)
|
|
}
|
|
|
|
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 := loadX509KeyPairPrivileged(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,
|
|
}
|
|
ctls.ConfigFallback = ctls.Config
|
|
return nil
|
|
}
|
|
|
|
// load x509 key/cert files from file descriptor possibly passed in by privileged
|
|
// process.
|
|
func loadX509KeyPairPrivileged(certPath, keyPath string) (tls.Certificate, error) {
|
|
certBuf, err := readFilePrivileged(certPath)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("reading tls certificate: %v", err)
|
|
}
|
|
keyBuf, err := readFilePrivileged(keyPath)
|
|
if err != nil {
|
|
return tls.Certificate{}, fmt.Errorf("reading tls key: %v", err)
|
|
}
|
|
return tls.X509KeyPair(certBuf, keyBuf)
|
|
}
|
|
|
|
// like os.ReadFile, but open privileged file possibly passed in by root process.
|
|
func readFilePrivileged(path string) ([]byte, error) {
|
|
f, err := OpenPrivileged(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer f.Close()
|
|
return io.ReadAll(f)
|
|
}
|