1// Package rdap is a basic client for checking the age of domains through RDAP.
16 "github.com/mjl-/mox/dns"
17 "github.com/mjl-/mox/mlog"
20var ErrNoRegistration = errors.New("registration date not found")
21var ErrNoRDAP = errors.New("rdap not available for top-level domain")
22var ErrNoDomain = errors.New("domain not found in registry")
23var ErrSyntax = errors.New("bad rdap response syntax")
25// https://www.iana.org/assignments/rdap-dns/rdap-dns.xhtml
27const rdapBoostrapDNSURL = "https://data.iana.org/rdap/dns.json"
31// Bootstrap data, parsed from JSON at the IANA DNS bootstrap URL.
32type Bootstrap struct {
33 Version string `json:"version"` // Should be "1.0".
34 Description string `json:"description"`
35 Publication time.Time `json:"publication"` // RFC3339
37 // Each entry has two elements: First a list of TLDs, then a list of RDAP service
38 // base URLs ending with a slash.
39 Services [][2][]string `json:"services"`
42// 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.
44// LookupLastDomainRegistration looks up the most recent (re)registration of a
45// domain through RDAP.
47// Not all TLDs have RDAP services yet at the time of writing.
48func LookupLastDomainRegistration(ctx context.Context, log mlog.Log, dom dns.Domain) (time.Time, error) {
50 // currently used by the quickstart, which is run once, or run from the cli without
51 // a place to keep state.
52 req, err := http.NewRequestWithContext(ctx, "GET", rdapBoostrapDNSURL, nil)
54 return time.Time{}, fmt.Errorf("new request for iana dns bootstrap data: %v", err)
57 req.Header.Add("Accept", "application/json")
58 resp, err := http.DefaultClient.Do(req)
60 return time.Time{}, fmt.Errorf("http get of iana dns bootstrap data: %v", err)
63 err := resp.Body.Close()
64 log.Check(err, "closing http response body")
66 if resp.StatusCode/100 != 2 {
67 return time.Time{}, fmt.Errorf("http get resulted in status %q, expected 200 ok", resp.Status)
69 var bootstrap Bootstrap
70 if err := json.NewDecoder(resp.Body).Decode(&bootstrap); err != nil {
71 return time.Time{}, fmt.Errorf("%w: parsing iana dns bootstrap data: %v", ErrSyntax, err)
74 // Note: We don't verify version numbers. If the format change incompatibly,
75 // decoding above would have failed. We'll try to work with what we got.
81 for _, svc := range bootstrap.Services {
82 for _, s := range svc[0] {
84 // currently only single labels, top level domains, in the bootstrap database.
85 if len(s) > len(tldmatch) && (s == dom.ASCII || strings.HasSuffix(dom.ASCII, "."+s)) {
93 return time.Time{}, ErrNoRDAP
95 //
../rfc/9224:172 We must try secure transports before insecure (https before http). In practice, there is just a single https URL.
96 sort.Slice(urls, func(i, j int) bool {
97 return strings.HasPrefix(urls[i], "https://")
100 for _, u := range urls {
102 reg, lastErr = rdapDomainRequest(ctx, log, u, dom)
107 return time.Time{}, lastErr
113// Domain is the RDAP response for a domain request.
115// More fields are available in RDAP responses, we only parse the one(s) a few.
119 RDAPConformance []string `json:"rdapConformance"` // E.g. "rdap_level_0"
120 LDHName string `json:"ldhName"` // Domain.
121 Events []Event `json:"events"`
124// Event is a historic or future change to the domain.
128 EventAction string `json:"eventAction"` // Required. See https://www.iana.org/assignments/rdap-json-values/rdap-json-values.xhtml.
129 EventDate time.Time `json:"eventDate"` // Required. RFC3339. May be in the future, e.g. date of expiry.
132// rdapDomainRequest looks up a the most recent registration time of a at an RDAP
134func rdapDomainRequest(ctx context.Context, log mlog.Log, rdapURL string, dom dns.Domain) (time.Time, error) {
137 rdapURL += "domain/" + dom.ASCII
138 req, err := http.NewRequestWithContext(ctx, "GET", rdapURL, nil)
140 return time.Time{}, fmt.Errorf("making http request for rdap service: %v", err)
143 req.Header.Add("Accept", "application/rdap+json")
145 resp, err := http.DefaultClient.Do(req)
147 return time.Time{}, fmt.Errorf("http domain rdap get request: %v", err)
150 err := resp.Body.Close()
151 log.Check(err, "closing http response body")
155 case resp.StatusCode == http.StatusNotFound:
157 return time.Time{}, ErrNoDomain
159 case resp.StatusCode/100 != 2:
160 // We try to read an error message, perhaps a bit too hard, but we may still
161 // truncate utf-8 in the middle of a rune...
163 var response struct {
164 // For errors, optional fields.
165 Title string `json:"title"`
166 Description []string `json:"description"`
169 buf, err := io.ReadAll(io.LimitReader(resp.Body, 100*1024))
171 msg = fmt.Sprintf("(error reading response: %v)", err)
172 } else if err := json.Unmarshal(buf, &response); err == nil && (response.Title != "" || len(response.Description) > 0) {
174 if s != "" && len(response.Description) > 0 {
177 s += strings.Join(response.Description, " ")
181 msg = fmt.Sprintf("message from remote: %q", s)
185 s = string(buf[:150]) + "..."
189 msg = fmt.Sprintf("raw response: %q", s)
191 return time.Time{}, fmt.Errorf("status %q, expected 200 ok: %s", resp.Status, msg)
195 if err := json.NewDecoder(resp.Body).Decode(&domain); err != nil {
196 return time.Time{}, fmt.Errorf("parse domain rdap response: %v", err)
199 sort.Slice(domain.Events, func(i, j int) bool {
200 return domain.Events[i].EventDate.Before(domain.Events[j].EventDate)
204 for _, ev := range slices.Backward(domain.Events) {
206 if ev.EventDate.After(now) {
209 switch ev.EventAction {
211 case "registration", "reregistration", "reinstantiation":
212 return ev.EventDate, nil
215 return time.Time{}, ErrNoRegistration