Files
dsh_ai1net_server/src/net/relay/addr-override.ts
T
admin 971ccc3703 feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)
三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

【档案 134 · 注册页人机验证 + 邮箱验证码】
- DB 迁移 v10:users.email(唯一索引 LOWER(email))+ email_codes 事件表(2 索引)
- 新增模块 src/web/{register-guard,mail,turnstile,email-code}.ts
- routes/auth.ts:新增 GET /api/auth/register/config、POST /api/auth/register/email-code;
  注册接口加人机验证与验证码校验;config.ts 新增 12 项配置(默认空 ⇒ 不配 = 老行为)
- 邮件走**可插拔驱动**(brevo/http/log),发件人 [email protected](Brevo 域名已认证 + DKIM + SPF)
- 防爆破:三层配额(邮箱 6/h、8/天;IP 20/h;全局 200/h)+ 递增冷却阶梯
  (60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用 + 与用户名绑定
- Turnstile 服务端校 **success + action + hostname 三项**:sitekey 是公开的,
  只校 success 时"拿我们的 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的
- 新增 test/register-guard.test.mjs(19 用例)

【档案 137 · 品牌标识改造 — 去 DeepSeek 图形】
- login/register/admin 页头:删 DeepSeek 鲸鱼图标 + 「DeepSeek」文字图形
  → 平台标识(中文「能力枢纽」/英语及其他语言「CapabilityNet」,走 i18n 词条 brand.name)
- portal 顶栏换图标(页面名「管理门户」保留)
- 新建 web/favicon.svg(平台自有 hub 图标,避开 DeepSeek 蓝)+ 四页 favicon 指向它
- 新增 test/i18n-brand.test.mjs(node:vm 跑真实 i18n.js,六条语言路径断言渲染结果)
- scripts/verify-static.mjs 新增 SVG 段:XML 注释不得含 ASCII 双连字符(否则整份 SVG
  解析失败、图标静默不显示 —— 实际踩到过)
- 🔴 会话页面(实例内官方 dsh 界面)的标识**按用户要求未动**(也受 R2 约束)

【档案 135/136 · 域名迁移线(另一会话产出)】
- 域名收敛为 ai1net.com;旧域 alotbuy.com 降级为 301 过渡装置
- src/net/relay/{addr-override,directory,rendezvous,switcher}.ts 种子与候选链更新;
  src/web/server.ts、src/worker/relay-tunnel.ts、scripts/verify-cluster-domain.mjs
- 档案 136 = 控制面按两台中继取并集(**已立项、未落地**)

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
2026-09-19 09:11:24 +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.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**,
* 而该泛解析被 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.ai1net.com=47.77.182.89
* ```
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 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
}