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