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