Skip to content

Commit 41d2a2c

Browse files
committed
fix(nip66): address relay probe review feedback
Signed-off-by: ABHAY PANDEY <pandeyabhay967@gmail.com>
1 parent bc34f89 commit 41d2a2c

9 files changed

Lines changed: 248 additions & 183 deletions

File tree

src/utils/relay-probe/dns-probe.ts

Lines changed: 27 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,35 +1,46 @@
11
import { DnsRecord, ProbeTarget } from './types'
22

33
export interface DnsResolver {
4-
resolve4(hostname: string): Promise<string[]>
5-
resolve6(hostname: string): Promise<string[]>
6-
resolveCname(hostname: string): Promise<string[]>
4+
resolve4(hostname: string): Promise<DnsRecord[]>
5+
resolve6(hostname: string): Promise<DnsRecord[]>
6+
resolveCname(hostname: string): Promise<DnsRecord[]>
7+
}
8+
9+
type RecordWithTtl = {
10+
address: string
11+
ttl: number
712
}
813

914
export const createNodeDnsResolver = (): DnsResolver => {
1015
// Lazy import keeps unit tests on stubbed resolvers without touching the network.
1116
const dns = require('dns').promises as {
12-
resolve4: (hostname: string) => Promise<string[]>
13-
resolve6: (hostname: string) => Promise<string[]>
17+
resolve4: (hostname: string, options: { ttl: true }) => Promise<RecordWithTtl[]>
18+
resolve6: (hostname: string, options: { ttl: true }) => Promise<RecordWithTtl[]>
1419
resolveCname: (hostname: string) => Promise<string[]>
1520
}
1621

1722
return {
18-
resolve4: (hostname) => dns.resolve4(hostname),
19-
resolve6: (hostname) => dns.resolve6(hostname),
20-
resolveCname: (hostname) => dns.resolveCname(hostname),
23+
resolve4: async (hostname) => {
24+
const entries = await dns.resolve4(hostname, { ttl: true })
25+
return entries.map(({ address, ttl }) => ({ type: 'A' as const, value: address, ttl }))
26+
},
27+
resolve6: async (hostname) => {
28+
const entries = await dns.resolve6(hostname, { ttl: true })
29+
return entries.map(({ address, ttl }) => ({ type: 'AAAA' as const, value: address, ttl }))
30+
},
31+
resolveCname: async (hostname) => {
32+
const values = await dns.resolveCname(hostname)
33+
return values.map((value) => ({ type: 'CNAME' as const, value }))
34+
},
2135
}
2236
}
2337

