Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/nip66-probe-engine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"nostream": minor
---

feat(nip66): add shared relay probe engine for DNS, TLS, WebSocket RTT, and NIP-11 checks
3 changes: 2 additions & 1 deletion .knip.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
43 changes: 43 additions & 0 deletions src/utils/relay-probe/dns-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { DEFAULT_DNS_CACHE_TTL_SECONDS, DnsRecord, DnsResult } from './types'

type DnsCacheEntry = {
records: DnsRecord[]
expiresAt: number
}

const dnsCache = new Map<string, DnsCacheEntry>()

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,
})
}
69 changes: 69 additions & 0 deletions src/utils/relay-probe/dns-probe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { DnsRecord, ProbeTarget } from './types'

export interface DnsResolver {
resolve4(hostname: string): Promise<DnsRecord[]>
resolve6(hostname: string): Promise<DnsRecord[]>
resolveCname(hostname: string): Promise<DnsRecord[]>
}

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<RecordWithTtl[]>
resolve6: (hostname: string, options: { ttl: true }) => Promise<RecordWithTtl[]>
resolveCname: (hostname: string) => Promise<string[]>
}

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<DnsRecord[]> => {
const records: DnsRecord[] = []

const append = async (lookup: () => Promise<DnsRecord[]>) => {
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<DnsRecord[]> => {
const records = await collectRecords(resolver, target)

if (records.length === 0) {
throw new Error(`No DNS records found for ${target.hostname}`)
}

return records
}
28 changes: 28 additions & 0 deletions src/utils/relay-probe/index.ts
Original file line number Diff line number Diff line change
@@ -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'
113 changes: 113 additions & 0 deletions src/utils/relay-probe/nip11-probe.ts
Original file line number Diff line number Diff line change
@@ -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<Nip11Result>
}

/**
* 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<Nip11Result> => {
return fetcher.fetch(url, timeoutMs)
}
Loading
Loading