move config-changing code from package mox-/ to admin/

needed for upcoming changes, where (now) package admin needs to import package
store. before, because package store imports mox- (for accessing the active
config), that would lead to a cyclic import. package mox- keeps its active
config, package admin has the higher-level config-changing functions.
This commit is contained in:
Mechiel Lukkien 2024-12-02 22:03:18 +01:00
parent de435fceba
commit 5f7831a7f0
No known key found for this signature in database
18 changed files with 805 additions and 756 deletions

View file

@ -1,68 +1,37 @@
package mox
package admin
import (
"bytes"
"context"
"crypto"
"crypto/ed25519"
cryptorand "crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"path/filepath"
"slices"
"sort"
"strings"
"time"
"golang.org/x/exp/maps"
"github.com/mjl-/adns"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/junk"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/tlsrpt"
)
var pkglog = mlog.New("admin", nil)
var ErrRequest = errors.New("bad request")
// TXTStrings returns a TXT record value as one or more quoted strings, each max
// 100 characters. In case of multiple strings, a multi-line record is returned.
func TXTStrings(s string) string {
if len(s) <= 100 {
return `"` + s + `"`
}
r := "(\n"
for len(s) > 0 {
n := len(s)
if n > 100 {
n = 100
}
if r != "" {
r += " "
}
r += "\t\t\"" + s[:n] + "\"\n"
s = s[n:]
}
r += "\t)"
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.
@ -206,7 +175,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
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.%s.privatekey.pkcs8.pem", record, timestamp, kind))
p := configDirPath(ConfigDynamicPath, keyPath)
p := mox.ConfigDynamicDirPath(keyPath)
if err := writeFile(log, p, privKey); err != nil {
return err
}
@ -323,10 +292,9 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
}
// Only take lock now, we don't want to hold it while generating a key.
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
d, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
@ -339,7 +307,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
record := fmt.Sprintf("%s._domainkey.%s", selector.ASCII, domain.ASCII)
timestamp := time.Now().Format("20060102T150405")
keyPath := filepath.Join("dkim", fmt.Sprintf("%s.%s.%s.privatekey.pkcs8.pem", record, timestamp, kind))
p := configDirPath(ConfigDynamicPath, keyPath)
p := mox.ConfigDynamicDirPath(keyPath)
if err := writeFile(log, p, privKey); err != nil {
return fmt.Errorf("writing key file: %v", err)
}
@ -377,7 +345,7 @@ func DKIMAdd(ctx context.Context, domain, selector dns.Domain, algorithm, hash s
}
nc.Domains[domain.Name()] = nd
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@ -397,10 +365,9 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
d, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
@ -433,7 +400,7 @@ func DKIMRemove(ctx context.Context, domain, selector dns.Domain) (rerr error) {
}
nc.Domains[domain.Name()] = nd
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@ -463,10 +430,9 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
if _, ok := c.Domains[domain.Name()]; ok {
return fmt.Errorf("%w: domain already present", ErrRequest)
}
@ -481,14 +447,14 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
// Only enable mta-sts for domain if there is a listener with mta-sts.
var withMTASTS bool
for _, l := range Conf.Static.Listeners {
for _, l := range mox.Conf.Static.Listeners {
if l.MTASTSHTTPS.Enabled {
withMTASTS = true
break
}
}
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, Conf.Static.HostnameDomain, accountName, withMTASTS)
confDomain, cleanupFiles, err := MakeDomainConfig(ctx, domain, mox.Conf.Static.HostnameDomain, accountName, withMTASTS)
if err != nil {
return fmt.Errorf("preparing domain config: %v", err)
}
@ -507,7 +473,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
return fmt.Errorf("%w: account name is empty", ErrRequest)
} else if !ok {
nc.Accounts[accountName] = MakeAccountConfig(smtp.NewAddress(localpart, domain))
} else if accountName != Conf.Static.Postmaster.Account {
} else if accountName != mox.Conf.Static.Postmaster.Account {
nacc := nc.Accounts[accountName]
nd := map[string]config.Destination{}
for k, v := range nacc.Destinations {
@ -521,7 +487,7 @@ func DomainAdd(ctx context.Context, domain dns.Domain, accountName string, local
nc.Domains[domain.Name()] = confDomain
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("domain added", slog.Any("domain", domain))
@ -540,10 +506,9 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
domConf, ok := c.Domains[domain.Name()]
if !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
@ -560,7 +525,7 @@ func DomainRemove(ctx context.Context, domain dns.Domain) (rerr error) {
}
}
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@ -588,8 +553,8 @@ func moveAwayKeys(log mlog.Log, sels map[string]config.Selector, usedKeyPaths ma
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)))
src := mox.ConfigDirPath(sel.PrivateKeyFile)
dst := mox.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")
@ -615,10 +580,9 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
nc := Conf.Dynamic // Shallow copy.
nc := mox.Conf.Dynamic // Shallow copy.
dom, ok := nc.Domains[domainName] // dom is a shallow copy.
if !ok {
return fmt.Errorf("%w: domain not present", ErrRequest)
@ -631,12 +595,12 @@ func DomainSave(ctx context.Context, domainName string, xmodify func(config *con
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
nc.Domains = map[string]config.Domain{}
for name, d := range Conf.Dynamic.Domains {
for name, d := range mox.Conf.Dynamic.Domains {
nc.Domains[name] = d
}
nc.Domains[domainName] = dom
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@ -656,13 +620,12 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
nc := Conf.Dynamic // Shallow copy.
nc := mox.Conf.Dynamic // Shallow copy.
xmodify(&nc)
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
@ -670,330 +633,6 @@ func ConfigSave(ctx context.Context, xmodify func(config *config.Dynamic)) (rerr
return nil
}
// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the
// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS
// transports.
func DomainSPFIPs() (ips []net.IP) {
for _, l := range Conf.Static.Listeners {
if !l.SMTP.Enabled || l.IPsNATed {
continue
}
ipstrs := l.IPs
if len(l.NATIPs) > 0 {
ipstrs = l.NATIPs
}
for _, ipstr := range ipstrs {
ip := net.ParseIP(ipstr)
if ip.IsUnspecified() {
continue
}
ips = append(ips, ip)
}
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips
}
// 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.
//
// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
// that caID will be suggested. If acmeAccountURI is also set, CAA records also
// restricting issuance to that account ID will be suggested.
func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
d := domain.ASCII
h := Conf.Static.HostnameDomain.ASCII
// The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
// ../testdata/integration/moxmail2.sh for selecting DNS records
records := []string{
"; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
"; Once your setup is working, you may want to increase the TTL.",
"$TTL 300",
"",
}
if public, ok := Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
records = append(records,
`; DANE: These records indicate that a remote mail server trying to deliver email`,
`; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
`; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
`; hexadecimal hash. DANE-EE verification means only the certificate or public`,
`; key is verified, not whether the certificate is signed by a (centralized)`,
`; certificate authority (CA), is expired, or matches the host name.`,
`;`,
`; NOTE: Create the records below only once: They are for the machine, and apply`,
`; to all hosted domains.`,
)
if !hasDNSSEC {
records = append(records,
";",
"; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
"; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
"; commented out.",
)
}
addTLSA := func(privKey crypto.Signer) error {
spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
if err != nil {
return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
}
sum := sha256.Sum256(spkiBuf)
tlsaRecord := adns.TLSA{
Usage: adns.TLSAUsageDANEEE,
Selector: adns.TLSASelectorSPKI,
MatchType: adns.TLSAMatchTypeSHA256,
CertAssoc: sum[:],
}
var s string
if hasDNSSEC {
s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
} else {
s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
}
records = append(records, s)
return nil
}
for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
records = append(records, "")
}
if d != h {
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
"; messages (DSNs) sent from host:",
fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
"",
)
}
if d != h && Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(Conf.Static.HostTLSRPT.ParsedLocalpart, Conf.Static.HostnameDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
"",
)
}
records = append(records,
"; Deliver email for the domain 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) > 100 {
records = append(records,
"; NOTE: The following is a single long record split over several lines for use",
"; in zone files. When adding through a DNS operator web interface, combine the",
"; strings into a single string, without ().",
)
}
s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, TXTStrings(txt))
records = append(records, s)
}
dmarcr := dmarc.DefaultRecord
dmarcr.Policy = "reject"
if domConf.DMARC != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
}
dmarcr.AggregateReportAddresses = []dmarc.URI{
{Address: uri.String(), MaxSize: 10, Unit: "m"},
}
}
dspfr := spf.Record{Version: "spf1"}
for _, ip := range DomainSPFIPs() {
mech := "ip4"
if ip.To4() == nil {
mech = "ip6"
}
dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
}
dspfr.Directives = append(dspfr.Directives,
spf.Directive{Mechanism: "mx"},
spf.Directive{Qualifier: "~", Mechanism: "all"},
)
dspftxt, err := dspfr.Record()
if err != nil {
return nil, fmt.Errorf("making domain spf record: %v", err)
}
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. TXT "%s"`, d, dspftxt),
"",
"; Emails that fail the DMARC check (without aligned DKIM and without aligned 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. TXT "%s"`, d, dmarcr.String()),
"",
)
if sts := domConf.MTASTS; sts != nil {
records = append(records,
"; Remote servers can use MTA-STS to verify our TLS certificate with the",
"; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
"; STARTTLSTLS.",
fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
"",
)
} else {
records = append(records,
"; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
"; domain or because mox.conf does not have a listener with MTA-STS configured.",
"",
)
}
if domConf.TLSRPT != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; Request reporting about TLS failures.",
fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
"",
)
}
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
records = append(records,
"; Client settings will reference a subdomain of the hosted domain, making it",
"; easier to migrate to a different server in the future by not requiring settings",
"; in all clients to be updated.",
fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
"",
)
}
records = append(records,
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
"",
// ../rfc/6186:133 ../rfc/8314:692
"; For secure IMAP and submission autoconfig, point to mail host.",
fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
"",
// ../rfc/6186:242
"; Next records specify POP3 and non-TLS ports are not to be used.",
"; These are optional and safe to leave out (e.g. if you have to click a lot in a",
"; DNS admin web interface).",
fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d),
)
if certIssuerDomainName != "" {
// ../rfc/8659:18 for CAA records.
records = append(records,
"",
"; Optional:",
"; You could mark Let's Encrypt as the only Certificate Authority allowed to",
"; sign TLS certificates for your domain.",
fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
)
if acmeAccountURI != "" {
// ../rfc/8657:99 for accounturi.
// ../rfc/8657:147 for validationmethods.
records = append(records,
";",
"; Optionally limit certificates for this domain to the account ID and methods used by mox.",
fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
";",
"; Or alternatively only limit for email-specific subdomains, so you can use",
"; other accounts/methods for other subdomains.",
fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
)
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != Conf.Static.HostnameDomain {
records = append(records,
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
)
}
if strings.HasSuffix(h, "."+d) {
records = append(records,
";",
"; And the mail hostname.",
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
)
}
} else {
// The string "will be suggested" is used by
// ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
// as end of DNS records.
records = append(records,
";",
"; Note: After starting up, once an ACME account has been created, CAA records",
"; that restrict issuance to the account will be suggested.",
)
}
}
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 yet log in. Email can be
@ -1013,10 +652,9 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
return fmt.Errorf("%w: parsing email address: %v", ErrRequest, err)
}
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
if _, ok := c.Accounts[account]; ok {
return fmt.Errorf("%w: account already present", ErrRequest)
}
@ -1034,7 +672,7 @@ func AccountAdd(ctx context.Context, account, address string) (rerr error) {
}
nc.Accounts[account] = MakeAccountConfig(addr)
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("account added", slog.String("account", account), slog.Any("address", addr))
@ -1050,10 +688,9 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
if _, ok := c.Accounts[account]; !ok {
return fmt.Errorf("%w: account does not exist", ErrRequest)
}
@ -1068,12 +705,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
}
}
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
odir := filepath.Join(DataDirPath("accounts"), account)
tmpdir := filepath.Join(DataDirPath("tmp"), "oldaccount-"+account)
odir := filepath.Join(mox.DataDirPath("accounts"), account)
tmpdir := filepath.Join(mox.DataDirPath("tmp"), "oldaccount-"+account)
if err := os.Rename(odir, tmpdir); err != nil {
log.Errorx("moving old account data directory out of the way", err, slog.String("account", account))
return fmt.Errorf("account removed, but account data directory %q could not be moved out of the way: %v", odir, err)
@ -1093,12 +730,12 @@ func AccountRemove(ctx context.Context, account string) (rerr error) {
//
// Must be called with config lock held.
func checkAddressAvailable(addr smtp.Address) error {
dc, ok := Conf.Dynamic.Domains[addr.Domain.Name()]
dc, ok := mox.Conf.Dynamic.Domains[addr.Domain.Name()]
if !ok {
return fmt.Errorf("domain does not exist")
}
lp := CanonicalLocalpart(addr.Localpart, dc)
if _, ok := Conf.accountDestinations[smtp.NewAddress(lp, addr.Domain).String()]; ok {
lp := mox.CanonicalLocalpart(addr.Localpart, dc)
if _, ok := mox.Conf.AccountDestinationsLocked[smtp.NewAddress(lp, addr.Domain).String()]; ok {
return fmt.Errorf("canonicalized address %s already configured", smtp.NewAddress(lp, addr.Domain))
} else if dc.LocalpartCatchallSeparator != "" && strings.Contains(string(addr.Localpart), dc.LocalpartCatchallSeparator) {
return fmt.Errorf("localpart cannot include domain catchall separator %s", dc.LocalpartCatchallSeparator)
@ -1118,10 +755,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
a, ok := c.Accounts[account]
if !ok {
return fmt.Errorf("%w: account does not exist", ErrRequest)
@ -1135,9 +771,9 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
}
dname := d.Name()
destAddr = "@" + dname
if _, ok := Conf.Dynamic.Domains[dname]; !ok {
if _, ok := mox.Conf.Dynamic.Domains[dname]; !ok {
return fmt.Errorf("%w: domain does not exist", ErrRequest)
} else if _, ok := Conf.accountDestinations[destAddr]; ok {
} else if _, ok := mox.Conf.AccountDestinationsLocked[destAddr]; ok {
return fmt.Errorf("%w: catchall address already configured for domain", ErrRequest)
}
} else {
@ -1167,7 +803,7 @@ func AddressAdd(ctx context.Context, address, account string) (rerr error) {
a.Destinations = nd
nc.Accounts[account] = a
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("address added", slog.String("address", address), slog.String("account", account))
@ -1187,17 +823,16 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
ad, ok := Conf.accountDestinations[address]
ad, ok := mox.Conf.AccountDestinationsLocked[address]
if !ok {
return fmt.Errorf("%w: address does not exists", ErrRequest)
}
// Compose new config without modifying existing data structures. If we fail, we
// leave no trace.
a, ok := Conf.Dynamic.Accounts[ad.Account]
a, ok := mox.Conf.Dynamic.Accounts[ad.Account]
if !ok {
return fmt.Errorf("internal error: cannot find account")
}
@ -1241,12 +876,12 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
if strings.HasPrefix(address, "@") {
continue
}
dc, ok := Conf.Dynamic.Domains[dom.Name()]
dc, ok := mox.Conf.Dynamic.Domains[dom.Name()]
if !ok {
return fmt.Errorf("%w: unknown domain in fromid login address %q", ErrRequest, fa.Pack(true))
}
flp := CanonicalLocalpart(fa.Localpart, dc)
alp := CanonicalLocalpart(pa.Localpart, dc)
flp := mox.CanonicalLocalpart(fa.Localpart, dc)
alp := mox.CanonicalLocalpart(pa.Localpart, dc)
if alp != flp {
// Keep for different localpart.
fromIDLoginAddresses = append(fromIDLoginAddresses, a.FromIDLoginAddresses[i])
@ -1255,7 +890,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
na.FromIDLoginAddresses = fromIDLoginAddresses
// And remove as member from aliases configured in domains.
domains := maps.Clone(Conf.Dynamic.Domains)
domains := maps.Clone(mox.Conf.Dynamic.Domains)
for _, aa := range na.Aliases {
if aa.SubscriptionAddress != address {
continue
@ -1263,7 +898,7 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
aliasAddr := fmt.Sprintf("%s@%s", aa.Alias.LocalpartStr, aa.Alias.Domain.Name())
dom, ok := Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
dom, ok := mox.Conf.Dynamic.Domains[aa.Alias.Domain.Name()]
if !ok {
return fmt.Errorf("cannot find domain for alias %s", aliasAddr)
}
@ -1283,15 +918,15 @@ func AddressRemove(ctx context.Context, address string) (rerr error) {
}
na.Aliases = nil // Filled when parsing config.
nc := Conf.Dynamic
nc := mox.Conf.Dynamic
nc.Accounts = map[string]config.Account{}
for name, a := range Conf.Dynamic.Accounts {
for name, a := range mox.Conf.Dynamic.Accounts {
nc.Accounts[name] = a
}
nc.Accounts[ad.Account] = na
nc.Domains = domains
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("address removed", slog.String("address", address), slog.String("account", ad.Account))
@ -1393,10 +1028,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
}
}()
Conf.dynamicMutex.Lock()
defer Conf.dynamicMutex.Unlock()
defer mox.Conf.DynamicLockUnlock()()
c := Conf.Dynamic
c := mox.Conf.Dynamic
acc, ok := c.Accounts[account]
if !ok {
return fmt.Errorf("%w: account not present", ErrRequest)
@ -1413,243 +1047,9 @@ func AccountSave(ctx context.Context, account string, xmodify func(acc *config.A
}
nc.Accounts[account] = acc
if err := writeDynamic(ctx, log, nc); err != nil {
if err := mox.WriteDynamicLocked(ctx, log, nc); err != nil {
return fmt.Errorf("writing domains.conf: %w", err)
}
log.Info("account fields saved", slog.String("account", account))
return nil
}
type TLSMode uint8
const (
TLSModeImmediate TLSMode = 0
TLSModeSTARTTLS TLSMode = 1
TLSModeNone TLSMode = 2
)
type ProtocolConfig struct {
Host dns.Domain
Port int
TLSMode TLSMode
}
type ClientConfig struct {
IMAP ProtocolConfig
Submission ProtocolConfig
}
// ClientConfigDomain returns a single IMAP and Submission client configuration for
// a domain.
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
var haveIMAP, haveSubmission bool
domConf, ok := Conf.Domain(d)
if !ok {
return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
gather := func(l config.Listener) (done bool) {
host := Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if !haveIMAP && l.IMAPS.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
rconfig.IMAP.TLSMode = TLSModeImmediate
haveIMAP = true
}
if !haveIMAP && l.IMAP.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
rconfig.IMAP.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.IMAP.TLSMode = TLSModeNone
}
haveIMAP = true
}
if !haveSubmission && l.Submissions.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
rconfig.Submission.TLSMode = TLSModeImmediate
haveSubmission = true
}
if !haveSubmission && l.Submission.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
rconfig.Submission.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.Submission.TLSMode = TLSModeNone
}
haveSubmission = true
}
return haveIMAP && haveSubmission
}
// Look at the public listener first. Most likely the intended configuration.
if public, ok := Conf.Static.Listeners["public"]; ok {
if gather(public) {
return
}
}
// Go through the other listeners in consistent order.
names := maps.Keys(Conf.Static.Listeners)
sort.Strings(names)
for _, name := range names {
if gather(Conf.Static.Listeners[name]) {
return
}
}
return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
}
// ClientConfigs holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfigs struct {
Entries []ClientConfigsEntry
}
type ClientConfigsEntry struct {
Protocol string
Host dns.Domain
Port int
Listener string
Note string
}
// ClientConfigsDomain returns the client configs for IMAP/Submission for a
// domain.
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
domConf, ok := Conf.Domain(d)
if !ok {
return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
c := ClientConfigs{}
c.Entries = []ClientConfigsEntry{}
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 domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if l.Submissions.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
}
if l.IMAPS.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
}
if l.Submission.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"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, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
}
}
return c, nil
}
// IPs returns ip addresses we may be listening/receiving mail on or
// connecting/sending from to the outside.
func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
log := pkglog.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 {
// If NATed, we don't know our external IPs.
if l.IPsNATed {
return nil, nil
}
check := l.IPs
if len(l.NATIPs) > 0 {
check = l.NATIPs
}
for _, s := range check {
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 balancer/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, slog.Any("address", addr))
continue
}
v4 := ip.To4() != nil
if ipv4all && v4 || ipv6all && !v4 {
ips = append(ips, ip)
}
}
}
}
if receiveOnly {
return ips, nil
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips, nil
}

