@@ -22,37 +22,162 @@ function isValidIpFormat(address: string, family: 4 | 6): boolean {
2222 return IPV6_RE . test ( address ) && address . includes ( ':' )
2323}
2424
25- interface DnsAnswer {
25+ // ── DNS wire format helpers (RFC 1035 / RFC 8484) ───────────────────
26+
27+ const DNS_HEADER_SIZE = 12
28+ const DNS_TYPE_A = 1
29+ const DNS_TYPE_AAAA = 28
30+ const DNS_CLASS_IN = 1
31+ const DNS_POINTER_MASK = 0xc0
32+
33+ /** Encode a hostname as DNS wire-format labels (length-prefixed, null-terminated). */
34+ function encodeLabels ( hostname : string ) : Uint8Array {
35+ const labels = hostname . split ( '.' )
36+ let totalLen = 1 // null terminator
37+ for ( const label of labels ) totalLen += 1 + label . length
38+
39+ const buf = new Uint8Array ( totalLen )
40+ let offset = 0
41+ for ( const label of labels ) {
42+ buf [ offset ++ ] = label . length
43+ for ( let i = 0 ; i < label . length ; i ++ ) {
44+ buf [ offset ++ ] = label . charCodeAt ( i )
45+ }
46+ }
47+ buf [ offset ] = 0 // root label
48+ return buf
49+ }
50+
51+ /** Build a minimal DNS query packet for the given hostname and record type. */
52+ function buildQuery ( hostname : string , qtype : number ) : Uint8Array {
53+ const labels = encodeLabels ( hostname )
54+ const packet = new Uint8Array ( DNS_HEADER_SIZE + labels . length + 4 )
55+ const view = new DataView ( packet . buffer )
56+
57+ // Header: ID=0, flags=0x0100 (RD=1), QDCOUNT=1
58+ view . setUint16 ( 0 , 0 ) // ID
59+ view . setUint16 ( 2 , 0x0100 ) // Flags: recursion desired
60+ view . setUint16 ( 4 , 1 ) // QDCOUNT
61+ view . setUint16 ( 6 , 0 ) // ANCOUNT
62+ view . setUint16 ( 8 , 0 ) // NSCOUNT
63+ view . setUint16 ( 10 , 0 ) // ARCOUNT
64+
65+ // Question section
66+ packet . set ( labels , DNS_HEADER_SIZE )
67+ const typeOffset = DNS_HEADER_SIZE + labels . length
68+ view . setUint16 ( typeOffset , qtype )
69+ view . setUint16 ( typeOffset + 2 , DNS_CLASS_IN )
70+
71+ return packet
72+ }
73+
74+ /** Skip a DNS name in wire format (handles compression pointers). */
75+ function skipName ( data : Uint8Array , offset : number ) : number {
76+ while ( offset < data . length ) {
77+ const len = data [ offset ]
78+ if ( len === 0 ) return offset + 1
79+ if ( ( len & DNS_POINTER_MASK ) === DNS_POINTER_MASK ) return offset + 2
80+ offset += len + 1
81+ }
82+ throw new Error ( 'Malformed DNS name' )
83+ }
84+
85+ interface ParsedRecord {
2686 type : number
2787 data : string
2888}
2989
30- interface DnsResponse {
31- Answer ?: DnsAnswer [ ]
90+ /** Parse a wire-format DNS response, extracting A and AAAA records. */
91+ function parseResponse ( data : Uint8Array ) : ParsedRecord [ ] {
92+ if ( data . length < DNS_HEADER_SIZE ) throw new Error ( 'DNS response too short' )
93+
94+ const view = new DataView ( data . buffer , data . byteOffset , data . byteLength )
95+ const flags = view . getUint16 ( 2 )
96+ const rcode = flags & 0xf
97+
98+ if ( rcode !== 0 ) {
99+ const label = rcode === 3 ? 'NXDOMAIN' : rcode === 2 ? 'SERVFAIL' : `RCODE_${ rcode } `
100+ throw new Error ( `DNS resolution failed: ${ label } ` )
101+ }
102+
103+ const ancount = view . getUint16 ( 6 )
104+ if ( ancount === 0 ) return [ ]
105+
106+ // Skip question section
107+ let offset = DNS_HEADER_SIZE
108+ const qdcount = view . getUint16 ( 4 )
109+ for ( let i = 0 ; i < qdcount ; i ++ ) {
110+ offset = skipName ( data , offset )
111+ offset += 4 // QTYPE + QCLASS
112+ }
113+
114+ // Parse answer section
115+ const records : ParsedRecord [ ] = [ ]
116+ for ( let i = 0 ; i < ancount && offset < data . length ; i ++ ) {
117+ offset = skipName ( data , offset )
118+ if ( offset + 10 > data . length ) break
119+
120+ const rtype = view . getUint16 ( offset )
121+ offset += 4 // TYPE + CLASS
122+ offset += 4 // TTL
123+ const rdlen = view . getUint16 ( offset )
124+ offset += 2
125+
126+ if ( offset + rdlen > data . length ) break
127+
128+ if ( rtype === DNS_TYPE_A && rdlen === 4 ) {
129+ const ip = `${ data [ offset ] } .${ data [ offset + 1 ] } .${ data [ offset + 2 ] } .${ data [ offset + 3 ] } `
130+ records . push ( { type : DNS_TYPE_A , data : ip } )
131+ } else if ( rtype === DNS_TYPE_AAAA && rdlen === 16 ) {
132+ const groups : string [ ] = [ ]
133+ for ( let g = 0 ; g < 16 ; g += 2 ) {
134+ groups . push ( view . getUint16 ( offset + g ) . toString ( 16 ) )
135+ }
136+ records . push ( { type : DNS_TYPE_AAAA , data : groups . join ( ':' ) } )
137+ }
138+
139+ offset += rdlen
140+ }
141+
142+ return records
143+ }
144+
145+ // ── Base64url encoding ──────────────────────────────────────────────
146+
147+ function toBase64url ( data : Uint8Array ) : string {
148+ const base64 = Buffer . from ( data ) . toString ( 'base64' )
149+ return base64 . replace ( / \+ / g, '-' ) . replace ( / \/ / g, '_' ) . replace ( / = + $ / , '' )
32150}
33151
152+ // ── Public API ──────────────────────────────────────────────────────
153+
34154async function queryDns (
35155 hostname : string ,
36156 gatewayUrl : string ,
37- type : 'A' | 'AAAA' ,
157+ qtype : number ,
38158 signal : AbortSignal ,
39- ) : Promise < DnsAnswer [ ] > {
40- const url = `${ gatewayUrl } dns-query?name=${ encodeURIComponent ( hostname ) } &type=${ type } `
159+ ) : Promise < ParsedRecord [ ] > {
160+ const wire = buildQuery ( hostname , qtype )
161+ const encoded = toBase64url ( wire )
162+ const url = `${ gatewayUrl } dns-query?dns=${ encoded } `
41163 const response = await fetch ( url , {
42- headers : { Accept : 'application/dns-json ' } ,
164+ headers : { Accept : 'application/dns-message ' } ,
43165 signal,
44- redirect : 'error' , // Prevent gateway redirects to internal services
166+ redirect : 'error' ,
45167 } )
46168 if ( ! response . ok ) {
47- throw new Error ( `DNS query failed: HTTP ${ response . status } for ${ hostname } ( ${ type } ) ` )
169+ throw new Error ( `DNS query failed: HTTP ${ response . status } for ${ hostname } ` )
48170 }
49- const data = ( await response . json ( ) ) as DnsResponse
50- return data . Answer ?? [ ]
171+ const buf = await response . arrayBuffer ( )
172+ return parseResponse ( new Uint8Array ( buf ) )
51173}
52174
53175/**
54176 * Resolve a Handshake (HNS) hostname via a DNS-over-HTTPS gateway.
55177 *
178+ * Uses RFC 8484 wire-format DoH (GET with ?dns= parameter), which is
179+ * supported by the default HDNS gateway (query.hdns.io).
180+ *
56181 * Tries an A record first; falls back to AAAA if no A records are returned.
57182 * Throws if neither resolves, the gateway returns an error, or the request
58183 * times out.
@@ -67,8 +192,8 @@ export async function resolveHns(
67192
68193 try {
69194 // Try A record first
70- const aAnswers = await queryDns ( hostname , gatewayUrl , 'A' , controller . signal )
71- const aRecord = aAnswers . find ( a => a . type === 1 )
195+ const aRecords = await queryDns ( hostname , gatewayUrl , DNS_TYPE_A , controller . signal )
196+ const aRecord = aRecords . find ( r => r . type === DNS_TYPE_A )
72197 if ( aRecord ) {
73198 if ( ! isValidIpFormat ( aRecord . data , 4 ) ) {
74199 throw new Error ( `HNS gateway returned invalid IPv4 address for ${ hostname } ` )
@@ -77,8 +202,8 @@ export async function resolveHns(
77202 }
78203
79204 // Fall back to AAAA
80- const aaaaAnswers = await queryDns ( hostname , gatewayUrl , 'AAAA' , controller . signal )
81- const aaaaRecord = aaaaAnswers . find ( a => a . type === 28 )
205+ const aaaaRecords = await queryDns ( hostname , gatewayUrl , DNS_TYPE_AAAA , controller . signal )
206+ const aaaaRecord = aaaaRecords . find ( r => r . type === DNS_TYPE_AAAA )
82207 if ( aaaaRecord ) {
83208 if ( ! isValidIpFormat ( aaaaRecord . data , 6 ) ) {
84209 throw new Error ( `HNS gateway returned invalid IPv6 address for ${ hostname } ` )
0 commit comments