mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
253 lines
10 KiB
TypeScript
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
|
|
}
|