From bc34f8997030b30f0a2a4e5239d59e8390a66c07 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Fri, 10 Jul 2026 09:49:51 +0530 Subject: [PATCH 1/2] feat(nip66): add shared relay probe engine (DNS, TLS, WS RTT, NIP-11) Signed-off-by: ABHAY PANDEY --- .changeset/nip66-probe-engine.md | 5 + .knip.json | 3 +- src/utils/relay-probe/dns-cache.ts | 43 +++++++ src/utils/relay-probe/dns-probe.ts | 58 +++++++++ src/utils/relay-probe/index.ts | 27 +++++ src/utils/relay-probe/nip11-probe.ts | 113 ++++++++++++++++++ src/utils/relay-probe/run-probe.ts | 141 ++++++++++++++++++++++ src/utils/relay-probe/target.ts | 64 ++++++++++ src/utils/relay-probe/tls-probe.ts | 90 ++++++++++++++ src/utils/relay-probe/types.ts | 85 ++++++++++++++ src/utils/relay-probe/ws-rtt-probe.ts | 63 ++++++++++ test/unit/utils/relay-probe.spec.ts | 163 ++++++++++++++++++++++++++ 12 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 .changeset/nip66-probe-engine.md create mode 100644 src/utils/relay-probe/dns-cache.ts create mode 100644 src/utils/relay-probe/dns-probe.ts create mode 100644 src/utils/relay-probe/index.ts create mode 100644 src/utils/relay-probe/nip11-probe.ts create mode 100644 src/utils/relay-probe/run-probe.ts create mode 100644 src/utils/relay-probe/target.ts create mode 100644 src/utils/relay-probe/tls-probe.ts create mode 100644 src/utils/relay-probe/types.ts create mode 100644 src/utils/relay-probe/ws-rtt-probe.ts create mode 100644 test/unit/utils/relay-probe.spec.ts diff --git a/.changeset/nip66-probe-engine.md b/.changeset/nip66-probe-engine.md new file mode 100644 index 00000000..79b12edd --- /dev/null +++ b/.changeset/nip66-probe-engine.md @@ -0,0 +1,5 @@ +--- +"nostream": minor +--- + +feat(nip66): add shared relay probe engine for DNS, TLS, WebSocket RTT, and NIP-11 checks diff --git a/.knip.json b/.knip.json index 001eba2e..df375161 100644 --- a/.knip.json +++ b/.knip.json @@ -15,7 +15,8 @@ ], "ignore": [ ".nostr/**", - "src/repositories/invite-code-repository.ts" + "src/repositories/invite-code-repository.ts", + "src/utils/relay-probe/**" ], "commitlint": false, "eslint": false, diff --git a/src/utils/relay-probe/dns-cache.ts b/src/utils/relay-probe/dns-cache.ts new file mode 100644 index 00000000..24448c36 --- /dev/null +++ b/src/utils/relay-probe/dns-cache.ts @@ -0,0 +1,43 @@ +import { DEFAULT_DNS_CACHE_TTL_SECONDS, DnsRecord, DnsResult } from './types' + +type DnsCacheEntry = { + records: DnsRecord[] + expiresAt: number +} + +const dnsCache = new Map() + +export const clearDnsProbeCache = (): void => { + dnsCache.clear() +} + +export const getCachedDnsResult = (hostname: string, now = Date.now()): DnsResult | undefined => { + const entry = dnsCache.get(hostname) + + if (!entry || entry.expiresAt <= now) { + if (entry) { + dnsCache.delete(hostname) + } + + return undefined + } + + return { + hostname, + records: entry.records, + fromCache: true, + cacheExpiresAt: new Date(entry.expiresAt), + } +} + +export const setCachedDnsResult = ( + hostname: string, + records: DnsRecord[], + ttlSeconds = DEFAULT_DNS_CACHE_TTL_SECONDS, + now = Date.now(), +): void => { + dnsCache.set(hostname, { + records, + expiresAt: now + ttlSeconds * 1000, + }) +} diff --git a/src/utils/relay-probe/dns-probe.ts b/src/utils/relay-probe/dns-probe.ts new file mode 100644 index 00000000..4ba8a6f4 --- /dev/null +++ b/src/utils/relay-probe/dns-probe.ts @@ -0,0 +1,58 @@ +import { DnsRecord, ProbeTarget } from './types' + +export interface DnsResolver { + resolve4(hostname: string): Promise + resolve6(hostname: string): Promise + resolveCname(hostname: string): Promise +} + +export const createNodeDnsResolver = (): DnsResolver => { + // Lazy import keeps unit tests on stubbed resolvers without touching the network. + const dns = require('dns').promises as { + resolve4: (hostname: string) => Promise + resolve6: (hostname: string) => Promise + resolveCname: (hostname: string) => Promise + } + + return { + resolve4: (hostname) => dns.resolve4(hostname), + resolve6: (hostname) => dns.resolve6(hostname), + resolveCname: (hostname) => dns.resolveCname(hostname), + } +} + +const collectRecords = async (resolver: DnsResolver, target: ProbeTarget): Promise => { + const records: DnsRecord[] = [] + + const append = async (type: DnsRecord['type'], lookup: () => Promise) => { + try { + const values = await lookup() + for (const value of values) { + records.push({ type, value }) + } + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException)?.code + if (code === 'ENOTFOUND' || code === 'ENODATA') { + return + } + + throw error + } + } + + await append('CNAME', () => resolver.resolveCname(target.hostname)) + await append('A', () => resolver.resolve4(target.hostname)) + await append('AAAA', () => resolver.resolve6(target.hostname)) + + return records +} + +export const resolveDnsRecords = async (resolver: DnsResolver, target: ProbeTarget): Promise => { + const records = await collectRecords(resolver, target) + + if (records.length === 0) { + throw new Error(`No DNS records found for ${target.hostname}`) + } + + return records +} diff --git a/src/utils/relay-probe/index.ts b/src/utils/relay-probe/index.ts new file mode 100644 index 00000000..e262a191 --- /dev/null +++ b/src/utils/relay-probe/index.ts @@ -0,0 +1,27 @@ +export { + clearDnsProbeCache, + createDefaultProbeClients, + parseProbeTarget, + runProbe, +} from './run-probe' +export type { ProbeClients } from './run-probe' +export { isNip11FetchTargetSafe } from './nip11-probe' +export { detectNetworkType, shouldSkipDnsProbe } from './target' +export { + DEFAULT_DNS_CACHE_TTL_SECONDS, + DEFAULT_PROBE_TIMEOUTS, +} from './types' +export type { + DnsRecord, + DnsResult, + Nip11Result, + ProbeCheckResult, + ProbeCheckStatus, + ProbeNetworkType, + ProbeOptions, + ProbeResult, + ProbeTarget, + ProbeTimeouts, + TlsResult, + WsRttResult, +} from './types' diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts new file mode 100644 index 00000000..0d044489 --- /dev/null +++ b/src/utils/relay-probe/nip11-probe.ts @@ -0,0 +1,113 @@ +import axios, { AxiosError } from 'axios' +import { z } from 'zod' + +import { pubkeySchema } from '../../schemas/base-schema' +import { Nip11Result } from './types' + +const MAX_RESPONSE_BYTES = 256 * 1024 +const MAX_REDIRECTS = 1 + +const nip11DocumentSchema = z + .object({ + name: z.string(), + pubkey: pubkeySchema, + }) + .passthrough() + +export interface Nip11Fetcher { + fetch(url: string, timeoutMs: number): Promise +} + +/** + * Reject redirect targets that would turn relay probing into an SSRF primitive. + * Mirrors the NIP-05 verification guard in src/utils/nip05.ts. + */ +export const isNip11FetchTargetSafe = (targetUrl: string): boolean => { + let parsed: URL + try { + parsed = new URL(targetUrl) + } catch { + return false + } + + if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') { + return false + } + + const host = parsed.hostname.toLowerCase() + if (host === 'localhost' || host === '0.0.0.0' || host.endsWith('.localhost')) { + return false + } + + const ipv4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/) + if (ipv4) { + const [a, b] = ipv4.slice(1, 3).map(Number) + if ( + a === 10 || + a === 127 || + a === 0 || + a >= 224 || + (a === 169 && b === 254) || + (a === 172 && b >= 16 && b <= 31) || + (a === 192 && b === 168) + ) { + return false + } + } + + if (host.startsWith('[') && host.endsWith(']')) { + return false + } + + return true +} + +export const createNodeNip11Fetcher = (): Nip11Fetcher => ({ + fetch: async (url, timeoutMs) => { + if (!isNip11FetchTargetSafe(url)) { + throw new Error(`refused unsafe NIP-11 fetch target: ${url}`) + } + + try { + const response = await axios.get(url, { + timeout: timeoutMs, + headers: { Accept: 'application/nostr+json' }, + responseType: 'json', + validateStatus: (status) => status === 200, + maxRedirects: MAX_REDIRECTS, + maxContentLength: MAX_RESPONSE_BYTES, + maxBodyLength: MAX_RESPONSE_BYTES, + beforeRedirect: (options: { href?: string; protocol?: string; hostname?: string }) => { + const href = options.href ?? `${options.protocol ?? ''}//${options.hostname ?? ''}` + if (!isNip11FetchTargetSafe(href)) { + throw new Error(`refused redirect to unsafe target: ${href}`) + } + }, + }) + + const parsed = nip11DocumentSchema.safeParse(response.data) + if (!parsed.success) { + const reason = parsed.error.issues.map((issue) => issue.message).join('; ') + throw new Error(`invalid NIP-11 document: ${reason}`) + } + + return { + statusCode: response.status, + name: parsed.data.name, + pubkey: parsed.data.pubkey, + } + } catch (error: unknown) { + const axiosError = error as AxiosError + if (axiosError.response?.status) { + throw new Error(`NIP-11 request failed with status ${axiosError.response.status}`) + } + + const message = axiosError?.message ?? (error instanceof Error ? error.message : String(error)) + throw new Error(message) + } + }, +}) + +export const probeNip11 = async (fetcher: Nip11Fetcher, url: string, timeoutMs: number): Promise => { + return fetcher.fetch(url, timeoutMs) +} diff --git a/src/utils/relay-probe/run-probe.ts b/src/utils/relay-probe/run-probe.ts new file mode 100644 index 00000000..bfabe91f --- /dev/null +++ b/src/utils/relay-probe/run-probe.ts @@ -0,0 +1,141 @@ +import { clearDnsProbeCache, getCachedDnsResult, setCachedDnsResult } from './dns-cache' +import { createNodeDnsResolver, DnsResolver, resolveDnsRecords } from './dns-probe' +import { createNodeNip11Fetcher, Nip11Fetcher, probeNip11 } from './nip11-probe' +import { parseProbeTarget, shouldSkipDnsProbe } from './target' +import { createNodeTlsConnector, probeTls, TlsConnector } from './tls-probe' +import { + DEFAULT_DNS_CACHE_TTL_SECONDS, + DEFAULT_PROBE_TIMEOUTS, + DnsResult, + ProbeCheckResult, + ProbeOptions, + ProbeResult, + ProbeTarget, + ProbeTimeouts, +} from './types' +import { createNodeWebSocketConnector, probeWebSocketRtt, WebSocketConnector } from './ws-rtt-probe' + +export interface ProbeClients { + dns: DnsResolver + tls: TlsConnector + ws: WebSocketConnector + nip11: Nip11Fetcher +} + +export const createDefaultProbeClients = (): ProbeClients => ({ + dns: createNodeDnsResolver(), + tls: createNodeTlsConnector(), + ws: createNodeWebSocketConnector(), + nip11: createNodeNip11Fetcher(), +}) + +const mergeTimeouts = (timeouts?: Partial): ProbeTimeouts => ({ + ...DEFAULT_PROBE_TIMEOUTS, + ...timeouts, +}) + +const runTimedProbe = async ( + runner: () => Promise, +): Promise> => { + const startedAt = Date.now() + + try { + const data = await runner() + + return { + status: 'ok', + durationMs: Date.now() - startedAt, + data, + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + + return { + status: 'error', + durationMs: Date.now() - startedAt, + error: message, + } + } +} + +const runDnsProbe = async ( + clients: ProbeClients, + target: ProbeTarget, + options: Required>, +): Promise> => { + const startedAt = Date.now() + + if (shouldSkipDnsProbe(target.networkType)) { + return { + status: 'skipped', + durationMs: Date.now() - startedAt, + error: `DNS probe skipped for ${target.networkType} destination`, + } + } + + if (!options.skipDnsCache) { + const cached = getCachedDnsResult(target.hostname) + if (cached) { + return { + status: 'ok', + durationMs: Date.now() - startedAt, + data: cached, + } + } + } + + try { + const records = await resolveDnsRecords(clients.dns, target) + + if (!options.skipDnsCache) { + setCachedDnsResult(target.hostname, records, options.dnsCacheTtlSeconds) + } + + return { + status: 'ok', + durationMs: Date.now() - startedAt, + data: { + hostname: target.hostname, + records, + fromCache: false, + }, + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + + return { + status: 'error', + durationMs: Date.now() - startedAt, + error: message, + } + } +} + +export const runProbe = async ( + relayUrl: string, + options: ProbeOptions = {}, + clients: ProbeClients = createDefaultProbeClients(), +): Promise => { + const target = parseProbeTarget(relayUrl) + const timeouts = mergeTimeouts(options.timeouts) + const dnsCacheTtlSeconds = options.dnsCacheTtlSeconds ?? DEFAULT_DNS_CACHE_TTL_SECONDS + const skipDnsCache = options.skipDnsCache ?? false + + const [dns, tls, wsRtt, nip11] = await Promise.all([ + runDnsProbe(clients, target, { dnsCacheTtlSeconds, skipDnsCache }), + runTimedProbe(() => probeTls(clients.tls, target, timeouts.tlsMs)), + runTimedProbe(() => probeWebSocketRtt(clients.ws, target, timeouts.wsRttMs)), + runTimedProbe(() => probeNip11(clients.nip11, target.nip11Url, timeouts.nip11Ms)), + ]) + + return { + target, + checkedAt: new Date(), + dns, + tls, + wsRtt, + nip11, + } +} + +export { clearDnsProbeCache, parseProbeTarget } diff --git a/src/utils/relay-probe/target.ts b/src/utils/relay-probe/target.ts new file mode 100644 index 00000000..eaeff39f --- /dev/null +++ b/src/utils/relay-probe/target.ts @@ -0,0 +1,64 @@ +import { ProbeNetworkType, ProbeTarget } from './types' + +const ONION_SUFFIX = '.onion' +const I2P_SUFFIX = '.i2p' + +export const detectNetworkType = (hostname: string): ProbeNetworkType => { + const lowerHostname = hostname.toLowerCase() + + if (lowerHostname.endsWith(ONION_SUFFIX)) { + return 'tor' + } + + if (lowerHostname.endsWith(I2P_SUFFIX)) { + return 'i2p' + } + + return 'clearnet' +} + +export const shouldSkipDnsProbe = (networkType: ProbeNetworkType): boolean => { + // Tor hidden services and I2P destinations do not use public DNS. + return networkType === 'tor' || networkType === 'i2p' +} + +export const parseProbeTarget = (relayUrl: string): ProbeTarget => { + const trimmed = relayUrl.trim() + + if (!trimmed) { + throw new Error('Relay URL is required') + } + + let parsed: URL + try { + parsed = new URL(trimmed) + } catch { + throw new Error(`Invalid relay URL: ${relayUrl}`) + } + + if (parsed.protocol !== 'wss:' && parsed.protocol !== 'ws:') { + throw new Error(`Relay URL must use ws:// or wss:// scheme: ${relayUrl}`) + } + + if (!parsed.hostname) { + throw new Error(`Relay URL is missing hostname: ${relayUrl}`) + } + + const hostname = parsed.hostname.toLowerCase() + const networkType = detectNetworkType(hostname) + const httpProtocol = parsed.protocol === 'wss:' ? 'https:' : 'http:' + const httpOrigin = `${httpProtocol}//${parsed.host}` + const nip11Path = parsed.pathname.endsWith('/') ? parsed.pathname : `${parsed.pathname || ''}/` + const nip11Url = new URL(nip11Path, httpOrigin).toString() + const port = parsed.port ? Number(parsed.port) : undefined + + return { + relayUrl: trimmed, + hostname, + port, + networkType, + httpOrigin, + nip11Url, + wsUrl: trimmed, + } +} diff --git a/src/utils/relay-probe/tls-probe.ts b/src/utils/relay-probe/tls-probe.ts new file mode 100644 index 00000000..47b248c6 --- /dev/null +++ b/src/utils/relay-probe/tls-probe.ts @@ -0,0 +1,90 @@ +import tls from 'tls' + +import { ProbeTarget, TlsResult } from './types' + +export interface TlsConnector { + connect(options: { + host: string + port: number + servername: string + timeoutMs: number + }): Promise +} + +const getDaysUntilExpiry = (expiresAt: Date, now = Date.now()): number => { + return Math.floor((expiresAt.getTime() - now) / (1000 * 60 * 60 * 24)) +} + +const readCertificateField = (value: string | string[] | undefined): string | undefined => { + if (Array.isArray(value)) { + return value[0] + } + + return value +} + +export const createNodeTlsConnector = (): TlsConnector => ({ + connect: ({ host, port, servername, timeoutMs }) => + new Promise((resolve, reject) => { + const socket = tls.connect( + { + host, + port, + servername, + rejectUnauthorized: true, + }, + () => { + try { + const certificate = socket.getPeerCertificate() + + if (!certificate || Object.keys(certificate).length === 0) { + reject(new Error('No peer certificate presented')) + socket.destroy() + return + } + + const expiresAt = certificate.valid_to ? new Date(certificate.valid_to) : undefined + const now = Date.now() + const valid = Boolean(expiresAt && expiresAt.getTime() > now) + + resolve({ + valid, + issuer: readCertificateField(certificate.issuer?.O) ?? readCertificateField(certificate.issuer?.CN), + subject: readCertificateField(certificate.subject?.CN) ?? certificate.subjectaltname, + expiresAt, + daysUntilExpiry: expiresAt ? getDaysUntilExpiry(expiresAt, now) : undefined, + }) + } catch (error) { + reject(error) + } finally { + socket.destroy() + } + }, + ) + + socket.setTimeout(timeoutMs, () => { + socket.destroy() + reject(new Error(`TLS probe timed out after ${timeoutMs}ms`)) + }) + + socket.on('error', (error) => { + socket.destroy() + reject(error) + }) + }), +}) + +export const probeTls = async ( + connector: TlsConnector, + target: ProbeTarget, + timeoutMs: number, +): Promise => { + const port = target.port ?? 443 + + return connector.connect({ + host: target.hostname, + port, + servername: target.hostname, + timeoutMs, + }) +} diff --git a/src/utils/relay-probe/types.ts b/src/utils/relay-probe/types.ts new file mode 100644 index 00000000..955205b2 --- /dev/null +++ b/src/utils/relay-probe/types.ts @@ -0,0 +1,85 @@ +export type ProbeNetworkType = 'clearnet' | 'tor' | 'i2p' + +export type ProbeCheckStatus = 'ok' | 'error' | 'skipped' + +export interface ProbeCheckResult { + status: ProbeCheckStatus + durationMs: number + data?: T + error?: string +} + +export interface ProbeTarget { + relayUrl: string + hostname: string + port?: number + networkType: ProbeNetworkType + httpOrigin: string + nip11Url: string + wsUrl: string +} + +export type DnsRecordType = 'A' | 'AAAA' | 'CNAME' + +export interface DnsRecord { + type: DnsRecordType + value: string + ttl?: number +} + +export interface DnsResult { + hostname: string + records: DnsRecord[] + fromCache: boolean + cacheExpiresAt?: Date +} + +export interface TlsResult { + valid: boolean + issuer?: string + subject?: string + expiresAt?: Date + daysUntilExpiry?: number +} + +export interface WsRttResult { + rttOpenMs: number + address: string +} + +export interface Nip11Result { + statusCode: number + name?: string + pubkey?: string +} + +export interface ProbeResult { + target: ProbeTarget + checkedAt: Date + dns: ProbeCheckResult + tls: ProbeCheckResult + wsRtt: ProbeCheckResult + nip11: ProbeCheckResult +} + +export interface ProbeTimeouts { + dnsMs: number + tlsMs: number + wsRttMs: number + nip11Ms: number +} + +export const DEFAULT_PROBE_TIMEOUTS: ProbeTimeouts = { + dnsMs: 10_000, + tlsMs: 10_000, + wsRttMs: 10_000, + nip11Ms: 10_000, +} + +export const DEFAULT_DNS_CACHE_TTL_SECONDS = 300 + +export interface ProbeOptions { + timeouts?: Partial + dnsCacheTtlSeconds?: number + skipDnsCache?: boolean +} diff --git a/src/utils/relay-probe/ws-rtt-probe.ts b/src/utils/relay-probe/ws-rtt-probe.ts new file mode 100644 index 00000000..aed9ca69 --- /dev/null +++ b/src/utils/relay-probe/ws-rtt-probe.ts @@ -0,0 +1,63 @@ +import { ProbeTarget, WsRttResult } from './types' + +export interface WebSocketConnector { + measureOpenRtt(address: string, timeoutMs: number): Promise +} + +export const createNodeWebSocketConnector = (): WebSocketConnector => { + const { WebSocket } = require('ws') as typeof import('ws') + + return { + measureOpenRtt: (address, timeoutMs) => + new Promise((resolve, reject) => { + const startedAt = Date.now() + const socket = new WebSocket(address, { timeout: timeoutMs }) + let settled = false + + const finish = (error?: Error, rttOpenMs?: number) => { + if (settled) { + return + } + + settled = true + socket.removeAllListeners() + + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) { + socket.terminate() + } + + if (error) { + reject(error) + return + } + + resolve(rttOpenMs as number) + } + + socket.once('open', () => { + finish(undefined, Date.now() - startedAt) + }) + + socket.once('error', (error) => { + finish(error instanceof Error ? error : new Error(String(error))) + }) + + setTimeout(() => { + finish(new Error(`WebSocket probe timed out after ${timeoutMs}ms`)) + }, timeoutMs).unref() + }), + } +} + +export const probeWebSocketRtt = async ( + connector: WebSocketConnector, + target: ProbeTarget, + timeoutMs: number, +): Promise => { + const rttOpenMs = await connector.measureOpenRtt(target.wsUrl, timeoutMs) + + return { + rttOpenMs, + address: target.wsUrl, + } +} diff --git a/test/unit/utils/relay-probe.spec.ts b/test/unit/utils/relay-probe.spec.ts new file mode 100644 index 00000000..8ccefc9b --- /dev/null +++ b/test/unit/utils/relay-probe.spec.ts @@ -0,0 +1,163 @@ +import { expect } from 'chai' + +import { + clearDnsProbeCache, + createDefaultProbeClients, + detectNetworkType, + isNip11FetchTargetSafe, + parseProbeTarget, + runProbe, + shouldSkipDnsProbe, +} from '../../../src/utils/relay-probe' +import { ProbeClients } from '../../../src/utils/relay-probe/run-probe' +import { TlsResult } from '../../../src/utils/relay-probe/types' + +const PUBKEY = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + +describe('relay-probe target parsing', () => { + it('parses wss relay URLs into HTTP and WebSocket probe targets', () => { + const target = parseProbeTarget('wss://relay.example.com/nostream') + + expect(target.hostname).to.equal('relay.example.com') + expect(target.networkType).to.equal('clearnet') + expect(target.httpOrigin).to.equal('https://relay.example.com') + expect(target.nip11Url).to.equal('https://relay.example.com/nostream/') + expect(target.wsUrl).to.equal('wss://relay.example.com/nostream') + }) + + it('detects tor and i2p destinations', () => { + expect(detectNetworkType('abc123.onion')).to.equal('tor') + expect(detectNetworkType('relay.i2p')).to.equal('i2p') + expect(shouldSkipDnsProbe('tor')).to.equal(true) + }) + + it('rejects non-websocket relay URLs', () => { + expect(() => parseProbeTarget('https://relay.example.com')).to.throw('ws:// or wss://') + }) +}) + +describe('relay-probe safety helpers', () => { + it('rejects unsafe NIP-11 fetch targets', () => { + expect(isNip11FetchTargetSafe('https://relay.example.com/')).to.equal(true) + expect(isNip11FetchTargetSafe('http://127.0.0.1/')).to.equal(false) + expect(isNip11FetchTargetSafe('ftp://relay.example.com/')).to.equal(false) + }) +}) + +describe('runProbe', () => { + afterEach(() => { + clearDnsProbeCache() + }) + + const makeClients = (overrides: Partial = {}): ProbeClients => ({ + dns: { + resolve4: async () => ['93.184.216.34'], + resolve6: async () => [], + resolveCname: async () => { + throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) + }, + }, + tls: { + connect: async () => + ({ + valid: true, + issuer: 'Example CA', + subject: 'relay.example.com', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + daysUntilExpiry: 365, + }) satisfies TlsResult, + }, + ws: { + measureOpenRtt: async () => 42, + }, + nip11: { + fetch: async () => ({ + statusCode: 200, + name: 'relay.example.com', + pubkey: PUBKEY, + }), + }, + ...overrides, + }) + + it('returns structured per-check results for a clearnet relay', async () => { + const result = await runProbe('wss://relay.example.com', {}, makeClients()) + + expect(result.target.hostname).to.equal('relay.example.com') + expect(result.dns.status).to.equal('ok') + expect(result.dns.data?.records).to.deep.include({ type: 'A', value: '93.184.216.34' }) + expect(result.tls.status).to.equal('ok') + expect(result.tls.data?.valid).to.equal(true) + expect(result.wsRtt.status).to.equal('ok') + expect(result.wsRtt.data?.rttOpenMs).to.equal(42) + expect(result.nip11.status).to.equal('ok') + expect(result.nip11.data?.pubkey).to.equal(PUBKEY) + }) + + it('skips DNS for onion destinations', async () => { + const dns = { + resolve4: async () => { + throw new Error('DNS should not be called for onion hosts') + }, + resolve6: async () => [], + resolveCname: async () => [], + } + + const result = await runProbe('wss://abc123def456.onion', {}, makeClients({ dns })) + + expect(result.target.networkType).to.equal('tor') + expect(result.dns.status).to.equal('skipped') + expect(result.tls.status).to.equal('ok') + }) + + it('uses the DNS cache on repeated probes', async () => { + let resolveCount = 0 + const dns = { + resolve4: async () => { + resolveCount += 1 + return ['93.184.216.34'] + }, + resolve6: async () => [], + resolveCname: async () => { + throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) + }, + } + + const clients = makeClients({ dns }) + + await runProbe('wss://relay.example.com', {}, clients) + const second = await runProbe('wss://relay.example.com', {}, clients) + + expect(resolveCount).to.equal(1) + expect(second.dns.data?.fromCache).to.equal(true) + }) + + it('surfaces probe errors without failing the full run', async () => { + const result = await runProbe( + 'wss://relay.example.com', + {}, + makeClients({ + tls: { + connect: async () => { + throw new Error('certificate expired') + }, + }, + }), + ) + + expect(result.dns.status).to.equal('ok') + expect(result.tls.status).to.equal('error') + expect(result.tls.error).to.include('certificate expired') + expect(result.wsRtt.status).to.equal('ok') + expect(result.nip11.status).to.equal('ok') + }) + + it('exposes default node-backed clients', () => { + const clients = createDefaultProbeClients() + + expect(clients.dns).to.have.property('resolve4') + expect(clients.tls).to.have.property('connect') + expect(clients.ws).to.have.property('measureOpenRtt') + expect(clients.nip11).to.have.property('fetch') + }) +}) From 41d2a2cdec853ec00d90d7f92c14bb245bd3c679 Mon Sep 17 00:00:00 2001 From: ABHAY PANDEY Date: Fri, 10 Jul 2026 12:34:26 +0530 Subject: [PATCH 2/2] fix(nip66): address relay probe review feedback Signed-off-by: ABHAY PANDEY --- src/utils/relay-probe/dns-probe.ts | 43 ++++-- src/utils/relay-probe/index.ts | 1 + src/utils/relay-probe/nip11-probe.ts | 4 +- src/utils/relay-probe/run-probe.ts | 14 +- src/utils/relay-probe/ws-rtt-probe.ts | 2 +- test/unit/utils/relay-probe-dns.spec.ts | 23 +++ test/unit/utils/relay-probe-run.spec.ts | 143 ++++++++++++++++++ test/unit/utils/relay-probe-target.spec.ts | 38 +++++ test/unit/utils/relay-probe.spec.ts | 163 --------------------- 9 files changed, 248 insertions(+), 183 deletions(-) create mode 100644 test/unit/utils/relay-probe-dns.spec.ts create mode 100644 test/unit/utils/relay-probe-run.spec.ts create mode 100644 test/unit/utils/relay-probe-target.spec.ts delete mode 100644 test/unit/utils/relay-probe.spec.ts diff --git a/src/utils/relay-probe/dns-probe.ts b/src/utils/relay-probe/dns-probe.ts index 4ba8a6f4..feb95fc4 100644 --- a/src/utils/relay-probe/dns-probe.ts +++ b/src/utils/relay-probe/dns-probe.ts @@ -1,35 +1,46 @@ import { DnsRecord, ProbeTarget } from './types' export interface DnsResolver { - resolve4(hostname: string): Promise - resolve6(hostname: string): Promise - resolveCname(hostname: string): Promise + resolve4(hostname: string): Promise + resolve6(hostname: string): Promise + resolveCname(hostname: string): Promise +} + +type RecordWithTtl = { + address: string + ttl: number } export const createNodeDnsResolver = (): DnsResolver => { // Lazy import keeps unit tests on stubbed resolvers without touching the network. const dns = require('dns').promises as { - resolve4: (hostname: string) => Promise - resolve6: (hostname: string) => Promise + resolve4: (hostname: string, options: { ttl: true }) => Promise + resolve6: (hostname: string, options: { ttl: true }) => Promise resolveCname: (hostname: string) => Promise } return { - resolve4: (hostname) => dns.resolve4(hostname), - resolve6: (hostname) => dns.resolve6(hostname), - resolveCname: (hostname) => dns.resolveCname(hostname), + resolve4: async (hostname) => { + const entries = await dns.resolve4(hostname, { ttl: true }) + return entries.map(({ address, ttl }) => ({ type: 'A' as const, value: address, ttl })) + }, + resolve6: async (hostname) => { + const entries = await dns.resolve6(hostname, { ttl: true }) + return entries.map(({ address, ttl }) => ({ type: 'AAAA' as const, value: address, ttl })) + }, + resolveCname: async (hostname) => { + const values = await dns.resolveCname(hostname) + return values.map((value) => ({ type: 'CNAME' as const, value })) + }, } } const collectRecords = async (resolver: DnsResolver, target: ProbeTarget): Promise => { const records: DnsRecord[] = [] - const append = async (type: DnsRecord['type'], lookup: () => Promise) => { + const append = async (lookup: () => Promise) => { try { - const values = await lookup() - for (const value of values) { - records.push({ type, value }) - } + records.push(...(await lookup())) } catch (error: unknown) { const code = (error as NodeJS.ErrnoException)?.code if (code === 'ENOTFOUND' || code === 'ENODATA') { @@ -40,9 +51,9 @@ const collectRecords = async (resolver: DnsResolver, target: ProbeTarget): Promi } } - await append('CNAME', () => resolver.resolveCname(target.hostname)) - await append('A', () => resolver.resolve4(target.hostname)) - await append('AAAA', () => resolver.resolve6(target.hostname)) + await append(() => resolver.resolveCname(target.hostname)) + await append(() => resolver.resolve4(target.hostname)) + await append(() => resolver.resolve6(target.hostname)) return records } diff --git a/src/utils/relay-probe/index.ts b/src/utils/relay-probe/index.ts index e262a191..92d147b2 100644 --- a/src/utils/relay-probe/index.ts +++ b/src/utils/relay-probe/index.ts @@ -5,6 +5,7 @@ export { runProbe, } from './run-probe' export type { ProbeClients } from './run-probe' +export { resolveDnsRecords } from './dns-probe' export { isNip11FetchTargetSafe } from './nip11-probe' export { detectNetworkType, shouldSkipDnsProbe } from './target' export { diff --git a/src/utils/relay-probe/nip11-probe.ts b/src/utils/relay-probe/nip11-probe.ts index 0d044489..ceb9763b 100644 --- a/src/utils/relay-probe/nip11-probe.ts +++ b/src/utils/relay-probe/nip11-probe.ts @@ -9,8 +9,8 @@ const MAX_REDIRECTS = 1 const nip11DocumentSchema = z .object({ - name: z.string(), - pubkey: pubkeySchema, + name: z.string().optional(), + pubkey: pubkeySchema.optional(), }) .passthrough() diff --git a/src/utils/relay-probe/run-probe.ts b/src/utils/relay-probe/run-probe.ts index bfabe91f..ee5f1f94 100644 --- a/src/utils/relay-probe/run-probe.ts +++ b/src/utils/relay-probe/run-probe.ts @@ -121,8 +121,20 @@ export const runProbe = async ( const dnsCacheTtlSeconds = options.dnsCacheTtlSeconds ?? DEFAULT_DNS_CACHE_TTL_SECONDS const skipDnsCache = options.skipDnsCache ?? false + const dnsStartedAt = Date.now() + const dnsPromise = runDnsProbe(clients, target, { dnsCacheTtlSeconds, skipDnsCache }) + const dnsTimeout = new Promise>((resolve) => { + setTimeout(() => { + resolve({ + status: 'error', + durationMs: Date.now() - dnsStartedAt, + error: `DNS probe timed out after ${timeouts.dnsMs}ms`, + }) + }, timeouts.dnsMs).unref() + }) + const [dns, tls, wsRtt, nip11] = await Promise.all([ - runDnsProbe(clients, target, { dnsCacheTtlSeconds, skipDnsCache }), + Promise.race([dnsPromise, dnsTimeout]), runTimedProbe(() => probeTls(clients.tls, target, timeouts.tlsMs)), runTimedProbe(() => probeWebSocketRtt(clients.ws, target, timeouts.wsRttMs)), runTimedProbe(() => probeNip11(clients.nip11, target.nip11Url, timeouts.nip11Ms)), diff --git a/src/utils/relay-probe/ws-rtt-probe.ts b/src/utils/relay-probe/ws-rtt-probe.ts index aed9ca69..e7b1463e 100644 --- a/src/utils/relay-probe/ws-rtt-probe.ts +++ b/src/utils/relay-probe/ws-rtt-probe.ts @@ -11,7 +11,7 @@ export const createNodeWebSocketConnector = (): WebSocketConnector => { measureOpenRtt: (address, timeoutMs) => new Promise((resolve, reject) => { const startedAt = Date.now() - const socket = new WebSocket(address, { timeout: timeoutMs }) + const socket = new WebSocket(address, { handshakeTimeout: timeoutMs }) let settled = false const finish = (error?: Error, rttOpenMs?: number) => { diff --git a/test/unit/utils/relay-probe-dns.spec.ts b/test/unit/utils/relay-probe-dns.spec.ts new file mode 100644 index 00000000..613bd0de --- /dev/null +++ b/test/unit/utils/relay-probe-dns.spec.ts @@ -0,0 +1,23 @@ +import { expect } from 'chai' + +import { parseProbeTarget, resolveDnsRecords } from '../../../src/utils/relay-probe/index' + +describe('relay-probe dns-probe', () => { + it('collects A, AAAA, and CNAME records with TTL when available', async () => { + const target = parseProbeTarget('wss://relay.example.com') + const records = await resolveDnsRecords( + { + resolveCname: async () => [{ type: 'CNAME', value: 'cdn.example.com' }], + resolve4: async () => [{ type: 'A', value: '93.184.216.34', ttl: 300 }], + resolve6: async () => [{ type: 'AAAA', value: '2606:2800:220:1:248:1893:25c8:1946', ttl: 120 }], + }, + target, + ) + + expect(records).to.deep.equal([ + { type: 'CNAME', value: 'cdn.example.com' }, + { type: 'A', value: '93.184.216.34', ttl: 300 }, + { type: 'AAAA', value: '2606:2800:220:1:248:1893:25c8:1946', ttl: 120 }, + ]) + }) +}) diff --git a/test/unit/utils/relay-probe-run.spec.ts b/test/unit/utils/relay-probe-run.spec.ts new file mode 100644 index 00000000..27379535 --- /dev/null +++ b/test/unit/utils/relay-probe-run.spec.ts @@ -0,0 +1,143 @@ +import { expect } from 'chai' + +import { clearDnsProbeCache, runProbe } from '../../../src/utils/relay-probe/index' + +const PUBKEY = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' + +const makeClients = (overrides: Record = {}) => ({ + dns: { + resolve4: async () => [{ type: 'A' as const, value: '93.184.216.34', ttl: 300 }], + resolve6: async () => [], + resolveCname: async () => { + throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) + }, + }, + tls: { + connect: async () => ({ + valid: true, + issuer: 'Example CA', + subject: 'relay.example.com', + expiresAt: new Date('2027-01-01T00:00:00.000Z'), + daysUntilExpiry: 365, + }), + }, + ws: { measureOpenRtt: async () => 42 }, + nip11: { + fetch: async () => ({ + statusCode: 200, + name: 'relay.example.com', + pubkey: PUBKEY, + }), + }, + ...overrides, +}) + +describe('runProbe', () => { + afterEach(() => { + clearDnsProbeCache() + }) + + it('returns structured per-check results for a clearnet relay', async () => { + const result = await runProbe('wss://relay.example.com', {}, makeClients()) + + expect(result.target.hostname).to.equal('relay.example.com') + expect(result.dns.status).to.equal('ok') + expect(result.dns.data?.records).to.deep.include({ type: 'A', value: '93.184.216.34', ttl: 300 }) + expect(result.tls.status).to.equal('ok') + expect(result.wsRtt.status).to.equal('ok') + expect(result.nip11.status).to.equal('ok') + }) + + it('skips DNS for onion destinations', async () => { + const result = await runProbe( + 'wss://abc123def456.onion', + {}, + makeClients({ + dns: { + resolve4: async () => { + throw new Error('DNS should not be called for onion hosts') + }, + resolve6: async () => [], + resolveCname: async () => [], + }, + }), + ) + + expect(result.target.networkType).to.equal('tor') + expect(result.dns.status).to.equal('skipped') + }) + + it('uses the DNS cache on repeated probes', async () => { + let resolveCount = 0 + const clients = makeClients({ + dns: { + resolve4: async () => { + resolveCount += 1 + return [{ type: 'A' as const, value: '93.184.216.34', ttl: 300 }] + }, + resolve6: async () => [], + resolveCname: async () => { + throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) + }, + }, + }) + + await runProbe('wss://relay.example.com', {}, clients) + const second = await runProbe('wss://relay.example.com', {}, clients) + + expect(resolveCount).to.equal(1) + expect(second.dns.data?.fromCache).to.equal(true) + }) + + it('surfaces probe errors without failing the full run', async () => { + const result = await runProbe( + 'wss://relay.example.com', + {}, + makeClients({ + tls: { + connect: async () => { + throw new Error('certificate expired') + }, + }, + }), + ) + + expect(result.tls.status).to.equal('error') + expect(result.tls.error).to.include('certificate expired') + }) + + it('times out stalled DNS probes without blocking other checks', async () => { + const result = await runProbe( + 'wss://relay.example.com', + { timeouts: { dnsMs: 50, tlsMs: 50, wsRttMs: 50, nip11Ms: 50 } }, + makeClients({ + dns: { + resolve4: () => new Promise(() => undefined), + resolve6: async () => [], + resolveCname: async () => { + throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) + }, + }, + }), + ) + + expect(result.dns.status).to.equal('error') + expect(result.dns.error).to.include('DNS probe timed out') + expect(result.tls.status).to.equal('ok') + }).timeout(5000) + + it('accepts NIP-11 documents without name or pubkey fields', async () => { + const result = await runProbe( + 'wss://relay.example.com', + {}, + makeClients({ + nip11: { + fetch: async () => ({ statusCode: 200 }), + }, + }), + ) + + expect(result.nip11.status).to.equal('ok') + expect(result.nip11.data?.name).to.equal(undefined) + }) +}) diff --git a/test/unit/utils/relay-probe-target.spec.ts b/test/unit/utils/relay-probe-target.spec.ts new file mode 100644 index 00000000..a99d2505 --- /dev/null +++ b/test/unit/utils/relay-probe-target.spec.ts @@ -0,0 +1,38 @@ +import { expect } from 'chai' + +import { + detectNetworkType, + isNip11FetchTargetSafe, + parseProbeTarget, + shouldSkipDnsProbe, +} from '../../../src/utils/relay-probe/index' + +describe('relay-probe target parsing', () => { + it('parses wss relay URLs into HTTP and WebSocket probe targets', () => { + const target = parseProbeTarget('wss://relay.example.com/nostream') + + expect(target.hostname).to.equal('relay.example.com') + expect(target.networkType).to.equal('clearnet') + expect(target.httpOrigin).to.equal('https://relay.example.com') + expect(target.nip11Url).to.equal('https://relay.example.com/nostream/') + expect(target.wsUrl).to.equal('wss://relay.example.com/nostream') + }) + + it('detects tor and i2p destinations', () => { + expect(detectNetworkType('abc123.onion')).to.equal('tor') + expect(detectNetworkType('relay.i2p')).to.equal('i2p') + expect(shouldSkipDnsProbe('tor')).to.equal(true) + }) + + it('rejects non-websocket relay URLs', () => { + expect(() => parseProbeTarget('https://relay.example.com')).to.throw('ws:// or wss://') + }) +}) + +describe('relay-probe safety helpers', () => { + it('rejects unsafe NIP-11 fetch targets', () => { + expect(isNip11FetchTargetSafe('https://relay.example.com/')).to.equal(true) + expect(isNip11FetchTargetSafe('http://127.0.0.1/')).to.equal(false) + expect(isNip11FetchTargetSafe('ftp://relay.example.com/')).to.equal(false) + }) +}) diff --git a/test/unit/utils/relay-probe.spec.ts b/test/unit/utils/relay-probe.spec.ts deleted file mode 100644 index 8ccefc9b..00000000 --- a/test/unit/utils/relay-probe.spec.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { expect } from 'chai' - -import { - clearDnsProbeCache, - createDefaultProbeClients, - detectNetworkType, - isNip11FetchTargetSafe, - parseProbeTarget, - runProbe, - shouldSkipDnsProbe, -} from '../../../src/utils/relay-probe' -import { ProbeClients } from '../../../src/utils/relay-probe/run-probe' -import { TlsResult } from '../../../src/utils/relay-probe/types' - -const PUBKEY = '22e804d26ed16b68db5259e78449e96dab5d464c8f470bda3eb1a70467f2c793' - -describe('relay-probe target parsing', () => { - it('parses wss relay URLs into HTTP and WebSocket probe targets', () => { - const target = parseProbeTarget('wss://relay.example.com/nostream') - - expect(target.hostname).to.equal('relay.example.com') - expect(target.networkType).to.equal('clearnet') - expect(target.httpOrigin).to.equal('https://relay.example.com') - expect(target.nip11Url).to.equal('https://relay.example.com/nostream/') - expect(target.wsUrl).to.equal('wss://relay.example.com/nostream') - }) - - it('detects tor and i2p destinations', () => { - expect(detectNetworkType('abc123.onion')).to.equal('tor') - expect(detectNetworkType('relay.i2p')).to.equal('i2p') - expect(shouldSkipDnsProbe('tor')).to.equal(true) - }) - - it('rejects non-websocket relay URLs', () => { - expect(() => parseProbeTarget('https://relay.example.com')).to.throw('ws:// or wss://') - }) -}) - -describe('relay-probe safety helpers', () => { - it('rejects unsafe NIP-11 fetch targets', () => { - expect(isNip11FetchTargetSafe('https://relay.example.com/')).to.equal(true) - expect(isNip11FetchTargetSafe('http://127.0.0.1/')).to.equal(false) - expect(isNip11FetchTargetSafe('ftp://relay.example.com/')).to.equal(false) - }) -}) - -describe('runProbe', () => { - afterEach(() => { - clearDnsProbeCache() - }) - - const makeClients = (overrides: Partial = {}): ProbeClients => ({ - dns: { - resolve4: async () => ['93.184.216.34'], - resolve6: async () => [], - resolveCname: async () => { - throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) - }, - }, - tls: { - connect: async () => - ({ - valid: true, - issuer: 'Example CA', - subject: 'relay.example.com', - expiresAt: new Date('2027-01-01T00:00:00.000Z'), - daysUntilExpiry: 365, - }) satisfies TlsResult, - }, - ws: { - measureOpenRtt: async () => 42, - }, - nip11: { - fetch: async () => ({ - statusCode: 200, - name: 'relay.example.com', - pubkey: PUBKEY, - }), - }, - ...overrides, - }) - - it('returns structured per-check results for a clearnet relay', async () => { - const result = await runProbe('wss://relay.example.com', {}, makeClients()) - - expect(result.target.hostname).to.equal('relay.example.com') - expect(result.dns.status).to.equal('ok') - expect(result.dns.data?.records).to.deep.include({ type: 'A', value: '93.184.216.34' }) - expect(result.tls.status).to.equal('ok') - expect(result.tls.data?.valid).to.equal(true) - expect(result.wsRtt.status).to.equal('ok') - expect(result.wsRtt.data?.rttOpenMs).to.equal(42) - expect(result.nip11.status).to.equal('ok') - expect(result.nip11.data?.pubkey).to.equal(PUBKEY) - }) - - it('skips DNS for onion destinations', async () => { - const dns = { - resolve4: async () => { - throw new Error('DNS should not be called for onion hosts') - }, - resolve6: async () => [], - resolveCname: async () => [], - } - - const result = await runProbe('wss://abc123def456.onion', {}, makeClients({ dns })) - - expect(result.target.networkType).to.equal('tor') - expect(result.dns.status).to.equal('skipped') - expect(result.tls.status).to.equal('ok') - }) - - it('uses the DNS cache on repeated probes', async () => { - let resolveCount = 0 - const dns = { - resolve4: async () => { - resolveCount += 1 - return ['93.184.216.34'] - }, - resolve6: async () => [], - resolveCname: async () => { - throw Object.assign(new Error('ENODATA'), { code: 'ENODATA' }) - }, - } - - const clients = makeClients({ dns }) - - await runProbe('wss://relay.example.com', {}, clients) - const second = await runProbe('wss://relay.example.com', {}, clients) - - expect(resolveCount).to.equal(1) - expect(second.dns.data?.fromCache).to.equal(true) - }) - - it('surfaces probe errors without failing the full run', async () => { - const result = await runProbe( - 'wss://relay.example.com', - {}, - makeClients({ - tls: { - connect: async () => { - throw new Error('certificate expired') - }, - }, - }), - ) - - expect(result.dns.status).to.equal('ok') - expect(result.tls.status).to.equal('error') - expect(result.tls.error).to.include('certificate expired') - expect(result.wsRtt.status).to.equal('ok') - expect(result.nip11.status).to.equal('ok') - }) - - it('exposes default node-backed clients', () => { - const clients = createDefaultProbeClients() - - expect(clients.dns).to.have.property('resolve4') - expect(clients.tls).to.have.property('connect') - expect(clients.ws).to.have.property('measureOpenRtt') - expect(clients.nip11).to.have.property('fetch') - }) -})