Files
deepseek-harness/packages/web/web-fetch-http/src/network.ts
T

253 lines
10 KiB
TypeScript

/**
* Public-network resolution and address-pinned HTTP transport for `web-fetch-http`.
* One DNS answer set is validated before Undici receives it through a custom lookup,
* so the connection cannot resolve the hostname again to a private address.
*
* @module @deepseek-ai/dsh-web-fetch-http/network
*/
import { lookup as systemLookup } from 'node:dns/promises'
import type { LookupAddress, LookupOptions } from 'node:dns'
import { isIP } from 'node:net'
import type { Response } from 'undici'
import ipaddr from 'ipaddr.js'
import { WebError } from '@deepseek-ai/dsh-web'
/** One address resolved and retained for the subsequent pinned connection. */
export interface PublicAddress {
/** Canonical textual IPv4 or IPv6 address. */
readonly address: string
/** Address family accepted by Node's connection lookup callback. */
readonly family: 4 | 6
}
/** The result of one address-pinned request; closing releases its private pool. */
export interface PinnedResponse {
/** HTTP response whose body remains readable until `close()` is called. */
readonly response: Response
/** Release the request's dispatcher after the response body is consumed or cancelled. */
close(): Promise<void>
}
/** Resolver signature used to test public-address policy without process DNS changes. */
export type AddressResolver = (hostname: string, options: { all: true; order: 'verbatim' }) => Promise<LookupAddress[]>
/** RFC 6052 prefix lengths that may carry an IPv4 destination through NAT64. */
const RFC6052_PREFIX_LENGTHS = [32, 40, 48, 56, 64, 96] as const
const IPV4ONLY_DISCOVERY_HOST = 'ipv4only.arpa'
const IPV4ONLY_SENTINELS = new Set(['192.0.0.170', '192.0.0.171'])
interface Nat64Prefix {
readonly bytes: readonly number[]
readonly length: typeof RFC6052_PREFIX_LENGTHS[number]
}
/**
* Return whether an address is globally reachable unicast. IPv4-mapped IPv6 is
* classified by its embedded IPv4 address; transition and translation prefixes
* remain blocked because their eventual IPv4 destination cannot be pinned here.
*
* @param input - textual IPv4 or IPv6 address.
* @returns true only for a public unicast destination.
*/
export function isPublicIpAddress(input: string): boolean {
let parsed: ipaddr.IPv4 | ipaddr.IPv6
try {
parsed = ipaddr.parse(stripIpv6Brackets(input))
} catch {
return false
}
if (parsed instanceof ipaddr.IPv4) return parsed.range() === 'unicast'
if (parsed.isIPv4MappedAddress()) return parsed.toIPv4Address().range() === 'unicast'
return parsed.range() === 'unicast'
}
/**
* Resolve a hostname once and reject the complete answer set if any destination
* is not public. The returned addresses are the only ones the transport may use.
*
* @param hostname - URL hostname, including brackets when it is an IPv6 literal.
* @param signal - aborts the wait for system resolution; an in-flight OS lookup may finish unused.
* @param resolver - lookup implementation, overridden only by focused tests.
* @returns the validated, non-empty address set.
*/
export async function resolvePublicAddresses(
hostname: string,
signal: AbortSignal,
resolver: AddressResolver = systemLookup,
): Promise<PublicAddress[]> {
const unbracketed = stripIpv6Brackets(hostname)
const literalFamily = isIP(unbracketed)
const resolved = literalFamily === 0
? await raceWithSignal(resolver(unbracketed, { all: true, order: 'verbatim' }), signal)
: [{ address: unbracketed, family: literalFamily }]
if (resolved.length === 0) {
throw new WebError(`hostname "${hostname}" resolved to no addresses`, 'WEB_PROVIDER_ERROR')
}
const hasIpv6 = resolved.some(entry => entry.family === 6 && isIP(entry.address) === 6)
const nat64Prefixes = hasIpv6
? await discoverNat64Prefixes(signal, resolver)
: []
const addresses: PublicAddress[] = []
for (const entry of resolved) {
if ((entry.family !== 4 && entry.family !== 6) || isIP(entry.address) !== entry.family) {
throw new WebError(`hostname "${hostname}" resolved to an invalid IP address`, 'WEB_PROVIDER_ERROR')
}
if (!isPublicIpAddress(entry.address)) {
throw new WebError(`URL hostname "${hostname}" resolves to a non-public IP address`, 'WEB_BLOCKED_URL')
}
const translatedIpv4 = translatedIpv4Address(entry.address, nat64Prefixes)
if (translatedIpv4 !== undefined && !isPublicIpAddress(translatedIpv4)) {
throw new WebError(`URL hostname "${hostname}" resolves through NAT64 to a non-public IPv4 address`, 'WEB_BLOCKED_URL')
}
addresses.push({ address: entry.address, family: entry.family })
}
return addresses
}
/** Discover the active DNS64 prefix set using RFC 7050's reserved hostname. */
async function discoverNat64Prefixes(signal: AbortSignal, resolver: AddressResolver): Promise<Nat64Prefix[]> {
const discovered = await raceWithSignal(
resolver(IPV4ONLY_DISCOVERY_HOST, { all: true, order: 'verbatim' }),
signal,
)
const prefixes: Nat64Prefix[] = []
const seen = new Set<string>()
for (const entry of discovered) {
if (entry.family !== 6 || isIP(entry.address) !== 6) continue
const bytes = ipaddr.parse(entry.address).toByteArray()
for (const length of RFC6052_PREFIX_LENGTHS) {
const embedded = embeddedIpv4Address(bytes, length)
if (embedded === undefined || !IPV4ONLY_SENTINELS.has(embedded)) continue
const prefixBytes = bytes.slice(0, length / 8)
const key = `${String(length)}:${prefixBytes.join('.')}`
if (seen.has(key)) continue
seen.add(key)
prefixes.push({ bytes: prefixBytes, length })
}
}
return prefixes
}
/** Return the RFC 6052-embedded IPv4 address when an IPv6 address matches a discovered prefix. */
function translatedIpv4Address(input: string, prefixes: readonly Nat64Prefix[]): string | undefined {
if (isIP(input) !== 6) return undefined
const bytes = ipaddr.parse(input).toByteArray()
for (const prefix of prefixes) {
if (!prefix.bytes.every((byte, index) => bytes[index] === byte)) continue
const embedded = embeddedIpv4Address(bytes, prefix.length)
if (embedded !== undefined) return embedded
}
return undefined
}
/** Extract one IPv4 address from an RFC 6052 IPv6 layout. */
function embeddedIpv4Address(bytes: readonly number[], prefixLength: Nat64Prefix['length']): string | undefined {
if (prefixLength === 96) return bytes.slice(12, 16).join('.')
if (bytes[8] !== 0) return undefined
const prefixBytes = prefixLength / 8
const beforeReservedOctet = 8 - prefixBytes
const ipv4 = [
...bytes.slice(prefixBytes, prefixBytes + beforeReservedOctet),
...bytes.slice(9, 9 + 4 - beforeReservedOctet),
]
return ipv4.join('.')
}
/**
* Fetch through an Undici agent whose lookup callback returns only the already
* validated address set. The URL hostname remains intact for HTTP Host and TLS SNI.
*
* @param url - validated HTTP(S) URL.
* @param addresses - public addresses returned by {@link resolvePublicAddresses}.
* @param headers - request headers.
* @param signal - request and body-read cancellation signal.
* @returns a response plus the dispatcher disposer its consumer must call.
*/
export async function requestPinned(
url: URL,
addresses: readonly PublicAddress[],
headers: Record<string, string>,
signal: AbortSignal,
): Promise<PinnedResponse> {
// Keep the Node-only transport out of browser-worker startup. The preview
// can load the provider and fail loud at its DNS stub without evaluating
// Undici; a real request on Node resolves this maintained dependency here.
const { Agent, fetch } = await import('undici')
const dispatcher = new Agent({
autoSelectFamily: true,
connect: { lookup: createPinnedLookup(addresses) },
})
try {
const response = await fetch(url, { method: 'GET', redirect: 'manual', headers, signal, dispatcher })
return { response, close: async () => { await dispatcher.close() } }
} catch (error: unknown) {
await dispatcher.close()
throw error
}
}
/** Production network operations kept as an object so provider tests can replace resolution only. */
export const publicHttpNetwork = {
resolve: resolvePublicAddresses,
request: requestPinned,
}
type LookupCallback = (
error: NodeJS.ErrnoException | null,
address: string | LookupAddress[],
family?: number,
) => void
/**
* Build the connector lookup that serves a fixed validated answer set.
*
* @param addresses - public addresses retained from the preceding resolution.
* @returns a Node-compatible lookup callback that performs no network resolution.
*/
export function createPinnedLookup(addresses: readonly PublicAddress[]): (
hostname: string,
options: LookupOptions,
callback: LookupCallback,
) => void {
return (hostname: string, options: LookupOptions, callback: LookupCallback): void => {
const family = typeof options.family === 'number'
? options.family
: options.family === 'IPv4' ? 4 : options.family === 'IPv6' ? 6 : 0
const eligible = family === 0 ? addresses : addresses.filter(address => address.family === family)
const selected = eligible[0]
if (selected === undefined) {
const error = Object.assign(new Error(`no validated address for ${hostname} in family ${family}`), {
code: 'ENOTFOUND',
hostname,
})
callback(error, options.all === true ? [] : '', family)
return
}
if (options.all === true) {
callback(null, eligible.map(address => ({ ...address })))
return
}
callback(null, selected.address, selected.family)
}
}
/** Race a non-cancellable OS lookup without letting it delay tool cancellation. */
function raceWithSignal<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
const abortError = () => new Error('web fetch aborted during hostname resolution', { cause: signal.reason })
if (signal.aborted) return Promise.reject(abortError())
return new Promise<T>((resolve, reject) => {
const abort = () => { reject(abortError()) }
signal.addEventListener('abort', abort, { once: true })
promise.then(resolve, reject).finally(() => { signal.removeEventListener('abort', abort) })
})
}
/** WHATWG URL retains brackets around IPv6 hostnames; IP parsers do not. */
function stripIpv6Brackets(hostname: string): string {
return hostname.startsWith('[') && hostname.endsWith(']') ? hostname.slice(1, -1) : hostname
}