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