Skip to content

Commit 2f09976

Browse files
committed
quickstart: check if domain was registered recently, and warn about potential deliverability issues
we use 6 weeks as the cutoff, but this is fuzzy, and will vary by mail server/service provider. we check the domain age using RDAP, the replacement for whois. it is a relatively simple protocol, with HTTP/JSON requests. we fetch the "registration"-related events to look for a date of registration. RDAP is not available for all country-level TLDs, but is for most (all?) ICANN global top level domains. some random cctlds i noticed without rdap: .sh, .au, .io. the rdap implementation is very basic, only parsing the fields we need. we don't yet cache the dns registry bootstrap file from iana. we should once we use this functionality from the web interface, with more calls.
1 parent c7354cc commit 2f09976

File tree

5 files changed

+322
-3
lines changed

5 files changed

+322
-3
lines changed

doc.go

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ any parameters. Followed by the help and usage information for each command.
110110
mox dnsbl check zone ip
111111
mox dnsbl checkhealth zone
112112
mox mtasts lookup domain
113+
mox rdap domainage domain
113114
mox retrain [accountname]
114115
mox sendmail [-Fname] [ignoredflags] [-t] [<message]
115116
mox spf check domain ip
@@ -186,7 +187,7 @@ output of "mox config describe-domains" and see the output of
186187
-hostname string
187188
hostname mox will run on, by default the hostname of the machine quickstart runs on; if specified, the IPs for the hostname are configured for the public listener
188189
-skipdial
189-
skip check for outgoing smtp (port 25) connectivity
190+
skip check for outgoing smtp (port 25) connectivity or for domain age with rdap
190191
191192
# mox stop
192193
@@ -1456,6 +1457,24 @@ should be used, and how long the policy can be cached.
14561457
14571458
usage: mox mtasts lookup domain
14581459
1460+
# mox rdap domainage
1461+
1462+
Lookup the age of domain in RDAP based on latest registration.
1463+
1464+
RDAP is the registration data access protocol. Registries run RDAP services for
1465+
their top level domains, providing information such as the registration date of
1466+
domains. This command looks up the "age" of a domain by looking at the most
1467+
recent "registration", "reregistration" or "reinstantiation" event.
1468+
1469+
Email messages from recently registered domains are often treated with
1470+
suspicion, and some mail systems are more likely to classify them as junk.
1471+
1472+
On each invocation, a bootstrap file with a list of registries (of top-level
1473+
domains) is retrieved, without caching. Do not run this command too often with
1474+
automation.
1475+
1476+
usage: mox rdap domainage domain
1477+
14591478
# mox retrain
14601479
14611480
Recreate and retrain the junk filter for the account or all accounts.

main.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import (
6161
"github.com/mjl-/mox/mtasts"
6262
"github.com/mjl-/mox/publicsuffix"
6363
"github.com/mjl-/mox/queue"
64+
"github.com/mjl-/mox/rdap"
6465
"github.com/mjl-/mox/smtp"
6566
"github.com/mjl-/mox/smtpclient"
6667
"github.com/mjl-/mox/spf"
@@ -195,6 +196,7 @@ var commands = []struct {
195196
{"dnsbl check", cmdDNSBLCheck},
196197
{"dnsbl checkhealth", cmdDNSBLCheckhealth},
197198
{"mtasts lookup", cmdMTASTSLookup},
199+
{"rdap domainage", cmdRDAPDomainage},
198200
{"retrain", cmdRetrain},
199201
{"sendmail", cmdSendmail},
200202
{"spf check", cmdSPFCheck},
@@ -2988,6 +2990,51 @@ should be used, and how long the policy can be cached.
29882990
}
29892991
}
29902992

