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