assume a dns cname record mail.<domain>, 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.<domain>" 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.
This commit is contained in:
Mechiel Lukkien 2023-12-24 11:01:16 +01:00
parent e7478ed6ac
commit da3ed38a5c
No known key found for this signature in database
8 changed files with 80 additions and 9 deletions

View file

@ -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

View file

@ -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.<domain>. 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 {

View file

@ -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.<domain>. 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)

View file

@ -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"})
}

View file

@ -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)

View file

@ -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 + "."

View file

@ -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(

View file

@ -2225,6 +2225,14 @@
"Name": "AutoconfCheckResult",
"Docs": "",
"Fields": [
{
"Name": "ClientSettingsDomainIPs",
"Docs": "",
"Typewords": [
"[]",
"string"
]
},
{
"Name": "IPs",
"Docs": "",