From 5f7831a7f0d462e62dfd46512719cd38ad3a9aeb Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Mon, 2 Dec 2024 22:03:18 +0100 Subject: [PATCH] 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. --- {mox- => admin}/admin.go | 720 +++-------------------------------- admin/clientconfig.go | 168 ++++++++ admin/dnsrecords.go | 320 ++++++++++++++++ ctl.go | 23 +- http/autoconf.go | 22 +- http/mobileconfig.go | 8 +- localserve.go | 3 +- main.go | 11 +- mox-/config.go | 39 +- mox-/dir.go | 9 +- mox-/ip.go | 109 ++++++ mox-/txt.go | 24 ++ quickstart.go | 7 +- serve_unix.go | 4 +- {mox- => store}/lastknown.go | 7 +- webaccount/account.go | 21 +- webadmin/admin.go | 61 +-- webmail/api.go | 5 +- 18 files changed, 805 insertions(+), 756 deletions(-) rename {mox- => admin}/admin.go (57%) create mode 100644 admin/clientconfig.go create mode 100644 admin/dnsrecords.go create mode 100644 mox-/txt.go rename {mox- => store}/lastknown.go (88%) diff --git a/mox-/admin.go b/admin/admin.go similarity index 57% rename from mox-/admin.go rename to admin/admin.go index ac4dc7f..17d5172 100644 --- a/mox-/admin.go +++ b/admin/admin.go @@ -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 -} diff --git a/admin/clientconfig.go b/admin/clientconfig.go new file mode 100644 index 0000000..df78967 --- /dev/null +++ b/admin/clientconfig.go @@ -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 +} diff --git a/admin/dnsrecords.go b/admin/dnsrecords.go new file mode 100644 index 0000000..5c92b59 --- /dev/null +++ b/admin/dnsrecords.go @@ -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 +} diff --git a/ctl.go b/ctl.go index 1722553..ed0018d 100644 --- a/ctl.go +++ b/ctl.go @@ -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() diff --git a/http/autoconf.go b/http/autoconf.go index 3c877e4..383779d 100644 --- a/http/autoconf.go +++ b/http/autoconf.go @@ -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) } diff --git a/http/mobileconfig.go b/http/mobileconfig.go index 373573e..af29fa6 100644 --- a/http/mobileconfig.go +++ b/http/mobileconfig.go @@ -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", diff --git a/localserve.go b/localserve.go index 0a74545..48f65f1 100644 --- a/localserve.go +++ b/localserve.go @@ -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) diff --git a/main.go b/main.go index 38b4d14..b68249f 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/mox-/config.go b/mox-/config.go index a238f07..1abd00c 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -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) diff --git a/mox-/dir.go b/mox-/dir.go index 792b673..7ea3e52 100644 --- a/mox-/dir.go +++ b/mox-/dir.go @@ -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. diff --git a/mox-/ip.go b/mox-/ip.go index 116322b..c5d9f28 100644 --- a/mox-/ip.go +++ b/mox-/ip.go @@ -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 +} diff --git a/mox-/txt.go b/mox-/txt.go new file mode 100644 index 0000000..47bc173 --- /dev/null +++ b/mox-/txt.go @@ -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 +} diff --git a/quickstart.go b/quickstart.go index 7740030..4dc7e26 100644 --- a/quickstart.go +++ b/quickstart.go @@ -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") } diff --git a/serve_unix.go b/serve_unix.go index 1d5e772..280e72e 100644 --- a/serve_unix.go +++ b/serve_unix.go @@ -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) } diff --git a/mox-/lastknown.go b/store/lastknown.go similarity index 88% rename from mox-/lastknown.go rename to store/lastknown.go index 1cf9c35..ef8a1b8 100644 --- a/mox-/lastknown.go +++ b/store/lastknown.go @@ -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() diff --git a/webaccount/account.go b/webaccount/account.go index d5cef38..cc0d74b 100644 --- a/webaccount/account.go +++ b/webaccount/account.go @@ -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 }) diff --git a/webadmin/admin.go b/webadmin/admin.go index 207ba5d..9e09143 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -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") } diff --git a/webmail/api.go b/webmail/api.go index fc9b7e3..1800d4c 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -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")