From da3ed38a5cb07617690e6a0b2f484e78a79eefc5 Mon Sep 17 00:00:00 2001 From: Mechiel Lukkien Date: Sun, 24 Dec 2023 11:01:16 +0100 Subject: [PATCH] assume a dns cname record mail., pointing to the hostname of the mail server, for clients to connect to the autoconfig/autodiscover endpoints, and the printed client settings (in quickstart, in the admin interface) now all point to the cname record (called "client settings domain"). it is configurable per domain, and set to "mail." by default. for existing mox installs, the domain can be added by editing the config file. this makes it easier for a domain to migrate to another server in the future. client settings don't have to be updated, the cname can just be changed. before, the hostname of the mail server was configured in email clients. migrating away would require changing settings in all clients. if a client settings domain is configured, a TLS certificate for the name will be requested through ACME, or must be configured manually. --- README.md | 1 - config/config.go | 4 +++- config/doc.go | 8 ++++++++ mox-/admin.go | 29 ++++++++++++++++++++++++++--- mox-/config.go | 12 ++++++++++++ webadmin/admin.go | 22 ++++++++++++++++++++-- webadmin/admin.html | 5 +++-- webadmin/adminapi.json | 8 ++++++++ 8 files changed, 80 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 26d1ad0..15012a2 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,6 @@ https://nlnet.nl/project/Mox/. - Integrate account page into webmail - Improve documentation -- Per-domain webmail and IMAP/SMTP host name (and TLS cert) and client settings - Authentication other than HTTP-basic for webmail/webadmin - Improve SMTP delivery from queue - Webmail improvements diff --git a/config/config.go b/config/config.go index a9c335d..ee49ad5 100644 --- a/config/config.go +++ b/config/config.go @@ -278,6 +278,7 @@ type TransportSocks struct { type Domain struct { Description string `sconf:"optional" sconf-doc:"Free-form description of domain."` + ClientSettingsDomain string `sconf:"optional" sconf-doc:"Hostname for client settings instead of the mail server hostname. E.g. mail.. For future migration to another mail operator without requiring all clients to update their settings, it is convenient to have client settings that reference a subdomain of the hosted domain instead of the hostname of the server where the mail is currently hosted. If empty, the hostname of the mail server is used for client configurations."` LocalpartCatchallSeparator string `sconf:"optional" sconf-doc:"If not empty, only the string before the separator is used to for email delivery decisions. For example, if set to \"+\", you+anything@example.com will be delivered to you@example.com."` LocalpartCaseSensitive bool `sconf:"optional" sconf-doc:"If set, upper/lower case is relevant for email delivery."` DKIM DKIM `sconf:"optional" sconf-doc:"With DKIM signing, a domain is taking responsibility for (content of) emails it sends, letting receiving mail servers build up a (hopefully positive) reputation of the domain, which can help with mail delivery."` @@ -286,7 +287,8 @@ type Domain struct { TLSRPT *TLSRPT `sconf:"optional" sconf-doc:"With TLSRPT a domain specifies in DNS where reports about encountered SMTP TLS behaviour should be sent. Useful for monitoring. Incoming TLS reports are automatically parsed, validated, added to metrics and stored in the reporting database for later display in the admin web pages."` Routes []Route `sconf:"optional" sconf-doc:"Routes for delivering outgoing messages through the queue. Each delivery attempt evaluates account routes, these domain routes and finally global routes. The transport of the first matching route is used in the delivery attempt. If no routes match, which is the default with no configured routes, messages are delivered directly from the queue."` - Domain dns.Domain `sconf:"-" json:"-"` + Domain dns.Domain `sconf:"-" json:"-"` + ClientSettingsDNSDomain dns.Domain `sconf:"-" json:"-"` } type DMARC struct { diff --git a/config/doc.go b/config/doc.go index aee588e..850a98f 100644 --- a/config/doc.go +++ b/config/doc.go @@ -638,6 +638,14 @@ describe-static" and "mox config describe-domains": # Free-form description of domain. (optional) Description: + # Hostname for client settings instead of the mail server hostname. E.g. + # mail.. For future migration to another mail operator without requiring + # all clients to update their settings, it is convenient to have client settings + # that reference a subdomain of the hosted domain instead of the hostname of the + # server where the mail is currently hosted. If empty, the hostname of the mail + # server is used for client configurations. (optional) + ClientSettingsDomain: + # If not empty, only the string before the separator is used to for email delivery # decisions. For example, if set to "+", you+anything@example.com will be # delivered to you@example.com. (optional) diff --git a/mox-/admin.go b/mox-/admin.go index 6eaa429..c5f51ed 100644 --- a/mox-/admin.go +++ b/mox-/admin.go @@ -250,6 +250,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN confDKIM.Sign = []string{year + "a", year + "b"} confDomain := config.Domain{ + ClientSettingsDomain: "mail." + domain.Name(), LocalpartCatchallSeparator: "+", DKIM: confDKIM, DMARC: &config.DMARC{ @@ -653,6 +654,16 @@ func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, cer ) } + 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), @@ -694,8 +705,13 @@ func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, cer "; 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(`;; mtasts.%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, ";", @@ -1077,7 +1093,8 @@ type ClientConfig struct { func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) { var haveIMAP, haveSubmission bool - if _, ok := Conf.Domain(d); !ok { + domConf, ok := Conf.Domain(d) + if !ok { return ClientConfig{}, fmt.Errorf("unknown domain") } @@ -1086,6 +1103,9 @@ func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) { 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) @@ -1153,7 +1173,7 @@ type ClientConfigsEntry struct { // ClientConfigsDomain returns the client configs for IMAP/Submission for a // domain. func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) { - _, ok := Conf.Domain(d) + domConf, ok := Conf.Domain(d) if !ok { return ClientConfigs{}, fmt.Errorf("unknown domain") } @@ -1185,6 +1205,9 @@ func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) { 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"}) } diff --git a/mox-/config.go b/mox-/config.go index d595a43..fd8a286 100644 --- a/mox-/config.go +++ b/mox-/config.go @@ -279,6 +279,10 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) { hostnames[d] = struct{}{} } } + + if dom.ClientSettingsDomain != "" { + hostnames[dom.ClientSettingsDNSDomain] = struct{}{} + } } if l.WebserverHTTPS.Enabled { @@ -1086,6 +1090,14 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string, domain.Domain = dnsdomain + if domain.ClientSettingsDomain != "" { + csd, err := dns.ParseDomain(domain.ClientSettingsDomain) + if err != nil { + addErrorf("bad client settings domain %q: %s", domain.ClientSettingsDomain, err) + } + domain.ClientSettingsDNSDomain = csd + } + for _, sign := range domain.DKIM.Sign { if _, ok := domain.DKIM.Selectors[sign]; !ok { addErrorf("selector %s for signing is missing in domain %s", sign, d) diff --git a/webadmin/admin.go b/webadmin/admin.go index f6ad046..35ebbc2 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -343,7 +343,8 @@ type SRVConfCheckResult struct { } type AutoconfCheckResult struct { - IPs []string + ClientSettingsDomainIPs []string + IPs []string Result } @@ -440,7 +441,7 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer, *l = append(*l, fmt.Sprintf(format, args...)) } - // host must be an absolute dns name, ending with a dot. + // Host must be an absolute dns name, ending with a dot. lookupIPs := func(errors *[]string, host string) (ips []string, ourIPs, notOurIPs []net.IP, rerr error) { addrs, _, err := resolver.LookupHost(ctx, host) if err != nil { @@ -1383,6 +1384,23 @@ When enabling MTA-STS, or updating a policy, always update the policy first (thr defer logPanic(ctx) defer wg.Done() + if domConf.ClientSettingsDomain != "" { + addf(&r.Autoconf.Instructions, "Ensure a DNS CNAME record like the following exists:\n\n\t%s CNAME %s\n\nNote: the trailing dot is relevant, it makes the host name absolute instead of relative to the domain name.", domConf.ClientSettingsDNSDomain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".") + + ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, domConf.ClientSettingsDNSDomain.ASCII+".") + if err != nil { + addf(&r.Autoconf.Errors, "Looking up client settings DNS CNAME: %s", err) + } + r.Autoconf.ClientSettingsDomainIPs = ips + if !isUnspecifiedNAT { + if len(ourIPs) == 0 { + addf(&r.Autoconf.Errors, "Client settings domain does not point to one of our IPs.") + } else if len(notOurIPs) > 0 { + addf(&r.Autoconf.Errors, "Client settings domain points to some IPs that are not ours: %v", notOurIPs) + } + } + } + addf(&r.Autoconf.Instructions, "Ensure a DNS CNAME record like the following exists:\n\n\tautoconfig.%s CNAME %s\n\nNote: the trailing dot is relevant, it makes the host name absolute instead of relative to the domain name.", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".") host := "autoconfig." + domain.ASCII + "." diff --git a/webadmin/admin.html b/webadmin/admin.html index bc036ab..fa10daf 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -1025,8 +1025,9 @@ const domainDNSCheck = async (d) => { }), ), ] - const detailsAutoconf = !checks.Autoconf.IPs ? [] : [ - dom.div('IPs: ' + checks.Autoconf.IPs.join(', ')), + const detailsAutoconf = [ + ...(!checks.Autoconf.ClientSettingsDomainIPs ? [] : [dom.div('Client settings domain IPs: ' + checks.Autoconf.ClientSettingsDomainIPs.join(', '))]), + ...(!checks.Autoconf.IPs ? [] : [dom.div('IPs: ' + checks.Autoconf.IPs.join(', '))]), ] const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [ dom.table( diff --git a/webadmin/adminapi.json b/webadmin/adminapi.json index 942feeb..7351c5e 100644 --- a/webadmin/adminapi.json +++ b/webadmin/adminapi.json @@ -2225,6 +2225,14 @@ "Name": "AutoconfCheckResult", "Docs": "", "Fields": [ + { + "Name": "ClientSettingsDomainIPs", + "Docs": "", + "Typewords": [ + "[]", + "string" + ] + }, { "Name": "IPs", "Docs": "",