168
admin/clientconfig.go Normal file
View file

@ -0,0 +1,168 @@
package admin
import (
"fmt"
"sort"
"golang.org/x/exp/maps"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
)
type TLSMode uint8
const (
TLSModeImmediate TLSMode = 0
TLSModeSTARTTLS TLSMode = 1
TLSModeNone TLSMode = 2
)
type ProtocolConfig struct {
Host dns.Domain
Port int
TLSMode TLSMode
}
type ClientConfig struct {
IMAP ProtocolConfig
Submission ProtocolConfig
}
// ClientConfigDomain returns a single IMAP and Submission client configuration for
// a domain.
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
var haveIMAP, haveSubmission bool
domConf, ok := mox.Conf.Domain(d)
if !ok {
return ClientConfig{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
gather := func(l config.Listener) (done bool) {
host := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if !haveIMAP && l.IMAPS.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
rconfig.IMAP.TLSMode = TLSModeImmediate
haveIMAP = true
}
if !haveIMAP && l.IMAP.Enabled {
rconfig.IMAP.Host = host
rconfig.IMAP.Port = config.Port(l.IMAP.Port, 143)
rconfig.IMAP.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.IMAP.TLSMode = TLSModeNone
}
haveIMAP = true
}
if !haveSubmission && l.Submissions.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submissions.Port, 465)
rconfig.Submission.TLSMode = TLSModeImmediate
haveSubmission = true
}
if !haveSubmission && l.Submission.Enabled {
rconfig.Submission.Host = host
rconfig.Submission.Port = config.Port(l.Submission.Port, 587)
rconfig.Submission.TLSMode = TLSModeSTARTTLS
if l.TLS == nil {
rconfig.Submission.TLSMode = TLSModeNone
}
haveSubmission = true
}
return haveIMAP && haveSubmission
}
// Look at the public listener first. Most likely the intended configuration.
if public, ok := mox.Conf.Static.Listeners["public"]; ok {
if gather(public) {
return
}
}
// Go through the other listeners in consistent order.
names := maps.Keys(mox.Conf.Static.Listeners)
sort.Strings(names)
for _, name := range names {
if gather(mox.Conf.Static.Listeners[name]) {
return
}
}
return ClientConfig{}, fmt.Errorf("%w: no listeners found for imap and/or submission", ErrRequest)
}
// ClientConfigs holds the client configuration for IMAP/Submission for a
// domain.
type ClientConfigs struct {
Entries []ClientConfigsEntry
}
type ClientConfigsEntry struct {
Protocol string
Host dns.Domain
Port int
Listener string
Note string
}
// ClientConfigsDomain returns the client configs for IMAP/Submission for a
// domain.
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
domConf, ok := mox.Conf.Domain(d)
if !ok {
return ClientConfigs{}, fmt.Errorf("%w: unknown domain", ErrRequest)
}
c := ClientConfigs{}
c.Entries = []ClientConfigsEntry{}
var listeners []string
for name := range mox.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 := mox.Conf.Static.Listeners[name]
host := mox.Conf.Static.HostnameDomain
if l.Hostname != "" {
host = l.HostnameDomain
}
if domConf.ClientSettingsDomain != "" {
host = domConf.ClientSettingsDNSDomain
}
if l.Submissions.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
}
if l.IMAPS.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 993), name, "with TLS"})
}
if l.Submission.Enabled {
c.Entries = append(c.Entries, ClientConfigsEntry{"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, ClientConfigsEntry{"IMAP", host, config.Port(l.IMAPS.Port, 143), name, note(l.TLS != nil, !l.IMAP.NoRequireSTARTTLS)})
}
}
return c, nil
}

