This commit is contained in:
Mechiel Lukkien
2023-01-30 14:27:06 +01:00
commit cb229cb6cf
1256 changed files with 491723 additions and 0 deletions

824
mox-/admin.go Normal file
View File

@ -0,0 +1,824 @@
package mox
import (
"bytes"
"context"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"net"
"os"
"path/filepath"
"sort"
"time"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/smtp"
)
// TXTStrings returns a TXT record value as one or more quoted strings, taking the max
// length of 255 characters for a string into account.
func TXTStrings(s string) string {
r := ""
for len(s) > 0 {
n := len(s)
if n > 255 {
n = 255
}
if r != "" {
r += " "
}
r += `"` + s[:n] + `"`
s = s[n:]
}
return r
}
// MakeDKIMEd25519Key returns a PEM buffer containing an ed25519 key for use
// with DKIM.
// selector and domain can be empty. If not, they are used in the note.
func MakeDKIMEd25519Key(selector, domain dns.Domain) ([]byte, error) {
_, privKey, err := ed25519.GenerateKey(cryptorand.Reader)
if err != nil {
return nil, fmt.Errorf("generating key: %w", err)
}
pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
if err != nil {
return nil, fmt.Errorf("marshal key: %w", err)
}
block := &pem.Block{
Type: "PRIVATE KEY",
Headers: map[string]string{
"Note": dkimKeyNote("ed25519", selector, domain),
},
Bytes: pkcs8,
}
b := &bytes.Buffer{}
if err := pem.Encode(b, block); err != nil {
return nil, fmt.Errorf("encoding pem: %w", err)
}
return b.Bytes(), nil
}
func dkimKeyNote(kind string, selector, domain dns.Domain) string {
s := kind + " dkim private key"
var zero dns.Domain
if selector != zero && domain != zero {
s += fmt.Sprintf(" for %s._domainkey.%s", selector.ASCII, domain.ASCII)
}
s += fmt.Sprintf(", generated by mox on %s", time.Now().Format(time.RFC3339))
return s
}
// MakeDKIMEd25519Key returns a PEM buffer containing an rsa key for use with
// DKIM.
// selector and domain can be empty. If not, they are used in the note.
func MakeDKIMRSAKey(selector, domain dns.Domain) ([]byte, error) {
// 2048 bits seems reasonable in 2022, 1024 is on the low side, larger
// keys may not fit in UDP DNS response.
privKey, err := rsa.GenerateKey(cryptorand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("generating key: %w", err)
}
pkcs8, err := x509.MarshalPKCS8PrivateKey(privKey)
if err != nil {
return nil, fmt.Errorf("marshal key: %w", err)
}
block := &pem.Block{
Type: "PRIVATE KEY",
Headers: map[string]string{
"Note": dkimKeyNote("rsa", selector, domain),
},
Bytes: pkcs8,
}
b := &bytes.Buffer{}
if err := pem.Encode(b, block); err != nil {
return nil, fmt.Errorf("encoding pem: %w", err)
}
return b.Bytes(), nil
}
// MakeAccountConfig returns a new account configuration for an email address.
func MakeAccountConfig(addr smtp.Address) config.Account {
account := config.Account{
Domain: addr.Domain.Name(),
Destinations: map[string]config.Destination{
addr.Localpart.String(): {},
},
RejectsMailbox: "Rejects",
JunkFilter: &config.JunkFilter{
Threshold: 0.95,
Params: junk.Params{
Onegrams: true,
MaxPower: .01,
TopWords: 10,
IgnoreWords: .1,
RareWords: 2,
},
},
}
account.SubjectPass.Period = 12 * time.Hour
return account
}
// MakeDomainConfig makes a new config for a domain, creating DKIM keys, using
// accountName for DMARC and TLS reports.
func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountName string) (config.Domain, []string, error) {
log := xlog.WithContext(ctx)
now := time.Now()
year := now.Format("2006")
timestamp := now.Format("20060102T150405")
var paths []string
defer func() {
for _, p := range paths {
if err := os.Remove(p); err != nil {
log.Errorx("removing path for domain config", err, mlog.Field("path", p))
}
}
}()
writeFile := func(path string, data []byte) error {
os.MkdirAll(filepath.Dir(path), 0770)
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0660)
if err != nil {
return fmt.Errorf("creating file %s: %s", path, err)
}
defer func() {
if f != nil {
os.Remove(path)
f.Close()
}
}()
if _, err := f.Write(data); err != nil {
return fmt.Errorf("writing file %s: %s", path, err)
}
if err := f.Close(); err != nil {
return fmt.Errorf("close file: %v", err)
}
f = nil
return nil
}
confDKIM := config.DKIM{
Selectors: map[string]config.Selector{},
}
addSelector := func(kind, name string, privKey []byte) error {
record := fmt.Sprintf("%s._domainkey.%s", name, domain.ASCII)
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%skey.pkcs8.pem", record, timestamp, kind))
p := ConfigDirPath(keyPath)
if err := writeFile(p, privKey); err != nil {
return err
}
paths = append(paths, p)
confDKIM.Selectors[name] = config.Selector{
// Example from RFC has 5 day between signing and expiration. ../rfc/6376:1393
// Expiration is not intended as antireplay defense, but it may help. ../rfc/6376:1340
// Messages in the wild have been observed with 2 hours and 1 year expiration.
Expiration: "72h",
PrivateKeyFile: keyPath,
}
return nil
}
addEd25519 := func(name string) error {
key, err := MakeDKIMEd25519Key(dns.Domain{ASCII: name}, domain)
if err != nil {
return fmt.Errorf("making dkim ed25519 private key: %s", err)
}
return addSelector("ed25519", name, key)
}
addRSA := func(name string) error {
key, err := MakeDKIMRSAKey(dns.Domain{ASCII: name}, domain)
if err != nil {
return fmt.Errorf("making dkim rsa private key: %s", err)
}
return addSelector("rsa", name, key)
}
if err := addEd25519(year + "a"); err != nil {
return config.Domain{}, nil, err
}
if err := addRSA(year + "b"); err != nil {
return config.Domain{}, nil, err
}
if err := addEd25519(year + "c"); err != nil {
return config.Domain{}, nil, err
}
if err := addRSA(year + "d"); err != nil {
return config.Domain{}, nil, err
}
// We sign with the first two. In case they are misused, the switch to the other
// keys is easy, just change the config. Operators should make the public key field
// of the misused keys empty in the DNS records to disable the misused keys.
confDKIM.Sign = []string{year + "a", year + "b"}
confDomain := config.Domain{
LocalpartCatchallSeparator: "+",
DKIM: confDKIM,
DMARC: &config.DMARC{
Account: accountName,
Localpart: "dmarc-reports",
Mailbox: "DMARC",
},
MTASTS: &config.MTASTS{
PolicyID: time.Now().UTC().Format("20060102T150405"),
Mode: mtasts.ModeEnforce,
// We start out with 24 hour, and warn in the admin interface that users should
// increase it to weeks. Once the setup works.
MaxAge: 24 * time.Hour,
MX: []string{hostname.ASCII},
},
TLSRPT: &config.TLSRPT{
Account: accountName,
Localpart: "tls-reports",
Mailbox: "TLSRPT",
},
}
rpaths := paths
paths = nil
return confDomain, rpaths, nil
}
// DomainAdd adds the domain to the domains config, rewriting domains.conf and
// marking it loaded.
//
// accountName is used for DMARC/TLS report.
// If the account does not exist, it is created with localpart. Localpart must be
// set only if the account does not yet exist.
func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, localpart smtp.Localpart) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("adding domain", rerr, mlog.Field("domain", domain), mlog.Field("account", accountName), mlog.Field("localpart", localpart))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
if _, ok := c.Domains[domain.Name()]; ok {
return fmt.Errorf("domain already present")
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.Domains = map[string]config.Domain{}
for name, d := range c.Domains {
nc.Domains[name] = d
}
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName)
if err != nil {
return fmt.Errorf("preparing domain config: %v", err)
}
defer func() {
for _, f := range cleanupFiles {
if err := os.Remove(f); err != nil {
log.Errorx("cleaning up file after error", err, mlog.Field("path", f))
}
}
}()
if _, ok := c.Accounts[accountName]; ok && localpart != "" {
return fmt.Errorf("account already exists (leave localpart empty when using an existing account)")
} else if !ok && localpart == "" {
return fmt.Errorf("account does not yet exist (specify a localpart)")
} else if accountName == "" {
return fmt.Errorf("account name is empty")
} else if !ok {
nc.Accounts[accountName] = MakeAccountConfig(smtp.Address{Localpart: localpart, Domain: domain})
}
nc.Domains[domain.Name()] = confDomain
if err := writeDynamic(ctx, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("domain added", mlog.Field("domain", domain))
cleanupFiles = nil // All good, don't cleanup.
return nil
}
// DomainRemove removes domain from the config, rewriting domains.conf.
//
// No accounts are removed, also not when they still reference this domain.
func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("removing domain", rerr, mlog.Field("domain", domain))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
domConf, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("domain does not exist")
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.Domains = map[string]config.Domain{}
s := domain.Name()
for name, d := range c.Domains {
if name != s {
nc.Domains[name] = d
}
}
if err := writeDynamic(ctx, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
// Move away any DKIM private keys to a subdirectory "old". But only if
// they are not in use by other domains.
usedKeyPaths := map[string]bool{}
for _, dc := range nc.Domains {
for _, sel := range dc.DKIM.Selectors {
usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] = true
}
}
for _, sel := range domConf.DKIM.Selectors {
if sel.PrivateKeyFile == "" || usedKeyPaths[filepath.Clean(sel.PrivateKeyFile)] {
continue
}
src := ConfigDirPath(sel.PrivateKeyFile)
dst := ConfigDirPath(filepath.Join(filepath.Dir(sel.PrivateKeyFile), "old", filepath.Base(sel.PrivateKeyFile)))
_, err := os.Stat(dst)
if err == nil {
err = fmt.Errorf("destination already exists")
} else if os.IsNotExist(err) {
os.MkdirAll(filepath.Dir(dst), 0770)
err = os.Rename(src, dst)
}
if err != nil {
log.Errorx("renaming dkim private key file for removed domain", err, mlog.Field("src", src), mlog.Field("dst", dst))
}
}
log.Info("domain removed", mlog.Field("domain", domain))
return nil
}
// todo: find a way to automatically create the dns records as it would greatly simplify setting up email for a domain. we could also dynamically make changes, e.g. providing grace periods after disabling a dkim key, only automatically removing the dkim dns key after a few days. but this requires some kind of api and authentication to the dns server. there doesn't appear to be a single commonly used api for dns management. each of the numerous cloud providers have their own APIs and rather large SKDs to use them. we don't want to link all of them in.
// DomainRecords returns text lines describing DNS records required for configuring
// a domain.
func DomainRecords(domConf config.Domain, domain dns.Domain) ([]string, error) {
d := domain.ASCII
h := Conf.Static.HostnameDomain.ASCII
records := []string{
"; Time To Live, may be recognized if importing as a zone file.",
"$TTL 300",
"",
"; Deliver email to this host.",
fmt.Sprintf("%s. MX 10 %s.", d, h),
"",
"; Outgoing messages will be signed with the first two DKIM keys. The other two",
"; configured for backup, switching to them is just a config change.",
}
var selectors []string
for name := range domConf.DKIM.Selectors {
selectors = append(selectors, name)
}
sort.Slice(selectors, func(i, j int) bool {
return selectors[i] < selectors[j]
})
for _, name := range selectors {
sel := domConf.DKIM.Selectors[name]
dkimr := dkim.Record{
Version: "DKIM1",
Hashes: []string{"sha256"},
PublicKey: sel.Key.Public(),
}
if _, ok := sel.Key.(ed25519.PrivateKey); ok {
dkimr.Key = "ed25519"
} else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
}
txt, err := dkimr.Record()
if err != nil {
return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
}
if len(txt) > 255 {
records = append(records,
"; NOTE: Ensure the next record is added in DNS as a single record, it consists",
"; of multiple strings (max size of each is 255 bytes).",
)
}
s := fmt.Sprintf("%s._domainkey.%s. IN TXT %s", name, d, TXTStrings(txt))
records = append(records, s)
}
records = append(records,
"",
"; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
"; ~all means softfail for anything else, which is done instead of -all to prevent older",
"; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
fmt.Sprintf(`%s. IN TXT "v=spf1 mx ~all"`, d),
"; The next record may already exist if you have more domains configured.",
fmt.Sprintf(`%-*s IN TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
"",
"; Emails that fail the DMARC check (without DKIM and without SPF) should be rejected, and request reports.",
"; If you email through mailing lists that strip DKIM-Signature headers and don't",
"; rewrite the From header, you may want to set the policy to p=none.",
fmt.Sprintf(`_dmarc.%s. IN TXT "v=DMARC1; p=reject; rua=mailto:dmarc-reports@%s!10m"`, d, d),
"",
)
if sts := domConf.MTASTS; sts != nil {
records = append(records,
"; TLS must be used when delivering to us.",
fmt.Sprintf(`mta-sts.%s. IN CNAME %s.`, d, h),
fmt.Sprintf(`_mta-sts.%s. IN TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
"",
)
}
records = append(records,
"; Request reporting about TLS failures.",
fmt.Sprintf(`_smtp._tls.%s. IN TXT "v=TLSRPTv1; rua=mailto:tls-reports@%s"`, d, d),
"",
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
fmt.Sprintf(`autoconfig.%s. IN CNAME %s.`, d, h),
fmt.Sprintf(`_autodiscover._tcp.%s. IN SRV 0 1 443 autoconfig.%s.`, d, d),
"",
// ../rfc/6186:133 ../rfc/8314:692
"; For secure IMAP and submission autoconfig, point to mail host.",
fmt.Sprintf(`_imaps._tcp.%s. IN SRV 0 1 993 %s.`, d, h),
fmt.Sprintf(`_submissions._tcp.%s. IN SRV 0 1 465 %s.`, d, h),
"",
// ../rfc/6186:242
"; Next records specify POP3 and plain text ports are not to be used.",
fmt.Sprintf(`_imap._tcp.%s. IN SRV 0 1 143 .`, d),
fmt.Sprintf(`_submission._tcp.%s. IN SRV 0 1 587 .`, d),
fmt.Sprintf(`_pop3._tcp.%s. IN SRV 0 1 110 .`, d),
fmt.Sprintf(`_pop3s._tcp.%s. IN SRV 0 1 995 .`, d),
"",
"; Optional:",
"; You could mark Let's Encrypt as the only Certificate Authority allowed to",
"; sign TLS certificates for your domain.",
fmt.Sprintf("%s. IN CAA 0 issue \"letsencrypt.org\"", d),
)
return records, nil
}
// AccountAdd adds an account and an initial address and reloads the
// configuration.
//
// The new account does not have a password, so cannot log in. Email can be
// delivered.
func AccountAdd(ctx context.Context, account, address string) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("adding account", rerr, mlog.Field("account", account), mlog.Field("address", address))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
if _, ok := c.Accounts[account]; ok {
return fmt.Errorf("account already present")
}
addr, err := smtp.ParseAddress(address)
if err != nil {
return fmt.Errorf("parsing email address: %v", err)
}
if _, ok := Conf.accountDestinations[addr.String()]; ok {
return fmt.Errorf("address already exists")
}
dname := addr.Domain.Name()
if _, ok := c.Domains[dname]; !ok {
return fmt.Errorf("domain does not exist")
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.Accounts = map[string]config.Account{}
for name, a := range c.Accounts {
nc.Accounts[name] = a
}
nc.Accounts[account] = MakeAccountConfig(addr)
if err := writeDynamic(ctx, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("account added", mlog.Field("account", account), mlog.Field("address", addr))
return nil
}
// AccountRemove removes an account and reloads the configuration.
func AccountRemove(ctx context.Context, account string) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("adding account", rerr, mlog.Field("account", account))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
if _, ok := c.Accounts[account]; !ok {
return fmt.Errorf("account does not exist")
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.Accounts = map[string]config.Account{}
for name, a := range c.Accounts {
if name != account {
nc.Accounts[name] = a
}
}
if err := writeDynamic(ctx, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("account removed", mlog.Field("account", account))
return nil
}
// AddressAdd adds an email address to an account and reloads the
// configuration.
func AddressAdd(ctx context.Context, address, account string) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("adding address", rerr, mlog.Field("address", address), mlog.Field("account", account))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
a, ok := c.Accounts[account]
if !ok {
return fmt.Errorf("account does not exist")
}
addr, err := smtp.ParseAddress(address)
if err != nil {
return fmt.Errorf("parsing email address: %v", err)
}
if _, ok := Conf.accountDestinations[addr.String()]; ok {
return fmt.Errorf("address already exists")
}
dname := addr.Domain.Name()
if _, ok := c.Domains[dname]; !ok {
return fmt.Errorf("domain does not exist")
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc := c
nc.Accounts = map[string]config.Account{}
for name, a := range c.Accounts {
nc.Accounts[name] = a
}
nd := map[string]config.Destination{}
for name, d := range a.Destinations {
nd[name] = d
}
var k string
if addr.Domain == a.DNSDomain {
k = addr.Localpart.String()
} else {
k = addr.String()
}
nd[k] = config.Destination{}
a.Destinations = nd
nc.Accounts[account] = a
if err := writeDynamic(ctx, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("address added", mlog.Field("address", addr), mlog.Field("account", account))
return nil
}
// AddressRemove removes an email address and reloads the configuration.
func AddressRemove(ctx context.Context, address string) (rerr error) {
log := xlog.WithContext(ctx)
defer func() {
if rerr != nil {
log.Errorx("removing address", rerr, mlog.Field("address", address))
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
c := Conf.Dynamic
addr, err := smtp.ParseAddress(address)
if err != nil {
return fmt.Errorf("parsing email address: %v", err)
}
ad, ok := Conf.accountDestinations[addr.String()]
if !ok {
return fmt.Errorf("address does not exists")
}
addrStr := addr.String()
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
a, ok := c.Accounts[ad.Account]
if !ok {
return fmt.Errorf("internal error: cannot find account")
}
na := a
na.Destinations = map[string]config.Destination{}
var dropped bool
for name, d := range a.Destinations {
if !(name == addr.Localpart.String() && a.DNSDomain == addr.Domain || name == addrStr) {
na.Destinations[name] = d
} else {
dropped = true
}
}
if !dropped {
return fmt.Errorf("address not removed, likely a postmaster/reporting address")
}
nc := c
nc.Accounts = map[string]config.Account{}
for name, a := range c.Accounts {
nc.Accounts[name] = a
}
nc.Accounts[ad.Account] = na
if err := writeDynamic(ctx, nc); err != nil {
return fmt.Errorf("writing domains.conf: %v", err)
}
log.Info("address removed", mlog.Field("address", addr), mlog.Field("account", ad.Account))
return nil
}
// ClientConfig holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfig struct {
Entries []ClientConfigEntry
}
type ClientConfigEntry struct {
Protocol string
Host dns.Domain
Port int
Listener string
Note string
}
// ClientConfigDomain returns the client config for IMAP/Submission for a
// domain.
func ClientConfigDomain(d dns.Domain) (ClientConfig, error) {
_, ok := Conf.Domain(d)
if !ok {
return ClientConfig{}, fmt.Errorf("unknown domain")
}
c := ClientConfig{}
c.Entries = []ClientConfigEntry{}
var listeners []string
for name := range Conf.Static.Listeners {
listeners = append(listeners, name)
}
sort.Slice(listeners, func(i, j int) bool {
return listeners[i] < listeners[j]
})
note := func(tls bool, requiretls bool) string {
if !tls {
return "plain text, no STARTTLS configured"
}
if requiretls {
return "STARTTLS required"
}
return "STARTTLS optional"
}
for _, name := range listeners {
l := Conf.Static.Listeners[name]
host := Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if l.Submissions.Enabled {
c.Entries = append(c.Entries, ClientConfigEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
}
if l.IMAPS.Enabled {
c.Entries = append(c.Entries, ClientConfigEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
}
if l.Submission.Enabled {
c.Entries = append(c.Entries, ClientConfigEntry{"Submission (SMTP)", host, config.Port(l.Submission.Port, 587), name, note(l.TLS != nil, !l.Submission.NoRequireSTARTTLS)})
}
if l.IMAP.Enabled {
c.Entries = append(c.Entries, ClientConfigEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
}
}
return c, nil
}
// return IPs we may be listening on or connecting from to the outside.
func IPs(ctx context.Context) ([]net.IP, error) {
log := xlog.WithContext(ctx)
// Try to gather all IPs we are listening on by going through the config.
// If we encounter 0.0.0.0 or ::, we'll gather all local IPs afterwards.
var ips []net.IP
var ipv4all, ipv6all bool
for _, l := range Conf.Static.Listeners {
for _, s := range l.IPs {
ip := net.ParseIP(s)
if ip.IsUnspecified() {
if ip.To4() != nil {
ipv4all = true
} else {
ipv6all = true
}
continue
}
ips = append(ips, ip)
}
}
// We'll list the IPs on the interfaces. How useful is this? There is a good chance
// we're listening on all addresses because of a load balancing/firewall.
if ipv4all || ipv6all {
ifaces, err := net.Interfaces()
if err != nil {
return nil, fmt.Errorf("listing network interfaces: %v", err)
}
for _, iface := range ifaces {
if iface.Flags&net.FlagUp == 0 {
continue
}
addrs, err := iface.Addrs()
if err != nil {
return nil, fmt.Errorf("listing addresses for network interface: %v", err)
}
if len(addrs) == 0 {
continue
}
for _, addr := range addrs {
ip, _, err := net.ParseCIDR(addr.String())
if err != nil {
log.Errorx("bad interface addr", err, mlog.Field("address", addr))
continue
}
v4 := ip.To4() != nil
if ipv4all && v4 || ipv6all && !v4 {
ips = append(ips, ip)
}
}
}
}
return ips, nil
}

17
mox-/cid.go Normal file
View File

@ -0,0 +1,17 @@
package mox
import (
"sync/atomic"
"time"
)
var cid atomic.Int64
func init() {
cid.Store(time.Now().UnixMilli())
}
// Cid returns a new unique id to be used for connections/sessions/requests.
func Cid() int64 {
return cid.Add(1)
}

888
mox-/config.go Normal file
View File

@ -0,0 +1,888 @@
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
}
// SetLogLevel 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) SetLogLevel(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)
}
// 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 {
d, err := dns.ParseDomain("autoconfig." + dom.Domain.ASCII)
if err != nil {
xlog.Errorx("parsing autoconfig domain", err, mlog.Field("domain", dom.Domain))
continue
}
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.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)
}
c.Accounts[accName] = acc
if strings.EqualFold(acc.RejectsMailbox, "Inbox") {
addErrorf("account %q: cannot set RejectsMailbox to inbox", accName)
}
checkMailboxNormf(acc.RejectsMailbox, "account %q", accName)
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
}

36
mox-/dir.go Normal file
View File

@ -0,0 +1,36 @@
package mox
import (
"path/filepath"
)
// ConfigDirPath returns the path to "f". Either f itself when absolute, or
// interpreted relative to the directory of the current config file.
func ConfigDirPath(f string) string {
return configDirPath(ConfigStaticPath, f)
}
// DataDirPath returns to the path to "f". Either f itself when absolute, or
// interpreted relative to the data directory from the currently active
// configuration.
func DataDirPath(f string) string {
return dataDirPath(ConfigStaticPath, Conf.Static.DataDir, f)
}
// return f interpreted relative to the directory of the config dir. f is returned
// unchanged when absolute.
func configDirPath(configFile, f string) string {
if filepath.IsAbs(f) {
return f
}
return filepath.Join(filepath.Dir(configFile), f)
}
// return f interpreted relative to the data directory that is interpreted relative
// to the directory of the config dir. f is returned unchanged when absolute.
func dataDirPath(configFile, dataDir, f string) string {
if filepath.IsAbs(f) {
return f
}
return filepath.Join(configDirPath(configFile, dataDir), f)
}

3
mox-/doc.go Normal file
View File

@ -0,0 +1,3 @@
// Package mox provides functions dealing with global state, such as the
// current configuration, and convenience functions.
package mox

21
mox-/ip.go Normal file
View File

@ -0,0 +1,21 @@
package mox
import (
"net"
)
// Network returns tcp4 or tcp6, depending on the ip.
// This network can be passed to Listen instead of "tcp", which may start listening
// on both ipv4 and ipv6 for addresses 0.0.0.0 and ::, which can lead to errors
// about the port already being in use.
// For invalid IPs, "tcp" is returned.
func Network(ip string) string {
v := net.ParseIP(ip)
if v == nil {
return "tcp"
}
if v.To4() != nil {
return "tcp4"
}
return "tcp6"
}

51
mox-/lastknown.go Normal file
View File

@ -0,0 +1,51 @@
package mox
import (
"fmt"
"os"
"strings"
"time"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/updates"
)
// StoreLastKnown stores the the last known version. Future update checks compare
// against it, or the currently running version, whichever is newer.
func StoreLastKnown(v updates.Version) error {
return os.WriteFile(DataDirPath("lastknownversion"), []byte(v.String()), 0660)
}
// LastKnown returns the last known version that has been mentioned in an update
// email, or the current application.
func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) {
curv, curerr := updates.ParseVersion(moxvar.Version)
p := DataDirPath("lastknownversion")
fi, _ := os.Stat(p)
if fi != nil {
mtime = fi.ModTime()
}
vbuf, err := os.ReadFile(p)
if err != nil && !os.IsNotExist(err) {
return curv, updates.Version{}, mtime, err
}
lastknown, lasterr := updates.ParseVersion(strings.TrimSpace(string(vbuf)))
if curerr == nil && lasterr == nil {
if curv.After(lastknown) {
return curv, curv, mtime, nil
}
return curv, lastknown, mtime, nil
} else if curerr == nil {
return curv, curv, mtime, nil
} else if lasterr == nil {
return curv, lastknown, mtime, nil
}
if moxvar.Version == "(devel)" {
return curv, updates.Version{}, mtime, fmt.Errorf("development version")
}
return curv, updates.Version{}, mtime, fmt.Errorf("parsing version: %w", err)
}

147
mox-/lifecycle.go Normal file
View File

@ -0,0 +1,147 @@
package mox
import (
"context"
"net"
"runtime/debug"
"sync"
"time"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promauto"
)
// Shutdown is closed when a graceful shutdown is initiated. SMTP, IMAP, periodic
// processes should check this before starting a new operation. If true, the
// operation should be aborted, and new connections should receive a message that
// the service is currently not available.
var Shutdown chan struct{}
// Context should be used as parent by all operations. It is canceled when mox is
// shutdown, aborting all pending operations.
//
// Operations typically have context timeouts, 30s for single i/o like DNS queries,
// and 1 minute for operations with more back and forth. These are set through a
// context.WithTimeout based on this context, so those contexts are still canceled
// when shutting down.
//
// Explicit read/write deadlines on connections, typically 30s.
//
// HTTP servers don't get graceful shutdown, their connections are just aborted.
var Context context.Context
// Connections holds all active protocol sockets (smtp, imap). They will be given
// an immediate read/write deadline shortly after initiating mox shutdown, after
// which the connections get 1 more second for error handling before actual
// shutdown.
var Connections = &connections{
conns: map[net.Conn]connKind{},
gauges: map[connKind]prometheus.GaugeFunc{},
active: map[connKind]int64{},
}
type connKind struct {
protocol string
listener string
}
type connections struct {
sync.Mutex
conns map[net.Conn]connKind
dones []chan struct{}
gauges map[connKind]prometheus.GaugeFunc
activeMutex sync.Mutex
active map[connKind]int64
}
// Register adds a connection for receiving an immediate i/o deadline on shutdown.
// When the connection is closed, Remove must be called to cancel the registration.
func (c *connections) Register(nc net.Conn, protocol, listener string) {
// This can happen, when a connection was initiated before a shutdown, but it
// doesn't hurt to log it.
select {
case <-Shutdown:
xlog.Error("new connection added while shutting down")
debug.PrintStack()
default:
}
ck := connKind{protocol, listener}
c.activeMutex.Lock()
c.active[ck]++
c.activeMutex.Unlock()
c.Lock()
defer c.Unlock()
c.conns[nc] = ck
if _, ok := c.gauges[ck]; !ok {
c.gauges[ck] = promauto.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "mox_connections_count",
Help: "Open connections, per protocol/listener.",
ConstLabels: prometheus.Labels{
"protocol": protocol,
"listener": listener,
},
},
func() float64 {
c.activeMutex.Lock()
defer c.activeMutex.Unlock()
return float64(c.active[ck])
},
)
}
}
// Unregister removes a connection for shutdown.
func (c *connections) Unregister(nc net.Conn) {
c.Lock()
defer c.Unlock()
ck := c.conns[nc]
defer func() {
c.activeMutex.Lock()
c.active[ck]--
c.activeMutex.Unlock()
}()
delete(c.conns, nc)
if len(c.conns) > 0 {
return
}
for _, done := range c.dones {
done <- struct{}{}
}
c.dones = nil
}
// Shutdown sets an immediate i/o deadline on all open registered sockets. Called
// some time after mox shutdown is initiated.
// The deadline will cause i/o's to be aborted, which should result in the
// connection being unregistered.
func (c *connections) Shutdown() {
now := time.Now()
c.Lock()
defer c.Unlock()
for nc := range c.conns {
if err := nc.SetDeadline(now); err != nil {
xlog.Errorx("setting immediate read/write deadline for shutdown", err)
}
}
}
// Done returns a new channel on which a value is sent when no more sockets are
// open, which could be immediate.
func (c *connections) Done() chan struct{} {
c.Lock()
defer c.Unlock()
done := make(chan struct{}, 1)
if len(c.conns) == 0 {
done <- struct{}{}
return done
}
c.dones = append(c.dones, done)
return done
}