2993+
func cmdRDAPDomainage(c *cmd) {
2994+
c.params = "domain"
2995+
c.help = `Lookup the age of domain in RDAP based on latest registration.
2996+
2997+
RDAP is the registration data access protocol. Registries run RDAP services for
2998+
their top level domains, providing information such as the registration date of
2999+
domains. This command looks up the "age" of a domain by looking at the most
3000+
recent "registration", "reregistration" or "reinstantiation" event.
3001+
3002+
Email messages from recently registered domains are often treated with
3003+
suspicion, and some mail systems are more likely to classify them as junk.
3004+
3005+
On each invocation, a bootstrap file with a list of registries (of top-level
3006+
domains) is retrieved, without caching. Do not run this command too often with
3007+
automation.
3008+
`
3009+
args := c.Parse()
3010+
if len(args) != 1 {
3011+
c.Usage()
3012+
}
3013+
3014+
domain := xparseDomain(args[0], "domain")
3015+
3016+
registration, err := rdap.LookupLastDomainRegistration(context.Background(), domain)
3017+
xcheckf(err, "looking up domain in rdap")
3018+
3019+
age := time.Since(registration)
3020+
const day = 24 * time.Hour
3021+
const year = 365 * day
3022+
years := age / year
3023+
days := (age - years*year) / day
3024+
var s string
3025+
if years == 1 {
3026+
s = "1 year, "
3027+
} else if years > 0 {
3028+
s = fmt.Sprintf("%d years, ", years)
3029+
}
3030+
if days == 1 {
3031+
s += "1 day"
3032+
} else {
3033+
s += fmt.Sprintf("%d days", days)
3034+
}
3035+
fmt.Println(s)
3036+
}
3037+
29913038
func cmdRetrain(c *cmd) {
29923039
c.params = "[accountname]"
29933040
c.help = `Recreate and retrain the junk filter for the account or all accounts.

quickstart.go

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import (
3535
"github.com/mjl-/mox/dnsbl"
3636
"github.com/mjl-/mox/mlog"
3737
"github.com/mjl-/mox/mox-"
38+
"github.com/mjl-/mox/publicsuffix"
39+
"github.com/mjl-/mox/rdap"
3840
"github.com/mjl-/mox/smtp"
3941
"github.com/mjl-/mox/store"
4042
)
@@ -102,7 +104,7 @@ output of "mox config describe-domains" and see the output of
102104
var skipDial bool
103105
c.flag.BoolVar(&existingWebserver, "existing-webserver", false, "use if a webserver is already running, so mox won't listen on port 80 and 443; you'll have to provide tls certificates/keys, and configure the existing webserver as reverse proxy, forwarding requests to mox.")
104106
c.flag.StringVar(&hostname, "hostname", "", "hostname mox will run on, by default the hostname of the machine quickstart runs on; if specified, the IPs for the hostname are configured for the public listener")
105-
c.flag.BoolVar(&skipDial, "skipdial", false, "skip check for outgoing smtp (port 25) connectivity")
107+
c.flag.BoolVar(&skipDial, "skipdial", false, "skip check for outgoing smtp (port 25) connectivity or for domain age with rdap")
106108
args := c.Parse()
107109
if len(args) != 1 && len(args) != 2 {
108110
c.Usage()
@@ -582,8 +584,8 @@ messages over SMTP.
582584
}
583585
}
584586

585-
// Check outgoing SMTP connectivity.
586587
if !skipDial {
588+
// Check outgoing SMTP connectivity.
587589
fmt.Printf("Checking if outgoing smtp connections can be made by connecting to gmail.com mx on port 25...")
588590
mxctx, mxcancel := context.WithTimeout(context.Background(), 5*time.Second)
589591
mx, _, err := resolver.LookupMX(mxctx, "gmail.com.")
@@ -619,6 +621,41 @@ in mox.conf and use it in "Routes" in domains.conf. See
619621
620622
`)
621623
}
624+
625+
// Check if domain is recently registered.
626+
rdapctx, rdapcancel := context.WithTimeout(context.Background(), 10*time.Second)
627+
defer rdapcancel()
628+
orgdom := publicsuffix.Lookup(rdapctx, c.log.Logger, domain)
629+
fmt.Printf("\nChecking if domain %s was registered recently...", orgdom)
630+
registration, err := rdap.LookupLastDomainRegistration(rdapctx, orgdom)
631+
rdapcancel()
632+
if err != nil {
633+
fmt.Printf(" error: %s (continuing)\n\n", err)
634+
} else {
635+
age := time.Since(registration)
636+
const day = 24 * time.Hour
637+
const year = 365 * day
638+
years := age / year
639+
days := (age - years*year) / day
640+
var s string
641+
if years == 1 {
642+
s = "1 year, "
643+
} else if years > 0 {
644+
s = fmt.Sprintf("%d years, ", years)
645+
}
646+
if days == 1 {
647+
s += "1 day"
648+
} else {
649+
s += fmt.Sprintf("%d days", days)
650+
}
651+
fmt.Printf(" %s", s)
652+
// 6 weeks is a guess, mail servers/service providers will have different policies.
653+
if age < 6*7*day {
654+
fmt.Printf(" (recent!)\nWARNING: Mail servers may treat messages coming from recently registered domains\n(in the order of weeks to months) with suspicion, with higher probability of\nmessages being classified as junk.\n\n")
655+
} else {
656+
fmt.Printf(" OK\n\n")
657+
}
658+
}
622659
}
623660