320
admin/dnsrecords.go Normal file
View file

@ -0,0 +1,320 @@
package admin
import (
"crypto"
"crypto/ed25519"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"fmt"
"net/url"
"sort"
"strings"
"github.com/mjl-/adns"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/spf"
"github.com/mjl-/mox/tlsrpt"
)
// 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.
//
// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
// that caID will be suggested. If acmeAccountURI is also set, CAA records also
// restricting issuance to that account ID will be suggested.
func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
d := domain.ASCII
h := mox.Conf.Static.HostnameDomain.ASCII
// The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
// ../testdata/integration/moxmail2.sh for selecting DNS records
records := []string{
"; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
"; Once your setup is working, you may want to increase the TTL.",
"$TTL 300",
"",
}
if public, ok := mox.Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
records = append(records,
`; DANE: These records indicate that a remote mail server trying to deliver email`,
`; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
`; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
`; hexadecimal hash. DANE-EE verification means only the certificate or public`,
`; key is verified, not whether the certificate is signed by a (centralized)`,
`; certificate authority (CA), is expired, or matches the host name.`,
`;`,
`; NOTE: Create the records below only once: They are for the machine, and apply`,
`; to all hosted domains.`,
)
if !hasDNSSEC {
records = append(records,
";",
"; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
"; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
"; commented out.",
)
}
addTLSA := func(privKey crypto.Signer) error {
spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
if err != nil {
return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
}
sum := sha256.Sum256(spkiBuf)
tlsaRecord := adns.TLSA{
Usage: adns.TLSAUsageDANEEE,
Selector: adns.TLSASelectorSPKI,
MatchType: adns.TLSAMatchTypeSHA256,
CertAssoc: sum[:],
}
var s string
if hasDNSSEC {
s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
} else {
s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
}
records = append(records, s)
return nil
}
for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
if err := addTLSA(privKey); err != nil {
return nil, err
}
}
records = append(records, "")
}
if d != h {
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
"; messages (DSNs) sent from host:",
fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
"",
)
}
if d != h && mox.Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; For the machine, only needs to be created once, for the first domain added:",
"; ",
"; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
"",
)
}
records = append(records,
"; Deliver email for the domain 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) > 100 {
records = append(records,
"; NOTE: The following is a single long record split over several lines for use",
"; in zone files. When adding through a DNS operator web interface, combine the",
"; strings into a single string, without ().",
)
}
s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, mox.TXTStrings(txt))
records = append(records, s)
}
dmarcr := dmarc.DefaultRecord
dmarcr.Policy = "reject"
if domConf.DMARC != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
}
dmarcr.AggregateReportAddresses = []dmarc.URI{
{Address: uri.String(), MaxSize: 10, Unit: "m"},
}
}
dspfr := spf.Record{Version: "spf1"}
for _, ip := range mox.DomainSPFIPs() {
mech := "ip4"
if ip.To4() == nil {
mech = "ip6"
}
dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
}
dspfr.Directives = append(dspfr.Directives,
spf.Directive{Mechanism: "mx"},
spf.Directive{Qualifier: "~", Mechanism: "all"},
)
dspftxt, err := dspfr.Record()
if err != nil {
return nil, fmt.Errorf("making domain spf record: %v", err)
}
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. TXT "%s"`, d, dspftxt),
"",
"; Emails that fail the DMARC check (without aligned DKIM and without aligned 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. TXT "%s"`, d, dmarcr.String()),
"",
)
if sts := domConf.MTASTS; sts != nil {
records = append(records,
"; Remote servers can use MTA-STS to verify our TLS certificate with the",
"; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
"; STARTTLSTLS.",
fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
"",
)
} else {
records = append(records,
"; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
"; domain or because mox.conf does not have a listener with MTA-STS configured.",
"",
)
}
if domConf.TLSRPT != nil {
uri := url.URL{
Scheme: "mailto",
Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
}
tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
records = append(records,
"; Request reporting about TLS failures.",
fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
"",
)
}
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
records = append(records,
"; Client settings will reference a subdomain of the hosted domain, making it",
"; easier to migrate to a different server in the future by not requiring settings",
"; in all clients to be updated.",
fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), domConf.ClientSettingsDNSDomain.ASCII+".", h),
"",
)
}
records = append(records,
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
"",
// ../rfc/6186:133 ../rfc/8314:692
"; For secure IMAP and submission autoconfig, point to mail host.",
fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, h),
fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, h),
"",
// ../rfc/6186:242
"; Next records specify POP3 and non-TLS ports are not to be used.",
"; These are optional and safe to leave out (e.g. if you have to click a lot in a",
"; DNS admin web interface).",
fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d),
)
if certIssuerDomainName != "" {
// ../rfc/8659:18 for CAA records.
records = append(records,
"",
"; Optional:",
"; You could mark Let's Encrypt as the only Certificate Authority allowed to",
"; sign TLS certificates for your domain.",
fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
)
if acmeAccountURI != "" {
// ../rfc/8657:99 for accounturi.
// ../rfc/8657:147 for validationmethods.
records = append(records,
";",
"; Optionally limit certificates for this domain to the account ID and methods used by mox.",
fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
";",
"; Or alternatively only limit for email-specific subdomains, so you can use",
"; other accounts/methods for other subdomains.",
fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
)
if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
records = append(records,
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), domConf.ClientSettingsDNSDomain.ASCII, certIssuerDomainName, acmeAccountURI),
)
}
if strings.HasSuffix(h, "."+d) {
records = append(records,
";",
"; And the mail hostname.",
fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
)
}
} else {
// The string "will be suggested" is used by
// ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
// as end of DNS records.
records = append(records,
";",
"; Note: After starting up, once an ACME account has been created, CAA records",
"; that restrict issuance to the account will be suggested.",
)
}
}
return records, nil
}

