atproto tools in zig zat.dev
sdk atproto zig

Add DNS did resolving via DoH #1

merged opened by seiso.moe targeting main

could probably use threaded workers for trying both methods at the same time in the future.

Labels

None yet.

assignee

None yet.

Participants 2
AT URI
at://did:plc:adtzorbhmmjbzxsl2y4vqlqs/sh.tangled.repo.pull/3mbpuzewgan22
+141 -3
Diff #0
+141 -3
src/internal/handle_resolver.zig
··· 15 pub const HandleResolver = struct { 16 allocator: std.mem.Allocator, 17 http_client: std.http.Client, 18 19 pub fn init(allocator: std.mem.Allocator) HandleResolver { 20 return .{ 21 .allocator = allocator, 22 .http_client = .{ .allocator = allocator }, 23 }; 24 } 25 ··· 29 30 /// resolve a handle to a DID via HTTP well-known 31 pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 { 32 - return try self.resolveHttp(handle); 33 } 34 35 /// resolve via HTTP at https://{handle}/.well-known/atproto-did ··· 63 64 return try self.allocator.dupe(u8, did_str); 65 } 66 }; 67 68 // === integration tests === 69 // these actually hit the network - run with: zig test src/internal/handle_resolver.zig 70 71 - test "resolve handle - integration" { 72 // use arena for http client internals that may leak 73 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 74 defer arena.deinit(); ··· 78 79 // resolve a known handle that has .well-known/atproto-did 80 const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; 81 - const did = resolver.resolve(handle) catch |err| { 82 // network errors are ok in CI without network access 83 std.debug.print("network error (expected in some CI): {}\n", .{err}); 84 return; ··· 88 try std.testing.expect(Did.parse(did) != null); 89 try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:")); 90 }
··· 15 pub const HandleResolver = struct { 16 allocator: std.mem.Allocator, 17 http_client: std.http.Client, 18 + doh_endpoint: []const u8, 19 20 pub fn init(allocator: std.mem.Allocator) HandleResolver { 21 return .{ 22 .allocator = allocator, 23 .http_client = .{ .allocator = allocator }, 24 + .doh_endpoint = "https://cloudflare-dns.com/dns-query", 25 }; 26 } 27 ··· 31 32 /// resolve a handle to a DID via HTTP well-known 33 pub fn resolve(self: *HandleResolver, handle: Handle) ![]const u8 { 34 + if (self.resolveHttp(handle)) |did| { 35 + return did; 36 + } else |_| { 37 + return try self.resolveDns(handle); 38 + } 39 } 40 41 /// resolve via HTTP at https://{handle}/.well-known/atproto-did ··· 69 70 return try self.allocator.dupe(u8, did_str); 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, 166 }; 167 168 // === integration tests === 169 // these actually hit the network - run with: zig test src/internal/handle_resolver.zig 170 171 + test "resolve handle (http) - integration" { 172 // use arena for http client internals that may leak 173 var arena = std.heap.ArenaAllocator.init(std.testing.allocator); 174 defer arena.deinit(); ··· 178 179 // resolve a known handle that has .well-known/atproto-did 180 const handle = Handle.parse("jay.bsky.social") orelse return error.InvalidHandle; 181 + const did = resolver.resolveHttp(handle) catch |err| { 182 // network errors are ok in CI without network access 183 std.debug.print("network error (expected in some CI): {}\n", .{err}); 184 return; ··· 188 try std.testing.expect(Did.parse(did) != null); 189 try std.testing.expect(std.mem.startsWith(u8, did, "did:plc:")); 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 + }

History

1 round 1 comment
sign up or login to add to the discussion
seiso.moe submitted #0
expand 1 comment

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!

pull request successfully merged