1// Package webadmin is a web app for the mox administrator for viewing and changing
2// the configuration, like creating/removing accounts, viewing DMARC and TLS
3// reports, check DNS records for a domain, change the webserver configuration,
4// etc.
5package webadmin
6
7import (
8 "bufio"
9 "bytes"
10 "context"
11 "crypto"
12 "crypto/ed25519"
13 cryptorand "crypto/rand"
14 "crypto/rsa"
15 "crypto/sha256"
16 "crypto/tls"
17 "crypto/x509"
18 "encoding/base64"
19 "encoding/json"
20 "errors"
21 "fmt"
22 "net"
23 "net/http"
24 "net/url"
25 "os"
26 "path/filepath"
27 "reflect"
28 "runtime/debug"
29 "sort"
30 "strings"
31 "sync"
32 "time"
33
34 _ "embed"
35
36 "golang.org/x/exp/maps"
37 "golang.org/x/exp/slog"
38
39 "github.com/mjl-/adns"
40
41 "github.com/mjl-/bstore"
42 "github.com/mjl-/sherpa"
43 "github.com/mjl-/sherpadoc"
44 "github.com/mjl-/sherpaprom"
45
46 "github.com/mjl-/mox/config"
47 "github.com/mjl-/mox/dkim"
48 "github.com/mjl-/mox/dmarc"
49 "github.com/mjl-/mox/dmarcdb"
50 "github.com/mjl-/mox/dmarcrpt"
51 "github.com/mjl-/mox/dns"
52 "github.com/mjl-/mox/dnsbl"
53 "github.com/mjl-/mox/metrics"
54 "github.com/mjl-/mox/mlog"
55 mox "github.com/mjl-/mox/mox-"
56 "github.com/mjl-/mox/moxvar"
57 "github.com/mjl-/mox/mtasts"
58 "github.com/mjl-/mox/mtastsdb"
59 "github.com/mjl-/mox/publicsuffix"
60 "github.com/mjl-/mox/queue"
61 "github.com/mjl-/mox/smtp"
62 "github.com/mjl-/mox/spf"
63 "github.com/mjl-/mox/store"
64 "github.com/mjl-/mox/tlsrpt"
65 "github.com/mjl-/mox/tlsrptdb"
66 "github.com/mjl-/mox/webauth"
67)
68
69var pkglog = mlog.New("webadmin", nil)
70
71//go:embed api.json
72var adminapiJSON []byte
73
74//go:embed admin.html
75var adminHTML []byte
76
77//go:embed admin.js
78var adminJS []byte
79
80var webadminFile = &mox.WebappFile{
81 HTML: adminHTML,
82 JS: adminJS,
83 HTMLPath: filepath.FromSlash("webadmin/admin.html"),
84 JSPath: filepath.FromSlash("webadmin/admin.js"),
85}
86
87var adminDoc = mustParseAPI("admin", adminapiJSON)
88
89func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
90 err := json.Unmarshal(buf, &doc)
91 if err != nil {
92 pkglog.Fatalx("parsing webadmin api docs", err, slog.String("api", api))
93 }
94 return doc
95}
96
97var sherpaHandlerOpts *sherpa.HandlerOpts
98
99func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
100 return sherpa.NewHandler("/api/", moxvar.Version, Admin{cookiePath, isForwarded}, &adminDoc, sherpaHandlerOpts)
101}
102
103func init() {
104 collector, err := sherpaprom.NewCollector("moxadmin", nil)
105 if err != nil {
106 pkglog.Fatalx("creating sherpa prometheus collector", err)
107 }
108
109 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
110 // Just to validate.
111 _, err = makeSherpaHandler("", false)
112 if err != nil {
113 pkglog.Fatalx("sherpa handler", err)
114 }
115}
116
117// Handler returns a handler for the webadmin endpoints, customized for the
118// cookiePath.
119func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
120 sh, err := makeSherpaHandler(cookiePath, isForwarded)
121 return func(w http.ResponseWriter, r *http.Request) {
122 if err != nil {
123 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
124 return
125 }
126 handle(sh, isForwarded, w, r)
127 }
128}
129
130// Admin exports web API functions for the admin web interface. All its methods are
131// exported under api/. Function calls require valid HTTP Authentication
132// credentials of a user.
133type Admin struct {
134 cookiePath string // From listener, for setting authentication cookies.
135 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
136}
137
138type ctxKey string
139
140var requestInfoCtxKey ctxKey = "requestInfo"
141
142type requestInfo struct {
143 SessionToken store.SessionToken
144 Response http.ResponseWriter
145 Request *http.Request // For Proto and TLS connection state during message submit.
146}
147
148func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
149 ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
150 log := pkglog.WithContext(ctx).With(slog.String("adminauth", ""))
151
152 // HTML/JS can be retrieved without authentication.
153 if r.URL.Path == "/" {
154 switch r.Method {
155 case "GET", "HEAD":
156 webadminFile.Serve(ctx, log, w, r)
157 default:
158 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
159 }
160 return
161 }
162
163 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
164 // Only allow POST for calls, they will not work cross-domain without CORS.
165 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
166 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
167 return
168 }
169
170 // All other URLs, except the login endpoint require some authentication.
171 var sessionToken store.SessionToken
172 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
173 var ok bool
174 _, sessionToken, _, ok = webauth.Check(ctx, log, webauth.Admin, "webadmin", isForwarded, w, r, isAPI, isAPI, false)
175 if !ok {
176 // Response has been written already.
177 return
178 }
179 }
180
181 if isAPI {
182 reqInfo := requestInfo{sessionToken, w, r}
183 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
184 apiHandler.ServeHTTP(w, r.WithContext(ctx))
185 return
186 }
187
188 http.NotFound(w, r)
189}
190
191func xcheckf(ctx context.Context, err error, format string, args ...any) {
192 if err == nil {
193 return
194 }
195 msg := fmt.Sprintf(format, args...)
196 errmsg := fmt.Sprintf("%s: %s", msg, err)
197 pkglog.WithContext(ctx).Errorx(msg, err)
198 code := "server:error"
199 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
200 code = "user:error"
201 }
202 panic(&sherpa.Error{Code: code, Message: errmsg})
203}
204
205func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
206 if err == nil {
207 return
208 }
209 msg := fmt.Sprintf(format, args...)
210 errmsg := fmt.Sprintf("%s: %s", msg, err)
211 pkglog.WithContext(ctx).Errorx(msg, err)
212 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
213}
214
215// LoginPrep returns a login token, and also sets it as cookie. Both must be
216// present in the call to Login.
217func (w Admin) LoginPrep(ctx context.Context) string {
218 log := pkglog.WithContext(ctx)
219 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
220
221 var data [8]byte
222 _, err := cryptorand.Read(data[:])
223 xcheckf(ctx, err, "generate token")
224 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
225
226 webauth.LoginPrep(ctx, log, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
227
228 return loginToken
229}
230
231// Login returns a session token for the credentials, or fails with error code
232// "user:badLogin". Call LoginPrep to get a loginToken.
233func (w Admin) Login(ctx context.Context, loginToken, password string) store.CSRFToken {
234 log := pkglog.WithContext(ctx)
235 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
236
237 csrfToken, err := webauth.Login(ctx, log, webauth.Admin, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, "", password)
238 if _, ok := err.(*sherpa.Error); ok {
239 panic(err)
240 }
241 xcheckf(ctx, err, "login")
242 return csrfToken
243}
244
245// Logout invalidates the session token.
246func (w Admin) Logout(ctx context.Context) {
247 log := pkglog.WithContext(ctx)
248 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
249
250 err := webauth.Logout(ctx, log, webauth.Admin, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, "", reqInfo.SessionToken)
251 xcheckf(ctx, err, "logout")
252}
253
254type Result struct {
255 Errors []string
256 Warnings []string
257 Instructions []string
258}
259
260type DNSSECResult struct {
261 Result
262}
263
264type IPRevCheckResult struct {
265 Hostname dns.Domain // This hostname, IPs must resolve back to this.
266 IPNames map[string][]string // IP to names.
267 Result
268}
269
270type MX struct {
271 Host string
272 Pref int
273 IPs []string
274}
275
276type MXCheckResult struct {
277 Records []MX
278 Result
279}
280
281type TLSCheckResult struct {
282 Result
283}
284
285type DANECheckResult struct {
286 Result
287}
288
289type SPFRecord struct {
290 spf.Record
291}
292
293type SPFCheckResult struct {
294 DomainTXT string
295 DomainRecord *SPFRecord
296 HostTXT string
297 HostRecord *SPFRecord
298 Result
299}
300
301type DKIMCheckResult struct {
302 Records []DKIMRecord
303 Result
304}
305
306type DKIMRecord struct {
307 Selector string
308 TXT string
309 Record *dkim.Record
310}
311
312type DMARCRecord struct {
313 dmarc.Record
314}
315
316type DMARCCheckResult struct {
317 Domain string
318 TXT string
319 Record *DMARCRecord
320 Result
321}
322
323type TLSRPTRecord struct {
324 tlsrpt.Record
325}
326
327type TLSRPTCheckResult struct {
328 TXT string
329 Record *TLSRPTRecord
330 Result
331}
332
333type MTASTSRecord struct {
334 mtasts.Record
335}
336type MTASTSCheckResult struct {
337 TXT string
338 Record *MTASTSRecord
339 PolicyText string
340 Policy *mtasts.Policy
341 Result
342}
343
344type SRVConfCheckResult struct {
345 SRVs map[string][]net.SRV // Service (e.g. "_imaps") to records.
346 Result
347}
348
349type AutoconfCheckResult struct {
350 ClientSettingsDomainIPs []string
351 IPs []string
352 Result
353}
354
355type AutodiscoverSRV struct {
356 net.SRV
357 IPs []string
358}
359
360type AutodiscoverCheckResult struct {
361 Records []AutodiscoverSRV
362 Result
363}
364
365// CheckResult is the analysis of a domain, its actual configuration (DNS, TLS,
366// connectivity) and the mox configuration. It includes configuration instructions
367// (e.g. DNS records), and warnings and errors encountered.
368type CheckResult struct {
369 Domain string
370 DNSSEC DNSSECResult
371 IPRev IPRevCheckResult
372 MX MXCheckResult
373 TLS TLSCheckResult
374 DANE DANECheckResult
375 SPF SPFCheckResult
376 DKIM DKIMCheckResult
377 DMARC DMARCCheckResult
378 HostTLSRPT TLSRPTCheckResult
379 DomainTLSRPT TLSRPTCheckResult
380 MTASTS MTASTSCheckResult
381 SRVConf SRVConfCheckResult
382 Autoconf AutoconfCheckResult
383 Autodiscover AutodiscoverCheckResult
384}
385
386// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
387func logPanic(ctx context.Context) {
388 x := recover()
389 if x == nil {
390 return
391 }
392 pkglog.WithContext(ctx).Error("recover from panic", slog.Any("panic", x))
393 debug.PrintStack()
394 metrics.PanicInc(metrics.Webadmin)
395}
396
397// return IPs we may be listening on.
398func xlistenIPs(ctx context.Context, receiveOnly bool) []net.IP {
399 ips, err := mox.IPs(ctx, receiveOnly)
400 xcheckf(ctx, err, "listing ips")
401 return ips
402}
403
404// return IPs from which we may be sending.
405func xsendingIPs(ctx context.Context) []net.IP {
406 ips, err := mox.IPs(ctx, false)
407 xcheckf(ctx, err, "listing ips")
408 return ips
409}
410
411// CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS,
412// SPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover.
413func (Admin) CheckDomain(ctx context.Context, domainName string) (r CheckResult) {
414 // todo future: should run these checks without a DNS cache so recent changes are picked up.
415
416 resolver := dns.StrictResolver{Pkg: "check", Log: pkglog.WithContext(ctx).Logger}
417 dialer := &net.Dialer{Timeout: 10 * time.Second}
418 nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
419 defer cancel()
420 return checkDomain(nctx, resolver, dialer, domainName)
421}
422
423func unptr[T any](l []*T) []T {
424 if l == nil {
425 return nil
426 }
427 r := make([]T, len(l))
428 for i, e := range l {
429 r[i] = *e
430 }
431 return r
432}
433
434func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer, domainName string) (r CheckResult) {
435 log := pkglog.WithContext(ctx)
436
437 domain, err := dns.ParseDomain(domainName)
438 xcheckuserf(ctx, err, "parsing domain")
439
440 domConf, ok := mox.Conf.Domain(domain)
441 if !ok {
442 panic(&sherpa.Error{Code: "user:notFound", Message: "domain not found"})
443 }
444
445 listenIPs := xlistenIPs(ctx, true)
446 isListenIP := func(ip net.IP) bool {
447 for _, lip := range listenIPs {
448 if ip.Equal(lip) {
449 return true
450 }
451 }
452 return false
453 }
454
455 addf := func(l *[]string, format string, args ...any) {
456 *l = append(*l, fmt.Sprintf(format, args...))
457 }
458
459 // Host must be an absolute dns name, ending with a dot.
460 lookupIPs := func(errors *[]string, host string) (ips []string, ourIPs, notOurIPs []net.IP, rerr error) {
461 addrs, _, err := resolver.LookupHost(ctx, host)
462 if err != nil {
463 addf(errors, "Looking up %q: %s", host, err)
464 return nil, nil, nil, err
465 }
466 for _, addr := range addrs {
467 ip := net.ParseIP(addr)
468 if ip == nil {
469 addf(errors, "Bad IP %q", addr)
470 continue
471 }
472 ips = append(ips, ip.String())
473 if isListenIP(ip) {
474 ourIPs = append(ourIPs, ip)
475 } else {
476 notOurIPs = append(notOurIPs, ip)
477 }
478 }
479 return ips, ourIPs, notOurIPs, nil
480 }
481
482 checkTLS := func(errors *[]string, host string, ips []string, port string) {
483 d := tls.Dialer{
484 NetDialer: dialer,
485 Config: &tls.Config{
486 ServerName: host,
487 MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
488 RootCAs: mox.Conf.Static.TLS.CertPool,
489 },
490 }
491 for _, ip := range ips {
492 conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(ip, port))
493 if err != nil {
494 addf(errors, "TLS connection to hostname %q, IP %q: %s", host, ip, err)
495 } else {
496 conn.Close()
497 }
498 }
499 }
500
501 // If at least one listener with SMTP enabled has unspecified NATed IPs, we'll skip
502 // some checks related to these IPs.
503 var isNAT, isUnspecifiedNAT bool
504 for _, l := range mox.Conf.Static.Listeners {
505 if !l.SMTP.Enabled {
506 continue
507 }
508 if l.IPsNATed {
509 isUnspecifiedNAT = true
510 isNAT = true
511 }
512 if len(l.NATIPs) > 0 {
513 isNAT = true
514 }
515 }
516
517 var wg sync.WaitGroup
518
519 // DNSSEC
520 wg.Add(1)
521 go func() {
522 defer logPanic(ctx)
523 defer wg.Done()
524
525 _, result, err := resolver.LookupNS(ctx, ".")
526 if err != nil {
527 addf(&r.DNSSEC.Errors, "Looking up NS for DNS root (.) to check support in resolver for DNSSEC-verification: %s", err)
528 } else if !result.Authentic {
529 addf(&r.DNSSEC.Warnings, `It looks like the DNS resolvers configured on your system do not verify DNSSEC, or aren't trusted (by having loopback IPs or through "options trust-ad" in /etc/resolv.conf). Without DNSSEC, outbound delivery with SMTP used unprotected MX records, and SMTP STARTTLS connections cannot verify the TLS certificate with DANE (based on a public key in DNS), and will fallback to either MTA-STS for verification, or use "opportunistic TLS" with no certificate verification.`)
530 } else {
531 _, result, _ := resolver.LookupMX(ctx, domain.ASCII+".")
532 if !result.Authentic {
533 addf(&r.DNSSEC.Warnings, `DNS records for this domain (zone) are not DNSSEC-signed. Mail servers sending email to your domain, or receiving email from your domain, cannot verify that the MX/SPF/DKIM/DMARC/MTA-STS records they see are authentic.`)
534 }
535 }
536
537 addf(&r.DNSSEC.Instructions, `Enable DNSSEC-signing of the DNS records of your domain (zone) at your DNS hosting provider.`)
538
539 addf(&r.DNSSEC.Instructions, `If your DNS records are already DNSSEC-signed, you may not have a DNSSEC-verifying recursive resolver in use. Install unbound, and enable support for "extended DNS errors" (EDE), for example:
540
541cat <<EOF >/etc/unbound/unbound.conf.d/ede.conf
542server:
543 ede: yes
544 val-log-level: 2
545EOF
546`)
547 }()
548
549 // IPRev
550 wg.Add(1)
551 go func() {
552 defer logPanic(ctx)
553 defer wg.Done()
554
555 // For each mox.Conf.SpecifiedSMTPListenIPs and all NATIPs, and each IP for
556 // mox.Conf.HostnameDomain, check if they resolve back to the host name.
557 hostIPs := map[dns.Domain][]net.IP{}
558 ips, _, err := resolver.LookupIP(ctx, "ip", mox.Conf.Static.HostnameDomain.ASCII+".")
559 if err != nil {
560 addf(&r.IPRev.Errors, "Looking up IPs for hostname: %s", err)
561 }
562
563 gatherMoreIPs := func(publicIPs []net.IP) {
564 nextip:
565 for _, ip := range publicIPs {
566 for _, xip := range ips {
567 if ip.Equal(xip) {
568 continue nextip
569 }
570 }
571 ips = append(ips, ip)
572 }
573 }
574 if !isNAT {
575 gatherMoreIPs(mox.Conf.Static.SpecifiedSMTPListenIPs)
576 }
577 for _, l := range mox.Conf.Static.Listeners {
578 if !l.SMTP.Enabled {
579 continue
580 }
581 var natips []net.IP
582 for _, ip := range l.NATIPs {
583 natips = append(natips, net.ParseIP(ip))
584 }
585 gatherMoreIPs(natips)
586 }
587 hostIPs[mox.Conf.Static.HostnameDomain] = ips
588
589 iplist := func(ips []net.IP) string {
590 var ipstrs []string
591 for _, ip := range ips {
592 ipstrs = append(ipstrs, ip.String())
593 }
594 return strings.Join(ipstrs, ", ")
595 }
596
597 r.IPRev.Hostname = mox.Conf.Static.HostnameDomain
598 r.IPRev.Instructions = []string{
599 fmt.Sprintf("Ensure IPs %s have reverse address %s.", iplist(ips), mox.Conf.Static.HostnameDomain.ASCII),
600 }
601
602 // If we have a socks transport, also check its host and IP.
603 for tname, t := range mox.Conf.Static.Transports {
604 if t.Socks != nil {
605 hostIPs[t.Socks.Hostname] = append(hostIPs[t.Socks.Hostname], t.Socks.IPs...)
606 instr := fmt.Sprintf("For SOCKS transport %s, ensure IPs %s have reverse address %s.", tname, iplist(t.Socks.IPs), t.Socks.Hostname)
607 r.IPRev.Instructions = append(r.IPRev.Instructions, instr)
608 }
609 }
610
611 type result struct {
612 Host dns.Domain
613 IP string
614 Addrs []string
615 Err error
616 }
617 results := make(chan result)
618 n := 0
619 for host, ips := range hostIPs {
620 for _, ip := range ips {
621 n++
622 s := ip.String()
623 host := host
624 go func() {
625 addrs, _, err := resolver.LookupAddr(ctx, s)
626 results <- result{host, s, addrs, err}
627 }()
628 }
629 }
630 r.IPRev.IPNames = map[string][]string{}
631 for i := 0; i < n; i++ {
632 lr := <-results
633 host, addrs, ip, err := lr.Host, lr.Addrs, lr.IP, lr.Err
634 if err != nil {
635 addf(&r.IPRev.Errors, "Looking up reverse name for %s of %s: %v", ip, host, err)
636 continue
637 }
638 if len(addrs) != 1 {
639 addf(&r.IPRev.Errors, "Expected exactly 1 name for %s of %s, got %d (%v)", ip, host, len(addrs), addrs)
640 }
641 var match bool
642 for i, a := range addrs {
643 a = strings.TrimRight(a, ".")
644 addrs[i] = a
645 ad, err := dns.ParseDomain(a)
646 if err != nil {
647 addf(&r.IPRev.Errors, "Parsing reverse name %q for %s: %v", a, ip, err)
648 }
649 if ad == host {
650 match = true
651 }
652 }
653 if !match {
654 addf(&r.IPRev.Errors, "Reverse name(s) %s for ip %s do not match hostname %s, which will cause other mail servers to reject incoming messages from this IP.", strings.Join(addrs, ","), ip, host)
655 }
656 r.IPRev.IPNames[ip] = addrs
657 }
658
659 // Linux machines are often initially set up with a loopback IP for the hostname in
660 // /etc/hosts, presumably because it isn't known if their external IPs are static.
661 // For mail servers, they should certainly be static. The quickstart would also
662 // have warned about this, but could have been missed/ignored.
663 for _, ip := range ips {
664 if ip.IsLoopback() {
665 addf(&r.IPRev.Errors, "Hostname %s resolves to loopback IP %s, this will likely prevent email delivery to local accounts from working. The loopback IP was probably configured in /etc/hosts at system installation time. Replace the loopback IP with your actual external IPs in /etc/hosts.", mox.Conf.Static.HostnameDomain, ip.String())
666 }
667 }
668 }()
669
670 // MX
671 wg.Add(1)
672 go func() {
673 defer logPanic(ctx)
674 defer wg.Done()
675
676 mxs, _, err := resolver.LookupMX(ctx, domain.ASCII+".")
677 if err != nil {
678 addf(&r.MX.Errors, "Looking up MX records for %s: %s", domain, err)
679 }
680 r.MX.Records = make([]MX, len(mxs))
681 for i, mx := range mxs {
682 r.MX.Records[i] = MX{mx.Host, int(mx.Pref), nil}
683 }
684 if len(mxs) == 1 && mxs[0].Host == "." {
685 addf(&r.MX.Errors, `MX records consists of explicit null mx record (".") indicating that domain does not accept email.`)
686 return
687 }
688 for i, mx := range mxs {
689 ips, ourIPs, notOurIPs, err := lookupIPs(&r.MX.Errors, mx.Host)
690 if err != nil {
691 addf(&r.MX.Errors, "Looking up IPs for mx host %q: %s", mx.Host, err)
692 }
693 r.MX.Records[i].IPs = ips
694 if isUnspecifiedNAT {
695 continue
696 }
697 if len(ourIPs) == 0 {
698 addf(&r.MX.Errors, "None of the IPs that mx %q points to is ours: %v", mx.Host, notOurIPs)
699 } else if len(notOurIPs) > 0 {
700 addf(&r.MX.Errors, "Some of the IPs that mx %q points to are not ours: %v", mx.Host, notOurIPs)
701 }
702
703 }
704 r.MX.Instructions = []string{
705 fmt.Sprintf("Ensure a DNS MX record like the following exists:\n\n\t%s MX 10 %s\n\nWithout the trailing dot, the name would be interpreted as relative to the domain.", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+"."),
706 }
707 }()
708
709 // TLS, mostly checking certificate expiration and CA trust.
710 // todo: should add checks about the listeners (which aren't specific to domains) somewhere else, not on the domain page with this checkDomain call. i.e. submissions, imap starttls, imaps.
711 wg.Add(1)
712 go func() {
713 defer logPanic(ctx)
714 defer wg.Done()
715
716 // MTA-STS, autoconfig, autodiscover are checked in their sections.
717
718 // Dial a single MX host with given IP and perform STARTTLS handshake.
719 dialSMTPSTARTTLS := func(host, ip string) error {
720 conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, "25"))
721 if err != nil {
722 return err
723 }
724 defer func() {
725 if conn != nil {
726 conn.Close()
727 }
728 }()
729
730 end := time.Now().Add(10 * time.Second)
731 cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
732 defer cancel()
733 err = conn.SetDeadline(end)
734 log.WithContext(ctx).Check(err, "setting deadline")
735
736 br := bufio.NewReader(conn)
737 _, err = br.ReadString('\n')
738 if err != nil {
739 return fmt.Errorf("reading SMTP banner from remote: %s", err)
740 }
741 if _, err := fmt.Fprintf(conn, "EHLO moxtest\r\n"); err != nil {
742 return fmt.Errorf("writing SMTP EHLO to remote: %s", err)
743 }
744 for {
745 line, err := br.ReadString('\n')
746 if err != nil {
747 return fmt.Errorf("reading SMTP EHLO response from remote: %s", err)
748 }
749 if strings.HasPrefix(line, "250-") {
750 continue
751 }
752 if strings.HasPrefix(line, "250 ") {
753 break
754 }
755 return fmt.Errorf("unexpected response to SMTP EHLO from remote: %q", strings.TrimSuffix(line, "\r\n"))
756 }
757 if _, err := fmt.Fprintf(conn, "STARTTLS\r\n"); err != nil {
758 return fmt.Errorf("writing SMTP STARTTLS to remote: %s", err)
759 }
760 line, err := br.ReadString('\n')
761 if err != nil {
762 return fmt.Errorf("reading response to SMTP STARTTLS from remote: %s", err)
763 }
764 if !strings.HasPrefix(line, "220 ") {
765 return fmt.Errorf("SMTP STARTTLS response from remote not 220 OK: %q", strings.TrimSuffix(line, "\r\n"))
766 }
767 config := &tls.Config{
768 ServerName: host,
769 RootCAs: mox.Conf.Static.TLS.CertPool,
770 }
771 tlsconn := tls.Client(conn, config)
772 if err := tlsconn.HandshakeContext(cctx); err != nil {
773 return fmt.Errorf("TLS handshake after SMTP STARTTLS: %s", err)
774 }
775 cancel()
776 conn.Close()
777 conn = nil
778 return nil
779 }
780
781 checkSMTPSTARTTLS := func() {
782 // Initial errors are ignored, will already have been warned about by MX checks.
783 mxs, _, err := resolver.LookupMX(ctx, domain.ASCII+".")
784 if err != nil {
785 return
786 }
787 if len(mxs) == 1 && mxs[0].Host == "." {
788 return
789 }
790 for _, mx := range mxs {
791 ips, _, _, err := lookupIPs(&r.MX.Errors, mx.Host)
792 if err != nil {
793 continue
794 }
795
796 for _, ip := range ips {
797 if err := dialSMTPSTARTTLS(mx.Host, ip); err != nil {
798 addf(&r.TLS.Errors, "SMTP connection with STARTTLS to MX hostname %q IP %s: %s", mx.Host, ip, err)
799 }
800 }
801 }
802 }
803
804 checkSMTPSTARTTLS()
805
806 }()
807
808 // DANE
809 wg.Add(1)
810 go func() {
811 defer logPanic(ctx)
812 defer wg.Done()
813
814 daneRecords := func(l config.Listener) map[string]struct{} {
815 if l.TLS == nil {
816 return nil
817 }
818 records := map[string]struct{}{}
819 addRecord := func(privKey crypto.Signer) {
820 spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
821 if err != nil {
822 addf(&r.DANE.Errors, "marshal SubjectPublicKeyInfo for DANE record: %v", err)
823 return
824 }
825 sum := sha256.Sum256(spkiBuf)
826 r := adns.TLSA{
827 Usage: adns.TLSAUsageDANEEE,
828 Selector: adns.TLSASelectorSPKI,
829 MatchType: adns.TLSAMatchTypeSHA256,
830 CertAssoc: sum[:],
831 }
832 records[r.Record()] = struct{}{}
833 }
834 for _, privKey := range l.TLS.HostPrivateRSA2048Keys {
835 addRecord(privKey)
836 }
837 for _, privKey := range l.TLS.HostPrivateECDSAP256Keys {
838 addRecord(privKey)
839 }
840 return records
841 }
842
843 expectedDANERecords := func(host string) map[string]struct{} {
844 for _, l := range mox.Conf.Static.Listeners {
845 if l.HostnameDomain.ASCII == host {
846 return daneRecords(l)
847 }
848 }
849 public := mox.Conf.Static.Listeners["public"]
850 if mox.Conf.Static.HostnameDomain.ASCII == host && public.HostnameDomain.ASCII == "" {
851 return daneRecords(public)
852 }
853 return nil
854 }
855
856 mxl, result, err := resolver.LookupMX(ctx, domain.ASCII+".")
857 if err != nil {
858 addf(&r.DANE.Errors, "Looking up MX hosts to check for DANE records: %s", err)
859 } else {
860 if !result.Authentic {
861 addf(&r.DANE.Warnings, "DANE is inactive because MX records are not DNSSEC-signed.")
862 }
863 for _, mx := range mxl {
864 expect := expectedDANERecords(mx.Host)
865
866 tlsal, tlsaResult, err := resolver.LookupTLSA(ctx, 25, "tcp", mx.Host+".")
867 if dns.IsNotFound(err) {
868 if len(expect) > 0 {
869 addf(&r.DANE.Errors, "No DANE records for MX host %s, expected: %s.", mx.Host, strings.Join(maps.Keys(expect), "; "))
870 }
871 continue
872 } else if err != nil {
873 addf(&r.DANE.Errors, "Looking up DANE records for MX host %s: %v", mx.Host, err)
874 continue
875 } else if !tlsaResult.Authentic && len(tlsal) > 0 {
876 addf(&r.DANE.Errors, "DANE records exist for MX host %s, but are not DNSSEC-signed.", mx.Host)
877 }
878
879 extra := map[string]struct{}{}
880 for _, e := range tlsal {
881 s := e.Record()
882 if _, ok := expect[s]; ok {
883 delete(expect, s)
884 } else {
885 extra[s] = struct{}{}
886 }
887 }
888 if len(expect) > 0 {
889 l := maps.Keys(expect)
890 sort.Strings(l)
891 addf(&r.DANE.Errors, "Missing DANE records of type TLSA for MX host _25._tcp.%s: %s", mx.Host, strings.Join(l, "; "))
892 }
893 if len(extra) > 0 {
894 l := maps.Keys(extra)
895 sort.Strings(l)
896 addf(&r.DANE.Errors, "Unexpected DANE records of type TLSA for MX host _25._tcp.%s: %s", mx.Host, strings.Join(l, "; "))
897 }
898 }
899 }
900
901 public := mox.Conf.Static.Listeners["public"]
902 pubDom := public.HostnameDomain
903 if pubDom.ASCII == "" {
904 pubDom = mox.Conf.Static.HostnameDomain
905 }
906 records := maps.Keys(daneRecords(public))
907 sort.Strings(records)
908 if len(records) > 0 {
909 instr := "Ensure the DNS records below exist. These records are for the whole machine, not per domain, so create them only once. Make sure DNSSEC is enabled, otherwise the records have no effect. The 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.\n\n"
910 for _, r := range records {
911 instr += fmt.Sprintf("\t_25._tcp.%s. TLSA %s\n", pubDom.ASCII, r)
912 }
913 addf(&r.DANE.Instructions, instr)
914 }
915 }()
916
917 // SPF
918 // todo: add warnings if we have Transports with submission? admin should ensure their IPs are in the SPF record. it may be an IP(net), or an include. that means we cannot easily check for it. and should we first check the transport can be used from this domain (or an account that has this domain?). also see DKIM.
919 wg.Add(1)
920 go func() {
921 defer logPanic(ctx)
922 defer wg.Done()
923
924 // Verify a domain with the configured IPs that do SMTP.
925 verifySPF := func(kind string, domain dns.Domain) (string, *SPFRecord, spf.Record) {
926 _, txt, record, _, err := spf.Lookup(ctx, log.Logger, resolver, domain)
927 if err != nil {
928 addf(&r.SPF.Errors, "Looking up %s SPF record: %s", kind, err)
929 }
930 var xrecord *SPFRecord
931 if record != nil {
932 xrecord = &SPFRecord{*record}
933 }
934
935 spfr := spf.Record{
936 Version: "spf1",
937 }
938
939 checkSPFIP := func(ip net.IP) {
940 mechanism := "ip4"
941 if ip.To4() == nil {
942 mechanism = "ip6"
943 }
944 spfr.Directives = append(spfr.Directives, spf.Directive{Mechanism: mechanism, IP: ip})
945
946 if record == nil {
947 return
948 }
949
950 args := spf.Args{
951 RemoteIP: ip,
952 MailFromLocalpart: "postmaster",
953 MailFromDomain: domain,
954 HelloDomain: dns.IPDomain{Domain: domain},
955 LocalIP: net.ParseIP("127.0.0.1"),
956 LocalHostname: dns.Domain{ASCII: "localhost"},
957 }
958 status, mechanism, expl, _, err := spf.Evaluate(ctx, log.Logger, record, resolver, args)
959 if err != nil {
960 addf(&r.SPF.Errors, "Evaluating IP %q against %s SPF record: %s", ip, kind, err)
961 } else if status != spf.StatusPass {
962 addf(&r.SPF.Errors, "IP %q does not pass %s SPF evaluation, status not \"pass\" but %q (mechanism %q, explanation %q)", ip, kind, status, mechanism, expl)
963 }
964 }
965
966 for _, l := range mox.Conf.Static.Listeners {
967 if !l.SMTP.Enabled || l.IPsNATed {
968 continue
969 }
970 ips := l.IPs
971 if len(l.NATIPs) > 0 {
972 ips = l.NATIPs
973 }
974 for _, ipstr := range ips {
975 ip := net.ParseIP(ipstr)
976 checkSPFIP(ip)
977 }
978 }
979 for _, t := range mox.Conf.Static.Transports {
980 if t.Socks != nil {
981 for _, ip := range t.Socks.IPs {
982 checkSPFIP(ip)
983 }
984 }
985 }
986
987 spfr.Directives = append(spfr.Directives, spf.Directive{Qualifier: "-", Mechanism: "all"})
988 return txt, xrecord, spfr
989 }
990
991 // Check SPF record for domain.
992 var dspfr spf.Record
993 r.SPF.DomainTXT, r.SPF.DomainRecord, dspfr = verifySPF("domain", domain)
994 // todo: possibly check all hosts for MX records? assuming they are also sending mail servers.
995 r.SPF.HostTXT, r.SPF.HostRecord, _ = verifySPF("host", mox.Conf.Static.HostnameDomain)
996
997 dtxt, err := dspfr.Record()
998 if err != nil {
999 addf(&r.SPF.Errors, "Making SPF record for instructions: %s", err)
1000 }
1001 domainspf := fmt.Sprintf("%s TXT %s", domain.ASCII+".", mox.TXTStrings(dtxt))
1002
1003 // Check SPF record for sending host. ../rfc/7208:2263 ../rfc/7208:2287
1004 hostspf := fmt.Sprintf(`%s TXT "v=spf1 a -all"`, mox.Conf.Static.HostnameDomain.ASCII+".")
1005
1006 addf(&r.SPF.Instructions, "Ensure DNS TXT records like the following exists:\n\n\t%s\n\t%s\n\nIf you have an existing mail setup, with other hosts also sending mail for you domain, you should add those IPs as well. You could replace \"-all\" with \"~all\" to treat mail sent from unlisted IPs as \"softfail\", or with \"?all\" for \"neutral\".", domainspf, hostspf)
1007 }()
1008
1009 // DKIM
1010 // todo: add warnings if we have Transports with submission? admin should ensure DKIM records exist. we cannot easily check if they actually exist though. and should we first check the transport can be used from this domain (or an account that has this domain?). also see SPF.
1011 wg.Add(1)
1012 go func() {
1013 defer logPanic(ctx)
1014 defer wg.Done()
1015
1016 var missing []string
1017 var haveEd25519 bool
1018 for sel, selc := range domConf.DKIM.Selectors {
1019 if _, ok := selc.Key.(ed25519.PrivateKey); ok {
1020 haveEd25519 = true
1021 }
1022
1023 _, record, txt, _, err := dkim.Lookup(ctx, log.Logger, resolver, selc.Domain, domain)
1024 if err != nil {
1025 missing = append(missing, sel)
1026 if errors.Is(err, dkim.ErrNoRecord) {
1027 addf(&r.DKIM.Errors, "No DKIM DNS record for selector %q.", sel)
1028 } else if errors.Is(err, dkim.ErrSyntax) {
1029 addf(&r.DKIM.Errors, "Parsing DKIM DNS record for selector %q: %s", sel, err)
1030 } else {
1031 addf(&r.DKIM.Errors, "Fetching DKIM record for selector %q: %s", sel, err)
1032 }
1033 }
1034 if txt != "" {
1035 r.DKIM.Records = append(r.DKIM.Records, DKIMRecord{sel, txt, record})
1036 pubKey := selc.Key.Public()
1037 var pk []byte
1038 switch k := pubKey.(type) {
1039 case *rsa.PublicKey:
1040 var err error
1041 pk, err = x509.MarshalPKIXPublicKey(k)
1042 if err != nil {
1043 addf(&r.DKIM.Errors, "Marshal public key for %q to compare against DNS: %s", sel, err)
1044 continue
1045 }
1046 case ed25519.PublicKey:
1047 pk = []byte(k)
1048 default:
1049 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", pubKey)
1050 continue
1051 }
1052
1053 if record != nil && !bytes.Equal(record.Pubkey, pk) {
1054 addf(&r.DKIM.Errors, "For selector %q, the public key in DKIM DNS TXT record does not match with configured private key.", sel)
1055 missing = append(missing, sel)
1056 }
1057 }
1058 }
1059 if len(domConf.DKIM.Selectors) == 0 {
1060 addf(&r.DKIM.Errors, "No DKIM configuration, add a key to the configuration file, and instructions for DNS records will appear here.")
1061 } else if !haveEd25519 {
1062 addf(&r.DKIM.Warnings, "Consider adding an ed25519 key: the keys are smaller, the cryptography faster and more modern.")
1063 }
1064 instr := ""
1065 for _, sel := range missing {
1066 dkimr := dkim.Record{
1067 Version: "DKIM1",
1068 Hashes: []string{"sha256"},
1069 PublicKey: domConf.DKIM.Selectors[sel].Key.Public(),
1070 }
1071 switch dkimr.PublicKey.(type) {
1072 case *rsa.PublicKey:
1073 case ed25519.PublicKey:
1074 dkimr.Key = "ed25519"
1075 default:
1076 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", dkimr.PublicKey)
1077 }
1078 txt, err := dkimr.Record()
1079 if err != nil {
1080 addf(&r.DKIM.Errors, "Making DKIM record for instructions: %s", err)
1081 continue
1082 }
1083 instr += fmt.Sprintf("\n\t%s._domainkey TXT %s\n", sel, mox.TXTStrings(txt))
1084 }
1085 if instr != "" {
1086 instr = "Ensure the following DNS record(s) exists, so mail servers receiving emails from this domain can verify the signatures in the mail headers:\n" + instr
1087 addf(&r.DKIM.Instructions, "%s", instr)
1088 }
1089 }()
1090
1091 // DMARC
1092 wg.Add(1)
1093 go func() {
1094 defer logPanic(ctx)
1095 defer wg.Done()
1096
1097 _, dmarcDomain, record, txt, _, err := dmarc.Lookup(ctx, log.Logger, resolver, domain)
1098 if err != nil {
1099 addf(&r.DMARC.Errors, "Looking up DMARC record: %s", err)
1100 } else if record == nil {
1101 addf(&r.DMARC.Errors, "No DMARC record")
1102 }
1103 r.DMARC.Domain = dmarcDomain.Name()
1104 r.DMARC.TXT = txt
1105 if record != nil {
1106 r.DMARC.Record = &DMARCRecord{*record}
1107 }
1108 if record != nil && record.Policy == "none" {
1109 addf(&r.DMARC.Warnings, "DMARC policy is in test mode (p=none), do not forget to change to p=reject or p=quarantine after test period has been completed.")
1110 }
1111 if record != nil && record.SubdomainPolicy == "none" {
1112 addf(&r.DMARC.Warnings, "DMARC subdomain policy is in test mode (sp=none), do not forget to change to sp=reject or sp=quarantine after test period has been completed.")
1113 }
1114 if record != nil && len(record.AggregateReportAddresses) == 0 {
1115 addf(&r.DMARC.Warnings, "It is recommended you specify you would like aggregate reports about delivery success in the DMARC record, see instructions.")
1116 }
1117
1118 dmarcr := dmarc.DefaultRecord
1119 dmarcr.Policy = "reject"
1120
1121 var extInstr string
1122 if domConf.DMARC != nil {
1123 // If the domain is in a different Organizational Domain, the receiving domain
1124 // needs a special DNS record to opt-in to receiving reports. We check for that
1125 // record.
1126 // ../rfc/7489:1541
1127 orgDom := publicsuffix.Lookup(ctx, log.Logger, domain)
1128 destOrgDom := publicsuffix.Lookup(ctx, log.Logger, domConf.DMARC.DNSDomain)
1129 if orgDom != destOrgDom {
1130 accepts, status, _, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, log.Logger, resolver, domain, domConf.DMARC.DNSDomain)
1131 if status != dmarc.StatusNone {
1132 addf(&r.DMARC.Errors, "Checking if external destination accepts reports: %s", err)
1133 } else if !accepts {
1134 addf(&r.DMARC.Errors, "External destination does not accept reports (%s)", err)
1135 }
1136 extInstr = fmt.Sprintf("Ensure a DNS TXT record exists in the domain of the destination address to opt-in to receiving reports from this domain:\n\n\t%s._report._dmarc.%s. TXT \"v=DMARC1;\"\n\n", domain.ASCII, domConf.DMARC.DNSDomain.ASCII)
1137 }
1138
1139 uri := url.URL{
1140 Scheme: "mailto",
1141 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
1142 }
1143 uristr := uri.String()
1144 dmarcr.AggregateReportAddresses = []dmarc.URI{
1145 {Address: uristr, MaxSize: 10, Unit: "m"},
1146 }
1147
1148 if record != nil {
1149 found := false
1150 for _, addr := range record.AggregateReportAddresses {
1151 if addr.Address == uristr {
1152 found = true
1153 break
1154 }
1155 }
1156 if !found {
1157 addf(&r.DMARC.Errors, "Configured DMARC reporting address is not present in record.")
1158 }
1159 }
1160 } else {
1161 addf(&r.DMARC.Instructions, `Configure a DMARC destination in domain in config file.`)
1162 }
1163 instr := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_dmarc TXT %s\n\nYou can start with testing mode by replacing p=reject with p=none. You can also request for the policy to be applied to a percentage of emails instead of all, by adding pct=X, with X between 0 and 100. Keep in mind that receiving mail servers will apply some anti-spam assessment regardless of the policy and whether it is applied to the message. The ruf= part requests daily aggregate reports to be sent to the specified address, which is automatically configured and reports automatically analyzed.", mox.TXTStrings(dmarcr.String()))
1164 addf(&r.DMARC.Instructions, instr)
1165 if extInstr != "" {
1166 addf(&r.DMARC.Instructions, extInstr)
1167 }
1168 }()
1169
1170 checkTLSRPT := func(result *TLSRPTCheckResult, dom dns.Domain, address smtp.Address, isHost bool) {
1171 defer logPanic(ctx)
1172 defer wg.Done()
1173
1174 record, txt, err := tlsrpt.Lookup(ctx, log.Logger, resolver, dom)
1175 if err != nil {
1176 addf(&result.Errors, "Looking up TLSRPT record: %s", err)
1177 }
1178 result.TXT = txt
1179 if record != nil {
1180 result.Record = &TLSRPTRecord{*record}
1181 }
1182
1183 instr := `TLSRPT is an opt-in mechanism to request feedback about TLS connectivity from remote SMTP servers when they connect to us. It allows detecting delivery problems and unwanted downgrades to plaintext SMTP connections. With TLSRPT you configure an email address to which reports should be sent. Remote SMTP servers will send a report once a day with the number of successful connections, and the number of failed connections including details that should help debugging/resolving any issues. Both the mail host (e.g. mail.domain.example) and a recipient domain (e.g. domain.example, with an MX record pointing to mail.domain.example) can have a TLSRPT record. The TLSRPT record for the hosts is for reporting about DANE, the TLSRPT record for the domain is for MTA-STS.`
1184 var zeroaddr smtp.Address
1185 if address != zeroaddr {
1186 // TLSRPT does not require validation of reporting addresses outside the domain.
1187 // ../rfc/8460:1463
1188 uri := url.URL{
1189 Scheme: "mailto",
1190 Opaque: address.Pack(false),
1191 }
1192 rua := tlsrpt.RUA(uri.String())
1193 tlsrptr := &tlsrpt.Record{
1194 Version: "TLSRPTv1",
1195 RUAs: [][]tlsrpt.RUA{{rua}},
1196 }
1197 instr += fmt.Sprintf(`
1198
1199Ensure a DNS TXT record like the following exists:
1200
1201 _smtp._tls TXT %s
1202`, mox.TXTStrings(tlsrptr.String()))
1203
1204 if err == nil {
1205 found := false
1206 RUA:
1207 for _, l := range record.RUAs {
1208 for _, e := range l {
1209 if e == rua {
1210 found = true
1211 break RUA
1212 }
1213 }
1214 }
1215 if !found {
1216 addf(&result.Errors, `Configured reporting address is not present in TLSRPT record.`)
1217 }
1218 }
1219
1220 } else if isHost {
1221 addf(&result.Errors, `Configure a host TLSRPT localpart in static mox.conf config file.`)
1222 } else {
1223 addf(&result.Errors, `Configure a domain TLSRPT destination in domains.conf config file.`)
1224 }
1225 addf(&result.Instructions, instr)
1226 }
1227
1228 // Host TLSRPT
1229 wg.Add(1)
1230 var hostTLSRPTAddr smtp.Address
1231 if mox.Conf.Static.HostTLSRPT.Localpart != "" {
1232 hostTLSRPTAddr = smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain)
1233 }
1234 go checkTLSRPT(&r.HostTLSRPT, mox.Conf.Static.HostnameDomain, hostTLSRPTAddr, true)
1235
1236 // Domain TLSRPT
1237 wg.Add(1)
1238 var domainTLSRPTAddr smtp.Address
1239 if domConf.TLSRPT != nil {
1240 domainTLSRPTAddr = smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domain)
1241 }
1242 go checkTLSRPT(&r.DomainTLSRPT, domain, domainTLSRPTAddr, false)
1243
1244 // MTA-STS
1245 wg.Add(1)
1246 go func() {
1247 defer logPanic(ctx)
1248 defer wg.Done()
1249
1250 record, txt, err := mtasts.LookupRecord(ctx, log.Logger, resolver, domain)
1251 if err != nil {
1252 addf(&r.MTASTS.Errors, "Looking up MTA-STS record: %s", err)
1253 }
1254 r.MTASTS.TXT = txt
1255 if record != nil {
1256 r.MTASTS.Record = &MTASTSRecord{*record}
1257 }
1258
1259 policy, text, err := mtasts.FetchPolicy(ctx, log.Logger, domain)
1260 if err != nil {
1261 addf(&r.MTASTS.Errors, "Fetching MTA-STS policy: %s", err)
1262 } else if policy.Mode == mtasts.ModeNone {
1263 addf(&r.MTASTS.Warnings, "MTA-STS policy is present, but does not require TLS.")
1264 } else if policy.Mode == mtasts.ModeTesting {
1265 addf(&r.MTASTS.Warnings, "MTA-STS policy is in testing mode, do not forget to change to mode enforce after testing period.")
1266 }
1267 r.MTASTS.PolicyText = text
1268 r.MTASTS.Policy = policy
1269 if policy != nil && policy.Mode != mtasts.ModeNone {
1270 if !policy.Matches(mox.Conf.Static.HostnameDomain) {
1271 addf(&r.MTASTS.Warnings, "Configured hostname is missing from policy MX list.")
1272 }
1273 if policy.MaxAgeSeconds <= 24*3600 {
1274 addf(&r.MTASTS.Warnings, "Policy has a MaxAge of less than 1 day. For stable configurations, the recommended period is in weeks.")
1275 }
1276
1277 mxl, _, _ := resolver.LookupMX(ctx, domain.ASCII+".")
1278 // We do not check for errors, the MX check will complain about mx errors, we assume we will get the same error here.
1279 mxs := map[dns.Domain]struct{}{}
1280 for _, mx := range mxl {
1281 d, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
1282 if err != nil {
1283 addf(&r.MTASTS.Warnings, "MX record %q is invalid: %s", mx.Host, err)
1284 continue
1285 }
1286 mxs[d] = struct{}{}
1287 }
1288 for mx := range mxs {
1289 if !policy.Matches(mx) {
1290 addf(&r.MTASTS.Warnings, "MX record %q does not match MTA-STS policy MX list.", mx)
1291 }
1292 }
1293 for _, mx := range policy.MX {
1294 if mx.Wildcard {
1295 continue
1296 }
1297 if _, ok := mxs[mx.Domain]; !ok {
1298 addf(&r.MTASTS.Warnings, "MX %q in MTA-STS policy is not in MX record.", mx)
1299 }
1300 }
1301 }
1302
1303 intro := `MTA-STS is an opt-in mechanism to signal to remote SMTP servers which MX records are valid and that they must use the STARTTLS command and verify the TLS connection. Email servers should already be using STARTTLS to protect communication, but active attackers can, and have in the past, removed the indication of support for the optional STARTTLS support from SMTP sessions, or added additional MX records in DNS responses. MTA-STS protects against compromised DNS and compromised plaintext SMTP sessions, but not against compromised internet PKI infrastructure. If an attacker controls a certificate authority, and is willing to use it, MTA-STS does not prevent an attack. MTA-STS does not protect against attackers on first contact with a domain. Only on subsequent contacts, with MTA-STS policies in the cache, can attacks can be detected.
1304
1305After enabling MTA-STS for this domain, remote SMTP servers may still deliver in plain text, without TLS-protection. MTA-STS is an opt-in mechanism, not all servers support it yet.
1306
1307You can opt-in to MTA-STS by creating a DNS record, _mta-sts.<domain>, and serving a policy at https://mta-sts.<domain>/.well-known/mta-sts.txt. Mox will serve the policy, you must create the DNS records.
1308
1309You can start with a policy in "testing" mode. Remote SMTP servers will apply the MTA-STS policy, but not abort delivery in case of failure. Instead, you will receive a report if you have TLSRPT configured. By starting in testing mode for a representative period, verifying all mail can be deliverd, you can safely switch to "enforce" mode. While in enforce mode, plaintext deliveries to mox are refused.
1310
1311The _mta-sts DNS TXT record has an "id" field. The id serves as a version of the policy. A policy specifies the mode: none, testing, enforce. For "none", no TLS is required. A policy has a "max age", indicating how long the policy can be cached. Allowing the policy to be cached for a long time provides stronger counter measures to active attackers, but reduces configuration change agility. After enabling "enforce" mode, remote SMTP servers may and will cache your policy for as long as "max age" was configured. Keep this in mind when enabling/disabling MTA-STS. To disable MTA-STS after having it enabled, publish a new record with mode "none" until all past policy expiration times have passed.
1312
1313When enabling MTA-STS, or updating a policy, always update the policy first (through a configuration change and reload/restart), and the DNS record second.
1314`
1315 addf(&r.MTASTS.Instructions, intro)
1316
1317 addf(&r.MTASTS.Instructions, `Enable a policy through the configuration file. For new deployments, it is best to start with mode "testing" while enabling TLSRPT. Start with a short "max_age", so updates to your policy are picked up quickly. When confidence in the deployment is high enough, switch to "enforce" mode and a longer "max age". A max age in the order of weeks is recommended. If you foresee a change to your setup in the future, requiring different policies or MX records, you may want to dial back the "max age" ahead of time, similar to how you would handle TTL's in DNS record updates.`)
1318
1319 host := fmt.Sprintf("Ensure DNS CNAME/A/AAAA records exist that resolve mta-sts.%s to this mail server. For example:\n\n\t%s CNAME %s\n\n", domain.ASCII, "mta-sts."+domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".")
1320 addf(&r.MTASTS.Instructions, host)
1321
1322 mtastsr := mtasts.Record{
1323 Version: "STSv1",
1324 ID: time.Now().Format("20060102T150405"),
1325 }
1326 dns := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_mta-sts TXT %s\n\nConfigure the ID in the configuration file, it must be of the form [a-zA-Z0-9]{1,31}. It represents the version of the policy. For each policy change, you must change the ID to a new unique value. You could use a timestamp like 20220621T123000. When this field exists, an SMTP server will fetch a policy at https://mta-sts.%s/.well-known/mta-sts.txt. This policy is served by mox.", mox.TXTStrings(mtastsr.String()), domain.Name())
1327 addf(&r.MTASTS.Instructions, dns)
1328 }()
1329
1330 // SRVConf
1331 wg.Add(1)
1332 go func() {
1333 defer logPanic(ctx)
1334 defer wg.Done()
1335
1336 type srvReq struct {
1337 name string
1338 port uint16
1339 host string
1340 srvs []*net.SRV
1341 err error
1342 }
1343
1344 // We'll assume if any submissions is configured, it is public. Same for imap. And
1345 // if not, that there is a plain option.
1346 var submissions, imaps bool
1347 for _, l := range mox.Conf.Static.Listeners {
1348 if l.TLS != nil && l.Submissions.Enabled {
1349 submissions = true
1350 }
1351 if l.TLS != nil && l.IMAPS.Enabled {
1352 imaps = true
1353 }
1354 }
1355 srvhost := func(ok bool) string {
1356 if ok {
1357 return mox.Conf.Static.HostnameDomain.ASCII + "."
1358 }
1359 return "."
1360 }
1361 var reqs = []srvReq{
1362 {name: "_submissions", port: 465, host: srvhost(submissions)},
1363 {name: "_submission", port: 587, host: srvhost(!submissions)},
1364 {name: "_imaps", port: 993, host: srvhost(imaps)},
1365 {name: "_imap", port: 143, host: srvhost(!imaps)},
1366 {name: "_pop3", port: 110, host: "."},
1367 {name: "_pop3s", port: 995, host: "."},
1368 }
1369 var srvwg sync.WaitGroup
1370 srvwg.Add(len(reqs))
1371 for i := range reqs {
1372 go func(i int) {
1373 defer srvwg.Done()
1374 _, reqs[i].srvs, _, reqs[i].err = resolver.LookupSRV(ctx, reqs[i].name[1:], "tcp", domain.ASCII+".")
1375 }(i)
1376 }
1377 srvwg.Wait()
1378
1379 instr := "Ensure DNS records like the following exist:\n\n"
1380 r.SRVConf.SRVs = map[string][]net.SRV{}
1381 for _, req := range reqs {
1382 name := req.name + "_.tcp." + domain.ASCII
1383 instr += fmt.Sprintf("\t%s._tcp.%-*s SRV 0 1 %d %s\n", req.name, len("_submissions")-len(req.name)+len(domain.ASCII+"."), domain.ASCII+".", req.port, req.host)
1384 r.SRVConf.SRVs[req.name] = unptr(req.srvs)
1385 if err != nil {
1386 addf(&r.SRVConf.Errors, "Looking up SRV record %q: %s", name, err)
1387 } else if len(req.srvs) == 0 {
1388 addf(&r.SRVConf.Errors, "Missing SRV record %q", name)
1389 } else if len(req.srvs) != 1 || req.srvs[0].Target != req.host || req.srvs[0].Port != req.port {
1390 addf(&r.SRVConf.Errors, "Unexpected SRV record(s) for %q", name)
1391 }
1392 }
1393 addf(&r.SRVConf.Instructions, instr)
1394 }()
1395
1396 // Autoconf
1397 wg.Add(1)
1398 go func() {
1399 defer logPanic(ctx)
1400 defer wg.Done()
1401
1402 if domConf.ClientSettingsDomain != "" {
1403 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+".")
1404
1405 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, domConf.ClientSettingsDNSDomain.ASCII+".")
1406 if err != nil {
1407 addf(&r.Autoconf.Errors, "Looking up client settings DNS CNAME: %s", err)
1408 }
1409 r.Autoconf.ClientSettingsDomainIPs = ips
1410 if !isUnspecifiedNAT {
1411 if len(ourIPs) == 0 {
1412 addf(&r.Autoconf.Errors, "Client settings domain does not point to one of our IPs.")
1413 } else if len(notOurIPs) > 0 {
1414 addf(&r.Autoconf.Errors, "Client settings domain points to some IPs that are not ours: %v", notOurIPs)
1415 }
1416 }
1417 }
1418
1419 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+".")
1420
1421 host := "autoconfig." + domain.ASCII + "."
1422 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, host)
1423 if err != nil {
1424 addf(&r.Autoconf.Errors, "Looking up autoconfig host: %s", err)
1425 return
1426 }
1427
1428 r.Autoconf.IPs = ips
1429 if !isUnspecifiedNAT {
1430 if len(ourIPs) == 0 {
1431 addf(&r.Autoconf.Errors, "Autoconfig does not point to one of our IPs.")
1432 } else if len(notOurIPs) > 0 {
1433 addf(&r.Autoconf.Errors, "Autoconfig points to some IPs that are not ours: %v", notOurIPs)
1434 }
1435 }
1436
1437 checkTLS(&r.Autoconf.Errors, "autoconfig."+domain.ASCII, ips, "443")
1438 }()
1439
1440 // Autodiscover
1441 wg.Add(1)
1442 go func() {
1443 defer logPanic(ctx)
1444 defer wg.Done()
1445
1446 addf(&r.Autodiscover.Instructions, "Ensure DNS records like the following exist:\n\n\t_autodiscover._tcp.%s SRV 0 1 443 %s\n\tautoconfig.%s CNAME %s\n\nNote: the trailing dots are relevant, it makes the host names absolute instead of relative to the domain name.", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".", domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".")
1447
1448 _, srvs, _, err := resolver.LookupSRV(ctx, "autodiscover", "tcp", domain.ASCII+".")
1449 if err != nil {
1450 addf(&r.Autodiscover.Errors, "Looking up SRV record %q: %s", "autodiscover", err)
1451 return
1452 }
1453 match := false
1454 for _, srv := range srvs {
1455 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autodiscover.Errors, srv.Target)
1456 if err != nil {
1457 addf(&r.Autodiscover.Errors, "Looking up target %q from SRV record: %s", srv.Target, err)
1458 continue
1459 }
1460 if srv.Port != 443 {
1461 continue
1462 }
1463 match = true
1464 r.Autodiscover.Records = append(r.Autodiscover.Records, AutodiscoverSRV{*srv, ips})
1465 if !isUnspecifiedNAT {
1466 if len(ourIPs) == 0 {
1467 addf(&r.Autodiscover.Errors, "SRV target %q does not point to our IPs.", srv.Target)
1468 } else if len(notOurIPs) > 0 {
1469 addf(&r.Autodiscover.Errors, "SRV target %q points to some IPs that are not ours: %v", srv.Target, notOurIPs)
1470 }
1471 }
1472
1473 checkTLS(&r.Autodiscover.Errors, strings.TrimSuffix(srv.Target, "."), ips, "443")
1474 }
1475 if !match {
1476 addf(&r.Autodiscover.Errors, "No SRV record for port 443 for https.")
1477 }
1478 }()
1479
1480 wg.Wait()
1481 return
1482}
1483
1484// Domains returns all configured domain names, in UTF-8 for IDNA domains.
1485func (Admin) Domains(ctx context.Context) []dns.Domain {
1486 l := []dns.Domain{}
1487 for _, s := range mox.Conf.Domains() {
1488 d, _ := dns.ParseDomain(s)
1489 l = append(l, d)
1490 }
1491 return l
1492}
1493
1494// Domain returns the dns domain for a (potentially unicode as IDNA) domain name.
1495func (Admin) Domain(ctx context.Context, domain string) dns.Domain {
1496 d, err := dns.ParseDomain(domain)
1497 xcheckuserf(ctx, err, "parse domain")
1498 _, ok := mox.Conf.Domain(d)
1499 if !ok {
1500 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1501 }
1502 return d
1503}
1504
1505// ParseDomain parses a domain, possibly an IDNA domain.
1506func (Admin) ParseDomain(ctx context.Context, domain string) dns.Domain {
1507 d, err := dns.ParseDomain(domain)
1508 xcheckuserf(ctx, err, "parse domain")
1509 return d
1510}
1511
1512// DomainLocalparts returns the encoded localparts and accounts configured in domain.
1513func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string) {
1514 d, err := dns.ParseDomain(domain)
1515 xcheckuserf(ctx, err, "parsing domain")
1516 _, ok := mox.Conf.Domain(d)
1517 if !ok {
1518 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1519 }
1520 return mox.Conf.DomainLocalparts(d)
1521}
1522
1523// Accounts returns the names of all configured accounts.
1524func (Admin) Accounts(ctx context.Context) []string {
1525 l := mox.Conf.Accounts()
1526 sort.Slice(l, func(i, j int) bool {
1527 return l[i] < l[j]
1528 })
1529 return l
1530}
1531
1532// Account returns the parsed configuration of an account.
1533func (Admin) Account(ctx context.Context, account string) map[string]any {
1534 ac, ok := mox.Conf.Account(account)
1535 if !ok {
1536 xcheckuserf(ctx, errors.New("no such account"), "looking up account")
1537 }
1538
1539 // todo: should change sherpa to understand config.Account directly, with its anonymous structs.
1540 buf, err := json.Marshal(ac)
1541 xcheckf(ctx, err, "marshal to json")
1542 r := map[string]any{}
1543 err = json.Unmarshal(buf, &r)
1544 xcheckf(ctx, err, "unmarshal from json")
1545
1546 return r
1547}
1548
1549// ConfigFiles returns the paths and contents of the static and dynamic configuration files.
1550func (Admin) ConfigFiles(ctx context.Context) (staticPath, dynamicPath, static, dynamic string) {
1551 buf0, err := os.ReadFile(mox.ConfigStaticPath)
1552 xcheckf(ctx, err, "read static config file")
1553 buf1, err := os.ReadFile(mox.ConfigDynamicPath)
1554 xcheckf(ctx, err, "read dynamic config file")
1555 return mox.ConfigStaticPath, mox.ConfigDynamicPath, string(buf0), string(buf1)
1556}
1557
1558// MTASTSPolicies returns all mtasts policies from the cache.
1559func (Admin) MTASTSPolicies(ctx context.Context) (records []mtastsdb.PolicyRecord) {
1560 records, err := mtastsdb.PolicyRecords(ctx)
1561 xcheckf(ctx, err, "fetching mtasts policies from database")
1562 return records
1563}
1564
1565// TLSReports returns TLS reports overlapping with period start/end, for the given
1566// policy domain (or all domains if empty). The reports are sorted first by period
1567// end (most recent first), then by policy domain.
1568func (Admin) TLSReports(ctx context.Context, start, end time.Time, policyDomain string) (reports []tlsrptdb.TLSReportRecord) {
1569 var polDom dns.Domain
1570 if policyDomain != "" {
1571 var err error
1572 polDom, err = dns.ParseDomain(policyDomain)
1573 xcheckuserf(ctx, err, "parsing domain %q", policyDomain)
1574 }
1575
1576 records, err := tlsrptdb.RecordsPeriodDomain(ctx, start, end, polDom)
1577 xcheckf(ctx, err, "fetching tlsrpt report records from database")
1578 sort.Slice(records, func(i, j int) bool {
1579 iend := records[i].Report.DateRange.End
1580 jend := records[j].Report.DateRange.End
1581 if iend == jend {
1582 return records[i].Domain < records[j].Domain
1583 }
1584 return iend.After(jend)
1585 })
1586 return records
1587}
1588
1589// TLSReportID returns a single TLS report.
1590func (Admin) TLSReportID(ctx context.Context, domain string, reportID int64) tlsrptdb.TLSReportRecord {
1591 record, err := tlsrptdb.RecordID(ctx, reportID)
1592 if err == nil && record.Domain != domain {
1593 err = bstore.ErrAbsent
1594 }
1595 if err == bstore.ErrAbsent {
1596 xcheckuserf(ctx, err, "fetching tls report from database")
1597 }
1598 xcheckf(ctx, err, "fetching tls report from database")
1599 return record
1600}
1601
1602// TLSRPTSummary presents TLS reporting statistics for a single domain
1603// over a period.
1604type TLSRPTSummary struct {
1605 PolicyDomain dns.Domain
1606 Success int64
1607 Failure int64
1608 ResultTypeCounts map[tlsrpt.ResultType]int64
1609}
1610
1611// TLSRPTSummaries returns a summary of received TLS reports overlapping with
1612// period start/end for one or all domains (when domain is empty).
1613// The returned summaries are ordered by domain name.
1614func (Admin) TLSRPTSummaries(ctx context.Context, start, end time.Time, policyDomain string) (domainSummaries []TLSRPTSummary) {
1615 var polDom dns.Domain
1616 if policyDomain != "" {
1617 var err error
1618 polDom, err = dns.ParseDomain(policyDomain)
1619 xcheckuserf(ctx, err, "parsing policy domain")
1620 }
1621 reports, err := tlsrptdb.RecordsPeriodDomain(ctx, start, end, polDom)
1622 xcheckf(ctx, err, "fetching tlsrpt reports from database")
1623
1624 summaries := map[dns.Domain]TLSRPTSummary{}
1625 for _, r := range reports {
1626 dom, err := dns.ParseDomain(r.Domain)
1627 xcheckf(ctx, err, "parsing domain %q", r.Domain)
1628
1629 sum := summaries[dom]
1630 sum.PolicyDomain = dom
1631 for _, result := range r.Report.Policies {
1632 sum.Success += result.Summary.TotalSuccessfulSessionCount
1633 sum.Failure += result.Summary.TotalFailureSessionCount
1634 for _, details := range result.FailureDetails {
1635 if sum.ResultTypeCounts == nil {
1636 sum.ResultTypeCounts = map[tlsrpt.ResultType]int64{}
1637 }
1638 sum.ResultTypeCounts[details.ResultType] += details.FailedSessionCount
1639 }
1640 }
1641 summaries[dom] = sum
1642 }
1643 sums := make([]TLSRPTSummary, 0, len(summaries))
1644 for _, sum := range summaries {
1645 sums = append(sums, sum)
1646 }
1647 sort.Slice(sums, func(i, j int) bool {
1648 return sums[i].PolicyDomain.Name() < sums[j].PolicyDomain.Name()
1649 })
1650 return sums
1651}
1652
1653// DMARCReports returns DMARC reports overlapping with period start/end, for the
1654// given domain (or all domains if empty). The reports are sorted first by period
1655// end (most recent first), then by domain.
1656func (Admin) DMARCReports(ctx context.Context, start, end time.Time, domain string) (reports []dmarcdb.DomainFeedback) {
1657 reports, err := dmarcdb.RecordsPeriodDomain(ctx, start, end, domain)
1658 xcheckf(ctx, err, "fetching dmarc aggregate reports from database")
1659 sort.Slice(reports, func(i, j int) bool {
1660 iend := reports[i].ReportMetadata.DateRange.End
1661 jend := reports[j].ReportMetadata.DateRange.End
1662 if iend == jend {
1663 return reports[i].Domain < reports[j].Domain
1664 }
1665 return iend > jend
1666 })
1667 return reports
1668}
1669
1670// DMARCReportID returns a single DMARC report.
1671func (Admin) DMARCReportID(ctx context.Context, domain string, reportID int64) (report dmarcdb.DomainFeedback) {
1672 report, err := dmarcdb.RecordID(ctx, reportID)
1673 if err == nil && report.Domain != domain {
1674 err = bstore.ErrAbsent
1675 }
1676 if err == bstore.ErrAbsent {
1677 xcheckuserf(ctx, err, "fetching dmarc aggregate report from database")
1678 }
1679 xcheckf(ctx, err, "fetching dmarc aggregate report from database")
1680 return report
1681}
1682
1683// DMARCSummary presents DMARC aggregate reporting statistics for a single domain
1684// over a period.
1685type DMARCSummary struct {
1686 Domain string
1687 Total int
1688 DispositionNone int
1689 DispositionQuarantine int
1690 DispositionReject int
1691 DKIMFail int
1692 SPFFail int
1693 PolicyOverrides map[dmarcrpt.PolicyOverride]int
1694}
1695
1696// DMARCSummaries returns a summary of received DMARC reports overlapping with
1697// period start/end for one or all domains (when domain is empty).
1698// The returned summaries are ordered by domain name.
1699func (Admin) DMARCSummaries(ctx context.Context, start, end time.Time, domain string) (domainSummaries []DMARCSummary) {
1700 reports, err := dmarcdb.RecordsPeriodDomain(ctx, start, end, domain)
1701 xcheckf(ctx, err, "fetching dmarc aggregate reports from database")
1702 summaries := map[string]DMARCSummary{}
1703 for _, r := range reports {
1704 sum := summaries[r.Domain]
1705 sum.Domain = r.Domain
1706 for _, record := range r.Records {
1707 n := record.Row.Count
1708
1709 sum.Total += n
1710
1711 switch record.Row.PolicyEvaluated.Disposition {
1712 case dmarcrpt.DispositionNone:
1713 sum.DispositionNone += n
1714 case dmarcrpt.DispositionQuarantine:
1715 sum.DispositionQuarantine += n
1716 case dmarcrpt.DispositionReject:
1717 sum.DispositionReject += n
1718 }
1719
1720 if record.Row.PolicyEvaluated.DKIM == dmarcrpt.DMARCFail {
1721 sum.DKIMFail += n
1722 }
1723 if record.Row.PolicyEvaluated.SPF == dmarcrpt.DMARCFail {
1724 sum.SPFFail += n
1725 }
1726
1727 for _, reason := range record.Row.PolicyEvaluated.Reasons {
1728 if sum.PolicyOverrides == nil {
1729 sum.PolicyOverrides = map[dmarcrpt.PolicyOverride]int{}
1730 }
1731 sum.PolicyOverrides[reason.Type] += n
1732 }
1733 }
1734 summaries[r.Domain] = sum
1735 }
1736 sums := make([]DMARCSummary, 0, len(summaries))
1737 for _, sum := range summaries {
1738 sums = append(sums, sum)
1739 }
1740 sort.Slice(sums, func(i, j int) bool {
1741 return sums[i].Domain < sums[j].Domain
1742 })
1743 return sums
1744}
1745
1746// Reverse is the result of a reverse lookup.
1747type Reverse struct {
1748 Hostnames []string
1749
1750 // In the future, we can add a iprev-validated host name, and possibly the IPs of the host names.
1751}
1752
1753// LookupIP does a reverse lookup of ip.
1754func (Admin) LookupIP(ctx context.Context, ip string) Reverse {
1755 resolver := dns.StrictResolver{Pkg: "webadmin", Log: pkglog.WithContext(ctx).Logger}
1756 names, _, err := resolver.LookupAddr(ctx, ip)
1757 xcheckuserf(ctx, err, "looking up ip")
1758 return Reverse{names}
1759}
1760
1761// DNSBLStatus returns the IPs from which outgoing connections may be made and
1762// their current status in DNSBLs that are configured. The IPs are typically the
1763// configured listen IPs, or otherwise IPs on the machines network interfaces, with
1764// internal/private IPs removed.
1765//
1766// The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and
1767// anything else is an error string, e.g. "fail: ..." or "temperror: ...".
1768func (Admin) DNSBLStatus(ctx context.Context) map[string]map[string]string {
1769 log := mlog.New("webadmin", nil).WithContext(ctx)
1770 resolver := dns.StrictResolver{Pkg: "check", Log: log.Logger}
1771 return dnsblsStatus(ctx, log, resolver)
1772}
1773
1774func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) map[string]map[string]string {
1775 // todo: check health before using dnsbl?
1776 var dnsbls []dns.Domain
1777 if l, ok := mox.Conf.Static.Listeners["public"]; ok {
1778 for _, dnsbl := range l.SMTP.DNSBLs {
1779 zone, err := dns.ParseDomain(dnsbl)
1780 xcheckf(ctx, err, "parse dnsbl zone")
1781 dnsbls = append(dnsbls, zone)
1782 }
1783 }
1784
1785 r := map[string]map[string]string{}
1786 for _, ip := range xsendingIPs(ctx) {
1787 if ip.IsLoopback() || ip.IsPrivate() {
1788 continue
1789 }
1790 ipstr := ip.String()
1791 r[ipstr] = map[string]string{}
1792 for _, zone := range dnsbls {
1793 status, expl, err := dnsbl.Lookup(ctx, log.Logger, resolver, zone, ip)
1794 result := string(status)
1795 if err != nil {
1796 result += ": " + err.Error()
1797 }
1798 if expl != "" {
1799 result += ": " + expl
1800 }
1801 r[ipstr][zone.LogString()] = result
1802 }
1803 }
1804 return r
1805}
1806
1807// DomainRecords returns lines describing DNS records that should exist for the
1808// configured domain.
1809func (Admin) DomainRecords(ctx context.Context, domain string) []string {
1810 log := pkglog.WithContext(ctx)
1811 return DomainRecords(ctx, log, domain)
1812}
1813
1814// DomainRecords is the implementation of API function Admin.DomainRecords, taking
1815// a logger.
1816func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string {
1817 d, err := dns.ParseDomain(domain)
1818 xcheckuserf(ctx, err, "parsing domain")
1819 dc, ok := mox.Conf.Domain(d)
1820 if !ok {
1821 xcheckuserf(ctx, errors.New("unknown domain"), "lookup domain")
1822 }
1823 resolver := dns.StrictResolver{Pkg: "webadmin", Log: pkglog.WithContext(ctx).Logger}
1824 _, result, err := resolver.LookupTXT(ctx, domain+".")
1825 if !dns.IsNotFound(err) {
1826 xcheckf(ctx, err, "looking up record to determine if dnssec is implemented")
1827 }
1828
1829 var certIssuerDomainName, acmeAccountURI string
1830 public := mox.Conf.Static.Listeners["public"]
1831 if public.TLS != nil && public.TLS.ACME != "" {
1832 acme, ok := mox.Conf.Static.ACME[public.TLS.ACME]
1833 if ok && acme.Manager.Manager.Client != nil {
1834 certIssuerDomainName = acme.IssuerDomainName
1835 acc, err := acme.Manager.Manager.Client.GetReg(ctx, "")
1836 log.Check(err, "get public acme account")
1837 if err == nil {
1838 acmeAccountURI = acc.URI
1839 }
1840 }
1841 }
1842
1843 records, err := mox.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
1844 xcheckf(ctx, err, "dns records")
1845 return records
1846}
1847
1848// DomainAdd adds a new domain and reloads the configuration.
1849func (Admin) DomainAdd(ctx context.Context, domain, accountName, localpart string) {
1850 d, err := dns.ParseDomain(domain)
1851 xcheckuserf(ctx, err, "parsing domain")
1852
1853 err = mox.DomainAdd(ctx, d, accountName, smtp.Localpart(localpart))
1854 xcheckf(ctx, err, "adding domain")
1855}
1856
1857// DomainRemove removes an existing domain and reloads the configuration.
1858func (Admin) DomainRemove(ctx context.Context, domain string) {
1859 d, err := dns.ParseDomain(domain)
1860 xcheckuserf(ctx, err, "parsing domain")
1861
1862 err = mox.DomainRemove(ctx, d)
1863 xcheckf(ctx, err, "removing domain")
1864}
1865
1866// AccountAdd adds existing a new account, with an initial email address, and
1867// reloads the configuration.
1868func (Admin) AccountAdd(ctx context.Context, accountName, address string) {
1869 err := mox.AccountAdd(ctx, accountName, address)
1870 xcheckf(ctx, err, "adding account")
1871}
1872
1873// AccountRemove removes an existing account and reloads the configuration.
1874func (Admin) AccountRemove(ctx context.Context, accountName string) {
1875 err := mox.AccountRemove(ctx, accountName)
1876 xcheckf(ctx, err, "removing account")
1877}
1878
1879// AddressAdd adds a new address to the account, which must already exist.
1880func (Admin) AddressAdd(ctx context.Context, address, accountName string) {
1881 err := mox.AddressAdd(ctx, address, accountName)
1882 xcheckf(ctx, err, "adding address")
1883}
1884
1885// AddressRemove removes an existing address.
1886func (Admin) AddressRemove(ctx context.Context, address string) {
1887 err := mox.AddressRemove(ctx, address)
1888 xcheckf(ctx, err, "removing address")
1889}
1890
1891// SetPassword saves a new password for an account, invalidating the previous password.
1892// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
1893// Password must be at least 8 characters.
1894func (Admin) SetPassword(ctx context.Context, accountName, password string) {
1895 log := pkglog.WithContext(ctx)
1896 if len(password) < 8 {
1897 panic(&sherpa.Error{Code: "user:error", Message: "password must be at least 8 characters"})
1898 }
1899 acc, err := store.OpenAccount(log, accountName)
1900 xcheckf(ctx, err, "open account")
1901 defer func() {
1902 err := acc.Close()
1903 log.WithContext(ctx).Check(err, "closing account")
1904 }()
1905 err = acc.SetPassword(log, password)
1906 xcheckf(ctx, err, "setting password")
1907}
1908
1909// SetAccountLimits set new limits on outgoing messages for an account.
1910func (Admin) SetAccountLimits(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64) {
1911 err := mox.AccountLimitsSave(ctx, accountName, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay, maxMsgSize)
1912 xcheckf(ctx, err, "saving account limits")
1913}
1914
1915// ClientConfigsDomain returns configurations for email clients, IMAP and
1916// Submission (SMTP) for the domain.
1917func (Admin) ClientConfigsDomain(ctx context.Context, domain string) mox.ClientConfigs {
1918 d, err := dns.ParseDomain(domain)
1919 xcheckuserf(ctx, err, "parsing domain")
1920
1921 cc, err := mox.ClientConfigsDomain(d)
1922 xcheckf(ctx, err, "client config for domain")
1923 return cc
1924}
1925
1926// QueueList returns the messages currently in the outgoing queue.
1927func (Admin) QueueList(ctx context.Context) []queue.Msg {
1928 l, err := queue.List(ctx)
1929 xcheckf(ctx, err, "listing messages in queue")
1930 return l
1931}
1932
1933// QueueSize returns the number of messages currently in the outgoing queue.
1934func (Admin) QueueSize(ctx context.Context) int {
1935 n, err := queue.Count(ctx)
1936 xcheckf(ctx, err, "listing messages in queue")
1937 return n
1938}
1939
1940// QueueKick initiates delivery of a message from the queue and sets the transport
1941// to use for delivery.
1942func (Admin) QueueKick(ctx context.Context, id int64, transport string) {
1943 n, err := queue.Kick(ctx, id, "", "", &transport)
1944 if err == nil && n == 0 {
1945 err = errors.New("message not found")
1946 }
1947 xcheckf(ctx, err, "kick message in queue")
1948}
1949
1950// QueueDrop removes a message from the queue.
1951func (Admin) QueueDrop(ctx context.Context, id int64) {
1952 log := pkglog.WithContext(ctx)
1953 n, err := queue.Drop(ctx, log, id, "", "")
1954 if err == nil && n == 0 {
1955 err = errors.New("message not found")
1956 }
1957 xcheckf(ctx, err, "drop message from queue")
1958}
1959
1960// QueueSaveRequireTLS updates the requiretls field for a message in the queue,
1961// to be used for the next delivery.
1962func (Admin) QueueSaveRequireTLS(ctx context.Context, id int64, requireTLS *bool) {
1963 err := queue.SaveRequireTLS(ctx, id, requireTLS)
1964 xcheckf(ctx, err, "update requiretls for message in queue")
1965}
1966
1967// LogLevels returns the current log levels.
1968func (Admin) LogLevels(ctx context.Context) map[string]string {
1969 m := map[string]string{}
1970 for pkg, level := range mox.Conf.LogLevels() {
1971 s, ok := mlog.LevelStrings[level]
1972 if !ok {
1973 s = level.String()
1974 }
1975 m[pkg] = s
1976 }
1977 return m
1978}
1979
1980// LogLevelSet sets a log level for a package.
1981func (Admin) LogLevelSet(ctx context.Context, pkg string, levelStr string) {
1982 level, ok := mlog.Levels[levelStr]
1983 if !ok {
1984 xcheckuserf(ctx, errors.New("unknown"), "lookup level")
1985 }
1986 mox.Conf.LogLevelSet(pkglog.WithContext(ctx), pkg, level)
1987}
1988
1989// LogLevelRemove removes a log level for a package, which cannot be the empty string.
1990func (Admin) LogLevelRemove(ctx context.Context, pkg string) {
1991 mox.Conf.LogLevelRemove(pkglog.WithContext(ctx), pkg)
1992}
1993
1994// CheckUpdatesEnabled returns whether checking for updates is enabled.
1995func (Admin) CheckUpdatesEnabled(ctx context.Context) bool {
1996 return mox.Conf.Static.CheckUpdates
1997}
1998
1999// WebserverConfig is the combination of WebDomainRedirects and WebHandlers
2000// from the domains.conf configuration file.
2001type WebserverConfig struct {
2002 WebDNSDomainRedirects [][2]dns.Domain // From server to frontend.
2003 WebDomainRedirects [][2]string // From frontend to server, it's not convenient to create dns.Domain in the frontend.
2004 WebHandlers []config.WebHandler
2005}
2006
2007// WebserverConfig returns the current webserver config
2008func (Admin) WebserverConfig(ctx context.Context) (conf WebserverConfig) {
2009 conf = webserverConfig()
2010 conf.WebDomainRedirects = nil
2011 return conf
2012}
2013
2014func webserverConfig() WebserverConfig {
2015 r, l := mox.Conf.WebServer()
2016 x := make([][2]dns.Domain, 0, len(r))
2017 xs := make([][2]string, 0, len(r))
2018 for k, v := range r {
2019 x = append(x, [2]dns.Domain{k, v})
2020 xs = append(xs, [2]string{k.Name(), v.Name()})
2021 }
2022 sort.Slice(x, func(i, j int) bool {
2023 return x[i][0].ASCII < x[j][0].ASCII
2024 })
2025 sort.Slice(xs, func(i, j int) bool {
2026 return xs[i][0] < xs[j][0]
2027 })
2028 return WebserverConfig{x, xs, l}
2029}
2030
2031// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
2032// the current config, an error is returned.
2033func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf WebserverConfig) (savedConf WebserverConfig) {
2034 current := webserverConfig()
2035 webhandlersEqual := func() bool {
2036 if len(current.WebHandlers) != len(oldConf.WebHandlers) {
2037 return false
2038 }
2039 for i, wh := range current.WebHandlers {
2040 if !wh.Equal(oldConf.WebHandlers[i]) {
2041 return false
2042 }
2043 }
2044 return true
2045 }
2046 if !reflect.DeepEqual(oldConf.WebDNSDomainRedirects, current.WebDNSDomainRedirects) || !webhandlersEqual() {
2047 xcheckuserf(ctx, errors.New("config has changed"), "comparing old/current config")
2048 }
2049
2050 // Convert to map, check that there are no duplicates here. The canonicalized
2051 // dns.Domain are checked again for uniqueness when parsing the config before
2052 // storing.
2053 domainRedirects := map[string]string{}
2054 for _, x := range newConf.WebDomainRedirects {
2055 if _, ok := domainRedirects[x[0]]; ok {
2056 xcheckuserf(ctx, errors.New("already present"), "checking redirect %s", x[0])
2057 }
2058 domainRedirects[x[0]] = x[1]
2059 }
2060
2061 err := mox.WebserverConfigSet(ctx, domainRedirects, newConf.WebHandlers)
2062 xcheckf(ctx, err, "saving webserver config")
2063
2064 savedConf = webserverConfig()
2065 savedConf.WebDomainRedirects = nil
2066 return savedConf
2067}
2068
2069// Transports returns the configured transports, for sending email.
2070func (Admin) Transports(ctx context.Context) map[string]config.Transport {
2071 return mox.Conf.Static.Transports
2072}
2073
2074// DMARCEvaluationStats returns a map of all domains with evaluations to a count of
2075// the evaluations and whether those evaluations will cause a report to be sent.
2076func (Admin) DMARCEvaluationStats(ctx context.Context) map[string]dmarcdb.EvaluationStat {
2077 stats, err := dmarcdb.EvaluationStats(ctx)
2078 xcheckf(ctx, err, "get evaluation stats")
2079 return stats
2080}
2081
2082// DMARCEvaluationsDomain returns all evaluations for aggregate reports for the
2083// domain, sorted from oldest to most recent.
2084func (Admin) DMARCEvaluationsDomain(ctx context.Context, domain string) (dns.Domain, []dmarcdb.Evaluation) {
2085 dom, err := dns.ParseDomain(domain)
2086 xcheckf(ctx, err, "parsing domain")
2087
2088 evals, err := dmarcdb.EvaluationsDomain(ctx, dom)
2089 xcheckf(ctx, err, "get evaluations for domain")
2090 return dom, evals
2091}
2092
2093// DMARCRemoveEvaluations removes evaluations for a domain.
2094func (Admin) DMARCRemoveEvaluations(ctx context.Context, domain string) {
2095 dom, err := dns.ParseDomain(domain)
2096 xcheckf(ctx, err, "parsing domain")
2097
2098 err = dmarcdb.RemoveEvaluationsDomain(ctx, dom)
2099 xcheckf(ctx, err, "removing evaluations for domain")
2100}
2101
2102// DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing
2103// reports will be suppressed for a period.
2104func (Admin) DMARCSuppressAdd(ctx context.Context, reportingAddress string, until time.Time, comment string) {
2105 addr, err := smtp.ParseAddress(reportingAddress)
2106 xcheckuserf(ctx, err, "parsing reporting address")
2107
2108 ba := dmarcdb.SuppressAddress{ReportingAddress: addr.String(), Until: until, Comment: comment}
2109 err = dmarcdb.SuppressAdd(ctx, &ba)
2110 xcheckf(ctx, err, "adding address to suppresslist")
2111}
2112
2113// DMARCSuppressList returns all reporting addresses on the suppress list.
2114func (Admin) DMARCSuppressList(ctx context.Context) []dmarcdb.SuppressAddress {
2115 l, err := dmarcdb.SuppressList(ctx)
2116 xcheckf(ctx, err, "listing reporting addresses in suppresslist")
2117 return l
2118}
2119
2120// DMARCSuppressRemove removes a reporting address record from the suppress list.
2121func (Admin) DMARCSuppressRemove(ctx context.Context, id int64) {
2122 err := dmarcdb.SuppressRemove(ctx, id)
2123 xcheckf(ctx, err, "removing reporting address from suppresslist")
2124}
2125
2126// DMARCSuppressExtend updates the until field of a suppressed reporting address record.
2127func (Admin) DMARCSuppressExtend(ctx context.Context, id int64, until time.Time) {
2128 err := dmarcdb.SuppressUpdate(ctx, id, until)
2129 xcheckf(ctx, err, "updating reporting address in suppresslist")
2130}
2131
2132// TLSRPTResults returns all TLSRPT results in the database.
2133func (Admin) TLSRPTResults(ctx context.Context) []tlsrptdb.TLSResult {
2134 results, err := tlsrptdb.Results(ctx)
2135 xcheckf(ctx, err, "get results")
2136 return results
2137}
2138
2139// TLSRPTResultsPolicyDomain returns the TLS results for a domain.
2140func (Admin) TLSRPTResultsDomain(ctx context.Context, isRcptDom bool, policyDomain string) (dns.Domain, []tlsrptdb.TLSResult) {
2141 dom, err := dns.ParseDomain(policyDomain)
2142 xcheckf(ctx, err, "parsing domain")
2143
2144 if isRcptDom {
2145 results, err := tlsrptdb.ResultsRecipientDomain(ctx, dom)
2146 xcheckf(ctx, err, "get result for recipient domain")
2147 return dom, results
2148 }
2149 results, err := tlsrptdb.ResultsPolicyDomain(ctx, dom)
2150 xcheckf(ctx, err, "get result for policy domain")
2151 return dom, results
2152}
2153
2154// LookupTLSRPTRecord looks up a TLSRPT record and returns the parsed form, original txt
2155// form from DNS, and error with the TLSRPT record as a string.
2156func (Admin) LookupTLSRPTRecord(ctx context.Context, domain string) (record *TLSRPTRecord, txt string, errstr string) {
2157 log := pkglog.WithContext(ctx)
2158 dom, err := dns.ParseDomain(domain)
2159 xcheckf(ctx, err, "parsing domain")
2160
2161 resolver := dns.StrictResolver{Pkg: "webadmin", Log: log.Logger}
2162 r, txt, err := tlsrpt.Lookup(ctx, log.Logger, resolver, dom)
2163 if err != nil && (errors.Is(err, tlsrpt.ErrNoRecord) || errors.Is(err, tlsrpt.ErrMultipleRecords) || errors.Is(err, tlsrpt.ErrRecordSyntax) || errors.Is(err, tlsrpt.ErrDNS)) {
2164 errstr = err.Error()
2165 err = nil
2166 }
2167 xcheckf(ctx, err, "fetching tlsrpt record")
2168
2169 if r != nil {
2170 record = &TLSRPTRecord{Record: *r}
2171 }
2172
2173 return record, txt, errstr
2174}
2175
2176// TLSRPTRemoveResults removes the TLS results for a domain for the given day. If
2177// day is empty, all results are removed.
2178func (Admin) TLSRPTRemoveResults(ctx context.Context, isRcptDom bool, domain string, day string) {
2179 dom, err := dns.ParseDomain(domain)
2180 xcheckf(ctx, err, "parsing domain")
2181
2182 if isRcptDom {
2183 err = tlsrptdb.RemoveResultsRecipientDomain(ctx, dom, day)
2184 xcheckf(ctx, err, "removing tls results")
2185 } else {
2186 err = tlsrptdb.RemoveResultsPolicyDomain(ctx, dom, day)
2187 xcheckf(ctx, err, "removing tls results")
2188 }
2189}
2190
2191// TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing
2192// reports will be suppressed for a period.
2193func (Admin) TLSRPTSuppressAdd(ctx context.Context, reportingAddress string, until time.Time, comment string) {
2194 addr, err := smtp.ParseAddress(reportingAddress)
2195 xcheckuserf(ctx, err, "parsing reporting address")
2196
2197 ba := tlsrptdb.TLSRPTSuppressAddress{ReportingAddress: addr.String(), Until: until, Comment: comment}
2198 err = tlsrptdb.SuppressAdd(ctx, &ba)
2199 xcheckf(ctx, err, "adding address to suppresslist")
2200}
2201
2202// TLSRPTSuppressList returns all reporting addresses on the suppress list.
2203func (Admin) TLSRPTSuppressList(ctx context.Context) []tlsrptdb.TLSRPTSuppressAddress {
2204 l, err := tlsrptdb.SuppressList(ctx)
2205 xcheckf(ctx, err, "listing reporting addresses in suppresslist")
2206 return l
2207}
2208
2209// TLSRPTSuppressRemove removes a reporting address record from the suppress list.
2210func (Admin) TLSRPTSuppressRemove(ctx context.Context, id int64) {
2211 err := tlsrptdb.SuppressRemove(ctx, id)
2212 xcheckf(ctx, err, "removing reporting address from suppresslist")
2213}
2214
2215// TLSRPTSuppressExtend updates the until field of a suppressed reporting address record.
2216func (Admin) TLSRPTSuppressExtend(ctx context.Context, id int64, until time.Time) {
2217 err := tlsrptdb.SuppressUpdate(ctx, id, until)
2218 xcheckf(ctx, err, "updating reporting address in suppresslist")
2219}
2220