Skip to content

Commit eec4bd8

Browse files
fix: rewrite HNS resolver to use wire-format DoH (RFC 8484)
The default HDNS gateway (query.hdns.io) only supports wire-format DoH (?dns=<base64url>), not the JSON API format (?name=&type=) that the resolver was using. Encode DNS queries as RFC 1035 wire format, base64url them, and parse binary responses. Tested live against namebase/ and handshake/ HNS names. Also adds multi-transport example script demonstrating the full onion > hns > https > http fallback chain.
1 parent 5d2ade9 commit eec4bd8

3 files changed

Lines changed: 468 additions & 123 deletions

File tree

examples/multi-transport.ts

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
/**
2+
* Multi-transport fallback demo.
3+
*
4+
* Demonstrates the onion → hns → https → http transport selection
5+
* and fallback chain. Run with:
6+
*
7+
* npx tsx examples/multi-transport.ts # mock demo (no network)
8+
* npx tsx examples/multi-transport.ts --live # also try real HNS + onion resolution
9+
*/
10+
11+
import { selectTransports } from '402-mcp/fetch/transport'
12+
import { withTransportFallback } from '402-mcp/fetch/resilient-fetch'
13+
import { resolveHns } from '402-mcp/fetch/hns-resolve'
14+
import { TransportUnavailableError } from '402-mcp/fetch/errors'
15+
16+
// ── 1. Transport classification & selection ─────────────────────────
17+
18+
const urls = [
19+
'https://api.example.com/weather', // https (clearnet)
20+
'https://api.exampleonion.onion/weather', // onion (Tor)
21+
'https://weather.satoshi/weather', // hns (Handshake TLD)
22+
'http://api.example.com/weather', // http (plain)
23+
]
24+
25+
const preference = ['onion', 'hns', 'https', 'http']
26+
27+
console.log('=== Transport Selection ===\n')
28+
console.log('URLs:', urls)
29+
console.log('Preference:', preference.join(' > '))
30+
31+
// With Tor proxy available - .onion URLs are included
32+
const withTor = selectTransports(urls, preference, { hasTorProxy: true })
33+
console.log('\nWith Tor proxy:')
34+
withTor.forEach((u: string, i: number) => console.log(` ${i + 1}. ${u}`))
35+
36+
// Without Tor proxy - .onion URLs are filtered out
37+
const withoutTor = selectTransports(urls, preference, { hasTorProxy: false })
38+
console.log('\nWithout Tor proxy:')
39+
withoutTor.forEach((u: string, i: number) => console.log(` ${i + 1}. ${u}`))
40+
41+
// ── 2. Fallback chain ───────────────────────────────────────────────
42+
43+
console.log('\n=== Fallback Chain ===\n')
44+
console.log('Simulating: onion fails (no proxy) -> HNS fails (ECONNREFUSED) -> clearnet succeeds\n')
45+
46+
const orderedUrls = [
47+
'https://api.exampleonion.onion/weather', // will fail - no Tor
48+
'https://weather.satoshi/weather', // will fail - ECONNREFUSED
49+
'https://api.example.com/weather', // will succeed
50+
]
51+
52+
const attempts: string[] = []
53+
54+
const mockFetch = async (url: string | URL): Promise<Response> => {
55+
const urlStr = url.toString()
56+
attempts.push(urlStr)
57+
58+
if (urlStr.includes('.onion')) {
59+
console.log(` [FAIL] ${urlStr}`)
60+
console.log(' TransportUnavailableError: no Tor proxy')
61+
throw new TransportUnavailableError(urlStr, 'no Tor proxy configured')
62+
}
63+
64+
if (urlStr.includes('.satoshi')) {
65+
console.log(` [FAIL] ${urlStr}`)
66+
console.log(' ECONNREFUSED: HNS host unreachable')
67+
const err = new Error('connect ECONNREFUSED') as NodeJS.ErrnoException
68+
err.code = 'ECONNREFUSED'
69+
throw err
70+
}
71+
72+
console.log(` [ OK ] ${urlStr}`)
73+
console.log(' 200 - {"temp": 18, "unit": "celsius", "city": "London"}')
74+
return new Response(
75+
JSON.stringify({ temp: 18, unit: 'celsius', city: 'London' }),
76+
{ status: 200, headers: { 'Content-Type': 'application/json' } },
77+
)
78+
}
79+
80+
const response = await withTransportFallback(orderedUrls, {}, mockFetch)
81+
const body = await response.json()
82+
83+
console.log(`\nResult: ${response.status} OK`)
84+
console.log('Body:', body)
85+
console.log(`Attempts: ${attempts.length} (${attempts.length - 1} transport failures before success)`)
86+
87+
// ── 3. Credential keying by pubkey ──────────────────────────────────
88+
89+
console.log('\n=== Credential Keying ===\n')
90+
91+
const servicePubkey = 'abcd1234'.repeat(8) // 64-char hex pubkey
92+
const origins = orderedUrls.map(u => new URL(u).origin)
93+
94+
console.log('Service pubkey:', servicePubkey.slice(0, 16) + '...')
95+
console.log('Transport origins:')
96+
origins.forEach(o => console.log(` - ${o}`))
97+
console.log('\nAll three origins are different, but credentials are keyed by')
98+
console.log('pubkey - so a macaroon obtained via onion works over clearnet.')
99+
100+
// ── 4. Live resolution (optional) ───────────────────────────────────
101+
102+
if (process.argv.includes('--live')) {
103+
// ── 4a. HNS resolution via HDNS gateway ─────────────────────────
104+
console.log('\n=== Live HNS Resolution (query.hdns.io) ===\n')
105+
106+
const hnsNames = ['namebase', 'handshake', 'forever']
107+
const gateway = 'https://query.hdns.io/'
108+
109+
for (const name of hnsNames) {
110+
try {
111+
const resolved = await resolveHns(name, gateway, 5000)
112+
console.log(` ${name}/ -> ${resolved.address} (IPv${resolved.family})`)
113+
} catch (err) {
114+
console.log(` ${name}/ -> ${(err as Error).message}`)
115+
}
116+
}
117+
118+
// ── 4b. Onion connectivity check ─────────────────────────────────
119+
console.log('\n=== Onion Transport Check ===\n')
120+
121+
const torProxy = process.env.TOR_PROXY || process.env.SOCKS_PROXY
122+
if (torProxy) {
123+
console.log(` Tor SOCKS proxy configured: ${torProxy}`)
124+
console.log(' .onion URLs will be routed through the proxy.')
125+
126+
// Test connectivity to Tor network via a known onion service
127+
const testOnion = 'http://2gzyxa5ihm7nsber64sieskb3r4zgbz2uho0vwqqmdxpcokbaurxuca.onion/'
128+
console.log(`\n Testing: ${testOnion}`)
129+
try {
130+
const ctrl = new AbortController()
131+
setTimeout(() => ctrl.abort(), 10_000)
132+
const resp = await fetch(testOnion, { signal: ctrl.signal })
133+
console.log(` Result: ${resp.status} ${resp.statusText}`)
134+
} catch (err) {
135+
console.log(` Result: ${(err as Error).message}`)
136+
console.log(' (This is expected without a SOCKS-aware fetch implementation)')
137+
}
138+
} else {
139+
console.log(' No TOR_PROXY or SOCKS_PROXY configured.')
140+
console.log(' .onion URLs will be filtered out by selectTransports().')
141+
console.log('\n To enable onion transport:')
142+
console.log(' 1. Install Tor: brew install tor && brew services start tor')
143+
console.log(' 2. Set proxy: export TOR_PROXY=socks5://127.0.0.1:9050')
144+
console.log(' 3. Re-run: npx tsx examples/multi-transport.ts --live')
145+
}
146+
} else {
147+
console.log('\nRun with --live to test real HNS resolution and onion transport.')
148+
}

src/fetch/hns-resolve.ts

Lines changed: 140 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
34154
async 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

Comments
 (0)