/** * 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。 * * ## 它解决的唯一问题 * 兜底入口 `relay-direct.` 与主入口 `` **同属 `*.{base-domain}`**, * 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于 * "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果 * **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。 * * ## 为什么落在 DNS 层(而不是 dispatcher / 换 URL) * 本项目**不引入 `undici` / `ws`**(`client.ts` 用的是 Node 22 内建的全局 `WebSocket`)。 * WHATWG `WebSocket` 不接受自定义 `dispatcher` ⇒「换地址但保留 SNI」在 URL 层没有落点: * 把 URL 换成 IP 会**连带**把 SNI 换成 IP ⇒ 证书校验必然失败(除非关校验 = 权限/安全净变差)。 * 实测(2026-09-17,Node v22.22.2):全局 `WebSocket` 的建连**走 JS 层 `dns.lookup`** * ⇒ 在解析层做定向覆盖,是**零新依赖、不动默认路径**的最小实现。 * * ## 安全边界(为什么它不是"通用 hosts 劫持") * - **白名单语义**:只覆盖 `DSHS_OVERLAY_ADDR_OVERRIDES` 里**逐字列出**的域名;其余一律走原始 * `dns.lookup`。**未配 ⇒ 本模块完全不生效**,行为与今天逐字一致(零退化)。 * - **不写 DNS、不改 hosts、不碰 `/etc`**:只改本进程内的解析结果,进程退出即消失。 * - **失败关闭**:形状非法的条目**不安装**并写一行日志 —— 不静默忽略、更不回落成"通用覆盖"。 * - **零新增暴露面**:不监听端口、不新增依赖、不新增凭据。 * * ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5) * ``` * DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.= * ``` * 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。 * * @module dshs/net/relay/addr-override */ import { createRequire } from 'node:module' import { isIPv4, isIPv6 } from 'node:net' /** `dns.lookup` 的回调形状(只用到 `(err, address, family)` 与 `all: true` 两种)。 */ type LookupCallback = ( err: NodeJS.ErrnoException | null, address?: string | Array<{ address: string; family: number }>, family?: number, ) => void /** `node:dns` 的最小面(用 `createRequire` 取,保证改的是 **net/undici 实际用的那个单例**)。 */ interface DnsModule { lookup: (hostname: string, options?: unknown, callback?: LookupCallback) => void promises: { lookup: (hostname: string, options?: unknown) => Promise } } export interface AddrOverride { /** 小写域名(**逐字匹配**,不做后缀/通配匹配)。 */ host: string /** 覆盖后的直连地址。 */ ip: string /** 地址族(由 IP 字面量推得)。 */ family: 4 | 6 } /** 已安装的覆盖表 —— 模块级唯一状态(`ensureOverlayAddrOverrides` 幂等)。 */ const installed = new Map() /** 已经打过一次补丁(**只打一次**;反复打会把自己的包装再包一层)。 */ let patched = false /** 已经播报过的条目(避免每次建连都刷日志)。 */ const announced = new Set() /** 原始实现(仅供测试精确还原)。 */ let originalLookup: DnsModule['lookup'] | undefined let originalPromisesLookup: DnsModule['promises']['lookup'] | undefined /** 该字符串能不能当"域名"用:非空、含点、无协议/路径/端口/空白、不是 IP 字面量。 */ function isHostnameLike(host: string): boolean { if (host === '') return false if (!host.includes('.')) return false if (isIPv4(host) || isIPv6(host)) return false return /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(host) } /** IP 字面量 ⇒ 地址族;不是字面量 ⇒ `undefined`(**不接受主机名**:那会把"覆盖"变成"再解析一次")。 */ function ipFamily(ip: string): 4 | 6 | undefined { if (isIPv4(ip)) return 4 if (isIPv6(ip)) return 6 return undefined } /** * `host=ip`(逗号多值)⇒ 覆盖表。 * * `bad` 装**非法条目的原文** —— 调用方必须把它播出去:静默丢弃会让"我明明配了"看起来像 * "覆盖没生效",而这正是本项目反复踩过的"静默失败"形态。 */ export function parseAddrOverrides(raw: string): { list: AddrOverride[]; bad: string[] } { const list: AddrOverride[] = [] const bad: string[] = [] const seen = new Set() for (const item of raw.split(',')) { const spec = item.trim() if (spec === '') continue const eq = spec.indexOf('=') const host = eq < 0 ? '' : spec.slice(0, eq).trim().toLowerCase() const ip = eq < 0 ? '' : spec.slice(eq + 1).trim() const family = ipFamily(ip) if (!isHostnameLike(host) || family === undefined) { bad.push(spec) continue } if (seen.has(host)) continue seen.add(host) list.push({ host, ip, family }) } return { list, bad } } /** 按 host 造一个 `lookup` 结果(尊重 `all` / `family` 两个入参形状)。 */ function overriddenLookup( hit: AddrOverride, options: unknown, callback: LookupCallback, ): void { const opts = ( options === null || typeof options !== 'object' ? {} : options ) as { all?: boolean; family?: number } const family = opts.family === 4 || opts.family === 6 ? opts.family : hit.family // 请求的地址族与覆盖项不符 ⇒ 交回调用方语义(给 ENOTFOUND),**不要**回一个错族的地址。 if (family !== hit.family) { process.nextTick(() => { // `node:dns` 的 ENOTFOUND 带 `.hostname`(不在 `ErrnoException` 的公开类型里)⇒ 显式补上, // 让"配了 IPv4、却按 IPv6 问"这件事在报错里一眼可读。 const err = new Error( `getaddrinfo ENOTFOUND ${hit.host} (addr-override 只提供 IPv${hit.family})`, ) as NodeJS.ErrnoException & { hostname?: string } err.code = 'ENOTFOUND' err.errno = -3008 err.syscall = 'getaddrinfo' err.hostname = hit.host callback(err) }) return } if (opts.all === true) { process.nextTick(() => callback(null, [{ address: hit.ip, family: hit.family }])) return } process.nextTick(() => callback(null, hit.ip, hit.family)) } /** 把回调版 `lookup` 包一层:**只**拦覆盖表里的域名,其余原样转发。 */ function wrapLookup(orig: DnsModule['lookup']): DnsModule['lookup'] { return function patchedLookup(hostname: string, options?: unknown, callback?: LookupCallback): void { let opts = options let cb = callback if (typeof opts === 'function') { cb = opts as LookupCallback opts = {} } const hit = typeof hostname === 'string' ? installed.get(hostname.toLowerCase()) : undefined if (hit === undefined || cb === undefined) { orig(hostname, opts, cb as LookupCallback) return } overriddenLookup(hit, opts, cb) } } /** 打补丁(**只打一次**)。用 `createRequire` 取同一单例,确保 `net` / undici 都走新实现。 */ function installPatch(): void { const nodeDns = createRequire(import.meta.url)('node:dns') as DnsModule if (originalLookup === undefined) originalLookup = nodeDns.lookup if (originalPromisesLookup === undefined) originalPromisesLookup = nodeDns.promises.lookup nodeDns.lookup = wrapLookup(originalLookup) nodeDns.promises.lookup = async (hostname: string, options?: unknown): Promise => { const hit = typeof hostname === 'string' ? installed.get(hostname.toLowerCase()) : undefined if (hit === undefined) return originalPromisesLookup!(hostname, options) const opts = (options === null || typeof options !== 'object' ? {} : options) as { all?: boolean } if (opts.all === true) return [{ address: hit.ip, family: hit.family }] return { address: hit.ip, family: hit.family } } } /** * **幂等安装**:解析 `raw`(缺省读 `DSHS_OVERLAY_ADDR_OVERRIDES`)并装覆盖;返回当前生效的整表。 * * ⚠️ 幂等但**累加**:多次调用可把新域名并进来(重连 / 多个建连点共用),不会重复打补丁。 */ export function ensureOverlayAddrOverrides( raw: string = process.env.DSHS_OVERLAY_ADDR_OVERRIDES ?? '', log: (line: string) => void = (line) => process.stdout.write(`${line}\n`), ): readonly AddrOverride[] { const { list, bad } = parseAddrOverrides(raw) for (const spec of bad) { log(`[addr-override] ⛔ 忽略非法条目 "${spec}"(语法应为 <域名>=,逗号分隔)`) } // 未配 ⇒ **完全不生效**(连补丁都不打)⇒ 与今天逐字一致。 if (installed.size === 0 && list.length === 0) return [] for (const o of list) { const prev = installed.get(o.host) if (prev !== undefined && prev.ip !== o.ip) { log(`[addr-override] ⚠ ${o.host} 的覆盖地址被改写:${prev.ip} -> ${o.ip}`) announced.delete(`${o.host}=${o.ip}`) } installed.set(o.host, o) } if (!patched) { installPatch() patched = true log(`[addr-override] 已启用地址覆盖(白名单 ${installed.size} 条;DNS 层定向改写,SNI 仍为域名)`) } for (const o of installed.values()) { const key = `${o.host}=${o.ip}` if (announced.has(key)) continue announced.add(key) log(`[addr-override] 覆盖生效:${o.host} -> ${o.ip}(IPv${o.family},直连该 IP,TLS SNI 仍为 ${o.host})`) } return [...installed.values()] } /** 当前生效的覆盖表(只读快照)。 */ export function currentAddrOverrides(): readonly AddrOverride[] { return [...installed.values()] } /** * **仅供测试**:撤掉补丁并清空覆盖表。 * ⛔ 生产路径不许调用 —— 它会把"已生效"悄悄退化成"没生效"。 */ export function resetOverlayAddrOverridesForTest(): void { if (originalLookup !== undefined && originalPromisesLookup !== undefined) { const nodeDns = createRequire(import.meta.url)('node:dns') as DnsModule nodeDns.lookup = originalLookup nodeDns.promises.lookup = originalPromisesLookup } originalLookup = undefined originalPromisesLookup = undefined installed.clear() announced.clear() patched = false }