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 "log/slog"
23 "maps"
24 "net"
25 "net/http"
26 "net/url"
27 "os"
28 "path/filepath"
29 "reflect"
30 "runtime"
31 "runtime/debug"
32 "slices"
33 "sort"
34 "strings"
35 "sync"
36 "time"
37
38 _ "embed"
39
40 "golang.org/x/text/unicode/norm"
41
42 "github.com/mjl-/adns"
43
44 "github.com/mjl-/bstore"
45 "github.com/mjl-/sherpa"
46 "github.com/mjl-/sherpadoc"
47 "github.com/mjl-/sherpaprom"
48
49 "github.com/mjl-/mox/admin"
50 "github.com/mjl-/mox/config"
51 "github.com/mjl-/mox/dkim"
52 "github.com/mjl-/mox/dmarc"
53 "github.com/mjl-/mox/dmarcdb"
54 "github.com/mjl-/mox/dmarcrpt"
55 "github.com/mjl-/mox/dns"
56 "github.com/mjl-/mox/dnsbl"
57 "github.com/mjl-/mox/metrics"
58 "github.com/mjl-/mox/mlog"
59 mox "github.com/mjl-/mox/mox-"
60 "github.com/mjl-/mox/moxvar"
61 "github.com/mjl-/mox/mtasts"
62 "github.com/mjl-/mox/mtastsdb"
63 "github.com/mjl-/mox/publicsuffix"
64 "github.com/mjl-/mox/queue"
65 "github.com/mjl-/mox/smtp"
66 "github.com/mjl-/mox/spf"
67 "github.com/mjl-/mox/store"
68 "github.com/mjl-/mox/tlsrpt"
69 "github.com/mjl-/mox/tlsrptdb"
70 "github.com/mjl-/mox/webauth"
71)
72
73var pkglog = mlog.New("webadmin", nil)
74
75//go:embed api.json
76var adminapiJSON []byte
77
78//go:embed admin.html
79var adminHTML []byte
80
81//go:embed admin.js
82var adminJS []byte
83
84var webadminFile = &mox.WebappFile{
85 HTML: adminHTML,
86 JS: adminJS,
87 HTMLPath: filepath.FromSlash("webadmin/admin.html"),
88 JSPath: filepath.FromSlash("webadmin/admin.js"),
89 CustomStem: "webadmin",
90}
91
92var adminDoc = mustParseAPI("admin", adminapiJSON)
93
94func mustParseAPI(api string, buf []byte) (doc sherpadoc.Section) {
95 err := json.Unmarshal(buf, &doc)
96 if err != nil {
97 pkglog.Fatalx("parsing webadmin api docs", err, slog.String("api", api))
98 }
99 return doc
100}
101
102var sherpaHandlerOpts *sherpa.HandlerOpts
103
104func makeSherpaHandler(cookiePath string, isForwarded bool) (http.Handler, error) {
105 return sherpa.NewHandler("/api/", moxvar.Version, Admin{cookiePath, isForwarded}, &adminDoc, sherpaHandlerOpts)
106}
107
108func init() {
109 collector, err := sherpaprom.NewCollector("moxadmin", nil)
110 if err != nil {
111 pkglog.Fatalx("creating sherpa prometheus collector", err)
112 }
113
114 sherpaHandlerOpts = &sherpa.HandlerOpts{Collector: collector, AdjustFunctionNames: "none", NoCORS: true}
115 // Just to validate.
116 _, err = makeSherpaHandler("", false)
117 if err != nil {
118 pkglog.Fatalx("sherpa handler", err)
119 }
120
121 mox.NewWebadminHandler = func(basePath string, isForwarded bool) http.Handler {
122 return http.HandlerFunc(Handler(basePath, isForwarded))
123 }
124}
125
126// Handler returns a handler for the webadmin endpoints, customized for the
127// cookiePath.
128func Handler(cookiePath string, isForwarded bool) func(w http.ResponseWriter, r *http.Request) {
129 sh, err := makeSherpaHandler(cookiePath, isForwarded)
130 return func(w http.ResponseWriter, r *http.Request) {
131 if err != nil {
132 http.Error(w, "500 - internal server error - cannot handle requests", http.StatusInternalServerError)
133 return
134 }
135 handle(sh, isForwarded, w, r)
136 }
137}
138
139// Admin exports web API functions for the admin web interface. All its methods are
140// exported under api/. Function calls require valid HTTP Authentication
141// credentials of a user.
142type Admin struct {
143 cookiePath string // From listener, for setting authentication cookies.
144 isForwarded bool // From listener, whether we look at X-Forwarded-* headers.
145}
146
147type ctxKey string
148
149var requestInfoCtxKey ctxKey = "requestInfo"
150
151type requestInfo struct {
152 SessionToken store.SessionToken
153 Response http.ResponseWriter
154 Request *http.Request // For Proto and TLS connection state during message submit.
155}
156
157func handle(apiHandler http.Handler, isForwarded bool, w http.ResponseWriter, r *http.Request) {
158 ctx := context.WithValue(r.Context(), mlog.CidKey, mox.Cid())
159 log := pkglog.WithContext(ctx).With(slog.String("adminauth", ""))
160
161 // HTML/JS can be retrieved without authentication.
162 if r.URL.Path == "/" {
163 switch r.Method {
164 case "GET", "HEAD":
165 webadminFile.Serve(ctx, log, w, r)
166 default:
167 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
168 }
169 return
170 } else if r.URL.Path == "/licenses.txt" {
171 switch r.Method {
172 case "GET", "HEAD":
173 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
174 mox.LicensesWrite(w)
175 default:
176 http.Error(w, "405 - method not allowed - use get", http.StatusMethodNotAllowed)
177 }
178 return
179 }
180
181 isAPI := strings.HasPrefix(r.URL.Path, "/api/")
182 // Only allow POST for calls, they will not work cross-domain without CORS.
183 if isAPI && r.URL.Path != "/api/" && r.Method != "POST" {
184 http.Error(w, "405 - method not allowed - use post", http.StatusMethodNotAllowed)
185 return
186 }
187
188 // All other URLs, except the login endpoint require some authentication.
189 var sessionToken store.SessionToken
190 if r.URL.Path != "/api/LoginPrep" && r.URL.Path != "/api/Login" {
191 var ok bool
192 _, sessionToken, _, ok = webauth.Check(ctx, log, webauth.Admin, "webadmin", isForwarded, w, r, isAPI, isAPI, false)
193 if !ok {
194 // Response has been written already.
195 return
196 }
197 }
198
199 if isAPI {
200 reqInfo := requestInfo{sessionToken, w, r}
201 ctx = context.WithValue(ctx, requestInfoCtxKey, reqInfo)
202 apiHandler.ServeHTTP(w, r.WithContext(ctx))
203 return
204 }
205
206 http.NotFound(w, r)
207}
208
209func xcheckf(ctx context.Context, err error, format string, args ...any) {
210 if err == nil {
211 return
212 }
213 // If caller tried saving a config that is invalid, or because of a bad request, cause a user error.
214 if errors.Is(err, mox.ErrConfig) || errors.Is(err, admin.ErrRequest) {
215 xcheckuserf(ctx, err, format, args...)
216 }
217
218 msg := fmt.Sprintf(format, args...)
219 errmsg := fmt.Sprintf("%s: %s", msg, err)
220 pkglog.WithContext(ctx).Errorx(msg, err)
221 code := "server:error"
222 if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
223 code = "user:error"
224 }
225 panic(&sherpa.Error{Code: code, Message: errmsg})
226}
227
228func xcheckuserf(ctx context.Context, err error, format string, args ...any) {
229 if err == nil {
230 return
231 }
232 msg := fmt.Sprintf(format, args...)
233 errmsg := fmt.Sprintf("%s: %s", msg, err)
234 pkglog.WithContext(ctx).Errorx(msg, err)
235 panic(&sherpa.Error{Code: "user:error", Message: errmsg})
236}
237
238func xusererrorf(ctx context.Context, format string, args ...any) {
239 msg := fmt.Sprintf(format, args...)
240 pkglog.WithContext(ctx).Error(msg)
241 panic(&sherpa.Error{Code: "user:error", Message: msg})
242}
243
244// LoginPrep returns a login token, and also sets it as cookie. Both must be
245// present in the call to Login.
246func (w Admin) LoginPrep(ctx context.Context) string {
247 log := pkglog.WithContext(ctx)
248 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
249
250 var data [8]byte
251 _, err := cryptorand.Read(data[:])
252 xcheckf(ctx, err, "generate token")
253 loginToken := base64.RawURLEncoding.EncodeToString(data[:])
254
255 webauth.LoginPrep(ctx, log, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken)
256
257 return loginToken
258}
259
260// Login returns a session token for the credentials, or fails with error code
261// "user:badLogin". Call LoginPrep to get a loginToken.
262func (w Admin) Login(ctx context.Context, loginToken, password string) store.CSRFToken {
263 log := pkglog.WithContext(ctx)
264 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
265
266 csrfToken, err := webauth.Login(ctx, log, webauth.Admin, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, loginToken, "", password)
267 if _, ok := err.(*sherpa.Error); ok {
268 panic(err)
269 }
270 xcheckf(ctx, err, "login")
271 return csrfToken
272}
273
274// Logout invalidates the session token.
275func (w Admin) Logout(ctx context.Context) {
276 log := pkglog.WithContext(ctx)
277 reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
278
279 err := webauth.Logout(ctx, log, webauth.Admin, "webadmin", w.cookiePath, w.isForwarded, reqInfo.Response, reqInfo.Request, "", reqInfo.SessionToken)
280 xcheckf(ctx, err, "logout")
281}
282
283// Version returns the version, goos and goarch.
284func (w Admin) Version(ctx context.Context) (version, goos, goarch string) {
285 return moxvar.Version, runtime.GOOS, runtime.GOARCH
286}
287
288type Result struct {
289 Errors []string
290 Warnings []string
291 Instructions []string
292}
293
294type DNSSECResult struct {
295 Result
296}
297
298type IPRevCheckResult struct {
299 Hostname dns.Domain // This hostname, IPs must resolve back to this.
300 IPNames map[string][]string // IP to names.
301 Result
302}
303
304type MX struct {
305 Host string
306 Pref int
307 IPs []string
308}
309
310type MXCheckResult struct {
311 Records []MX
312 Result
313}
314
315type TLSCheckResult struct {
316 Result
317}
318
319type DANECheckResult struct {
320 Result
321}
322
323type SPFRecord struct {
324 spf.Record
325}
326
327type SPFCheckResult struct {
328 DomainTXT string
329 DomainRecord *SPFRecord
330 HostTXT string
331 HostRecord *SPFRecord
332 Result
333}
334
335type DKIMCheckResult struct {
336 Records []DKIMRecord
337 Result
338}
339
340type DKIMRecord struct {
341 Selector string
342 TXT string
343 Record *dkim.Record
344}
345
346type DMARCRecord struct {
347 dmarc.Record
348}
349
350type DMARCCheckResult struct {
351 Domain string
352 TXT string
353 Record *DMARCRecord
354 Result
355}
356
357type TLSRPTRecord struct {
358 tlsrpt.Record
359}
360
361type TLSRPTCheckResult struct {
362 TXT string
363 Record *TLSRPTRecord
364 Result
365}
366
367type MTASTSRecord struct {
368 mtasts.Record
369}
370type MTASTSCheckResult struct {
371 TXT string
372 Record *MTASTSRecord
373 PolicyText string
374 Policy *mtasts.Policy
375 Result
376}
377
378type SRVConfCheckResult struct {
379 SRVs map[string][]net.SRV // Service (e.g. "_imaps") to records.
380 Result
381}
382
383type AutoconfCheckResult struct {
384 ClientSettingsDomainIPs []string
385 IPs []string
386 Result
387}
388
389type AutodiscoverSRV struct {
390 net.SRV
391 IPs []string
392}
393
394type AutodiscoverCheckResult struct {
395 Records []AutodiscoverSRV
396 Result
397}
398
399// CheckResult is the analysis of a domain, its actual configuration (DNS, TLS,
400// connectivity) and the mox configuration. It includes configuration instructions
401// (e.g. DNS records), and warnings and errors encountered.
402type CheckResult struct {
403 Domain string
404 DNSSEC DNSSECResult
405 IPRev IPRevCheckResult
406 MX MXCheckResult
407 TLS TLSCheckResult
408 DANE DANECheckResult
409 SPF SPFCheckResult
410 DKIM DKIMCheckResult
411 DMARC DMARCCheckResult
412 HostTLSRPT TLSRPTCheckResult
413 DomainTLSRPT TLSRPTCheckResult
414 MTASTS MTASTSCheckResult
415 SRVConf SRVConfCheckResult
416 Autoconf AutoconfCheckResult
417 Autodiscover AutodiscoverCheckResult
418}
419
420// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
421func logPanic(ctx context.Context) {
422 x := recover()
423 if x == nil {
424 return
425 }
426 pkglog.WithContext(ctx).Error("recover from panic", slog.Any("panic", x))
427 debug.PrintStack()
428 metrics.PanicInc(metrics.Webadmin)
429}
430
431// return IPs we may be listening on.
432func xlistenIPs(ctx context.Context, receiveOnly bool) []net.IP {
433 ips, err := mox.IPs(ctx, receiveOnly)
434 xcheckf(ctx, err, "listing ips")
435 return ips
436}
437
438// return IPs from which we may be sending.
439func xsendingIPs(ctx context.Context) []net.IP {
440 ips, err := mox.IPs(ctx, false)
441 xcheckf(ctx, err, "listing ips")
442 return ips
443}
444
445// CheckDomain checks the configuration for the domain, such as MX, SMTP STARTTLS,
446// SPF, DKIM, DMARC, TLSRPT, MTASTS, autoconfig, autodiscover.
447func (Admin) CheckDomain(ctx context.Context, domainName string) (r CheckResult) {
448 // todo future: should run these checks without a DNS cache so recent changes are picked up.
449
450 resolver := dns.StrictResolver{Pkg: "check", Log: pkglog.WithContext(ctx).Logger}
451 dialer := &net.Dialer{Timeout: 10 * time.Second}
452 nctx, cancel := context.WithTimeout(ctx, 30*time.Second)
453 defer cancel()
454 return checkDomain(nctx, resolver, dialer, domainName)
455}
456
457func unptr[T any](l []*T) []T {
458 if l == nil {
459 return nil
460 }
461 r := make([]T, len(l))
462 for i, e := range l {
463 r[i] = *e
464 }
465 return r
466}
467
468func checkDomain(ctx context.Context, resolver dns.Resolver, dialer *net.Dialer, domainName string) (r CheckResult) {
469 log := pkglog.WithContext(ctx)
470
471 domain, xerr := dns.ParseDomain(domainName)
472 xcheckuserf(ctx, xerr, "parsing domain")
473
474 domConf, ok := mox.Conf.Domain(domain)
475 if !ok {
476 panic(&sherpa.Error{Code: "user:notFound", Message: "domain not found"})
477 }
478
479 listenIPs := xlistenIPs(ctx, true)
480 isListenIP := func(ip net.IP) bool {
481 for _, lip := range listenIPs {
482 if ip.Equal(lip) {
483 return true
484 }
485 }
486 return false
487 }
488
489 addf := func(l *[]string, format string, args ...any) {
490 *l = append(*l, fmt.Sprintf(format, args...))
491 }
492
493 // Host must be an absolute dns name, ending with a dot.
494 lookupIPs := func(errors *[]string, host string) (ips []string, ourIPs, notOurIPs []net.IP, rerr error) {
495 addrs, _, err := resolver.LookupHost(ctx, host)
496 if err != nil {
497 addf(errors, "Looking up %q: %s", host, err)
498 return nil, nil, nil, err
499 }
500 for _, addr := range addrs {
501 ip := net.ParseIP(addr)
502 if ip == nil {
503 addf(errors, "Bad IP %q", addr)
504 continue
505 }
506 ips = append(ips, ip.String())
507 if isListenIP(ip) {
508 ourIPs = append(ourIPs, ip)
509 } else {
510 notOurIPs = append(notOurIPs, ip)
511 }
512 }
513 return ips, ourIPs, notOurIPs, nil
514 }
515
516 checkTLS := func(errors *[]string, host string, ips []string, port string) {
517 d := tls.Dialer{
518 NetDialer: dialer,
519 Config: &tls.Config{
520 ServerName: host,
521 MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
522 RootCAs: mox.Conf.Static.TLS.CertPool,
523 },
524 }
525 for _, ip := range ips {
526 conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(ip, port))
527 if err != nil {
528 addf(errors, "TLS connection to hostname %q, IP %q: %s", host, ip, err)
529 } else {
530 err := conn.Close()
531 log.Check(err, "closing tcp connection")
532 }
533 }
534 }
535
536 // If at least one listener with SMTP enabled has unspecified NATed IPs, we'll skip
537 // some checks related to these IPs.
538 var isNAT, isUnspecifiedNAT bool
539 for _, l := range mox.Conf.Static.Listeners {
540 if !l.SMTP.Enabled {
541 continue
542 }
543 if l.IPsNATed {
544 isUnspecifiedNAT = true
545 isNAT = true
546 }
547 if len(l.NATIPs) > 0 {
548 isNAT = true
549 }
550 }
551
552 var wg sync.WaitGroup
553
554 // DNSSEC
555 wg.Add(1)
556 go func() {
557 defer logPanic(ctx)
558 defer wg.Done()
559
560 // Some DNSSEC-verifying resolvers return unauthentic data for ".", so we check "com".
561 _, result, err := resolver.LookupNS(ctx, "com.")
562 if err != nil {
563 addf(&r.DNSSEC.Errors, "Looking up NS for DNS root (.) to check support in resolver for DNSSEC-verification: %s", err)
564 } else if !result.Authentic {
565 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 uses unprotected MX records, and SMTP STARTTLS connections cannot verify the TLS certificate with DANE (based on public keys in DNS), and will fall back to either MTA-STS for verification, or use "opportunistic TLS" with no certificate verification.`)
566 } else {
567 _, result, _ := resolver.LookupMX(ctx, domain.ASCII+".")
568 if !result.Authentic {
569 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.`)
570 }
571 }
572
573 addf(&r.DNSSEC.Instructions, `Enable DNSSEC-signing of the DNS records of your domain (zone) at your DNS hosting provider.`)
574
575 addf(&r.DNSSEC.Instructions, `If your DNS records are already DNSSEC-signed, you may not have a DNSSEC-verifying recursive resolver configured. Install unbound, ensure it has DNSSEC root keys (see unbound-anchor), and enable support for "extended dns errors" (EDE, available since unbound v1.16.0). Test with "dig com. ns" and look for "ad" (authentic data) in response "flags".
576
577cat <<EOF >/etc/unbound/unbound.conf.d/ede.conf
578server:
579 ede: yes
580 val-log-level: 2
581EOF
582`)
583 }()
584
585 // IPRev
586 wg.Add(1)
587 go func() {
588 defer logPanic(ctx)
589 defer wg.Done()
590
591 // For each mox.Conf.SpecifiedSMTPListenIPs and all NATIPs, and each IP for
592 // mox.Conf.HostnameDomain, check if they resolve back to the host name.
593 hostIPs := map[dns.Domain][]net.IP{}
594 ips, _, err := resolver.LookupIP(ctx, "ip", mox.Conf.Static.HostnameDomain.ASCII+".")
595 if err != nil {
596 addf(&r.IPRev.Errors, "Looking up IPs for hostname: %s", err)
597 }
598
599 gatherMoreIPs := func(publicIPs []net.IP) {
600 nextip:
601 for _, ip := range publicIPs {
602 for _, xip := range ips {
603 if ip.Equal(xip) {
604 continue nextip
605 }
606 }
607 ips = append(ips, ip)
608 }
609 }
610 if !isNAT {
611 gatherMoreIPs(mox.Conf.Static.SpecifiedSMTPListenIPs)
612 }
613 for _, l := range mox.Conf.Static.Listeners {
614 if !l.SMTP.Enabled {
615 continue
616 }
617 var natips []net.IP
618 for _, ip := range l.NATIPs {
619 natips = append(natips, net.ParseIP(ip))
620 }
621 gatherMoreIPs(natips)
622 }
623 hostIPs[mox.Conf.Static.HostnameDomain] = ips
624
625 iplist := func(ips []net.IP) string {
626 var ipstrs []string
627 for _, ip := range ips {
628 ipstrs = append(ipstrs, ip.String())
629 }
630 return strings.Join(ipstrs, ", ")
631 }
632
633 r.IPRev.Hostname = mox.Conf.Static.HostnameDomain
634 r.IPRev.Instructions = []string{
635 fmt.Sprintf("Ensure IPs %s have reverse address %s.", iplist(ips), mox.Conf.Static.HostnameDomain.ASCII),
636 }
637
638 // If we have a socks transport, also check its host and IP.
639 for tname, t := range mox.Conf.Static.Transports {
640 if t.Socks != nil {
641 hostIPs[t.Socks.Hostname] = append(hostIPs[t.Socks.Hostname], t.Socks.IPs...)
642 instr := fmt.Sprintf("For SOCKS transport %s, ensure IPs %s have reverse address %s.", tname, iplist(t.Socks.IPs), t.Socks.Hostname)
643 r.IPRev.Instructions = append(r.IPRev.Instructions, instr)
644 }
645 }
646
647 type result struct {
648 Host dns.Domain
649 IP string
650 Addrs []string
651 Err error
652 }
653 results := make(chan result)
654 n := 0
655 for host, ips := range hostIPs {
656 for _, ip := range ips {
657 n++
658 s := ip.String()
659 host := host
660 go func() {
661 addrs, _, err := resolver.LookupAddr(ctx, s)
662 results <- result{host, s, addrs, err}
663 }()
664 }
665 }
666 r.IPRev.IPNames = map[string][]string{}
667 for range n {
668 lr := <-results
669 host, addrs, ip, err := lr.Host, lr.Addrs, lr.IP, lr.Err
670 if err != nil {
671 addf(&r.IPRev.Errors, "Looking up reverse name for %s of %s: %v", ip, host, err)
672 continue
673 }
674 var match bool
675 for i, a := range addrs {
676 a = strings.TrimRight(a, ".")
677 addrs[i] = a
678 ad, err := dns.ParseDomain(a)
679 if err != nil {
680 addf(&r.IPRev.Errors, "Parsing reverse name %q for %s: %v", a, ip, err)
681 }
682 if ad == host {
683 match = true
684 }
685 }
686 if !match && !isNAT && host == mox.Conf.Static.HostnameDomain {
687 addf(&r.IPRev.Warnings, "IP %s with name(s) %s is forward confirmed, but does not match hostname %s.", ip, strings.Join(addrs, ","), host)
688 }
689 r.IPRev.IPNames[ip] = addrs
690 }
691
692 // Linux machines are often initially set up with a loopback IP for the hostname in
693 // /etc/hosts, presumably because it isn't known if their external IPs are static.
694 // For mail servers, they should certainly be static. The quickstart would also
695 // have warned about this, but could have been missed/ignored.
696 for _, ip := range ips {
697 if ip.IsLoopback() {
698 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())
699 }
700 }
701 }()
702
703 // MX
704 wg.Add(1)
705 go func() {
706 defer logPanic(ctx)
707 defer wg.Done()
708
709 mxs, _, err := resolver.LookupMX(ctx, domain.ASCII+".")
710 if err != nil {
711 addf(&r.MX.Errors, "Looking up MX records for %s: %s", domain, err)
712 }
713 r.MX.Records = make([]MX, len(mxs))
714 for i, mx := range mxs {
715 r.MX.Records[i] = MX{mx.Host, int(mx.Pref), nil}
716 }
717 if len(mxs) == 1 && mxs[0].Host == "." {
718 addf(&r.MX.Errors, `MX records consists of explicit null mx record (".") indicating that domain does not accept email.`)
719 return
720 }
721 for i, mx := range mxs {
722 ips, ourIPs, notOurIPs, err := lookupIPs(&r.MX.Errors, mx.Host)
723 if err != nil {
724 addf(&r.MX.Errors, "Looking up IPs for mx host %q: %s", mx.Host, err)
725 }
726 r.MX.Records[i].IPs = ips
727 if isUnspecifiedNAT {
728 continue
729 }
730 if len(ourIPs) == 0 {
731 addf(&r.MX.Errors, "None of the IPs that mx %q points to is ours: %v", mx.Host, notOurIPs)
732 } else if len(notOurIPs) > 0 {
733 addf(&r.MX.Errors, "Some of the IPs that mx %q points to are not ours: %v", mx.Host, notOurIPs)
734 }
735
736 }
737 r.MX.Instructions = []string{
738 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+"."),
739 }
740 }()
741
742 // TLS, mostly checking certificate expiration and CA trust.
743 // 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.
744 wg.Add(1)
745 go func() {
746 defer logPanic(ctx)
747 defer wg.Done()
748
749 // MTA-STS, autoconfig, autodiscover are checked in their sections.
750
751 // Dial a single MX host with given IP and perform STARTTLS handshake.
752 dialSMTPSTARTTLS := func(host, ip string) error {
753 conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(ip, "25"))
754 if err != nil {
755 return err
756 }
757 defer func() {
758 if conn != nil {
759 err := conn.Close()
760 log.Check(err, "closing tcp connection")
761 }
762 }()
763
764 end := time.Now().Add(10 * time.Second)
765 cctx, cancel := context.WithTimeout(ctx, 10*time.Second)
766 defer cancel()
767 err = conn.SetDeadline(end)
768 log.WithContext(ctx).Check(err, "setting deadline")
769
770 br := bufio.NewReader(conn)
771 _, err = br.ReadString('\n')
772 if err != nil {
773 return fmt.Errorf("reading SMTP banner from remote: %s", err)
774 }
775 if _, err := fmt.Fprintf(conn, "EHLO moxtest\r\n"); err != nil {
776 return fmt.Errorf("writing SMTP EHLO to remote: %s", err)
777 }
778 for {
779 line, err := br.ReadString('\n')
780 if err != nil {
781 return fmt.Errorf("reading SMTP EHLO response from remote: %s", err)
782 }
783 if strings.HasPrefix(line, "250-") {
784 continue
785 }
786 if strings.HasPrefix(line, "250 ") {
787 break
788 }
789 return fmt.Errorf("unexpected response to SMTP EHLO from remote: %q", strings.TrimSuffix(line, "\r\n"))
790 }
791 if _, err := fmt.Fprintf(conn, "STARTTLS\r\n"); err != nil {
792 return fmt.Errorf("writing SMTP STARTTLS to remote: %s", err)
793 }
794 line, err := br.ReadString('\n')
795 if err != nil {
796 return fmt.Errorf("reading response to SMTP STARTTLS from remote: %s", err)
797 }
798 if !strings.HasPrefix(line, "220 ") {
799 return fmt.Errorf("SMTP STARTTLS response from remote not 220 OK: %q", strings.TrimSuffix(line, "\r\n"))
800 }
801 config := &tls.Config{
802 ServerName: host,
803 RootCAs: mox.Conf.Static.TLS.CertPool,
804 }
805 tlsconn := tls.Client(conn, config)
806 if err := tlsconn.HandshakeContext(cctx); err != nil {
807 return fmt.Errorf("TLS handshake after SMTP STARTTLS: %s", err)
808 }
809 cancel()
810 err = conn.Close()
811 log.Check(err, "closing smtp connection")
812 conn = nil
813 return nil
814 }
815
816 checkSMTPSTARTTLS := func() {
817 // Initial errors are ignored, will already have been warned about by MX checks.
818 mxs, _, err := resolver.LookupMX(ctx, domain.ASCII+".")
819 if err != nil {
820 return
821 }
822 if len(mxs) == 1 && mxs[0].Host == "." {
823 return
824 }
825 for _, mx := range mxs {
826 ips, _, _, err := lookupIPs(&r.MX.Errors, mx.Host)
827 if err != nil {
828 continue
829 }
830
831 for _, ip := range ips {
832 if err := dialSMTPSTARTTLS(mx.Host, ip); err != nil {
833 addf(&r.TLS.Errors, "SMTP connection with STARTTLS to MX hostname %q IP %s: %s", mx.Host, ip, err)
834 }
835 }
836 }
837 }
838
839 checkSMTPSTARTTLS()
840
841 }()
842
843 // DANE
844 wg.Add(1)
845 go func() {
846 defer logPanic(ctx)
847 defer wg.Done()
848
849 daneRecords := func(l config.Listener) map[string]struct{} {
850 if l.TLS == nil {
851 return nil
852 }
853 records := map[string]struct{}{}
854 addRecord := func(privKey crypto.Signer) {
855 spkiBuf, err := x509.MarshalPKIXPublicKey(privKey.Public())
856 if err != nil {
857 addf(&r.DANE.Errors, "marshal SubjectPublicKeyInfo for DANE record: %v", err)
858 return
859 }
860 sum := sha256.Sum256(spkiBuf)
861 r := adns.TLSA{
862 Usage: adns.TLSAUsageDANEEE,
863 Selector: adns.TLSASelectorSPKI,
864 MatchType: adns.TLSAMatchTypeSHA256,
865 CertAssoc: sum[:],
866 }
867 records[r.Record()] = struct{}{}
868 }
869 for _, privKey := range l.TLS.HostPrivateRSA2048Keys {
870 addRecord(privKey)
871 }
872 for _, privKey := range l.TLS.HostPrivateECDSAP256Keys {
873 addRecord(privKey)
874 }
875 return records
876 }
877
878 expectedDANERecords := func(host string) map[string]struct{} {
879 for _, l := range mox.Conf.Static.Listeners {
880 if l.HostnameDomain.ASCII == host {
881 return daneRecords(l)
882 }
883 }
884 public := mox.Conf.Static.Listeners["public"]
885 if mox.Conf.Static.HostnameDomain.ASCII == host && public.HostnameDomain.ASCII == "" {
886 return daneRecords(public)
887 }
888 return nil
889 }
890
891 mxl, result, err := resolver.LookupMX(ctx, domain.ASCII+".")
892 if err != nil {
893 addf(&r.DANE.Errors, "Looking up MX hosts to check for DANE records: %s", err)
894 } else {
895 if !result.Authentic {
896 addf(&r.DANE.Warnings, "DANE is inactive because MX records are not DNSSEC-signed.")
897 }
898 for _, mx := range mxl {
899 expect := expectedDANERecords(mx.Host)
900
901 tlsal, tlsaResult, err := resolver.LookupTLSA(ctx, 25, "tcp", mx.Host+".")
902 if dns.IsNotFound(err) {
903 if len(expect) > 0 {
904 addf(&r.DANE.Errors, "No DANE records for MX host %s, expected: %s.", mx.Host, strings.Join(slices.Collect(maps.Keys(expect)), "; "))
905 }
906 continue
907 } else if err != nil {
908 addf(&r.DANE.Errors, "Looking up DANE records for MX host %s: %v", mx.Host, err)
909 continue
910 } else if !tlsaResult.Authentic && len(tlsal) > 0 {
911 addf(&r.DANE.Errors, "DANE records exist for MX host %s, but are not DNSSEC-signed.", mx.Host)
912 }
913
914 extra := map[string]struct{}{}
915 for _, e := range tlsal {
916 s := e.Record()
917 if _, ok := expect[s]; ok {
918 delete(expect, s)
919 } else {
920 extra[s] = struct{}{}
921 }
922 }
923 if len(expect) > 0 {
924 l := slices.Sorted(maps.Keys(expect))
925 addf(&r.DANE.Errors, "Missing DANE records of type TLSA for MX host _25._tcp.%s: %s", mx.Host, strings.Join(l, "; "))
926 }
927 if len(extra) > 0 {
928 l := slices.Sorted(maps.Keys(extra))
929 addf(&r.DANE.Errors, "Unexpected DANE records of type TLSA for MX host _25._tcp.%s: %s", mx.Host, strings.Join(l, "; "))
930 }
931 }
932 }
933
934 public := mox.Conf.Static.Listeners["public"]
935 pubDom := public.HostnameDomain
936 if pubDom.ASCII == "" {
937 pubDom = mox.Conf.Static.HostnameDomain
938 }
939 records := slices.Sorted(maps.Keys(daneRecords(public)))
940 if len(records) > 0 {
941 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"
942 for _, r := range records {
943 instr += fmt.Sprintf("\t_25._tcp.%s. TLSA %s\n", pubDom.ASCII, r)
944 }
945 addf(&r.DANE.Instructions, instr)
946 } else {
947 addf(&r.DANE.Warnings, "DANE not configured: no static TLS host keys.")
948
949 instr := "Add static TLS keys for use with DANE to mox.conf under: Listeners, public, TLS, HostPrivateKeyFiles.\n\nIf automatic TLS certificate management with ACME is configured, run \"mox config ensureacmehostprivatekeys\" to generate static TLS keys and to print a snippet for \"HostPrivateKeyFiles\" for inclusion in mox.conf.\n\nIf TLS keys and certificates are managed externally, configure the TLS keys manually under \"HostPrivateKeyFiles\" in mox.conf, and make sure new TLS keys are not generated for each new certificate (look for an option to \"reuse private keys\" when doing ACME). Important: Before using new TLS keys, corresponding new DANE (TLSA) DNS records must be published (taking TTL into account to let the previous records expire). Using new TLS keys without updating DANE (TLSA) DNS records will cause DANE verification failures, breaking incoming deliveries.\n\nWith \"HostPrivateKeyFiles\" configured, DNS records for DANE based on those TLS keys will be suggested, and future DNS checks will look for those DNS records. Once those DNS records are published, DANE is active for all domains with an MX record pointing to the host."
950 addf(&r.DANE.Instructions, instr)
951 }
952 }()
953
954 // SPF
955 // 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.
956 wg.Add(1)
957 go func() {
958 defer logPanic(ctx)
959 defer wg.Done()
960
961 ips := mox.DomainSPFIPs()
962
963 // Verify a domain with the configured IPs that do SMTP.
964 verifySPF := func(isHost bool, domain dns.Domain) (string, *SPFRecord, spf.Record) {
965 kind := "domain"
966 if isHost {
967 kind = "host"
968 }
969
970 _, txt, record, _, err := spf.Lookup(ctx, log.Logger, resolver, domain)
971 if err != nil {
972 addf(&r.SPF.Errors, "Looking up %s SPF record: %s", kind, err)
973 }
974 var xrecord *SPFRecord
975 if record != nil {
976 xrecord = &SPFRecord{*record}
977 }
978
979 spfr := spf.Record{
980 Version: "spf1",
981 }
982
983 checkSPFIP := func(ip net.IP) {
984 mechanism := "ip4"
985 if ip.To4() == nil {
986 mechanism = "ip6"
987 }
988 spfr.Directives = append(spfr.Directives, spf.Directive{Mechanism: mechanism, IP: ip})
989
990 if record == nil {
991 return
992 }
993
994 args := spf.Args{
995 RemoteIP: ip,
996 MailFromLocalpart: "postmaster",
997 MailFromDomain: domain,
998 HelloDomain: dns.IPDomain{Domain: domain},
999 LocalIP: net.ParseIP("127.0.0.1"),
1000 LocalHostname: dns.Domain{ASCII: "localhost"},
1001 }
1002 status, mechanism, expl, _, err := spf.Evaluate(ctx, log.Logger, record, resolver, args)
1003 if err != nil {
1004 addf(&r.SPF.Errors, "Evaluating IP %q against %s SPF record: %s", ip, kind, err)
1005 } else if status != spf.StatusPass {
1006 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)
1007 }
1008 }
1009
1010 for _, ip := range ips {
1011 checkSPFIP(ip)
1012 }
1013 if !isHost {
1014 spfr.Directives = append(spfr.Directives, spf.Directive{Mechanism: "mx"})
1015 }
1016
1017 qual := "~"
1018 if isHost {
1019 qual = "-"
1020 }
1021 spfr.Directives = append(spfr.Directives, spf.Directive{Qualifier: qual, Mechanism: "all"})
1022 return txt, xrecord, spfr
1023 }
1024
1025 // Check SPF record for domain.
1026 var dspfr spf.Record
1027 r.SPF.DomainTXT, r.SPF.DomainRecord, dspfr = verifySPF(false, domain)
1028 // todo: possibly check all hosts for MX records? assuming they are also sending mail servers.
1029 r.SPF.HostTXT, r.SPF.HostRecord, _ = verifySPF(true, mox.Conf.Static.HostnameDomain)
1030
1031 if len(ips) == 0 {
1032 addf(&r.SPF.Warnings, `No explicitly configured IPs found to check SPF policy against. Consider configuring public IPs instead of unspecified addresses (0.0.0.0 and/or ::) in the "public" listener in mox.conf, or NATIPs in case of NAT.`)
1033 }
1034
1035 dtxt, err := dspfr.Record()
1036 if err != nil {
1037 addf(&r.SPF.Errors, "Making SPF record for instructions: %s", err)
1038 }
1039 domainspf := fmt.Sprintf("%s TXT %s", domain.ASCII+".", mox.TXTStrings(dtxt))
1040
1041 // Check SPF record for sending host. ../rfc/7208:2263 ../rfc/7208:2287
1042 hostspf := fmt.Sprintf(`%s TXT "v=spf1 a -all"`, mox.Conf.Static.HostnameDomain.ASCII+".")
1043
1044 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)
1045 }()
1046
1047 // DKIM
1048 // 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.
1049 wg.Add(1)
1050 go func() {
1051 defer logPanic(ctx)
1052 defer wg.Done()
1053
1054 var missing []string
1055 for sel, selc := range domConf.DKIM.Selectors {
1056 _, record, txt, _, err := dkim.Lookup(ctx, log.Logger, resolver, selc.Domain, domain)
1057 if err != nil {
1058 missing = append(missing, sel)
1059 if errors.Is(err, dkim.ErrNoRecord) {
1060 addf(&r.DKIM.Errors, "No DKIM DNS record for selector %q.", sel)
1061 } else if errors.Is(err, dkim.ErrSyntax) {
1062 addf(&r.DKIM.Errors, "Parsing DKIM DNS record for selector %q: %s", sel, err)
1063 } else {
1064 addf(&r.DKIM.Errors, "Fetching DKIM record for selector %q: %s", sel, err)
1065 }
1066 }
1067 if txt != "" {
1068 r.DKIM.Records = append(r.DKIM.Records, DKIMRecord{sel, txt, record})
1069 pubKey := selc.Key.Public()
1070 var pk []byte
1071 switch k := pubKey.(type) {
1072 case *rsa.PublicKey:
1073 var err error
1074 pk, err = x509.MarshalPKIXPublicKey(k)
1075 if err != nil {
1076 addf(&r.DKIM.Errors, "Marshal public key for %q to compare against DNS: %s", sel, err)
1077 continue
1078 }
1079 case ed25519.PublicKey:
1080 pk = []byte(k)
1081 default:
1082 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", pubKey)
1083 continue
1084 }
1085
1086 if record != nil && !bytes.Equal(record.Pubkey, pk) {
1087 addf(&r.DKIM.Errors, "For selector %q, the public key in DKIM DNS TXT record does not match with configured private key.", sel)
1088 missing = append(missing, sel)
1089 }
1090 }
1091 }
1092 if len(domConf.DKIM.Selectors) == 0 {
1093 addf(&r.DKIM.Errors, "No DKIM configuration, add a key to the configuration file, and instructions for DNS records will appear here.")
1094 }
1095 instr := ""
1096 for _, sel := range missing {
1097 dkimr := dkim.Record{
1098 Version: "DKIM1",
1099 Hashes: []string{"sha256"},
1100 PublicKey: domConf.DKIM.Selectors[sel].Key.Public(),
1101 }
1102 switch dkimr.PublicKey.(type) {
1103 case *rsa.PublicKey:
1104 case ed25519.PublicKey:
1105 dkimr.Key = "ed25519"
1106 default:
1107 addf(&r.DKIM.Errors, "Internal error: unknown public key type %T.", dkimr.PublicKey)
1108 }
1109 txt, err := dkimr.Record()
1110 if err != nil {
1111 addf(&r.DKIM.Errors, "Making DKIM record for instructions: %s", err)
1112 continue
1113 }
1114 instr += fmt.Sprintf("\n\t%s._domainkey.%s TXT %s\n", sel, domain.ASCII+".", mox.TXTStrings(txt))
1115 }
1116 if instr != "" {
1117 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
1118 addf(&r.DKIM.Instructions, "%s", instr)
1119 }
1120 }()
1121
1122 // DMARC
1123 wg.Add(1)
1124 go func() {
1125 defer logPanic(ctx)
1126 defer wg.Done()
1127
1128 _, dmarcDomain, record, txt, _, err := dmarc.Lookup(ctx, log.Logger, resolver, domain)
1129 if err != nil {
1130 addf(&r.DMARC.Errors, "Looking up DMARC record: %s", err)
1131 } else if record == nil {
1132 addf(&r.DMARC.Errors, "No DMARC record")
1133 }
1134 r.DMARC.Domain = dmarcDomain.Name()
1135 r.DMARC.TXT = txt
1136 if record != nil {
1137 r.DMARC.Record = &DMARCRecord{*record}
1138 }
1139 if record != nil && record.Policy == "none" {
1140 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.")
1141 }
1142 if record != nil && record.SubdomainPolicy == "none" {
1143 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.")
1144 }
1145 if record != nil && len(record.AggregateReportAddresses) == 0 {
1146 addf(&r.DMARC.Warnings, "It is recommended you specify you would like aggregate reports about delivery success in the DMARC record, see instructions.")
1147 }
1148
1149 dmarcr := dmarc.DefaultRecord
1150 dmarcr.Policy = "reject"
1151
1152 var extInstr string
1153 if domConf.DMARC != nil {
1154 // If the domain is in a different Organizational Domain, the receiving domain
1155 // needs a special DNS record to opt-in to receiving reports. We check for that
1156 // record.
1157 // ../rfc/7489:1541
1158 orgDom := publicsuffix.Lookup(ctx, log.Logger, domain)
1159 destOrgDom := publicsuffix.Lookup(ctx, log.Logger, domConf.DMARC.DNSDomain)
1160 if orgDom != destOrgDom {
1161 accepts, status, _, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, log.Logger, resolver, domain, domConf.DMARC.DNSDomain)
1162 if status != dmarc.StatusNone {
1163 addf(&r.DMARC.Errors, "Checking if external destination accepts reports: %s", err)
1164 } else if !accepts {
1165 addf(&r.DMARC.Errors, "External destination does not accept reports (%s)", err)
1166 }
1167 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)
1168 }
1169
1170 uri := url.URL{
1171 Scheme: "mailto",
1172 Opaque: smtp.NewAddress(domConf.DMARC.ParsedLocalpart, domConf.DMARC.DNSDomain).Pack(false),
1173 }
1174 uristr := uri.String()
1175 dmarcr.AggregateReportAddresses = []dmarc.URI{
1176 {Address: uristr, MaxSize: 10, Unit: "m"},
1177 }
1178
1179 if record != nil {
1180 found := false
1181 for _, addr := range record.AggregateReportAddresses {
1182 if addr.Address == uristr {
1183 found = true
1184 break
1185 }
1186 }
1187 if !found {
1188 addf(&r.DMARC.Errors, "Configured DMARC reporting address is not present in record.")
1189 }
1190 }
1191 } else {
1192 addf(&r.DMARC.Instructions, `Configure a DMARC destination in domain in config file.`)
1193 }
1194 instr := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_dmarc.%s 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.", domain.ASCII+".", mox.TXTStrings(dmarcr.String()))
1195 addf(&r.DMARC.Instructions, instr)
1196 if extInstr != "" {
1197 addf(&r.DMARC.Instructions, extInstr)
1198 }
1199 }()
1200
1201 checkTLSRPT := func(result *TLSRPTCheckResult, dom dns.Domain, address smtp.Address, isHost bool) {
1202 defer logPanic(ctx)
1203 defer wg.Done()
1204
1205 record, txt, err := tlsrpt.Lookup(ctx, log.Logger, resolver, dom)
1206 if err != nil {
1207 addf(&result.Errors, "Looking up TLSRPT record for domain %s: %s", dom, err)
1208 }
1209 result.TXT = txt
1210 if record != nil {
1211 result.Record = &TLSRPTRecord{*record}
1212 }
1213
1214 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.`
1215 var zeroaddr smtp.Address
1216 if address != zeroaddr {
1217 // TLSRPT does not require validation of reporting addresses outside the domain.
1218 // ../rfc/8460:1463
1219 uri := url.URL{
1220 Scheme: "mailto",
1221 Opaque: address.Pack(false),
1222 }
1223 rua := tlsrpt.RUA(uri.String())
1224 tlsrptr := &tlsrpt.Record{
1225 Version: "TLSRPTv1",
1226 RUAs: [][]tlsrpt.RUA{{rua}},
1227 }
1228 instr += fmt.Sprintf(`
1229
1230Ensure a DNS TXT record like the following exists:
1231
1232 _smtp._tls.%s TXT %s
1233
1234`, dom.ASCII+".", mox.TXTStrings(tlsrptr.String()))
1235
1236 if err == nil {
1237 found := false
1238 RUA:
1239 for _, l := range record.RUAs {
1240 for _, e := range l {
1241 if e == rua {
1242 found = true
1243 break RUA
1244 }
1245 }
1246 }
1247 if !found {
1248 addf(&result.Errors, `Configured reporting address is not present in TLSRPT record.`)
1249 }
1250 }
1251
1252 } else if isHost {
1253 instr += fmt.Sprintf(`
1254
1255Ensure the following snippet is present in mox.conf (ensure tabs are used for indenting, not spaces):
1256
1257HostTLSRPT:
1258 Account: %s
1259 Mailbox: TLSRPT
1260 Localpart: tlsrpt
1261
1262`, mox.Conf.Static.Postmaster.Account)
1263 addf(&result.Errors, `Configure a HostTLSRPT section in the static mox.conf config file, restart mox and check again for instructions for the TLSRPT DNS record.`)
1264 } else {
1265 addf(&result.Errors, `Configure a TLSRPT destination for the domain (through the admin web interface or by editing the domains.conf config file, adding a TLSRPT section) and check again for instructions for the TLSRPT DNS record.`)
1266 }
1267 addf(&result.Instructions, instr)
1268 }
1269
1270 // Host TLSRPT
1271 wg.Add(1)
1272 var hostTLSRPTAddr smtp.Address
1273 if mox.Conf.Static.HostTLSRPT.Localpart != "" {
1274 hostTLSRPTAddr = smtp.NewAddress(mox.Conf.Static.HostTLSRPT.ParsedLocalpart, mox.Conf.Static.HostnameDomain)
1275 }
1276 go checkTLSRPT(&r.HostTLSRPT, mox.Conf.Static.HostnameDomain, hostTLSRPTAddr, true)
1277
1278 // Domain TLSRPT
1279 wg.Add(1)
1280 var domainTLSRPTAddr smtp.Address
1281 if domConf.TLSRPT != nil {
1282 domainTLSRPTAddr = smtp.NewAddress(domConf.TLSRPT.ParsedLocalpart, domain)
1283 }
1284 go checkTLSRPT(&r.DomainTLSRPT, domain, domainTLSRPTAddr, false)
1285
1286 // MTA-STS
1287 wg.Add(1)
1288 go func() {
1289 defer logPanic(ctx)
1290 defer wg.Done()
1291
1292 // The admin has explicitly disabled mta-sts, keep warning about it.
1293 if domConf.MTASTS == nil {
1294 addf(&r.MTASTS.Warnings, "MTA-STS is not configured for this domain.")
1295 }
1296
1297 record, txt, err := mtasts.LookupRecord(ctx, log.Logger, resolver, domain)
1298 if err != nil && !(domConf.MTASTS == nil && errors.Is(err, mtasts.ErrNoRecord)) {
1299 addf(&r.MTASTS.Errors, "Looking up MTA-STS record: %s", err)
1300 }
1301 r.MTASTS.TXT = txt
1302 if record != nil {
1303 r.MTASTS.Record = &MTASTSRecord{*record}
1304 }
1305
1306 policy, text, err := mtasts.FetchPolicy(ctx, log.Logger, domain)
1307 if err != nil {
1308 if !(domConf.MTASTS == nil && errors.Is(err, mtasts.ErrNoPolicy)) {
1309 addf(&r.MTASTS.Errors, "Fetching MTA-STS policy: %s", err)
1310 }
1311 } else if policy.Mode == mtasts.ModeNone {
1312 addf(&r.MTASTS.Warnings, "MTA-STS policy is present, but does not require TLS.")
1313 } else if policy.Mode == mtasts.ModeTesting {
1314 addf(&r.MTASTS.Warnings, "MTA-STS policy is in testing mode, do not forget to change to mode enforce after testing period.")
1315 }
1316 r.MTASTS.PolicyText = text
1317 r.MTASTS.Policy = policy
1318 if policy != nil && policy.Mode != mtasts.ModeNone {
1319 if !policy.Matches(mox.Conf.Static.HostnameDomain) {
1320 addf(&r.MTASTS.Warnings, "Configured hostname is missing from policy MX list.")
1321 }
1322 if policy.MaxAgeSeconds <= 24*3600 {
1323 addf(&r.MTASTS.Warnings, "Policy has a MaxAge of less than 1 day. For stable configurations, the recommended period is in weeks.")
1324 }
1325
1326 mxl, _, _ := resolver.LookupMX(ctx, domain.ASCII+".")
1327 // We do not check for errors, the MX check will complain about mx errors, we assume we will get the same error here.
1328 mxs := map[dns.Domain]struct{}{}
1329 for _, mx := range mxl {
1330 d, err := dns.ParseDomain(strings.TrimSuffix(mx.Host, "."))
1331 if err != nil {
1332 addf(&r.MTASTS.Warnings, "MX record %q is invalid: %s", mx.Host, err)
1333 continue
1334 }
1335 mxs[d] = struct{}{}
1336 }
1337 for mx := range mxs {
1338 if !policy.Matches(mx) {
1339 addf(&r.MTASTS.Warnings, "MX record %q does not match MTA-STS policy MX list.", mx)
1340 }
1341 }
1342 for _, mx := range policy.MX {
1343 if mx.Wildcard {
1344 continue
1345 }
1346 if _, ok := mxs[mx.Domain]; !ok {
1347 addf(&r.MTASTS.Warnings, "MX %q in MTA-STS policy is not in MX record.", mx)
1348 }
1349 }
1350 }
1351
1352 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.
1353
1354After 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.
1355
1356You 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.
1357
1358You 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.
1359
1360The _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.
1361
1362When enabling MTA-STS, or updating a policy, always update the policy first (through a configuration change and reload/restart), and the DNS record second.
1363`
1364 addf(&r.MTASTS.Instructions, intro)
1365
1366 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.`)
1367
1368 host := fmt.Sprintf("Ensure DNS CNAME/A/AAAA records exist that resolves mta-sts.%s to this mail server. For example:\n\n\tmta-sts.%s CNAME %s\n\n", domain.ASCII, domain.ASCII+".", mox.Conf.Static.HostnameDomain.ASCII+".")
1369 addf(&r.MTASTS.Instructions, host)
1370
1371 mtastsr := mtasts.Record{
1372 Version: "STSv1",
1373 ID: time.Now().Format("20060102T150405"),
1374 }
1375 dns := fmt.Sprintf("Ensure a DNS TXT record like the following exists:\n\n\t_mta-sts.%s 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.", domain.ASCII+".", mox.TXTStrings(mtastsr.String()), domain.Name())
1376 addf(&r.MTASTS.Instructions, dns)
1377 }()
1378
1379 // SRVConf
1380 wg.Add(1)
1381 go func() {
1382 defer logPanic(ctx)
1383 defer wg.Done()
1384
1385 type srvReq struct {
1386 name string
1387 port uint16
1388 host string
1389 srvs []*net.SRV
1390 err error
1391 }
1392
1393 // We'll assume if any submissions is configured, it is public. Same for imap. And
1394 // if not, that there is a plain option.
1395 var submissions, imaps bool
1396 for _, l := range mox.Conf.Static.Listeners {
1397 if l.TLS != nil && l.Submissions.Enabled {
1398 submissions = true
1399 }
1400 if l.TLS != nil && l.IMAPS.Enabled {
1401 imaps = true
1402 }
1403 }
1404 srvhost := func(ok bool) string {
1405 if ok {
1406 return mox.Conf.Static.HostnameDomain.ASCII + "."
1407 }
1408 return "."
1409 }
1410 var reqs = []srvReq{
1411 {name: "_submissions", port: 465, host: srvhost(submissions)},
1412 {name: "_submission", port: 587, host: srvhost(!submissions)},
1413 {name: "_imaps", port: 993, host: srvhost(imaps)},
1414 {name: "_imap", port: 143, host: srvhost(!imaps)},
1415 {name: "_pop3", port: 110, host: "."},
1416 {name: "_pop3s", port: 995, host: "."},
1417 }
1418 // Host "." indicates the service is not available. We suggested in the DNS records
1419 // that the port be set to 0, so check for that. ../rfc/6186:242
1420 for i := range reqs {
1421 if reqs[i].host == "." {
1422 reqs[i].port = 0
1423 }
1424 }
1425 var srvwg sync.WaitGroup
1426 srvwg.Add(len(reqs))
1427 for i := range reqs {
1428 go func(i int) {
1429 defer srvwg.Done()
1430 _, reqs[i].srvs, _, reqs[i].err = resolver.LookupSRV(ctx, reqs[i].name[1:], "tcp", domain.ASCII+".")
1431 }(i)
1432 }
1433 srvwg.Wait()
1434
1435 instr := "Ensure DNS records like the following exist:\n\n"
1436 r.SRVConf.SRVs = map[string][]net.SRV{}
1437 for _, req := range reqs {
1438 name := req.name + "._tcp." + domain.ASCII
1439 weight := 1
1440 if req.host == "." {
1441 weight = 0
1442 }
1443 instr += fmt.Sprintf("\t%s._tcp.%-*s SRV 0 %d %d %s\n", req.name, len("_submissions")-len(req.name)+len(domain.ASCII+"."), domain.ASCII+".", weight, req.port, req.host)
1444 r.SRVConf.SRVs[req.name] = unptr(req.srvs)
1445 if req.err != nil {
1446 addf(&r.SRVConf.Errors, "Looking up SRV record %q: %s", name, req.err)
1447 } else if len(req.srvs) == 0 {
1448 if req.host == "." {
1449 addf(&r.SRVConf.Warnings, "Missing optional SRV record %q", name)
1450 } else {
1451 addf(&r.SRVConf.Errors, "Missing SRV record %q", name)
1452 }
1453 } else if len(req.srvs) != 1 || req.srvs[0].Target != req.host || req.srvs[0].Port != req.port {
1454 var srvs []string
1455 for _, srv := range req.srvs {
1456 srvs = append(srvs, fmt.Sprintf("%d %d %d %s", srv.Priority, srv.Weight, srv.Port, srv.Target))
1457 }
1458 addf(&r.SRVConf.Errors, "Unexpected SRV record(s) for %q: %s", name, strings.Join(srvs, ", "))
1459 }
1460 }
1461 addf(&r.SRVConf.Instructions, instr)
1462 }()
1463
1464 // Autoconf
1465 wg.Add(1)
1466 go func() {
1467 defer logPanic(ctx)
1468 defer wg.Done()
1469
1470 if domConf.ClientSettingsDomain != "" {
1471 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+".")
1472
1473 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, domConf.ClientSettingsDNSDomain.ASCII+".")
1474 if err != nil {
1475 addf(&r.Autoconf.Errors, "Looking up client settings DNS CNAME: %s", err)
1476 }
1477 r.Autoconf.ClientSettingsDomainIPs = ips
1478 if !isUnspecifiedNAT {
1479 if len(ourIPs) == 0 {
1480 addf(&r.Autoconf.Errors, "Client settings domain does not point to one of our IPs.")
1481 } else if len(notOurIPs) > 0 {
1482 addf(&r.Autoconf.Errors, "Client settings domain points to some IPs that are not ours: %v", notOurIPs)
1483 }
1484 }
1485 }
1486
1487 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+".")
1488
1489 host := "autoconfig." + domain.ASCII + "."
1490 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autoconf.Errors, host)
1491 if err != nil {
1492 addf(&r.Autoconf.Errors, "Looking up autoconfig host: %s", err)
1493 return
1494 }
1495
1496 r.Autoconf.IPs = ips
1497 if !isUnspecifiedNAT {
1498 if len(ourIPs) == 0 {
1499 addf(&r.Autoconf.Errors, "Autoconfig does not point to one of our IPs.")
1500 } else if len(notOurIPs) > 0 {
1501 addf(&r.Autoconf.Errors, "Autoconfig points to some IPs that are not ours: %v", notOurIPs)
1502 }
1503 }
1504
1505 checkTLS(&r.Autoconf.Errors, "autoconfig."+domain.ASCII, ips, "443")
1506 }()
1507
1508 // Autodiscover
1509 wg.Add(1)
1510 go func() {
1511 defer logPanic(ctx)
1512 defer wg.Done()
1513
1514 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+".")
1515
1516 _, srvs, _, err := resolver.LookupSRV(ctx, "autodiscover", "tcp", domain.ASCII+".")
1517 if err != nil {
1518 addf(&r.Autodiscover.Errors, "Looking up SRV record %q: %s", "autodiscover", err)
1519 return
1520 }
1521 match := false
1522 for _, srv := range srvs {
1523 ips, ourIPs, notOurIPs, err := lookupIPs(&r.Autodiscover.Errors, srv.Target)
1524 if err != nil {
1525 addf(&r.Autodiscover.Errors, "Looking up target %q from SRV record: %s", srv.Target, err)
1526 continue
1527 }
1528 if srv.Port != 443 {
1529 continue
1530 }
1531 match = true
1532 r.Autodiscover.Records = append(r.Autodiscover.Records, AutodiscoverSRV{*srv, ips})
1533 if !isUnspecifiedNAT {
1534 if len(ourIPs) == 0 {
1535 addf(&r.Autodiscover.Errors, "SRV target %q does not point to our IPs.", srv.Target)
1536 } else if len(notOurIPs) > 0 {
1537 addf(&r.Autodiscover.Errors, "SRV target %q points to some IPs that are not ours: %v", srv.Target, notOurIPs)
1538 }
1539 }
1540
1541 checkTLS(&r.Autodiscover.Errors, strings.TrimSuffix(srv.Target, "."), ips, "443")
1542 }
1543 if !match {
1544 addf(&r.Autodiscover.Errors, "No SRV record for port 443 for https.")
1545 }
1546 }()
1547
1548 wg.Wait()
1549 return
1550}
1551
1552// Domains returns all configured domain names.
1553func (Admin) Domains(ctx context.Context) []config.Domain {
1554 return mox.Conf.DomainConfigs()
1555}
1556
1557// Domain returns the dns domain for a (potentially unicode as IDNA) domain name.
1558func (Admin) Domain(ctx context.Context, domain string) dns.Domain {
1559 d, err := dns.ParseDomain(domain)
1560 xcheckuserf(ctx, err, "parse domain")
1561 _, ok := mox.Conf.Domain(d)
1562 if !ok {
1563 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1564 }
1565 return d
1566}
1567
1568// ParseDomain parses a domain, possibly an IDNA domain.
1569func (Admin) ParseDomain(ctx context.Context, domain string) dns.Domain {
1570 d, err := dns.ParseDomain(domain)
1571 xcheckuserf(ctx, err, "parse domain")
1572 return d
1573}
1574
1575// DomainConfig returns the configuration for a domain.
1576func (Admin) DomainConfig(ctx context.Context, domain string) config.Domain {
1577 d, err := dns.ParseDomain(domain)
1578 xcheckuserf(ctx, err, "parse domain")
1579 conf, ok := mox.Conf.Domain(d)
1580 if !ok {
1581 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1582 }
1583 return conf
1584}
1585
1586// DomainLocalparts returns the encoded localparts and accounts configured in domain.
1587func (Admin) DomainLocalparts(ctx context.Context, domain string) (localpartAccounts map[string]string, localpartAliases map[string]config.Alias) {
1588 d, err := dns.ParseDomain(domain)
1589 xcheckuserf(ctx, err, "parsing domain")
1590 _, ok := mox.Conf.Domain(d)
1591 if !ok {
1592 xcheckuserf(ctx, errors.New("no such domain"), "looking up domain")
1593 }
1594 return mox.Conf.DomainLocalparts(d)
1595}
1596
1597// Accounts returns the names of all configured and all disabled accounts.
1598func (Admin) Accounts(ctx context.Context) (all, disabled []string) {
1599 all, disabled = mox.Conf.AccountsDisabled()
1600 slices.Sort(all)
1601 return
1602}
1603
1604// Account returns the parsed configuration of an account.
1605func (Admin) Account(ctx context.Context, account string) (accountConfig config.Account, diskUsage int64) {
1606 log := pkglog.WithContext(ctx)
1607
1608 acc, err := store.OpenAccount(log, account, false)
1609 if err != nil && errors.Is(err, store.ErrAccountUnknown) {
1610 xcheckuserf(ctx, err, "looking up account")
1611 }
1612 xcheckf(ctx, err, "open account")
1613 defer func() {
1614 err := acc.Close()
1615 log.Check(err, "closing account")
1616 }()
1617
1618 var ac config.Account
1619 acc.WithRLock(func() {
1620 ac, _ = mox.Conf.Account(acc.Name)
1621
1622 err := acc.DB.Read(ctx, func(tx *bstore.Tx) error {
1623 du := store.DiskUsage{ID: 1}
1624 err := tx.Get(&du)
1625 diskUsage = du.MessageSize
1626 return err
1627 })
1628 xcheckf(ctx, err, "get disk usage")
1629 })
1630
1631 return ac, diskUsage
1632}
1633
1634// ConfigFiles returns the paths and contents of the static and dynamic configuration files.
1635func (Admin) ConfigFiles(ctx context.Context) (staticPath, dynamicPath, static, dynamic string) {
1636 buf0, err := os.ReadFile(mox.ConfigStaticPath)
1637 xcheckf(ctx, err, "read static config file")
1638 buf1, err := os.ReadFile(mox.ConfigDynamicPath)
1639 xcheckf(ctx, err, "read dynamic config file")
1640 return mox.ConfigStaticPath, mox.ConfigDynamicPath, string(buf0), string(buf1)
1641}
1642
1643// MTASTSPolicies returns all mtasts policies from the cache.
1644func (Admin) MTASTSPolicies(ctx context.Context) (records []mtastsdb.PolicyRecord) {
1645 records, err := mtastsdb.PolicyRecords(ctx)
1646 xcheckf(ctx, err, "fetching mtasts policies from database")
1647 return records
1648}
1649
1650// TLSReports returns TLS reports overlapping with period start/end, for the given
1651// policy domain (or all domains if empty). The reports are sorted first by period
1652// end (most recent first), then by policy domain.
1653func (Admin) TLSReports(ctx context.Context, start, end time.Time, policyDomain string) (reports []tlsrptdb.Record) {
1654 var polDom dns.Domain
1655 if policyDomain != "" {
1656 var err error
1657 polDom, err = dns.ParseDomain(policyDomain)
1658 xcheckuserf(ctx, err, "parsing domain %q", policyDomain)
1659 }
1660
1661 records, err := tlsrptdb.RecordsPeriodDomain(ctx, start, end, polDom)
1662 xcheckf(ctx, err, "fetching tlsrpt report records from database")
1663 sort.Slice(records, func(i, j int) bool {
1664 iend := records[i].Report.DateRange.End
1665 jend := records[j].Report.DateRange.End
1666 if iend == jend {
1667 return records[i].Domain < records[j].Domain
1668 }
1669 return iend.After(jend)
1670 })
1671 return records
1672}
1673
1674// TLSReportID returns a single TLS report.
1675func (Admin) TLSReportID(ctx context.Context, domain string, reportID int64) tlsrptdb.Record {
1676 record, err := tlsrptdb.RecordID(ctx, reportID)
1677 if err == nil && record.Domain != domain {
1678 err = bstore.ErrAbsent
1679 }
1680 if err == bstore.ErrAbsent {
1681 xcheckuserf(ctx, err, "fetching tls report from database")
1682 }
1683 xcheckf(ctx, err, "fetching tls report from database")
1684 return record
1685}
1686
1687// TLSRPTSummary presents TLS reporting statistics for a single domain
1688// over a period.
1689type TLSRPTSummary struct {
1690 PolicyDomain dns.Domain
1691 Success int64
1692 Failure int64
1693 ResultTypeCounts map[tlsrpt.ResultType]int64
1694}
1695
1696// TLSRPTSummaries returns a summary of received TLS 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) TLSRPTSummaries(ctx context.Context, start, end time.Time, policyDomain string) (domainSummaries []TLSRPTSummary) {
1700 var polDom dns.Domain
1701 if policyDomain != "" {
1702 var err error
1703 polDom, err = dns.ParseDomain(policyDomain)
1704 xcheckuserf(ctx, err, "parsing policy domain")
1705 }
1706 reports, err := tlsrptdb.RecordsPeriodDomain(ctx, start, end, polDom)
1707 xcheckf(ctx, err, "fetching tlsrpt reports from database")
1708
1709 summaries := map[dns.Domain]TLSRPTSummary{}
1710 for _, r := range reports {
1711 dom, err := dns.ParseDomain(r.Domain)
1712 xcheckf(ctx, err, "parsing domain %q", r.Domain)
1713
1714 sum := summaries[dom]
1715 sum.PolicyDomain = dom
1716 for _, result := range r.Report.Policies {
1717 sum.Success += result.Summary.TotalSuccessfulSessionCount
1718 sum.Failure += result.Summary.TotalFailureSessionCount
1719 for _, details := range result.FailureDetails {
1720 if sum.ResultTypeCounts == nil {
1721 sum.ResultTypeCounts = map[tlsrpt.ResultType]int64{}
1722 }
1723 sum.ResultTypeCounts[details.ResultType] += details.FailedSessionCount
1724 }
1725 }
1726 summaries[dom] = sum
1727 }
1728 sums := make([]TLSRPTSummary, 0, len(summaries))
1729 for _, sum := range summaries {
1730 sums = append(sums, sum)
1731 }
1732 sort.Slice(sums, func(i, j int) bool {
1733 return sums[i].PolicyDomain.Name() < sums[j].PolicyDomain.Name()
1734 })
1735 return sums
1736}
1737
1738// DMARCReports returns DMARC reports overlapping with period start/end, for the
1739// given domain (or all domains if empty). The reports are sorted first by period
1740// end (most recent first), then by domain.
1741func (Admin) DMARCReports(ctx context.Context, start, end time.Time, domain string) (reports []dmarcdb.DomainFeedback) {
1742 reports, err := dmarcdb.RecordsPeriodDomain(ctx, start, end, domain)
1743 xcheckf(ctx, err, "fetching dmarc aggregate reports from database")
1744 sort.Slice(reports, func(i, j int) bool {
1745 iend := reports[i].ReportMetadata.DateRange.End
1746 jend := reports[j].ReportMetadata.DateRange.End
1747 if iend == jend {
1748 return reports[i].Domain < reports[j].Domain
1749 }
1750 return iend > jend
1751 })
1752 return reports
1753}
1754
1755// DMARCReportID returns a single DMARC report.
1756func (Admin) DMARCReportID(ctx context.Context, domain string, reportID int64) (report dmarcdb.DomainFeedback) {
1757 report, err := dmarcdb.RecordID(ctx, reportID)
1758 if err == nil && report.Domain != domain {
1759 err = bstore.ErrAbsent
1760 }
1761 if err == bstore.ErrAbsent {
1762 xcheckuserf(ctx, err, "fetching dmarc aggregate report from database")
1763 }
1764 xcheckf(ctx, err, "fetching dmarc aggregate report from database")
1765 return report
1766}
1767
1768// DMARCSummary presents DMARC aggregate reporting statistics for a single domain
1769// over a period.
1770type DMARCSummary struct {
1771 Domain string
1772 Total int
1773 DispositionNone int
1774 DispositionQuarantine int
1775 DispositionReject int
1776 DKIMFail int
1777 SPFFail int
1778 PolicyOverrides map[dmarcrpt.PolicyOverride]int
1779}
1780
1781// DMARCSummaries returns a summary of received DMARC reports overlapping with
1782// period start/end for one or all domains (when domain is empty).
1783// The returned summaries are ordered by domain name.
1784func (Admin) DMARCSummaries(ctx context.Context, start, end time.Time, domain string) (domainSummaries []DMARCSummary) {
1785 reports, err := dmarcdb.RecordsPeriodDomain(ctx, start, end, domain)
1786 xcheckf(ctx, err, "fetching dmarc aggregate reports from database")
1787 summaries := map[string]DMARCSummary{}
1788 for _, r := range reports {
1789 sum := summaries[r.Domain]
1790 sum.Domain = r.Domain
1791 for _, record := range r.Records {
1792 n := record.Row.Count
1793
1794 sum.Total += n
1795
1796 switch record.Row.PolicyEvaluated.Disposition {
1797 case dmarcrpt.DispositionNone:
1798 sum.DispositionNone += n
1799 case dmarcrpt.DispositionQuarantine:
1800 sum.DispositionQuarantine += n
1801 case dmarcrpt.DispositionReject:
1802 sum.DispositionReject += n
1803 }
1804
1805 if record.Row.PolicyEvaluated.DKIM == dmarcrpt.DMARCFail {
1806 sum.DKIMFail += n
1807 }
1808 if record.Row.PolicyEvaluated.SPF == dmarcrpt.DMARCFail {
1809 sum.SPFFail += n
1810 }
1811
1812 for _, reason := range record.Row.PolicyEvaluated.Reasons {
1813 if sum.PolicyOverrides == nil {
1814 sum.PolicyOverrides = map[dmarcrpt.PolicyOverride]int{}
1815 }
1816 sum.PolicyOverrides[reason.Type] += n
1817 }
1818 }
1819 summaries[r.Domain] = sum
1820 }
1821 sums := make([]DMARCSummary, 0, len(summaries))
1822 for _, sum := range summaries {
1823 sums = append(sums, sum)
1824 }
1825 sort.Slice(sums, func(i, j int) bool {
1826 return sums[i].Domain < sums[j].Domain
1827 })
1828 return sums
1829}
1830
1831// Reverse is the result of a reverse lookup.
1832type Reverse struct {
1833 Hostnames []string
1834
1835 // In the future, we can add a iprev-validated host name, and possibly the IPs of the host names.
1836}
1837
1838// LookupIP does a reverse lookup of ip.
1839func (Admin) LookupIP(ctx context.Context, ip string) Reverse {
1840 resolver := dns.StrictResolver{Pkg: "webadmin", Log: pkglog.WithContext(ctx).Logger}
1841 names, _, err := resolver.LookupAddr(ctx, ip)
1842 xcheckuserf(ctx, err, "looking up ip")
1843 return Reverse{names}
1844}
1845
1846// DNSBLStatus returns the IPs from which outgoing connections may be made and
1847// their current status in DNSBLs that are configured. The IPs are typically the
1848// configured listen IPs, or otherwise IPs on the machines network interfaces, with
1849// internal/private IPs removed.
1850//
1851// The returned value maps IPs to per DNSBL statuses, where "pass" means not listed and
1852// anything else is an error string, e.g. "fail: ..." or "temperror: ...".
1853func (Admin) DNSBLStatus(ctx context.Context) (results map[string]map[string]string, using, monitoring []dns.Domain) {
1854 log := mlog.New("webadmin", nil).WithContext(ctx)
1855 resolver := dns.StrictResolver{Pkg: "check", Log: log.Logger}
1856 return dnsblsStatus(ctx, log, resolver)
1857}
1858
1859func dnsblsStatus(ctx context.Context, log mlog.Log, resolver dns.Resolver) (results map[string]map[string]string, using, monitoring []dns.Domain) {
1860 // todo: check health before using dnsbl?
1861 using = mox.Conf.Static.Listeners["public"].SMTP.DNSBLZones
1862 zones := slices.Clone(using)
1863 conf := mox.Conf.DynamicConfig()
1864 for _, zone := range conf.MonitorDNSBLZones {
1865 if !slices.Contains(zones, zone) {
1866 zones = append(zones, zone)
1867 monitoring = append(monitoring, zone)
1868 }
1869 }
1870
1871 r := map[string]map[string]string{}
1872 for _, ip := range xsendingIPs(ctx) {
1873 if ip.IsLoopback() || ip.IsPrivate() {
1874 continue
1875 }
1876 ipstr := ip.String()
1877 r[ipstr] = map[string]string{}
1878 for _, zone := range zones {
1879 status, expl, err := dnsbl.Lookup(ctx, log.Logger, resolver, zone, ip)
1880 result := string(status)
1881 if err != nil {
1882 result += ": " + err.Error()
1883 }
1884 if expl != "" {
1885 result += ": " + expl
1886 }
1887 r[ipstr][zone.LogString()] = result
1888 }
1889 }
1890 return r, using, monitoring
1891}
1892
1893func (Admin) MonitorDNSBLsSave(ctx context.Context, text string) {
1894 var zones []dns.Domain
1895 publicZones := mox.Conf.Static.Listeners["public"].SMTP.DNSBLZones
1896 for _, line := range strings.Split(text, "\n") {
1897 line = strings.TrimSpace(line)
1898 if line == "" {
1899 continue
1900 }
1901 d, err := dns.ParseDomain(line)
1902 xcheckuserf(ctx, err, "parsing dnsbl zone %s", line)
1903 if slices.Contains(zones, d) {
1904 xusererrorf(ctx, "duplicate dnsbl zone %s", line)
1905 }
1906 if slices.Contains(publicZones, d) {
1907 xusererrorf(ctx, "dnsbl zone %s already present in public listener", line)
1908 }
1909 zones = append(zones, d)
1910 }
1911
1912 err := admin.ConfigSave(ctx, func(conf *config.Dynamic) {
1913 conf.MonitorDNSBLs = make([]string, len(zones))
1914 conf.MonitorDNSBLZones = nil
1915 for i, z := range zones {
1916 conf.MonitorDNSBLs[i] = z.Name()
1917 }
1918 })
1919 xcheckf(ctx, err, "saving monitoring dnsbl zones")
1920}
1921
1922// DomainRecords returns lines describing DNS records that should exist for the
1923// configured domain.
1924func (Admin) DomainRecords(ctx context.Context, domain string) []string {
1925 log := pkglog.WithContext(ctx)
1926 return DomainRecords(ctx, log, domain)
1927}
1928
1929// DomainRecords is the implementation of API function Admin.DomainRecords, taking
1930// a logger.
1931func DomainRecords(ctx context.Context, log mlog.Log, domain string) []string {
1932 d, err := dns.ParseDomain(domain)
1933 xcheckuserf(ctx, err, "parsing domain")
1934 dc, ok := mox.Conf.Domain(d)
1935 if !ok {
1936 xcheckuserf(ctx, errors.New("unknown domain"), "lookup domain")
1937 }
1938 resolver := dns.StrictResolver{Pkg: "webadmin", Log: pkglog.WithContext(ctx).Logger}
1939 _, result, err := resolver.LookupTXT(ctx, domain+".")
1940 if !dns.IsNotFound(err) {
1941 xcheckf(ctx, err, "looking up record to determine if dnssec is implemented")
1942 }
1943
1944 var certIssuerDomainName, acmeAccountURI string
1945 public := mox.Conf.Static.Listeners["public"]
1946 if public.TLS != nil && public.TLS.ACME != "" {
1947 acme, ok := mox.Conf.Static.ACME[public.TLS.ACME]
1948 if ok && acme.Manager.Manager.Client != nil {
1949 certIssuerDomainName = acme.IssuerDomainName
1950 acc, err := acme.Manager.Manager.Client.GetReg(ctx, "")
1951 log.Check(err, "get public acme account")
1952 if err == nil {
1953 acmeAccountURI = acc.URI
1954 }
1955 }
1956 }
1957
1958 records, err := admin.DomainRecords(dc, d, result.Authentic, certIssuerDomainName, acmeAccountURI)
1959 xcheckf(ctx, err, "dns records")
1960 return records
1961}
1962
1963// DomainAdd adds a new domain and reloads the configuration.
1964func (Admin) DomainAdd(ctx context.Context, disabled bool, domain, accountName, localpart string) {
1965 d, err := dns.ParseDomain(domain)
1966 xcheckuserf(ctx, err, "parsing domain")
1967
1968 err = admin.DomainAdd(ctx, disabled, d, accountName, smtp.Localpart(norm.NFC.String(localpart)))
1969 xcheckf(ctx, err, "adding domain")
1970}
1971
1972// DomainRemove removes an existing domain and reloads the configuration.
1973func (Admin) DomainRemove(ctx context.Context, domain string) {
1974 d, err := dns.ParseDomain(domain)
1975 xcheckuserf(ctx, err, "parsing domain")
1976
1977 err = admin.DomainRemove(ctx, d)
1978 xcheckf(ctx, err, "removing domain")
1979}
1980
1981// AccountAdd adds existing a new account, with an initial email address, and
1982// reloads the configuration.
1983func (Admin) AccountAdd(ctx context.Context, accountName, address string) {
1984 err := admin.AccountAdd(ctx, accountName, address)
1985 xcheckf(ctx, err, "adding account")
1986}
1987
1988// AccountRemove removes an existing account and reloads the configuration.
1989func (Admin) AccountRemove(ctx context.Context, accountName string) {
1990 err := admin.AccountRemove(ctx, accountName)
1991 xcheckf(ctx, err, "removing account")
1992}
1993
1994// AddressAdd adds a new address to the account, which must already exist.
1995func (Admin) AddressAdd(ctx context.Context, address, accountName string) {
1996 err := admin.AddressAdd(ctx, address, accountName)
1997 xcheckf(ctx, err, "adding address")
1998}
1999
2000// AddressRemove removes an existing address.
2001func (Admin) AddressRemove(ctx context.Context, address string) {
2002 err := admin.AddressRemove(ctx, address)
2003 xcheckf(ctx, err, "removing address")
2004}
2005
2006// SetPassword saves a new password for an account, invalidating the previous password.
2007// Sessions are not interrupted, and will keep working. New login attempts must use the new password.
2008// Password must be at least 8 characters.
2009func (Admin) SetPassword(ctx context.Context, accountName, password string) {
2010 log := pkglog.WithContext(ctx)
2011 if len(password) < 8 {
2012 xusererrorf(ctx, "message must be at least 8 characters")
2013 }
2014 acc, err := store.OpenAccount(log, accountName, false)
2015 xcheckf(ctx, err, "open account")
2016 defer func() {
2017 err := acc.Close()
2018 log.WithContext(ctx).Check(err, "closing account")
2019 }()
2020 err = acc.SetPassword(log, password)
2021 xcheckf(ctx, err, "setting password")
2022}
2023
2024// AccountSettingsSave set new settings for an account that only an admin can set.
2025func (Admin) AccountSettingsSave(ctx context.Context, accountName string, maxOutgoingMessagesPerDay, maxFirstTimeRecipientsPerDay int, maxMsgSize int64, firstTimeSenderDelay, noCustomPassword bool) {
2026 err := admin.AccountSave(ctx, accountName, func(acc *config.Account) {
2027 acc.MaxOutgoingMessagesPerDay = maxOutgoingMessagesPerDay
2028 acc.MaxFirstTimeRecipientsPerDay = maxFirstTimeRecipientsPerDay
2029 acc.QuotaMessageSize = maxMsgSize
2030 acc.NoFirstTimeSenderDelay = !firstTimeSenderDelay
2031 acc.NoCustomPassword = noCustomPassword
2032 })
2033 xcheckf(ctx, err, "saving account settings")
2034}
2035
2036// AccountLoginDisabledSave saves the LoginDisabled field of an account.
2037func (Admin) AccountLoginDisabledSave(ctx context.Context, accountName string, loginDisabled string) {
2038 log := pkglog.WithContext(ctx)
2039
2040 acc, err := store.OpenAccount(log, accountName, false)
2041 xcheckf(ctx, err, "open account")
2042 defer func() {
2043 err := acc.Close()
2044 log.Check(err, "closing account")
2045 }()
2046
2047 err = admin.AccountSave(ctx, accountName, func(acc *config.Account) {
2048 acc.LoginDisabled = loginDisabled
2049 })
2050 xcheckf(ctx, err, "saving login disabled account")
2051
2052 err = acc.SessionsClear(ctx, log)
2053 xcheckf(ctx, err, "removing current sessions")
2054}
2055
2056// ClientConfigsDomain returns configurations for email clients, IMAP and
2057// Submission (SMTP) for the domain.
2058func (Admin) ClientConfigsDomain(ctx context.Context, domain string) admin.ClientConfigs {
2059 d, err := dns.ParseDomain(domain)
2060 xcheckuserf(ctx, err, "parsing domain")
2061
2062 cc, err := admin.ClientConfigsDomain(d)
2063 xcheckf(ctx, err, "client config for domain")
2064 return cc
2065}
2066
2067// QueueSize returns the number of messages currently in the outgoing queue.
2068func (Admin) QueueSize(ctx context.Context) int {
2069 n, err := queue.Count(ctx)
2070 xcheckf(ctx, err, "listing messages in queue")
2071 return n
2072}
2073
2074// QueueHoldRuleList lists the hold rules.
2075func (Admin) QueueHoldRuleList(ctx context.Context) []queue.HoldRule {
2076 l, err := queue.HoldRuleList(ctx)
2077 xcheckf(ctx, err, "listing queue hold rules")
2078 return l
2079}
2080
2081// QueueHoldRuleAdd adds a hold rule. Newly submitted and existing messages
2082// matching the hold rule will be marked "on hold".
2083func (Admin) QueueHoldRuleAdd(ctx context.Context, hr queue.HoldRule) queue.HoldRule {
2084 var err error
2085 hr.SenderDomain, err = dns.ParseDomain(hr.SenderDomainStr)
2086 xcheckuserf(ctx, err, "parsing sender domain %q", hr.SenderDomainStr)
2087 hr.RecipientDomain, err = dns.ParseDomain(hr.RecipientDomainStr)
2088 xcheckuserf(ctx, err, "parsing recipient domain %q", hr.RecipientDomainStr)
2089
2090 log := pkglog.WithContext(ctx)
2091 hr, err = queue.HoldRuleAdd(ctx, log, hr)
2092 xcheckf(ctx, err, "adding queue hold rule")
2093 return hr
2094}
2095
2096// QueueHoldRuleRemove removes a hold rule. The Hold field of messages in
2097// the queue are not changed.
2098func (Admin) QueueHoldRuleRemove(ctx context.Context, holdRuleID int64) {
2099 log := pkglog.WithContext(ctx)
2100 err := queue.HoldRuleRemove(ctx, log, holdRuleID)
2101 xcheckf(ctx, err, "removing queue hold rule")
2102}
2103
2104// QueueList returns the messages currently in the outgoing queue.
2105func (Admin) QueueList(ctx context.Context, filter queue.Filter, sort queue.Sort) []queue.Msg {
2106 l, err := queue.List(ctx, filter, sort)
2107 xcheckf(ctx, err, "listing messages in queue")
2108 return l
2109}
2110
2111// QueueNextAttemptSet sets a new time for next delivery attempt of matching
2112// messages from the queue.
2113func (Admin) QueueNextAttemptSet(ctx context.Context, filter queue.Filter, minutes int) (affected int) {
2114 n, err := queue.NextAttemptSet(ctx, filter, time.Now().Add(time.Duration(minutes)*time.Minute))
2115 xcheckf(ctx, err, "setting new next delivery attempt time for matching messages in queue")
2116 return n
2117}
2118
2119// QueueNextAttemptAdd adds a duration to the time of next delivery attempt of
2120// matching messages from the queue.
2121func (Admin) QueueNextAttemptAdd(ctx context.Context, filter queue.Filter, minutes int) (affected int) {
2122 n, err := queue.NextAttemptAdd(ctx, filter, time.Duration(minutes)*time.Minute)
2123 xcheckf(ctx, err, "adding duration to next delivery attempt for matching messages in queue")
2124 return n
2125}
2126
2127// QueueHoldSet sets the Hold field of matching messages in the queue.
2128func (Admin) QueueHoldSet(ctx context.Context, filter queue.Filter, onHold bool) (affected int) {
2129 n, err := queue.HoldSet(ctx, filter, onHold)
2130 xcheckf(ctx, err, "changing onhold for matching messages in queue")
2131 return n
2132}
2133
2134// QueueFail fails delivery for matching messages, causing DSNs to be sent.
2135func (Admin) QueueFail(ctx context.Context, filter queue.Filter) (affected int) {
2136 log := pkglog.WithContext(ctx)
2137 n, err := queue.Fail(ctx, log, filter)
2138 xcheckf(ctx, err, "drop messages from queue")
2139 return n
2140}
2141
2142// QueueDrop removes matching messages from the queue.
2143func (Admin) QueueDrop(ctx context.Context, filter queue.Filter) (affected int) {
2144 log := pkglog.WithContext(ctx)
2145 n, err := queue.Drop(ctx, log, filter)
2146 xcheckf(ctx, err, "drop messages from queue")
2147 return n
2148}
2149
2150// QueueRequireTLSSet updates the requiretls field for matching messages in the
2151// queue, to be used for the next delivery.
2152func (Admin) QueueRequireTLSSet(ctx context.Context, filter queue.Filter, requireTLS *bool) (affected int) {
2153 n, err := queue.RequireTLSSet(ctx, filter, requireTLS)
2154 xcheckf(ctx, err, "update requiretls for messages in queue")
2155 return n
2156}
2157
2158// QueueTransportSet initiates delivery of a message from the queue and sets the transport
2159// to use for delivery.
2160func (Admin) QueueTransportSet(ctx context.Context, filter queue.Filter, transport string) (affected int) {
2161 n, err := queue.TransportSet(ctx, filter, transport)
2162 xcheckf(ctx, err, "changing transport for messages in queue")
2163 return n
2164}
2165
2166// RetiredList returns messages retired from the queue (delivery could
2167// have succeeded or failed).
2168func (Admin) RetiredList(ctx context.Context, filter queue.RetiredFilter, sort queue.RetiredSort) []queue.MsgRetired {
2169 l, err := queue.RetiredList(ctx, filter, sort)
2170 xcheckf(ctx, err, "listing retired messages")
2171 return l
2172}
2173
2174// HookQueueSize returns the number of webhooks still to be delivered.
2175func (Admin) HookQueueSize(ctx context.Context) int {
2176 n, err := queue.HookQueueSize(ctx)
2177 xcheckf(ctx, err, "get hook queue size")
2178 return n
2179}
2180
2181// HookList lists webhooks still to be delivered.
2182func (Admin) HookList(ctx context.Context, filter queue.HookFilter, sort queue.HookSort) []queue.Hook {
2183 l, err := queue.HookList(ctx, filter, sort)
2184 xcheckf(ctx, err, "listing hook queue")
2185 return l
2186}
2187
2188// HookNextAttemptSet sets a new time for next delivery attempt of matching
2189// hooks from the queue.
2190func (Admin) HookNextAttemptSet(ctx context.Context, filter queue.HookFilter, minutes int) (affected int) {
2191 n, err := queue.HookNextAttemptSet(ctx, filter, time.Now().Add(time.Duration(minutes)*time.Minute))
2192 xcheckf(ctx, err, "setting new next delivery attempt time for matching webhooks in queue")
2193 return n
2194}
2195
2196// HookNextAttemptAdd adds a duration to the time of next delivery attempt of
2197// matching hooks from the queue.
2198func (Admin) HookNextAttemptAdd(ctx context.Context, filter queue.HookFilter, minutes int) (affected int) {
2199 n, err := queue.HookNextAttemptAdd(ctx, filter, time.Duration(minutes)*time.Minute)
2200 xcheckf(ctx, err, "adding duration to next delivery attempt for matching webhooks in queue")
2201 return n
2202}
2203
2204// HookRetiredList lists retired webhooks.
2205func (Admin) HookRetiredList(ctx context.Context, filter queue.HookRetiredFilter, sort queue.HookRetiredSort) []queue.HookRetired {
2206 l, err := queue.HookRetiredList(ctx, filter, sort)
2207 xcheckf(ctx, err, "listing retired hooks")
2208 return l
2209}
2210
2211// HookCancel prevents further delivery attempts of matching webhooks.
2212func (Admin) HookCancel(ctx context.Context, filter queue.HookFilter) (affected int) {
2213 log := pkglog.WithContext(ctx)
2214 n, err := queue.HookCancel(ctx, log, filter)
2215 xcheckf(ctx, err, "cancel hooks in queue")
2216 return n
2217}
2218
2219// LogLevels returns the current log levels.
2220func (Admin) LogLevels(ctx context.Context) map[string]string {
2221 m := map[string]string{}
2222 for pkg, level := range mox.Conf.LogLevels() {
2223 s, ok := mlog.LevelStrings[level]
2224 if !ok {
2225 s = level.String()
2226 }
2227 m[pkg] = s
2228 }
2229 return m
2230}
2231
2232// LogLevelSet sets a log level for a package.
2233func (Admin) LogLevelSet(ctx context.Context, pkg string, levelStr string) {
2234 level, ok := mlog.Levels[levelStr]
2235 if !ok {
2236 xcheckuserf(ctx, errors.New("unknown"), "lookup level")
2237 }
2238 mox.Conf.LogLevelSet(pkglog.WithContext(ctx), pkg, level)
2239}
2240
2241// LogLevelRemove removes a log level for a package, which cannot be the empty string.
2242func (Admin) LogLevelRemove(ctx context.Context, pkg string) {
2243 mox.Conf.LogLevelRemove(pkglog.WithContext(ctx), pkg)
2244}
2245
2246// CheckUpdatesEnabled returns whether checking for updates is enabled.
2247func (Admin) CheckUpdatesEnabled(ctx context.Context) bool {
2248 return mox.Conf.Static.CheckUpdates
2249}
2250
2251// WebserverConfig is the combination of WebDomainRedirects and WebHandlers
2252// from the domains.conf configuration file.
2253type WebserverConfig struct {
2254 WebDNSDomainRedirects [][2]dns.Domain // From server to frontend.
2255 WebDomainRedirects [][2]string // From frontend to server, it's not convenient to create dns.Domain in the frontend.
2256 WebHandlers []config.WebHandler
2257}
2258
2259// WebserverConfig returns the current webserver config
2260func (Admin) WebserverConfig(ctx context.Context) (conf WebserverConfig) {
2261 conf = webserverConfig()
2262 conf.WebDomainRedirects = nil
2263 return conf
2264}
2265
2266func webserverConfig() WebserverConfig {
2267 conf := mox.Conf.DynamicConfig()
2268 r := conf.WebDNSDomainRedirects
2269 l := conf.WebHandlers
2270
2271 x := make([][2]dns.Domain, 0, len(r))
2272 xs := make([][2]string, 0, len(r))
2273 for k, v := range r {
2274 x = append(x, [2]dns.Domain{k, v})
2275 xs = append(xs, [2]string{k.Name(), v.Name()})
2276 }
2277 sort.Slice(x, func(i, j int) bool {
2278 return x[i][0].ASCII < x[j][0].ASCII
2279 })
2280 sort.Slice(xs, func(i, j int) bool {
2281 return xs[i][0] < xs[j][0]
2282 })
2283 return WebserverConfig{x, xs, l}
2284}
2285
2286// WebserverConfigSave saves a new webserver config. If oldConf is not equal to
2287// the current config, an error is returned.
2288func (Admin) WebserverConfigSave(ctx context.Context, oldConf, newConf WebserverConfig) (savedConf WebserverConfig) {
2289 current := webserverConfig()
2290 webhandlersEqual := func() bool {
2291 if len(current.WebHandlers) != len(oldConf.WebHandlers) {
2292 return false
2293 }
2294 for i, wh := range current.WebHandlers {
2295 if !wh.Equal(oldConf.WebHandlers[i]) {
2296 return false
2297 }
2298 }
2299 return true
2300 }
2301 if !reflect.DeepEqual(oldConf.WebDNSDomainRedirects, current.WebDNSDomainRedirects) || !webhandlersEqual() {
2302 xcheckuserf(ctx, errors.New("config has changed"), "comparing old/current config")
2303 }
2304
2305 // Convert to map, check that there are no duplicates here. The canonicalized
2306 // dns.Domain are checked again for uniqueness when parsing the config before
2307 // storing.
2308 domainRedirects := map[string]string{}
2309 for _, x := range newConf.WebDomainRedirects {
2310 if _, ok := domainRedirects[x[0]]; ok {
2311 xcheckuserf(ctx, errors.New("already present"), "checking redirect %s", x[0])
2312 }
2313 domainRedirects[x[0]] = x[1]
2314 }
2315
2316 err := admin.ConfigSave(ctx, func(conf *config.Dynamic) {
2317 conf.WebDomainRedirects = domainRedirects
2318 conf.WebHandlers = newConf.WebHandlers
2319 })
2320 xcheckf(ctx, err, "saving webserver config")
2321
2322 savedConf = webserverConfig()
2323 savedConf.WebDomainRedirects = nil
2324 return savedConf
2325}
2326
2327// Transports returns the configured transports, for sending email.
2328func (Admin) Transports(ctx context.Context) map[string]config.Transport {
2329 return mox.Conf.Static.Transports
2330}
2331
2332// DMARCEvaluationStats returns a map of all domains with evaluations to a count of
2333// the evaluations and whether those evaluations will cause a report to be sent.
2334func (Admin) DMARCEvaluationStats(ctx context.Context) map[string]dmarcdb.EvaluationStat {
2335 stats, err := dmarcdb.EvaluationStats(ctx)
2336 xcheckf(ctx, err, "get evaluation stats")
2337 return stats
2338}
2339
2340// DMARCEvaluationsDomain returns all evaluations for aggregate reports for the
2341// domain, sorted from oldest to most recent.
2342func (Admin) DMARCEvaluationsDomain(ctx context.Context, domain string) (dns.Domain, []dmarcdb.Evaluation) {
2343 dom, err := dns.ParseDomain(domain)
2344 xcheckf(ctx, err, "parsing domain")
2345
2346 evals, err := dmarcdb.EvaluationsDomain(ctx, dom)
2347 xcheckf(ctx, err, "get evaluations for domain")
2348 return dom, evals
2349}
2350
2351// DMARCRemoveEvaluations removes evaluations for a domain.
2352func (Admin) DMARCRemoveEvaluations(ctx context.Context, domain string) {
2353 dom, err := dns.ParseDomain(domain)
2354 xcheckf(ctx, err, "parsing domain")
2355
2356 err = dmarcdb.RemoveEvaluationsDomain(ctx, dom)
2357 xcheckf(ctx, err, "removing evaluations for domain")
2358}
2359
2360// DMARCSuppressAdd adds a reporting address to the suppress list. Outgoing
2361// reports will be suppressed for a period.
2362func (Admin) DMARCSuppressAdd(ctx context.Context, reportingAddress string, until time.Time, comment string) {
2363 addr, err := smtp.ParseAddress(reportingAddress)
2364 xcheckuserf(ctx, err, "parsing reporting address")
2365
2366 ba := dmarcdb.SuppressAddress{ReportingAddress: addr.String(), Until: until, Comment: comment}
2367 err = dmarcdb.SuppressAdd(ctx, &ba)
2368 xcheckf(ctx, err, "adding address to suppresslist")
2369}
2370
2371// DMARCSuppressList returns all reporting addresses on the suppress list.
2372func (Admin) DMARCSuppressList(ctx context.Context) []dmarcdb.SuppressAddress {
2373 l, err := dmarcdb.SuppressList(ctx)
2374 xcheckf(ctx, err, "listing reporting addresses in suppresslist")
2375 return l
2376}
2377
2378// DMARCSuppressRemove removes a reporting address record from the suppress list.
2379func (Admin) DMARCSuppressRemove(ctx context.Context, id int64) {
2380 err := dmarcdb.SuppressRemove(ctx, id)
2381 xcheckf(ctx, err, "removing reporting address from suppresslist")
2382}
2383
2384// DMARCSuppressExtend updates the until field of a suppressed reporting address record.
2385func (Admin) DMARCSuppressExtend(ctx context.Context, id int64, until time.Time) {
2386 err := dmarcdb.SuppressUpdate(ctx, id, until)
2387 xcheckf(ctx, err, "updating reporting address in suppresslist")
2388}
2389
2390// TLSRPTResults returns all TLSRPT results in the database.
2391func (Admin) TLSRPTResults(ctx context.Context) []tlsrptdb.TLSResult {
2392 results, err := tlsrptdb.Results(ctx)
2393 xcheckf(ctx, err, "get results")
2394 return results
2395}
2396
2397// TLSRPTResultsPolicyDomain returns the TLS results for a domain.
2398func (Admin) TLSRPTResultsDomain(ctx context.Context, isRcptDom bool, policyDomain string) (dns.Domain, []tlsrptdb.TLSResult) {
2399 dom, err := dns.ParseDomain(policyDomain)
2400 xcheckf(ctx, err, "parsing domain")
2401
2402 if isRcptDom {
2403 results, err := tlsrptdb.ResultsRecipientDomain(ctx, dom)
2404 xcheckf(ctx, err, "get result for recipient domain")
2405 return dom, results
2406 }
2407 results, err := tlsrptdb.ResultsPolicyDomain(ctx, dom)
2408 xcheckf(ctx, err, "get result for policy domain")
2409 return dom, results
2410}
2411
2412// LookupTLSRPTRecord looks up a TLSRPT record and returns the parsed form, original txt
2413// form from DNS, and error with the TLSRPT record as a string.
2414func (Admin) LookupTLSRPTRecord(ctx context.Context, domain string) (record *TLSRPTRecord, txt string, errstr string) {
2415 log := pkglog.WithContext(ctx)
2416 dom, err := dns.ParseDomain(domain)
2417 xcheckf(ctx, err, "parsing domain")
2418
2419 resolver := dns.StrictResolver{Pkg: "webadmin", Log: log.Logger}
2420 r, txt, err := tlsrpt.Lookup(ctx, log.Logger, resolver, dom)
2421 if err != nil && (errors.Is(err, tlsrpt.ErrNoRecord) || errors.Is(err, tlsrpt.ErrMultipleRecords) || errors.Is(err, tlsrpt.ErrRecordSyntax) || errors.Is(err, tlsrpt.ErrDNS)) {
2422 errstr = err.Error()
2423 err = nil
2424 }
2425 xcheckf(ctx, err, "fetching tlsrpt record")
2426
2427 if r != nil {
2428 record = &TLSRPTRecord{Record: *r}
2429 }
2430
2431 return record, txt, errstr
2432}
2433
2434// TLSRPTRemoveResults removes the TLS results for a domain for the given day. If
2435// day is empty, all results are removed.
2436func (Admin) TLSRPTRemoveResults(ctx context.Context, isRcptDom bool, domain string, day string) {
2437 dom, err := dns.ParseDomain(domain)
2438 xcheckf(ctx, err, "parsing domain")
2439
2440 if isRcptDom {
2441 err = tlsrptdb.RemoveResultsRecipientDomain(ctx, dom, day)
2442 xcheckf(ctx, err, "removing tls results")
2443 } else {
2444 err = tlsrptdb.RemoveResultsPolicyDomain(ctx, dom, day)
2445 xcheckf(ctx, err, "removing tls results")
2446 }
2447}
2448
2449// TLSRPTSuppressAdd adds a reporting address to the suppress list. Outgoing
2450// reports will be suppressed for a period.
2451func (Admin) TLSRPTSuppressAdd(ctx context.Context, reportingAddress string, until time.Time, comment string) {
2452 addr, err := smtp.ParseAddress(reportingAddress)
2453 xcheckuserf(ctx, err, "parsing reporting address")
2454
2455 ba := tlsrptdb.SuppressAddress{ReportingAddress: addr.String(), Until: until, Comment: comment}
2456 err = tlsrptdb.SuppressAdd(ctx, &ba)
2457 xcheckf(ctx, err, "adding address to suppresslist")
2458}
2459
2460// TLSRPTSuppressList returns all reporting addresses on the suppress list.
2461func (Admin) TLSRPTSuppressList(ctx context.Context) []tlsrptdb.SuppressAddress {
2462 l, err := tlsrptdb.SuppressList(ctx)
2463 xcheckf(ctx, err, "listing reporting addresses in suppresslist")
2464 return l
2465}
2466
2467// TLSRPTSuppressRemove removes a reporting address record from the suppress list.
2468func (Admin) TLSRPTSuppressRemove(ctx context.Context, id int64) {
2469 err := tlsrptdb.SuppressRemove(ctx, id)
2470 xcheckf(ctx, err, "removing reporting address from suppresslist")
2471}
2472
2473// TLSRPTSuppressExtend updates the until field of a suppressed reporting address record.
2474func (Admin) TLSRPTSuppressExtend(ctx context.Context, id int64, until time.Time) {
2475 err := tlsrptdb.SuppressUpdate(ctx, id, until)
2476 xcheckf(ctx, err, "updating reporting address in suppresslist")
2477}
2478
2479// LookupCid turns an ID from a Received header into a cid as used in logging.
2480func (Admin) LookupCid(ctx context.Context, recvID string) (cid string) {
2481 v, err := mox.ReceivedToCid(recvID)
2482 xcheckf(ctx, err, "received id to cid")
2483 return fmt.Sprintf("%x", v)
2484}
2485
2486// Config returns the dynamic config.
2487func (Admin) Config(ctx context.Context) config.Dynamic {
2488 return mox.Conf.DynamicConfig()
2489}
2490
2491// AccountRoutesSave saves routes for an account.
2492func (Admin) AccountRoutesSave(ctx context.Context, accountName string, routes []config.Route) {
2493 err := admin.AccountSave(ctx, accountName, func(acc *config.Account) {
2494 acc.Routes = routes
2495 })
2496 xcheckf(ctx, err, "saving account routes")
2497}
2498
2499// DomainRoutesSave saves routes for a domain.
2500func (Admin) DomainRoutesSave(ctx context.Context, domainName string, routes []config.Route) {
2501 err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
2502 domain.Routes = routes
2503 return nil
2504 })
2505 xcheckf(ctx, err, "saving domain routes")
2506}
2507
2508// RoutesSave saves global routes.
2509func (Admin) RoutesSave(ctx context.Context, routes []config.Route) {
2510 err := admin.ConfigSave(ctx, func(config *config.Dynamic) {
2511 config.Routes = routes
2512 })
2513 xcheckf(ctx, err, "saving global routes")
2514}
2515
2516// DomainDescriptionSave saves the description for a domain.
2517func (Admin) DomainDescriptionSave(ctx context.Context, domainName, descr string) {
2518 err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
2519 domain.Description = descr
2520 return nil
2521 })
2522 xcheckf(ctx, err, "saving domain description")
2523}
2524
2525// DomainClientSettingsDomainSave saves the client settings domain for a domain.
2526func (Admin) DomainClientSettingsDomainSave(ctx context.Context, domainName, clientSettingsDomain string) {
2527 err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
2528 domain.ClientSettingsDomain = clientSettingsDomain
2529 return nil
2530 })
2531 xcheckf(ctx, err, "saving client settings domain")
2532}
2533
2534// DomainLocalpartConfigSave saves the localpart catchall and case-sensitive
2535// settings for a domain.
2536func (Admin) DomainLocalpartConfigSave(ctx context.Context, domainName string, localpartCatchallSeparators []string, localpartCaseSensitive bool) {
2537 err := admin.DomainSave(ctx, domainName, func(domain *config.Domain) error {
2538 domain.LocalpartCatchallSeparatorsEffective = localpartCatchallSeparators
2539 // If there is a single separator, we prefer the non-list form, it's easier to
2540 // read/edit and should suffice for most setups.
2541 domain.LocalpartCatchallSeparator = ""
2542 domain.LocalpartCatchallSeparators = nil
2543 if len(localpartCatchallSeparators) == 1 {
2544 domain.LocalpartCatchallSeparator = localpartCatchallSeparators[0]
2545 } else {
2546 domain.LocalpartCatchallSeparators = localpartCatchallSeparators
2547 }
2548
2549 domain.LocalpartCaseSensitive = localpartCaseSensitive
2550 return nil
2551 })
2552 xcheckf(ctx, err, "saving localpart settings for domain")
2553}
2554
2555// DomainDMARCAddressSave saves the DMARC reporting address/processing
2556// configuration for a domain. If localpart is empty, processing reports is
2557// disabled.
2558func (Admin) DomainDMARCAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
2559 err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
2560 if localpart == "" {
2561 d.DMARC = nil
2562 } else {
2563 d.DMARC = &config.DMARC{
2564 Localpart: localpart,
2565 Domain: domain,
2566 Account: account,
2567 Mailbox: mailbox,
2568 }
2569 }
2570 return nil
2571 })
2572 xcheckf(ctx, err, "saving dmarc reporting address/settings for domain")
2573}
2574
2575// DomainTLSRPTAddressSave saves the TLS reporting address/processing
2576// configuration for a domain. If localpart is empty, processing reports is
2577// disabled.
2578func (Admin) DomainTLSRPTAddressSave(ctx context.Context, domainName, localpart, domain, account, mailbox string) {
2579 err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
2580 if localpart == "" {
2581 d.TLSRPT = nil
2582 } else {
2583 d.TLSRPT = &config.TLSRPT{
2584 Localpart: localpart,
2585 Domain: domain,
2586 Account: account,
2587 Mailbox: mailbox,
2588 }
2589 }
2590 return nil
2591 })
2592 xcheckf(ctx, err, "saving tls reporting address/settings for domain")
2593}
2594
2595// DomainMTASTSSave saves the MTASTS policy for a domain. If policyID is empty,
2596// no MTASTS policy is served.
2597func (Admin) DomainMTASTSSave(ctx context.Context, domainName, policyID string, mode mtasts.Mode, maxAge time.Duration, mx []string) {
2598 err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
2599 if policyID == "" {
2600 d.MTASTS = nil
2601 } else {
2602 d.MTASTS = &config.MTASTS{
2603 PolicyID: policyID,
2604 Mode: mode,
2605 MaxAge: maxAge,
2606 MX: mx,
2607 }
2608 }
2609 return nil
2610 })
2611 xcheckf(ctx, err, "saving mtasts policy for domain")
2612}
2613
2614// DomainDKIMAdd adds a DKIM selector for a domain, generating a new private
2615// key. The selector is not enabled for signing.
2616func (Admin) DomainDKIMAdd(ctx context.Context, domainName, selector, algorithm, hash string, headerRelaxed, bodyRelaxed, seal bool, headers []string, lifetime time.Duration) {
2617 d, err := dns.ParseDomain(domainName)
2618 xcheckuserf(ctx, err, "parsing domain")
2619 s, err := dns.ParseDomain(selector)
2620 xcheckuserf(ctx, err, "parsing selector")
2621 err = admin.DKIMAdd(ctx, d, s, algorithm, hash, headerRelaxed, bodyRelaxed, seal, headers, lifetime)
2622 xcheckf(ctx, err, "adding dkim key")
2623}
2624
2625// DomainDKIMRemove removes a DKIM selector for a domain.
2626func (Admin) DomainDKIMRemove(ctx context.Context, domainName, selector string) {
2627 d, err := dns.ParseDomain(domainName)
2628 xcheckuserf(ctx, err, "parsing domain")
2629 s, err := dns.ParseDomain(selector)
2630 xcheckuserf(ctx, err, "parsing selector")
2631 err = admin.DKIMRemove(ctx, d, s)
2632 xcheckf(ctx, err, "removing dkim key")
2633}
2634
2635// DomainDKIMSave saves the settings of selectors, and which to enable for
2636// signing, for a domain. All currently configured selectors must be present,
2637// selectors cannot be added/removed with this function.
2638func (Admin) DomainDKIMSave(ctx context.Context, domainName string, selectors map[string]config.Selector, sign []string) {
2639 for _, s := range sign {
2640 if _, ok := selectors[s]; !ok {
2641 xcheckuserf(ctx, fmt.Errorf("cannot sign unknown selector %q", s), "checking selectors")
2642 }
2643 }
2644
2645 err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
2646 if len(selectors) != len(d.DKIM.Selectors) {
2647 xcheckuserf(ctx, fmt.Errorf("cannot add/remove dkim selectors with this function"), "checking selectors")
2648 }
2649 for s := range selectors {
2650 if _, ok := d.DKIM.Selectors[s]; !ok {
2651 xcheckuserf(ctx, fmt.Errorf("unknown selector %q", s), "checking selectors")
2652 }
2653 }
2654 // At least the selectors are the same.
2655
2656 // Build up new selectors.
2657 sels := map[string]config.Selector{}
2658 for name, nsel := range selectors {
2659 osel := d.DKIM.Selectors[name]
2660 xsel := config.Selector{
2661 Hash: nsel.Hash,
2662 Canonicalization: nsel.Canonicalization,
2663 DontSealHeaders: nsel.DontSealHeaders,
2664 Expiration: nsel.Expiration,
2665
2666 PrivateKeyFile: osel.PrivateKeyFile,
2667 }
2668 if !slices.Equal(osel.HeadersEffective, nsel.Headers) {
2669 xsel.Headers = nsel.Headers
2670 }
2671 sels[name] = xsel
2672 }
2673
2674 // Enable the new selector settings.
2675 d.DKIM = config.DKIM{
2676 Selectors: sels,
2677 Sign: sign,
2678 }
2679 return nil
2680 })
2681 xcheckf(ctx, err, "saving dkim selector for domain")
2682}
2683
2684// DomainDisabledSave saves the Disabled field of a domain. A disabled domain
2685// rejects incoming/outgoing messages involving the domain and does not request new
2686// TLS certificats with ACME.
2687func (Admin) DomainDisabledSave(ctx context.Context, domainName string, disabled bool) {
2688 err := admin.DomainSave(ctx, domainName, func(d *config.Domain) error {
2689 d.Disabled = disabled
2690 return nil
2691 })
2692 xcheckf(ctx, err, "saving disabled setting for domain")
2693}
2694
2695func xparseAddress(ctx context.Context, lp, domain string) smtp.Address {
2696 xlp, err := smtp.ParseLocalpart(lp)
2697 xcheckuserf(ctx, err, "parsing localpart")
2698 d, err := dns.ParseDomain(domain)
2699 xcheckuserf(ctx, err, "parsing domain")
2700 return smtp.NewAddress(xlp, d)
2701}
2702
2703func (Admin) AliasAdd(ctx context.Context, aliaslp string, domainName string, alias config.Alias) {
2704 addr := xparseAddress(ctx, aliaslp, domainName)
2705 err := admin.AliasAdd(ctx, addr, alias)
2706 xcheckf(ctx, err, "adding alias")
2707}
2708
2709func (Admin) AliasUpdate(ctx context.Context, aliaslp string, domainName string, postPublic, listMembers, allowMsgFrom bool) {
2710 addr := xparseAddress(ctx, aliaslp, domainName)
2711 alias := config.Alias{
2712 PostPublic: postPublic,
2713 ListMembers: listMembers,
2714 AllowMsgFrom: allowMsgFrom,
2715 }
2716 err := admin.AliasUpdate(ctx, addr, alias)
2717 xcheckf(ctx, err, "saving alias")
2718}
2719
2720func (Admin) AliasRemove(ctx context.Context, aliaslp string, domainName string) {
2721 addr := xparseAddress(ctx, aliaslp, domainName)
2722 err := admin.AliasRemove(ctx, addr)
2723 xcheckf(ctx, err, "removing alias")
2724}
2725
2726func (Admin) AliasAddressesAdd(ctx context.Context, aliaslp string, domainName string, addresses []string) {
2727 addr := xparseAddress(ctx, aliaslp, domainName)
2728 err := admin.AliasAddressesAdd(ctx, addr, addresses)
2729 xcheckf(ctx, err, "adding address to alias")
2730}
2731
2732func (Admin) AliasAddressesRemove(ctx context.Context, aliaslp string, domainName string, addresses []string) {
2733 addr := xparseAddress(ctx, aliaslp, domainName)
2734 err := admin.AliasAddressesRemove(ctx, addr, addresses)
2735 xcheckf(ctx, err, "removing address from alias")
2736}
2737
2738func (Admin) TLSPublicKeys(ctx context.Context, accountOpt string) ([]store.TLSPublicKey, error) {
2739 return store.TLSPublicKeyList(ctx, accountOpt)
2740}
2741
2742func (Admin) LoginAttempts(ctx context.Context, accountName string, limit int) []store.LoginAttempt {
2743 l, err := store.LoginAttemptList(ctx, accountName, limit)
2744 xcheckf(ctx, err, "listing login attempts")
2745 return l
2746}
2747