44
mox-/lifecycle_test.go Normal file
View File

@ -0,0 +1,44 @@
package mox
import (
"errors"
"net"
"os"
"testing"
"github.com/prometheus/client_golang/prometheus"
)
func TestLifecycle(t *testing.T) {
c := &connections{
conns: map[net.Conn]connKind{},
gauges: map[connKind]prometheus.GaugeFunc{},
active: map[connKind]int64{},
}
nc0, nc1 := net.Pipe()
defer nc0.Close()
defer nc1.Close()
c.Register(nc0, "proto", "listener")
c.Shutdown()
done := c.Done()
select {
case <-done:
t.Fatalf("already done, but still a connection open")
default:
}
_, err := nc0.Read(make([]byte, 1))
if err == nil {
t.Fatalf("expected i/o deadline exceeded, got no error")
}
if !errors.Is(err, os.ErrDeadlineExceeded) {
t.Fatalf("got %v, expected os.ErrDeadlineExceeded", err)
}
c.Unregister(nc0)
select {
case <-done:
default:
t.Fatalf("unregistered connection, but not yet done")
}
}

66
mox-/lookup.go Normal file
View File

@ -0,0 +1,66 @@
package mox
import (
"errors"
"fmt"
"strings"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/smtp"
)
var (
ErrDomainNotFound = errors.New("domain not found")
ErrAccountNotFound = errors.New("account not found")
)
// FindAccount lookups the account for localpart and domain.
//
// Can return ErrDomainNotFound and ErrAccountNotFound.
func FindAccount(localpart smtp.Localpart, domain dns.Domain, allowPostmaster bool) (accountName string, canonicalAddress string, dest config.Destination, rerr error) {
if strings.EqualFold(string(localpart), "postmaster") {
localpart = "postmaster"
}
var zerodomain dns.Domain
if localpart == "postmaster" && domain == zerodomain {
if !allowPostmaster {
return "", "", config.Destination{}, ErrAccountNotFound
}
return Conf.Static.Postmaster.Account, "postmaster", config.Destination{Mailbox: Conf.Static.Postmaster.Mailbox}, nil
}
d, ok := Conf.Domain(domain)
if !ok {
return "", "", config.Destination{}, ErrDomainNotFound
}
localpart, err := CanonicalLocalpart(localpart, d)
if err != nil {
return "", "", config.Destination{}, fmt.Errorf("%w: %s", ErrAccountNotFound, err)
}
canonical := smtp.NewAddress(localpart, domain).String()
accAddr, ok := Conf.AccountDestination(canonical)
if !ok {
return "", "", config.Destination{}, ErrAccountNotFound
}
return accAddr.Account, canonical, accAddr.Destination, nil
}
// CanonicalLocalpart returns the canonical localpart, removing optional catchall
// separator, and optionally lower-casing the string.
func CanonicalLocalpart(localpart smtp.Localpart, d config.Domain) (smtp.Localpart, error) {
if d.LocalpartCatchallSeparator != "" {
t := strings.SplitN(string(localpart), d.LocalpartCatchallSeparator, 2)
localpart = smtp.Localpart(t[0])
if localpart == "" {
return "", fmt.Errorf("empty localpart")
}
}
if !d.LocalpartCaseSensitive {
localpart = smtp.Localpart(strings.ToLower(string(localpart)))
}
return localpart, nil
}

