Files
dsh_ai1net_server/src/net/relay/addr-override.ts
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +08:00

235 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
*
* ## 它解决的唯一问题
* 兜底入口 `relay-direct.<base-domain>` 与主入口 `<base-domain>` **同属 `*.{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.<base-domain>=<server-public-ip>
* ```
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 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<unknown> }
}
export interface AddrOverride {
/** 小写域名(**逐字匹配**,不做后缀/通配匹配)。 */
host: string
/** 覆盖后的直连地址。 */
ip: string
/** 地址族(由 IP 字面量推得)。 */
family: 4 | 6
}
/** 已安装的覆盖表 —— 模块级唯一状态(`ensureOverlayAddrOverrides` 幂等)。 */
const installed = new Map<string, AddrOverride>()
/** 已经打过一次补丁(**只打一次**;反复打会把自己的包装再包一层)。 */
let patched = false
/** 已经播报过的条目(避免每次建连都刷日志)。 */
const announced = new Set<string>()
/** 原始实现(仅供测试精确还原)。 */
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<string>()
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<unknown> => {
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}"(语法应为 <域名>=<IP>,逗号分隔)`)
}
// 未配 ⇒ **完全不生效**(连补丁都不打)⇒ 与今天逐字一致。
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
}