2438
const collectRecords = async (resolver: DnsResolver, target: ProbeTarget): Promise<DnsRecord[]> => {
2539
const records: DnsRecord[] = []
2640

27-
const append = async (type: DnsRecord['type'], lookup: () => Promise<string[]>) => {
41+
const append = async (lookup: () => Promise<DnsRecord[]>) => {
2842
try {
29-
const values = await lookup()
30-
for (const value of values) {
31-
records.push({ type, value })
32-
}
43+
records.push(...(await lookup()))
3344
} catch (error: unknown) {
3445
const code = (error as NodeJS.ErrnoException)?.code
3546
if (code === 'ENOTFOUND' || code === 'ENODATA') {
@@ -40,9 +51,9 @@ const collectRecords = async (resolver: DnsResolver, target: ProbeTarget): Promi
4051
}
4152
}
4253

43-
await append('CNAME', () => resolver.resolveCname(target.hostname))
44-
await append('A', () => resolver.resolve4(target.hostname))
45-
await append('AAAA', () => resolver.resolve6(target.hostname))
54+
await append(() => resolver.resolveCname(target.hostname))
55+
await append(() => resolver.resolve4(target.hostname))
56+
await append(() => resolver.resolve6(target.hostname))
4657

4758
return records
4859
}

src/utils/relay-probe/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export {
55
runProbe,
66
} from './run-probe'
77
export type { ProbeClients } from './run-probe'
8+
export { resolveDnsRecords } from './dns-probe'
89
export { isNip11FetchTargetSafe } from './nip11-probe'
910
export { detectNetworkType, shouldSkipDnsProbe } from './target'
1011
export {

src/utils/relay-probe/nip11-probe.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ const MAX_REDIRECTS = 1
99

1010
const nip11DocumentSchema = z
1111
.object({
12-
name: z.string(),
13-
pubkey: pubkeySchema,
12+
name: z.string().optional(),
13+
pubkey: pubkeySchema.optional(),
1414
})
1515
.passthrough()
1616

src/utils/relay-probe/run-probe.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,8 +121,20 @@ export const runProbe = async (
121121
const dnsCacheTtlSeconds = options.dnsCacheTtlSeconds ?? DEFAULT_DNS_CACHE_TTL_SECONDS
122122
const skipDnsCache = options.skipDnsCache ?? false
123123

124+
const dnsStartedAt = Date.now()
125+
const dnsPromise = runDnsProbe(clients, target, { dnsCacheTtlSeconds, skipDnsCache })
126+
const dnsTimeout = new Promise<ProbeCheckResult<DnsResult>>((resolve) => {
127+
setTimeout(() => {
128+
resolve({
129+
status: 'error',
130+
durationMs: Date.now() - dnsStartedAt,
131+
error: `DNS probe timed out after ${timeouts.dnsMs}ms`,
132+
})
133+
}, timeouts.dnsMs).unref()
134+
})
135+
124136
const [dns, tls, wsRtt, nip11] = await Promise.all([
125-
runDnsProbe(clients, target, { dnsCacheTtlSeconds, skipDnsCache }),
137+
Promise.race([dnsPromise, dnsTimeout]),
126138
runTimedProbe(() => probeTls(clients.tls, target, timeouts.tlsMs)),
127139
runTimedProbe(() => probeWebSocketRtt(clients.ws, target, timeouts.wsRttMs)),
128140
runTimedProbe(() => probeNip11(clients.nip11, target.nip11Url, timeouts.nip11Ms)),

src/utils/relay-probe/ws-rtt-probe.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export const createNodeWebSocketConnector = (): WebSocketConnector => {
1111
measureOpenRtt: (address, timeoutMs) =>
1212
new Promise<number>((resolve, reject) => {
1313
const startedAt = Date.now()
14-
const socket = new WebSocket(address, { timeout: timeoutMs })
14+
const socket = new WebSocket(address, { handshakeTimeout: timeoutMs })
1515
let settled = false
1616

1717
const finish = (error?: Error, rttOpenMs?: number) => {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { expect } from 'chai'
2+
3+
import { parseProbeTarget, resolveDnsRecords } from '../../../src/utils/relay-probe/index'
4+
5+
describe('relay-probe dns-probe', () => {
6+
it('collects A, AAAA, and CNAME records with TTL when available', async () => {
7+
const target = parseProbeTarget('wss://relay.example.com')
8+
const records = await resolveDnsRecords(
9+
{
10+
resolveCname: async () => [{ type: 'CNAME', value: 'cdn.example.com' }],
11+
resolve4: async () => [{ type: 'A', value: '93.184.216.34', ttl: 300 }],
12+
resolve6: async () => [{ type: 'AAAA', value: '2606:2800:220:1:248:1893:25c8:1946', ttl: 120 }],
13+
},
14+
target,
15+
)
16+
17+
expect(records).to.deep.equal([
18+
{ type: 'CNAME', value: 'cdn.example.com' },
19+
{ type: 'A', value: '93.184.216.34', ttl: 300 },
20+
{ type: 'AAAA', value: '2606:2800:220:1:248:1893:25c8:1946', ttl: 120 },
21+
])
22+
})
23+
})
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
import { expect } from 'chai'
2+
3+
import { clearDnsProbeCache, runProbe } from '../../../src/utils/relay-probe/index'
4+
5+
const PUBKEY = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793'
6+
7+
const makeClients = (overrides: Record<string, unknown> = {}) => ({
8+
dns: {
9+
resolve4: async () => [{ type: 'A' as const, value: '93.184.216.34', ttl: 300 }],
10+
resolve6: async () => [],
11+
resolveCname: async () => {
12+
throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' })
13+
},
14+
},
15+
tls: {
16+
connect: async () => ({
17+
valid: true,
18+
issuer: 'Example CA',
19+
subject: 'relay.example.com',
20+
expiresAt: new Date('2027-01-01T00:00:00.000Z'),
21+
daysUntilExpiry: 365,
22+
}),
23+
},
24+
ws: { measureOpenRtt: async () => 42 },
25+
nip11: {
26+
fetch: async () => ({
27+
statusCode: 200,
28+
name: 'relay.example.com',
29+
pubkey: PUBKEY,
30+
}),
31+
},
32+
...overrides,
33+
})
34+
35+
describe('runProbe', () => {
36+
afterEach(() => {
37+
clearDnsProbeCache()
38+
})
39+
40+
it('returns structured per-check results for a clearnet relay', async () => {
41+
const result = await runProbe('wss://relay.example.com', {}, makeClients())
42+
43+
expect(result.target.hostname).to.equal('relay.example.com')
44+
expect(result.dns.status).to.equal('ok')
45+
expect(result.dns.data?.records).to.deep.include({ type: 'A', value: '93.184.216.34', ttl: 300 })
46+
expect(result.tls.status).to.equal('ok')
47+
expect(result.wsRtt.status).to.equal('ok')
48+
expect(result.nip11.status).to.equal('ok')
49+
})
50+
51+
it('skips DNS for onion destinations', async () => {
52+
const result = await runProbe(
53+
'wss://abc123def456.onion',
54+
{},
55+
makeClients({
56+
dns: {
57+
resolve4: async () => {
58+
throw new Error('DNS should not be called for onion hosts')
59+
},
60+
resolve6: async () => [],
61+
resolveCname: async () => [],
62+
},
63+
}),
64+
)
65+
66+
expect(result.target.networkType).to.equal('tor')
67+
expect(result.dns.status).to.equal('skipped')
68+
})
69+
70+
it('uses the DNS cache on repeated probes', async () => {
71+
let resolveCount = 0
72+
const clients = makeClients({
73+
dns: {
74+
resolve4: async () => {
75+
resolveCount += 1
76+
return [{ type: 'A' as const, value: '93.184.216.34', ttl: 300 }]
77+
},
78+
resolve6: async () => [],
79+
resolveCname: async () => {
80+
throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' })
81+
},
82+
},
83+
})
84+
85+
await runProbe('wss://relay.example.com', {}, clients)
86+
const second = await runProbe('wss://relay.example.com', {}, clients)
87+
88+
expect(resolveCount).to.equal(1)
89+
expect(second.dns.data?.fromCache).to.equal(true)
90+
})
91+
92+
it('surfaces probe errors without failing the full run', async () => {
93+
const result = await runProbe(
94+
'wss://relay.example.com',
95+
{},
96+
makeClients({
97+
tls: {
98+
connect: async () => {
99+
throw new Error('certificate expired')
100+
},
101+
},
102+
}),
103+
)
104+
105+
expect(result.tls.status).to.equal('error')
106+
expect(result.tls.error).to.include('certificate expired')
107+
})
108+
109+
it('times out stalled DNS probes without blocking other checks', async () => {
110+
const result = await runProbe(
111+
'wss://relay.example.com',
112+
{ timeouts: { dnsMs: 50, tlsMs: 50, wsRttMs: 50, nip11Ms: 50 } },
113+
makeClients({
114+
dns: {
115+
resolve4: () => new Promise(() => undefined),
116+
resolve6: async () => [],
117+
resolveCname: async () => {
118+
throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' })
119+
},
120+
},
121+
}),
122+
)
123+
124+
expect(result.dns.status).to.equal('error')
125+
expect(result.dns.error).to.include('DNS probe timed out')
126+
expect(result.tls.status).to.equal('ok')
127+
}).timeout(5000)
128+
129+
it('accepts NIP-11 documents without name or pubkey fields', async () => {
130+
const result = await runProbe(
131+
'wss://relay.example.com',
132+
{},
133+
makeClients({
134+
nip11: {
135+
fetch: async () => ({ statusCode: 200 }),
136+
},
137+
}),
138+
)
139+
140+
expect(result.nip11.status).to.equal('ok')
141+
expect(result.nip11.data?.name).to.equal(undefined)
142+
})
143+
})
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { expect } from 'chai'
2+
3+
import {
4+
detectNetworkType,
5+
isNip11FetchTargetSafe,
6+
parseProbeTarget,
7+
shouldSkipDnsProbe,
8+
} from '../../../src/utils/relay-probe/index'
9+
10+
describe('relay-probe target parsing', () => {
11+
it('parses wss relay URLs into HTTP and WebSocket probe targets', () => {
12+
const target = parseProbeTarget('wss://relay.example.com/nostream')
13+
14+
expect(target.hostname).to.equal('relay.example.com')
15+
expect(target.networkType).to.equal('clearnet')
16+
expect(target.httpOrigin).to.equal('https://relay.example.com')
17+
expect(target.nip11Url).to.equal('https://relay.example.com/nostream/')
18+
expect(target.wsUrl).to.equal('wss://relay.example.com/nostream')
19+
})
20+
21+
it('detects tor and i2p destinations', () => {
22+
expect(detectNetworkType('abc123.onion')).to.equal('tor')
23+
expect(detectNetworkType('relay.i2p')).to.equal('i2p')
24+
expect(shouldSkipDnsProbe('tor')).to.equal(true)
25+
})
26+
27+
it('rejects non-websocket relay URLs', () => {
28+
expect(() => parseProbeTarget('https://relay.example.com')).to.throw('ws:// or wss://')
29+
})
30+
})
31+
32+
describe('relay-probe safety helpers', () => {
33+
it('rejects unsafe NIP-11 fetch targets', () => {
34+
expect(isNip11FetchTargetSafe('https://relay.example.com/')).to.equal(true)
35+
expect(isNip11FetchTargetSafe('http://127.0.0.1/')).to.equal(false)
36+
expect(isNip11FetchTargetSafe('ftp://relay.example.com/')).to.equal(false)
37+
})
38+
})

0 commit comments

Comments
 (0)