23
ctl.go
View file

@ -21,6 +21,7 @@ import (
"github.com/mjl-/bstore"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message"
@ -973,7 +974,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
localpart := ctl.xread()
d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain")
err = mox.DomainAdd(ctx, d, account, smtp.Localpart(localpart))
err = admin.DomainAdd(ctx, d, account, smtp.Localpart(localpart))
ctl.xcheck(err, "adding domain")
ctl.xwriteok()
@ -986,7 +987,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
domain := ctl.xread()
d, err := dns.ParseDomain(domain)
ctl.xcheck(err, "parsing domain")
err = mox.DomainRemove(ctx, d)
err = admin.DomainRemove(ctx, d)
ctl.xcheck(err, "removing domain")
ctl.xwriteok()
@ -999,7 +1000,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/
account := ctl.xread()
address := ctl.xread()
err := mox.AccountAdd(ctx, account, address)
err := admin.AccountAdd(ctx, account, address)
ctl.xcheck(err, "adding account")
ctl.xwriteok()
@ -1010,7 +1011,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error
*/
account := ctl.xread()
err := mox.AccountRemove(ctx, account)
err := admin.AccountRemove(ctx, account)
ctl.xcheck(err, "removing account")
ctl.xwriteok()
@ -1023,7 +1024,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
*/
address := ctl.xread()
account := ctl.xread()
err := mox.AddressAdd(ctx, address, account)
err := admin.AddressAdd(ctx, address, account)
ctl.xcheck(err, "adding address")
ctl.xwriteok()
@ -1034,7 +1035,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
< "ok" or error
*/
address := ctl.xread()
err := mox.AddressRemove(ctx, address)
err := admin.AddressRemove(ctx, address)
ctl.xcheck(err, "removing address")
ctl.xwriteok()
@ -1099,7 +1100,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address")
var alias config.Alias
xparseJSON(ctl, line, &alias)
err = mox.AliasAdd(ctx, addr, alias)
err = admin.AliasAdd(ctx, addr, alias)
ctl.xcheck(err, "adding alias")
ctl.xwriteok()
@ -1118,7 +1119,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
allowmsgfrom := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
err = mox.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
err = admin.DomainSave(ctx, addr.Domain.Name(), func(d *config.Domain) error {
a, ok := d.Aliases[addr.Localpart.String()]
if !ok {
return fmt.Errorf("alias does not exist")
@ -1159,7 +1160,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
address := ctl.xread()
addr, err := smtp.ParseAddress(address)
ctl.xcheck(err, "parsing address")
err = mox.AliasRemove(ctx, addr)
err = admin.AliasRemove(ctx, addr)
ctl.xcheck(err, "removing alias")
ctl.xwriteok()
@ -1176,7 +1177,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address")
var addresses []string
xparseJSON(ctl, line, &addresses)
err = mox.AliasAddressesAdd(ctx, addr, addresses)
err = admin.AliasAddressesAdd(ctx, addr, addresses)
ctl.xcheck(err, "adding addresses to alias")
ctl.xwriteok()
@ -1193,7 +1194,7 @@ func servectlcmd(ctx context.Context, ctl *ctl, shutdown func()) {
ctl.xcheck(err, "parsing address")
var addresses []string
xparseJSON(ctl, line, &addresses)
err = mox.AliasAddressesRemove(ctx, addr, addresses)
err = admin.AliasAddressesRemove(ctx, addr, addresses)
ctl.xcheck(err, "removing addresses to alias")
ctl.xwriteok()

View file

@ -11,7 +11,7 @@ import (
"github.com/prometheus/client_golang/prometheus/promauto"
"rsc.io/qr"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp"
)
@ -70,13 +70,13 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
return
}
socketType := func(tlsMode mox.TLSMode) (string, error) {
socketType := func(tlsMode admin.TLSMode) (string, error) {
switch tlsMode {
case mox.TLSModeImmediate:
case admin.TLSModeImmediate:
return "SSL", nil
case mox.TLSModeSTARTTLS:
case admin.TLSModeSTARTTLS:
return "STARTTLS", nil
case mox.TLSModeNone:
case admin.TLSModeNone:
return "plain", nil
default:
return "", fmt.Errorf("unknown tls mode %v", tlsMode)
@ -84,7 +84,7 @@ func autoconfHandle(w http.ResponseWriter, r *http.Request) {
}
var imapTLS, submissionTLS string
config, err := mox.ClientConfigDomain(addr.Domain)
config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil {
imapTLS, err = socketType(config.IMAP.TLSMode)
}
@ -170,13 +170,13 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
}
// tlsmode returns the "ssl" and "encryption" fields.
tlsmode := func(tlsMode mox.TLSMode) (string, string, error) {
tlsmode := func(tlsMode admin.TLSMode) (string, string, error) {
switch tlsMode {
case mox.TLSModeImmediate:
case admin.TLSModeImmediate:
return "on", "TLS", nil
case mox.TLSModeSTARTTLS:
case admin.TLSModeSTARTTLS:
return "on", "", nil
case mox.TLSModeNone:
case admin.TLSModeNone:
return "off", "", nil
default:
return "", "", fmt.Errorf("unknown tls mode %v", tlsMode)
@ -185,7 +185,7 @@ func autodiscoverHandle(w http.ResponseWriter, r *http.Request) {
var imapSSL, imapEncryption string
var submissionSSL, submissionEncryption string
config, err := mox.ClientConfigDomain(addr.Domain)
config, err := admin.ClientConfigDomain(addr.Domain)
if err == nil {
imapSSL, imapEncryption, err = tlsmode(config.IMAP.TLSMode)
}

View file

@ -12,7 +12,7 @@ import (
"golang.org/x/exp/maps"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/smtp"
)
@ -122,7 +122,7 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
return nil, fmt.Errorf("parsing address: %v", err)
}
config, err := mox.ClientConfigDomain(addr.Domain)
config, err := admin.ClientConfigDomain(addr.Domain)
if err != nil {
return nil, fmt.Errorf("getting config for domain: %v", err)
}
@ -175,12 +175,12 @@ func MobileConfig(addresses []string, fullName string) ([]byte, error) {
"IncomingMailServerUsername": addresses[0],
"IncomingMailServerHostName": config.IMAP.Host.ASCII,
"IncomingMailServerPortNumber": config.IMAP.Port,
"IncomingMailServerUseSSL": config.IMAP.TLSMode == mox.TLSModeImmediate,
"IncomingMailServerUseSSL": config.IMAP.TLSMode == admin.TLSModeImmediate,
"OutgoingMailServerAuthentication": "EmailAuthCRAMMD5", // SCRAM not an option at time of writing...
"OutgoingMailServerHostName": config.Submission.Host.ASCII,
"OutgoingMailServerPortNumber": config.Submission.Port,
"OutgoingMailServerUsername": addresses[0],
"OutgoingMailServerUseSSL": config.Submission.TLSMode == mox.TLSModeImmediate,
"OutgoingMailServerUseSSL": config.Submission.TLSMode == admin.TLSModeImmediate,
"OutgoingPasswordSameAsIncomingPassword": true,
"PayloadIdentifier": reverseAddr + ".email.account",
"PayloadType": "com.apple.mail.managed",

View file

@ -25,6 +25,7 @@ import (
"github.com/mjl-/sconf"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns"
@ -421,7 +422,7 @@ func writeLocalConfig(log mlog.Log, dir, ip string) (rerr error) {
},
}
dkimKeyBuf, err := mox.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
dkimKeyBuf, err := admin.MakeDKIMEd25519Key(dns.Domain{ASCII: "localserve"}, dns.Domain{ASCII: "localhost"})
xcheck(err, "making dkim key")
dkimKeyPath := "dkim.localserve.privatekey.pkcs8.pem"
err = os.WriteFile(filepath.Join(dir, dkimKeyPath), dkimKeyBuf, 0660)

11
main.go
View file

@ -45,6 +45,7 @@ import (
"github.com/mjl-/sconf"
"github.com/mjl-/sherpa"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dane"
"github.com/mjl-/mox/dkim"
@ -570,7 +571,7 @@ configured over otherwise secured connections, like a VPN.
}
func printClientConfig(d dns.Domain) {
cc, err := mox.ClientConfigsDomain(d)
cc, err := admin.ClientConfigsDomain(d)
xcheckf(err, "getting client config")
fmt.Printf("%-20s %-30s %5s %-15s %s\n", "Protocol", "Host", "Port", "Listener", "Note")
for _, e := range cc.Entries {
@ -1006,7 +1007,7 @@ configured.
}
}
records, err := mox.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
records, err := admin.DomainRecords(domConf, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
xcheckf(err, "records")
fmt.Print(strings.Join(records, "\n") + "\n")
}
@ -1539,7 +1540,7 @@ with DKIM, by mox.
c.Usage()
}
buf, err := mox.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{})
buf, err := admin.MakeDKIMRSAKey(dns.Domain{}, dns.Domain{})
xcheckf(err, "making rsa private key")
_, err = os.Stdout.Write(buf)
xcheckf(err, "writing rsa private key")
@ -2077,7 +2078,7 @@ so it is recommended to sign messages with both RSA and ed25519 keys.
c.Usage()
}
buf, err := mox.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{})
buf, err := admin.MakeDKIMEd25519Key(dns.Domain{}, dns.Domain{})
xcheckf(err, "making dkim ed25519 key")
_, err = os.Stdout.Write(buf)
xcheckf(err, "writing dkim ed25519 key")
@ -2786,7 +2787,7 @@ printed.
}
mustLoadConfig()
current, lastknown, _, err := mox.LastKnown()
current, lastknown, _, err := store.LastKnown()
if err != nil {
log.Printf("getting last known version: %s", err)
} else {

View file

@ -86,11 +86,13 @@ type Config struct {
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.
accountDestinations map[string]AccountDestination
// Like accountDestinations, but for aliases.
// 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
}
@ -142,9 +144,11 @@ func (c *Config) LogLevels() map[string]slog.Level {
return c.copyLogLevels()
}
func (c *Config) withDynamicLock(fn func()) {
// 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()
defer c.dynamicMutex.Unlock()
now := time.Now()
if now.Sub(c.DynamicLastCheck) > time.Second {
c.DynamicLastCheck = now
@ -159,6 +163,11 @@ func (c *Config) withDynamicLock(fn func()) {
}
}
}
return c.dynamicMutex.Unlock
}
func (c *Config) withDynamicLock(fn func()) {
defer c.DynamicLockUnlock()()
fn()
}
@ -170,7 +179,7 @@ func (c *Config) loadDynamic() []error {
}
c.Dynamic = d
c.dynamicMtime = mtime
c.accountDestinations = accDests
c.AccountDestinationsLocked = accDests
c.aliases = aliases
c.allowACMEHosts(pkglog, true)
return nil
@ -213,7 +222,7 @@ func (c *Config) DomainLocalparts(d dns.Domain) (map[string]string, map[string]c
m := map[string]string{}
aliases := map[string]config.Alias{}
c.withDynamicLock(func() {
for addr, ad := range c.accountDestinations {
for addr, ad := range c.AccountDestinationsLocked {
if strings.HasSuffix(addr, suffix) {
if ad.Catchall {
m[""] = ad.Account
@ -247,7 +256,7 @@ func (c *Config) Account(name string) (acc config.Account, ok bool) {
func (c *Config) AccountDestination(addr string) (accDest AccountDestination, alias *config.Alias, ok bool) {
c.withDynamicLock(func() {
accDest, ok = c.accountDestinations[addr]
accDest, ok = c.AccountDestinationsLocked[addr]
if !ok {
var a config.Alias
a, ok = c.aliases[addr]
@ -345,9 +354,13 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
// 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.
// 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.
func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
//
// 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))
@ -399,7 +412,7 @@ func writeDynamic(ctx context.Context, log mlog.Log, c config.Dynamic) error {
Conf.dynamicMtime = fi.ModTime()
Conf.DynamicLastCheck = time.Now()
Conf.Dynamic = c
Conf.accountDestinations = accDests
Conf.AccountDestinationsLocked = accDests
Conf.aliases = aliases
Conf.allowACMEHosts(log, true)
@ -440,7 +453,7 @@ func LoadConfig(ctx context.Context, log mlog.Log, doLoadTLSKeyCerts, checkACMEH
// 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, c.aliases}
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 {
@ -491,7 +504,7 @@ func ParseConfig(ctx context.Context, log mlog.Log, p string, checkOnly, doLoadT
}
pp := filepath.Join(filepath.Dir(p), "domains.conf")
c.Dynamic, c.dynamicMtime, c.accountDestinations, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
c.Dynamic, c.dynamicMtime, c.AccountDestinationsLocked, c.aliases, errs = ParseDynamicConfig(ctx, log, pp, c.Static)
if !checkOnly {
c.allowACMEHosts(log, checkACMEHosts)

View file

@ -5,11 +5,18 @@ import (
)
// ConfigDirPath returns the path to "f". Either f itself when absolute, or
// interpreted relative to the directory of the current config file.
// interpreted relative to the directory of the static configuration file
// (mox.conf).
func ConfigDirPath(f string) string {
return configDirPath(ConfigStaticPath, f)
}
// Like ConfigDirPath, but relative paths are interpreted relative to the directory
// of the dynamic configuration file (domains.conf).
func ConfigDynamicDirPath(f string) string {
return configDirPath(ConfigDynamicPath, 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.

View file

@ -1,6 +1,9 @@
package mox
import (
"context"
"fmt"
"log/slog"
"net"
)
@ -19,3 +22,109 @@ func Network(ip string) string {
}
return "tcp6"
}
// DomainSPFIPs returns IPs to include in SPF records for domains. It includes the
// IPs on listeners that have SMTP enabled, and includes IPs configured for SOCKS
// transports.
func DomainSPFIPs() (ips []net.IP) {
for _, l := range Conf.Static.Listeners {
if !l.SMTP.Enabled || l.IPsNATed {
continue
}
ipstrs := l.IPs
if len(l.NATIPs) > 0 {
ipstrs = l.NATIPs
}
for _, ipstr := range ipstrs {
ip := net.ParseIP(ipstr)
if ip.IsUnspecified() {
continue
}
ips = append(ips, ip)
}
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips
}
// IPs returns ip addresses we may be listening/receiving mail on or
// connecting/sending from to the outside.
func IPs(ctx context.Context, receiveOnly bool) ([]net.IP, error) {
log := pkglog.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 {
// If NATed, we don't know our external IPs.
if l.IPsNATed {
return nil, nil
}
check := l.IPs
if len(l.NATIPs) > 0 {
check = l.NATIPs
}
for _, s := range check {
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 balancer/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, slog.Any("address", addr))
continue
}
v4 := ip.To4() != nil
if ipv4all && v4 || ipv6all && !v4 {
ips = append(ips, ip)
}
}
}
}
if receiveOnly {
return ips, nil
}
for _, t := range Conf.Static.Transports {
if t.Socks != nil {
ips = append(ips, t.Socks.IPs...)
}
}
return ips, nil
}

24
mox-/txt.go Normal file
View file

@ -0,0 +1,24 @@
package mox
// TXTStrings returns a TXT record value as one or more quoted strings, each max
// 100 characters. In case of multiple strings, a multi-line record is returned.
func TXTStrings(s string) string {
if len(s) <= 100 {
return `"` + s + `"`
}
r := "(\n"
for len(s) > 0 {
n := len(s)
if n > 100 {
n = 100
}
if r != "" {
r += " "
}
r += "\t\t\"" + s[:n] + "\"\n"
s = s[n:]
}
r += "\t)"
return r
}

View file

@ -28,6 +28,7 @@ import (
"github.com/mjl-/sconf"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/dnsbl"
@ -827,9 +828,9 @@ and check the admin page for the needed DNS records.`)
mox.Conf.DynamicLastCheck = time.Now() // Prevent error logging by Make calls below.
accountConf := mox.MakeAccountConfig(addr)
accountConf := admin.MakeAccountConfig(addr)
const withMTASTS = true
confDomain, keyPaths, err := mox.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS)
confDomain, keyPaths, err := admin.MakeDomainConfig(context.Background(), domain, dnshostname, accountName, withMTASTS)
if err != nil {
fatalf("making domain config: %s", err)
}
@ -989,7 +990,7 @@ have been configured correctly. The DNS records to add:
// priming dns caches with negative/absent records, causing our "quick setup" to
// appear to fail or take longer than "quick".
records, err := mox.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "")
records, err := admin.DomainRecords(confDomain, domain, domainDNSSECResult.Authentic, "letsencrypt.org", "")
if err != nil {
fatalf("making required DNS records")
}

View file

@ -266,7 +266,7 @@ Only implemented on unix systems, not Windows.
if mox.Conf.Static.CheckUpdates {
checkUpdates := func() time.Duration {
next := 24 * time.Hour
current, lastknown, mtime, err := mox.LastKnown()
current, lastknown, mtime, err := store.LastKnown()
if err != nil {
log.Infox("determining own version before checking for updates, trying again in 24h", err)
return next
@ -350,7 +350,7 @@ Only implemented on unix systems, not Windows.
slog.Any("current", current),
slog.Any("lastknown", lastknown),
slog.Any("latest", latest))
if err := mox.StoreLastKnown(latest); err != nil {
if err := store.StoreLastKnown(latest); err != nil {
// This will be awkward, we'll keep notifying the postmaster once every 24h...
log.Infox("updating last known version", err)
}

View file

@ -1,4 +1,4 @@
package mox
package store
import (
"fmt"
@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxvar"
"github.com/mjl-/mox/updates"
)
@ -13,7 +14,7 @@ import (
// 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)
return os.WriteFile(mox.DataDirPath("lastknownversion"), []byte(v.String()), 0660)
}
// LastKnown returns the last known version that has been mentioned in an update
@ -21,7 +22,7 @@ func StoreLastKnown(v updates.Version) error {
func LastKnown() (current, lastknown updates.Version, mtime time.Time, rerr error) {
curv, curerr := updates.ParseVersion(moxvar.VersionBare)
p := DataDirPath("lastknownversion")
p := mox.DataDirPath("lastknownversion")
fi, _ := os.Stat(p)
if fi != nil {
mtime = fi.ModTime()

View file

@ -26,6 +26,7 @@ import (
"github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-"
@ -110,7 +111,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
return
}
// If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) {
if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
xcheckuserf(ctx, err, format, args...)
}
@ -433,7 +434,7 @@ func (Account) Account(ctx context.Context) (account config.Account, storageUsed
// for the account.
func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.FullName = fullName
})
xcheckf(ctx, err, "saving account full name")
@ -445,7 +446,7 @@ func (Account) AccountSaveFullName(ctx context.Context, fullName string) {
func (Account) DestinationSave(ctx context.Context, destName string, oldDest, newDest config.Destination) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(conf *config.Account) {
curDest, ok := conf.Destinations[destName]
if !ok {
xcheckuserf(ctx, errors.New("not found"), "looking up destination")
@ -527,7 +528,7 @@ func (Account) SuppressionRemove(ctx context.Context, address string) {
// to be delivered, or all if empty/nil.
func (Account) OutgoingWebhookSave(ctx context.Context, url, authorization string, events []string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if url == "" {
acc.OutgoingWebhook = nil
} else {
@ -566,7 +567,7 @@ func (Account) OutgoingWebhookTest(ctx context.Context, urlStr, authorization st
// the Authorization header in requests.
func (Account) IncomingWebhookSave(ctx context.Context, url, authorization string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if url == "" {
acc.IncomingWebhook = nil
} else {
@ -611,7 +612,7 @@ func (Account) IncomingWebhookTest(ctx context.Context, urlStr, authorization st
// MAIL FROM addresses ("fromid") for deliveries from the queue.
func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.FromIDLoginAddresses = loginAddresses
})
xcheckf(ctx, err, "saving account fromid login addresses")
@ -620,7 +621,7 @@ func (Account) FromIDLoginAddressesSave(ctx context.Context, loginAddresses []st
// KeepRetiredPeriodsSave saves periods to save retired messages and webhooks.
func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePeriod, keepRetiredWebhookPeriod time.Duration) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.KeepRetiredMessagePeriod = keepRetiredMessagePeriod
acc.KeepRetiredWebhookPeriod = keepRetiredWebhookPeriod
})
@ -631,7 +632,7 @@ func (Account) KeepRetiredPeriodsSave(ctx context.Context, keepRetiredMessagePer
// junk/nonjunk when moved to mailboxes matching certain regular expressions.
func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkRegexp, neutralRegexp, notJunkRegexp string) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.AutomaticJunkFlags = config.AutomaticJunkFlags{
Enabled: enabled,
JunkMailboxRegexp: junkRegexp,
@ -646,7 +647,7 @@ func (Account) AutomaticJunkFlagsSave(ctx context.Context, enabled bool, junkReg
// is disabled. Otherwise all fields except Threegrams are stored.
func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
if junkFilter == nil {
acc.JunkFilter = nil
return
@ -664,7 +665,7 @@ func (Account) JunkFilterSave(ctx context.Context, junkFilter *config.JunkFilter
// RejectsSave saves the RejectsMailbox and KeepRejects settings.
func (Account) RejectsSave(ctx context.Context, mailbox string, keep bool) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.AccountName, func(acc *config.Account) {
acc.RejectsMailbox = mailbox
acc.KeepRejects = keep
})

View file

@ -45,6 +45,7 @@ import (
"github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dmarc"
@ -209,7 +210,7 @@ func xcheckf(ctx context.Context, err error, format string, args ...any) {
return
}
// If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
if errors.Is(err, mox.ErrConfig) || errors.Is(err, mox.ErrRequest) {
if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
xcheckuserf(ctx, err, format, args...)
}
@ -1898,7 +1899,7 @@ func (Admin) MonitorDNSBLsSave(ctx context.Context, text string) {
zones = append(zones, d)
}
err := mox.ConfigSave(ctx, func(conf *config.Dynamic) {
err := admin.ConfigSave(ctx, func(conf *config.Dynamic) {
conf.MonitorDNSBLs = make([]string, len(zones))
conf.MonitorDNSBLZones = nil
for i, z := range zones {
@ -1944,7 +1945,7 @@ func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string {
}
}
records, err := mox.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
records, err := admin.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
xcheckf(ctx, err, "dns records")
return records
}
@ -1954,7 +1955,7 @@ func (Admin) DomainAdd(ctx context.Context, domain, accountName, localpart strin
d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain")
err = mox.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart)))
err = admin.DomainAdd(ctx, d, accountName, smtp.Localpart(norm.NFC.String(localpart)))
xcheckf(ctx, err, "adding domain")
}
@ -1963,32 +1964,32 @@ func (Admin) DomainRemove(ctx context.Context, domain string) {
d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain")
err = mox.DomainRemove(ctx, d)
err = admin.DomainRemove(ctx, d)
xcheckf(ctx, err, "removing domain")
}
// AccountAdd adds existing a new account, with an initial email address, and
// reloads the configuration.
func (Admin) AccountAdd(ctx context.Context, accountName, address string) {
err := mox.AccountAdd(ctx, accountName, address)
err := admin.AccountAdd(ctx, accountName, address)
xcheckf(ctx, err, "adding account")
}
// AccountRemove removes an existing account and reloads the configuration.
func (Admin) AccountRemove(ctx context.Context, accountName string) {
err := mox.AccountRemove(ctx, accountName)
err := admin.AccountRemove(ctx, accountName)
xcheckf(ctx, err, "removing account")
}
// AddressAdd adds a new address to the account, which must already exist.
func (Admin) AddressAdd(ctx context.Context, address, accountName string) {
err := mox.AddressAdd(ctx, address, accountName)
err := admin.AddressAdd(ctx, address, accountName)
xcheckf(ctx, err, "adding address")
}
// AddressRemove removes an existing address.
func (Admin) AddressRemove(ctx context.Context, address string) {
err := mox.AddressRemove(ctx, address)
err := admin.AddressRemove(ctx, address)
xcheckf(ctx, err, "removing address")
}
@ -2012,7 +2013,7 @@ func (Admin) SetPassword(ctx context.Context, accountName, password string) {
// AccountSettingsSave set new settings for an account that only an admin can set.
func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay bool) {
err := mox.AccountSave(ctx, accountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, accountName, func(acc *config.Account) {
acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay
acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay
acc.QuotaMessageSize = maxMsgSize
@ -2023,11 +2024,11 @@ func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOut
// ClientConfigsDomain returns configurations for email clients, IMAP and
// Submission (SMTP) for the domain.
func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientConfigs {
func (Admin) ClientConfigsDomain(ctx context.Context, domain string) admin.ClientConfigs {
d, err := dns.ParseDomain(domain)
xcheckuserf(ctx, err, "parsing domain")
cc, err := mox.ClientConfigsDomain(d)
cc, err := admin.ClientConfigsDomain(d)
xcheckf(ctx, err, "client config for domain")
return cc
}
@ -2281,7 +2282,7 @@ func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf Webserver
domainRedirects[x[0]] = x[1]
}
err := mox.ConfigSave(ctx, func(conf *config.Dynamic) {
err := admin.ConfigSave(ctx, func(conf *config.Dynamic) {
conf.WebDomainRedirects = domainRedirects
conf.WebHandlers = newConf.WebHandlers
})
@ -2458,7 +2459,7 @@ func (Admin) Config(ctx context.Context) config.Dynamic {
// AccountRoutesSave saves routes for an account.
func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes []config.Route) {
err := mox.AccountSave(ctx, accountName, func(acc *config.Account) {
err := admin.AccountSave(ctx, accountName, func(acc *config.Account) {
acc.Routes = routes
})
xcheckf(ctx, err, "saving account routes")
@ -2466,7 +2467,7 @@ func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes [
// DomainRoutesSave saves routes for a domain.
func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []config.Route) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.Routes = routes
return nil
})
@ -2475,7 +2476,7 @@ func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []c
// RoutesSave saves global routes.
func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
err := mox.ConfigSave(ctx, func(config *config.Dynamic) {
err := admin.ConfigSave(ctx, func(config *config.Dynamic) {
config.Routes = routes
})
xcheckf(ctx, err, "saving global routes")
@ -2483,7 +2484,7 @@ func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
// DomainDescriptionSave saves the description for a domain.
func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.Description = descr
return nil
})
@ -2492,7 +2493,7 @@ func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string
// DomainClientSettingsDomainSave saves the client settings domain for a domain.
func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.ClientSettingsDomain = clientSettingsDomain
return nil
})
@ -2502,7 +2503,7 @@ func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, cli
// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
// settings for a domain.
func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpartCatchallSeparator string, localpartCaseSensitive bool) {
err := mox.DomainSave(ctx, domainName, func(domain *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
domain.LocalpartCatchallSeparator = localpartCatchallSeparator
domain.LocalpartCaseSensitive = localpartCaseSensitive
return nil
@ -2514,7 +2515,7 @@ func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName, localpar
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if localpart == "" {
d.DMARC = nil
} else {
@ -2534,7 +2535,7 @@ func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart,
// configuration for a domain. If localpart is empty, processing reports is
// disabled.
func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if localpart == "" {
d.TLSRPT = nil
} else {
@ -2553,7 +2554,7 @@ func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart,
// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
// no MTASTS policy is served.
func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) {
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if policyID == "" {
d.MTASTS = nil
} else {
@ -2576,7 +2577,7 @@ func (Admin) DomainDKIMAdd(ctx context.Context, domainName, selector, algorithm,
xcheckuserf(ctx, err, "parsing domain")
s, err := dns.ParseDomain(selector)
xcheckuserf(ctx, err, "parsing selector")
err = mox.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime)
err = admin.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime)
xcheckf(ctx, err, "adding dkim key")
}
@ -2586,7 +2587,7 @@ func (Admin) DomainDKIMRemove(ctx context.Context, domainName, selector string)
xcheckuserf(ctx, err, "parsing domain")
s, err := dns.ParseDomain(selector)
xcheckuserf(ctx, err, "parsing selector")
err = mox.DKIMRemove(ctx, d, s)
err = admin.DKIMRemove(ctx, d, s)
xcheckf(ctx, err, "removing dkim key")
}
@ -2600,7 +2601,7 @@ func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors ma
}
}
err := mox.DomainSave(ctx, domainName, func(d *config.Domain) error {
err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
if len(selectors) != len(d.DKIM.Selectors) {
xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors")
}
@ -2649,7 +2650,7 @@ func xparseAddress(ctx context.Context, lp, domain string) smtp.Address {
func (Admin) AliasAdd(ctx context.Context, aliaslp string, domainName string, alias config.Alias) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAdd(ctx, addr, alias)
err := admin.AliasAdd(ctx, addr, alias)
xcheckf(ctx, err, "adding alias")
}
@ -2660,24 +2661,24 @@ func (Admin) AliasUpdate(ctx context.Context, aliaslp string, domainName string,
ListMembers: listMembers,
AllowMsgFrom: allowMsgFrom,
}
err := mox.AliasUpdate(ctx, addr, alias)
err := admin.AliasUpdate(ctx, addr, alias)
xcheckf(ctx, err, "saving alias")
}
func (Admin) AliasRemove(ctx context.Context, aliaslp string, domainName string) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasRemove(ctx, addr)
err := admin.AliasRemove(ctx, addr)
xcheckf(ctx, err, "removing alias")
}
func (Admin) AliasAddressesAdd(ctx context.Context, aliaslp string, domainName string, addresses []string) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAddressesAdd(ctx, addr, addresses)
err := admin.AliasAddressesAdd(ctx, addr, addresses)
xcheckf(ctx, err, "adding address to alias")
}
func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainName string, addresses []string) {
addr := xparseAddress(ctx, aliaslp, domainName)
err := mox.AliasAddressesRemove(ctx, addr, addresses)
err := admin.AliasAddressesRemove(ctx, addr, addresses)
xcheckf(ctx, err, "removing address from alias")
}

View file

@ -33,6 +33,7 @@ import (
"github.com/mjl-/sherpadoc"
"github.com/mjl-/sherpaprom"
"github.com/mjl-/mox/admin"
"github.com/mjl-/mox/config"
"github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns"
@ -1986,7 +1987,7 @@ func parseListID(s string) (listID string, dom dns.Domain) {
func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
dest, ok := acc.Destinations[rcptTo]
if !ok {
// todo: we could find the catchall address and add the rule, or add the address explicitly.
@ -2007,7 +2008,7 @@ func (Webmail) RulesetAdd(ctx context.Context, rcptTo string, ruleset config.Rul
func (Webmail) RulesetRemove(ctx context.Context, rcptTo string, ruleset config.Ruleset) {
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
err := mox.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
err := admin.AccountSave(ctx, reqInfo.Account.Name, func(acc *config.Account) {
dest, ok := acc.Destinations[rcptTo]
if !ok {
xcheckuserf(ctx, errors.New("destination address not found in account"), "looking up address")