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。
This commit is contained in:
admin committed 2026-09-19 09:11:24 +08:00
1 parent d2ef362a98
commit 971ccc3703
56 files changed
+3498 -193

No files matched your search

+209 -3
View File
@@ -17,6 +17,9 @@ export type IsolationMode = 'soft' | 'account'
* `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */
export type DeployMode = 'local' | 'k8s' | 'cluster'
/** 外发邮件驱动(注册验证码)。见 `web/mail.ts`。 */
export type MailDriver = 'brevo' | 'http' | 'log'
/** Resolved, immutable runtime configuration. */
export interface ServerConfig {
/** Bind host for the orchestrator HTTP server. */
@@ -119,7 +122,7 @@ export interface ServerConfig {
*/
clusterRendezvousUrl: string
/**
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://ai1net.com/dshs-relay`。
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
*/
relayUrl: string
@@ -195,6 +198,57 @@ export interface ServerConfig {
overlayNodeKeyFile: string
/** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */
overlayNodeGrantFile: string
// ── 注册页人机验证 + 邮箱验证码(档案 134;`web/turnstile.ts` / `web/mail.ts`)──────
/**
* Cloudflare Turnstile 站点公钥(**会下发到注册页**,不是秘密)。
* ⚠️ 与 `turnstileSecret` **必须成对**:只填一把 = 视为未配置(人机验证整体不启用)。
*/
turnstileSiteKey: string
/** Turnstile 服务端密钥。空 ⇒ 人机验证停用(注册页不渲染 widget、后端不校验)。 */
turnstileSecret: string
/**
* 期望的 `action`(渲染 widget 时声明、siteverify 时回显)。默认 `signup`。
* 不校它 ⇒ 同 sitekey 的各个入口共享 token,人机验证退化成"过一处即可用到处"。
*/
turnstileAction: string
/**
* 🔴 期望的**前端主机名**白名单(`result.hostname` 必须在此列)。
*
* sitekey 是公开的(就在页面 HTML 里)⇒ 攻击者可在**自己站点**嵌入我们的 sitekey、
* 为真人访客拿到合法 token,再拿去打我们的注册接口;只校 `success` 的话这条路完全通畅。
* `hostname` 由 **Cloudflare 服务端**判定并回显,访客篡改不了 ⇒ 只有校它才能把 token
* 真正绑到"从我们站点发出的挑战"上。
* 默认从 `baseDomain` 派生(`<domain>` + `www.<domain>`);**空数组 = 不安全 ⇒ 视为未配置完成**。
*/
turnstileHostnames: string[]
/** 邮件驱动:`brevo` / `http` / `log`。`log` 只在开发排障时用(验证码会进 journald)。 */
mailDriver: MailDriver
mailApiUrl: string
mailApiKey: string
mailAuthHeader: string
mailFrom: string
mailFromName: string
mailBodyTemplate: string
mailTimeoutMs: number
/**
* 注册是否**强制**邮箱验证码。默认 `true`,但**仅当邮件通道已配置**时才真正生效
* (见 `mailEnabled`)—— 这样"没配邮件"的环境不会因为缺配置而注册不了。
*/
registerRequireEmailCode: boolean
/** 注册是否强制人机验证。同理:只在 `turnstileSecret` 有值时才生效。 */
registerRequireCaptcha: boolean
/** 验证码有效期(毫秒)。 */
emailCodeTtlMs: number
/** 单个验证码允许的最大试错次数(达上限即作废)。 */
emailCodeMaxAttempts: number
/** 反爆破策略:限额与冷却阶梯(见 `web/register-guard.ts`)。 */
emailCodeGuard: {
emailPerHour: number
emailSentPerDay: number
ipPerHour: number
globalPerHour: number
cooldownLadderMs: number[]
}
}
/** Untyped overrides collected from argv / env. */
@@ -258,6 +312,22 @@ export interface ConfigOverrides {
overlayDirTrustedKeys?: string[]
overlayDirKeyFile?: string
overlayDirectoryCacheFile?: string
turnstileSiteKey?: string
turnstileSecret?: string
turnstileAction?: string
turnstileHostnames?: string[]
mailDriver?: MailDriver | string
mailApiUrl?: string
mailApiKey?: string
mailAuthHeader?: string
mailFrom?: string
mailFromName?: string
mailBodyTemplate?: string
mailTimeoutMs?: number | string
registerRequireEmailCode?: boolean
registerRequireCaptcha?: boolean
emailCodeTtlMs?: number | string
emailCodeMaxAttempts?: number | string
}
const DEFAULT_HOST = '127.0.0.1'
@@ -304,12 +374,30 @@ const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
const DEFAULT_K8S_NAMESPACE = 'dsh'
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
// 注册页人机验证 + 邮箱验证码(档案 134)
const DEFAULT_REGISTER_REQUIRE_EMAIL_CODE = true
const DEFAULT_REGISTER_REQUIRE_CAPTCHA = true
const DEFAULT_MAIL_TIMEOUT_MS = 10_000
const DEFAULT_TURNSTILE_TIMEOUT_MS = 8_000
/** Turnstile `action` 默认值:与注册页渲染时声明的一致(见 `web/register.html`)。 */
const DEFAULT_TURNSTILE_ACTION = 'signup'
const DEFAULT_EMAIL_CODE_TTL_MS = 10 * 60 * 1000
const DEFAULT_EMAIL_CODE_MAX_ATTEMPTS = 5
const DEFAULT_EMAIL_GUARD = {
// 6 = 冷却阶梯每一级都可达(索引 0…5);见 `web/register-guard.ts` 的说明。
emailPerHour: 6,
emailSentPerDay: 8,
ipPerHour: 20,
globalPerHour: 200,
// 索引 = 该邮箱最近一小时内已发起的次数(超出取最后一项)⇒ 冷却随重试次数递增。
cooldownLadderMs: [60_000, 60_000, 180_000, 300_000, 900_000, 1_800_000],
}
/**
* 覆盖网络 P0-2:**内置种子**(引导链的常量位)。
* 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。
* ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。
*/
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://alotbuy.com/dshs-relay']
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://ai1net.com/dshs-relay']
/** Load the encryption secret from env, or persist a generated one at
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
@@ -350,6 +438,77 @@ function parseCidrs(value: string | undefined): string[] {
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
}
/**
* 邮件驱动解析(档案 134)。**不认识的值回落 `brevo` 而不是报错** ——
* 写错一个字母就打不开注册页,代价远大于"驱动没换成"。
*/
function toMailDriver(value: string | undefined): MailDriver {
const v = (value ?? '').trim().toLowerCase()
return v === 'http' || v === 'log' || v === 'brevo' ? v : 'brevo'
}
/**
* Turnstile `action` 归一:CF 规定 1–32 字符、仅 `[A-Za-z0-9_-]`。
* 非法值 ⇒ 回落默认(而不是抛错):`action` 只是"这枚 token 属于哪个业务"的标签,
* 打错字不该让注册页起不来。
*/
function normalizeAction(value: string | undefined): string {
const v = (value ?? '').trim()
return /^[A-Za-z0-9_-]{1,32}$/.test(v) ? v : DEFAULT_TURNSTILE_ACTION
}
/**
* 主机名归一:去掉协议 / 路径 / 端口 / 首尾点,转小写并去重。
* 为什么要容错:运维很容易把 env 写成 `https://ai1net.com/`(照抄 URL 的习惯),
* 而 CF 回显的是**裸主机名** ⇒ 不归一就是"配了却永远不匹配"的静默失效。
*/
export function normalizeHostnames(values: readonly string[]): string[] {
const out: string[] = []
for (const raw of values) {
const host = raw
.trim()
.toLowerCase()
.replace(/^[a-z]+:\/\//, '')
.split('/')[0]
.split(':')[0]
.replace(/^\.+|\.+$/g, '')
if (host !== '' && !out.includes(host)) out.push(host)
}
return out
}
/**
* 期望主机名:**显式配了就用配的**(空 ⇒ 视为未配置完成,交由 `turnstileEnabled` 判停用),
* 否则从 `baseDomain` 派生 `<domain>` + `www.<domain>`。
* ⚠️ **绝不自动加 `localhost` / `127.0.0.1`** —— 生产后端的白名单里放它们 = 放开本地伪造。
*/
export function resolveTurnstileHostnames(configured: readonly string[] | undefined, baseDomain: string): string[] {
if (configured !== undefined && configured.length > 0) return normalizeHostnames(configured)
if (configured !== undefined) return [] // 显式空 = 关闭(不派生)
const domain = baseDomain.trim().toLowerCase()
return domain === '' ? [] : normalizeHostnames([domain, `www.${domain}`])
}
/** 正整数解析(0 / 负数 / 非数字 / 空 ⇒ `fallback`)。用于各种毫秒数与次数上限。 */
function toPositiveInt(value: string | number | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback
const n = Number(value)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
}
/**
* 冷却阶梯解析:env 给的是**秒**的逗号分隔列表(`60,60,180,300,900,1800`),
* 内部一律用毫秒。空 / 全非法 ⇒ 回落默认阶梯。
*/
function parseSecondsLadder(value: string | undefined, fallback: number[]): number[] {
if (value === undefined || value === '') return [...fallback]
const seconds = value
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n > 0)
return seconds.length === 0 ? [...fallback] : seconds.map((s) => Math.floor(s) * 1000)
}
/**
* 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回
* `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。
@@ -395,6 +554,8 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
toDeployMode(overrides.deployMode) ??
toDeployMode(process.env.DSHS_DEPLOY_MODE) ??
DEFAULT_DEPLOY_MODE
// `baseDomain` 提前算出:Turnstile 的**期望主机名白名单**要从它派生(见下)。
const baseDomainResolved = overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN
return {
host: overrides.host ?? DEFAULT_HOST,
port: typeof port === 'number' ? port : Number(port),
@@ -447,7 +608,7 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
isolationMode,
spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND,
baseUid: Number(overrides.baseUid ?? process.env.DSHS_BASE_UID ?? DEFAULT_BASE_UID),
baseDomain: overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN,
baseDomain: baseDomainResolved,
cookieDomain: overrides.cookieDomain ?? process.env.DSHS_COOKIE_DOMAIN ?? DEFAULT_COOKIE_DOMAIN,
enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH),
portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false),
@@ -529,5 +690,50 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
// 表现成"一切正常",等 relay 一开强制就整台失联)。
overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(),
overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '').trim(),
// ── 注册页人机验证 + 邮箱验证码(档案 134)────────────────────────────────
// 主键一律**默认空** ⇒ 「不配任何 env = 与今天行为完全一致」;配齐了才启用。
// 这一条是刻意的:注册是平台唯一的入口,不能因为漏配一个 env 就把注册锁死。
turnstileSiteKey: (overrides.turnstileSiteKey ?? process.env.DSHS_TURNSTILE_SITE_KEY ?? '').trim(),
turnstileSecret: (overrides.turnstileSecret ?? process.env.DSHS_TURNSTILE_SECRET ?? '').trim(),
turnstileAction: normalizeAction(overrides.turnstileAction ?? process.env.DSHS_TURNSTILE_ACTION),
turnstileHostnames: resolveTurnstileHostnames(
overrides.turnstileHostnames ?? splitList(process.env.DSHS_TURNSTILE_HOSTNAMES),
baseDomainResolved,
),
mailDriver: toMailDriver(overrides.mailDriver ?? process.env.DSHS_MAIL_DRIVER),
mailApiUrl: (overrides.mailApiUrl ?? process.env.DSHS_MAIL_API_URL ?? '').trim(),
mailApiKey: (overrides.mailApiKey ?? process.env.DSHS_MAIL_API_KEY ?? '').trim(),
mailAuthHeader: (overrides.mailAuthHeader ?? process.env.DSHS_MAIL_AUTH_HEADER ?? '').trim(),
mailFrom: (overrides.mailFrom ?? process.env.DSHS_MAIL_FROM ?? '').trim(),
mailFromName: (overrides.mailFromName ?? process.env.DSHS_MAIL_FROM_NAME ?? '').trim(),
mailBodyTemplate: (overrides.mailBodyTemplate ?? process.env.DSHS_MAIL_BODY_TEMPLATE ?? '').trim(),
mailTimeoutMs: toPositiveInt(
overrides.mailTimeoutMs ?? process.env.DSHS_MAIL_TIMEOUT_MS,
DEFAULT_MAIL_TIMEOUT_MS,
),
registerRequireEmailCode:
overrides.registerRequireEmailCode ??
toBool(process.env.DSHS_REGISTER_REQUIRE_EMAIL_CODE, DEFAULT_REGISTER_REQUIRE_EMAIL_CODE),
registerRequireCaptcha:
overrides.registerRequireCaptcha ??
toBool(process.env.DSHS_REGISTER_REQUIRE_CAPTCHA, DEFAULT_REGISTER_REQUIRE_CAPTCHA),
emailCodeTtlMs: toPositiveInt(
overrides.emailCodeTtlMs ?? process.env.DSHS_EMAIL_CODE_TTL_MS,
DEFAULT_EMAIL_CODE_TTL_MS,
),
emailCodeMaxAttempts: toPositiveInt(
overrides.emailCodeMaxAttempts ?? process.env.DSHS_EMAIL_CODE_MAX_ATTEMPTS,
DEFAULT_EMAIL_CODE_MAX_ATTEMPTS,
),
emailCodeGuard: {
emailPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_EMAIL_HOUR, DEFAULT_EMAIL_GUARD.emailPerHour),
emailSentPerDay: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_EMAIL_DAY, DEFAULT_EMAIL_GUARD.emailSentPerDay),
ipPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_IP_HOUR, DEFAULT_EMAIL_GUARD.ipPerHour),
globalPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_GLOBAL_HOUR, DEFAULT_EMAIL_GUARD.globalPerHour),
cooldownLadderMs: parseSecondsLadder(
process.env.DSHS_EMAIL_COOLDOWN_LADDER_SEC,
DEFAULT_EMAIL_GUARD.cooldownLadderMs,
),
},
}
}