14
mox-/msgid.go Normal file
View File

@ -0,0 +1,14 @@
package mox
import (
"encoding/base64"
)
var messageIDRand = NewRand()
// MessageIDGen returns a generated unique random Message-Id value, excluding <>.
func MessageIDGen(smtputf8 bool) string {
buf := make([]byte, 16)
messageIDRand.Read(buf)
return base64.RawURLEncoding.EncodeToString(buf) + "@" + Conf.Static.HostnameDomain.XName(smtputf8)
}

22
mox-/rand.go Normal file
View File

@ -0,0 +1,22 @@
package mox
import (
cryptorand "crypto/rand"
"encoding/binary"
"fmt"
mathrand "math/rand"
)
// NewRand returns a new PRNG seeded with random bytes from crypto/rand.
func NewRand() *mathrand.Rand {
return mathrand.New(mathrand.NewSource(cryptoRandInt()))
}
func cryptoRandInt() int64 {
buf := make([]byte, 8)
_, err := cryptorand.Read(buf)
if err != nil {
panic(fmt.Errorf("reading random bytes: %v", err))
}
return int64(binary.LittleEndian.Uint64(buf))
}

61
mox-/recvid.go Normal file
View File

@ -0,0 +1,61 @@
package mox
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/binary"
"fmt"
)
var idCipher cipher.Block
var idRand []byte
func init() {
// Init for tests. Overwritten in ../serve.go.
err := ReceivedIDInit([]byte("0123456701234567"), []byte("01234567"))
if err != nil {
panic(err)
}
}
// ReceivedIDInit sets an AES key (must be 16 bytes) and random buffer (must be
// 8 bytes) for use by ReceivedID.
func ReceivedIDInit(key, rand []byte) error {
var err error
idCipher, err = aes.NewCipher(key)
idRand = rand
return err
}
// ReceivedID returns an ID for use in a message Received header.
//
// The ID is based on the cid. The cid itself is a counter and would leak the
// number of connections in received headers. Instead they are obfuscated by
// encrypting them with AES with a per-install key and random buffer. This allows
// recovery of the cid based on the id. See subcommand cid.
func ReceivedID(cid int64) string {
buf := make([]byte, 16)
copy(buf, idRand)
binary.BigEndian.PutUint64(buf[8:], uint64(cid))
idCipher.Encrypt(buf, buf)
return base64.RawURLEncoding.EncodeToString(buf)
}
// ReceivedToCid returns the cid given a ReceivedID.
func ReceivedToCid(s string) (cid int64, err error) {
buf, err := base64.RawURLEncoding.DecodeString(s)
if err != nil {
return 0, fmt.Errorf("decode base64: %v", err)
}
if len(buf) != 16 {
return 0, fmt.Errorf("bad length, got %d, expect 16", len(buf))
}
idCipher.Decrypt(buf, buf)
if !bytes.Equal(buf[:8], idRand) {
return 0, fmt.Errorf("rand mismatch")
}
cid = int64(binary.BigEndian.Uint64(buf[8:]))
return cid, nil
}

