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..feb95fc4 --- /dev/null +++ b/src/utils/relay-probe/dns-probe.ts @@ -0,0 +1,69 @@ +import { DnsRecord, ProbeTarget } from './types' + +export interface DnsResolver { + 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, options: { ttl: true }) => Promise + resolve6: (hostname: string, options: { ttl: true }) => Promise + resolveCname: (hostname: string) => Promise + } + + return { + 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 (lookup: () => Promise) => { + try { + records.push(...(await lookup())) + } catch (error: unknown) { + const code = (error as NodeJS.ErrnoException)?.code + if (code === 'ENOTFOUND' || code === 'ENODATA') { + return + } + + throw error + } + } + + await append(() => resolver.resolveCname(target.hostname)) + await append(() => resolver.resolve4(target.hostname)) + await append(() => 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..92d147b2 --- /dev/null +++ b/src/utils/relay-probe/index.ts @@ -0,0 +1,28 @@ +export { + clearDnsProbeCache, + createDefaultProbeClients, + parseProbeTarget, + 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 { + 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..ceb9763b --- /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().optional(), + pubkey: pubkeySchema.optional(), + }) + .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..ee5f1f94 --- /dev/null +++ b/src/utils/relay-probe/run-probe.ts @@ -0,0 +1,153 @@ +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 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([ + 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)), + ]) + + 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..e7b1463e --- /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, { handshakeTimeout: 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-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) + }) +})