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