1package admin
2
3import (
4 "crypto"
5 "crypto/ed25519"
6 "crypto/rsa"
7 "crypto/sha256"
8 "crypto/x509"
9 "fmt"
10 "net/url"
11 "strings"
12
13 "github.com/mjl-/adns"
14
15 "github.com/mjl-/mox/config"
16 "github.com/mjl-/mox/dkim"
17 "github.com/mjl-/mox/dmarc"
18 "github.com/mjl-/mox/dns"
19 "github.com/mjl-/mox/mox-"
20 "github.com/mjl-/mox/smtp"
21 "github.com/mjl-/mox/spf"
22 "github.com/mjl-/mox/tlsrpt"
23 "slices"
24)
25
26// 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.
27
28// DomainRecords returns text lines describing DNS records required for configuring
29// a domain.
30//
31// If certIssuerDomainName is set, CAA records to limit TLS certificate issuance to
32// that caID will be suggested. If acmeAccountURI is also set, CAA records also
33// restricting issuance to that account ID will be suggested.
34func DomainRecords(domConf config.Domain, domain dns.Domain, hasDNSSEC bool, certIssuerDomainName, acmeAccountURI string) ([]string, error) {
35 d := domain.ASCII
36 h := mox.Conf.Static.HostnameDomain.ASCII
37 csd := h
38 if domConf.ClientSettingsDomain != "" && domConf.ClientSettingsDNSDomain != mox.Conf.Static.HostnameDomain {
39 csd = domConf.ClientSettingsDNSDomain.ASCII
40 }
41
42 // The first line with ";" is used by ../testdata/integration/moxacmepebble.sh and
43 // ../testdata/integration/moxmail2.sh for selecting DNS records
44 records := []string{
45 "; Time To Live of 5 minutes, may be recognized if importing as a zone file.",
46 "; Once your setup is working, you may want to increase the TTL.",
47 "$TTL 300",
48 "",
49 }
50
51 if public, ok := mox.Conf.Static.Listeners["public"]; ok && public.TLS != nil && (len(public.TLS.HostPrivateRSA2048Keys) > 0 || len(public.TLS.HostPrivateECDSAP256Keys) > 0) {
52 records = append(records,
53 `; DANE: These records indicate that a remote mail server trying to deliver email`,
54 `; with SMTP (TCP port 25) must verify the TLS certificate with DANE-EE (3), based`,
55 `; on the certificate public key ("SPKI", 1) that is SHA2-256-hashed (1) to the`,
56 `; hexadecimal hash. DANE-EE verification means only the certificate or public`,
57 `; key is verified, not whether the certificate is signed by a (centralized)`,
58 `; certificate authority (CA), is expired, or matches the host name.`,
59 `;`,
60 `; NOTE: Create the records below only once: They are for the machine, and apply`,
61 `; to all hosted domains.`,
62 )
63 if !hasDNSSEC {
64 records = append(records,
65 ";",
66 "; WARNING: Domain does not appear to be DNSSEC-signed. To enable DANE, first",
67 "; enable DNSSEC on your domain, then add the TLSA records. Records below have been",
68 "; commented out.",
69 )
70 }
71 addTLSA := func(privKey crypto.Signer) error {
72 spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
73 if err != nil {
74 return fmt.Errorf("marshal SubjectPublicKeyInfo for DANE record: %v", err)
75 }
76 sum := sha256.Sum256(spkiBuf)
77 tlsaRecord := adns.TLSA{
78 Usage: adns.TLSAUsageDANEEE,
79 Selector: adns.TLSASelectorSPKI,
80 MatchType: adns.TLSAMatchTypeSHA256,
81 CertAssoc: sum[:],
82 }
83 var s string
84 if hasDNSSEC {
85 s = fmt.Sprintf("_25._tcp.%-*s TLSA %s", 20+len(d)-len("_25._tcp."), h+".", tlsaRecord.Record())
86 } else {
87 s = fmt.Sprintf(";; _25._tcp.%-*s TLSA %s", 20+len(d)-len(";; _25._tcp."), h+".", tlsaRecord.Record())
88 }
89 records = append(records, s)
90 return nil
91 }
92 for _, privKey := range public.TLS.HostPrivateECDSAP256Keys {
93 if err := addTLSA(privKey); err != nil {
94 return nil, err
95 }
96 }
97 for _, privKey := range public.TLS.HostPrivateRSA2048Keys {
98 if err := addTLSA(privKey); err != nil {
99 return nil, err
100 }
101 }
102 records = append(records, "")
103 }
104
105 if d != h {
106 records = append(records,
107 "; For the machine, only needs to be created once, for the first domain added:",
108 "; ",
109 "; SPF-allow host for itself, resulting in relaxed DMARC pass for (postmaster)",
110 "; messages (DSNs) sent from host:",
111 fmt.Sprintf(`%-*s TXT "v=spf1 a -all"`, 20+len(d), h+"."), // ../rfc/7208:2263 ../rfc/7208:2287
112 "",
113 )
114 }
115 if d != h && mox.Conf.Static.HostTLSRPT.ParsedLocalpart != "" {
116 uri := url.URL{
117 Scheme: "mailto",
118 Opaque: smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain).Pack(false),
119 }
120 tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
121 records = append(records,
122 "; For the machine, only needs to be created once, for the first domain added:",
123 "; ",
124 "; Request reporting about success/failures of TLS connections to (MX) host, for DANE.",
125 fmt.Sprintf(`_smtp._tls.%-*s TXT "%s"`, 20+len(d)-len("_smtp._tls."), h+".", tlsrptr.String()),
126 "",
127 )
128 }
129
130 records = append(records,
131 "; Deliver email for the domain to this host.",
132 fmt.Sprintf("%s. MX 10 %s.", d, h),
133 "",
134
135 "; Outgoing messages will be signed with the first two DKIM keys. The other two",
136 "; configured for backup, switching to them is just a config change.",
137 )
138 var selectors []string
139 for name := range domConf.DKIM.Selectors {
140 selectors = append(selectors, name)
141 }
142 slices.Sort(selectors)
143 for _, name := range selectors {
144 sel := domConf.DKIM.Selectors[name]
145 dkimr := dkim.Record{
146 Version: "DKIM1",
147 Hashes: []string{"sha256"},
148 PublicKey: sel.Key.Public(),
149 }
150 if _, ok := sel.Key.(ed25519.PrivateKey); ok {
151 dkimr.Key = "ed25519"
152 } else if _, ok := sel.Key.(*rsa.PrivateKey); !ok {
153 return nil, fmt.Errorf("unrecognized private key for DKIM selector %q: %T", name, sel.Key)
154 }
155 txt, err := dkimr.Record()
156 if err != nil {
157 return nil, fmt.Errorf("making DKIM DNS TXT record: %v", err)
158 }
159
160 if len(txt) > 100 {
161 records = append(records,
162 "; NOTE: The following is a single long record split over several lines for use",
163 "; in zone files. When adding through a DNS operator web interface, combine the",
164 "; strings into a single string, without ().",
165 )
166 }
167 s := fmt.Sprintf("%s._domainkey.%s. TXT %s", name, d, mox.TXTStrings(txt))
168 records = append(records, s)
169
170 }
171 dmarcr := dmarc.DefaultRecord
172 dmarcr.Policy = "reject"
173 if domConf.DMARC != nil {
174 uri := url.URL{
175 Scheme: "mailto",
176 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
177 }
178 dmarcr.AggregateReportAddresses = []dmarc.URI{
179 {Address: uri.String(), MaxSize: 10, Unit: "m"},
180 }
181 }
182 dspfr := spf.Record{Version: "spf1"}
183 for _, ip := range mox.DomainSPFIPs() {
184 mech := "ip4"
185 if ip.To4() == nil {
186 mech = "ip6"
187 }
188 dspfr.Directives = append(dspfr.Directives, spf.Directive{Mechanism: mech, IP: ip})
189 }
190 dspfr.Directives = append(dspfr.Directives,
191 spf.Directive{Mechanism: "mx"},
192 spf.Directive{Qualifier: "~", Mechanism: "all"},
193 )
194 dspftxt, err := dspfr.Record()
195 if err != nil {
196 return nil, fmt.Errorf("making domain spf record: %v", err)
197 }
198 records = append(records,
199 "",
200
201 "; Specify the MX host is allowed to send for our domain and for itself (for DSNs).",
202 "; ~all means softfail for anything else, which is done instead of -all to prevent older",
203 "; mail servers from rejecting the message because they never get to looking for a dkim/dmarc pass.",
204 fmt.Sprintf(`%s. TXT "%s"`, d, dspftxt),
205 "",
206
207 "; Emails that fail the DMARC check (without aligned DKIM and without aligned SPF)",
208 "; should be rejected, and request reports. If you email through mailing lists that",
209 "; strip DKIM-Signature headers and don't rewrite the From header, you may want to",
210 "; set the policy to p=none.",
211 fmt.Sprintf(`_dmarc.%s. TXT "%s"`, d, dmarcr.String()),
212 "",
213 )
214
215 if sts := domConf.MTASTS; sts != nil {
216 records = append(records,
217 "; Remote servers can use MTA-STS to verify our TLS certificate with the",
218 "; WebPKI pool of CA's (certificate authorities) when delivering over SMTP with",
219 "; STARTTLS.",
220 fmt.Sprintf(`mta-sts.%s. CNAME %s.`, d, h),
221 fmt.Sprintf(`_mta-sts.%s. TXT "v=STSv1; id=%s"`, d, sts.PolicyID),
222 "",
223 )
224 } else {
225 records = append(records,
226 "; Note: No MTA-STS to indicate TLS should be used. Either because disabled for the",
227 "; domain or because mox.conf does not have a listener with MTA-STS configured.",
228 "",
229 )
230 }
231
232 if domConf.TLSRPT != nil {
233 uri := url.URL{
234 Scheme: "mailto",
235 Opaque: smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domConf.TLSRPT.DNSDomain).Pack(false),
236 }
237 tlsrptr := tlsrpt.Record{Version: "TLSRPTv1", RUAs: [][]tlsrpt.RUA{{tlsrpt.RUA(uri.String())}}}
238 records = append(records,
239 "; Request reporting about TLS failures.",
240 fmt.Sprintf(`_smtp._tls.%s. TXT "%s"`, d, tlsrptr.String()),
241 "",
242 )
243 }
244
245 if csd != h {
246 records = append(records,
247 "; Client settings will reference a subdomain of the hosted domain, making it",
248 "; easier to migrate to a different server in the future by not requiring settings",
249 "; in all clients to be updated.",
250 fmt.Sprintf(`%-*s CNAME %s.`, 20+len(d), csd+".", h),
251 "",
252 )
253 }
254
255 records = append(records,
256 "; Autoconfig is used by Thunderbird. Autodiscover is (in theory) used by Microsoft.",
257 fmt.Sprintf(`autoconfig.%s. CNAME %s.`, d, h),
258 fmt.Sprintf(`_autodiscover._tcp.%s. SRV 0 1 443 %s.`, d, h),
259 "",
260
261 // ../rfc/6186:133 ../rfc/8314:692
262 // ../rfc/2782:202 says we MUST NOT have a CNAME as the target to a SRV record, but
263 // arnt says it's safe to ignore that statement, see
264 // https://github.com/mjl-/mox/pull/367#issuecomment-3486518824. Software isn't
265 // likely to actually update their configs to the targets of CNAMEs, and the
266 // additional lookups won't cause relevant delays or traffic.
267 "; For secure IMAP and submission autoconfig, point to mail host.",
268 fmt.Sprintf(`_imaps._tcp.%s. SRV 0 1 993 %s.`, d, csd),
269 fmt.Sprintf(`_submissions._tcp.%s. SRV 0 1 465 %s.`, d, csd),
270 "",
271 // ../rfc/6186:242
272 "; Next records specify POP3 and non-TLS ports are not to be used.",
273 "; These are optional and safe to leave out (e.g. if you have to click a lot in a",
274 "; DNS admin web interface).",
275 fmt.Sprintf(`_imap._tcp.%s. SRV 0 0 0 .`, d),
276 fmt.Sprintf(`_submission._tcp.%s. SRV 0 0 0 .`, d),
277 fmt.Sprintf(`_pop3._tcp.%s. SRV 0 0 0 .`, d),
278 fmt.Sprintf(`_pop3s._tcp.%s. SRV 0 0 0 .`, d),
279 )
280
281 if certIssuerDomainName != "" {
282 // ../rfc/8659:18 for CAA records.
283 records = append(records,
284 "",
285 "; Optional:",
286 "; You could mark Let's Encrypt as the only Certificate Authority allowed to",
287 "; sign TLS certificates for your domain.",
288 fmt.Sprintf(`%s. CAA 0 issue "%s"`, d, certIssuerDomainName),
289 )
290 if acmeAccountURI != "" {
291 // ../rfc/8657:99 for accounturi.
292 // ../rfc/8657:147 for validationmethods.
293 records = append(records,
294 ";",
295 "; Optionally limit certificates for this domain to the account ID and methods used by mox.",
296 fmt.Sprintf(`;; %s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
297 ";",
298 "; Or alternatively only limit for email-specific subdomains, so you can use",
299 "; other accounts/methods for other subdomains.",
300 fmt.Sprintf(`;; autoconfig.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
301 fmt.Sprintf(`;; mta-sts.%s. CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, d, certIssuerDomainName, acmeAccountURI),
302 )
303 if csd != h {
304 records = append(records,
305 fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), csd, certIssuerDomainName, acmeAccountURI),
306 )
307 }
308 if strings.HasSuffix(h, "."+d) {
309 records = append(records,
310 ";",
311 "; And the mail hostname.",
312 fmt.Sprintf(`;; %-*s CAA 0 issue "%s; accounturi=%s; validationmethods=tls-alpn-01,http-01"`, 20-3+len(d), h+".", certIssuerDomainName, acmeAccountURI),
313 )
314 }
315 } else {
316 // The string "will be suggested" is used by
317 // ../testdata/integration/moxacmepebble.sh and ../testdata/integration/moxmail2.sh
318 // as end of DNS records.
319 records = append(records,
320 ";",
321 "; Note: After starting up, once an ACME account has been created, CAA records",
322 "; that restrict issuance to the account will be suggested.",
323 )
324 }
325 }
326 return records, nil
327}
328