18
mox-/setcaphint.go Normal file
View File

@ -0,0 +1,18 @@
package mox
import (
"errors"
"os"
"runtime"
)
// todo: perhaps find and document the recommended way to get this on other platforms?
// LinuxSetcapHint returns a hint about using setcap for binding to privileged
// ports, only if relevant the error and GOOS (Linux).
func LinuxSetcapHint(err error) string {
if runtime.GOOS == "linux" && errors.Is(err, os.ErrPermission) {
return " (privileged port? try again after: sudo setcap 'cap_net_bind_service=+ep' mox)"
}
return ""
}

19
mox-/sleep.go Normal file
View File

@ -0,0 +1,19 @@
package mox
import (
"context"
"time"
)
// Sleep for d, but return as soon as ctx is done.
//
// Used for a few places where sleep is used to push back on clients, but where
// shutting down should abort the sleep.
func Sleep(ctx context.Context, d time.Duration) {
t := time.NewTicker(d)
defer t.Stop()
select {
case <-t.C:
case <-ctx.Done():
}
}

29
mox-/tlsinfo.go Normal file
View File

@ -0,0 +1,29 @@
package mox
import (
"crypto/tls"
"fmt"
)
// TLSInfo returns human-readable strings about the TLS connection, for use in
// logging.
func TLSInfo(conn *tls.Conn) (version, ciphersuite string) {
st := conn.ConnectionState()
versions := map[uint16]string{
tls.VersionTLS10: "TLS1.0",
tls.VersionTLS11: "TLS1.1",
tls.VersionTLS12: "TLS1.2",
tls.VersionTLS13: "TLS1.3",
}
v, ok := versions[st.Version]
if ok {
version = v
} else {
version = fmt.Sprintf("TLS %x", st.Version)
}
ciphersuite = tls.CipherSuiteName(st.CipherSuite)
return
}