could probably use threaded workers for trying both methods at the same time in the future.
+141
-3
src/internal/handle_resolver.zig
+141
-3
src/internal/handle_resolver.zig
···
15
15
pub const HandleResolver = struct {
16
16
allocator: std.mem.Allocator,
17
17
http_client: std.http.Client,
18
+
doh_endpoint: []const u8,
18
19
19
20
pub fn init(allocator: std.mem.Allocator) HandleResolver {
20
21
return .{
21
22
.allocator = allocator,
22
23
.http_client = .{ .allocator = allocator },
24
+
.doh_endpoint = "https://cloudflare-dns.com/dns-query",
23
25
};
24
26
}
25
27
···
29
31
30
32
/// resolve a handle to a DID via HTTP well-known
31
33
pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 {
32
-
return try self.resolveHttp(handle);
34
+
if (self.resolveHttp(handle)) |did| {
35
+
return did;
36
+
} else |_| {
37
+
return try self.resolveDns(handle);
38
+
}
33
39
}
34
40
35
41
/// resolve via HTTP at https://{handle}/.well-known/atproto-did
···
63
69
64
70
return try self.allocator.dupe(u8, did_str);
65
71
}
72
+
73
+
/// resolve via DoH default: https://cloudflare-dns.com/dns-query
74
+
pub fn resolveDns(self: *HandleResolver, handle: Handle) ![]const u8 {
75
+
const dns_name = try std.fmt.allocPrint(
76
+
self.allocator,
77
+
"_atproto.{s}",
78
+
.{handle.str()},
79
+
);
80
+
defer self.allocator.free(dns_name);
81
+
82
+
const url = try std.fmt.allocPrint(
83
+
self.allocator,
84
+
"{s}?name={s}&type=TXT",
85
+
.{ self.doh_endpoint, dns_name },
86
+
);
87
+
defer self.allocator.free(url);
88
+
89
+
var aw: std.io.Writer.Allocating = .init(self.allocator);
90
+
defer aw.deinit();
91
+
92
+
const result = self.http_client.fetch(.{
93
+
.location = .{ .url = url },
94
+
.extra_headers = &.{
95
+
.{ .name = "accept", .value = "application/dns-json" },
96
+
},
97
+
.response_writer = &aw.writer,
98
+
}) catch return error.DnsResolutionFailed;
99
+
100
+
if (result.status != .ok) {
101
+
return error.DnsResolutionFailed;
102
+
}
103
+
104
+
const response_body = aw.toArrayList().items;
105
+
const parsed = std.json.parseFromSlice(
106
+
DnsResponse,
107
+
self.allocator,
108
+
response_body,
109
+
.{},
110
+
) catch return error.InvalidDnsResponse;
111
+
defer parsed.deinit();
112
+
113
+
const dns_response = parsed.value;
114
+
if (dns_response.Answer == null or dns_response.Answer.?.len == 0) {
115
+
return error.NoDnsRecordsFound;
116
+
}
117
+
118
+
for (dns_response.Answer.?) |answer| {
119
+
const data = answer.data orelse continue;
120
+
const did_str = extractDidFromTxt(data) orelse continue;
121
+
122
+
if (Did.parse(did_str) != null) {
123
+
return try self.allocator.dupe(u8, did_str);
124
+
}
125
+
}
126
+
127
+
return error.NoValidDidFound;
128
+
}
129
+
};
130
+
131
+
fn extractDidFromTxt(txt_data: []const u8) ?[]const u8 {
132
+
var data = txt_data;
133
+
if (data.len >= 2 and data[0] == '"' and data[data.len - 1] == '"') {
134
+
data = data[1 .. data.len - 1];
135
+
}
136
+
137
+
const prefix = "did=";
138
+
if (std.mem.startsWith(u8, data, prefix)) {
139
+
return data[prefix.len..];
140
+
}
141
+
142
+
return null;
143
+
}
144
+
145
+
const DnsResponse = struct {
146
+
Status: i32,
147
+
TC: bool,
148
+
RD: bool,
149
+
RA: bool,
150
+
AD: bool,
151
+
CD: bool,
152
+
Question: ?[]Question = null,
153
+
Answer: ?[]Answer = null,
154
+
};
155
+
156
+
const Question = struct {
157
+
name: []const u8,
158
+
type: i32,
159
+
};
160
+
161
+
const Answer = struct {
162
+
name: []const u8,
163
+
type: i32,
164
+
TTL: i32,
165
+
data: ?[]const u8 = null,
66
166
};
67
167
68
168
// === integration tests ===
69
169
// these actually hit the network - run with: zig test src/internal/handle_resolver.zig
70
170
71
-
test "resolve handle - integration" {
171
+
test "resolve handle (http) - integration" {
72
172
// use arena for http client internals that may leak
73
173
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
74
174
defer arena.deinit();
···
78
178
79
179
// resolve a known handle that has .well-known/atproto-did
80
180
const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle;
81
-
const did = resolver.resolve(handle) catch |err| {
181
+
const did = resolver.resolveHttp(handle) catch |err| {
82
182
// network errors are ok in CI without network access
83
183
std.debug.print("network error (expected in some CI): {}\n", .{err});
84
184
return;
···
88
188
try std.testing.expect(Did.parse(did) != null);
89
189
try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:"));
90
190
}
191
+
192
+
test "resolve handle (dns over http) - integration" {
193
+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
194
+
defer arena.deinit();
195
+
196
+
var resolver = HandleResolver.init(arena.allocator());
197
+
defer resolver.deinit();
198
+
199
+
const handle = Handle.parse("seiso.moe") orelse return error.InvalidHandle;
200
+
const did = resolver.resolveDns(handle) catch |err| {
201
+
// network errors are ok in CI without network access
202
+
std.debug.print("network error (expected in some CI): {}\n", .{err});
203
+
return;
204
+
};
205
+
206
+
// should be a valid DID
207
+
try std.testing.expect(Did.parse(did) != null);
208
+
try std.testing.expect(std.mem.startsWith(u8, did, "did:"));
209
+
}
210
+
211
+
test "resolve handle - integration" {
212
+
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
213
+
defer arena.deinit();
214
+
215
+
var resolver = HandleResolver.init(arena.allocator());
216
+
defer resolver.deinit();
217
+
218
+
const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle;
219
+
const did = resolver.resolve(handle) catch |err| {
220
+
// network errors are ok in CI without network access
221
+
std.debug.print("network error (expected in some CI): {}\n", .{err});
222
+
return;
223
+
};
224
+
225
+
// should be a valid DID
226
+
try std.testing.expect(Did.parse(did) != null);
227
+
try std.testing.expect(std.mem.startsWith(u8, did, "did:"));
228
+
}
hell yea!
my maintainer nature wants to suggest that we try to keep zero dependence on a DNS provider like cloudflare and force users to choose one but i think this is good for now
thank you!