mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
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:
parent
e7478ed6ac
commit
da3ed38a5c
8 changed files with 80 additions and 9 deletions
|
@ -117,7 +117,6 @@ https://nlnet.nl/project/Mox/.
|
||||||
|
|
||||||
- Integrate account page into webmail
|
- Integrate account page into webmail
|
||||||
- Improve documentation
|
- Improve documentation
|
||||||
- Per-domain webmail and IMAP/SMTP host name (and TLS cert) and client settings
|
|
||||||
- Authentication other than HTTP-basic for webmail/webadmin
|
- Authentication other than HTTP-basic for webmail/webadmin
|
||||||
- Improve SMTP delivery from queue
|
- Improve SMTP delivery from queue
|
||||||
- Webmail improvements
|
- Webmail improvements
|
||||||
|
|
|
@ -278,6 +278,7 @@ type TransportSocks struct {
|
||||||
|
|
||||||
type Domain struct {
|
type Domain struct {
|
||||||
Description string `sconf:"optional" sconf-doc:"Free-form description of domain."`
|
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."`
|
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."`
|
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."`
|
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."`
|
||||||
|
@ -287,6 +288,7 @@ type Domain struct {
|
||||||
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."`
|
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 {
|
type DMARC struct {
|
||||||
|
|
|
@ -638,6 +638,14 @@ describe-static" and "mox config describe-domains":
|
||||||
# Free-form description of domain. (optional)
|
# Free-form description of domain. (optional)
|
||||||
Description:
|
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
|
# 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
|
# decisions. For example, if set to "+", you+anything@example.com will be
|
||||||
# delivered to you@example.com. (optional)
|
# delivered to you@example.com. (optional)
|
||||||
|
|
|
@ -250,6 +250,7 @@ func MakeDomainConfig(ctx context.Context, domain, hostname dns.Domain, accountN
|
||||||
confDKIM.Sign = []string{year + "a", year + "b"}
|
confDKIM.Sign = []string{year + "a", year + "b"}
|
||||||
|
|
||||||
confDomain := config.Domain{
|
confDomain := config.Domain{
|
||||||
|
ClientSettingsDomain: "mail." + domain.Name(),
|
||||||
LocalpartCatchallSeparator: "+",
|
LocalpartCatchallSeparator: "+",
|
||||||
DKIM: confDKIM,
|
DKIM: confDKIM,
|
||||||
DMARC: &config.DMARC{
|
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,
|
records = append(records,
|
||||||
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
|
"; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
|
||||||
fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
|
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",
|
"; Or alternatively only limit for email-specific subdomains, so you can use",
|
||||||
"; other accounts/methods for other subdomains.",
|
"; 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(`;; 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) {
|
if strings.HasSuffix(h, "."+d) {
|
||||||
records = append(records,
|
records = append(records,
|
||||||
";",
|
";",
|
||||||
|
@ -1077,7 +1093,8 @@ type ClientConfig struct {
|
||||||
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
|
func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
|
||||||
var haveIMAP, haveSubmission bool
|
var haveIMAP, haveSubmission bool
|
||||||
|
|
||||||
if _, ok := Conf.Domain(d); !ok {
|
domConf, ok := Conf.Domain(d)
|
||||||
|
if !ok {
|
||||||
return ClientConfig{}, fmt.Errorf("unknown domain")
|
return ClientConfig{}, fmt.Errorf("unknown domain")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1086,6 +1103,9 @@ func ClientConfigDomain(d dns.Domain) (rconfig ClientConfig, rerr error) {
|
||||||
if l.Hostname != "" {
|
if l.Hostname != "" {
|
||||||
host = l.HostnameDomain
|
host = l.HostnameDomain
|
||||||
}
|
}
|
||||||
|
if domConf.ClientSettingsDomain != "" {
|
||||||
|
host = domConf.ClientSettingsDNSDomain
|
||||||
|
}
|
||||||
if !haveIMAP && l.IMAPS.Enabled {
|
if !haveIMAP && l.IMAPS.Enabled {
|
||||||
rconfig.IMAP.Host = host
|
rconfig.IMAP.Host = host
|
||||||
rconfig.IMAP.Port = config.Port(l.IMAPS.Port, 993)
|
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
|
// ClientConfigsDomain returns the client configs for IMAP/Submission for a
|
||||||
// domain.
|
// domain.
|
||||||
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
|
func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
|
||||||
_, ok := Conf.Domain(d)
|
domConf, ok := Conf.Domain(d)
|
||||||
if !ok {
|
if !ok {
|
||||||
return ClientConfigs{}, fmt.Errorf("unknown domain")
|
return ClientConfigs{}, fmt.Errorf("unknown domain")
|
||||||
}
|
}
|
||||||
|
@ -1185,6 +1205,9 @@ func ClientConfigsDomain(d dns.Domain) (ClientConfigs, error) {
|
||||||
if l.Hostname != "" {
|
if l.Hostname != "" {
|
||||||
host = l.HostnameDomain
|
host = l.HostnameDomain
|
||||||
}
|
}
|
||||||
|
if domConf.ClientSettingsDomain != "" {
|
||||||
|
host = domConf.ClientSettingsDNSDomain
|
||||||
|
}
|
||||||
if l.Submissions.Enabled {
|
if l.Submissions.Enabled {
|
||||||
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
|
c.Entries = append(c.Entries, ClientConfigsEntry{"Submission (SMTP)", host, config.Port(l.Submissions.Port, 465), name, "with TLS"})
|
||||||
}
|
}
|
||||||
|
|
|
@ -279,6 +279,10 @@ func (c *Config) allowACMEHosts(log mlog.Log, checkACMEHosts bool) {
|
||||||
hostnames[d] = struct{}{}
|
hostnames[d] = struct{}{}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if dom.ClientSettingsDomain != "" {
|
||||||
|
hostnames[dom.ClientSettingsDNSDomain] = struct{}{}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if l.WebserverHTTPS.Enabled {
|
if l.WebserverHTTPS.Enabled {
|
||||||
|
@ -1086,6 +1090,14 @@ func prepareDynamicConfig(ctx context.Context, log mlog.Log, dynamicPath string,
|
||||||
|
|
||||||
domain.Domain = dnsdomain
|
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 {
|
for _, sign := range domain.DKIM.Sign {
|
||||||
if _, ok := domain.DKIM.Selectors[sign]; !ok {
|
if _, ok := domain.DKIM.Selectors[sign]; !ok {
|
||||||
addErrorf("selector %s for signing is missing in domain %s", sign, d)
|
addErrorf("selector %s for signing is missing in domain %s", sign, d)
|
||||||
|
|
|
@ -343,6 +343,7 @@ type SRVConfCheckResult struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type AutoconfCheckResult struct {
|
type AutoconfCheckResult struct {
|
||||||
|
ClientSettingsDomainIPs []string
|
||||||
IPs []string
|
IPs []string
|
||||||
Result
|
Result
|
||||||
}
|
}
|
||||||
|
@ -440,7 +441,7 @@ func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer,
|
||||||
*l = append(*l, fmt.Sprintf(format, args...))
|
*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) {
|
lookupIPs := func(errors *[]string, host string) (ips []string, ourIPs, notOurIPs []net.IP, rerr error) {
|
||||||
addrs, _, err := resolver.LookupHost(ctx, host)
|
addrs, _, err := resolver.LookupHost(ctx, host)
|
||||||
if err != nil {
|
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 logPanic(ctx)
|
||||||
defer wg.Done()
|
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+".")
|
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 + "."
|
host := "autoconfig." + domain.ASCII + "."
|
||||||
|
|
|
@ -1025,8 +1025,9 @@ const domainDNSCheck = async (d) => {
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
const detailsAutoconf = !checks.Autoconf.IPs ? [] : [
|
const detailsAutoconf = [
|
||||||
dom.div('IPs: ' + checks.Autoconf.IPs.join(', ')),
|
...(!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 ? [] : [
|
const detailsAutodiscover = !checks.Autodiscover.Records ? [] : [
|
||||||
dom.table(
|
dom.table(
|
||||||
|
|
|
@ -2225,6 +2225,14 @@
|
||||||
"Name": "AutoconfCheckResult",
|
"Name": "AutoconfCheckResult",
|
||||||
"Docs": "",
|
"Docs": "",
|
||||||
"Fields": [
|
"Fields": [
|
||||||
|
{
|
||||||
|
"Name": "ClientSettingsDomainIPs",
|
||||||
|
"Docs": "",
|
||||||
|
"Typewords": [
|
||||||
|
"[]",
|
||||||
|
"string"
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"Name": "IPs",
|
"Name": "IPs",
|
||||||
"Docs": "",
|
"Docs": "",
|
||||||
|
|
Loading…
Reference in a new issue