624661
zones := []dns.Domain{

rdap/rdap.go

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// Package rdap is a basic client for checking the age of domains through RDAP.
2+
package rdap
3+
4+
import (
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"net/http"
11+
"sort"
12+
"strings"
13+
"time"
14+
15+
"github.com/mjl-/mox/dns"
16+
)
17+
18+
var ErrNoRegistration = errors.New("registration date not found")
19+
var ErrNoRDAP = errors.New("rdap not available for top-level domain")
20+
var ErrNoDomain = errors.New("domain not found in registry")
21+
var ErrSyntax = errors.New("bad rdap response syntax")
22+
23+
// https://www.iana.org/assignments/rdap-dns/rdap-dns.xhtml
24+
// ../rfc/9224:115
25+
const rdapBoostrapDNSURL = "https://data.iana.org/rdap/dns.json"
26+
27+
// Example data: ../rfc/9224:192
28+
29+
// Bootstrap data, parsed from JSON at the IANA DNS bootstrap URL.
30+
type Bootstrap struct {
31+
Version string `json:"version"` // Should be "1.0".
32+
Description string `json:"description"`
33+
Publication time.Time `json:"publication"` // RFC3339
34+
35+
// Each entry has two elements: First a list of TLDs, then a list of RDAP service
36+
// base URLs ending with a slash.
37+
Services [][2][]string `json:"services"`
38+
}
39+
40+
// todo: when using this more regularly in the admin web interface, store the iana bootstrap response in a database file, including cache-controle results (max-age it seems) and the etag, and do conditional requests when asking for a new version. same for lookups of domains at registries.
41+
42+
// LookupLastDomainRegistration looks up the most recent (re)registration of a
43+
// domain through RDAP.
44+
//
45+
// Not all TLDs have RDAP services yet at the time of writing.
46+
func LookupLastDomainRegistration(ctx context.Context, dom dns.Domain) (time.Time, error) {
47+
// ../rfc/9224:434 Against advice, we do not cache the bootstrap data. This is
48+
// currently used by the quickstart, which is run once, or run from the cli without
49+
// a place to keep state.
50+
req, err := http.NewRequestWithContext(ctx, "GET", rdapBoostrapDNSURL, nil)
51+
if err != nil {
52+
return time.Time{}, fmt.Errorf("new request for iana dns bootstrap data: %v", err)
53+
}
54+
// ../rfc/9224:588
55+
req.Header.Add("Accept", "application/json")
56+
resp, err := http.DefaultClient.Do(req)
57+
if err != nil {
58+
return time.Time{}, fmt.Errorf("http get of iana dns bootstrap data: %v", err)
59+
}
60+
defer resp.Body.Close()
61+
if resp.StatusCode/100 != 2 {
62+
return time.Time{}, fmt.Errorf("http get resulted in status %q, expected 200 ok", resp.Status)
63+
}
64+
var bootstrap Bootstrap
65+
if err := json.NewDecoder(resp.Body).Decode(&bootstrap); err != nil {
66+
return time.Time{}, fmt.Errorf("%w: parsing iana dns bootstrap data: %v", ErrSyntax, err)
67+
}
68+
69+
// Note: We don't verify version numbers. If the format change incompatibly,
70+
// decoding above would have failed. We'll try to work with what we got.
71+
72+
// ../rfc/9224:184 The bootstrap JSON has A-labels we must match against.
73+
// ../rfc/9224:188 Names are lower-case, like our dns.Domain.
74+
var urls []string
75+
var tldmatch string
76+
for _, svc := range bootstrap.Services {
77+
for _, s := range svc[0] {
78+
// ../rfc/9224:225 We match the longest domain suffix. In practice, there are
79+
// currently only single labels, top level domains, in the bootstrap database.
80+
if len(s) > len(tldmatch) && (s == dom.ASCII || strings.HasSuffix(dom.ASCII, "."+s)) {
81+
urls = svc[1]
82+
tldmatch = s
83+
}
84+
}
85+
}
86+
// ../rfc/9224:428
87+
if len(urls) == 0 {
88+
return time.Time{}, ErrNoRDAP
89+
}
90+
// ../rfc/9224:172 We must try secure transports before insecure (https before http). In practice, there is just a single https URL.
91+
sort.Slice(urls, func(i, j int) bool {
92+
return strings.HasPrefix(urls[i], "https://")
93+
})
94+
var lastErr error
95+
for _, u := range urls {
96+
var reg time.Time
97+
reg, lastErr = rdapDomainRequest(ctx, u, dom)
98+
if lastErr == nil {
99+
return reg, nil
100+
}
101+
}
102+
return time.Time{}, lastErr
103+
}
104+
105+
// ../rfc/9083:284 We must match json fields case-sensitively, so explicitly.
106+
// Example domain object: ../rfc/9083:945
107+
108+
// Domain is the RDAP response for a domain request.
109+
//
110+
// More fields are available in RDAP responses, we only parse the one(s) a few.
111+
type Domain struct {
112+
// ../rfc/9083:1172
113+
114+
RDAPConformance []string `json:"rdapConformance"` // E.g. "rdap_level_0"
115+
LDHName string `json:"ldhName"` // Domain.
116+
Events []Event `json:"events"`
117+
}
118+
119+
// Event is a historic or future change to the domain.
120+
type Event struct {
121+
// ../rfc/9083:573
122+
123+
EventAction string `json:"eventAction"` // Required. See https://www.iana.org/assignments/rdap-json-values/rdap-json-values.xhtml.
124+
EventDate time.Time `json:"eventDate"` // Required. RFC3339. May be in the future, e.g. date of expiry.
125+
}
126+
127+
// rdapDomainRequest looks up a the most recent registration time of a at an RDAP
128+
// service base URL.
129+
func rdapDomainRequest(ctx context.Context, rdapURL string, dom dns.Domain) (time.Time, error) {
130+
// ../rfc/9082:316
131+
// ../rfc/9224:177 base URLs have a trailing slash.
132+
rdapURL += "domain/" + dom.ASCII
133+
req, err := http.NewRequestWithContext(ctx, "GET", rdapURL, nil)
134+
if err != nil {
135+
return time.Time{}, fmt.Errorf("making http request for rdap service: %v", err)
136+
}
137+
// ../rfc/9083:2372 ../rfc/7480:273
138+
req.Header.Add("Accept", "application/rdap+json")
139+
// ../rfc/7480:319 Redirects are handled by net/http.
140+
resp, err := http.DefaultClient.Do(req)
141+
if err != nil {
142+
return time.Time{}, fmt.Errorf("http domain rdap get request: %v", err)
143+
}
144+
defer resp.Body.Close()
145+
146+
switch {
147+
case resp.StatusCode == http.StatusNotFound:
148+
// ../rfc/7480:189 ../rfc/7480:359
149+
return time.Time{}, ErrNoDomain
150+
151+
case resp.StatusCode/100 != 2:
152+
// We try to read an error message, perhaps a bit too hard, but we may still
153+
// truncate utf-8 in the middle of a rune...
154+
var msg string
155+
var response struct {
156+
// For errors, optional fields.
157+
Title string `json:"title"`
158+
Description []string `json:"description"`
159+
// ../rfc/9083:2123
160+
}
161+
buf, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
162+
if err != nil {
163+
msg = fmt.Sprintf("(error reading response: %v)", err)
164+
} else if err := json.Unmarshal(buf, &response); err == nil && (response.Title != "" || len(response.Description) > 0) {
165+
s := response.Title
166+
if s != "" && len(response.Description) > 0 {
167+
s += "; "
168+
}
169+
s += strings.Join(response.Description, " ")
170+
if len(s) > 200 {
171+
s = s[:150] + "..."
172+
}
173+
msg = fmt.Sprintf("message from remote: %q", s)
174+
} else {
175+
var s string
176+
if len(buf) > 200 {
177+
s = string(buf[:150]) + "..."
178+
} else {
179+
s = string(buf)
180+
}
181+
msg = fmt.Sprintf("raw response: %q", s)
182+
}
183+
return time.Time{}, fmt.Errorf("status %q, expected 200 ok: %s", resp.Status, msg)
184+
}
185+
186+
var domain Domain
187+
if err := json.NewDecoder(resp.Body).Decode(&domain); err != nil {
188+
return time.Time{}, fmt.Errorf("parse domain rdap response: %v", err)
189+
}
190+
191+
sort.Slice(domain.Events, func(i, j int) bool {
192+
return domain.Events[i].EventDate.Before(domain.Events[j].EventDate)
193+
})
194+
195+
now := time.Now()
196+
for i := len(domain.Events) - 1; i >= 0; i-- {
197+
ev := domain.Events[i]
198+
if ev.EventDate.After(now) {
199+
continue
200+
}
201+
switch ev.EventAction {
202+
// ../rfc/9083:2690
203+
case "registration", "reregistration", "reinstantiation":
204+
return ev.EventDate, nil
205+
}
206+
}
207+
return time.Time{}, ErrNoRegistration
208+
}

0 commit comments

Comments
 (0)