1package identity
2
3import (
4 "context"
5 "errors"
6 "fmt"
7 "io"
8 "log/slog"
9 "net"
10 "net/http"
11 "strings"
12 "time"
13
14 "github.com/bluesky-social/indigo/atproto/syntax"
15)
16
17func parseTXTResp(res []string) (syntax.DID, error) {
18 for _, s := range res {
19 if strings.HasPrefix(s, "did=") {
20 parts := strings.SplitN(s, "=", 2)
21 did, err := syntax.ParseDID(parts[1])
22 if err != nil {
23 return "", fmt.Errorf("%w: invalid DID in handle DNS record: %w", ErrHandleResolutionFailed, err)
24 }
25 return did, nil
26 }
27 }
28 return "", ErrHandleNotFound
29}
30
31// Does not cross-verify, only does the handle resolution step.
32func (d *BaseDirectory) ResolveHandleDNS(ctx context.Context, handle syntax.Handle) (syntax.DID, error) {
33 res, err := d.Resolver.LookupTXT(ctx, "_atproto."+handle.String())
34 // check for NXDOMAIN
35 var dnsErr *net.DNSError
36 if errors.As(err, &dnsErr) {
37 if dnsErr.IsNotFound {
38 return "", fmt.Errorf("%w: %s", ErrHandleNotFound, handle)
39 }
40 }
41 if err != nil {
42 return "", fmt.Errorf("%w: %w", ErrHandleResolutionFailed, err)
43 }
44 return parseTXTResp(res)
45}
46
47// this is a variant of ResolveHandleDNS which first does an authoritative nameserver lookup, then queries there
48func (d *BaseDirectory) ResolveHandleDNSAuthoritative(ctx context.Context, handle syntax.Handle) (syntax.DID, error) {
49 // lookup nameserver using configured resolver
50 resNS, err := d.Resolver.LookupNS(ctx, handle.String())
51 // check for NXDOMAIN
52 var dnsErr *net.DNSError
53 if errors.As(err, &dnsErr) {
54 if dnsErr.IsNotFound {
55 return "", ErrHandleNotFound
56 }
57 }
58 if err != nil {
59 return "", fmt.Errorf("%w: DNS error: %w", ErrHandleResolutionFailed, err)
60 }
61 if len(resNS) == 0 {
62 return "", ErrHandleNotFound
63 }
64 ns := resNS[0].Host
65 if !strings.Contains(ns, ":") {
66 ns = ns + ":53"
67 }
68
69 // create a custom resolver to use the specific nameserver for TXT lookup
70 resolver := &net.Resolver{
71 PreferGo: true,
72 Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
73 rd := net.Dialer{
74 Timeout: time.Second * 5,
75 }
76 return rd.DialContext(ctx, network, ns)
77 },
78 }
79 res, err := resolver.LookupTXT(ctx, "_atproto."+handle.String())
80 // check for NXDOMAIN
81 if errors.As(err, &dnsErr) {
82 if dnsErr.IsNotFound {
83 return "", ErrHandleNotFound
84 }
85 }
86 if err != nil {
87 return "", fmt.Errorf("%w: DNS resolution failed: %w", ErrHandleResolutionFailed, err)
88 }
89 return parseTXTResp(res)
90}
91
92// variant of ResolveHandleDNS which uses any configured fallback DNS servers
93func (d *BaseDirectory) ResolveHandleDNSFallback(ctx context.Context, handle syntax.Handle) (syntax.DID, error) {
94 retErr := fmt.Errorf("no fallback servers configured")
95 var dnsErr *net.DNSError
96 for _, ns := range d.FallbackDNSServers {
97 // create a custom resolver to use the specific nameserver for TXT lookup
98 resolver := &net.Resolver{
99 PreferGo: true,
100 Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
101 rd := net.Dialer{
102 Timeout: time.Second * 5,
103 }
104 return rd.DialContext(ctx, network, ns)
105 },
106 }
107 res, err := resolver.LookupTXT(ctx, "_atproto."+handle.String())
108 // check for NXDOMAIN
109 if errors.As(err, &dnsErr) {
110 if dnsErr.IsNotFound {
111 retErr = ErrHandleNotFound
112 continue
113 }
114 }
115 if err != nil {
116 retErr = fmt.Errorf("%w: %w", ErrHandleResolutionFailed, err)
117 continue
118 }
119 ret, err := parseTXTResp(res)
120 if err != nil {
121 retErr = err
122 continue
123 }
124 return ret, nil
125 }
126 return "", retErr
127}
128
129func (d *BaseDirectory) ResolveHandleWellKnown(ctx context.Context, handle syntax.Handle) (syntax.DID, error) {
130 req, err := http.NewRequestWithContext(ctx, "GET", fmt.Sprintf("https://%s/.well-known/atproto-did", handle), nil)
131 if err != nil {
132 return "", fmt.Errorf("constructing HTTP request for handle resolution: %w", err)
133 }
134 if d.UserAgent != "" {
135 req.Header.Set("User-Agent", d.UserAgent)
136 }
137
138 resp, err := d.HTTPClient.Do(req)
139 if err != nil {
140 // check for NXDOMAIN
141 var dnsErr *net.DNSError
142 if errors.As(err, &dnsErr) {
143 if dnsErr.IsNotFound {
144 return "", fmt.Errorf("%w: DNS NXDOMAIN for HTTP well-known resolution of %s", ErrHandleNotFound, handle)
145 }
146 }
147 return "", fmt.Errorf("%w: HTTP well-known request error: %w", ErrHandleResolutionFailed, err)
148 }
149 defer resp.Body.Close()
150 if resp.ContentLength > 2048 {
151 // NOTE: intentionally not draining body
152 return "", fmt.Errorf("%w: HTTP well-known body too large for %s", ErrHandleResolutionFailed, handle)
153 }
154 if resp.StatusCode == http.StatusNotFound {
155 io.Copy(io.Discard, resp.Body)
156 return "", fmt.Errorf("%w: HTTP 404 for %s", ErrHandleNotFound, handle)
157 }
158 if resp.StatusCode != http.StatusOK {
159 io.Copy(io.Discard, resp.Body)
160 return "", fmt.Errorf("%w: HTTP well-known status %d for %s", ErrHandleResolutionFailed, resp.StatusCode, handle)
161 }
162
163 b, err := io.ReadAll(io.LimitReader(resp.Body, 2048))
164 if err != nil {
165 return "", fmt.Errorf("%w: HTTP well-known body read for %s: %w", ErrHandleResolutionFailed, handle, err)
166 }
167 line := strings.TrimSpace(string(b))
168 outDid, err := syntax.ParseDID(line)
169 if err != nil {
170 return outDid, fmt.Errorf("%w: invalid DID in HTTP well-known for %s", ErrHandleResolutionFailed, handle)
171 }
172 return outDid, err
173}
174
175func (d *BaseDirectory) ResolveHandle(ctx context.Context, handle syntax.Handle) (syntax.DID, error) {
176 // TODO: *could* do resolution in parallel, but expecting that sequential is sufficient to start
177 var dnsErr error
178 var did syntax.DID
179
180 handle = handle.Normalize()
181
182 if handle.IsInvalidHandle() {
183 return "", fmt.Errorf("can not resolve handle: %w", ErrInvalidHandle)
184 }
185
186 if !handle.AllowedTLD() {
187 return "", ErrHandleReservedTLD
188 }
189
190 tryDNS := true
191 for _, suffix := range d.SkipDNSDomainSuffixes {
192 if strings.HasSuffix(handle.String(), suffix) {
193 tryDNS = false
194 break
195 }
196 }
197
198 if tryDNS {
199 start := time.Now()
200 triedAuthoritative := false
201 triedFallback := false
202 did, dnsErr = d.ResolveHandleDNS(ctx, handle)
203 if errors.Is(dnsErr, ErrHandleNotFound) && d.TryAuthoritativeDNS {
204 slog.Debug("attempting authoritative handle DNS resolution", "handle", handle)
205 triedAuthoritative = true
206 // try harder with authoritative lookup
207 did, dnsErr = d.ResolveHandleDNSAuthoritative(ctx, handle)
208 }
209 if errors.Is(dnsErr, ErrHandleNotFound) && len(d.FallbackDNSServers) > 0 {
210 slog.Debug("attempting fallback DNS resolution", "handle", handle)
211 triedFallback = true
212 // try harder with fallback lookup
213 did, dnsErr = d.ResolveHandleDNSFallback(ctx, handle)
214 }
215 elapsed := time.Since(start)
216 slog.Debug("resolve handle DNS", "handle", handle, "err", dnsErr, "did", did, "authoritative", triedAuthoritative, "fallback", triedFallback, "duration_ms", elapsed.Milliseconds())
217 if nil == dnsErr { // if *not* an error
218 return did, nil
219 }
220 }
221
222 start := time.Now()
223 did, httpErr := d.ResolveHandleWellKnown(ctx, handle)
224 elapsed := time.Since(start)
225 slog.Debug("resolve handle HTTP well-known", "handle", handle, "err", httpErr, "did", did, "duration_ms", elapsed.Milliseconds())
226 if nil == httpErr { // if *not* an error
227 return did, nil
228 }
229
230 // return the most specific/helpful error
231 if !errors.Is(dnsErr, ErrHandleNotFound) {
232 return "", dnsErr
233 }
234 if !errors.Is(httpErr, ErrHandleNotFound) {
235 return "", httpErr
236 }
237 return "", dnsErr
238}