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