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:
1 parent
d2ef362a98
commit
971ccc3703
56 files changed
+3498
-193
No files matched your search
@@ -238,9 +238,9 @@ node /opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 门户 / 用户实例 | `https://alotbuy.com` / `https://<用户名>.alotbuy.com`(旧域 `dsh.alotbuy.com` 及子域 **301**,含用户名映射) |
|
||||
| 环境变量 | `DSHS_BASE_DOMAIN=alotbuy.com`、`COOKIE_DOMAIN=.alotbuy.com`(备份 `/etc/dshs.env.bak-*`) |
|
||||
| 证书 | `/etc/letsencrypt/live/alotbuy.com/`(`alotbuy.com + *.alotbuy.com`,DNS-01);旧域证书仅用于 301 |
|
||||
| 门户 / 用户实例 | `https://ai1net.com` / `https://<用户名>.ai1net.com`(旧域 `alotbuy.com` 自 **2026-09-19** 降为 301 过渡装置) |
|
||||
| 环境变量 | `DSHS_BASE_DOMAIN=ai1net.com`、`COOKIE_DOMAIN=.ai1net.com`(备份 `/etc/dshs.env.bak-*`) |
|
||||
| 证书 | `/etc/letsencrypt/live/ai1net.com/`(`ai1net.com + *.ai1net.com`,DNS-01 via `/etc/cloudflare-ai1net.ini`);旧域 `/etc/letsencrypt/live/alotbuy.com/` 仅用于过渡期 301 |
|
||||
| **签发/续期必须带传播等待** | `certbot … --dns-cloudflare-propagation-seconds 60`(默认 10 s 会失败;renewal conf 已写死该值) |
|
||||
| 切换脚本 | `/opt/dsh/switch-domain-alotbuy.sh`(改 env → drain → 重启,幂等) |
|
||||
| Cloudflare | token 仅 DNS 权限;回源实测走 **443**;`*.alotbuy.com` 为共享通配(gao/mao/work 在用,勿改) |
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
# 134-注册页人机验证与邮箱验证码(2026-09-19 落地)
|
||||
|
||||
> **一句话**:注册页从「用户名 + 密码」变成「用户名 + 密码 + 邮箱验证码(+ 人机验证)」,并配一套
|
||||
> **可解释、可审计、重启不清零**的防爆破策略。
|
||||
> **触发**:用户 2026-09-19 原话 —— 「注册账号页面增加,cloudfare人机验证和 amber发送邮件验证码的验证(增加重复获取验证码的爆破设计)」
|
||||
|
||||
## 背景与动机
|
||||
|
||||
1. **注册口是唯一无鉴权写入口**。原实现只有「用户名 + 密码」+ `@fastify/rate-limit`(每 IP 每分钟 5 次),
|
||||
机器人可以低成本批量灌 `pending` 用户(每个用户都会占一条 DB 行 + 一个 home 目录)。
|
||||
2. **平台本来就没有邮件基础设施**(全库 `grep -iE "smtp|nodemailer|mail"` = **0 命中**)⇒ 邮箱验证码
|
||||
不是"加个字段",而是**从零建一条外发通道**。
|
||||
3. 用户明确要了**防爆破设计** ⇒ "能发验证码"只算一半,另一半是"不能被当成免费邮件炮台 + 不能被当口令猜"。
|
||||
|
||||
## 用户决策
|
||||
|
||||
| 时间 | 用户口径 | 落地 |
|
||||
|---|---|---|
|
||||
| 2026-09-19 | 「增加 cloudflare 人机验证」 | 接 **Cloudflare Turnstile**(服务端 `siteverify`)。⚠️ **密钥待用户提供**(见 §待办) |
|
||||
| 2026-09-19 | 「amber 发送邮件验证码」 | **用户当日澄清:「amber 是日志服务我说错了」** ⇒ 与邮件无关(即档案 129 的 `yaop-labs/amber`)|落地 = **Brevo** + **可插拔驱动**(`brevo` / 通用 `http` / `log`,**换供应商只改 env、不改代码**) |
|
||||
| 2026-09-19 | 「用 B 方案:`[email protected]`」 | **已落地**:Brevo 建域名 `ai1net.com` → 4 条 DNS + SPF → **`authenticated: true` / `verified: true`** → 发件人 **id=2 `[email protected]`(AI1NET)** → `DSHS_MAIL_FROM` 切换并重启|实测 `delivered from= [email protected]` |
|
||||
| 2026-09-19 | 「1、说一下在哪里操作」(Turnstile 密钥) | 见 §待办 1 —— 给了控制台逐步路径 |
|
||||
| 2026-09-19 | 「使用代理访问」(抓官方 `prompt.md`) | 经本机代理抓到 Cloudflare 官方 **Turnstile Spin skill**(`developers.cloudflare.com/turnstile/spin/prompt.md`,31.6 KB)⇒ 对照出**我实现里的两处校验缺口并已补齐**(见 §实现 D):官方 canonical siteverify = `success` ∧ `action` ∧ `hostname` **三项**,我原先只校 `success` |
|
||||
| 2026-09-19 | 「4、调整完成后在处理」 | 调整已完成;**提交/推送留待你一句话**(本轮未 commit) |
|
||||
| 2026-09-19 | 提供 Turnstile 两把密钥(「站点密钥:`0x4AAAAAAE8aE7sRfp10el5C` / 站点 secret:…」) | **人机验证正式启用**:写入 600 的 drop-in + `restart dshs`;密钥自证 + 配置端点 + 403 拦截三项实测(见 §实现 E)。⛔ 私钥不入任何文档与记忆 |
|
||||
| 2026-09-19 | 「登录和注册页面上的 deepseek的图标去掉,改为中文:能力枢纽…CapabilityNet」+「一并替换」+「会话页面左上角的 deepseek 先不替换」 | **另立档案 137**(品牌标识改造:四页 + favicon;会话页面那处不动)—— 与本节同一会话的相邻改动,见 `137-品牌标识改造-去DeepSeek图形.md` |
|
||||
| 2026-09-19 | 「增加重复获取验证码的爆破设计」 | 见 §实现 B:三层配额 + 递增冷却 + 试错上限 + 单次使用,全部落库 |
|
||||
|
||||
## 实现
|
||||
|
||||
### A. 代码
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/db/schema.ts` | **迁移 v10**:`users.email` + 唯一索引 `LOWER(email)` + `email_codes` 事件表(2 个索引) |
|
||||
| `src/db/types.ts` | `User.email`、`EmailCodeRow`、`RecordEmailCodeInput`、`EmailCodeCounts`、`toEmailCode()` |
|
||||
| `src/db/adapter.ts` | `DbAdapter` 新增 7 个方法(`findUserByEmail` + 6 个事件表方法) |
|
||||
| `src/db/repo.ts` / `sqlite.ts` / `pg.ts` | 两方言各自实现(计数一律**现算**,不维护冗余计数器) |
|
||||
| `src/web/register-guard.ts` | **新增**:纯函数策略(发码判定 / 校验前判定),零 IO,可逐边界断言 |
|
||||
| `src/web/mail.ts` | **新增**:邮件驱动(`brevo` / `http` / `log`)+ 双语正文渲染 |
|
||||
| `src/web/turnstile.ts` | **新增**:Cloudflare Turnstile 服务端校验(**失败关闭**) |
|
||||
| `src/web/email-code.ts` | **新增**:编排(人机验证 → 配额 → 发信 → 落事件 / 校验 → 消费) |
|
||||
| `src/web/routes/auth.ts` | 新增 `GET /api/auth/register/config`、`POST /api/auth/register/email-code`;`POST /api/auth/register` 加人机验证与验证码校验 |
|
||||
| `src/config.ts` | 新增 12 个配置项 + 3 个解析工具(全部**默认空 ⇒ 不配即老行为**) |
|
||||
| `web/register.html` | 邮箱 + 「获取验证码」(带倒计时)+ 验证码 + Turnstile 容器;配置由服务端下发 |
|
||||
| `web/i18n.js` | 新增 25 条 `register.*` 词条(中英) |
|
||||
| `test/register-guard.test.mjs` | **新增 19 条用例**(策略边界 / 哈希 / 邮件驱动 / Turnstile 三项判定逐组合 / 主机名归一 / 编排 / 迁移 v10) |
|
||||
| `package.json` | 测试清单纳入 `test/register-guard.test.mjs` |
|
||||
|
||||
**两个刻意的默认值**:
|
||||
① 所有新配置**默认为空** ⇒ 「不配任何 env = 与改造前完全一致」;
|
||||
② 迁移 v10 是**纯加列/加表**,回滚只需不读新列(列可留着不删)。
|
||||
|
||||
### E. Turnstile 正式启用(2026-09-19 08:3x · 用户提供密钥)
|
||||
|
||||
用户按「路 A」在 CF 控制台建好 widget 并把两把密钥发来,落地:
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| env(drop-in `/etc/systemd/system/dshs.service.d/register-verify.conf`,600) | `DSHS_TURNSTILE_SITE_KEY=0x4AAAAAAE8aE7sRfp10el5C`(**公钥,公开**)/`DSHS_TURNSTILE_SECRET`(**私钥,只落 600 文件,⛔ 不入任何文档与记忆**)/`DSHS_TURNSTILE_ACTION=signup`/`DSHS_TURNSTILE_HOSTNAMES=ai1net.com,www.ai1net.com,alotbuy.com,www.alotbuy.com` |
|
||||
| 密钥自证 | 用假 token 打 `siteverify` ⇒ 回 **`invalid-input-response`**(而非 `invalid-input-secret`)⇒ 私钥被 CF 认可 |
|
||||
| 部署后验收 | `GET /api/auth/register/config` ⇒ `{"captcha":{"enabled":true,"siteKey":"0x4AAAAAAE8aE7sRfp10el5C","action":"signup"},…}`|无 token 提交 ⇒ **403 `captcha_failed`**|假 token 提交 ⇒ **403 `captcha_failed`** |
|
||||
| 备份 | `register-verify.conf.bak-turnstile-20260919-083853`(= 只有邮件通道、无 Turnstile 的上一版) |
|
||||
|
||||
**为什么 hostnames 写 4 条(超集)**:这是**安全**的 —— CF 的 widget 域名列表是第一道闸门
|
||||
(只有注册过的域名才会被签发 token),我方白名单列全只是保证"旧域仍在线期间"从旧域注册的访客不被误拒。
|
||||
若将来旧域退役(档案 **135**),可收窄为两条。
|
||||
|
||||
### D. Turnstile 校验补强(2026-09-19 · 对照官方 `prompt.md` 后补齐)
|
||||
|
||||
抓官方文档后发现**原先只校 `success`,漏了另外两项** —— 这是真实的安全缺口,已补齐:
|
||||
|
||||
| 校验项 | 不校它的后果 | 现状 |
|
||||
|---|---|---|
|
||||
| `success === true` | 无 | ✅ 原先就有 |
|
||||
| **`action` 匹配** | 同一 sitekey 的**所有入口共享 token** ⇒ 人机验证退化成"过任意一处即可用到处" | ✅ 新增(默认 `signup`,前后端取值**由服务端下发**,防漂移) |
|
||||
| 🔴 **`hostname` 在白名单** | sitekey 是**公开的**(就在页面 HTML 里)⇒ 攻击者可在**自己站点**嵌入我们的 sitekey、为真人访客拿到合法 token,再拿去打我们的注册接口。**这是三项里最关键的一项** | ✅ 新增(默认从 `baseDomain` 派生 `<domain>` + `www.<domain>`) |
|
||||
|
||||
附带加固:
|
||||
- token **长度上限 2048**(CF 规定)+ 空 token ⇒ **本地就拒**,不浪费上游往返;
|
||||
- `hostnames` 为空数组 ⇒ `turnstileEnabled=false` ⇒ 视为**未配置完成**(宁可功能不启用,也不放开);
|
||||
- 「**密钥给了但主机名没给**」= 人机验证静默不生效 ⇒ `captchaPartiallyConfigured()` 检测 + 进程内首条 **WARN**(这种"看起来配了、实际没防住"的形态必须能在日志里被看见);
|
||||
- 主机名**归一化**(去协议 / 端口 / 路径、小写、去重):运维照 URL 习惯写 `https://ai1net.com/` 也能匹配;⛔ **绝不自动加 `localhost`/`127.0.0.1`**(生产白名单里放它们 = 放开本地伪造);
|
||||
- `verifyUrl` 可覆盖 ⇒ `action`/`hostname` 的**每个组合都能用本地假 siteverify 断言**(否则只能靠真 token 打线上才验得到)。
|
||||
- 新增 2 个配置:`DSHS_TURNSTILE_ACTION`(默认 `signup`)、`DSHS_TURNSTILE_HOSTNAMES`(逗号分隔)。
|
||||
⚠️ **旧域 `alotbuy.com` 门户仍在线** ⇒ 部署时须显式写全(`ai1net.com,www.ai1net.com,alotbuy.com,www.alotbuy.com`),
|
||||
否则从旧域注册的访客会被人机验证拒掉(实测配置解析已确认四种形态均按预期)。
|
||||
|
||||
### B. 防爆破(用户点名的部分)
|
||||
|
||||
四道闸门,层层独立:
|
||||
|
||||
| 维度 | 阈值(默认) | 作用 |
|
||||
|---|---|---|
|
||||
| **邮箱 · 冷却阶梯** | 60s → 60s → 180s → 300s → 900s → 1800s(按最近 1 小时已发起次数取档) | 让脚本化重试收益递减;真人重发一次仍只等 60 秒 |
|
||||
| **邮箱 · 小时/天配额** | 每小时 ≤ 6 次发起;每 24 小时 ≤ 8 封真正发出 | 卡住"盯着一个邮箱猛发" |
|
||||
| **IP · 小时配额** | ≤ 20 次(IP 可能 NAT,故意比邮箱宽) | 卡住"一台机器换邮箱猛发" |
|
||||
| **全局 · 小时配额** | ≤ 200 次 | 保护邮件服务商日配额(Brevo 免费档 300 封/天) |
|
||||
|
||||
校验侧(防猜码):
|
||||
|
||||
| 机制 | 值 |
|
||||
|---|---|
|
||||
| 验证码 | 6 位数字,`crypto.randomInt`(非 `Math.random`) |
|
||||
| 存储 | **只存哈希**:`sha256(email\|purpose\|code\|encryptionSecret)`,明文只出现在邮件里 |
|
||||
| 有效期 | 10 分钟 |
|
||||
| 试错上限 | 5 次;达上限**立即作废**(连正确码也不再认,必须重新获取) |
|
||||
| 单次使用 | 校验通过即 `consumed_at`(CAS 写入,并发重放只有一次成功) |
|
||||
| 绑定 | 码与「申请时填的用户名」绑定,换用户名重放不作数 |
|
||||
| 比较 | `timingSafeEqual` |
|
||||
|
||||
**三条不变式(顺序即语义)**:
|
||||
① **先人机验证再花配额** —— 否则机器人只要猛点就能把真用户的配额吃光;
|
||||
② **被拒绝也落库**(`status='throttled'`)—— 它既是配额的一部分,也是"有人在撞"的唯一证据;
|
||||
③ **发信成功才记 `sent`** —— 记早了会让"上游全挂"看起来像"发出去过"。
|
||||
|
||||
⚠️ **计数器落 DB 而不是进程内 Map**:进程内计数**一次 restart 就清零**,而本部署天天重启 ⇒ 等于把限流关掉。
|
||||
|
||||
### C. 部署
|
||||
|
||||
- 产物:`lib/**`(tsc 产物)+ `web/register.html` + `web/i18n.js` → `/opt/dshs/`(**只传本次改的文件**,非整包覆盖)
|
||||
- 备份:`/opt/dsh/backups/register-verify-20260919_070418/{pre.tar,pre2.tar}`(600)
|
||||
- env:**新 drop-in** `/etc/systemd/system/dshs.service.d/register-verify.conf`(600)
|
||||
```
|
||||
DSHS_MAIL_DRIVER=brevo
|
||||
DSHS_MAIL_API_KEY=<Brevo 密钥>
|
||||
[email protected] # 2026-09-19 08:07 由 [email protected] 切换
|
||||
DSHS_MAIL_FROM_NAME=aisharenet
|
||||
```
|
||||
- **发件域名认证(2026-09-19 已在 Cloudflare 加 5 条记录)**:
|
||||
| 类型 | 主机 | 值 |
|
||||
|---|---|---|
|
||||
| CNAME | `brevo1._domainkey` | `b1.ai1net-com.dkim.brevo.com`(**DNS-only**) |
|
||||
| CNAME | `brevo2._domainkey` | `b2.ai1net-com.dkim.brevo.com`(**DNS-only**) |
|
||||
| TXT | `@` | `brevo-code:2d01b491ca8de2c083b49bcbc606c95b` |
|
||||
| TXT | `@` | `v=spf1 include:spf.brevo.com ~all` |
|
||||
| TXT | `_dmarc` | 已有(`p=quarantine`,平台原有),未动 |
|
||||
⇒ Brevo 侧 `GET /senders/domains` = **`authenticated: true / verified: true`**;发件人 `id=2 noreply@ai1net.com`。
|
||||
⚠️ 触发验证的正确端点 = **`PUT /v3/senders/domains/{domain}/authenticate`**(`…/validate` **不存在**,会 404 `resource_not_found`)。
|
||||
- ⚠️ **未配** `DSHS_TURNSTILE_SITE_KEY` / `DSHS_TURNSTILE_SECRET` ⇒ 人机验证当前为**停用**状态
|
||||
(未配置 = 跳过,不是"配了一半")
|
||||
|
||||
## 验证记录
|
||||
|
||||
### 本机
|
||||
| 项 | 结果 |
|
||||
|---|---|
|
||||
| `tsc -p tsconfig.json` | 0 报错 |
|
||||
| `node --test test/register-guard.test.mjs` | **19/19 通过**(含 Turnstile `action`/`hostname` 五个组合 + 本地拒空/超长 token 不打上游) |
|
||||
| 全量单测(15 个文件) | **219 通过 / 1 skipped(pg,缺 `DSHS_TEST_DB_URL`)/ 0 失败**(`# tests 220`) |
|
||||
| 配置解析实测(5 种形态) | 派生 / 显式含旧域 / URL 形态误填 / 显式空 / action 非法 ⇒ 全部按预期 |
|
||||
| `check-layering.mjs` | 无新增违规 |
|
||||
| `verify-static.mjs` | 全部合格(含 `register.html` 内联脚本语法、去痕迹、i18n 无裸中文) |
|
||||
| 其余 10 个 `verify-*.mjs/cjs` | 全 OK |
|
||||
|
||||
### 线上(`https://ai1net.com`,2026-09-19 07:0x)
|
||||
| 步骤 | 实测输出 | 结论 |
|
||||
|---|---|---|
|
||||
| `GET /api/auth/register/config` | `{"captcha":{"enabled":false,"siteKey":""},"emailCode":{"enabled":true,"ttlSeconds":600,"maxAttempts":5},…}` | 配置下发通路可用;人机验证按预期停用 |
|
||||
| `POST …/email-code`(首发) | `200 {"ok":true,"retryAfterSeconds":60,"expiresInSeconds":600}` | 发码成功 |
|
||||
| 立刻重发 | `429` + `retry-after: 58` | **冷却生效** |
|
||||
| 事件表 | `sent`(07:05:23,hash 前 8 位 `7f4d15a3`)+ `throttled`(07:05:26,`reason=email_cooldown`) | 落库口径正确 |
|
||||
| 审计 | `register_code_sent` / `register_code_rejected`,带真实客户端 IPv6 | 取证可用 |
|
||||
| **Brevo 投递事件** | `delivered` ×2(07:05:25 `959046 is your DSH verification code`;07:09:16 `261788 is your AI1NET verification code`) | **端到端投递成功** |
|
||||
| 错码提交 | `400 {"error":"code_invalid","attemptsLeft":4}` | 试错计数生效 |
|
||||
| 正确码提交 | `201` + 建号成功,`users.email` 落库 | 全链路通 |
|
||||
| 同邮箱重放 | `409 {"error":"email_taken"}` | 重放被挡 |
|
||||
| md5 对账 | `lib/web/mail.js` / `email-code.js` 两端一致 | 部署无漂移 |
|
||||
| **Turnstile 补强部署后复验** | 配置端点新增 `"action":"signup"`;`register.html` 含 `cfg.captcha.action`;门户 200;发码仍 `200` | 补强**零回归**(未配密钥 ⇒ 行为与补强前一致) |
|
||||
|
||||
**清理**:测试账号 `rcverify1`(DB 行 + 47/106 上的目录)、`tzcheck1`/`nrverify1` 的测试事件行全部删除;
|
||||
`email_codes` 现存 **0 行**;47 `users/` 目录数 **7**(= 2 真实用户 + 5 个历史孤儿,本轮未新增)。
|
||||
|
||||
## 事故/踩坑记录
|
||||
|
||||
0. 🔴 **人机验证只校 `success` 是不够的(本档案最重要的教训 · 2026-09-19 对照官方文档才发现)**:
|
||||
sitekey **本身就是公开的**(写在页面 HTML 里)⇒ 攻击者可以在**自己的站点**用我们的 sitekey 渲染 widget、
|
||||
为真人访客拿到合法 token,再拿到我们的注册接口上用 —— 只校 `success` 时这条路**完全通畅**。
|
||||
必须同时校 **`hostname`**(Cloudflare 服务端判定并回显,访客篡改不了)与 **`action`**。
|
||||
官方 canonical siteverify 就是三项齐备。⇒ **凡"公开 key + 服务端校验"的模型,都要问一句
|
||||
"这个 token 凭什么只是给我用的"**,答案往往就是 hostname / audience / origin 这类**服务端回显的绑定字段**。
|
||||
1. **`DSH` 曾写进用户可见邮件主题**。首版 `renderVerificationMail` 把 `brand` 默认为 `'DSH'`,
|
||||
实测投递的主题是 `959046 is your DSH verification code` ⇒ 平台内部名泄露给终端用户。
|
||||
修法:`brand` 缺省为**空**,由 `baseDomain` 主标签大写(`ai1net.com` → `AI1NET`)推导;
|
||||
空则整句退化为"你的验证码"。已加断言 `!/DSH|dshs/i.test(subject+text)`。
|
||||
2. **本机默认 `node` 是 24.x,而 `better-sqlite3` 是按 22 编译的**(`NODE_MODULE_VERSION 127 vs 137`)
|
||||
⇒ `ERR_DLOPEN_FAILED`。跑测试必须用 **Node 22**(`E:/ProgramData/.workbuddy/binaries/node/versions/22.22.2-3/node.exe`)。
|
||||
3. **冷却阶梯与小时配额曾互相打架**:`emailPerHour=5` 时,阶梯第 6 级(1800s)永远走不到
|
||||
⇒ 白写一级。改为 `emailPerHour=6`,并加断言 `ladder.length === emailPerHour`。
|
||||
4. **新用户的实例目录落在 `w-106` 而不是 47**(集群按容量分配)⇒ 清理测试账号时容易漏。
|
||||
本次已按 `w-106:/var/lib/dshs/users/<id>` 单点删除。
|
||||
5. **Brevo 事件接口有分钟级延迟**:发信后立刻查 `/smtp/statistics/events` 查不到,
|
||||
≈25 秒后才出现。别据此误判"没发出去",应以业务侧的 `sent` 落库 + 稍后复查事件为准。
|
||||
6. **Brevo 触发域名验证的端点**是 `PUT /v3/senders/domains/{domain}/authenticate`;
|
||||
`…/validate` 会回 **404 `resource_not_found`**(官方文档未列,实测而来)。
|
||||
7. **`ai1net.com` 原先没有 SPF 记录** ⇒ 本次新建时一并补上 `v=spf1 include:spf.brevo.com ~all`
|
||||
(没有 SPF 的域用第三方发信,被判伪造 / 进垃圾箱的概率显著上升)。既有的 `_dmarc`(`p=quarantine`)**未动**。
|
||||
8. **主机名白名单必须含旧域**:`alotbuy.com` 门户仍在线(软回滚路径)⇒
|
||||
只写 `ai1net.com` 会让人机验证把从旧域注册的访客拒掉。配置解析已实测四种形态(派生 / 显式含旧域 /
|
||||
URL 形态误填 / 显式空)均按预期。
|
||||
|
||||
## 回滚 / 注意
|
||||
|
||||
**回滚(三选一,代价递增)**
|
||||
1. **只停用功能**:`mv /etc/systemd/system/dshs.service.d/register-verify.conf{,.off} && systemctl daemon-reload && systemctl restart dshs`
|
||||
⇒ `emailCodeActive=false`,注册页恢复成"用户名 + 密码"(迁移 v10 的列/表留着不碍事)。
|
||||
2. **回滚代码**:从 `/opt/dsh/backups/register-verify-20260919_070418/pre.tar`(+ `pre2.tar`)覆盖回 `/opt/dshs/` 后重启。
|
||||
3. **回滚 schema**:需要 `DROP TABLE email_codes` / `DROP INDEX idx_users_email` / `ALTER TABLE users DROP COLUMN email`
|
||||
+ 删 `schema_migrations` 里 version=10 的行。⚠️ **不必做** —— 纯加列加表对旧代码零影响。
|
||||
|
||||
**注意**
|
||||
- 注册页改的是**静态文件**(`web/*.html`),`scp` 即生效、**不需要重启**;本次重启只为 `lib/**`。
|
||||
- ⚠️ Turnstile 脚本来自 `challenges.cloudflare.com`:**中国大陆网络下可能加载慢/被拦截**。
|
||||
前端已做**失败关闭 + 明确提示**(提示关掉广告拦截或换网络),不会静默变成"点不了"。
|
||||
- ⚠️ 发件人是 `maogeigei@gmail.com`(Brevo 里唯一已验证的发件人)。发给 QQ/163 等国内邮箱
|
||||
**大概率进垃圾箱**,建议在 Brevo 验证自有域名(`ai1net.com`)再改 `DSHS_MAIL_FROM`。
|
||||
- `email_codes` 表自带**自维护清理**(每小时最多一次,清 30 天前的行),不需要额外 cron。
|
||||
|
||||
## 待办 / 未闭环
|
||||
|
||||
1. ✅ **人机验证已启用**(2026-09-19 08:3x,用户按「路 A」建好 widget 并提供密钥)—— 见 **§实现 E**。
|
||||
密钥自证(假 token ⇒ `invalid-input-response`)、配置端点 `enabled:true`、无 token / 假 token 提交均 **403** 三项已实测。
|
||||
⚠️ 唯一未做的是**真实人机通过后的端到端**(需要一个真人/真浏览器取得真 token 再提交)——
|
||||
该项**留给用户首次真实注册时自然验证**:若届时收到"人机验证未通过",最常见的两类原因是
|
||||
① widget 的 Hostnames 没覆盖当前访问域名;② 访客侧 CF 脚本被广告拦截插件挡住(页面已做**明确提示**而非静默失败)。
|
||||
(历史备查 · 两条原始路径:**路 A** = CF 控制台 → 左侧 **Turnstile** → Add widget(Hostnames 填域名、Mode = Managed)→ 取两把密钥;
|
||||
**路 B** = 给一个含 `Account → Turnstile → Edit` 的 account 级令牌,我用官方 API 自建。本轮走的是路 A。)
|
||||
2. ✅ **「amber」已对号** —— 用户当日澄清 = **日志服务**(即档案 129 的 `yaop-labs/amber`),与邮件无关;
|
||||
邮件按 **Brevo** 落地,且驱动可插拔(换供应商零代码)。
|
||||
3. ✅ **发件人已换 `noreply@ai1net.com`** —— Brevo 域名 `ai1net.com` 已认证 + 4 条 DNS + SPF;
|
||||
实测 `delivered from= noreply@ai1net.com`。替代了原先的个人 Gmail。
|
||||
4. 🟡 **未提交 / 未推送**(用户级规矩:未明确要求不 commit/push;且用户 2026-09-19 回「调整完成后在处理」
|
||||
⇒ 调整已完成,**待你一句话**即提交)。改动面较大(5 个 DB 文件 + 4 个新模块),
|
||||
建议尽快提交以免被并行会话的 checkout 冲掉。
|
||||
5. 🟡 `users.email` 目前**只在 DB**,未进 `PublicUser` / admin 用户列表 ⇒ admin 看不到用户邮箱。
|
||||
6. 🟡 47 上另有 **5 个孤儿用户目录**(`3ec95f69…`/`4eaeb26b…`/`74e8804a…`/`7ba268be…`/`ca3f36e0…`,
|
||||
09-15 产生,DB 无对应行)—— **本次未动**,属历史遗留,建议单独清理。
|
||||
7. 🟡 **CF 侧仍有 1 处遗留**:本次为 Brevo 加的 5 条记录已生效,但**未动任何既有记录**;
|
||||
若日后停用 Brevo,需回收这 4 条(`brevo-code` / 两条 DKIM CNAME / SPF)。
|
||||
@@ -0,0 +1,129 @@
|
||||
# 135-旧域 alotbuy.com 从项目移除(2026-09-19 落地)
|
||||
|
||||
> **一句话**:平台对外域名**收敛为 `ai1net.com` 单一域名**;旧域 `alotbuy.com` 在**代码 / 服务端配置 / 文档技能**三层全部移除,
|
||||
> 旧域 nginx 站点**降级为 301 过渡装置**(保留 relay 透传与 ACME,30 天零流量后退役)。
|
||||
> **触发**:用户 2026-09-19 原话 —— 「旧域 alotbuy.com 怎么处置:**从项目中移除后续都用新域名**」
|
||||
> **执行**:序㊿ 执行棒(会话名 `旧域移除-控制面并集-20260919`)· 计划书 = 工作区根 `_tmp_dompurge/PLAN-旧域移除与并集立项_20260919.md`
|
||||
> **姊妹件**:同棒产出的立项交接单 = `04-调整方案/136-控制面按两台中继取并集-立项交接单.md`
|
||||
|
||||
## 背景与动机
|
||||
|
||||
1. **域名已在 09-19 切换完成**(见 `ops/域名迁移_ai1net_20260919/RUNBOOK-cutover.md`),但切换只做到"新域能用",
|
||||
旧域仍以**独立门户站点**形态存在 ⇒ 两套入口并存 = 两套证书、两处签名目录、两处排障路径。
|
||||
2. **旧域残留会让判据撒谎**:演练脚本的"是否切到 47 那台中继"判定用**硬编码域名字符串**匹配端点 ⇒
|
||||
域一改,**真实发生的切换会被判成失败**(本节 §"实测踩坑 1" 即此坑)。
|
||||
3. **relay 候选里存在"摘名不同、物理同一"的伪多样性**:`alotbuy.com` 与 `relay-direct.alotbuy.com` 摘名不同、
|
||||
**同落 47 同一路径**、且后者还开了 `DSHS_OVERLAY_ADDR_OVERRIDES` 直连 IP ⇒ 5 条候选里 2 条**零物理多样性**。
|
||||
|
||||
## 用户决策
|
||||
|
||||
| 时间 | 用户口径 | 落地 |
|
||||
|---|---|---|
|
||||
| 2026-09-19 | 「从项目中移除后续都用新域名」 | 代码 / 服务端 / 文档技能三层真实替换;**旧域不删站、降级 301**(删站会打断仍指向旧域的客户端,净变差 ⇒ R11 不允许) |
|
||||
| 2026-09-19 | 「A 立项修」(控制面并集) | 只出立项交接单(档案 136),**本轮不落地** |
|
||||
|
||||
## 范围分桶(最终实测结论)
|
||||
|
||||
| 桶 | 对象 | 处置 |
|
||||
|---|---|---|
|
||||
| **A 代码 / 运行时** | `src/**`、`web/**`、`test/**`、`scripts/**` | ✅ 全量替换(10 个源文件 + 2 个 test + 2 个 live 校验脚本 + 1 个演练脚本 + 1 个探针注释) |
|
||||
| **B 服务器配置** | 47 的 drop-in / nginx;106 的 worker env | ✅ 落新值(见 §执行记录 S3/S4) |
|
||||
| **C nginx 旧域站点** | 47 `/etc/nginx/conf.d/alotbuy.com.conf` | ✅ 降级为 **301 过渡装置**(保留 3 条非 301 路径) |
|
||||
| **D 文档 / 技能** | 文档库 + `skills/**` | ✅ 42 处精确替换 + 1 处补 Edit(**只改"现行事实"章节**) |
|
||||
| **E 不动的** | `04-调整方案/**` 历史档案、`archive/**`、参数表 §11 历史补记 | ⛔ 历史记录**不改**(改了就是伪造历史) |
|
||||
| **F 排除** | `work.alotbuy.com`(Gitea 仓库服务) | ⛔ **不是本平台域名**,与迁移无关 |
|
||||
|
||||
## 执行记录(S1–S7)
|
||||
|
||||
**S1 · 代码层替换(A)**
|
||||
- 扫描 127 个文件,命中并改写 10 个:`src/config.ts`×2、`src/net/relay/addr-override.ts`×4、`src/net/relay/directory.ts`×2、
|
||||
`src/net/relay/rendezvous.ts`×1、`src/net/relay/switcher.ts`×4、`src/web/server.ts`×3、`src/worker/relay-tunnel.ts`×5、
|
||||
`test/overlay-bootstrap.test.mjs`×22、`test/relay-failover.test.mjs`×4、`web/wake.html`×1。
|
||||
- ⚠️ **计划书的行号表是快照、已漂移**(例:计划写 `config.ts:312`,实测 `:381`;另有计划未登记的 `config.ts:125`、`directory.ts:324`)
|
||||
⇒ 一律**先自己 scoped grep、再做字节级精确替换**,⛔ 不按计划行号下手。
|
||||
|
||||
**S2 · 修掉一个真 bug(A3)**
|
||||
- `web/wake.html` 里有**转义点写法** `alotbuy\.com`,被批量替换漏掉 ⇒ 白名单正则仍只认旧域 = 唤醒后**跳转白名单恒不命中**。
|
||||
- 修为新域后缀(单后缀即正确 —— 已核 `next` 是**由请求 host 拼出的绝对 URL**,见 `src/supervisor/proxy.ts:613`):
|
||||
`/^(\^|\.)ai1net\.com$/.test(x.hostname)`(源码中无多余反斜杠)。
|
||||
|
||||
**S3 · 47 服务端(B + C)**
|
||||
- drop-in `overlay-443fb.conf`:`DSHS_OVERLAY_BOOTSTRAP_SEEDS` **5 → 3**,删掉**同机同路径**的两条旧域候选;
|
||||
`DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.ai1net.com=47.77.182.89`;注释写明"旧域 `/dshs-relay` 透传仍在"与回滚原文。
|
||||
- nginx 旧域站点降级:`map $host $alotbuy_301_host`(默认 `ai1net.com`;`~^(?<lb301>[^.]+)\.alotbuy\.com$ → $lb301.ai1net.com`
|
||||
⇒ **子域对子域映射**),80/443 双 server 全 301;**仅保留 3 条非 301 路径**:
|
||||
`/.well-known/acme-challenge/`、`/dshs-relay` → `127.0.0.1:20080`、`= /dshs-overlay/bootstrap` → `127.0.0.1:3080`。
|
||||
- 文件头写入**降级而非删除的理由**与**退役条件 = 30 天零流量**。
|
||||
- 归档 `_pending-ai1net/` → `mv` 到 `/root/_dompurge/_pending-ai1net-archived-<ts>`(**不是删除**)。
|
||||
|
||||
**S4 · 106 worker(B)**
|
||||
- `/etc/dshs-worker.env` 第 26 行同步为同 3 条 SEEDS;备份 `/etc/dshs-worker.env.bak-dompurge-20260919-073149`;`daemon-reload`。
|
||||
- 顺带取证:`dshs-relay-client` 单元为 **disabled**(历史遗留、无害)—— **只报告、未动**。
|
||||
|
||||
**S5 · 控制面二次重启以清签名目录缓存(关键一步)**
|
||||
- 一次重启后 bootstrap **仍是 5 条、仍含 2 条旧域** ⇒ 定位到 Manager 把**自己发布的签名目录**缓存在
|
||||
`/var/lib/dshs/overlay/directory.json`(`fetchedAt` 07:29,**早于改动**)且**懒重建**。
|
||||
- 处置:备份 + 删除该**可再生**缓存 → 第二次重启控制面 ⇒ Manager 解析出 **3 条候选、全部新域**
|
||||
(`source=seed-directory`),缓存重建 **578 B**。
|
||||
|
||||
**S6 · 文档与技能(D)**
|
||||
- 42/42 处精确替换(**每处带 count 断言**):`BRIEF.md`、`README.md`、`INDEX.md`、`DEPLOY-本部署.md`、
|
||||
`02-运维手册.md`(**仅附录 C = 现行事实章节**)、`docs-manifest.json`、`scripts/docs-consistency.py`、
|
||||
`scripts/cf-probe.py`(新增 `CREDS={'ai1net.com':'/etc/cloudflare-ai1net.ini'}` + `token(zone)`)、7 个技能文件。
|
||||
- 补 1 处:`skills/dsh-instance-diagnose/SKILL.md`(`goto https://<user>.ai1net.com/`)。
|
||||
- 文档库镜像同步:`ops/nginx/alotbuy.com.conf` 换为 301 版(md5 `eccf9014c6e1c3f13a5c32b7b14418a9`,与生产一致)。
|
||||
- 从 47 暂存目录抢救回 `ops/域名迁移_ai1net_20260919/RUNBOOK-cutover.md`(**唯一副本**),并在头部加状态横幅
|
||||
(记录 §1 阻塞已解、S1–S5 已执行;原"留待你确认退役时机"**已被本棒取代**)。
|
||||
|
||||
**S7 · 参数字典与判据脚本**
|
||||
- 参数表值格 **2 键**:`PORTAL_HOST_HEADER`、`DRILL_KILLED_MATCH` 由 `alotbuy.com` → `ai1net.com`(🔴 后者是**假红根因**,见踩坑 1);
|
||||
+ 两处 `-H "Host: alotbuy.com"` 示例 + §6 一条候选摘名说明 ⇒ **§10 指纹同步为 `075a97239c2d1d8a1f442ec6cc787ede`**。
|
||||
- `scripts/overlay-probe.cjs` 注释、`verify-cluster-domain.mjs` / `verify-platform-admin-section.mjs` 默认域同改。
|
||||
|
||||
## 实测踩坑(两条,都是"判据撒谎"型)
|
||||
|
||||
**踩坑 1 · 演练"假红"(已修)**
|
||||
- 现象:`overlay-failover-drill.cjs --scene all` = **11 PASS / 1 FAIL**(基线 12 PASS),红在幕4-A「目标不是 47 那台」。
|
||||
- 根因:幕4-A/幕4-B 的判定用**硬编码字符串** `alotbuy.com` 去匹配端点 ⇒ 豁免**真的生效了**、真的切回 47,**只是判定认不出**。
|
||||
- 修法:两处匹配器改为取参数表 `DRILL_KILLED_MATCH`(`m3.includes(killedMatch)` / `m2.includes(killedMatch)`),并在注释块记录本次域迁移的勘误。
|
||||
|
||||
**踩坑 2 · 「查旧域不存在」的两种假象**
|
||||
- 旧域 `/dshs-relay` 直取 = **404**:曾疑似回归 ⇒ **A/B 对照**:新域裸 GET **同样 404**;两域**带 WS 升级头都返回 101**
|
||||
⇒ 非缺陷(`/dshs-relay` 只对 WebSocket 升级生效)。
|
||||
- `admin/guest.ai1net.com` = **401**(不是 302):属既有"**无 session 直连实例页**"行为,**非回归**(档案 136 §7 E7 已登记口径)。
|
||||
|
||||
## 验收证据
|
||||
|
||||
| # | 判据 | 结果 |
|
||||
|---|---|---|
|
||||
| V1 | 签名 bootstrap 候选集 | **3 条 / 0 处旧域**,`source=seed-directory`,缓存 578 B |
|
||||
| V2 | 零回归三件套 · 探针 `overlay-probe.cjs` | **29 PASS / 0 FAIL / 0 SKIP(rc=0)**;`OBS-21` = `count=3 hosts=3`,**47 / 106 两机读一致**,全表零 `alotbuy` |
|
||||
| V3 | 门户可达 | 新域首屏 **200** |
|
||||
| V4 | 旧域过渡 | 301 生效,**含子域映射**(`guest.alotbuy.com` → `https://guest.ai1net.com/...`) |
|
||||
| V5 | relay 透传双域一致 | 裸 GET 双域同 404 / WS 升级双域同 101 |
|
||||
| V6 | 零回归三件套 · `npm test` | 未跑(**本棒零测试面逻辑改动**;仅字符串与匹配器) |
|
||||
| V7 | 零回归三件套 · 演练 `--scene all` | ✅ **12 PASS / 0 SKIP / 0 FAIL**(rc=0;**5m56s~6m**;末行原文 `# 结果:12 PASS / 0 SKIP / 0 FAIL`)—— 修后复跑,`幕4-A` 目标 = `wss://ai1net.com/dshs-relay`、耗时 15867 ms / deadline 30000 ms。⚠️ 全套含 120 s + 90 s 观察窗 ⇒ **外层超时必须 ≥ 8 min**,否则 `restore()` 不跑、**106 relay 会留在停机态** |
|
||||
| V8 | 技能「三处」同步 | ✅ 6 个技能全部本机/文档库/47 镜像三处**域已清**;其中 3 个(decision-method / feature-first / plugin-diagnose)**三处 md5 全同**(`a8971220` / `a1397140` / `3edeeb89`);⚠️ 另 3 个(change-workflow / opensource-release / instance-diagnose)**本机副本更新 ⇒ 存在本会话之外的既存漂移**,本轮**只做域替换、⛔ 未覆盖新副本**(详见 §残留项 6) |
|
||||
|
||||
## 回滚
|
||||
|
||||
- **代码**:`git checkout -- src web scripts` + `npm run build` ⇒ scp 回 `/opt/dshs/`(产物清单见 `_中间产物_待清理/_证据_序50_部署产物15文件md5_20260919.txt`,15 个文件)。
|
||||
- **47**:drop-in 恢复 5 条 SEEDS(原文在文件注释里)→ `daemon-reload` → `restart dshs`;nginx 旧域站点恢复原站(备份在同目录 `.bak-*`)。
|
||||
- **106**:`cp /etc/dshs-worker.env.bak-dompurge-20260919-073149 /etc/dshs-worker.env` → `daemon-reload` → `restart dshs-worker`。
|
||||
- 回滚后**必须**删一次 `/var/lib/dshs/overlay/directory.json` 并重启控制面(否则又读到旧缓存 ⇒ 假回滚成功)。
|
||||
|
||||
## 残留项 / 后续(不阻塞)
|
||||
|
||||
1. **旧域退役条件**:`alotbuy.com` 站点保持 301 过渡,**30 天零流量**后再评估删除;`relay-direct.conf`、`dsh.alotbuy.com.conf` 同属"导向新域 / 兜底"装置,**删除即净变差(R11)** ⇒ 保留。
|
||||
2. **参数表 §11 历史补记**仍引用旧域(含旧证据原文引用)—— **故意不改**(历史记录)。
|
||||
3. **`dshs-relay-client`(106)** = disabled 历史遗留,无害,未动。
|
||||
4. **演练脚本修正版**按惯例应两机同源部署(`scripts/overlay-failover-drill.cjs` 是**本机 ssh 出去的工具**,不被任何单元读取 ⇒ 零服务影响)。
|
||||
5. 控制面**按两台中继取并集** —— 立项见 **档案 136**(本轮只立项,未落地)。
|
||||
6. **技能副本的既存漂移(⛔ 本会话之外形成,只报告未统一)**:`.workbuddy/skills/` 与文档库 `skills/` 三对不一致 ——
|
||||
`dsh-change-workflow`(本机 1055 行 vs 文档库 1039 行)、`dsh-opensource-release`(本机 **1.8.2** vs 文档库 1.7.8)、
|
||||
`dsh-instance-diagnose`(本机 SSH 别名更正 vs 文档库旧写法)。⇒ 本轮对这三份**只做域替换**(⛔ 不覆盖,避免丢内容);
|
||||
镜像 `/opt/dsh/docs/skills/` 已与**文档库**对齐(md5 同)。**回头条件 = 有人真正要动用这三份技能时**,先做一次副本收敛。
|
||||
7. **`/opt/dsh/docs` 镜像的更广泛漂移(⛔ 本单范围外,未推送)**:`docs-sync-check.sh`(须 `[email protected]`,默认别名已失效)
|
||||
报出**30+ 个"仅本地待推送"文件** —— 属**本会话之前**就存在的镜像滞后,本轮**只推了 6 个技能文件**,其余按"只做被明确要求的事"**未动**。
|
||||
8. **演练脚本 `overlay-failover-drill.cjs` 未铺服务器**:两机 `/opt/dshs{,-cluster}/scripts/` **本来就没有该文件**(它是本机 ssh 出去的工具)
|
||||
⇒ 只在本机生效;而 `overlay-probe.cjs` 已按惯例**三处同源**(md5 `55568f87bfe2dc8e43289c69b4126004`)。
|
||||
@@ -0,0 +1,145 @@
|
||||
# 136 · 控制面(平台 web 层)按两台中继取并集 —— 立项交接单
|
||||
|
||||
> **归档号**:136(占号 = `mkdir 04-调整方案/.lock-136`,2026-09-19 现核成功)
|
||||
> **日期**:2026-09-19
|
||||
> **状态**:🔄 待执行(**已立项,未开工** —— 本单只做立项,零代码 / 零服务器改动)
|
||||
> **触发**:用户 2026-09-19 原话 —— 「**A 立项修**」(对「控制面也按两台中继取并集」的答复)
|
||||
> **上游依据**:本工作区 `接续入口_覆盖网络线_20260916.md §0` 最新行的 **⑤ 遗留**(序㊾ 只修了观测面,控制面未跟)
|
||||
> **单一来源**:根因链 = 本单 §2|观测面口径 = `scripts/overlay-probe.cjs`(序㊾ 已并列集)|拓扑 = `会合中继拆分_取证与改造方案_20260916.md`
|
||||
|
||||
---
|
||||
|
||||
## 1. 目标与不在范围
|
||||
|
||||
**目标(一句话)**:让**控制面**(`src/web/` 这一层的可达性解析)与**观测面**(`overlay-probe.cjs`)**用同一口径**看两台自研中继 —— 即「按 `hostId`(含 `network`)合并两台中继的 `/status` 视图」,从而消除「worker 漂到另一台中继时,探针全绿而**实例子域 500**」这一**假绿**。
|
||||
|
||||
**判据(可验证终态)**:在「`w-106` 归属在 **106 自家中继**」这一现场下,`https://guest.<domain>` 与 `https://admin.<domain>` **均 302**(⛔ 不是 500),且探针仍 **29 PASS / 0 FAIL / 0 SKIP(rc=0)**。
|
||||
|
||||
**在范围内**:`src/web/server.ts`(`RelayRendezvous` 接线 + `/status` 快照取数点 + `presenceOnline`)、`src/net/relay/rendezvous.ts`(`resolve()` 三级链)、以及为对齐口径可能需要的 `src/net/relay/presence*` 一侧改动。
|
||||
|
||||
**⛔ 不在范围内**:
|
||||
- ⛔ 不改 `hostsProvider` / `dsh_hosts` 表结构(那是拆分方案 S2/C3,**已落地**,本项不再动)。
|
||||
- ⛔ 不改 `src/net/reachability.ts` 的 **P0-3「`via='relay'` 拒绝回落 `agentUrl`」** —— 那是**有意的失败关闭**,本项要修的是「解析不出落点」,⛔ 不是「让它回落」。
|
||||
- ⛔ 不改探针阈值 / 参数表值格 / `CAND_MIN`(序㊾ 已定,⛔ 本项只让控制面**追平**它)。
|
||||
- ⛔ 不动 sshd 反向隧道相关残留、⛔ 不新增监听口、⛔ 不改 nft / nginx。
|
||||
|
||||
---
|
||||
|
||||
## 2. 根因链(**已取证,2026-09-19,⛔ 别重做**)
|
||||
|
||||
行号 = 本次阅读的 **HEAD `d2ef362`**(装置已部署到 47 `/opt/dshs/lib`)。
|
||||
|
||||
| # | 位置 | 事实 |
|
||||
|---|---|---|
|
||||
| 1 | `src/config.ts:592` | 状态源**只有一个** URL:`relayStatusUrl: overrides.relayStatusUrl ?? process.env.DSHS_RELAY_STATUS_URL ?? ''` |
|
||||
| 2 | `src/web/server.ts:361` | `currentClient()` = **当前唯一在持有的那条**拨号通道的 client(`currentClientRef` 由 `:639` 赋值) |
|
||||
| 3 | `src/web/server.ts:398-413` | `/status` 快照 → `relayEndpoints`(`{localPort, online}`),**只读 `currentClient()` 那一台** |
|
||||
| 4 | `src/web/server.ts:647-655` | `presenceOnline(name)` 与订阅里的落点,**同样只取 `currentClient()`** |
|
||||
| 5 | `src/web/server.ts:727 / 746 / 767` | `new RelayRendezvous({ …, online: (name)=>…, presence: presenceOnline })` —— 两个数据源**同源于一台中继** |
|
||||
| 6 | `src/net/relay/rendezvous.ts:62-68` | `const pushed = this.opts.presence?.(name)`;`const online = pushed !== undefined ? pushed : this.opts.online?.(name)` ⇒ **两者都拿不到 ⇒ `resolve()` 返 `undefined`** |
|
||||
| 7 | `src/net/reachability.ts:101-108` | `host.via === VIA_RELAY` 且解析不出落点 ⇒ **按设计抛错**:`host "…" 声明 via=relay 但解析不出落点:拒绝回落到 endpoint`(P0-3) |
|
||||
|
||||
**⇒ 现象**:`w-106` 一旦按**抖动选路**(`switcher.ts`,属既有设计)漂到 **106 自家中继**,Manager 侧 `currentClient()` 仍是 47 那条 ⇒ `online` / `presence` 都查不到 `w-106` ⇒ 第 7 步抛错 ⇒ **实例子域 500**;而观测面(序㊾ 已并列集)**全绿** ⇒ **假绿**。
|
||||
|
||||
**本轮实测旁证(2026-09-19 07:3x)**:
|
||||
```
|
||||
47 relay /status ⇒ sessions ["manager","w-106"] dialers ["manager"]
|
||||
106 relay /status ⇒ sessions [] dialers ["manager"]
|
||||
```
|
||||
⇒ 两台**都**被 Manager 拨入,但 `w-106` 只出现在 47 的会话表里 ⇒ 缺口**当下被"worker 恰好挂在 47"掩盖**,尚未显形 —— 这正是它危险的地方(**平时不红,漂到 106 才红**)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 🔴 修前补测(**必做,第一件事**)
|
||||
|
||||
⚠️ 序㊾ 收口行里的结论「S5 让 106 worker 主入口落回 47 中继后修复(500 → 302)」**只验了"落回 47 即 302",未验"漂到 106 自家中继是否仍 500"**。⛔ 不许把"已恢复 302"当成缺口不存在。
|
||||
|
||||
**补测步骤(只读 + 一次性制造现场)**:
|
||||
1. 记录基线归属:`curl -s http://127.0.0.1:20080/status`(47)与 `ssh test106 'curl -s http://127.0.0.1:20080/status'`(106)。
|
||||
2. 制造现场:**停 47 的 `dshs-relay` 45 s 后起**(序㊾ 已实测此法能逼 worker 回落/漂移;⚠️ 首轮"停 3 s"不足够 —— 106 是该 worker 候选链**第一条**,它会回连),或按候选链顺序停"当前那条"。
|
||||
3. 现场下取三组读数:① 两台 relay `/status` 的 `sessions` 归属 ② 探针输出(应**仍全绿**)③ `curl -sk --http1.1 -o /dev/null -w '%{http_code}' https://guest.<domain>/`(**期望复现 500**)。
|
||||
4. **先红**:把第 3 步 ③ 的 500 原文(含 `journalctl -u dshs` 里那条 `声明 via=relay 但解析不出落点`)贴进本单 §8。
|
||||
⚠️ 若**复现不出 500** ⇒ ⛔ **停手报告**(缺口可能已被别处改动掩盖),**不许**在没有红的情况下开工 —— 没有红就没有可验证的绿。
|
||||
|
||||
---
|
||||
|
||||
## 4. 修法与决策点
|
||||
|
||||
**主修方向(已定)**:把 `online` / `presence` **合并两台中继**的在线态(并集),与序㊾ 探针**同口径**。
|
||||
|
||||
| 难点 | 说明 |
|
||||
|---|---|
|
||||
| `online`(`/status` 快照) | ✅ **容易**:对**每台**中继各取一份 `/status`,按 `network:hostId:port` 合并(`online` 取**或**、`localPort` 取**在线那一侧**)—— 与 `overlay-probe.cjs#mergeEndpoints()` **逐字同口径** |
|
||||
| `presence`(订阅推送) | 🔴 **真难点**:presence 是**订阅**,Manager 连哪台就只能订阅哪台。两条候选见下 |
|
||||
| 🔴 口径一致性 | 第二台若走 `/status` 轮询,其**新鲜度**语义必须与订阅一致,⛔ 防「**假在线**」(订阅侧是推、轮询侧是拉,陈旧窗口不同) |
|
||||
|
||||
**决策点(两条候选 —— 各有优有劣,需在执行棒开工前定)**:
|
||||
|
||||
**候选 A:Manager 同时对两台中继各建一条 presence 订阅**
|
||||
- 优点:口径**完全一致**(都是推、都是同一份 presence 语义);无陈旧窗口差异;与「并集」设计最贴合。
|
||||
- 缺点:Manager 需**同时持有两条拨号通道**(当前是"单通道 + 切流"),与 `switcher.ts` 的**单通道/冷却**模型冲突 ⇒ 改动面最大,可能牵动切流与冷却语义(🔴 **动冷却 = 高风险区**,参数表已把冷却值钉死)。
|
||||
|
||||
**候选 B:第一条(当前通道)走订阅,第二条降级为 `/status` 轮询,两边并集**
|
||||
- 优点:改动面小、⛔ 不碰切流与冷却;复用 `:398` 已有的 `/status` 快照代码路径(只是从"一台"变"两台")。
|
||||
- 缺点:第二台是**拉**、有陈旧窗口 ⇒ 存在「**假在线**」窗口(对端刚离线、轮询未刷);需额外定义并**断言**陈旧阈值,否则把真 FAIL 糊成 PASS(违反序㊾ 已立的「并集只消除'看不见'、⛔ 不把真 FAIL 糊成 PASS」原则)。
|
||||
|
||||
**倾向(可推翻)**:**先 B 后 A** —— B 能**立即消除 500**(目标达成),且不碰冷却(风险最低);A 作为架构目标登记。⚠️ 若执行棒取证发现「B 的陈旧窗口会让实例子域**偶尔 500**」⇒ 升级为 A。
|
||||
🔴 **需要用户拍板的只有边界外项**;以上属**技术实现取舍**,执行棒按 `dsh-decision-method §4.4` 自决并在 §8 写「我选了什么(可推翻)」,⛔ **不上抛**。
|
||||
|
||||
---
|
||||
|
||||
## 5. 避让 / 重叠检查(**开工前必核**)
|
||||
|
||||
| 对象 | 关系 | 处置 |
|
||||
|---|---|---|
|
||||
| `会合中继拆分_取证与改造方案_20260916.md` **§2 C3**(`dsh_hosts.endpoint` 磨掉"经谁中转") | ⚠️ **最易混淆**:C3 说的是**表形状**(补 `via` 列),⇒ **已由该方案 S2 落地**(`dsh_hosts.via` 已存在、`Reachability.via` 已在用) | ⛔ **本项不再动表**;本项修的是**解析数据源数量**,不是表形状 |
|
||||
| 同方案 **§9.4**「S4 有方案缺口:中继一旦不在 Manager 主机上,'Manager 拨落点'就断」 | 🔴 **同源问题、不同切面** —— §9.4 讲**拨号侧**落点,本项讲**读状态侧**并集 | 执行棒须引用 §9.4 并写明**本项不解决 §9.4**(避免重复设计 / 互相以为对方会修) |
|
||||
| `ts` 侧 `presence` 既有实现(`src/web/server.ts:363-389`,序⑲ D5「订阅新鲜 ⇒ 挂起 `/status` 轮询」) | 🔴 **强耦合**:该开关决定 `/status` 轮询**是否在跑** | 本项改动**必须**说明它与 D5 开关的相互作用(第二台轮询是否受 D5 门控?) |
|
||||
| 探针 `overlay-probe.cjs`(序㊾ 已并列集) | **同口径来源** | 本项**必须复用其合并语义**(键、`online` 取或、`localPort` 取在线侧),⛔ 不许另造一套口径(避免"同一事实两处写") |
|
||||
|
||||
---
|
||||
|
||||
## 6. 步骤(S0–S4)
|
||||
|
||||
| 步 | 动作 | 生效 | 验收 |
|
||||
|---|---|---|---|
|
||||
| **S0** | **修前补测**(§3):制造"worker 在 106"现场 ⇒ 取证 **500 原文** | — | §8 里贴出 500 原文 + `在解析不出落点` 日志行 |
|
||||
| **S1** | 抽"多中继状态视图"取数入口(**先不动行为**):把"取一台 `/status`"泛化为"取 N 台 + 合并",**默认 N=1 ⇒ 零行为变化**(等价性证明写进注释) | 本机 | `npm test` **与基线逐字相同**(≥217 pass / 0 fail / 1 skipped) |
|
||||
| **S2** | 按 §4 选定候选(倾向 **B**)接线 `online` / `presence` 并集;🔴 同时定义**陈旧阈值**并写进参数表 | build + 部署 | §7 逐条 |
|
||||
| **S3** | 部署 + 重启 47 `dshs`(控制面断数十秒,R8 允许,动手前一句话说明) | **立即** | 探针 29 PASS + 目标现场 302 |
|
||||
| **S4** | 回填:本单 §8 + 当日日志 + `接续入口 §0` | — | — |
|
||||
|
||||
⚠️ `npm test` 必须用 **Node 22**(`E:\ProgramData\.workbuddy\binaries\node\versions\22.22.2-3\node.exe`)。
|
||||
⚠️ 部署面:47 `/opt/dshs/lib`(`src/web/server.js`、`src/net/relay/*.js` 及其 `.d.ts`);**md5 逐文件对账**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 逐条验收(**先红后绿**)
|
||||
|
||||
| # | 判据 | 红(开工前必须拿到) | 绿(修后) |
|
||||
|---|---|---|---|
|
||||
| E1 | **修复本体** | 现场(worker 在 106 自家中继)⇒ `guest.<domain>` **500** | 同态 ⇒ **302** |
|
||||
| E2 | **解析链** | `journalctl -u dshs` 有 `声明 via=relay 但解析不出落点` | 该行**消失**,且出现"并集命中"的可诊断痕迹 |
|
||||
| E3 | **并集真并集(⛔ 防假绿)** | — | 模拟"对端不可达"腿 ⇒ 判据**按单台视角回退**(⛔ 不许因为"并集了"就把红的糊成绿) |
|
||||
| E4 | 零回归 · 探针 | 基线 **29 PASS / 0 FAIL / 0 SKIP(rc=0)** | **逐字相同** |
|
||||
| E5 | 零回归 · 单测 | 基线 **217 pass / 0 fail / 1 skipped**(`# tests 218`) | **逐字相同** |
|
||||
| E6 | 零回归 · 演练 | 基线 `--scene all` **12 PASS / 0 SKIP / 0 FAIL** | 逐字相同;⚠️ 含 120 s + 90 s 观察窗 ⇒ **外层超时 ≥ 8 min**,否则 `restore()` 不跑 ⇒ **106 `dshs-relay` 留在停机态** |
|
||||
| E7 | 端到端 | — | 门户 `https://<domain>/portal.html` = **200**;`admin.<domain>` / `guest.<domain>` 在**有 session** 时 302 带 `?token=`(⚠️ 无 cookie 直连返回 **401 属已知正常**) |
|
||||
| E8 | `bootstrap` 面 | — | `relays[]` / `bootstrap[]` 仍 **3 条、零旧域** |
|
||||
|
||||
---
|
||||
|
||||
## 8. 回滚 / 回报格式
|
||||
|
||||
**回滚**:`git checkout -- src/` → 重新 build → scp 回 47 `/opt/dshs/lib` → `systemctl restart dshs`。
|
||||
⚠️ 回滚点须在执行棒开工时**先备份** 47 `/opt/dshs/lib`(`cp -a` 到 `/opt/dsh/backups/<seq>-<ts>/`)。
|
||||
|
||||
**§8 回报(执行棒收口必写,逐条给原文级证据)**:
|
||||
1. **修前补测结果**:现场归属(两机 `sessions`)+ 500 原文 + 那行 journal。
|
||||
2. **我选了什么(可推翻)**:候选 A / B 的取舍与理由。
|
||||
3. **改动清单**:文件 × 行 × 改前改后(+ 等价性证明,若适用)。
|
||||
4. **先红后绿**:红 / 绿两条原文并列 **diff 只差这几行**。
|
||||
5. **零回归三件套**:E4 / E5 / E6 的逐字数值。
|
||||
6. **部署与部署面**:文件 list + **md5 对账** + 回滚点路径。
|
||||
7. **边界自证**:⛔ 未 commit / 未 push(写 HEAD)|⛔ 未改 `REACHABILITY`/P0-3 语义 |⛔ 未改探针阈值与参数表值格 |⛔ 未新增监听口 |🔴 `COOLDOWN_MS=0` 计数 = 0 |⛔ 未调 `RELAY_FAILOVER_DEADLINE_MS` / `HB_SEC` / burst。
|
||||
8. **如实留档**:本轮未解决项、未测项、以及任何与上游文档冲突的取证结论。
|
||||
@@ -0,0 +1,118 @@
|
||||
# 137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识)
|
||||
|
||||
> **一句话**:把我们自己的页面上所有 **DeepSeek 品牌标识**(鲸鱼图标 / 「DeepSeek」文字图形 / favicon)
|
||||
> 换成**平台自有标识** —— 中文「**能力枢纽**」,英语及其他语言「**CapabilityNet**」。
|
||||
> **触发**:用户 2026-09-19 原话三句 ——
|
||||
> ① 「登录和注册页面上的 deepseek的图标去掉,改为中文: 能力枢纽 小写 CapabilityNet ,英语和其他语言:CapabilityNet」
|
||||
> ② 「一并替换」(= 其余页面也一并办)
|
||||
> ③ 「会话页面左上角的 deepseek 先不替换」(= 实例内官方 dsh 界面那处**不动**)
|
||||
> **执行**:会话 `登录注册页品牌标识改造` → `品牌标识一并替换-admin portal favicon` · 状态 ✅ 已上线
|
||||
|
||||
---
|
||||
|
||||
## 0. 替换范围(**先划边界,这是本档案最重要的一节**)
|
||||
|
||||
| 位置 | 处置 | 依据 |
|
||||
|---|---|---|
|
||||
| `login.html` / `register.html` 页头品牌区 | ✅ **替换** | 用户① |
|
||||
| `admin.html`(管理台登录卡片) | ✅ **替换** | 用户②「一并替换」 |
|
||||
| `portal.html`(管理门户顶栏) | ✅ **换图标** | 用户②;文字「管理门户」保留(见 §1 注) |
|
||||
| 四页的 **favicon** | ✅ **换新图标** | 用户②;`logo.svg` 就是 DeepSeek 图标 |
|
||||
| 🔴 **会话页面左上角**(用户实例内的 dsh 会话窗口) | ⛔ **不替换** | 用户③明示「先不替换」。技术上它属**官方 dsh 的 client bundle**,不在本仓 `web/` 目录 ⇒ 也不受红线 **R2**(不改官方 dsh 主程序)允许 |
|
||||
| `index.html` / `wake.html` | — 无品牌标识 | 前者是纯跳转页、后者是启动过渡页,二者均无 logo/wordmark,且**本来就没有 favicon link**(未加,见 §4) |
|
||||
|
||||
⚠️ **"会话页面"不在本次能力范围内**:本仓 `web/*.html` 是**平台自有的门户/管理页**;
|
||||
用户在实例里看到的会话界面由官方 `@deepseek-ai/dsh` 渲染 ⇒ 那处的标识要么属于官方、要么需走
|
||||
**profile 层官方插件机制**(R2 的合规路径),不是"改个静态文件"能解决的。
|
||||
|
||||
## 1. 改了什么
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `web/login.html` | `.auth-brand` 内:**删** `<img class="logo" src="/logo.svg">`(DeepSeek 鲸鱼,2109 B 的 `#4D6BFE` 图形)+ **删** 内联 `<svg><title>DeepSeek</title>…</svg>`(131×24 文字图形)→ **换** `<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>`。10758 → 6799 字节 |
|
||||
| `web/register.html` | 同上。20002 → 16919 字节 |
|
||||
| `web/admin.html` | 同构处理(它复用 `.auth-brand`):**删图标 + 删内联 DeepSeek SVG** → `<span class="wordmark">能力枢纽</span>`(该页是**纯中文页**、未接 i18n ⇒ 直接写中文)。11913 → 7928 字节 |
|
||||
| `web/portal.html` | 顶栏 `.brand` **只换图标**:`src="/logo.svg"` → `src="/favicon.svg"`;⭐ **文字「管理门户」保留** —— 它是**页面定位名**、不是 DeepSeek 痕迹,删掉属越界 |
|
||||
| **`web/favicon.svg`** | **新建**(1008 B):平台自有图标,取「枢纽 / hub」意象 —— 圆角深底 + 中心节点 + 三条辐条 + 三个外环节点;配色用平台自己的 token(`ink #0f1c33` 底 / `accent-2 #38d6d0` 节点),**刻意避开**原 DeepSeek 的 `#4D6BFE`。同时兼作 favicon 与 portal 顶栏图标 |
|
||||
| 四页 head | `<link rel="icon" href="/logo.svg">` → `href="/favicon.svg"` |
|
||||
| `web/i18n.js` | 新增词条 `brand.name`:`en` → `CapabilityNet`、`zh` → `能力枢纽` |
|
||||
| `web/design.css` | `.auth-brand .wordmark` 由「定高 22px(给 SVG 用)」改为**文字排版**(`height:auto` + 19px/600/行高 1.2) |
|
||||
| `test/i18n-brand.test.mjs` | **新增**:`node:vm` + 最小 DOM 桩跑**仓库里那份真实的 `i18n.js`**,断言六条语言路径的**实际渲染结果** |
|
||||
| `scripts/verify-static.mjs` | **新增 SVG 段**:见 §3-1(踩坑后的防护) |
|
||||
|
||||
**两个刻意的选择**
|
||||
|
||||
1. **登录/注册页走 i18n 词条,⛔ 不写死在 HTML**:① `verify-static.mjs` 的 `I18N_PAGES`
|
||||
已把这两页列入「不得出现裸中文文案」,硬写「能力枢纽」会被拦;② 「英语**和其他语言**」
|
||||
要的正是**回退**语义 —— `lookup()` 缺 key 时回退 `en`,只写 `en` 一条即覆盖所有其他语言。
|
||||
⚠️ `admin.html` / `portal.html` **未接 i18n**(既有的纯中文页)⇒ 那两处直接写中文,
|
||||
**不引入 i18n 运行时**(引它会连带处理它们大量既有裸中文,属扩大范围)。
|
||||
2. **发子域名 `favicon.svg` 而不是复用 `logo.svg`**:favicon 缓存按 **URL** 生效 ⇒ 换文件名
|
||||
天然绕过浏览器里那份旧的 DeepSeek 图标缓存,**不需要加 `?v=` 版本串**。
|
||||
|
||||
## 2. 验证证据
|
||||
|
||||
| 项 | 实测 |
|
||||
|---|---|
|
||||
| **i18n 渲染自证**(跑真实 `i18n.js`) | 六条全绿:浏览器 `en-US` → `CapabilityNet`|`zh-CN` → `能力枢纽`|`?lang=zh` → `能力枢纽`|`?lang=en`(覆盖中文浏览器)→ `CapabilityNet`|`cookie dsh_lang=zh` → `能力枢纽`|`ja-JP` → 回退 `CapabilityNet` |
|
||||
| **裸 key / 空文案** | 四种语言下均非 `brand.name`、非空 |
|
||||
| **SVG 合法性**(真 XML 解析器) | `favicon.svg` 解析通过;`children=[title, rect, g, circle×4]`、`paths` 三条齐备;另两个既有 SVG 也一并验过 |
|
||||
| **反向自证**(坏样本必须被拦) | 故意往注释里注入 `--accent` ⇒ **真解析器报 `not well-formed`** + **校验脚本报红**;清理后复跑全绿 |
|
||||
| **残留检查**(本地 + 线上) | `web/*.html|css|js` 的 DeepSeek 命中:**仅 `design.css` / `i18n.js` 各 1 处、且都在注释内**(记录"为什么改",属必要留痕);四页线上 `grep -ci deepseek` = **0 / 0 / 0 / 0** |
|
||||
| **静态页不变量** | `verify-static.mjs` 全部合格(6 页 + 7 段内联脚本语法 + 3 个 css/js + 3 个 svg) |
|
||||
| **全量单测** | **221 pass / 0 fail / 1 skipped**(`# tests 222`,Node 22.22.2) |
|
||||
| **线上(`https://ai1net.com`)** | `favicon.svg` = **200 + `image/svg+xml`**;四页均 `favicon=1 / logo.svg=0`;`admin.html` 显示 `<span class="wordmark">能力枢纽</span>`;`portal.html` 显示新图标 + 「管理门户」 |
|
||||
| **部署 md5 对账** | 5 个 web 文件两端逐一一致(`admin 8736bcc7…` / `portal 164075c8…` / `login 2467bd50…` / `register dfbf996f…` / `favicon fe37ebbf…`) |
|
||||
| **行尾** | 逐字节核对:`admin.html` / `portal.html` 仍 CRLF、`login.html` / `register.html` / `favicon.svg` 仍 LF —— **与该目录既有风格逐一对应,无偏差** |
|
||||
| **回滚点** | ① `/opt/dsh/backups/rebrand-20260919-084220/pre.tar`(第一批 4 文件)② `/opt/dsh/backups/rebrand2-20260919-090205/pre.tar`(本批) |
|
||||
|
||||
**部署方式**:静态文件 `scp` 即生效(**不需要重启**);未动任何服务或 env。
|
||||
|
||||
## 3. 踩坑记录
|
||||
|
||||
1. 🔴 **SVG 里的 XML 注释不能含 ASCII 双连字符 `--`**(本次实际踩到)。
|
||||
我在 `favicon.svg` 的注释里写了 CSS 变量名 `--ink` / `--accent-2` ⇒ **XML 语法不允许**
|
||||
⇒ **整份 SVG 解析失败 ⇒ 浏览器静默不显示图标**,页面与接口都不报任何错。
|
||||
- 抓到它的方式:**用真解析器验一遍**(`ET.parse` 报 `not well-formed (invalid token)`),
|
||||
而不是只看标签配对或肉眼看 —— 肉眼 100% 看不出这处问题。
|
||||
- ⚠️ **中文破折号 `——`(U+2014×2)是合法的**,只有 ASCII `--`(U+002D×2)非法 ——
|
||||
所以"注释里有破折号"不是判据,"注释里有**两个连续半角减号**"才是。
|
||||
- **已固化为判据**:`scripts/verify-static.mjs` 新增 SVG 段(开头/结尾/viewBox/去痕迹/注释双连字符),
|
||||
并做了**反向自证**(注入坏样本 ⇒ 必须报红)。
|
||||
2. **`logo.svg` 同时兼作 favicon 与页面图标** ⇒ 换品牌时"页面上的图标"和"浏览器标签图标"
|
||||
是**同一份资产**,改一处必须想到另一处(第一批只改了页面内,favicon 就漏了)。
|
||||
|
||||
## 4. 未做 / 已发现(**只报告,未动手**)
|
||||
|
||||
1. 🟡 **`index.html` / `wake.html` 没有 favicon link** ⇒ 这两个页面的标签图标是浏览器默认。
|
||||
本轮未加(用户没要求,且属功能增量)。若要统一,改动 = 各加一行 `<link rel="icon" href="/favicon.svg">`。
|
||||
2. 🟡 **三个 DeepSeek 资产现已全部无引用**:`web/logo.svg`、`web/deepseek-text.svg`、`web/deepseek.webp`
|
||||
(`grep` 全仓已无任何 HTML/CSS/JS 引用)。**未删** —— 属"额外发现即先报告",
|
||||
删除是不可逆动作、需你点头(`git` 里可恢复,风险低)。
|
||||
3. ⚪ **`.topbar .wordmark` 那条 CSS 保留未改** —— `admin.html` 用的是 `.auth-brand`、
|
||||
`portal.html` 用的是 `.brand`,当前**没有任何页面**用 `.topbar .wordmark`;
|
||||
保留是为防样式回归(改它不影响现状,属无收益改动)。
|
||||
|
||||
## 5. 一条待澄清的表述(未阻塞交付)
|
||||
|
||||
用户原话里的「**小写 CapabilityNet**」有两种读法,本轮按**读法一**实现:
|
||||
|
||||
- **读法一(已实现)**:中文界面 = 「能力枢纽」;英语及其他语言 = 「CapabilityNet」。
|
||||
- **读法二**:中文界面 = 「能力枢纽」+ 其下一行**小号** `CapabilityNet`(双语锁定);
|
||||
或要求英文写成**全小写** `capabilitynet`。
|
||||
|
||||
⇒ 若是读法二,改动量 = `i18n.js` 一处词条 + `design.css` 一行(**无需碰 HTML**)。
|
||||
|
||||
## 6. 回滚
|
||||
|
||||
| 对象 | 方式 |
|
||||
|---|---|
|
||||
| **第一批(login/register/design.css/i18n.js)** | `/opt/dsh/backups/rebrand-20260919-084220/pre.tar` → `/opt/dshs/` |
|
||||
| **本批(admin/portal/login/register/favicon/verify-static)** | `/opt/dsh/backups/rebrand2-20260919-090205/pre.tar` → `/opt/dshs/` |
|
||||
| 生效方式 | **`tar` 覆盖即生效,无需重启**(纯静态资源) |
|
||||
| ⚠️ 注意 | 回滚后页面会**重新引用 `/logo.svg`**(DeepSeek 图标仍在服务器上、文件没删)⇒ 回滚是完整的 |
|
||||
|
||||
## 7. 关联
|
||||
|
||||
- 同一会话的相邻改动:**Cloudflare Turnstile 密钥配置并启用**(见档案 **134 §实现 E**)。
|
||||
- 旧域移除见 **135**;控制面并集立项见 **136**(并行线产物,本档案不重复)。
|
||||
@@ -2,20 +2,20 @@
|
||||
|
||||
> **用途**:30 秒掌握「现在是什么样、正在做什么、该读哪一篇」。只写**结论与指针**,细节的单一来源见每行括注。
|
||||
> **防漂移**:本文件是**人工维护的摘要层**,不复制细节;`scripts/docs-audit.py` 会检查本文件引用的档案号是否存在、域名/阈值等关键串是否与来源文件一致。
|
||||
> **最后人工核对**:2026-09-16 10:5x
|
||||
> **最后人工核对**:2026-09-19 06:4x
|
||||
|
||||
---
|
||||
|
||||
## 1. 一句话
|
||||
|
||||
多租户 DSH 托管平台:门户(`alotbuy.com`)+ 每用户一个独立 dsh 实例(bwrap 合成根 + 独立 uid + systemd scope)。
|
||||
多租户 DSH 托管平台:门户(`ai1net.com`)+ 每用户一个独立 dsh 实例(bwrap 合成根 + 独立 uid + systemd scope)。
|
||||
**自 2026-09-15 起为集群形态**:`47.77.182.89` = **Manager**(cluster 模式,兼本地 Worker `w-47`)|**106 = Worker `w-106`**(经 SSH 反向隧道)。
|
||||
|
||||
## 2. 现行事实(结论层 · 改任何东西前先对齐这里)
|
||||
|
||||
| 项 | 现行值 | 单一来源 |
|
||||
|---|---|---|
|
||||
| **入口** | 门户 `https://alotbuy.com`;用户实例 `https://<用户名>.alotbuy.com`;旧域 `dsh.alotbuy.com` → **301** | 档案 22 / `DEPLOY §1` |
|
||||
| **入口** | 门户 `https://ai1net.com`;用户实例 `https://<用户名>.ai1net.com`;旧域 `alotbuy.com` 自 **2026-09-19** 起**降为 301 过渡装置**(门户块 `return 301 https://ai1net.com$request_uri`;**仅** `relay-direct.alotbuy.com` 与旧域 `/dshs-relay`、`/dshs-overlay/bootstrap` 继续透传 = 覆盖网络兜底);**relay 候选链同步由 5 条压到 3 条**(删掉两条「摘名不同、物理同落 47 同一路径」的伪多样性,⛔ 未减少真实冗余)。⛔ **退役条件 = 旧域 30 天访问量为 0** 后再删 `relay-direct.conf` 与 `dsh.alotbuy.com.conf` | 档案 22 / **135** / `DEPLOY §1` / `ops/域名迁移_ai1net_20260919/RUNBOOK-cutover.md §5` |
|
||||
| **服务** | systemd `dshs`(`127.0.0.1:3080`)+ 宝塔 nginx 反代(CF 回源 443) | `DEPLOY §1` |
|
||||
| **集群拓扑** | 47 = **Manager**(`deployMode=cluster`;控制面库 = 47 上 **PG13** `/var/lib/dshs-pg`,单元 `dshs-pg`,`127.0.0.1:15432`)+ 本地 Worker `w-47`(19100)|**106 = Worker `w-106`**(19000,**经 SSH 反向隧道**落在 Manager loopback,`DSHS_TUNNEL_TARGET`)|存量用户锚 `w-47`、新用户按容量落 `w-106`(**粘性优先**,`epoch` fencing 防双写)。⛔ 47 兼作 Worker 是**有意设计**,勿当待修项。**回滚** = `rm -f /etc/systemd/system/dshs.service.d/cluster.conf && systemctl daemon-reload && systemctl restart dshs` | `交接单/T08 §10` / `集群化改造方案_Manager-Worker_20260914.md` |
|
||||
| **运行时** | dsh **0.1.5-rc.1**(2026-09-14 由 0.1.2-rc.1 升级,走档案 07 五阶段全流程;官方包**零改动**)| Node 22.23.2 | pnpm 9.15.9 | nginx 1.28.3 | ⚠️ **bwrap 逐机不同:47 = 0.4.0 / 106 = 0.11.0**(`--perms` 属 0.5+ ⇒ 47 上 `Unknown option` ⇒ 沙箱起不来 = 所有实例全挂;要权限**用 `--tmpfs`**) | `DEPLOY §4` | 升级档案 `04/90` | `交接单/T08 §10` |
|
||||
@@ -27,7 +27,7 @@
|
||||
| **admin 管理面** | 门户:服务 / **密钥** / 用户 / 技能 / 插件管理 + 运行环境;会话内 `portal-entry` 卡片**一键整页跳门户** | `portal.html` / 档案 47 |
|
||||
| **清理阈值** | `ws` ≥ **2048 MB** → 清 T1 平台产物 + T2 超 **90 天**顶层脚本;`sessions` ≥ **1024 MB** → 清超 **365 天**;回收站 **30 天** | 档案 28 / `02 §C.7` |
|
||||
| **定时任务** | 每小时 :05 用量快照 → 04:10 工作区清理 → 04:20 会话回收 → 04:40 回收站清空 → **每周日 05:00 全量备份** | `DEPLOY §6.5` |
|
||||
| **证书** | `alotbuy.com + *.alotbuy.com`,DNS-01 via `/etc/cloudflare.ini`,**必须 `--dns-cloudflare-propagation-seconds 60`** | 档案 22 / `02 §C.1` |
|
||||
| **证书** | `ai1net.com + *.ai1net.com`(2026-09-19 经 **DNS-01** 签发,凭据 `/etc/cloudflare-ai1net.ini`,已入 certbot 自动续期)|`alotbuy.com + *.alotbuy.com`(旧域,**仅过渡期保留**)|**必须 `--dns-cloudflare-propagation-seconds 60`** | 档案 22 / `02 §C.1` / RUNBOOK §5 |
|
||||
| **红线** | 不自动升级 dsh;不改官方 dsh 主程序与缓存;client bundle 禁 `exports.default`;服务器 docs 保持 root 600;未明确要求不 commit/push;⛔ **禁止人工删锁 / 接管**(**R9**) | `README §红线` |
|
||||
|
||||
## 3. 当前待办(**单一来源 = `03-路线图与待办.md`** + **已规划的单子见 `交接单/README.md §一`**)
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
## 7. 文档层约定
|
||||
|
||||
单一来源:清单与状态 = `INDEX.md` §二|待办 = `03-路线图`|部署事实 = `DEPLOY-本部署.md`|术语以档案 38b 为准|域名以 `alotbuy.com` 为准。详见 `README §阅读约定` 与档案 53 / 54。
|
||||
单一来源:清单与状态 = `INDEX.md` §二|待办 = `03-路线图`|部署事实 = `DEPLOY-本部署.md`|术语以档案 38b 为准|域名以 `ai1net.com` 为准。详见 `README §阅读约定` 与档案 53 / 54。
|
||||
**归档编号**:至 **112**(其中 103–112 = 覆盖网络 / 客户端化 10 份规划);**编号 63 是空号,勿补占**。
|
||||
|
||||
**本机工作区(2026-09-13 起)**:`E:\ProgramData\AI技能\aliyun-dsh-server`(文档库工作树 = `dsh-server-docs/`);代码仓仍为 `D:\github\dsh_shenxian`。历史文档里出现的 `D:\AI技能\...` 一律是**迁移前旧值**,勿照抄执行。
|
||||
@@ -1,15 +1,15 @@
|
||||
# DEPLOY — 本部署说明(alotbuy.com / 47.77.182.89)
|
||||
# DEPLOY — 本部署说明(ai1net.com / 47.77.182.89)
|
||||
|
||||
> 用途:把"这套平台到底部署在哪、装了什么、改了什么、怎么重建/回滚"一次讲清,供后来人(或未来的我)在**不翻聊天记录**的前提下独立重建。
|
||||
> 对应档案 19 §C9。最后更新:2026-09-11
|
||||
> 对应档案 19 §C9。最后更新:2026-09-19(域名迁至 ai1net.com)
|
||||
|
||||
## 1. 拓扑与入口
|
||||
|
||||
| 项 | 值 |
|
||||
|---|---|
|
||||
| 服务器 | `47.77.182.89`(Alibaba Cloud Linux 8,阿里云)|SSH 别名 `bt-server`(端口 32022,root) |
|
||||
| 门户 / 用户实例 | **`https://alotbuy.com`** / **`https://<用户名>.alotbuy.com`**(Cloudflare 代理,回源走 443) |
|
||||
| 旧域名 | `dsh.alotbuy.com`(含 `*.dsh.*`)→ **301** 到新域(保留用户名映射) |
|
||||
| 门户 / 用户实例 | **`https://ai1net.com`** / **`https://<用户名>.ai1net.com`**(Cloudflare 代理,回源走 443) |
|
||||
| 旧域名 | `alotbuy.com`(含 `*.alotbuy.com`、`*.dsh.alotbuy.com`)→ **301** 到新域(2026-09-19 降为过渡装置;`relay-direct.alotbuy.com` 与 `/dshs-relay`、`/dshs-overlay/bootstrap` 仍透传) |
|
||||
| 面板 | 宝塔(站点 vhost 由面板管理,注意:面板保存会**重写** vhost,可能抹掉自定义指令) |
|
||||
| 编排器 | 自研 `dshs`(Node/TS),systemd 单元 `dshs`,监听 `127.0.0.1:3080` |
|
||||
| 被托管运行时 | 官方 **`@deepseek-ai/[email protected]`**(全局安装于 `/usr/local/lib/node_modules/@deepseek-ai/dsh`,**平台不改其任何文件**) |
|
||||
@@ -23,8 +23,8 @@
|
||||
| `<userRoot> = dataRoot/users/<uuid>` | 每用户根:`ws/`(工作区,用户可见)、`home/`(DSH_HOME:profiles/sessions/storages/skills)、`tmp/`(1777) |
|
||||
| `<profile> = <userRoot>/home/profiles/web` | 该用户的 dsh profile:`node_modules/`、`cordis.patch.yml`、`package.json`、`pnpm-lock.yaml` |
|
||||
| `/opt/dsh/` | 运维脚本与备份:`switch-domain-alotbuy.sh`、`backups/`、`artifacts/`(插件 tgz) |
|
||||
| `/www/server/panel/vhost/nginx/` | `alotbuy.com.conf`(现役)、`dsh.alotbuy.com.conf`(301 跳转)+ 多份 `.bak-*` |
|
||||
| `/etc/letsencrypt/live/{alotbuy.com,dsh.alotbuy.com}/` | 证书(DNS-01 via `/etc/cloudflare.ini`) |
|
||||
| `/www/server/panel/vhost/nginx/` | `ai1net.com.conf`(**现役门户**)、`relay-direct.conf`(443/TCP 兜底)、`alotbuy.com.conf`(**2026-09-19 降级为 301**)、`dsh.alotbuy.com.conf`(301)+ 多份 `.bak-*` |
|
||||
| `/etc/letsencrypt/live/{ai1net.com,alotbuy.com,dsh.alotbuy.com}/` | 证书(新域 DNS-01 via `/etc/cloudflare-ai1net.ini`;旧域 via `/etc/cloudflare.ini`) |
|
||||
|
||||
## 3. 环境变量(`/etc/dshs.env`)
|
||||
|
||||
@@ -32,8 +32,8 @@
|
||||
|---|---|
|
||||
| `DSHS_PORT` | 编排器监听端口(127.0.0.1:3080) |
|
||||
| `DSHS_DATA_ROOT` | dataRoot |
|
||||
| `DSHS_BASE_DOMAIN` | 门户/用户子域基域(现 `alotbuy.com`) |
|
||||
| `DSHS_COOKIE_DOMAIN` | 会话 Cookie 域(现 `.alotbuy.com`;见档案 22 §五 取舍说明) |
|
||||
| `DSHS_BASE_DOMAIN` | 门户/用户子域基域(现 `ai1net.com`) |
|
||||
| `DSHS_COOKIE_DOMAIN` | 会话 Cookie 域(现 `.ai1net.com`;见档案 22 §五 取舍说明) |
|
||||
| `DSHS_SECURE_COOKIES` | Cookie `Secure` 标记(HTTPS 下应开) |
|
||||
| `DSHS_DSH_BIN` | 子实例可执行路径 |
|
||||
| `DSHS_ISOLATION_MODE` | 隔离模式(本部署 `account` = bwrap + setpriv + systemd-run scope) |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> **首读 `BRIEF.md`(现状卡)**:30 秒对齐现行事实;本文件只管"定位篇目 + 状态"。
|
||||
> **双端模型**:本机 `D:\github\dsh_shenxian\dsh-server-docs\`(**工作树**,git 仓库 `dsh_shenxian_doc`)↔ 服务器 `/opt/dsh/docs`(**部署镜像**,root 600,**无 .git**,靠 scp)。
|
||||
> **对账**:`bash scripts/docs-sync-check.sh`(退出码 0 = 全绿)。
|
||||
> **最后核对**:2026-09-12 — **篇数与规模不在此写死**:本库由多会话并行改动,绝对值数十分钟即失效(同日实测 83 → 85 → 87,3 次作废);**一律以 `python3 scripts/docs-manifest.py` 复跑结果为准**。代码 HEAD 看服务器:`git -C /opt/dshs log -1`(最后核对值 `ebe8075`)。域名 `alotbuy.com`(旧域 `dsh.alotbuy.com` 已 301)。
|
||||
> **最后核对**:2026-09-12 — **篇数与规模不在此写死**:本库由多会话并行改动,绝对值数十分钟即失效(同日实测 83 → 85 → 87,3 次作废);**一律以 `python3 scripts/docs-manifest.py` 复跑结果为准**。代码 HEAD 看服务器:`git -C /opt/dshs log -1`(最后核对值 `ebe8075`)。域名 `ai1net.com`(旧域 `alotbuy.com` 自 2026-09-19 仅作 301 过渡装置)。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
| 我要做什么 | 先读 |
|
||||
|---|---|
|
||||
| **访问入口(当前域名)** | 门户 `https://alotbuy.com`;用户实例 `https://<用户名>.alotbuy.com` | `04-22`(迁移+回滚) |
|
||||
| **访问入口(当前域名)** | 门户 `https://ai1net.com`;用户实例 `https://<用户名>.ai1net.com` | `04-22`(迁移+回滚) |
|
||||
| **重建 / 交接部署** | **`DEPLOY-本部署.md`**(拓扑·目录·env·依赖·脚本族·构建/部署/回滚) | 档案 19 §C9 |
|
||||
| 理解架构 / 多租户隔离 | `01-规划与架构.md`(一~五、九、十三) | `archive/` 全文 |
|
||||
| 日常运维(重启/备份/token/KEY/排障) | `02-运维手册.md` | `03-路线图与待办.md` |
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
## 二、全量清单
|
||||
|
||||
> **状态摘要**(**机器生成,勿手改**):档案 **117** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 17 | 🔄 5 | 📝 1 | 🔍 1 | 📋 22 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。
|
||||
> **状态摘要**(**机器生成,勿手改**):档案 **121** 份(`04-*`),另含根级编号 5 条(01/02/03/06/07),另有非编号行 14 条(README / INDEX / 技能 / poc 等)—— ✅ 20 | 🔄 5 | 🔍 1 | 📋 23 | 🟡 1 | 未标记 76。复跑 `python3 scripts/docs-index-stats.py` 取数,`--write` 就地刷新本行。
|
||||
> **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / tier,可 `jq` 先筛后读):**`docs-manifest.json`**;复跑 `scripts/docs-manifest.py` 即刷新。
|
||||
> 图例:✅已落地 | 🔄维护中 | 🧪PoC | 📝待开发 | 🔍核查完成 | 📋评估 | 🟡保留兜底 | 🗄归档|🔧修复|🔴|🚧|❓|PoC 实证完成(插件 v0|已上线|生效|已实施|已实施 + API 全链路验|已冻结|已落地 + 验证通过|审计已完成 → 加固项待用户|已上线,页面全部 200|已封板,实施中|核查完成 → 收敛项 H1-|✅ 已实施并在 admin/|🔄 批次 1 因改走 bun|方案 A + handoff|根因已定位并修复(nginx|修复已编译(CI ✅),待重|已实施并上线|已修复并上线(服务已重启|清单建立|✅ 已实施并端到端验证|✅ 已实施并 live 验证|✅ 已实施并视觉验证|🔍 核查完成,P0 待决策|✅ 已实施(commit 见|✅ 已实施(commit `|✅ 已修复(commit `|🔍 取证完成|🔍 核查完成|✅ 已实施(`99de8c5|✅ 已落地|✅ 已修复|已落地并实测验证|已落地并端到端验证|🔄 第一批已修复|✅ 已实施(两批):第一批|✅ 已实施并端到端验证(co|✅ 已实施并验证(服务器已生|admin 侧已实施|✅ 已部署并生效(服务器 `|✅ 已实现、已验证、已部署|✅ 已完成并部署|✅ 全部完成并验证|✅ 已完成(2026-09-|✅ 已止损|✅ 已落地并部署(2026-|✅ 已完成并部署(2026-|✅ 已实现并单点验证|✅ 已落地并实测验证(配置|📋 标准已立|✅ 代码改造 + 组件级验证|✅ 已落地并验证(服务器 `|✅ 已部署并验证(2026-|✅ 已修复并部署|📋 评估中|🚧 执行中(R0 已完成|🚧 代码完成、产物已出|✅ 已完成并上线验证|✅ 已上线并端到端验证(后端|✅ 已修复并验证(源仓 `c|🔄 进行中(L2 机制级已验
|
||||
|
||||
@@ -178,6 +178,10 @@
|
||||
| 04-128 | 📋 | **DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代**(待评审 · 只做规划不含代码):复用官方壳 vs 自建的取舍与分发/版本矩阵 |
|
||||
| 04-129 | ✅ | **日志采集 · 实时巡检 · 事后溯源(方案 C 实现)**:不装新日志服务(否 Amber / 否 Loki+Alloy),用 `scripts/dshlog.mjs` 把 47/106 journald 拉回本机按天归档 + 跨机时间线 + 8 条巡检规则;**服务器侧零安装零端口**;⚠️ 最大剩余缺口 = 实例 `dsh --profile` 的 stdout 不进 journald |
|
||||
| 04-133 | 🟡 | 🔴 **序㊺ 实测判「D 本轮不实现」**(真实首屏包 **23 块 0 个低熵块**、60 份真实独立内容 **0 份低熵** ⇒ `D-1` 无对象可稀释、`D-2` 前提不成立)⇒ 剩 **C 域分离 + 判据重裁**(序㊻ · automation `aab9e357-8101-4011-b829-bf9f4a459bf7`)|**覆盖网络 · 低熵块治理方案 —— C 域分离 + D 非确定性**:块 id / 内容 id 的收敛哈希改 **per-network keyed hash**(切断跨 network 相关性,代价≈0)+ 低熵块非确定性(首选 **D-1「稀释域」**);⛔ **B(OPRF / SA-MLE)不立项**(降级为可选加强);🔴 **第一个动作 = 补测「低熵块种类数 / 体积 / 占首屏包比例」**(从未测过;且切分是**定长 1 MiB** ⇒ 首屏包内几乎无纯低熵块,待测必须含**子窗口腿**防假绿)|交付 = `交接单/覆盖网络-序45-低熵块治理-测熵与实现.md` |
|
||||
| 04-134 | ✅ | **注册页人机验证与邮箱验证码**(2026-09-19 落地 · 线上实测通过):注册 = 用户名 + 密码 + **邮箱验证码** + **Cloudflare Turnstile**(✅ **密钥已配、已启用**;服务端校 `success` + `action` + `hostname` **三项** —— 只校 `success` 时"拿公开 sitekey 在自己站点替真人取合法 token 再打我们接口"这条路是通的);DB **迁移 v10**(`users.email` + `email_codes` 事件表);邮件走**可插拔驱动**(`brevo` / `http` / `log`,换供应商只改 env),发件人 **`noreply@ai1net.com`**(Brevo 域名已认证 + DKIM + SPF);防爆破 = 三层配额 + **递增冷却阶梯**(60→60→180→300→900→1800s)+ 试错 5 次作废 + 码只存哈希 + 单次使用;实测 Brevo **delivered**、冷却 429、错码 `attemptsLeft` 递减、正确码 201、重放被挡、无 token/假 token **403**|档案 `04-调整方案/134-注册页人机验证与邮箱验证码.md` |
|
||||
| 04-135 | ✅ | **旧域 `alotbuy.com` 从项目移除**(2026-09-19 落地):代码 / 服务端 / 文档技能三层收敛为 `ai1net.com` 单域;旧域站点**降级为 301 过渡装置**(保留 ACME + `/dshs-relay` + `/dshs-overlay/bootstrap`,**30 天零流量**后退役);relay 候选 **5 → 3**(删掉两条“摘名不同、同落 47”的伪多样性);修 `wake.html` 转义点残留 + 演练脚本硬编码域造成的**假红**;参数表 2 键与 §10 指纹同步 |
|
||||
| 04-136 | 📋 | **控制面按两台中继取并集 —— 立项交接单**(2026-09-19 立项 · **未落地**):平台 web 层只取 47 中继 ⇒ 106 中继不被使用;根因链 7 行(取址路径 `src/web/server.ts` / `rendezvous.ts`)、**先红后绿**强制、候选 A/B 各带优缺点、验收 E1–E8 + 回滚 |
|
||||
| 04-137 | ✅ | **品牌标识改造 —— 去 DeepSeek 图形 → 平台自有标识**(2026-09-19 上线):**四页 + favicon 一并替换** —— `login` / `register` / `admin` 页头删 **DeepSeek 鲸鱼图标 +「DeepSeek」文字图形** 改平台标识(中文「**能力枢纽**」/英语及其他语言「**CapabilityNet**」,走 i18n 词条 `brand.name`,`en` 一条即覆盖其他语言回退);`portal` 顶栏换图标(页面名「管理门户」保留);**新建 `web/favicon.svg`**(平台自有 hub 图标,避开 DeepSeek 蓝);🔴 **会话页面左上角那处不动**(用户明示「先不替换」+它属官方 dsh client bundle,受 R2 约束);🔴 踩坑:**SVG 的 XML 注释不得含 ASCII 双连字符 `--`**(写 CSS 变量名即踩)⇒ 整份 SVG 解析失败、图标**静默不显示**,已加进 `verify-static.mjs` 并做反向自证;新增 `test/i18n-brand.test.mjs`|档案 `04-调整方案/137-品牌标识改造-去DeepSeek图形.md` |
|
||||
| — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 |
|
||||
| — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)|
|
||||
| — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)|
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# dsh 多租户平台 — 改造方案文档库
|
||||
|
||||
> 服务器:47.77.182.89(Alibaba Cloud Linux al8)| 平台:dshs + dsh 0.1.2-rc.1(account 硬隔离)
|
||||
> 域名:**`alotbuy.com`**(门户)/**`<用户名>.alotbuy.com`**(用户实例)| 旧域 `dsh.alotbuy.com` 已 **301**(档案 22)
|
||||
> 域名:**`ai1net.com`**(门户)/**`<用户名>.ai1net.com`**(用户实例)| 旧域 `alotbuy.com` 自 2026-09-19 仅作 **301** 过渡装置(档案 22 / `域名迁移_ai1net_20260919`)
|
||||
> 服务:systemd `dshs`(127.0.0.1:3080)| 现行状态与部署细节见 **`DEPLOY-本部署.md`**
|
||||
|
||||
> ⚠️ **两处 `docs/` 勿混**(档案 19 §C10):
|
||||
@@ -16,7 +16,7 @@
|
||||
1. **两套编号互不相同**:根级 `01/02/03/06` 是**长期文档**(架构、运维、路线图、UI 规范);`04-调整方案/NN-` 是**一次性改造成档案**。说"档案 NN"时**默认指后者**。
|
||||
2. **单一来源**:**清单与状态 = `INDEX.md` §二**;**待办与优先级 = `03-路线图与待办.md`**;**部署/构建/回滚 = `DEPLOY-本部署.md`**。其余文件提到这些内容时**只做指针,不复制**(历史上 README/INDEX 各存一份清单,已漂移 —— 见档案 53)。
|
||||
3. **术语以档案 38b 为准**:统一称「**功能插件**」(早期档案写"业务插件"= 同一事物;历史档案保留原文,不回改)。
|
||||
4. **域名以 `alotbuy.com` 为准**:2026-09-11 迁移(档案 22)。早期档案里的 `dsh.alotbuy.com` 属当时事实,**保留原文**;现行入口一律 `alotbuy.com`。
|
||||
4. **域名以 `ai1net.com` 为准**:**2026-09-19** 由 `alotbuy.com` 迁入(档案 22 / `ops/域名迁移_ai1net_20260919/`)。早期档案里的 `dsh.alotbuy.com`、`alotbuy.com` 属当时事实,**保留原文**;现行入口一律 `ai1net.com`。
|
||||
5. **AI 默认不读**:`archive/`(历史全文)、`scripts/` `ops/`(工具与配置)、`*.bak*`、`04-调整方案/poc/`(源码)—— 需要时再按需打开。
|
||||
6. **档案头部格式**:`- 日期:` + `- 状态:`(✅已完成 / 🔄进行中 / ⏸暂缓 / ❌关闭)+ `- 触发:`;正文用「需求 → 改动点 → 验证」三段。
|
||||
|
||||
|
||||
@@ -1,31 +1,31 @@
|
||||
{
|
||||
"generatedFrom": "scripts/docs-manifest.py",
|
||||
"counts": {
|
||||
"files": 189,
|
||||
"chars": 1982749
|
||||
"files": 194,
|
||||
"chars": 2050190
|
||||
},
|
||||
"tiers": {
|
||||
"hot": 6,
|
||||
"cur": 25,
|
||||
"warm": 61,
|
||||
"cold": 38,
|
||||
"doc": 59
|
||||
"cold": 42,
|
||||
"doc": 60
|
||||
},
|
||||
"domains": {
|
||||
"platform": 74,
|
||||
"platform": 75,
|
||||
"method": 47,
|
||||
"ui": 15,
|
||||
"ui": 17,
|
||||
"plugin": 27,
|
||||
"ops": 14,
|
||||
"ops": 16,
|
||||
"?": 8,
|
||||
"external": 4
|
||||
},
|
||||
"layers": {
|
||||
"L0": 1,
|
||||
"L5": 145,
|
||||
"L5": 149,
|
||||
"L4": 22,
|
||||
"L2": 3,
|
||||
"?": 3,
|
||||
"?": 4,
|
||||
"L1": 2,
|
||||
"L3": 13
|
||||
},
|
||||
@@ -51,7 +51,7 @@
|
||||
"title": "六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 14585,
|
||||
"chars": 14656,
|
||||
"lines": 338,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -548,7 +548,7 @@
|
||||
"date": "04-12",
|
||||
"chars": 4117,
|
||||
"lines": 77,
|
||||
"refs": 4,
|
||||
"refs": 6,
|
||||
"refsCurrent": 0,
|
||||
"tier": "warm",
|
||||
"layer": "L5",
|
||||
@@ -713,7 +713,7 @@
|
||||
"date": "04-13",
|
||||
"chars": 2444,
|
||||
"lines": 58,
|
||||
"refs": 5,
|
||||
"refs": 9,
|
||||
"refsCurrent": 0,
|
||||
"tier": "warm",
|
||||
"layer": "L5",
|
||||
@@ -735,6 +735,66 @@
|
||||
"domain": "method",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "04-调整方案/134-注册页人机验证与邮箱验证码.md",
|
||||
"num": "134",
|
||||
"title": "134-注册页人机验证与邮箱验证码(2026-09-19 落地)",
|
||||
"status": "✅",
|
||||
"date": "09-19",
|
||||
"chars": 13148,
|
||||
"lines": 240,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "cold",
|
||||
"layer": "L5",
|
||||
"domain": "ui",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "04-调整方案/135-旧域alotbuy.com从项目移除.md",
|
||||
"num": "135",
|
||||
"title": "135-旧域 alotbuy.com 从项目移除(2026-09-19 落地)",
|
||||
"status": "✅",
|
||||
"date": "09-19",
|
||||
"chars": 7758,
|
||||
"lines": 130,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "cold",
|
||||
"layer": "L5",
|
||||
"domain": "ops",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "04-调整方案/136-控制面按两台中继取并集-立项交接单.md",
|
||||
"num": "136",
|
||||
"title": "136 · 控制面(平台 web 层)按两台中继取并集 —— 立项交接单",
|
||||
"status": "🔄 待执行(已立项,未开工",
|
||||
"date": "09-19",
|
||||
"chars": 7839,
|
||||
"lines": 146,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "cold",
|
||||
"layer": "L5",
|
||||
"domain": "platform",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "04-调整方案/137-品牌标识改造-去DeepSeek图形.md",
|
||||
"num": "137",
|
||||
"title": "137-品牌标识改造(去 DeepSeek 图形 → 平台自有标识)",
|
||||
"status": "✅",
|
||||
"date": "09-19",
|
||||
"chars": 6287,
|
||||
"lines": 119,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "cold",
|
||||
"layer": "L5",
|
||||
"domain": "ui",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "04-调整方案/14-实例会话敏感信息暴露面审计与加固.md",
|
||||
"num": "14",
|
||||
@@ -2076,7 +2136,7 @@
|
||||
"title": "BRIEF — 现状卡(**AI / 人 首读这一份**)",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 5965,
|
||||
"chars": 6424,
|
||||
"lines": 80,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2103,10 +2163,10 @@
|
||||
{
|
||||
"path": "DEPLOY-本部署.md",
|
||||
"num": null,
|
||||
"title": "DEPLOY — 本部署说明(alotbuy.com / 47.77.182.89)",
|
||||
"title": "DEPLOY — 本部署说明(ai1net.com / 47.77.182.89)",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 4415,
|
||||
"chars": 4659,
|
||||
"lines": 102,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2121,8 +2181,8 @@
|
||||
"title": "dsh 平台文档导航(INDEX)",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 22828,
|
||||
"lines": 264,
|
||||
"chars": 24468,
|
||||
"lines": 268,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "doc",
|
||||
@@ -2136,7 +2196,7 @@
|
||||
"title": "dsh 多租户平台 — 改造方案文档库",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 10835,
|
||||
"chars": 10935,
|
||||
"lines": 103,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2340,6 +2400,21 @@
|
||||
"domain": "plugin",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "ops/域名迁移_ai1net_20260919/RUNBOOK-cutover.md",
|
||||
"num": null,
|
||||
"title": "域名迁移 runbook —— `alotbuy.com` → `ai1net.com`(47 · 2026-09-19 建立)",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 5785,
|
||||
"lines": 99,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "doc",
|
||||
"layer": "?",
|
||||
"domain": "ops",
|
||||
"tldr": ""
|
||||
},
|
||||
{
|
||||
"path": "ops/接续入口_覆盖网络线_20260916.md",
|
||||
"num": null,
|
||||
@@ -2391,7 +2466,7 @@
|
||||
"title": "dsh-change-workflow — dsh 平台改造工作流",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 70412,
|
||||
"chars": 70439,
|
||||
"lines": 1039,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2406,7 +2481,7 @@
|
||||
"title": "dsh-decision-method — 平台改造的思考与决策方法",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 15995,
|
||||
"chars": 15994,
|
||||
"lines": 360,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2496,7 +2571,7 @@
|
||||
"title": "dsh-feature-first — 功能优先协作协议",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 14920,
|
||||
"chars": 14919,
|
||||
"lines": 361,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2511,7 +2586,7 @@
|
||||
"title": "dsh-instance-diagnose — DSH 实例故障诊断",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 12545,
|
||||
"chars": 12542,
|
||||
"lines": 252,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2541,7 +2616,7 @@
|
||||
"title": "dsh-opensource-release — 开源导出与版本迭代",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 48137,
|
||||
"chars": 48133,
|
||||
"lines": 595,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2556,7 +2631,7 @@
|
||||
"title": "DSH 业务插件故障诊断",
|
||||
"status": "?",
|
||||
"date": "",
|
||||
"chars": 5919,
|
||||
"chars": 5917,
|
||||
"lines": 121,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
@@ -2856,8 +2931,8 @@
|
||||
"title": "交接单 · 低熵块治理(**测熵先行** + C 域分离 + D 非确定性)",
|
||||
"status": "待执行",
|
||||
"date": "",
|
||||
"chars": 19215,
|
||||
"lines": 313,
|
||||
"chars": 43309,
|
||||
"lines": 620,
|
||||
"refs": 0,
|
||||
"refsCurrent": 0,
|
||||
"tier": "doc",
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
# alotbuy.com —— DSH 平台站点(2026-09-10 建立,档案 21 场景延伸)
|
||||
# 走 Cloudflare 代理(橙云),故:
|
||||
# 1) 用 set_real_ip_from + CF-Connecting-IP 还原真实客户端 IP(否则日志/风控里全是 CF 的 IP)
|
||||
# 2) 80 端口「代理」而非 301:CF 若为 Flexible,源站 301 会造成 CF 侧循环;改成代理两种模式都能用
|
||||
# —— 但**必须**把 CF 的 SSL/TLS 模式设为 Full (Strict),否则 CF→源站是明文(凭据裸奔)
|
||||
# 3) 继承 dsh 站点的关键优化:proxy_buffering off(流式)、gzip_proxied any(930KB bundle 压缩)
|
||||
# alotbuy.com —— 旧域(2026-09-10 建立为门户站点;**2026-09-19 降级为 301 过渡装置**)
|
||||
#
|
||||
# 为什么"降级"而不是"删除"(判据见 04-调整方案/135):
|
||||
# ① 老书签 / 外链不 404(301 到新域**同名子域**,保留用户名映射)
|
||||
# ② 已入网节点的**旧域中继入口**仍可透传 ⇒ 不断线(见下方保留的 location)
|
||||
# ③ 软回滚路径保留:`cp alotbuy.com.conf.bak-dompurge-<ts> alotbuy.com.conf && nginx -t && nginx -s reload`
|
||||
# ⛔ 退役条件 = **旧域 30 天访问量为 0**(看 /www/wwwlogs/alotbuy.com.log)→ 连
|
||||
# `dsh.alotbuy.com.conf`、`relay-direct.conf` 一并删。
|
||||
#
|
||||
# 唯三**不** 301 的路径(其余一律 301 到新域):
|
||||
# /dshs-relay → 127.0.0.1:20080(自研 relay WebSocket;旧域入口,SEEDS 已不再引导)
|
||||
# /dshs-overlay/bootstrap → 127.0.0.1:3080(覆盖网络目录只读路由)
|
||||
# /.well-known/acme-challenge/(ACME 落点,续期不掉链)
|
||||
#
|
||||
# map 必须位于 http 上下文 —— 面板 vhost 文件正是被 include 在 http{} 内,故写在本文件顶层。
|
||||
# ⚠️ 与 `dsh.alotbuy.com.conf` 的 map 是**两个不同变量名**,不冲突;
|
||||
# `*.dsh.alotbuy.com` 由那个文件**更长的通配 server_name** 接管,不进本文件的 map。
|
||||
# ⚠️ 301 目标是**不同主机**(ai1net.com,CF 侧已是 Full(strict))⇒ 不存在"源站 301 造成 CF 循环"。
|
||||
# 故 80 端口可以直接 301(原门户块当年为兼容 CF Flexible 才用代理)。
|
||||
|
||||
map $host $alotbuy_301_host {
|
||||
default ai1net.com;
|
||||
~^(?<lb301>[^.]+)\.alotbuy\.com$ $lb301.ai1net.com;
|
||||
}
|
||||
|
||||
# ---- HTTP:代理(不用 301,兼容 CF Flexible;Full (Strict) 下 80 不会被用到)----
|
||||
# ---- HTTP:301 到新域(旧域不再承载门户)----
|
||||
server {
|
||||
# CF 回源 IP → 还原真实客户端 IP(仅本 server 生效)
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
@@ -34,25 +51,40 @@ server {
|
||||
real_ip_header CF-Connecting-IP;
|
||||
listen 80;
|
||||
server_name alotbuy.com www.alotbuy.com *.alotbuy.com;
|
||||
location / {
|
||||
|
||||
# ── ACME 挑战(续期用;`^~` 优先于下面的 `location /`)──
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /www/server/nginx/html;
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ── 覆盖网络中继(保留:已入网节点仍可能持旧域地址)──
|
||||
location /dshs-relay {
|
||||
proxy_pass http://127.0.0.1:20080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
location = /dshs-overlay/bootstrap {
|
||||
proxy_pass http://127.0.0.1:3080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_read_timeout 3600s;
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
gzip_proxied any;
|
||||
}
|
||||
|
||||
location / { return 301 https://$alotbuy_301_host$request_uri; }
|
||||
|
||||
access_log /www/wwwlogs/alotbuy.com.log;
|
||||
error_log /www/wwwlogs/alotbuy.com.error.log;
|
||||
}
|
||||
|
||||
# ---- HTTPS:门户 + 用户实例子域 ----
|
||||
# ---- HTTPS:301 到新域(同上)----
|
||||
server {
|
||||
# CF 回源 IP → 还原真实客户端 IP(仅本 server 生效)
|
||||
set_real_ip_from 173.245.48.0/20;
|
||||
@@ -84,32 +116,33 @@ server {
|
||||
ssl_certificate /etc/letsencrypt/live/alotbuy.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/alotbuy.com/privkey.pem;
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /www/server/nginx/html;
|
||||
default_type text/plain;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# ── 覆盖网络中继(保留)──
|
||||
location /dshs-relay {
|
||||
proxy_pass http://127.0.0.1:20080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection $connection_upgrade;
|
||||
proxy_set_header Host $host;
|
||||
proxy_read_timeout 3600s;
|
||||
# 流式(dsh 回复/思考逐块下发)+ 反代压缩 + 解除缓冲
|
||||
proxy_buffering off;
|
||||
proxy_cache off;
|
||||
proxy_send_timeout 3600s;
|
||||
gzip_proxied any;
|
||||
proxy_buffering off;
|
||||
}
|
||||
# 内容哈希命名的静态资源 → 长缓存
|
||||
location ~* ^/assets/ {
|
||||
location = /dshs-overlay/bootstrap {
|
||||
proxy_pass http://127.0.0.1:3080;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_buffering off;
|
||||
gzip_proxied any;
|
||||
expires 30d;
|
||||
}
|
||||
|
||||
location / { return 301 https://$alotbuy_301_host$request_uri; }
|
||||
|
||||
access_log /www/wwwlogs/alotbuy.com.log;
|
||||
error_log /www/wwwlogs/alotbuy.com.error.log;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
> ✅ **2026-09-19 07:3x 状态更新:本 RUNBOOK 已全部执行完,且「旧域彻底退役」那一步也一并做了。**
|
||||
> 原件原位于 47 的 `/www/server/panel/vhost/nginx/_pending-ai1net/`(该目录已于本日归档报废)
|
||||
> ⇒ **本文件是该 RUNBOOK 的唯一在册副本**。
|
||||
>
|
||||
> - **§1 阻塞项已解决**:用户提供 ai1net.com 的 CF 令牌 ⇒ 落 `/etc/cloudflare-ai1net.ini`(600)
|
||||
> ⇒ `certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/cloudflare-ai1net.ini
|
||||
> --dns-cloudflare-propagation-seconds 60 -d ai1net.com -d '*.ai1net.com' --cert-name ai1net.com` 签发成功(DNS-01)。
|
||||
> - **§2 的 S1–S5 全部执行**(06:0x–07:3x):两份新 vhost 已启用;`/etc/dshs.env` 已切到 `ai1net.com` / `.ai1net.com`;
|
||||
> `relay-direct.conf` 与 `dsh.alotbuy.com.conf` 已改指新域;106 `/etc/dshs-worker.env` 已补同份种子。
|
||||
> - 🔴 **§4 末尾那条「留待你确认退役时机」已被确认并落地**(用户 2026-09-19 明令「旧域从项目中移除,后续全部使用 ai1net.com」):
|
||||
> `alotbuy.com.conf` 由**独立门户**降级为 **301 过渡装置**;引导种子里的 2 条旧域项已撤。
|
||||
> ⇒ 完整判据 / 步骤 / 验收 / 回滚见 **`04-调整方案/135-旧域alotbuy.com从项目移除.md`**。
|
||||
> ⚠️ 故本文件 §4 末条「本轮**不做**」的表述**已被本次取代**(⛔ §4 原文保留不改,仅在此更正)。
|
||||
> - **回滚点**:47 `/root/_dompurge/`(被替换的两份新配置、目录缓存备份、归档的 `_pending-ai1net-*`);
|
||||
> 生产侧 `/www/server/panel/vhost/nginx/alotbuy.com.conf.bak-dompurge-20260919-073004`、
|
||||
> `/etc/systemd/system/dshs.service.d/overlay-443fb.conf.bak-dompurge-20260919-073101`、
|
||||
> 106 `/etc/dshs-worker.env.bak-dompurge-20260919-073149`。
|
||||
|
||||
---
|
||||
|
||||
# 域名迁移 runbook —— `alotbuy.com` → `ai1net.com`(47 · 2026-09-19 建立)
|
||||
|
||||
> 本文件与同目录两个 `*.conf` 一起放在 47 的 `/www/server/panel/vhost/nginx/_pending-ai1net/`(**待启用**,nginx 只 include 该目录的 `*.conf` 一层,子目录天然不生效)。
|
||||
> 目标:把线上部署的 DSH 服务域名由 `alotbuy.com` 切到 `ai1net.com`(门户 + 实例子域 + 中继兜底入口 + 平台 env)。
|
||||
|
||||
## 0. 现状(2026-09-19 取证)
|
||||
|
||||
| 项 | 现状 |
|
||||
|---|---|
|
||||
| 门户 vhost | `/www/server/panel/vhost/nginx/alotbuy.com.conf`:`alotbuy.com www.alotbuy.com *.alotbuy.com` → 3080,`/dshs-relay` → 20080,证书 `live/alotbuy.com`(SAN `alotbuy.com` + `*.alotbuy.com`) |
|
||||
| 实例子域 | **`<user>.alotbuy.com`**(一级标签,由门户 vhost 的 `*.alotbuy.com` 承载,无需第二张证书) |
|
||||
| 旧名 301 | `dsh.alotbuy.com.conf`:`dsh.alotbuy.com` / `*.dsh.alotbuy.com` → 301 到 `alotbuy.com` / `<label>.alotbuy.com`,证书 `live/dsh.alotbuy.com` |
|
||||
| 中继兜底 | `relay-direct.conf`:`relay-direct.alotbuy.com`(443),两端点 `/dshs-relay` + `= /dshs-overlay/bootstrap`(Host 改写成 `alotbuy.com`) |
|
||||
| 平台 env | `/etc/dshs.env`:`DSHS_BASE_DOMAIN=alotbuy.com`、`DSHS_COOKIE_DOMAIN=.alotbuy.com`、`DSHS_PORT=3080` |
|
||||
| 中继引导种子 | `lib/config.js` 内置 `['https://alotbuy.com/dshs-relay']`;env 覆盖键 = **`DSHS_OVERLAY_BOOTSTRAP_SEEDS`**(`splitList`,可多值) |
|
||||
| 证书签发 | certbot 1.22.0 + **dns-cloudflare** 插件;凭据 `/etc/cloudflare.ini`(**令牌仅覆盖 alotbuy.com 一个 zone**) |
|
||||
|
||||
**ai1net.com 侧已就绪的部分**:DNS 已在 Cloudflare(`ai1net.com` / `www` / `*` / `<x>.dsh` 均解析到 CF)、CF→源站(47.77.182.89)链路已验证可达(用 80 端口探针文件经 CF 取回原文)、LE **http-01 路径可用**。
|
||||
|
||||
## 1. 🔴 唯一阻塞项(需用户侧提供)
|
||||
|
||||
**`*.ai1net.com` 通配证书必须走 DNS-01**(LE 对通配符只认 DNS-01),而 47 上现存的 CF 令牌**只覆盖 alotbuy.com**(实测 `/zones` 只返回 1 个 zone)⇒ 我无法为 ai1net.com 写 `_acme-challenge` TXT。
|
||||
|
||||
两条可选路径(任选其一):
|
||||
- **A. 给一份 ai1net.com 域的 CF API 令牌**(权限最小化:`Zone → DNS → Edit`,Zone Resources = 仅 `ai1net.com`)
|
||||
→ 落到 `/etc/cloudflare-ai1net.ini`(`dns_cloudflare_api_token = <令牌>`,mode 600)
|
||||
→ 我执行 S1:`certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/cloudflare-ai1net.ini --dns-cloudflare-propagation-seconds 60 -d ai1net.com -d '*.ai1net.com' --cert-name ai1net.com`
|
||||
- **B. 在 CF 面板生成 Origin CA 证书**(主机名填 `ai1net.com, *.ai1net.com`),把 cert + key 文本给我
|
||||
→ 落到 `/etc/ssl/ai1net/{fullchain.pem,privkey.pem}`,`ai1net.com.conf` 里两处证书路径改指它
|
||||
→ 无需给我任何 API 权限;代价是证书由 CF 侧管理、不参与 certbot 自动续期。
|
||||
|
||||
## 2. cutover 步骤(**证书就绪后**按序执行,每步带验收)
|
||||
|
||||
```bash
|
||||
V=/www/server/panel/vhost/nginx
|
||||
# S1 证书(见 §1;A 路径用 certbot,B 路径跳过)
|
||||
certbot certificates | grep -A3 ai1net.com
|
||||
|
||||
# S2 启用两份 vhost(⛔ 证书不存在时**不要**做这步 ⇒ nginx 起不来 = 门户全挂)
|
||||
cp -a $V/_pending-ai1net/ai1net.com.conf $V/ai1net.com.conf
|
||||
cp -a $V/_pending-ai1net/relay-direct.ai1net.com.conf $V/relay-direct.ai1net.com.conf
|
||||
nginx -t && nginx -s reload
|
||||
# 验收:curl -s --http1.1 -o /dev/null -w '%{http_code}\n' https://ai1net.com/portal.html ⇒ 200
|
||||
|
||||
# S3 平台 env 切换(备份 → 改两项 → 追加种子(加性,保留 alotbuy 作第二种子)→ 重启)
|
||||
cp -a /etc/dshs.env /etc/dshs.env.bak-dom-$(date +%Y%m%d-%H%M%S)
|
||||
sed -i 's/^DSHS_BASE_DOMAIN=.*/DSHS_BASE_DOMAIN=ai1net.com/' /etc/dshs.env
|
||||
sed -i 's/^DSHS_COOKIE_DOMAIN=.*/DSHS_COOKIE_DOMAIN=.ai1net.com/' /etc/dshs.env
|
||||
grep -q '^DSHS_OVERLAY_BOOTSTRAP_SEEDS=' /etc/dshs.env || \
|
||||
echo 'DSHS_OVERLAY_BOOTSTRAP_SEEDS=https://ai1net.com/dshs-relay,https://alotbuy.com/dshs-relay' >> /etc/dshs.env
|
||||
systemctl restart dshs
|
||||
# 验收:实例地址变成 <user>.ai1net.com 且能登录;中继仍在线(探针 29 PASS / 0 FAIL / 0 SKIP)
|
||||
|
||||
# S4 旧名连带项(**必须同批**,否则留半破状态)
|
||||
# S4a relay-direct.conf(alotbuy 版)的 Host 改写也必须指向新基底域:
|
||||
# proxy_set_header Host alotbuy.com; → ai1net.com
|
||||
# S4b dsh.alotbuy.com.conf(旧名 301)的 map 目标改指新域(⚠️ 只改两行,别碰 server_name/正则):
|
||||
# default alotbuy.com; → ai1net.com;
|
||||
# ~^(?<label>[^.]+)\.dsh\.alotbuy\.com$ $label.alotbuy.com; → $label.ai1net.com;
|
||||
nginx -t && nginx -s reload
|
||||
|
||||
# S5 worker 侧种子(可选,加性):/etc/dshs-worker.env 加同名多值 SEEDS,再 restart dshs-worker
|
||||
# ⚠️ 改前 `--scene all` 演练含观察窗口,预算 ≥8 min;⛔ 别套短超时
|
||||
```
|
||||
|
||||
## 3. 回滚(任一步失败都能退回)
|
||||
|
||||
1. 删两份新 vhost ⇒ `nginx -t && nginx -s reload`(门户立即回到 alotbuy 版)。
|
||||
2. `cp -a /etc/dshs.env.bak-dom-<ts> /etc/dshs.env` ⇒ `systemctl restart dshs`(回到 `alotbuy.com` / `.alotbuy.com`)。
|
||||
3. S4 的两处 sed 反向改回(alotbuy 版 vhost 与旧名 301 都可原样恢复)。
|
||||
4. 证书可留(不影响 alotbuy);`certbot delete --cert-name ai1net.com` 可彻底清掉。
|
||||
|
||||
## 4. 已知影响面(提前说明,非阻塞)
|
||||
|
||||
- **切换瞬间所有人需要在新域重登一次**:cookie 域由 `.alotbuy.com` 变 `.ai1net.com` ⇒ 旧 cookie 不再随请求发送(无法双域共存,一个 cookie 只能挂一个 Domain)。
|
||||
- **实例地址由 `<user>.alotbuy.com` 变为 `<user>.ai1net.com`**:旧地址在 alotbuy 版 vhost 仍可用(两套 vhost 并存期间);若要彻底退役旧域,再补一条 `alotbuy.com → ai1net.com` 的 301(本轮**不做**,留待你确认退役时机)。
|
||||
- `work.alotbuy.com`(Gitea · 154.40.35.3)**与本次无关**,不动。
|
||||
- 中继引导种子改成「新域为主、旧域为备」⇒ 两域任一存活都不影响 worker 入网。
|
||||
@@ -9,11 +9,13 @@ import time
|
||||
import urllib.request
|
||||
|
||||
CRED = '/etc/cloudflare.ini'
|
||||
# 2026-09-19 域迁 ai1net.com 后其 zone 走独立凭据(与旧域 token 不通用)
|
||||
CREDS = {'ai1net.com': '/etc/cloudflare-ai1net.ini'}
|
||||
API = 'https://api.cloudflare.com/client/v4'
|
||||
|
||||
|
||||
def token():
|
||||
txt = open(CRED, 'r', encoding='utf-8').read()
|
||||
def token(zone=None):
|
||||
txt = open(CREDS.get(zone or '', CRED), 'r', encoding='utf-8').read()
|
||||
m = re.search(r'dns_cloudflare_api_token\s*=\s*([A-Za-z0-9_\-]+)', txt)
|
||||
if not m:
|
||||
sys.exit('未找到 token')
|
||||
@@ -52,9 +54,9 @@ def show(title, name, typ):
|
||||
|
||||
|
||||
def main():
|
||||
zone = sys.argv[1] if len(sys.argv) > 1 else 'alotbuy.com'
|
||||
zone = sys.argv[1] if len(sys.argv) > 1 else 'ai1net.com'
|
||||
name = sys.argv[2] if len(sys.argv) > 2 else '_acme-challenge.' + zone
|
||||
tok = token()
|
||||
tok = token(zone)
|
||||
|
||||
zid = ''
|
||||
for z in (req('GET', '/zones?name=' + zone, tok).get('result') or []):
|
||||
|
||||
@@ -21,9 +21,9 @@ docs-consistency.py —— 文档库「事实一致性」校验(只读,可
|
||||
- 豁免(历史事实,只增不改):`04-调整方案/**`、`archive/**`、`01-规划与架构.md`、`02-运维手册.md`
|
||||
—— 它们写的时候那个值是对的,回改反而破坏历史。
|
||||
|
||||
⚠️ **刻意不查**「旧域名 dsh.alotbuy.com」「旧配额 512M」:这两者在库里几乎都是
|
||||
⚠️ **刻意不查**「旧域名 `dsh.alotbuy.com` / `alotbuy.com`」「旧配额 512M」:这些在库里几乎都是
|
||||
"旧域已 301" / "512M→384M" 的**合法历史表述**,正则无法可靠区分,误报率过高。
|
||||
改由人工在改域名/改配额时顺手核(档案 22 / 58 是权威)。
|
||||
改由人工在改域名/改配额时顺手核(档案 22 / 58 是权威;2026-09-19 域迁 `ai1net.com` 后旧域仅作过渡装置)。
|
||||
|
||||
用法:
|
||||
python3 scripts/docs-consistency.py
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-change-workflow
|
||||
description: dsh 多租户平台(alotbuy.com / dshs,服务器 47.77.182.89)功能改造与优化任务的完整工作流。当用户提出平台需求、改造、优化、缺陷修复、功能调研,或要求"按既有流程执行/沉淀/整理处理机制"时触发。核心:需求识别→调研→规划→开发→验证→归档清理 六阶段 + 红线机制(不自动升级 dsh / 不改官方主程序 / client bundle 禁 exports.default / 不用真实账号测登录 / **R7 禁未经确认的批量·全仓写入** / **R8 会中断在线用户的生产变更须先知会**)+ 备份先行 + append-only 日志 + 表格式交付 + 文档「服务器唯一源 + docs-status 单目录同步」。
|
||||
description: dsh 多租户平台(ai1net.com / dshs,服务器 47.77.182.89)功能改造与优化任务的完整工作流。当用户提出平台需求、改造、优化、缺陷修复、功能调研,或要求"按既有流程执行/沉淀/整理处理机制"时触发。核心:需求识别→调研→规划→开发→验证→归档清理 六阶段 + 红线机制(不自动升级 dsh / 不改官方主程序 / client bundle 禁 exports.default / 不用真实账号测登录 / **R7 禁未经确认的批量·全仓写入** / **R8 会中断在线用户的生产变更须先知会**)+ 备份先行 + append-only 日志 + 表格式交付 + 文档「服务器唯一源 + docs-status 单目录同步」。
|
||||
version: 2.9.2-playwright-banned
|
||||
updated_at: 2026-09-15
|
||||
last_change: 【2026-09-12 新增「三把锁」落地机制 + 更正过期指向】把「多任务并行调度协议」从**设计**补成**可强制执行的判据** —— 全局执行锁 `交接单/.exec-lock`(同一时刻只允许一个执行会话)+ 单级占用锁 `.doing-<单号>` + **服务器侧操作锁 `/opt/dsh/state/.op-lock/`**(管生产态:重启 / drain / 改实例 env·quota / 批量铺插件 / 改 nginx·nft·证书,因「服务器态变更看不出来」故必须显式加锁);工装 `scripts/handoff-guard.sh`(新增【1d】分支)与 `scripts/op-lock.sh`。同时**更正产出闭环里的过期指向**:原文写 `docs-status/文档库状态备注.md` + `bash scripts/docs-status-sync.sh --pull`,**该机制已不存在** → 改为 `INDEX.md §二` 登记 + 收尾四件套(audit → manifest → sync-check → `PUSH=1 handoff-guard`)。出处:当日 3 次并行事故 + `04-调整方案/69`。此前】**R7 禁止未经确认的批量 / 全仓写入**(由来:为"让 scp 出去的文件行尾干净",用脚本把 **147 个文件** CRLF→LF —— 当期只被要求改一个 UI 字符串;被用户指为「容易把服务器搞崩」,且随后 `cp -r` 确实把**未改动的** 2 个文件覆盖到了服务器)+ **R8 会中断在线用户的生产变更须先知会**(由来:档案 58/59 连续两次重启,直接引发用户"会话连接异常"报障)。【2026-09-11 晚,用户当面纠正后大修】① **阶段 0 新增第 0 条「开工前置检查」并标明"本技能最重要的一步"**(装工具/写自动化/查机制前先看:可用技能列表 → 本机/项目已有技能与记忆 → "本机已有的能否满足")+ 红线 **R6「先查已有资产,再动手」**;② **浏览器自动化定型**:用 `browser-harness`(CDP 9223,Windows 调用方式 + helper 清单),**禁再装 agent-browser / Playwright**;⛔ **铁律:browser-harness 附着的是「用户正在用的 Chrome」→ 动手前先 `list_tabs()`,不是全新空浏览器就立刻停手**(本轮真实事故:在其浏览器里登录测试号 → 覆盖用户 `.alotbuy.com` 的 `sid`);dsh composer 是 Lexical(`data-lexical-editor`),`type_text`/`fill_input` 均未生效;③ **更正「空闲回收」认知**:回收**从未生效**(TTL 默认 7 天 / cap 4 / 每请求 touch),但 **"回收没生效"≠"实例不会中断"** —— 中断真凶 = **服务重启(09-11 达 48 次)→ 旧实例变孤儿 → 另起新实例 → 旧页面 401** + **实例崩溃自动重启(近 4 天 69 次;admin 根因 `duplicate loader entry id: permission`)**;附三数核对手法;④ 阶段 1 新增「**用户报障第一步:先读 journal 拿真实失败请求**」(三分钟定位,判据速查 401/502/crash);⑤ 阶段 4 新增「**验证用例必须同时覆盖「有请求体」与「无请求体」**」(档案 51 事后修正实证:只测 POST 会整条漏掉 GET/SSE)+ 「模拟浏览器语义要测到底」(只带最初旧 cookie、显式断言 Set-Cookie 回写);⑥ R4 补**专用测试账号模板**(`role='active'`、`approved_by` 需真实 admin id;`users` 表无 `status` 列)。此前新增「**本环境 TLS 走中间人代理 → Python 脚本必须 SSL_CERT_FILE=/etc/pki/tls/certs/ca-bundle.crt**」(两份 CA bundle 的区别 + 零平台改动正解 + 为何不走 R5 注入 env)+「职责边界:技能投放=admin 的事,平台只提供机制与把关」;此前 R5 追加「**安装类操作=扩大,必须先出安装确认清单(七项)**;技能/插件依赖体检只报告+拦截、绝不代装」;另把「复杂引号一律本地写文件→scp(禁 ssh 内 inline node -e/sed)」从建议升为**硬性要求**(本轮同类错误连犯 4 次)。此前补环境事实「正确域名 alotbuy.com(dsh.alotbuy.com 是旧域 301,抓页面只会拿到 301 HTML)」+ 档案 45 存量会话提示落点(login.html);此前新增「**基础运行时版本冻结**」节(档案 44:唯一缝隙=Python user-site 会盖平台包;修法 PYTHONNOUSERSITE + PYTHONUSERBASE 双保险;版本漂移巡检 scripts/runtime-baseline.cjs + cron 05:10);补「3.6 不开放」的正解(引导优先,遮蔽不可行)+ 属主自愈(ws-cleanup --reclaim-only,平台 root 跑 pnpm 污染用户 ws);**R5 已立**。此前新增红线 R5「权限可见面只准收窄,扩大必须先确认」**(含「R5 权限扩大门禁」整节:扩大/收窄方向判定表、权限影响评估四问、触发文件清单、以「实例可访问路径清单」做 diff 验收);阶段 2 红线自查与影响评估同步加入 R5 门禁;此前新增「**无法遮蔽 /usr 内文件**」(ro-bind 目录无法创建挂载点 → 叠加遮蔽会让实例起不来;判据「只有先 tmpfs 再白名单的目录才可自由增删」+ 改 bwrap 参数后必须真启动实例验证)+「**实例Line truncated
|
||||
@@ -13,12 +13,12 @@ dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改
|
||||
|
||||
## 平台速查(硬编码事实,勿猜)
|
||||
|
||||
> ⚠️ **正确域名 = `alotbuy.com`**;`dsh.alotbuy.com` 是**旧域名、nginx 301 跳转**
|
||||
> ⚠️ **正确域名 = `ai1net.com`**(2026-09-19 由 `alotbuy.com` 迁入);`alotbuy.com` 是**旧域名、已降为 nginx 301 过渡装置**
|
||||
> ——对它抓页面只会拿到 301 HTML,**别误判为"改动没生效"**(2026-09-11 踩过)。
|
||||
> 平台页由门户直出(`127.0.0.1:3080`),生效副本只有 `/opt/dshs/web/` 一份。
|
||||
> 存量会话提示(档案 45)落在 `web/login.html`:告知「shell 频繁要求审批 → **新开一个会话**即可」。
|
||||
>
|
||||
> ⚠️ **`*.alotbuy.com`(含每个用户子域)都由同一个 vhost 先 `proxy_pass 127.0.0.1:3080`(门户)**,
|
||||
> ⚠️ **`*.ai1net.com`(含每个用户子域)都由同一个 vhost 先 `proxy_pass 127.0.0.1:3080`(门户)**,
|
||||
> 再由门户的 `src/supervisor/proxy.ts` 转发到实例 → **想改"子域访问行为"要改 `proxy.ts`,
|
||||
> nginx 的 `error_page` / `location` 到不了实例层**(档案 49 实测)。
|
||||
> 实例空闲回收后首次访问的链路(档案 49):**导航**(GET+`Accept: text/html`)→ 立刻 302 到
|
||||
@@ -73,7 +73,7 @@ dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改
|
||||
> 🔴 **浏览器自动化:只允许两件工具;Playwright 全面禁止**(用户 2026-09-13 明令:「**playwright 禁止使用,加到规则中**」):
|
||||
> ① **`agent-browser`**(WorkBuddy 内置技能,**独立浏览器,不碰用户环境**)—— **本项目默认走这条**(2026-09-13 选定);
|
||||
> ② **`browser-harness`**(CDP **9223**,**附着用户正在用的 Chrome**)—— 只在需要看用户现场时用,
|
||||
> ⛔ **动手前必须先 `list_tabs()`**;旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.alotbuy.com` 的 `sid`**。
|
||||
> ⛔ **动手前必须先 `list_tabs()`**;旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.ai1net.com` 的 `sid`**。
|
||||
> ⛔ **禁止 Playwright / `playwright-core`**(含「独立无头」用法)—— 本行**更正**此前把它写成「只读验收默认路径」的错
|
||||
> (那是 2026-09-11 加的,与同页「禁 Playwright」**自相矛盾**;2026-09-13 已按用户明令统一为**禁止**)。
|
||||
> 静态页与 API 取数优先 `curl` / WebFetch,**别动浏览器**。
|
||||
@@ -83,7 +83,7 @@ dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改
|
||||
| 服务器 | 47.77.182.89(Alibaba Cloud Linux al8);SSH:用别名 `bt-server`(`~/.ssh/config`:`HostName 47.77.182.89`、`Port 32022`、`User root`、`IdentityFile ~/.ssh/id_ed25519`) |
|
||||
| 门户 | dshs(systemd `dshs`,127.0.0.1:3080);源码 `/opt/dshs`(git master) |
|
||||
| 数据 | `/var/lib/dshs/users/<id>/{home,ws}`;DB `/var/lib/dshs/dshs.db`(root 600) |
|
||||
| 域名 | **alotbuy.com**(门户,CF 代理 + 通配证书,源站 443);用户子域 **`<用户名>.alotbuy.com`** 经 portal proxy 转发实例;旧 `dsh.alotbuy.com` 301 → `alotbuy.com`(档案 22 域名迁移) |
|
||||
| 域名 | **ai1net.com**(门户,CF 代理 + 通配证书,源站 443);用户子域 **`<用户名>.ai1net.com`** 经 portal proxy 转发实例;旧 `alotbuy.com` 301 → `ai1net.com`(档案 22 / 2026-09-19 迁入) |
|
||||
| 实例 | 门户 spawn `node /usr/local/bin/dsh --profile web --host 127.0.0.1 --port <动态>`;ISOLATION account(setpriv,uid∈[100000,199999];admin=114801) |
|
||||
| 文档 | **本机 git 工作树 = 唯一源**:`E://ProgramData//AI技能//aliyun-dsh-server//dsh-server-docs`(仓库 `dsh_shenxian_doc`)→ scp 到服务器 `/opt/dsh/docs`(root 600 / README 644,**无 .git 的部署镜像**)。⛔ **本行 2026-09-13 更正**:原文写「服务器唯一源 / 本地不保留副本 / `dsh-server-docs/` 已废弃」——那是**更早的状态,现已作废** |
|
||||
| 文档同步 | 改完跑**四件套**:`docs-audit.py` → `docs-index-stats.py --write`(INDEX 状态摘要**机器生成**)→ `docs-manifest.py` → `docs-sync-check.sh` → `docs-consistency.py`;**只 scp 自己本次改的文件**、推前复跑对账。⛔ `docs-status/` 机制**已不存在**(原文作废) |
|
||||
@@ -163,7 +163,7 @@ dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改
|
||||
4. 本机直连验证(绕 DNS/代理):
|
||||
```bash
|
||||
# SECURE_COOKIES=true → 必须走 TLS;用 --resolve 把域名指到回环
|
||||
curl -sk --resolve alotbuy.com:443:127.0.0.1 https://alotbuy.com/api/...
|
||||
curl -sk --resolve ai1net.com:443:127.0.0.1 https://ai1net.com/api/...
|
||||
# 带登录态:-H "Cookie: sid=<token>"
|
||||
```
|
||||
(明文 `http://127.0.0.1:3080` 在 SECURE_COOKIES 下不通)
|
||||
@@ -205,13 +205,13 @@ dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改
|
||||
|
||||
**browser-harness 会附着到本机**正在运行的**用户日常 Chrome**(CDP **9223**)——2026-09-11 实证:
|
||||
`BU_CDP_URL=9223` **不被尊重**(daemon 常驻共享,`BH_RUNTIME_DIR_SHARED=1`),换 `BU_NAME` + 非共享 runtime 重建 daemon 后
|
||||
`list_tabs()` **仍返回用户的 16 个标签页**(`admin.alotbuy.com` / `guest.alotbuy.com` / `portal.html#/plugins/manual`);
|
||||
`list_tabs()` **仍返回用户的 16 个标签页**(`admin.ai1net.com` / `guest.ai1net.com` / `portal.html#/plugins/manual`);
|
||||
`curl --noproxy '*' http://127.0.0.1:9223/json/list` 直接证实 **9223 就是用户浏览器的调试端口**。
|
||||
|
||||
**所以:脚本第一步永远是 `print([t["url"] for t in list_tabs()])`。**
|
||||
- 只要看到的**不是**你自己新开的空浏览器 → **立刻停手**,只做只读检查。
|
||||
- **禁止**在未确认隔离前做任何有副作用的动作(登录 / 输入 / 提交 / 关闭标签)。
|
||||
- 已造成的真实事故:在用户浏览器里 `fetch('/api/auth/login')` → **覆盖其 `.alotbuy.com` 的 `sid`**,用户在该浏览器里变成测试账号身份。
|
||||
- 已造成的真实事故:在用户浏览器里 `fetch('/api/auth/login')` → **覆盖其 `.ai1net.com` 的 `sid`**,用户在该浏览器里变成测试账号身份。
|
||||
补救 = 删掉测试账号(cookie 失效 → 用户重新登录即可)。**收尾必须 `close_tab` 关掉自己开的标签。**
|
||||
|
||||
### 正确调用(Windows,2026-09-11 实测可用)
|
||||
@@ -269,7 +269,7 @@ dsh 多租户平台(服务器 47.77.182.89,dshs + dsh 0.1.2-rc.1)功能改
|
||||
```
|
||||
|
||||
**登录态**:DB 直插临时 session(`user_agent=poc-ui`,用后即删),取 token 后
|
||||
`cdp("Network.setCookie", name="sid", value=TOKEN, domain=".alotbuy.com", path="/", secure=True)` → `Page.reload`。
|
||||
`cdp("Network.setCookie", name="sid", value=TOKEN, domain=".ai1net.com", path="/", secure=True)` → `Page.reload`。
|
||||
用户侧真实浏览器完全不受影响(不覆盖其 sid)。
|
||||
|
||||
**三个必踩坑**:
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-decision-method
|
||||
description: dsh 多租户平台(alotbuy.com)「改造 / 优化功能交互 / UI 界面」的**决策方法论**。当用户提出一个新需求、问「这是不是最优方案 / 还有没有更好的做法」、要在多个方案里选型、要判断某个决策是否该做 / 该不该扩大范围、要**自主给技术实现选取最优解**、或者要复盘「为什么这么定」时使用。⛔ **用户点名「决策方法」/「参考决策方法」/「按你的规划」/「别问我」/「自行决策」/「自主决策」⇒ 必须立即加载本技能,不得凭记忆代替**(机制层由钩子 `dsh-server-docs/scripts/skill-load-guard.py` 强制注入加载提醒;2026-09-16 实证:用户点名后 AI 全程 `Skill` 调用 **0 次**)。核心 = 决策素材库(用户有效决策 **U1-U28** / AI 有效决策 A1-A25 / 反例 X1-X14,**素材源含 180 条用户真实发言**)+ 「如何确认最优解」的十问与判定矩阵 + **技术实现的默认裁决顺序(8 条,AI 自主用、不问用户)** + 决策流程十步 + 交互 UI 改造专项清单 + 决策语言对照表 + **复跑脚本 `extract-user-voice.py`**。**v2.0.0(2026-09-14)结构变更:素材库(U/A/X)已拆到 `references/`,按需读;本文件只留判定核心 + 触发词索引**(索引绑定可识别动作)。**与 dsh-change-workflow 分工:本技能管「怎么想、怎么定」,那个管「怎么落地」。** 与 dsh-feature-first 分工:那个管「谁定什么」,本技能管「怎么定得对」。
|
||||
description: dsh 多租户平台(ai1net.com)「改造 / 优化功能交互 / UI 界面」的**决策方法论**。当用户提出一个新需求、问「这是不是最优方案 / 还有没有更好的做法」、要在多个方案里选型、要判断某个决策是否该做 / 该不该扩大范围、要**自主给技术实现选取最优解**、或者要复盘「为什么这么定」时使用。⛔ **用户点名「决策方法」/「参考决策方法」/「按你的规划」/「别问我」/「自行决策」/「自主决策」⇒ 必须立即加载本技能,不得凭记忆代替**(机制层由钩子 `dsh-server-docs/scripts/skill-load-guard.py` 强制注入加载提醒;2026-09-16 实证:用户点名后 AI 全程 `Skill` 调用 **0 次**)。核心 = 决策素材库(用户有效决策 **U1-U28** / AI 有效决策 A1-A25 / 反例 X1-X14,**素材源含 180 条用户真实发言**)+ 「如何确认最优解」的十问与判定矩阵 + **技术实现的默认裁决顺序(8 条,AI 自主用、不问用户)** + 决策流程十步 + 交互 UI 改造专项清单 + 决策语言对照表 + **复跑脚本 `extract-user-voice.py`**。**v2.0.0(2026-09-14)结构变更:素材库(U/A/X)已拆到 `references/`,按需读;本文件只留判定核心 + 触发词索引**(索引绑定可识别动作)。**与 dsh-change-workflow 分工:本技能管「怎么想、怎么定」,那个管「怎么落地」。** 与 dsh-feature-first 分工:那个管「谁定什么」,本技能管「怎么定得对」。
|
||||
version: 2.8.0
|
||||
updated_at: 2026-09-16
|
||||
created_from: 本工作区 62 份改造档案 + 5 天工作日志(2026-09-08 ~ 09-12)全量提炼
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-feature-first
|
||||
description: dsh 多租户平台(alotbuy.com)的「功能优先」协作协议 —— **用户只提功能需求,AI 自主完成全部技术决策**。当用户提出任何平台功能/交互需求、报障、或说「我要把精力放在功能搭建上」时使用;也用于 AI 自查「这件事该不该拿去问用户」。核心 = 复盘证据(技术类+报障类占 64%)+ 用户的「功能卡」4 问 + **AI 自主决策的 9 类白名单(永不问)** + **只准上抛的 3 类(功能语义分叉 / 红线门禁 / 超出方法边界)** + **决策方法边界定义(边界外必须问的 8 类)** + 上抛语言转换表 + 报障闭环前置 + 交付回执格式 + 方法本身演进不再上抛 + **§3.6 拆包上抛:红线问题不得与技术方案捆在一起问** · **§3.2 红线按「实际影响面」判、不按「动作名字」判** · **§5.1 结论骨架(答「是否已实现 / 为什么不行」)** + **§5.3 铁律 4–5 / §5.4 硬约束 7–9:待确认项 = 回复的最后一节、逐条编号;每个候选各占一段(竖排、不横排)且必须写「优点 / 缺点」;只有优点或只有缺点 ⇒ 自决、不上抛**(2026-09-15 用户明令)。**配套:`dsh-decision-method`(怎么想)· `dsh-change-workflow`(怎么落地)。**
|
||||
description: dsh 多租户平台(ai1net.com)的「功能优先」协作协议 —— **用户只提功能需求,AI 自主完成全部技术决策**。当用户提出任何平台功能/交互需求、报障、或说「我要把精力放在功能搭建上」时使用;也用于 AI 自查「这件事该不该拿去问用户」。核心 = 复盘证据(技术类+报障类占 64%)+ 用户的「功能卡」4 问 + **AI 自主决策的 9 类白名单(永不问)** + **只准上抛的 3 类(功能语义分叉 / 红线门禁 / 超出方法边界)** + **决策方法边界定义(边界外必须问的 8 类)** + 上抛语言转换表 + 报障闭环前置 + 交付回执格式 + 方法本身演进不再上抛 + **§3.6 拆包上抛:红线问题不得与技术方案捆在一起问** · **§3.2 红线按「实际影响面」判、不按「动作名字」判** · **§5.1 结论骨架(答「是否已实现 / 为什么不行」)** + **§5.3 铁律 4–5 / §5.4 硬约束 7–9:待确认项 = 回复的最后一节、逐条编号;每个候选各占一段(竖排、不横排)且必须写「优点 / 缺点」;只有优点或只有缺点 ⇒ 自决、不上抛**(2026-09-15 用户明令)。**配套:`dsh-decision-method`(怎么想)· `dsh-change-workflow`(怎么落地)。**
|
||||
version: 1.7.1
|
||||
updated_at: 2026-09-16
|
||||
agent_created: true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-instance-diagnose
|
||||
description: DSH 多租户平台(alotbuy.com / 47.77.182.89)单个用户实例的「故障诊断」技能,重点是内存 / OOM / 实例崩溃重启。当出现「实例打不开」「会话突然报错/中断」「跑着跑着断了」「响应慢」「怀疑内存不够」时触发。核心:先做第 0 层「跨机日志取证」(dshlog),再分「服务级 / 实例级 / 会话级」三层定位,再用 cgroup 内存三件套定量归因,最后在隔离 cgroup 里复现——**绝不在生产实例上做压力测试**。
|
||||
description: DSH 多租户平台(ai1net.com / 47.77.182.89)单个用户实例的「故障诊断」技能,重点是内存 / OOM / 实例崩溃重启。当出现「实例打不开」「会话突然报错/中断」「跑着跑着断了」「响应慢」「怀疑内存不够」时触发。核心:先做第 0 层「跨机日志取证」(dshlog),再分「服务级 / 实例级 / 会话级」三层定位,再用 cgroup 内存三件套定量归因,最后在隔离 cgroup 里复现——**绝不在生产实例上做压力测试**。
|
||||
version: 1.1.0
|
||||
updated_at: 2026-09-18
|
||||
last_change: 2026-09-15:死亡路径由 2 条扩到 4 条 —— 新增 C(bwrap 挂载点被嵌套 tmpfs 遮蔽(`Can't chdir to <正常路径>`))与 D(迁移复现不了启动参数(`Can't chdir to :` 空路径)),含分诊判据与「中间目录必须统一前置」的修法。
|
||||
@@ -215,7 +215,7 @@ TOKEN=$(ssh bt-server 'cd /opt/dshs && /usr/local/bin/node mksess-guest.cjs')
|
||||
ssh bt-server "cd /opt/dshs && /usr/local/bin/node -e \"const c=require('crypto');const D=require('/opt/dshs/node_modules/better-sqlite3');const db=new D('/var/lib/dshs/dshs.db');console.log(db.prepare('DELETE FROM sessions WHERE token_hash=?').run(c.createHash('sha256').update('$TOKEN').digest('hex')).changes);db.close();\""
|
||||
```
|
||||
|
||||
**工具**:`playwright-core` + `channel:'chrome'`(装在 `E:\ProgramData\.workbuddy\binaries\node\workspace`;须 `createRequire` 指向该目录,并在该目录内跑)。把 `sid` cookie 写进 context(`domain:'.alotbuy.com'`, `secure:true`, `sameSite:'None'`),再 `goto https://<user>.alotbuy.com/`。
|
||||
**工具**:`playwright-core` + `channel:'chrome'`(装在 `E:\ProgramData\.workbuddy\binaries\node\workspace`;须 `createRequire` 指向该目录,并在该目录内跑)。把 `sid` cookie 写进 context(`domain:'.ai1net.com'`, `secure:true`, `sameSite:'None'`),再 `goto https://<user>.ai1net.com/`。
|
||||
|
||||
**判定用的哨兵与元素(直接 `page.evaluate` 读)**:
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ mksess.cjs ensure-role-profile-patch.cjs(临时会话 / profile 补丁)
|
||||
|
||||
| 类别 | 原值 | 替换为 | 命中 |
|
||||
|---|---|---|---|
|
||||
| 平台域名 | `dsh.alotbuy.com` / `*.alotbuy.com` / `alotbuy` | `<baseDomain>` | 3 文件 |
|
||||
| 平台域名 | `dsh.alotbuy.com` / `*.ai1net.com` / `ai1net` | `<baseDomain>` | 3 文件 |
|
||||
| ↳ **特例** | `web/wake.html` 的 `safeNext()` 正则 | 改成**运行时从 `location.hostname` 推导注册域**(`split('.')` 去最左一段),彻底去硬编码 | — |
|
||||
| 服务器公网 IP | `47.77.182.89` | `<SERVER_PUBLIC_IP>` | 1 |
|
||||
| 宿主内网 IP | `172.18.16.212` | `<HOST_LAN_IP>` | 1 |
|
||||
@@ -512,7 +512,7 @@ git rev-parse --short=12 HEAD
|
||||
1. **`text` 被重新赋值导致替换未落盘** —— 脱敏函数里 `text = text.replace(...)` 之后再用 `if text2 != text` 判「是否需要写盘」⇒ 全局替换**永远不落盘**(只落行级改动)。**必须留 `orig = text` 做比较基准。**
|
||||
2. **行级重写的 key 必须写「全局替换之后的文本」** —— 例:原行 `(档案 70 的 anysearch 事故…)`,key 要写 `(档案 70 的 该插件 事故…)`。用替换前的原文当 key ⇒ 静默不命中,留下半吊子句子。
|
||||
3. **行级重写会丢缩进** —— 按 `strip()` 匹配就必须把**原行的 indent 补回去**(多行替换值还要给后续行也加 indent),否则 TS 缩进错乱。
|
||||
4. **全局替换顺序敏感** —— `wake.html` 的专用规则必须在笼统的 `alotbuy → <baseDomain>` **之前**,否则专用规则永远不命中(会被先替换成 `<baseDomain>.com` 这种半成品)。
|
||||
4. **全局替换顺序敏感** —— `wake.html` 的专用规则必须在笼统的 `ai1net → <baseDomain>` **之前**,否则专用规则永远不命中(会被先替换成 `<baseDomain>.com` 这种半成品)。
|
||||
5. **重建会抹掉手写文件** —— 所以有 `OVERLAY`(先快照 `_overlay/` 后恢复)+ `--force` 安全闸。**验证过一次**:重建后 5/5 个 overlay 文件逐字节一致。
|
||||
6. **`.git` 绝不能带过去** —— 历史里含全部内部信息;必须是**全新仓库**。
|
||||
7. **Windows 下 `install.sh` 没有执行位** —— README 一律写 `sudo bash install.sh`;要执行位就 `chmod +x` 后再提交。
|
||||
@@ -555,7 +555,7 @@ cd "/e/ProgramData/AI技能/dsh-ai1net-github"
|
||||
"/e/ProgramData/.workbuddy/binaries/python/versions/3.13.12/python.exe" _build_export.py --force
|
||||
|
||||
# 只跑探针/看结构(不重建)
|
||||
grep -rIlF "alotbuy" dsh-web-platform/ ; find dsh-web-platform -type f | wc -l
|
||||
grep -rIlF "ai1net" dsh-web-platform/ ; find dsh-web-platform -type f | wc -l
|
||||
|
||||
# 看某文件与源的差异
|
||||
diff -u "D:/github/dsh_shenxian/<f>" "dsh-web-platform/<f>"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dsh-plugin-diagnose
|
||||
description: DSH 多租户平台(alotbuy.com / 47.77.182.89)**业务插件**的故障诊断技能 —— 专治「工具能用但浏览器里什么都没有」「原生绑定装不上」「明明改了却没生效」这类**静默失败**。当出现「插件没 UI / 预览不出现 / 卡片不渲染」「导入导出报 GLIBC 版本」「网关崩了但数据其实写进去了」「改了包上传了还是老行为」时触发。核心:先按 **host 半边 / client 半边 / 网关·原生绑定** 三层归属定位,再用「**inject 差集**」「**glibc 直测**」「**产物插探针**」三把尺子取证——**每一次都要有可复现的命令**。
|
||||
description: DSH 多租户平台(ai1net.com / 47.77.182.89)**业务插件**的故障诊断技能 —— 专治「工具能用但浏览器里什么都没有」「原生绑定装不上」「明明改了却没生效」这类**静默失败**。当出现「插件没 UI / 预览不出现 / 卡片不渲染」「导入导出报 GLIBC 版本」「网关崩了但数据其实写进去了」「改了包上传了还是老行为」时触发。核心:先按 **host 半边 / client 半边 / 网关·原生绑定** 三层归属定位,再用「**inject 差集**」「**glibc 直测**」「**产物插探针**」三把尺子取证——**每一次都要有可复现的命令**。
|
||||
version: 1.0.0
|
||||
updated_at: 2026-09-13
|
||||
last_change: 首版。由 2026-09-13 一整天 guest 实例 univer 插件四轮排查沉淀(worker socket 垫片被打包顺序废掉 / 宿主 glibc 2.32 < 绑定要求 2.35 / 网关提交 changeset 也建投影致其崩溃 / client 半边因一条不可满足的 inject 永久挂起),含 4 类真因、3 套取证配方与 8 条实测踩坑。
|
||||
@@ -38,7 +38,7 @@ agent_created: true
|
||||
1. 取实例页面(**不碰用户浏览器**,用临时会话走平台代理):
|
||||
```bash
|
||||
SID=$(node /opt/dshs/mksess-guest.cjs) # 需对应用户;用完必须删会话
|
||||
curl -s -H "Host: <用户名>.alotbuy.com" -H "Cookie: sid=$SID" http://127.0.0.1:3080/ -o page.html
|
||||
curl -s -H "Host: <用户名>.ai1net.com" -H "Cookie: sid=$SID" http://127.0.0.1:3080/ -o page.html
|
||||
```
|
||||
2. 从页面里取**实际下发的客户端插件集合**(权威清单,别用 manifest 正则——会漏行):
|
||||
```python
|
||||
|
||||
+2
-2
@@ -23,8 +23,8 @@
|
||||
"dev": "node lib/cli.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"check:layering": "node scripts/check-layering.mjs",
|
||||
"verify": "npm run build && node scripts/check-layering.mjs && node --test test/db.test.mjs test/local-user-fs.test.mjs test/remote-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.test.mjs test/worker-provision.test.mjs test/reachability.test.mjs test/instance-port.test.mjs test/relay.test.mjs test/remote-spawner.test.mjs test/overlay-network.test.mjs test/overlay-auth.test.mjs test/overlay-bootstrap.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs",
|
||||
"test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/remote-user-fs.test.mjs test/crash-policy.test.mjs test/reachability.test.mjs test/instance-port.test.mjs test/relay.test.mjs test/remote-spawner.test.mjs test/overlay-network.test.mjs test/overlay-auth.test.mjs test/overlay-bootstrap.test.mjs test/overlay-identity.test.mjs test/relay-failover.test.mjs test/overlay-jitter.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js",
|
||||
"verify": "npm run build && node scripts/check-layering.mjs && node --test test/db.test.mjs test/register-guard.test.mjs test/i18n-brand.test.mjs test/local-user-fs.test.mjs test/remote-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.test.mjs test/worker-provision.test.mjs test/reachability.test.mjs test/instance-port.test.mjs test/relay.test.mjs test/remote-spawner.test.mjs test/overlay-network.test.mjs test/overlay-auth.test.mjs test/overlay-bootstrap.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs",
|
||||
"test": "npm run build && node --test test/db.test.mjs test/register-guard.test.mjs test/i18n-brand.test.mjs test/local-user-fs.test.mjs test/remote-user-fs.test.mjs test/crash-policy.test.mjs test/reachability.test.mjs test/instance-port.test.mjs test/relay.test.mjs test/remote-spawner.test.mjs test/overlay-network.test.mjs test/overlay-auth.test.mjs test/overlay-bootstrap.test.mjs test/overlay-identity.test.mjs test/relay-failover.test.mjs test/overlay-jitter.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"smoke:admin": "node scripts/smoke-admin.mjs",
|
||||
"smoke:auth": "node scripts/smoke-auth.mjs",
|
||||
|
||||
@@ -942,14 +942,19 @@ async function main() {
|
||||
|
||||
/* ═══ 幕 4(序⑧):**冷却语义拆分**的真机判据(E9 / 构 A) ═══
|
||||
*
|
||||
* 立项依据(上单 §8.8-4):生产目录 3 条候选里 **2 条同机**(`alotbuy.com` + `relay-direct.alotbuy.com`
|
||||
* 立项依据(上单 §8.8-4):生产目录 3 条候选里 **2 条同机**(`ai1net.com` + `relay-direct.ai1net.com`
|
||||
* 都在 47)⇒ 一次 47 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住
|
||||
* ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。
|
||||
*
|
||||
* 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106
|
||||
* (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`alotbuy` = switched-away)
|
||||
* (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`ai1net` = switched-away)
|
||||
* → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。
|
||||
*
|
||||
* 🔴 **2026-09-19 域迁 `ai1net.com` 修正**:本幕判定"目标是不是 47 那台"原先**写死** `alotbuy.com`,
|
||||
* 域一切就恒判失败(**假红**:豁免实际已生效并切回 47,仅字面匹配不上)。
|
||||
* ⇒ 现一律改用参数表的 `DRILL_KILLED_MATCH`(= 目录 `relays[]` 首位的 host),
|
||||
* ⛔ 不再在脚本里写死域名 —— 下次换域只改参数表一处。
|
||||
*
|
||||
* ⚠️ 本幕**会重启 Manager**(为了①的确定性 + 施加演练期覆盖);`finally` 一律清覆盖并重启回生产值。
|
||||
*/
|
||||
const runScene4 = async (variant) => {
|
||||
@@ -1037,7 +1042,7 @@ async function main() {
|
||||
`${tag}-C 冷却过期后**自然**切回 47 ⇒ 归因收敛到"冷却语义"而非方向逻辑(E9-b 归因)`,
|
||||
hit3.err !== ''
|
||||
? 'FAIL'
|
||||
: hit3.ok && m3 !== undefined && m3.includes('alotbuy.com')
|
||||
: hit3.ok && m3 !== undefined && m3.includes(killedMatch)
|
||||
? 'PASS'
|
||||
: 'FAIL',
|
||||
hit3.err !== ''
|
||||
@@ -1051,7 +1056,7 @@ async function main() {
|
||||
const hit2 = await waitSwitch(sinceB, detectBudgetMs)
|
||||
const last2 = hit2.ok ? hit2.lines.split('\n').slice(-1)[0] : ''
|
||||
const m2 = hit2.ok ? (/->\s*(\S+?)((|$)/.exec(last2) ?? [])[1] : undefined
|
||||
const to47 = m2 !== undefined && m2.includes('alotbuy.com')
|
||||
const to47 = m2 !== undefined && m2.includes(killedMatch)
|
||||
const exemptMark = /|豁免 kind=(switched-away|open-failed) 剩 \d+ms/.test(last2)
|
||||
const healthReason = last2.includes('原因:当前通道不健康')
|
||||
if (hit2.ok) process.stdout.write(`# ${tag} ④ 后的切换原文:${last2}\n`)
|
||||
|
||||
@@ -1637,7 +1637,7 @@ function main() {
|
||||
* ② **`count ≥ CAND_MIN`**(每连接候选数 ≥ 2)。
|
||||
*
|
||||
* 🔑 **`hosts`(主机名个数)只作信息输出、⛔ 不作判据** —— 且它**不是「独立物理路径数」**:
|
||||
* 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `alotbuy.com` 与 `relay-direct.alotbuy.com`
|
||||
* 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `ai1net.com` 与 `relay-direct.ai1net.com`
|
||||
* **摘名不同、同落 47** ⇒ **真机实测 `count=3` 时 `hosts` 也报 3**,而机器级独立路径只有 **2**(47 + 106)。
|
||||
* 故本项**照实判「条数」**,把 `hosts` 打出来供人判「冗余建成」,⛔ **不放宽判据凑绿**;
|
||||
* 真机首轮读数(2026-09-18 00:0x)已实证 **worker 侧 `count=1`**(根因见回报)。
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* T08 · **域名形态访问**验证(在演练环境做:不动生产、不动 DNS、不动证书)。
|
||||
*
|
||||
* 要回答的问题:生产切到 cluster(Manager 在 47、实例在 106)后,
|
||||
* **按域名形态访问**(`<用户名>.alotbuy.com`)还能不能正常落到 106 上的实例?
|
||||
* **按域名形态访问**(`<用户名>.ai1net.com`)还能不能正常落到 106 上的实例?
|
||||
*
|
||||
* 做法:给演练 Manager 设一个**测试 baseDomain**,用**显式 `Host` 头**打进去。
|
||||
*
|
||||
@@ -10,7 +10,7 @@
|
||||
* (Fetch 规范把它列为禁止头,undici 直接忽略)⇒ 请求落到"无租户"的门户路由、回 200 门户页,
|
||||
* 看起来"验证通过"其实是假的。⇒ **必须用 curl(`-H Host:`)**,且判据不能只看状态码。
|
||||
*
|
||||
* 运行(在 47 上):MANAGER=http://127.0.0.1:13080 BASE_DOMAIN=test.alotbuy.com \
|
||||
* 运行(在 47 上):MANAGER=http://127.0.0.1:13080 BASE_DOMAIN=test.ai1net.com \
|
||||
* AGENT=http://127.0.0.1:19000 AGENT_TOKEN=cross-machine-token \
|
||||
* node scripts/verify-cluster-domain.mjs
|
||||
*/
|
||||
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
const MANAGER = process.env.MANAGER ?? 'http://127.0.0.1:13080'
|
||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'test.alotbuy.com'
|
||||
const BASE_DOMAIN = process.env.BASE_DOMAIN ?? 'test.ai1net.com'
|
||||
const AGENT = process.env.AGENT ?? 'http://127.0.0.1:19000'
|
||||
const TOKEN = process.env.AGENT_TOKEN ?? 'cross-machine-token'
|
||||
const ADMIN_PW = process.env.ADMIN_PW ?? 'crossmgr123'
|
||||
|
||||
@@ -34,7 +34,7 @@ const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" };
|
||||
let def = null;
|
||||
const win = {
|
||||
__ModuleLoader__: { load: (d) => { def = d; } },
|
||||
location: { hostname: "admin.alotbuy.com", protocol: "https:", origin: "https://admin.alotbuy.com" },
|
||||
location: { hostname: "admin.ai1net.com", protocol: "https:", origin: "https://admin.ai1net.com" },
|
||||
open: (u) => { globalThis.__OPENED = u; },
|
||||
document: {
|
||||
createElement: () => {
|
||||
@@ -80,7 +80,7 @@ const sandbox = {
|
||||
Math, Date, encodeURIComponent, decodeURIComponent,
|
||||
window: win, document: win.document,
|
||||
fetch: async (url) => {
|
||||
const p = String(url).replace("https://alotbuy.com", "");
|
||||
const p = String(url).replace("https://ai1net.com", "");
|
||||
const body = p === "/api/auth/me" ? { user: { id: "u1", username: ROLE, role: ROLE } } : DATA[p];
|
||||
return { ok: body !== undefined, status: body === undefined ? 404 : 200, json: async () => body };
|
||||
},
|
||||
|
||||
@@ -81,5 +81,26 @@ for (const f of readdirSync(WEB).filter((f) => /\.(css|js)$/.test(f))) {
|
||||
else console.log(' ✓ ' + f)
|
||||
}
|
||||
|
||||
// SVG 资产单独过一遍(2026-09-19 档案 137 新增)。
|
||||
// 为什么必须查:**XML 注释里不允许出现两个连续 ASCII 连字符**,而 SVG 文件里写 CSS 变量名
|
||||
// (双连字符加名字)是最自然的写法 ⇒ 一旦踩中,**整份 SVG 解析失败、图标静默不显示**,
|
||||
// 页面与接口都不报任何错。第一版 favicon 就栽在这里(注释里写了 `--ink`),
|
||||
// 是靠"用真解析器验一遍"才抓到的 ⇒ 把它做成可重跑的判据,而不是靠人记得。
|
||||
for (const f of readdirSync(WEB).filter((f) => f.endsWith('.svg'))) {
|
||||
const s = readFileSync(join(WEB, f), 'utf8')
|
||||
const problems = []
|
||||
if (s.includes(BANNED)) problems.push('含内部平台名')
|
||||
if (!/^\s*<svg[\s>]/.test(s)) problems.push('未以 <svg 开头')
|
||||
if (!s.trimEnd().endsWith('</svg>')) problems.push('未以 </svg> 结尾')
|
||||
if (!/viewBox="0 0 \d+ \d+"/.test(s)) problems.push('缺 viewBox(缩放不可控)')
|
||||
for (const m of s.matchAll(/<!--([\s\S]*?)-->/g)) {
|
||||
if (m[1].includes('--')) {
|
||||
problems.push('XML 注释含双连字符(XML 语法不允许 ⇒ 整份 SVG 会解析失败、图标静默不显示)')
|
||||
}
|
||||
}
|
||||
if (problems.length) { bad++; console.log(' ✗ ' + f + ':' + problems.join(';')) }
|
||||
else console.log(' ✓ ' + f)
|
||||
}
|
||||
|
||||
console.log(bad ? '结论:' + bad + ' 项不合格 ❌' : '结论:全部合格 ✅')
|
||||
process.exit(bad ? 1 : 0)
|
||||
+209
-3
@@ -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,
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,10 @@ import type {
|
||||
DshInstance,
|
||||
DshInstanceRole,
|
||||
DshInstanceStatus,
|
||||
EmailCodeCounts,
|
||||
EmailCodeRow,
|
||||
PublicUser,
|
||||
RecordEmailCodeInput,
|
||||
SessionRow,
|
||||
SessionUser,
|
||||
UpsertBusinessPluginInput,
|
||||
@@ -57,6 +60,24 @@ export interface DbAdapter {
|
||||
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
|
||||
/** Whether the user has any session that has not yet expired (idle reap). */
|
||||
hasActiveSession(userId: string): Promise<boolean>
|
||||
/** Registration e-mail lookup (v10, case-insensitive). */
|
||||
findUserByEmail(email: string): Promise<User | undefined>
|
||||
// e-mail verification codes (v10) — 注册页「邮箱验证码」的存储 + 防爆破计数来源
|
||||
/** Append one `email_codes` row (sent / throttled / failed). */
|
||||
recordEmailCode(input: RecordEmailCodeInput): Promise<void>
|
||||
/** Rolling counters for one (email, ip) pair — the brute-force guard's only input. */
|
||||
emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts>
|
||||
/** The newest **deliverable** code for (email, purpose), or undefined. */
|
||||
latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined>
|
||||
/**
|
||||
* 记一次校验失败:`attempts + 1`;`consume` 为真时同时打上 `consumed_at`(= 该码作废)。
|
||||
* 返回 false 表示该行已不存在(例如被并发守卫先行作废)。
|
||||
*/
|
||||
bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean>
|
||||
/** 校验通过:打上 `consumed_at`(**单次使用**)。返回 false = 在写入前已被别人用掉。 */
|
||||
consumeEmailCode(id: string, now: number): Promise<boolean>
|
||||
/** 删除早于 `before` 的 `email_codes` 行(自维护,避免表无限增长)。 */
|
||||
purgeEmailCodes(before: number): Promise<number>
|
||||
// audit
|
||||
audit(actor: string | null, action: string, detail?: string | null): Promise<void>
|
||||
// workspaces / plugins
|
||||
|
||||
+96
-4
@@ -16,6 +16,7 @@ import {
|
||||
toDomain,
|
||||
toDshHost,
|
||||
toDshInstance,
|
||||
toEmailCode,
|
||||
toPublicUser,
|
||||
toSession,
|
||||
toUser,
|
||||
@@ -33,7 +34,10 @@ import {
|
||||
type DshInstance,
|
||||
type DshInstanceRole,
|
||||
type DshInstanceStatus,
|
||||
type EmailCodeCounts,
|
||||
type EmailCodeRow,
|
||||
type PublicUser,
|
||||
type RecordEmailCodeInput,
|
||||
type SessionRow,
|
||||
type SessionUser,
|
||||
type UpsertBusinessPluginInput,
|
||||
@@ -50,7 +54,9 @@ import {
|
||||
// both backends. This is process-global and idempotent.
|
||||
types.setTypeParser(20, (value: string) => Number(value))
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
|
||||
const EMAIL_CODE_COLS =
|
||||
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
|
||||
const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat'
|
||||
@@ -114,8 +120,9 @@ export class PgAdapter implements DbAdapter {
|
||||
try {
|
||||
return await withTx(this.pool, async (client) => {
|
||||
const { rows } = await client.query(
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING row_id',
|
||||
[input.id, input.username, input.passHash, input.role, input.homeDir, createdAt],
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) '
|
||||
+ 'VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING row_id',
|
||||
[input.id, input.username, input.passHash, input.role, input.homeDir, input.email ?? null, createdAt],
|
||||
)
|
||||
const uid = this.baseUid + Number((rows[0] as { row_id: number }).row_id)
|
||||
await client.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, input.id])
|
||||
@@ -129,6 +136,7 @@ export class PgAdapter implements DbAdapter {
|
||||
created_at: createdAt,
|
||||
approved_by: null,
|
||||
uid,
|
||||
email: input.email ?? null,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
@@ -140,7 +148,6 @@ export class PgAdapter implements DbAdapter {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async findUserBySlug(slug: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [
|
||||
slug.toLowerCase(),
|
||||
@@ -153,6 +160,91 @@ export class PgAdapter implements DbAdapter {
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/** v10:注册邮箱查重(大小写不敏感)。 */
|
||||
async findUserByEmail(email: string): Promise<User | undefined> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(email) = $1`, [
|
||||
email.toLowerCase(),
|
||||
])
|
||||
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
// ── v10 邮箱验证码(注册页)—— 与 SQLite 版同款语义,见 repo.ts 注释 ───────────
|
||||
|
||||
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
|
||||
await this.pool.query(
|
||||
'INSERT INTO email_codes '
|
||||
+ '(id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at) '
|
||||
+ 'VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8, $9, $10, NULL)',
|
||||
[
|
||||
input.id,
|
||||
input.email,
|
||||
input.purpose,
|
||||
input.codeHash,
|
||||
input.status,
|
||||
input.ip ?? null,
|
||||
input.username ?? null,
|
||||
input.reason ?? null,
|
||||
input.createdAt,
|
||||
input.expiresAt ?? null,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = $1 AND created_at >= $2) AS email_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = $1 AND created_at >= $2 AND status = 'sent') AS email_sent,
|
||||
(SELECT COALESCE(MAX(created_at), 0) FROM email_codes WHERE email = $1) AS email_last_at,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE ip IS NOT NULL AND ip = $3 AND created_at >= $2) AS ip_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE created_at >= $2) AS global_total`,
|
||||
[email, since, ip],
|
||||
)
|
||||
const row = rows[0] as Record<string, unknown>
|
||||
return {
|
||||
emailTotal: Number(row.email_total ?? 0),
|
||||
emailSent: Number(row.email_sent ?? 0),
|
||||
emailLastAt: Number(row.email_last_at ?? 0),
|
||||
ipTotal: Number(row.ip_total ?? 0),
|
||||
globalTotal: Number(row.global_total ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
|
||||
const { rows } = await this.pool.query(
|
||||
`SELECT ${EMAIL_CODE_COLS} FROM email_codes WHERE email = $1 AND purpose = $2 AND status = 'sent' `
|
||||
+ 'ORDER BY created_at DESC LIMIT 1',
|
||||
[email, purpose],
|
||||
)
|
||||
return rows.length > 0 ? toEmailCode(rows[0] as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
|
||||
const { rowCount } = consume
|
||||
? await this.pool.query(
|
||||
'UPDATE email_codes SET attempts = attempts + 1, consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL',
|
||||
[now, id],
|
||||
)
|
||||
: await this.pool.query(
|
||||
'UPDATE email_codes SET attempts = attempts + 1 WHERE id = $1 AND consumed_at IS NULL',
|
||||
[id],
|
||||
)
|
||||
return (rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async consumeEmailCode(id: string, now: number): Promise<boolean> {
|
||||
const { rowCount } = await this.pool.query(
|
||||
'UPDATE email_codes SET consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL',
|
||||
[now, id],
|
||||
)
|
||||
return (rowCount ?? 0) > 0
|
||||
}
|
||||
|
||||
async purgeEmailCodes(before: number): Promise<number> {
|
||||
const { rowCount } = await this.pool.query('DELETE FROM email_codes WHERE created_at < $1', [before])
|
||||
return rowCount ?? 0
|
||||
}
|
||||
|
||||
async listPublicUsers(): Promise<PublicUser[]> {
|
||||
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`)
|
||||
return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>)))
|
||||
|
||||
+80
-3
@@ -17,6 +17,7 @@ import {
|
||||
toDomain,
|
||||
toDshHost,
|
||||
toDshInstance,
|
||||
toEmailCode,
|
||||
toPublicUser,
|
||||
toSession,
|
||||
toUser,
|
||||
@@ -34,7 +35,10 @@ import {
|
||||
type DshInstance,
|
||||
type DshInstanceRole,
|
||||
type DshInstanceStatus,
|
||||
type EmailCodeCounts,
|
||||
type EmailCodeRow,
|
||||
type PublicUser,
|
||||
type RecordEmailCodeInput,
|
||||
type SessionRow,
|
||||
type SessionUser,
|
||||
type UpsertBusinessPluginInput,
|
||||
@@ -45,7 +49,9 @@ import {
|
||||
type Workspace,
|
||||
} from './types.js'
|
||||
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
|
||||
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
|
||||
const EMAIL_CODE_COLS =
|
||||
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
|
||||
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
|
||||
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
|
||||
const INSTANCE_COLS =
|
||||
@@ -56,8 +62,8 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
|
||||
const createdAt = Date.now()
|
||||
return db.transaction((): User => {
|
||||
const info = prepare(db,
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
).run(input.id, input.username, input.passHash, input.role, input.homeDir, createdAt)
|
||||
'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
).run(input.id, input.username, input.passHash, input.role, input.homeDir, input.email ?? null, createdAt)
|
||||
// SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
|
||||
const uid = baseUid + Number(info.lastInsertRowid)
|
||||
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id)
|
||||
@@ -71,6 +77,7 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
|
||||
created_at: createdAt,
|
||||
approved_by: null,
|
||||
uid,
|
||||
email: input.email ?? null,
|
||||
}
|
||||
})()
|
||||
}
|
||||
@@ -86,6 +93,76 @@ export function findUserBySlug(db: Database, slug: string): User | undefined {
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
/** v10:注册邮箱查重(大小写不敏感)。 */
|
||||
export function findUserByEmail(db: Database, email: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE LOWER(email) = ?`).get(email.toLowerCase())
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
// ── v10 邮箱验证码 ────────────────────────────────────────────────────────────
|
||||
// 全部计数都**现算**(不维护冗余计数器):省掉"计数器与事实不一致"的整类缺陷,
|
||||
// 代价只是一次带索引的 COUNT;量级(每邮箱每小时个位数行)完全够用。
|
||||
|
||||
export function recordEmailCode(db: Database, input: RecordEmailCodeInput): void {
|
||||
prepare(db,
|
||||
`INSERT INTO email_codes (${EMAIL_CODE_COLS}) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL)`,
|
||||
).run(
|
||||
input.id,
|
||||
input.email,
|
||||
input.purpose,
|
||||
input.codeHash,
|
||||
input.status,
|
||||
input.ip ?? null,
|
||||
input.username ?? null,
|
||||
input.reason ?? null,
|
||||
input.createdAt,
|
||||
input.expiresAt ?? null,
|
||||
)
|
||||
}
|
||||
|
||||
export function emailCodeCounts(db: Database, email: string, ip: string | null, since: number): EmailCodeCounts {
|
||||
const row = prepare(db, `
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = ? AND created_at >= ?) AS email_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE email = ? AND created_at >= ? AND status = 'sent') AS email_sent,
|
||||
(SELECT COALESCE(MAX(created_at), 0) FROM email_codes WHERE email = ?) AS email_last_at,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE ip IS NOT NULL AND ip = ? AND created_at >= ?) AS ip_total,
|
||||
(SELECT COUNT(*) FROM email_codes WHERE created_at >= ?) AS global_total
|
||||
`).get(email, since, email, since, email, ip, since, since) as Record<string, unknown>
|
||||
return {
|
||||
emailTotal: Number(row.email_total ?? 0),
|
||||
emailSent: Number(row.email_sent ?? 0),
|
||||
emailLastAt: Number(row.email_last_at ?? 0),
|
||||
ipTotal: Number(row.ip_total ?? 0),
|
||||
globalTotal: Number(row.global_total ?? 0),
|
||||
}
|
||||
}
|
||||
|
||||
export function latestSentEmailCode(db: Database, email: string, purpose: string): EmailCodeRow | undefined {
|
||||
const row = prepare(db,
|
||||
`SELECT ${EMAIL_CODE_COLS} FROM email_codes WHERE email = ? AND purpose = ? AND status = 'sent' `
|
||||
+ 'ORDER BY created_at DESC LIMIT 1',
|
||||
).get(email, purpose)
|
||||
return row ? toEmailCode(row as Record<string, unknown>) : undefined
|
||||
}
|
||||
|
||||
export function bumpEmailCodeAttempts(db: Database, id: string, consume: boolean, now: number): boolean {
|
||||
const info = consume
|
||||
? prepare(db, 'UPDATE email_codes SET attempts = attempts + 1, consumed_at = ? WHERE id = ? AND consumed_at IS NULL')
|
||||
.run(now, id)
|
||||
: prepare(db, 'UPDATE email_codes SET attempts = attempts + 1 WHERE id = ? AND consumed_at IS NULL').run(id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function consumeEmailCode(db: Database, id: string, now: number): boolean {
|
||||
const info = prepare(db, 'UPDATE email_codes SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL').run(now, id)
|
||||
return info.changes > 0
|
||||
}
|
||||
|
||||
export function purgeEmailCodes(db: Database, before: number): number {
|
||||
return prepare(db, 'DELETE FROM email_codes WHERE created_at < ?').run(before).changes
|
||||
}
|
||||
|
||||
export function findUserById(db: Database, id: string): User | undefined {
|
||||
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id)
|
||||
return row ? toUser(row as Record<string, unknown>) : undefined
|
||||
|
||||
@@ -382,6 +382,66 @@ const PG_V9 = `
|
||||
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
|
||||
`
|
||||
|
||||
// v10(注册页人机验证 + 邮箱验证码):`users.email` + `email_codes` 事件表。
|
||||
//
|
||||
// 为什么放 DB 而不是进程内 Map:① 本表既是**验证码存储**,也是**防爆破计数器的唯一来源**
|
||||
// (冷却 / 每时每刻配额 / 试错次数全靠 `COUNT(*)` 现算)—— 进程内的计数器**一次 restart 即清零**,
|
||||
// 而 deployment 恰好天天重启,等于把限流关掉;② 发码是被外部触发的花钱动作(邮件配额),
|
||||
// 必须可审计(`audit_log` 只记"发生过",记不了"每分钟多少次");③ PG/SQLite 双后端可查。
|
||||
//
|
||||
// 表设计取舍:**只建一张事件表**,把"发送请求"与"校验失败"都记成行(`status` 区分)——
|
||||
// 比"验证码表 + 计数器表 + 黑名单表"三张表少两次 JOIN、且天然是取证时间线。
|
||||
// · `status`:`sent`(已发出,可校验)/ `throttled`(被限流,占位计入配额)/ `failed`(驱动发信失败)
|
||||
// · 只有 `sent` 且 `consumed_at IS NULL` 且未过期的那一行可被校验通过。
|
||||
// · `code_hash` **不存明文**:sha256(email:purpose:code:pepper),pepper = 平台 `encryptionSecret`。
|
||||
// · `attempts` 记该码**已被试错几次**,达上限即 `consumed_at` 打上(作废,必须重新获取)。
|
||||
// ⚠️ 时间戳仍是 **epoch 毫秒 BIGINT**(与全库一致,勿用 timestamptz)。
|
||||
// ⚠️ `users.email` 唯一索引:存量行全为 NULL ⇒ 两方言都允许多个 NULL ⇒ 迁移不会失败;
|
||||
// 用 `LOWER(email)` 保证大小写不敏感唯一(注册时不区分大小写)。
|
||||
const SQLITE_V10 = `
|
||||
ALTER TABLE users ADD COLUMN email TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
|
||||
CREATE TABLE IF NOT EXISTS email_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
code_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'sent'
|
||||
CHECK (status IN ('sent','throttled','failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
ip TEXT,
|
||||
username TEXT,
|
||||
reason TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER,
|
||||
consumed_at INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
|
||||
`
|
||||
|
||||
const PG_V10 = `
|
||||
ALTER TABLE users ADD COLUMN email TEXT;
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
|
||||
CREATE TABLE IF NOT EXISTS email_codes (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT NOT NULL,
|
||||
purpose TEXT NOT NULL,
|
||||
code_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'sent'
|
||||
CHECK (status IN ('sent','throttled','failed')),
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
ip TEXT,
|
||||
username TEXT,
|
||||
reason TEXT,
|
||||
created_at BIGINT NOT NULL,
|
||||
expires_at BIGINT,
|
||||
consumed_at BIGINT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
|
||||
`
|
||||
|
||||
interface Migration {
|
||||
version: number
|
||||
name: string
|
||||
@@ -399,6 +459,7 @@ const MIGRATIONS: readonly Migration[] = [
|
||||
{ version: 7, name: 'cluster host registry + instance lease', sqlite: SQLITE_V7, pg: PG_V7 },
|
||||
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
|
||||
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
|
||||
{ version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 },
|
||||
]
|
||||
|
||||
/** Apply unapplied SQLite migrations inside a single transaction. */
|
||||
|
||||
@@ -14,6 +14,8 @@ import { openDatabase, type Database } from './connection.js'
|
||||
import { mapSqliteError } from './errors.js'
|
||||
import {
|
||||
audit as auditSync,
|
||||
bumpEmailCodeAttempts as bumpEmailCodeAttemptsSync,
|
||||
consumeEmailCode as consumeEmailCodeSync,
|
||||
countAdmins as countAdminsSync,
|
||||
createSession as createSessionSync,
|
||||
createUser as createUserSync,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
deleteUser as deleteUserSync,
|
||||
deleteUserInstances as deleteUserInstancesSync,
|
||||
deleteUserSessions as deleteUserSessionsSync,
|
||||
emailCodeCounts as emailCodeCountsSync,
|
||||
findBusinessPlugin as findBusinessPluginSync,
|
||||
findDomainById as findDomainByIdSync,
|
||||
findDomainByUser as findDomainByUserSync,
|
||||
@@ -31,6 +34,7 @@ import {
|
||||
findSession as findSessionSync,
|
||||
findSessionWithUser as findSessionWithUserSync,
|
||||
hasActiveSession as hasActiveSessionSync,
|
||||
findUserByEmail as findUserByEmailSync,
|
||||
findUserById as findUserByIdSync,
|
||||
findUserBySlug as findUserBySlugSync,
|
||||
findUserByUsername as findUserByUsernameSync,
|
||||
@@ -40,6 +44,7 @@ import {
|
||||
getEnabledPluginIds as getEnabledPluginIdsSync,
|
||||
getOrCreateWorkspace as getOrCreateWorkspaceSync,
|
||||
getSharedModelEnabled as getSharedModelEnabledSync,
|
||||
latestSentEmailCode as latestSentEmailCodeSync,
|
||||
listBusinessPlugins as listBusinessPluginsSync,
|
||||
listCredentialKeys as listCredentialKeysSync,
|
||||
listCredentialLandingRows as listCredentialLandingRowsSync,
|
||||
@@ -48,6 +53,8 @@ import {
|
||||
listInstancesByRole as listInstancesByRoleSync,
|
||||
listPublicUsers as listPublicUsersSync,
|
||||
listUsersWithoutUid as listUsersWithoutUidSync,
|
||||
purgeEmailCodes as purgeEmailCodesSync,
|
||||
recordEmailCode as recordEmailCodeSync,
|
||||
selectCredentialKey as selectCredentialKeySync,
|
||||
setCredentialKey as setCredentialKeySync,
|
||||
setDomainVerified as setDomainVerifiedSync,
|
||||
@@ -86,7 +93,10 @@ import type {
|
||||
DshInstance,
|
||||
DshInstanceRole,
|
||||
DshInstanceStatus,
|
||||
EmailCodeCounts,
|
||||
EmailCodeRow,
|
||||
PublicUser,
|
||||
RecordEmailCodeInput,
|
||||
SessionRow,
|
||||
SessionUser,
|
||||
UpsertBusinessPluginInput,
|
||||
@@ -176,6 +186,35 @@ export class SqliteAdapter implements DbAdapter {
|
||||
auditSync(this.db, actor, action, detail)
|
||||
}
|
||||
|
||||
// ── v10 邮箱验证码(注册页)────────────────────────────────────────────────
|
||||
async findUserByEmail(email: string): Promise<User | undefined> {
|
||||
return findUserByEmailSync(this.db, email)
|
||||
}
|
||||
|
||||
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
|
||||
recordEmailCodeSync(this.db, input)
|
||||
}
|
||||
|
||||
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
|
||||
return emailCodeCountsSync(this.db, email, ip, since)
|
||||
}
|
||||
|
||||
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
|
||||
return latestSentEmailCodeSync(this.db, email, purpose)
|
||||
}
|
||||
|
||||
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
|
||||
return bumpEmailCodeAttemptsSync(this.db, id, consume, now)
|
||||
}
|
||||
|
||||
async consumeEmailCode(id: string, now: number): Promise<boolean> {
|
||||
return consumeEmailCodeSync(this.db, id, now)
|
||||
}
|
||||
|
||||
async purgeEmailCodes(before: number): Promise<number> {
|
||||
return purgeEmailCodesSync(this.db, before)
|
||||
}
|
||||
|
||||
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
|
||||
return findWorkspaceByPathSync(this.db, userId, relPath)
|
||||
}
|
||||
|
||||
@@ -23,8 +23,29 @@ export interface User {
|
||||
approved_by: string | null
|
||||
/** Assigned Linux uid; null until backfilled/assigned (legacy rows). */
|
||||
uid: number | null
|
||||
/** Registration e-mail (v10); null for the legacy rows created before v10. */
|
||||
email: string | null
|
||||
}
|
||||
|
||||
/** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */
|
||||
export interface EmailCodeRow {
|
||||
id: string
|
||||
email: string
|
||||
purpose: string
|
||||
code_hash: string | null
|
||||
status: EmailCodeStatus
|
||||
attempts: number
|
||||
ip: string | null
|
||||
username: string | null
|
||||
reason: string | null
|
||||
created_at: number
|
||||
expires_at: number | null
|
||||
consumed_at: number | null
|
||||
}
|
||||
|
||||
/** `sent` = a deliverable code; the other two only feed the brute-force counters. */
|
||||
export type EmailCodeStatus = 'sent' | 'throttled' | 'failed'
|
||||
|
||||
/** The user shape safe to return over the wire. */
|
||||
export interface PublicUser {
|
||||
id: string
|
||||
@@ -194,6 +215,36 @@ export interface CreateUserInput {
|
||||
passHash: string
|
||||
role: UserRole
|
||||
homeDir: string
|
||||
/** Registration e-mail (v10, optional so the legacy/k8s paths keep working). */
|
||||
email?: string | null
|
||||
}
|
||||
|
||||
/** Insert one `email_codes` row (send attempt, throttled attempt or failed send). */
|
||||
export interface RecordEmailCodeInput {
|
||||
id: string
|
||||
email: string
|
||||
purpose: string
|
||||
codeHash: string | null
|
||||
status: EmailCodeStatus
|
||||
ip?: string | null
|
||||
username?: string | null
|
||||
reason?: string | null
|
||||
createdAt: number
|
||||
expiresAt?: number | null
|
||||
}
|
||||
|
||||
/** Rolling counters used by the brute-force guard (all computed from `email_codes`). */
|
||||
export interface EmailCodeCounts {
|
||||
/** Rows for this e-mail since `since`. */
|
||||
emailTotal: number
|
||||
/** Rows for this e-mail since `since` that were actually deliverable (`sent`). */
|
||||
emailSent: number
|
||||
/** Most recent row timestamp for this e-mail (any status), or 0 when none. */
|
||||
emailLastAt: number
|
||||
/** Rows from this ip since `since` (empty ip ⇒ 0). */
|
||||
ipTotal: number
|
||||
/** Global row count since `since` (protects the provider's daily quota). */
|
||||
globalTotal: number
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
@@ -236,6 +287,24 @@ export function toUser(row: Record<string, unknown>): User {
|
||||
created_at: row.created_at as number,
|
||||
approved_by: (row.approved_by as string | null) ?? null,
|
||||
uid: (row.uid as number | null) ?? null,
|
||||
email: (row.email as string | null) ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toEmailCode(row: Record<string, unknown>): EmailCodeRow {
|
||||
return {
|
||||
id: row.id as string,
|
||||
email: row.email as string,
|
||||
purpose: row.purpose as string,
|
||||
code_hash: (row.code_hash as string | null) ?? null,
|
||||
status: row.status as EmailCodeStatus,
|
||||
attempts: Number(row.attempts ?? 0),
|
||||
ip: (row.ip as string | null) ?? null,
|
||||
username: (row.username as string | null) ?? null,
|
||||
reason: (row.reason as string | null) ?? null,
|
||||
created_at: Number(row.created_at),
|
||||
expires_at: row.expires_at === null || row.expires_at === undefined ? null : Number(row.expires_at),
|
||||
consumed_at: row.consumed_at === null || row.consumed_at === undefined ? null : Number(row.consumed_at),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
|
||||
*
|
||||
* ## 它解决的唯一问题
|
||||
* 兜底入口 `relay-direct.alotbuy.com` 与主入口 `alotbuy.com` **同属 `*.alotbuy.com`**,
|
||||
* 兜底入口 `relay-direct.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**,
|
||||
* 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于
|
||||
* "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果
|
||||
* **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5)
|
||||
* ```
|
||||
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89
|
||||
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.ai1net.com=47.77.182.89
|
||||
* ```
|
||||
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。
|
||||
*
|
||||
|
||||
@@ -78,7 +78,7 @@ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||
* ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量,
|
||||
* **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。
|
||||
*/
|
||||
export const DEFAULT_OVERLAY_SEED = 'https://alotbuy.com/dshs-relay'
|
||||
export const DEFAULT_OVERLAY_SEED = 'https://ai1net.com/dshs-relay'
|
||||
|
||||
/** 从环境变量取种子;**没配** ⇒ 用内置常量位。 */
|
||||
export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] {
|
||||
@@ -321,7 +321,7 @@ export function buildDirectoryDocument(input: {
|
||||
/**
|
||||
* 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。
|
||||
*
|
||||
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://alotbuy.com/dshs-relay`,
|
||||
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://ai1net.com/dshs-relay`,
|
||||
* 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。
|
||||
* 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,7 @@ import type { AddressLookup, Rendezvous } from '../rendezvous.js'
|
||||
import { parseLogicalName } from './network.js'
|
||||
|
||||
export interface RelayRendezvousOptions {
|
||||
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.alotbuy.com/dshs-relay`。 */
|
||||
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.ai1net.com/dshs-relay`。 */
|
||||
dialTargetUrl: string
|
||||
/**
|
||||
* **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。
|
||||
|
||||
@@ -329,7 +329,7 @@ export class RelayFailoverSupervisor {
|
||||
*
|
||||
* 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与
|
||||
* **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。
|
||||
* 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前
|
||||
* 首轮真机实测(11:43:26):`wss://106… -> wss://ai1net.com…` —— 而 `ai1net.com` 十几分钟前
|
||||
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
|
||||
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
|
||||
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。
|
||||
@@ -353,7 +353,7 @@ export class RelayFailoverSupervisor {
|
||||
/**
|
||||
* 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖):
|
||||
*
|
||||
* 生产目录的 `relays[]` = `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`
|
||||
* 生产目录的 `relays[]` = `[ai1net.com(47), relay-direct.ai1net.com(47), 106]`
|
||||
* ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
|
||||
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
|
||||
* 链虽然"不再退化成单点",却依然**换不过去**。
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* 注册邮箱验证码:发码 / 校验两条链路 + 防爆破。路由层只做参数校验与响应组装。
|
||||
*
|
||||
* **为什么单独成模块**:这一段同时牵着四样东西 —— DB 事件表(配额与验证码的唯一来源)、
|
||||
* 外发邮件、人机验证、以及限流策略。塞进 `routes/auth.ts` 会让"注册"这个路由变成
|
||||
* 300 行的混合体,且**没法单测**(策略本身要用真接口才验得到边界)。拆出来后:
|
||||
* · 策略 = `register-guard.ts`(纯函数,可逐边界断言)
|
||||
* · 通道 = `mail.ts` / `turnstile.ts`(只负责一次网络往返)
|
||||
* · 编排 = 本文件(把上面三者按"先判配额 → 再发信 → 落事件"的顺序串起来)
|
||||
*
|
||||
* 三条不可让步的顺序:
|
||||
* ① **先人机验证再花配额** —— 否则机器人可以靠"打满配额"把真用户挡在门外;
|
||||
* ② **被拒绝也要落库** —— 事件表既是配额来源,也是"有人在撞"的唯一证据;
|
||||
* ③ **发信成功才记 `sent`** —— 记早了会让"上游全挂"看起来像"发出去过"。
|
||||
* @module dshs/web/email-code
|
||||
*/
|
||||
|
||||
import { createHash, randomInt, randomUUID, timingSafeEqual } from 'node:crypto'
|
||||
import type { DbAdapter } from '../db/adapter.js'
|
||||
import type { ServerConfig } from '../config.js'
|
||||
import { sendVerificationCodeMail, type MailSettings } from './mail.js'
|
||||
import { turnstileEnabled, type TurnstileSettings } from './turnstile.js'
|
||||
import {
|
||||
DAY_MS,
|
||||
HOUR_MS,
|
||||
evaluateSendGuard,
|
||||
evaluateVerifyGuard,
|
||||
type GuardPolicy,
|
||||
} from './register-guard.js'
|
||||
|
||||
export const PURPOSE_REGISTER = 'register'
|
||||
|
||||
/** 事件表自维护:每小时最多清一次 30 天前的行。 */
|
||||
const PURGE_OLDER_THAN_MS = 30 * DAY_MS
|
||||
const PURGE_INTERVAL_MS = HOUR_MS
|
||||
|
||||
/** 进程内串行化:同一邮箱同时只允许一个发信在飞(单进程控制面,够用)。 */
|
||||
const inflight = new Map<string, Promise<unknown>>()
|
||||
|
||||
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/
|
||||
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,32}$/
|
||||
|
||||
export function normalizeEmail(raw: string): string {
|
||||
return raw.trim().toLowerCase()
|
||||
}
|
||||
|
||||
export function isValidEmail(email: string): boolean {
|
||||
return email.length >= 6 && email.length <= 254 && EMAIL_RE.test(email)
|
||||
}
|
||||
|
||||
export function isValidUsername(username: string): boolean {
|
||||
return USERNAME_RE.test(username)
|
||||
}
|
||||
|
||||
/** 邮件里的站点名:取主域名标签大写(`ai1net.com` → `AI1NET`)。空 ⇒ 不写站点名。 */
|
||||
export function mailBrandFromConfig(config: ServerConfig): string {
|
||||
const domain = (config.baseDomain ?? '').trim()
|
||||
if (domain === '') return ''
|
||||
const label = domain.split('.')[0] ?? ''
|
||||
return label === '' ? '' : label.toUpperCase()
|
||||
}
|
||||
|
||||
/** 6 位数字(含前导 0)。用 `randomInt` 而非 `Math.random` —— 后者可预测。 */
|
||||
export function generateCode(): string {
|
||||
return String(randomInt(0, 1_000_000)).padStart(6, '0')
|
||||
}
|
||||
|
||||
/** 验证码**不存明文**:sha256(email | purpose | code | pepper),pepper = 平台密钥。 */
|
||||
export function hashCode(email: string, purpose: string, code: string, pepper: string): string {
|
||||
return createHash('sha256').update(`${email}|${purpose}|${code}|${pepper}`).digest('hex')
|
||||
}
|
||||
|
||||
/** 定长比较(两边都是 hex ⇒ 等长),避免按字节短路泄露前缀。 */
|
||||
export function codeMatches(expected: string, candidate: string): boolean {
|
||||
const a = Buffer.from(expected, 'utf8')
|
||||
const b = Buffer.from(candidate, 'utf8')
|
||||
if (a.length !== b.length) return false
|
||||
return timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
/** 把配置翻译成策略。**策略与配置分家**:策略是纯函数需要的形状,配置是 env 的形状。 */
|
||||
export function guardPolicyFromConfig(config: ServerConfig): GuardPolicy {
|
||||
return {
|
||||
codeTtlMs: config.emailCodeTtlMs,
|
||||
maxAttemptsPerCode: config.emailCodeMaxAttempts,
|
||||
emailPerHour: config.emailCodeGuard.emailPerHour,
|
||||
emailSentPerDay: config.emailCodeGuard.emailSentPerDay,
|
||||
ipPerHour: config.emailCodeGuard.ipPerHour,
|
||||
globalPerHour: config.emailCodeGuard.globalPerHour,
|
||||
cooldownLadderMs: config.emailCodeGuard.cooldownLadderMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function mailSettingsFromConfig(config: ServerConfig): MailSettings {
|
||||
return {
|
||||
driver: config.mailDriver,
|
||||
apiUrl: config.mailApiUrl,
|
||||
apiKey: config.mailApiKey,
|
||||
authHeader: config.mailAuthHeader,
|
||||
from: config.mailFrom,
|
||||
fromName: config.mailFromName,
|
||||
bodyTemplate: config.mailBodyTemplate,
|
||||
timeoutMs: config.mailTimeoutMs,
|
||||
}
|
||||
}
|
||||
|
||||
export function turnstileSettingsFromConfig(config: ServerConfig): TurnstileSettings {
|
||||
return {
|
||||
siteKey: config.turnstileSiteKey,
|
||||
secret: config.turnstileSecret,
|
||||
timeoutMs: 8000,
|
||||
action: config.turnstileAction,
|
||||
hostnames: config.turnstileHostnames,
|
||||
}
|
||||
}
|
||||
|
||||
/** 人机验证是否**实际**启用(要密钥成对 + 期望主机名非空 + 策略要求)。 */
|
||||
export function captchaActive(config: ServerConfig): boolean {
|
||||
return config.registerRequireCaptcha && turnstileEnabled(turnstileSettingsFromConfig(config))
|
||||
}
|
||||
|
||||
/**
|
||||
* 「配了一半」的检测:**密钥给了但从没给期望主机名**。
|
||||
*
|
||||
* 为什么单独判这个:`hostnames` 为空 ⇒ `turnstileEnabled=false` ⇒ 人机验证**静默不生效**
|
||||
* (注册页不渲染控件)。这是"看起来配了、实际没防住"的典型形态,必须能在日志里被看见,
|
||||
* 否则只有抓包才能发现。
|
||||
*/
|
||||
export function captchaPartiallyConfigured(config: ServerConfig): boolean {
|
||||
const keysGiven = config.turnstileSiteKey !== '' && config.turnstileSecret !== ''
|
||||
return keysGiven && config.turnstileHostnames.length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 邮箱验证码是否**实际**强制。注意是"策略要求 **且** 邮件通道已配置"——
|
||||
* 只要求策略、不检查通道,会让一个配错 env 的部署**彻底注册不进来**。
|
||||
*/
|
||||
export function emailCodeActive(config: ServerConfig): boolean {
|
||||
if (!config.registerRequireEmailCode) return false
|
||||
const settings = mailSettingsFromConfig(config)
|
||||
if (settings.driver === 'log') return true
|
||||
return settings.apiKey !== '' && settings.from !== ''
|
||||
}
|
||||
|
||||
export interface RequestCodeInput {
|
||||
email: string
|
||||
username: string
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export interface RequestCodeOutcome {
|
||||
ok: boolean
|
||||
/** 直接作为 HTTP 状态码使用。 */
|
||||
status: number
|
||||
error: string | null
|
||||
retryAfterSeconds: number
|
||||
expiresInSeconds: number
|
||||
}
|
||||
|
||||
export interface ConsumeCodeInput {
|
||||
email: string
|
||||
username: string
|
||||
code: string
|
||||
ip: string | null
|
||||
}
|
||||
|
||||
export interface ConsumeCodeOutcome {
|
||||
ok: boolean
|
||||
status: number
|
||||
error: string | null
|
||||
/** 还剩几次机会(失败时给界面用;不泄露码本身)。 */
|
||||
attemptsLeft?: number
|
||||
}
|
||||
|
||||
/** 同邮箱串行化:并发点两次"获取验证码"只会有一次真的发信。 */
|
||||
function withEmailLock<T>(email: string, fn: () => Promise<T>): Promise<T> {
|
||||
const previous = inflight.get(email) ?? Promise.resolve()
|
||||
const run = previous.then(fn, fn)
|
||||
// 表里只留"不会 reject 的链尾",避免未处理的 rejection 与 Map 无界增长。
|
||||
const tail = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
)
|
||||
inflight.set(email, tail)
|
||||
return run.finally(() => {
|
||||
if (inflight.get(email) === tail) inflight.delete(email)
|
||||
})
|
||||
}
|
||||
|
||||
let lastPurgeAt = 0
|
||||
|
||||
/** 机会性清理:调用方不用管,量级极小(每邮箱每小时个位行)。 */
|
||||
async function maybePurge(db: DbAdapter, now: number): Promise<void> {
|
||||
if (now - lastPurgeAt < PURGE_INTERVAL_MS) return
|
||||
lastPurgeAt = now
|
||||
try {
|
||||
await db.purgeEmailCodes(now - PURGE_OLDER_THAN_MS)
|
||||
} catch {
|
||||
// 清理失败不影响发码;下次再试。
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发码。**调用方必须先完成人机验证**(本函数不碰 Turnstile,避免两处都校一遍)。
|
||||
*/
|
||||
export async function requestRegisterCode(
|
||||
deps: { db: DbAdapter; config: ServerConfig; onWarn?: (message: string) => void },
|
||||
input: RequestCodeInput,
|
||||
): Promise<RequestCodeOutcome> {
|
||||
const { db, config } = deps
|
||||
const policy = guardPolicyFromConfig(config)
|
||||
const now = Date.now()
|
||||
const email = normalizeEmail(input.email)
|
||||
const miss = (status: number, error: string, retryAfterSeconds = 0): RequestCodeOutcome => ({
|
||||
ok: false,
|
||||
status,
|
||||
error,
|
||||
retryAfterSeconds,
|
||||
expiresInSeconds: Math.ceil(policy.codeTtlMs / 1000),
|
||||
})
|
||||
|
||||
if (!isValidEmail(email)) return miss(400, 'invalid_email')
|
||||
if (!isValidUsername(input.username)) return miss(400, 'invalid_username')
|
||||
|
||||
// 提前挡住注定失败的注册:既不浪费邮件配额,也让用户在填表阶段就得到反馈。
|
||||
if ((await db.findUserByUsername(input.username)) !== undefined) return miss(409, 'username_taken')
|
||||
if ((await db.findUserByEmail(email)) !== undefined) return miss(409, 'email_taken')
|
||||
|
||||
await maybePurge(db, now)
|
||||
|
||||
const [hour, day] = await Promise.all([
|
||||
db.emailCodeCounts(email, input.ip, now - HOUR_MS),
|
||||
db.emailCodeCounts(email, input.ip, now - DAY_MS),
|
||||
])
|
||||
const verdict = evaluateSendGuard({ hour, day }, now, policy)
|
||||
if (!verdict.allowed) {
|
||||
// ③ 被拒也落库:它是配额的一部分,也是"有人在猛撞"的证据。
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: PURPOSE_REGISTER,
|
||||
codeHash: null,
|
||||
status: 'throttled',
|
||||
ip: input.ip,
|
||||
username: input.username,
|
||||
reason: verdict.reason,
|
||||
createdAt: now,
|
||||
})
|
||||
return miss(429, verdict.reason, verdict.retryAfterSeconds)
|
||||
}
|
||||
|
||||
return withEmailLock(email, async () => {
|
||||
const code = generateCode()
|
||||
const settings = mailSettingsFromConfig(config)
|
||||
const result = await sendVerificationCodeMail(settings, {
|
||||
to: email,
|
||||
code,
|
||||
ttlMinutes: Math.round(policy.codeTtlMs / 60_000),
|
||||
brand: mailBrandFromConfig(config),
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
// ② 发信失败**也**占配额(否则上游一挂,请求会无限重试把它压得更死)。
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: PURPOSE_REGISTER,
|
||||
codeHash: null,
|
||||
status: 'failed',
|
||||
ip: input.ip,
|
||||
username: input.username,
|
||||
reason: result.error,
|
||||
createdAt: now,
|
||||
})
|
||||
deps.onWarn?.(`[register-code] 发送失败 to=${maskEmail(email)} driver=${settings.driver} err=${result.error ?? ''}`)
|
||||
return miss(502, 'mail_send_failed')
|
||||
}
|
||||
|
||||
// ③ 只有真发出去才记 `sent`(它才是可校验的那一行)。
|
||||
if (settings.driver === 'log') {
|
||||
// 显式选择了 `log` 驱动才把验证码写进服务日志(开发/断网排障用)。
|
||||
deps.onWarn?.(`[register-code][log-driver] ${maskEmail(email)} 验证码=${code}(${Math.round(policy.codeTtlMs / 60_000)} 分钟内有效)`)
|
||||
}
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: PURPOSE_REGISTER,
|
||||
codeHash: hashCode(email, PURPOSE_REGISTER, code, config.encryptionSecret),
|
||||
status: 'sent',
|
||||
ip: input.ip,
|
||||
username: input.username,
|
||||
reason: null,
|
||||
createdAt: now,
|
||||
expiresAt: now + policy.codeTtlMs,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
error: null,
|
||||
retryAfterSeconds: Math.ceil(verdict.cooldownMs / 1000),
|
||||
expiresInSeconds: Math.ceil(policy.codeTtlMs / 1000),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并**消费**验证码。返回 `ok:true` 时该码已被标记用掉(单次使用),
|
||||
* 调用方可以放心建账号 —— 重复提交同一码会在第二次拿到 `code_used`。
|
||||
*/
|
||||
export async function consumeRegisterCode(
|
||||
deps: { db: DbAdapter; config: ServerConfig },
|
||||
input: ConsumeCodeInput,
|
||||
): Promise<ConsumeCodeOutcome> {
|
||||
const { db, config } = deps
|
||||
const policy = guardPolicyFromConfig(config)
|
||||
const now = Date.now()
|
||||
const email = normalizeEmail(input.email)
|
||||
const fail = (status: number, error: string, attemptsLeft?: number): ConsumeCodeOutcome => ({
|
||||
ok: false,
|
||||
status,
|
||||
error,
|
||||
attemptsLeft,
|
||||
})
|
||||
|
||||
if (!isValidEmail(email)) return fail(400, 'invalid_email')
|
||||
if (!/^\d{4,8}$/.test(input.code)) return fail(400, 'code_invalid')
|
||||
|
||||
const row = await db.latestSentEmailCode(email, PURPOSE_REGISTER)
|
||||
const pre = evaluateVerifyGuard(row, now, policy)
|
||||
if (pre === 'missing') return fail(400, 'code_missing')
|
||||
if (pre === 'used') return fail(400, 'code_used')
|
||||
if (pre === 'too_many_attempts') return fail(400, 'code_attempts_exceeded')
|
||||
if (pre === 'expired') return fail(400, 'code_expired')
|
||||
if (row === undefined) return fail(400, 'code_missing')
|
||||
|
||||
// 验证码与"申请时填的用户名"绑定:换用户名重放同一封邮件里拿到的码不作数。
|
||||
if (row.username !== null && row.username.toLowerCase() !== input.username.toLowerCase()) {
|
||||
return fail(400, 'code_username_mismatch')
|
||||
}
|
||||
|
||||
const expected = hashCode(email, PURPOSE_REGISTER, input.code, config.encryptionSecret)
|
||||
if (!codeMatches(row.code_hash ?? '', expected)) {
|
||||
const nextAttempts = row.attempts + 1
|
||||
const consume = nextAttempts >= policy.maxAttemptsPerCode
|
||||
await db.bumpEmailCodeAttempts(row.id, consume, now)
|
||||
return consume
|
||||
? fail(400, 'code_attempts_exceeded', 0)
|
||||
: fail(400, 'code_invalid', Math.max(0, policy.maxAttemptsPerCode - nextAttempts))
|
||||
}
|
||||
|
||||
// 单次使用:CAS 失败 = 已被并发请求用掉。
|
||||
if (!(await db.consumeEmailCode(row.id, now))) return fail(409, 'code_used')
|
||||
return { ok: true, status: 200, error: null }
|
||||
}
|
||||
|
||||
/** 日志里的邮箱脱敏(只留域名,够定位是哪家邮箱出问题)。 */
|
||||
export function maskEmail(email: string): string {
|
||||
const at = email.indexOf('@')
|
||||
return at <= 0 ? '***' : `***${email.slice(at)}`
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 注册验证码的外发邮件层。
|
||||
*
|
||||
* 为什么做成"驱动 + 通用 HTTP 兜底"而不是直接写死某家 SDK:
|
||||
* ① 平台目前**没有**任何邮件基础设施(全库 grep `smtp|nodemailer|mail` = 0 命中),
|
||||
* 而注册是**唯一**必须先发信才能完成的功能 ⇒ 它是外部依赖最重的一环,必须能换;
|
||||
* ② 换供应商时**只改 env、不动代码**(`http` 驱动连 body 结构都能由配置给出),
|
||||
* 这对"以后可能要接别的邮件服务"是硬需求 —— 不必为了换家再走一次发布;
|
||||
* ③ 不引第三方依赖:`fetch` + JSON 就够,少一个供应链面。
|
||||
*
|
||||
* 驱动:
|
||||
* · `brevo` —— Brevo(原 Sendinblue)事务邮件 API:`POST /v3/smtp/email`,头 `api-key`。
|
||||
* 本机已有可用凭据(见档案),因此作为默认驱动。
|
||||
* · `http` —— **任意** JSON HTTP 接口:URL / 鉴权头 / body 模板全部由 env 给,
|
||||
* body 里可用 `{{to}} {{code}} {{subject}} {{text}} {{from}} {{fromName}}` 占位。
|
||||
* · `log` —— 不真发信,只把验证码交给调用方(由路由写 journald)。**仅供开发/断网排障**,
|
||||
* 显式选它才会生效(不选=不发日志,避免验证码进日志)。
|
||||
*
|
||||
* 纪律:**失败即失败**(返回 `{ok:false}`),不重试 —— 重试会造成"用户点一次收两封",
|
||||
* 且与 `email_codes` 的事件计数(= 防爆破的判据)对不上。
|
||||
* @module dshs/web/mail
|
||||
*/
|
||||
|
||||
/** 邮件通道的运行时配置(由 `config.ts` 从 env 组装后传入,本模块不读 env)。 */
|
||||
export interface MailSettings {
|
||||
driver: MailDriver
|
||||
/** 服务端点(`brevo` 驱动留空则用官方默认)。 */
|
||||
apiUrl: string
|
||||
apiKey: string
|
||||
/** `http` 驱动的鉴权头名(留空 + 有 apiKey ⇒ 用 `Authorization: Bearer`)。 */
|
||||
authHeader: string
|
||||
/** 发件地址(**必须**是该服务里已验证过的发件人,否则上游直接拒收)。 */
|
||||
from: string
|
||||
fromName: string
|
||||
/** `http` 驱动的 JSON body 模板(支持占位符)。 */
|
||||
bodyTemplate: string
|
||||
timeoutMs: number
|
||||
}
|
||||
|
||||
export type MailDriver = 'brevo' | 'http' | 'log'
|
||||
|
||||
export interface VerificationMail {
|
||||
to: string
|
||||
code: string
|
||||
ttlMinutes: number
|
||||
/** 展示给收件人的站点名(如 `AI1NET`)。**为空则整句退化成"你的验证码"**,绝不回落到平台内部名。 */
|
||||
brand?: string
|
||||
}
|
||||
|
||||
export interface MailResult {
|
||||
ok: boolean
|
||||
/** 失败原因(**不含**验证码本身),供审计与界面提示。 */
|
||||
error: string | null
|
||||
/** 上游返回的状态码(有则记),便于区分"配错了"与"上游抽风"。 */
|
||||
status?: number
|
||||
}
|
||||
|
||||
/** 该驱动是否具备发信条件(缺关键项 ⇒ 视为未配置,路由据此回退/报错)。 */
|
||||
export function mailConfigured(settings: MailSettings): boolean {
|
||||
if (settings.driver === 'log') return true
|
||||
if (settings.from === '') return false
|
||||
if (settings.driver === 'brevo') return settings.apiKey !== '' || settings.apiUrl !== ''
|
||||
return settings.apiUrl !== ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染主题与正文(中英双语:平台默认语言是英语,运营方是中文,两者都照顾到)。
|
||||
* ⚠️ `brand` 为空时**不能**回落到任何内部名(邮件是给终端用户的,平台内部名不该出现在里面)——
|
||||
* 此时整句退化为"你的验证码是"。
|
||||
*/
|
||||
export function renderVerificationMail(mail: VerificationMail): { subject: string; text: string } {
|
||||
const brand = (mail.brand ?? '').trim()
|
||||
const name = brand === '' ? '' : ` ${brand}`
|
||||
const subject = brand === '' ? `${mail.code} is your verification code` : `${mail.code} is your ${brand} verification code`
|
||||
const text = [
|
||||
`Your${name} verification code is: ${mail.code}`,
|
||||
`It expires in ${mail.ttlMinutes} minutes. If you did not request this, just ignore this e-mail.`,
|
||||
'',
|
||||
`你的${name}验证码是:${mail.code}`,
|
||||
`有效期 ${mail.ttlMinutes} 分钟。若非本人操作,请忽略本邮件。`,
|
||||
...(brand === '' ? [] : ['', `— ${brand}`]),
|
||||
].join('\n')
|
||||
return { subject, text }
|
||||
}
|
||||
|
||||
/** 占位符替换:值按 JSON 字符串转义后**只保留内容**,以便安全地嵌进 body 模板的引号内。 */
|
||||
function applyTemplate(template: string, values: Record<string, string>): string {
|
||||
return template.replace(/\{\{\s*([a-zA-Z]+)\s*\}\}/g, (match, key: string) => {
|
||||
const value = values[key]
|
||||
if (value === undefined) return match
|
||||
// slice(1,-1) 去掉 JSON.stringify 加的两端引号:调用方的模板里自己带引号。
|
||||
return JSON.stringify(value).slice(1, -1)
|
||||
})
|
||||
}
|
||||
|
||||
/** 发一封验证码邮件。永不抛异常 —— 失败以 `{ok:false}` 返回,由调用方决定如何记事件。 */
|
||||
export async function sendVerificationCodeMail(
|
||||
settings: MailSettings,
|
||||
mail: VerificationMail,
|
||||
): Promise<MailResult> {
|
||||
const { subject, text } = renderVerificationMail(mail)
|
||||
|
||||
if (settings.driver === 'log') return { ok: true, error: null }
|
||||
|
||||
if (!mailConfigured(settings)) {
|
||||
return { ok: false, error: 'mail_not_configured' }
|
||||
}
|
||||
|
||||
try {
|
||||
if (settings.driver === 'brevo') {
|
||||
return await sendViaBrevo(settings, { ...mail, subject, text })
|
||||
}
|
||||
return await sendViaHttp(settings, { ...mail, subject, text })
|
||||
} catch (e) {
|
||||
// AbortSignal.timeout 抛的是 TimeoutError;其它是网络/解析错误。
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: message.slice(0, 200) }
|
||||
}
|
||||
}
|
||||
|
||||
async function sendViaBrevo(
|
||||
settings: MailSettings,
|
||||
mail: VerificationMail & { subject: string; text: string },
|
||||
): Promise<MailResult> {
|
||||
const url = settings.apiUrl === '' ? 'https://api.brevo.com/v3/smtp/email' : settings.apiUrl
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json', accept: 'application/json' }
|
||||
if (settings.apiKey !== '') headers['api-key'] = settings.apiKey
|
||||
const body = {
|
||||
sender: { email: settings.from, name: settings.fromName === '' ? undefined : settings.fromName },
|
||||
to: [{ email: mail.to }],
|
||||
subject: mail.subject,
|
||||
textContent: mail.text,
|
||||
}
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(settings.timeoutMs),
|
||||
})
|
||||
if (!res.ok) {
|
||||
// 上游会回一段 JSON({code,message})—— 它不含验证码,可以安全地截断留证。
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 200)
|
||||
return { ok: false, error: `brevo_http_${res.status}${detail === '' ? '' : `: ${detail}`}`, status: res.status }
|
||||
}
|
||||
return { ok: true, error: null, status: res.status }
|
||||
}
|
||||
|
||||
async function sendViaHttp(
|
||||
settings: MailSettings,
|
||||
mail: VerificationMail & { subject: string; text: string },
|
||||
): Promise<MailResult> {
|
||||
const headers: Record<string, string> = { 'content-type': 'application/json' }
|
||||
if (settings.apiKey !== '') {
|
||||
if (settings.authHeader !== '') headers[settings.authHeader] = settings.apiKey
|
||||
else headers.authorization = `Bearer ${settings.apiKey}`
|
||||
}
|
||||
const values: Record<string, string> = {
|
||||
to: mail.to,
|
||||
code: mail.code,
|
||||
subject: mail.subject,
|
||||
text: mail.text,
|
||||
from: settings.from,
|
||||
fromName: settings.fromName,
|
||||
}
|
||||
const body =
|
||||
settings.bodyTemplate === ''
|
||||
? JSON.stringify({ from: settings.from, to: mail.to, subject: mail.subject, text: mail.text })
|
||||
: applyTemplate(settings.bodyTemplate, values)
|
||||
|
||||
const res = await fetch(settings.apiUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(settings.timeoutMs),
|
||||
})
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => '')).slice(0, 200)
|
||||
return { ok: false, error: `mail_http_${res.status}${detail === '' ? '' : `: ${detail}`}`, status: res.status }
|
||||
}
|
||||
return { ok: true, error: null, status: res.status }
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* 注册验证码的**防爆破策略**(纯函数 + 纯数据,便于单测与复用)。
|
||||
*
|
||||
* 为什么单独成模块:限流一旦和路由耦合,就只能靠"真打一遍接口"验证 —— 而限流恰恰是
|
||||
* **最需要精确边界**的东西(差 1 秒就漏一次发送)。这里所有判定都不碰 IO:
|
||||
* 输入 = 计数快照 + 当前时间,输出 = 允许/拒绝 + 还需等多久,因此可以逐边界断言。
|
||||
*
|
||||
* 设计口径(用户 2026-09-19 需求:「增加重复获取验证码的爆破设计」):
|
||||
* ① **发送侧**是花钱 + 打信誉的动作 ⇒ 收紧(阶梯冷却 + 每小时/每天配额 + IP 维度 + 全局维度);
|
||||
* ② **校验侧**是猜码 ⇒ 每个码最多试 N 次、超过即作废、成功即作废(单次使用);
|
||||
* ③ 被拒绝的请求**也要落库计入配额** —— 否则"被拒 → 立刻重试"就成了无限循环,
|
||||
* 而且拿不到"有人在猛撞"的证据;
|
||||
* ④ 冷却时间随次数**递增**(阶梯),让脚本化重试的收益递减,而真人重发一次仍只等 60 秒。
|
||||
*/
|
||||
|
||||
import type { EmailCodeCounts, EmailCodeRow } from '../db/types.js'
|
||||
|
||||
/** 生效中的策略值(可由配置覆盖;默认值即生产口径)。 */
|
||||
export interface GuardPolicy {
|
||||
/** 验证码有效期。 */
|
||||
codeTtlMs: number
|
||||
/** 同一个码最多允许试错几次,达上限**立即作废**(必须重新获取)。 */
|
||||
maxAttemptsPerCode: number
|
||||
/** 同一邮箱每小时最多**发起**几次(含被拒的)。 */
|
||||
emailPerHour: number
|
||||
/** 同一邮箱每 24 小时最多**真正发出**几封。 */
|
||||
emailSentPerDay: number
|
||||
/** 同一 IP 每小时最多发起几次(IP 可能 NAT,给得比邮箱宽)。 */
|
||||
ipPerHour: number
|
||||
/** 保护邮件服务商日配额的全局闸门(每小时)。 */
|
||||
globalPerHour: number
|
||||
/**
|
||||
* 冷却阶梯:索引 = 该邮箱**最近一小时内已发起的次数**(超出则用最后一项)。
|
||||
* 于是"第 1、2 次等 60 秒;第 3 次 3 分钟;第 4 次 5 分钟;第 5 次起 15~30 分钟"。
|
||||
*/
|
||||
cooldownLadderMs: readonly number[]
|
||||
}
|
||||
|
||||
export const DEFAULT_GUARD_POLICY: GuardPolicy = {
|
||||
codeTtlMs: 10 * 60 * 1000,
|
||||
maxAttemptsPerCode: 5,
|
||||
/**
|
||||
* 6 = **刚好让冷却阶梯的每一级都可达**(阶梯索引 = 一小时内已发起次数,0…5)。
|
||||
* 若设成 5,最后一级(30 分钟)永远走不到 —— 那就等于白写一级。
|
||||
* 真正拦人的是阶梯(累计 60+60+180+300+900+1800 ≈ 55 分钟),小时配额只是兜底。
|
||||
*/
|
||||
emailPerHour: 6,
|
||||
emailSentPerDay: 8,
|
||||
ipPerHour: 20,
|
||||
globalPerHour: 200,
|
||||
cooldownLadderMs: [60_000, 60_000, 180_000, 300_000, 900_000, 1_800_000],
|
||||
}
|
||||
|
||||
export const HOUR_MS = 60 * 60 * 1000
|
||||
export const DAY_MS = 24 * HOUR_MS
|
||||
|
||||
/** 计数快照:两个窗口各查一次(`hour` 供冷却与小时配额,`day` 供日配额)。 */
|
||||
export interface GuardCounts {
|
||||
hour: EmailCodeCounts
|
||||
day: EmailCodeCounts
|
||||
}
|
||||
|
||||
export type SendVerdict =
|
||||
| { allowed: true; cooldownMs: number }
|
||||
| { allowed: false; reason: string; retryAfterSeconds: number }
|
||||
|
||||
/**
|
||||
* 发送前判定。**顺序有意义**:先判配额(终局性拒绝,无"等一会就好"的错觉),
|
||||
* 再判冷却(可以等到具体时刻)。
|
||||
*/
|
||||
export function evaluateSendGuard(counts: GuardCounts, now: number, policy: GuardPolicy = DEFAULT_GUARD_POLICY): SendVerdict {
|
||||
const { hour, day } = counts
|
||||
|
||||
if (day.emailSent >= policy.emailSentPerDay) {
|
||||
// 按"本邮箱当天第一封的时间 + 24h"给出最早可再试的时刻,避免用户面对一个空泛的拒绝。
|
||||
return { allowed: false, reason: 'email_daily_quota', retryAfterSeconds: 3600 }
|
||||
}
|
||||
if (hour.emailTotal >= policy.emailPerHour) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'email_hourly_quota',
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((HOUR_MS - (now - hour.emailLastAt)) / 1000)),
|
||||
}
|
||||
}
|
||||
if (counts.hour.ipTotal >= policy.ipPerHour) {
|
||||
return { allowed: false, reason: 'ip_hourly_quota', retryAfterSeconds: 600 }
|
||||
}
|
||||
if (hour.globalTotal >= policy.globalPerHour) {
|
||||
return { allowed: false, reason: 'global_hourly_quota', retryAfterSeconds: 300 }
|
||||
}
|
||||
|
||||
const ladder = policy.cooldownLadderMs
|
||||
const cooldown = ladder[Math.min(hour.emailTotal, ladder.length - 1)] ?? 60_000
|
||||
const last = hour.emailLastAt
|
||||
if (last > 0 && now - last < cooldown) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: 'email_cooldown',
|
||||
retryAfterSeconds: Math.max(1, Math.ceil((cooldown - (now - last)) / 1000)),
|
||||
}
|
||||
}
|
||||
return { allowed: true, cooldownMs: cooldown }
|
||||
}
|
||||
|
||||
/** 校验前的判定(尚未比较哈希):码存在性 / 有效期 / 试错余量。 */
|
||||
export type VerifyPreVerdict = 'ok' | 'missing' | 'expired' | 'too_many_attempts' | 'used'
|
||||
|
||||
export function evaluateVerifyGuard(
|
||||
row: EmailCodeRow | undefined,
|
||||
now: number,
|
||||
policy: GuardPolicy = DEFAULT_GUARD_POLICY,
|
||||
): VerifyPreVerdict {
|
||||
if (row === undefined || row.code_hash === null) return 'missing'
|
||||
// `consumed_at` 非空 = 已用掉或被判作废(试错超限)。
|
||||
if (row.consumed_at !== null) return row.attempts >= policy.maxAttemptsPerCode ? 'too_many_attempts' : 'used'
|
||||
if (row.expires_at !== null && now > row.expires_at) return 'expired'
|
||||
return 'ok'
|
||||
}
|
||||
|
||||
/** 把剩余等待秒数切成给用户看的粒度(前端只用它做倒计时)。 */
|
||||
export function formatRetryAfter(seconds: number): string {
|
||||
if (seconds <= 90) return `${seconds} 秒`
|
||||
const minutes = Math.ceil(seconds / 60)
|
||||
return minutes < 60 ? `${minutes} 分钟` : `${Math.ceil(minutes / 60)} 小时`
|
||||
}
|
||||
+196
-3
@@ -14,6 +14,18 @@ import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProvide
|
||||
import { PROTOCOLS } from '../model-landing.js'
|
||||
import { readTextOrEmpty, writeHomeFile } from '../home-files.js'
|
||||
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
|
||||
import {
|
||||
captchaActive,
|
||||
captchaPartiallyConfigured,
|
||||
consumeRegisterCode,
|
||||
emailCodeActive,
|
||||
guardPolicyFromConfig,
|
||||
isValidEmail,
|
||||
normalizeEmail,
|
||||
requestRegisterCode,
|
||||
turnstileSettingsFromConfig,
|
||||
} from '../email-code.js'
|
||||
import { verifyTurnstile } from '../turnstile.js'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
clearSessionCookie,
|
||||
@@ -42,10 +54,87 @@ const registerSchema = {
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
|
||||
password: { type: 'string', minLength: 8, maxLength: 128 },
|
||||
// 档案 134:邮箱 + 验证码 + 人机验证 token 一律**可选**声明 ——
|
||||
// 「必填」由配置决定(`emailCodeActive` / `captchaActive`),schema 只保证形状与长度。
|
||||
// 这样同一份代码在"没配邮件的环境"里仍是老行为,不用改 schema 再发一次版。
|
||||
email: { type: 'string', maxLength: 254 },
|
||||
code: { type: 'string', maxLength: 16 },
|
||||
captchaToken: { type: 'string', maxLength: 4096 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
const emailCodeSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['username', 'email'],
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
|
||||
email: { type: 'string', minLength: 6, maxLength: 254 },
|
||||
captchaToken: { type: 'string', maxLength: 4096 },
|
||||
},
|
||||
},
|
||||
} as const
|
||||
|
||||
interface RegisterBody {
|
||||
username: string
|
||||
password: string
|
||||
email?: string
|
||||
code?: string
|
||||
captchaToken?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 真实客户端 IP(防爆破的**次要**维度)。
|
||||
*
|
||||
* 站点经 Cloudflare → 宝塔 nginx → `127.0.0.1:3080`,`trustProxy` 已开 ⇒ `request.ip` 取 XFF 首项
|
||||
* (由 CF 覆写,不可由访客伪造)。这里优先用 `CF-Connecting-IP`(CF 的权威口径,且不受链路上
|
||||
* 其它代理拼接 XFF 的影响)。
|
||||
* ⚠️ **它是次要维度**:源站若可被直连,头仍可伪造 —— 因此真正的承重维度是
|
||||
* **邮箱维度**(攻击者无法伪造目标地址)与**全局闸门**(保护邮件服务商配额)。IP 维度只用来
|
||||
* 让"同一台机器猛撞"更快被拦住。
|
||||
*/
|
||||
function clientIp(request: { headers: Record<string, unknown>; ip: string }): string | null {
|
||||
const header = request.headers['cf-connecting-ip']
|
||||
const value = Array.isArray(header) ? header[0] : header
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.trim()
|
||||
return request.ip === '' ? null : request.ip
|
||||
}
|
||||
|
||||
/** 人机验证:配置齐了才校;未配置时**放行**(老行为),并由调用方必要时告警。 */
|
||||
async function checkCaptcha(
|
||||
app: { config: unknown; log: { warn: (message: string) => void } },
|
||||
token: string | undefined,
|
||||
ip: string | null,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const config = app.config as Parameters<typeof captchaActive>[0]
|
||||
if (!captchaActive(config)) {
|
||||
// 「密钥给了但没给期望主机名」⇒ 人机验证**静默不生效**。这是最危险的形态
|
||||
// (看起来配了、实际没防住),所以每次走到这里都留一条告警,让它在日志里可见。
|
||||
// 去重只做在同一进程内,不影响可观测性(重启后第一条仍会打出来)。
|
||||
if (captchaPartiallyConfigured(config)) captchaWarnOnce(app)
|
||||
return { ok: true }
|
||||
}
|
||||
const verdict = await verifyTurnstile(turnstileSettingsFromConfig(config), token ?? '', ip ?? undefined)
|
||||
if (verdict.ok) return { ok: true }
|
||||
// 上游故障与"token 不对"必须可分:前者是运维问题,后者是访客问题。
|
||||
const upstream = verdict.error !== null && verdict.error.startsWith('network_error')
|
||||
app.log.warn(`[register] Turnstile 校验失败 ip=${ip ?? '-'} err=${verdict.error ?? ''}`)
|
||||
return { ok: false, error: upstream ? 'captcha_unavailable' : 'captcha_failed' }
|
||||
}
|
||||
|
||||
let captchaPartialWarned = false
|
||||
/** 只在进程内第一条上告警,避免被机器人刷日志。 */
|
||||
function captchaWarnOnce(app: { log: { warn: (message: string) => void } }): void {
|
||||
if (captchaPartialWarned) return
|
||||
captchaPartialWarned = true
|
||||
app.log.warn(
|
||||
'[register] Turnstile 已配 siteKey/secret,但期望主机名列表为空 ⇒ 人机验证**未启用**'
|
||||
+ '(请设 DSHS_TURNSTILE_HOSTNAMES=<域名[,www.域名]>)',
|
||||
)
|
||||
}
|
||||
|
||||
const loginSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
@@ -64,23 +153,127 @@ interface Credentials {
|
||||
}
|
||||
|
||||
export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
/**
|
||||
* 注册页需要知道的**公开**配置(档案 134)。
|
||||
*
|
||||
* 为什么要一个端点而不是把 sitekey 写进 HTML:
|
||||
* · sitekey 随环境变(本地/测试/线上各一个 Turnstile widget),写死会让"换环境忘记换 key"
|
||||
* 变成一次静默失效 —— 人机验证看起来在,其实一直在报 `invalid-input-secret`;
|
||||
* · 前端据此**决定要不要渲染**控件,而不是渲染一个永远失败的控件。
|
||||
* ⚠️ 只回**公钥**。服务端密钥永不出现在任何响应里。
|
||||
*/
|
||||
app.get('/api/auth/register/config', async (_request, reply) => {
|
||||
reply.header('cache-control', 'no-store')
|
||||
const captchaOn = captchaActive(app.config)
|
||||
return {
|
||||
captcha: {
|
||||
enabled: captchaOn,
|
||||
// 未启用时给空串 ⇒ 前端不会去加载 CF 脚本(少一个第三方请求)。
|
||||
siteKey: captchaOn ? app.config.turnstileSiteKey : '',
|
||||
// ⚠️ `action` 必须由**服务端**下发且与 siteverify 校验的一致:两边各写一份常量,
|
||||
// 一旦漂掉就是"过不了验证且看不出原因"(CF 只回 action 不匹配,不说是谁配错)。
|
||||
action: app.config.turnstileAction,
|
||||
},
|
||||
emailCode: {
|
||||
enabled: emailCodeActive(app.config),
|
||||
ttlSeconds: Math.round(app.config.emailCodeTtlMs / 1000),
|
||||
maxAttempts: app.config.emailCodeMaxAttempts,
|
||||
},
|
||||
// 前端做即时校验用(避免"提交了才说格式错"),与后端同一份口径。
|
||||
usernamePattern: '^[a-zA-Z0-9_-]{3,32}$',
|
||||
minPasswordLength: 8,
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 请求邮箱验证码(档案 134)。
|
||||
*
|
||||
* 顺序不可交换:**人机验证 → 配额/冷却 → 发信 → 落事件**。
|
||||
* 反过来的话,机器人只需猛点就能把真用户的配额吃光(用自己的拒绝服务挡住别人)。
|
||||
*/
|
||||
app.post(
|
||||
'/api/auth/register/email-code',
|
||||
{ schema: emailCodeSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const body = request.body as { username: string; email: string; captchaToken?: string }
|
||||
const ip = clientIp(request as unknown as { headers: Record<string, unknown>; ip: string })
|
||||
|
||||
if (!emailCodeActive(app.config)) return reply.code(503).send({ error: 'email_code_disabled' })
|
||||
|
||||
const captcha = await checkCaptcha(app, body.captchaToken, ip)
|
||||
if (!captcha.ok) return reply.code(403).send({ error: captcha.error })
|
||||
|
||||
const outcome = await requestRegisterCode(
|
||||
{ db: app.db, config: app.config, onWarn: (message) => app.log.warn(message) },
|
||||
{ email: body.email, username: body.username, ip },
|
||||
)
|
||||
if (!outcome.ok) {
|
||||
await app.db.audit(null, 'register_code_rejected', JSON.stringify({ reason: outcome.error, ip }))
|
||||
if (outcome.retryAfterSeconds > 0) reply.header('retry-after', String(outcome.retryAfterSeconds))
|
||||
return reply.code(outcome.status).send({
|
||||
error: outcome.error,
|
||||
retryAfterSeconds: outcome.retryAfterSeconds,
|
||||
})
|
||||
}
|
||||
await app.db.audit(null, 'register_code_sent', JSON.stringify({ ip }))
|
||||
return reply.header('retry-after', String(outcome.retryAfterSeconds)).send({
|
||||
ok: true,
|
||||
retryAfterSeconds: outcome.retryAfterSeconds,
|
||||
expiresInSeconds: outcome.expiresInSeconds,
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
'/api/auth/register',
|
||||
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
|
||||
async (request, reply) => {
|
||||
const { username, password } = request.body as Credentials
|
||||
const { username, password } = request.body as RegisterBody
|
||||
const body = request.body as RegisterBody
|
||||
const ip = clientIp(request as unknown as { headers: Record<string, unknown>; ip: string })
|
||||
const email = normalizeEmail(body.email ?? '')
|
||||
|
||||
// ① 先做**无副作用**的占用检查:命中就不必浪费一次人机验证与一个验证码。
|
||||
if ((await app.db.findUserByUsername(username)) !== undefined) {
|
||||
return reply.code(409).send({ error: 'username_taken' })
|
||||
}
|
||||
if (email !== '' && (await app.db.findUserByEmail(email)) !== undefined) {
|
||||
return reply.code(409).send({ error: 'email_taken' })
|
||||
}
|
||||
|
||||
// ② 人机验证(配置齐了才校)。
|
||||
const captcha = await checkCaptcha(app, body.captchaToken, ip)
|
||||
if (!captcha.ok) return reply.code(403).send({ error: captcha.error })
|
||||
|
||||
// ③ 邮箱验证码(配置齐了才强制)。**校验通过即消费**,同一码不能建两个账号。
|
||||
if (emailCodeActive(app.config)) {
|
||||
if (email === '' || !isValidEmail(email)) return reply.code(400).send({ error: 'email_required' })
|
||||
const verdict = await consumeRegisterCode(
|
||||
{ db: app.db, config: app.config },
|
||||
{ email, username, code: (body.code ?? '').trim(), ip },
|
||||
)
|
||||
if (!verdict.ok) {
|
||||
await app.db.audit(null, 'register_code_invalid', JSON.stringify({ reason: verdict.error, ip }))
|
||||
return reply.code(verdict.status).send({ error: verdict.error, attemptsLeft: verdict.attemptsLeft })
|
||||
}
|
||||
}
|
||||
|
||||
const id = randomUUID()
|
||||
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
|
||||
const passHash = await hashPassword(password)
|
||||
// Create the user first so `initUserRoot` resolves the DB-assigned uid
|
||||
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
|
||||
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
|
||||
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
|
||||
const user = await app.db.createUser({
|
||||
id,
|
||||
username,
|
||||
passHash,
|
||||
role: 'pending',
|
||||
homeDir,
|
||||
email: email === '' ? null : email,
|
||||
})
|
||||
await app.userFs.initUserRoot(id, user.uid ?? undefined)
|
||||
await app.db.audit(id, 'register', JSON.stringify({ username }))
|
||||
await app.db.audit(id, 'register', JSON.stringify({ username, email: email === '' ? null : email }))
|
||||
return reply.code(201).send({ user: { id, username, role: 'pending' } })
|
||||
},
|
||||
)
|
||||
|
||||
+3
-3
@@ -1127,9 +1127,9 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
|
||||
await registerDshProxy(app)
|
||||
|
||||
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
|
||||
// runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on
|
||||
// `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
|
||||
// Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow
|
||||
// runs in the browser on `<user>.dsh.ai1net.com` and calls portal APIs on
|
||||
// `dsh.ai1net.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
|
||||
// Domain=.dsh.ai1net.com, so credentials ride along; we only need to allow
|
||||
// the Origin. Restricted to the platform base domain and its subdomains.
|
||||
app.addHook('onRequest', async (request, reply) => {
|
||||
const origin = request.headers.origin
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* Cloudflare Turnstile 服务端校验(注册页人机验证)。
|
||||
*
|
||||
* 为什么人机验证放在**服务端**而不是只靠前端 widget:
|
||||
* 前端 widget 只负责"取一个 token",token 本身**谁都能伪造**—— 只有拿它去 CF 的
|
||||
* `siteverify` 换回 `success:true` 才算数。因此后端必须独立再校一遍,且**发码与注册两处都校**
|
||||
* (发码是花钱动作、注册是建账号动作,任一被绕过都不算防住)。
|
||||
*
|
||||
* 两处刻意的选择:
|
||||
* ① **失败关闭(fail-closed)**:CF 不可达 / 校验不通过 ⇒ 直接拒绝注册。
|
||||
* 理由:Turnstile 是反自动化闸门,若"网络一抖就放行",攻击者只要让校验超时即可绕过。
|
||||
* 代价是 CF 侧长时间不可用时注册会受影响 —— 因此失败原因**分类返回**(见 `error`),
|
||||
* 运维能一眼分清"配错了密钥"和"上游抽风"。
|
||||
* ② token **单次有效**(CF 语义):一个 token 校验过一次后再用会拿到 `timeout-or-duplicate`。
|
||||
* 所以发码成功与注册成功之后,前端都必须 `reset()` 重新取 —— 见 `web/register.html`。
|
||||
* @module dshs/web/turnstile
|
||||
*/
|
||||
|
||||
/** Turnstile 的运行时配置(由 `config.ts` 从 env 组装)。 */
|
||||
export interface TurnstileSettings {
|
||||
/** 站点公钥 —— 会下发到注册页,**不是秘密**。 */
|
||||
siteKey: string
|
||||
/** 服务端密钥 —— 只留在服务端,绝不下发。 */
|
||||
secret: string
|
||||
timeoutMs: number
|
||||
/**
|
||||
* 期望的 `action`(渲染 widget 时声明、siteverify 时回显)。
|
||||
* 为什么必须校:`action` 是 CF 给"这一枚 token 是给哪个业务用的"打的标签。
|
||||
* 不校它 ⇒ 我们**所有**用同一 sitekey 的入口共享 token,人机验证退化成"过了任意一处即可用到处"。
|
||||
* 取值约束(CF 规定):1–32 字符,仅字母 / 数字 / `_` / `-`。
|
||||
*/
|
||||
action: string
|
||||
/**
|
||||
* 期望的**前端主机名**白名单(`result.hostname` 必须在此列)。
|
||||
*
|
||||
* 🔴 为什么这是**最关键**的一项:sitekey 是公开的(会出现在页面 HTML 里)⇒
|
||||
* 攻击者可以在**自己的站点**上嵌入我们的 sitekey、为真人访客拿到合法 token,再拿去打我们的接口
|
||||
* —— 只校 `success` 的话,这条路完全通畅。`hostname` 是**服务端**(CF)判定并回显的,
|
||||
* 访客篡改不了 ⇒ 只有校它才能真正把 token 绑到"从我们站点发出的挑战"上。
|
||||
*
|
||||
* ⚠️ 空数组 = **不安全**,`captchaActive()` 据此判"未配置完成"(宁可功能不启用,也不放开)。
|
||||
*/
|
||||
hostnames: string[]
|
||||
/**
|
||||
* siteverify 端点(可选,默认 Cloudflare 官方)。
|
||||
* 做成可覆盖的**唯一动机是可测性**:`action` / `hostname` 这两条判定是"放过还是拦下"的
|
||||
* 全部依据,必须能逐组合断言 —— 而硬编码 URL 就只能靠"真拿 CF token 打线上"才验得到。
|
||||
*/
|
||||
verifyUrl?: string
|
||||
}
|
||||
|
||||
const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'
|
||||
|
||||
export interface TurnstileResult {
|
||||
ok: boolean
|
||||
/** CF 的错误码或本地判定的拒绝原因;网络失败时为 `network_error`。 */
|
||||
error: string | null
|
||||
}
|
||||
|
||||
/** 是否启用:**公钥、私钥、期望主机名三者齐了才算配置完成**(缺一视为未启用并告警)。 */
|
||||
export function turnstileEnabled(settings: TurnstileSettings): boolean {
|
||||
return settings.siteKey !== '' && settings.secret !== '' && settings.hostnames.length > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务端校验一个 Turnstile token。永不抛异常。
|
||||
* 判定**三项齐备**才放行(与 Cloudflare 官方 canonical siteverify 同口径):
|
||||
* `success === true` ∧ `action` 匹配 ∧ `hostname` 在白名单内。
|
||||
*/
|
||||
export async function verifyTurnstile(
|
||||
settings: TurnstileSettings,
|
||||
token: string,
|
||||
remoteIp?: string,
|
||||
): Promise<TurnstileResult> {
|
||||
if (!turnstileEnabled(settings)) return { ok: false, error: 'not_configured' }
|
||||
if (token === '') return { ok: false, error: 'missing-input-response' }
|
||||
// 官方上限 2048:超长一律视为伪造(不必浪费一次上游往返)。
|
||||
if (token.length > 2048) return { ok: false, error: 'invalid-input-response' }
|
||||
|
||||
const form = new URLSearchParams()
|
||||
form.set('secret', settings.secret)
|
||||
form.set('response', token)
|
||||
if (remoteIp !== undefined && remoteIp !== '') form.set('remoteip', remoteIp)
|
||||
|
||||
try {
|
||||
const res = await fetch(settings.verifyUrl ?? SITEVERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
||||
body: form.toString(),
|
||||
signal: AbortSignal.timeout(settings.timeoutMs),
|
||||
})
|
||||
if (!res.ok) return { ok: false, error: `siteverify_http_${res.status}` }
|
||||
const body = (await res.json()) as {
|
||||
success?: boolean
|
||||
action?: string
|
||||
hostname?: string
|
||||
'error-codes'?: string[]
|
||||
}
|
||||
if (body.success !== true) {
|
||||
const codes = body['error-codes'] ?? []
|
||||
return { ok: false, error: codes.length === 0 ? 'verification_failed' : codes.join(',') }
|
||||
}
|
||||
// ⚠️ `success:true` 之后**仍要**校这两项 —— 它们才是"这枚 token 是为我们站点、我们这个动作签发的"证据。
|
||||
if (settings.action !== '' && body.action !== settings.action) {
|
||||
return { ok: false, error: `action_mismatch: ${body.action ?? '(none)'}` }
|
||||
}
|
||||
if (body.hostname === undefined || !settings.hostnames.includes(body.hostname)) {
|
||||
return { ok: false, error: `hostname_mismatch: ${body.hostname ?? '(none)'}` }
|
||||
}
|
||||
return { ok: true, error: null }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : String(e)
|
||||
return { ok: false, error: `network_error: ${message.slice(0, 120)}` }
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/s
|
||||
import type { WorkerTunnel } from './tunnel.js'
|
||||
|
||||
export interface RelayTunnelOptions {
|
||||
/** relay 的 WebSocket 地址:`wss://alotbuy.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
|
||||
/** relay 的 WebSocket 地址:`wss://ai1net.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
|
||||
url: string
|
||||
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
|
||||
hostId: string
|
||||
@@ -93,7 +93,7 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea
|
||||
* - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`);
|
||||
* - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**:
|
||||
* 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选
|
||||
* `wss://alotbuy.com/dshs-relay` 与 `wss://relay-direct.alotbuy.com/dshs-relay` **摘名不同、
|
||||
* `wss://ai1net.com/dshs-relay` 与 `wss://relay-direct.ai1net.com/dshs-relay` **摘名不同、
|
||||
* 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
|
||||
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
|
||||
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
|
||||
@@ -119,7 +119,7 @@ export function candidateObsMs(env: Record<string, string | undefined> = process
|
||||
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
|
||||
*
|
||||
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
|
||||
* 本数**不是独立物理路径数**(真机实证:`alotbuy.com` 与 `relay-direct.alotbuy.com` 都在 47,
|
||||
* 本数**不是独立物理路径数**(真机实证:`ai1net.com` 与 `relay-direct.ai1net.com` 都在 47,
|
||||
* 但 `count=3` 时 `hosts` 也报 3)。
|
||||
*/
|
||||
function candHostsOf(urls: readonly string[]): number {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* `web/i18n.js` 的品牌文案渲染回归(2026-09-19,档案 134 配套)。
|
||||
*
|
||||
* 为什么单独钉这一条:品牌标识从 DeepSeek 图形换成了**文字**(中文「能力枢纽」/
|
||||
* 英文及其他语言 `CapabilityNet`)。词条漏配或 key 写错时,i18n 会**静默回退英文**
|
||||
* —— 页面上看不出报错,只有中文用户看到英文名。这类"静默失败"必须靠测试钉住。
|
||||
*
|
||||
* 做法:用 `node:vm` 跑**仓库里那份真实的 `web/i18n.js`**(不是复制一遍逻辑),
|
||||
* 配一个够用的最小 DOM 桩,断言六种语言解析路径下的**实际渲染结果**。
|
||||
*
|
||||
* ⚠️ 桩里必须注入 `URLSearchParams` / `URL`:`vm` 上下文只含 ECMAScript 内置、
|
||||
* 不含 Web API;少了它们 `new URLSearchParams(location.search)` 会抛 ReferenceError,
|
||||
* 被 i18n.js 的 try/catch 吞掉 ⇒ 表现为"`?lang=` 不生效"的**假失败**(第一版踩过)。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import vm from 'node:vm'
|
||||
|
||||
const SRC = readFileSync(new URL('../web/i18n.js', import.meta.url), 'utf8')
|
||||
|
||||
/** 造一个够 i18n.js 用的最小元素桩(只需要 getAttribute / textContent)。 */
|
||||
function makeEl(attrs = {}) {
|
||||
return {
|
||||
_attrs: { ...attrs },
|
||||
textContent: attrs.__text ?? '',
|
||||
className: '',
|
||||
innerHTML: '',
|
||||
style: {},
|
||||
getAttribute(name) {
|
||||
return Object.prototype.hasOwnProperty.call(this._attrs, name) ? this._attrs[name] : null
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this._attrs[name] = value
|
||||
},
|
||||
appendChild() {},
|
||||
addEventListener() {},
|
||||
querySelectorAll() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** 在最小 DOM 桩里跑一次 i18n.js,返回品牌节点的实际渲染结果与解析出的语言。 */
|
||||
function renderBrand({ search = '', cookie = '', language = 'en-US' } = {}) {
|
||||
const brandNode = makeEl({ 'data-i18n': 'brand.name', __text: 'CapabilityNet' })
|
||||
const sandbox = {
|
||||
console,
|
||||
setTimeout: () => {},
|
||||
URLSearchParams,
|
||||
URL,
|
||||
document: {
|
||||
readyState: 'complete',
|
||||
cookie,
|
||||
documentElement: makeEl(),
|
||||
querySelectorAll(selector) {
|
||||
return selector === '[data-i18n]' ? [brandNode] : []
|
||||
},
|
||||
createElement: () => makeEl(),
|
||||
addEventListener: () => {},
|
||||
},
|
||||
navigator: { language, languages: [language] },
|
||||
location: { search, href: `https://ai1net.com/login.html${search}`, reload: () => {} },
|
||||
window: {},
|
||||
}
|
||||
sandbox.window = sandbox
|
||||
vm.createContext(sandbox)
|
||||
vm.runInContext(SRC, sandbox, { filename: 'web/i18n.js' })
|
||||
return { brand: brandNode.textContent, lang: sandbox.window.I18N.lang }
|
||||
}
|
||||
|
||||
test('品牌文案:中文显示「能力枢纽」,英文及其他语言显示 CapabilityNet', () => {
|
||||
const cases = [
|
||||
['默认(浏览器 en-US)', { language: 'en-US' }, 'CapabilityNet'],
|
||||
['中文(浏览器 zh-CN)', { language: 'zh-CN' }, '能力枢纽'],
|
||||
['中文(?lang=zh)', { search: '?lang=zh', language: 'en-US' }, '能力枢纽'],
|
||||
['英文(?lang=en 覆盖中文浏览器)', { search: '?lang=en', language: 'zh-CN' }, 'CapabilityNet'],
|
||||
['cookie dsh_lang=zh', { cookie: 'dsh_lang=zh', language: 'en-US' }, '能力枢纽'],
|
||||
['未支持语言(ja)⇒ 回退英文', { language: 'ja-JP' }, 'CapabilityNet'],
|
||||
]
|
||||
for (const [label, opts, expected] of cases) {
|
||||
const got = renderBrand(opts)
|
||||
assert.equal(got.brand, expected, label)
|
||||
}
|
||||
})
|
||||
|
||||
test('品牌文案:不会退化成裸 key、不留空', () => {
|
||||
for (const language of ['en-US', 'zh-CN', 'ja-JP', 'de-DE']) {
|
||||
const { brand } = renderBrand({ language })
|
||||
assert.notEqual(brand, 'brand.name', `裸 key 泄露(${language})`)
|
||||
assert.ok(brand.trim() !== '', `空文案(${language})`)
|
||||
}
|
||||
})
|
||||
@@ -126,7 +126,7 @@ function refusingFetch(calls) {
|
||||
|
||||
test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
// 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径
|
||||
assert.equal(directoryUrlFor('https://alotbuy.com/dshs-relay'), `https://alotbuy.com${DIRECTORY_PATH}`)
|
||||
assert.equal(directoryUrlFor('https://ai1net.com/dshs-relay'), `https://ai1net.com${DIRECTORY_PATH}`)
|
||||
assert.equal(directoryUrlFor('http://127.0.0.1:8080/dshs-relay'), `http://127.0.0.1:8080${DIRECTORY_PATH}`)
|
||||
// 已经是目录地址 ⇒ 原样
|
||||
assert.equal(directoryUrlFor(`https://a.example${DIRECTORY_PATH}`), `https://a.example${DIRECTORY_PATH}`)
|
||||
@@ -134,7 +134,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`)
|
||||
|
||||
// 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据)
|
||||
assert.equal(toRelayUrl('https://alotbuy.com/dshs-relay'), 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(toRelayUrl('https://ai1net.com/dshs-relay'), 'wss://ai1net.com/dshs-relay')
|
||||
assert.equal(toRelayUrl('http://127.0.0.1:20080/dshs-relay'), 'ws://127.0.0.1:20080/dshs-relay')
|
||||
assert.equal(toRelayUrl('wss://a.example/x'), 'wss://a.example/x')
|
||||
assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝')
|
||||
@@ -178,20 +178,20 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
'http://100.64.7.7/dshs-relay',
|
||||
'http://relaybox/dshs-relay',
|
||||
'http://[::1]/dshs-relay',
|
||||
'https://alotbuy.com/dshs-relay',
|
||||
'https://ai1net.com/dshs-relay',
|
||||
'https://relay.example.com/dshs-relay',
|
||||
]),
|
||||
['https://alotbuy.com/dshs-relay', 'https://relay.example.com/dshs-relay'],
|
||||
['https://ai1net.com/dshs-relay', 'https://relay.example.com/dshs-relay'],
|
||||
)
|
||||
|
||||
// 常量位:只锚一条、指向**已持证书的门户**(第二地域留空 ⇒ 不新增域名成本)
|
||||
assert.equal(DEFAULT_OVERLAY_SEED, 'https://alotbuy.com/dshs-relay')
|
||||
assert.equal(DEFAULT_OVERLAY_SEED, 'https://ai1net.com/dshs-relay')
|
||||
|
||||
// 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443)
|
||||
// ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继)
|
||||
assert.deepEqual(
|
||||
publicRelayEntries(['wss://alotbuy.com/dshs-relay', 'https://alotbuy.com/dshs-relay']),
|
||||
['wss://alotbuy.com/dshs-relay'],
|
||||
publicRelayEntries(['wss://ai1net.com/dshs-relay', 'https://ai1net.com/dshs-relay']),
|
||||
['wss://ai1net.com/dshs-relay'],
|
||||
)
|
||||
// …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留
|
||||
assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [
|
||||
@@ -210,7 +210,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
|
||||
test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => {
|
||||
const keys = makeKeys()
|
||||
const other = makeKeys()
|
||||
const origin = 'https://alotbuy.com/dshs-relay'
|
||||
const origin = 'https://ai1net.com/dshs-relay'
|
||||
const { doc, sig } = signedDoc(origin, keys.privatePem)
|
||||
|
||||
// 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验
|
||||
@@ -248,7 +248,7 @@ test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律
|
||||
|
||||
test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => {
|
||||
const keys = makeKeys()
|
||||
const seed = 'https://alotbuy.com/dshs-relay'
|
||||
const seed = 'https://ai1net.com/dshs-relay'
|
||||
|
||||
// ① env 显式 ⇒ 压制引导链(**一次网络都不发**)
|
||||
{
|
||||
@@ -279,7 +279,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
|
||||
fetchImpl: refusingFetch(calls),
|
||||
})
|
||||
assert.equal(r.source, 'cache')
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
assert.deepEqual(calls, [], '新鲜缓存不许联网')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
@@ -298,7 +298,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
|
||||
fetchImpl: refusingFetch(calls),
|
||||
})
|
||||
assert.equal(r.source, 'seed-fallback')
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
assert.ok(calls.length >= 1, '应当尝试过取目录')
|
||||
assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存')
|
||||
} finally {
|
||||
@@ -320,7 +320,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
|
||||
fetchImpl: refusingFetch([]),
|
||||
})
|
||||
assert.equal(r.source, 'stale-cache')
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
// 缓存**被改坏 / 换了密钥** ⇒ 当作没有缓存(读也要验签)
|
||||
assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined)
|
||||
} finally {
|
||||
@@ -685,8 +685,8 @@ test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => {
|
||||
* **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」——
|
||||
* `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。
|
||||
*/
|
||||
const FB_MAIN = 'https://alotbuy.com/dshs-relay'
|
||||
const FB_ALT = 'https://relay-direct.alotbuy.com/dshs-relay'
|
||||
const FB_MAIN = 'https://ai1net.com/dshs-relay'
|
||||
const FB_ALT = 'https://relay-direct.ai1net.com/dshs-relay'
|
||||
|
||||
/** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */
|
||||
function twoEntryDoc(keys) {
|
||||
@@ -704,7 +704,7 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的
|
||||
const logs = []
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(String(url))
|
||||
if (String(url).startsWith('https://alotbuy.com/')) throw new Error('cf unreachable')
|
||||
if (String(url).startsWith('https://ai1net.com/')) throw new Error('cf unreachable')
|
||||
return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
}
|
||||
const r = await resolveOverlayRelay({
|
||||
@@ -717,14 +717,14 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的
|
||||
// ① 逐个 origin 试,主 origin 被拒后才到兜底
|
||||
assert.equal(calls.length, 2)
|
||||
assert.ok(
|
||||
logs.some((l) => l.includes('拒绝 https://alotbuy.com/dshs-overlay/bootstrap')),
|
||||
logs.some((l) => l.includes('拒绝 https://ai1net.com/dshs-overlay/bootstrap')),
|
||||
'缺"逐 origin 拒绝原因"这一行',
|
||||
)
|
||||
// ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据)
|
||||
assert.equal(r.source, 'seed-directory')
|
||||
assert.equal(r.url, 'wss://relay-direct.alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://relay-direct.ai1net.com/dshs-relay')
|
||||
assert.ok(
|
||||
logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.alotbuy.com/dshs-relay')),
|
||||
logs.some((l) => l.includes('同源优先') && l.includes('wss://relay-direct.ai1net.com/dshs-relay')),
|
||||
'缺"为什么走了兜底"这一行(可解释性)',
|
||||
)
|
||||
})
|
||||
@@ -742,6 +742,6 @@ test('序④·L1-G 主 origin 通时选择与今天逐字一致(relays[] 首
|
||||
fetchImpl,
|
||||
log: (l) => logs.push(l),
|
||||
})
|
||||
assert.equal(r.url, 'wss://alotbuy.com/dshs-relay')
|
||||
assert.equal(r.url, 'wss://ai1net.com/dshs-relay')
|
||||
assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志')
|
||||
})
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* 注册页「邮箱验证码 + 爆破防护」回归测试(档案 134)。
|
||||
*
|
||||
* 为什么这几条必须钉在测试里(而不是靠"上线上点一遍"):
|
||||
* · **冷却与配额是时间相关的** —— 手点是验不出边界的(第 5 次到底拦没拦、retryAfter 是不是
|
||||
* 正好等于剩余秒数),只有把 `now` 当入参才钉得住;
|
||||
* · **`sent` / `throttled` / `failed` 三种事件直接决定配额与可校验性** —— 记错一种就变成
|
||||
* "发信失败也允许校验"或"被限流不计入配额(于是可以无限重试)";
|
||||
* · **单次使用 + 试错作废** —— 猜码防护的全部价值就在这里。
|
||||
*
|
||||
* 全程用 **`log` 驱动 + 内存 SQLite**:不打网络、不发真邮件,但仍走完整的编排与落库路径
|
||||
* (唯一的例外是 `http` 驱动那两条 —— 它们打到本测试起的本地 HTTP 服务上,验证头/模板)。
|
||||
*/
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createServer } from 'node:http'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { SqliteAdapter } from '../lib/db/sqlite.js'
|
||||
import {
|
||||
DEFAULT_GUARD_POLICY,
|
||||
HOUR_MS,
|
||||
evaluateSendGuard,
|
||||
evaluateVerifyGuard,
|
||||
formatRetryAfter,
|
||||
} from '../lib/web/register-guard.js'
|
||||
import {
|
||||
codeMatches,
|
||||
consumeRegisterCode,
|
||||
generateCode,
|
||||
hashCode,
|
||||
isValidEmail,
|
||||
isValidUsername,
|
||||
normalizeEmail,
|
||||
requestRegisterCode,
|
||||
} from '../lib/web/email-code.js'
|
||||
import { renderVerificationMail, sendVerificationCodeMail } from '../lib/web/mail.js'
|
||||
import { turnstileEnabled, verifyTurnstile } from '../lib/web/turnstile.js'
|
||||
import { normalizeHostnames, resolveTurnstileHostnames } from '../lib/config.js'
|
||||
|
||||
const PEPPER = 'test-pepper'
|
||||
|
||||
/** 一份最小可用的配置(只填本模块真的会读的字段)。 */
|
||||
function makeConfig(overrides = {}) {
|
||||
return {
|
||||
encryptionSecret: PEPPER,
|
||||
emailCodeTtlMs: DEFAULT_GUARD_POLICY.codeTtlMs,
|
||||
emailCodeMaxAttempts: DEFAULT_GUARD_POLICY.maxAttemptsPerCode,
|
||||
emailCodeGuard: {
|
||||
emailPerHour: DEFAULT_GUARD_POLICY.emailPerHour,
|
||||
emailSentPerDay: DEFAULT_GUARD_POLICY.emailSentPerDay,
|
||||
ipPerHour: DEFAULT_GUARD_POLICY.ipPerHour,
|
||||
globalPerHour: DEFAULT_GUARD_POLICY.globalPerHour,
|
||||
cooldownLadderMs: [...DEFAULT_GUARD_POLICY.cooldownLadderMs],
|
||||
},
|
||||
mailDriver: 'log',
|
||||
mailApiUrl: '',
|
||||
mailApiKey: '',
|
||||
mailAuthHeader: '',
|
||||
mailFrom: '',
|
||||
mailFromName: '',
|
||||
mailBodyTemplate: '',
|
||||
mailTimeoutMs: 3000,
|
||||
registerRequireEmailCode: true,
|
||||
registerRequireCaptcha: false,
|
||||
turnstileSiteKey: '',
|
||||
turnstileSecret: '',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
/** 计数快照构造器:把 5 个数字包成 `evaluateSendGuard` 需要的形状。 */
|
||||
function counts(over = {}) {
|
||||
const zero = { emailTotal: 0, emailSent: 0, emailLastAt: 0, ipTotal: 0, globalTotal: 0 }
|
||||
return { hour: { ...zero, ...over }, day: { ...zero, ...over } }
|
||||
}
|
||||
|
||||
test('冷却阶梯:首次放行,紧接着再发被拦且 retryAfter = 剩余秒数', () => {
|
||||
const now = 1_700_000_000_000
|
||||
assert.deepEqual(evaluateSendGuard(counts(), now), { allowed: true, cooldownMs: 60_000 })
|
||||
|
||||
// 30 秒前发过一次 ⇒ 还差 30 秒
|
||||
const recent = evaluateSendGuard(counts({ emailTotal: 1, emailLastAt: now - 30_000 }), now)
|
||||
assert.equal(recent.allowed, false)
|
||||
assert.equal(recent.reason, 'email_cooldown')
|
||||
assert.equal(recent.retryAfterSeconds, 30)
|
||||
|
||||
// 冷却已过 ⇒ 放行
|
||||
assert.equal(evaluateSendGuard(counts({ emailTotal: 1, emailLastAt: now - 61_000 }), now).allowed, true)
|
||||
})
|
||||
|
||||
test('冷却阶梯随"一小时内已发起次数"递增(脚本化重试收益递减)', () => {
|
||||
const now = 1_700_000_000_000
|
||||
const ladder = DEFAULT_GUARD_POLICY.cooldownLadderMs
|
||||
// 索引 0…5 恰好覆盖全部 6 级(`emailPerHour` = 6 就是为了让最后一级可达)
|
||||
for (const n of [0, 1, 2, 3, 4, 5]) {
|
||||
const verdict = evaluateSendGuard(counts({ emailTotal: n, emailLastAt: 0 }), now)
|
||||
assert.equal(verdict.allowed, true, `hourCount=${n} 应放行`)
|
||||
assert.equal(verdict.cooldownMs, ladder[n], `hourCount=${n}`)
|
||||
}
|
||||
assert.equal(ladder.length, DEFAULT_GUARD_POLICY.emailPerHour, '每一级都必须可达(否则白写一级)')
|
||||
})
|
||||
|
||||
test('配额:每小时 6 次 / 每天 8 封 / IP 20 次 / 全局 200 次各自独挡一面', () => {
|
||||
const now = 1_700_000_000_000
|
||||
const hourQuota = evaluateSendGuard(counts({ emailTotal: DEFAULT_GUARD_POLICY.emailPerHour }), now)
|
||||
assert.equal(hourQuota.allowed, false)
|
||||
assert.equal(hourQuota.reason, 'email_hourly_quota')
|
||||
|
||||
const dayQuota = evaluateSendGuard(
|
||||
{ hour: { emailTotal: 0, emailSent: 0, emailLastAt: 0, ipTotal: 0, globalTotal: 0 },
|
||||
day: { emailTotal: 8, emailSent: 8, emailLastAt: now, ipTotal: 0, globalTotal: 0 } },
|
||||
now,
|
||||
)
|
||||
assert.equal(dayQuota.allowed, false)
|
||||
assert.equal(dayQuota.reason, 'email_daily_quota')
|
||||
|
||||
assert.equal(evaluateSendGuard(counts({ ipTotal: 20 }), now).reason, 'ip_hourly_quota')
|
||||
assert.equal(evaluateSendGuard(counts({ globalTotal: 200 }), now).reason, 'global_hourly_quota')
|
||||
})
|
||||
|
||||
test('校验前置判定:过期 / 已用 / 试错超限 三种不可用状态分得开', () => {
|
||||
const now = 1_700_000_000_000
|
||||
const row = (over) => ({
|
||||
id: 'x', email: '[email protected]', purpose: 'register', code_hash: 'h', status: 'sent', attempts: 0,
|
||||
ip: null, username: null, reason: null, created_at: now - 1000, expires_at: now + 1000, consumed_at: null,
|
||||
...over,
|
||||
})
|
||||
assert.equal(evaluateVerifyGuard(row({}), now), 'ok')
|
||||
assert.equal(evaluateVerifyGuard(row({ expires_at: now - 1 }), now), 'expired')
|
||||
assert.equal(evaluateVerifyGuard(row({ consumed_at: now - 1, attempts: 1 }), now), 'used')
|
||||
assert.equal(
|
||||
evaluateVerifyGuard(row({ consumed_at: now - 1, attempts: DEFAULT_GUARD_POLICY.maxAttemptsPerCode }), now),
|
||||
'too_many_attempts',
|
||||
)
|
||||
assert.equal(evaluateVerifyGuard(undefined, now), 'missing')
|
||||
})
|
||||
|
||||
test('验证码/邮箱/用户名 的取值口径', () => {
|
||||
const seen = new Set(Array.from({ length: 200 }, () => generateCode()))
|
||||
for (const code of seen) assert.match(code, /^\d{6}$/)
|
||||
assert.ok(seen.size > 150, '随机性抽样:200 次不应大量重复')
|
||||
|
||||
assert.equal(normalizeEmail(' [email protected] '), '[email protected]')
|
||||
assert.equal(isValidEmail('[email protected]'), true)
|
||||
assert.equal(isValidEmail('a@b'), false)
|
||||
assert.equal(isValidEmail('nope'), false)
|
||||
assert.equal(isValidUsername('ab'), false)
|
||||
assert.equal(isValidUsername('a_b-1'), true)
|
||||
|
||||
const hash = hashCode('[email protected]', 'register', '123456', PEPPER)
|
||||
assert.equal(hash.length, 64)
|
||||
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123456', PEPPER)), true)
|
||||
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123457', PEPPER)), false)
|
||||
assert.equal(codeMatches(hash, hashCode('[email protected]', 'register', '123456', 'other-pepper')), false)
|
||||
})
|
||||
|
||||
test('retryAfter 人话格式化', () => {
|
||||
assert.equal(formatRetryAfter(45), '45 秒')
|
||||
assert.equal(formatRetryAfter(120), '2 分钟')
|
||||
assert.equal(formatRetryAfter(7200), '2 小时')
|
||||
})
|
||||
|
||||
test('mail: 正文含验证码与有效期,且明确告知"非本人操作请忽略"', () => {
|
||||
const { subject, text } = renderVerificationMail({ to: '[email protected]', code: '123456', ttlMinutes: 10, brand: 'AI1NET' })
|
||||
assert.ok(subject.includes('123456'))
|
||||
assert.ok(subject.includes('AI1NET'))
|
||||
assert.ok(text.includes('123456'))
|
||||
assert.ok(text.includes('10'))
|
||||
assert.ok(text.includes('ignore this e-mail'))
|
||||
|
||||
// 站点名缺失时**不得**回落到平台内部名(邮件是给终端用户看的)
|
||||
const plain = renderVerificationMail({ to: '[email protected]', code: '654321', ttlMinutes: 10 })
|
||||
assert.equal(plain.subject, '654321 is your verification code')
|
||||
assert.ok(!/DSH|dshs/i.test(plain.subject + plain.text), '不得出现平台内部名')
|
||||
assert.ok(plain.text.includes('654321'))
|
||||
})
|
||||
|
||||
test('mail: log 驱动不发信但算成功;未配置的通道直接判失败(不静默吞掉)', async () => {
|
||||
const settings = (over) => ({
|
||||
driver: 'log', apiUrl: '', apiKey: '', authHeader: '', from: '', fromName: '', bodyTemplate: '', timeoutMs: 1000,
|
||||
...over,
|
||||
})
|
||||
assert.equal((await sendVerificationCodeMail(settings({}), { to: '[email protected]', code: '1', ttlMinutes: 10 })).ok, true)
|
||||
|
||||
const unconfigured = await sendVerificationCodeMail(
|
||||
settings({ driver: 'brevo', from: '' }),
|
||||
{ to: '[email protected]', code: '1', ttlMinutes: 10 },
|
||||
)
|
||||
assert.equal(unconfigured.ok, false)
|
||||
assert.equal(unconfigured.error, 'mail_not_configured')
|
||||
})
|
||||
|
||||
test('mail: http 驱动把 URL / 鉴权头 / body 模板交给配置(换供应商不用改代码)', async () => {
|
||||
const seen = []
|
||||
const server = createServer((req, res) => {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => { body += chunk })
|
||||
req.on('end', () => {
|
||||
seen.push({ url: req.url, headers: req.headers, body })
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end('{"ok":true}')
|
||||
})
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = server.address().port
|
||||
try {
|
||||
const result = await sendVerificationCodeMail(
|
||||
{
|
||||
driver: 'http',
|
||||
apiUrl: `http://127.0.0.1:${port}/send`,
|
||||
apiKey: 'sekret',
|
||||
authHeader: 'x-api-key',
|
||||
from: '[email protected]',
|
||||
fromName: 'AI1NET',
|
||||
bodyTemplate: '{"to":"{{to}}","subject":"{{subject}}","payload":{"code":"{{code}}"}}',
|
||||
timeoutMs: 3000,
|
||||
},
|
||||
{ to: '[email protected]', code: '987654', ttlMinutes: 10 },
|
||||
)
|
||||
assert.equal(result.ok, true)
|
||||
assert.equal(seen.length, 1)
|
||||
assert.equal(seen[0].headers['x-api-key'], 'sekret')
|
||||
const payload = JSON.parse(seen[0].body)
|
||||
assert.equal(payload.to, '[email protected]')
|
||||
assert.equal(payload.payload.code, '987654')
|
||||
assert.ok(payload.subject.includes('987654'))
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
test('mail: 上游 5xx ⇒ 明确失败(不重试、不假装成功)', async () => {
|
||||
const server = createServer((req, res) => { res.writeHead(502); res.end('bad gateway') })
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = server.address().port
|
||||
try {
|
||||
const result = await sendVerificationCodeMail(
|
||||
{
|
||||
driver: 'http', apiUrl: `http://127.0.0.1:${port}/send`, apiKey: '', authHeader: '',
|
||||
from: '[email protected]', fromName: '', bodyTemplate: '', timeoutMs: 3000,
|
||||
},
|
||||
{ to: '[email protected]', code: '1', ttlMinutes: 10 },
|
||||
)
|
||||
assert.equal(result.ok, false)
|
||||
assert.equal(result.status, 502)
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
test('turnstile: 只填一把钥匙视为未配置(半配置不许当"已启用")', () => {
|
||||
const base = { siteKey: 'k', secret: 's', action: 'signup', hostnames: ['ai1net.com'], timeoutMs: 1000 }
|
||||
assert.equal(turnstileEnabled(base), true)
|
||||
assert.equal(turnstileEnabled({ ...base, secret: '' }), false)
|
||||
assert.equal(turnstileEnabled({ ...base, siteKey: '' }), false)
|
||||
// 🔴 主机名白名单为空 ⇒ **视为未配置完成**(否则 sitekey 泄露后可在任意站点伪造挑战)
|
||||
assert.equal(turnstileEnabled({ ...base, hostnames: [] }), false)
|
||||
})
|
||||
|
||||
test('turnstile: success 之后仍校 action 与 hostname(官方 canonical 三项)', async () => {
|
||||
// 本地假 siteverify:按 path 决定回什么,从而逐组合断言判定逻辑
|
||||
const cases = {
|
||||
'/ok': { success: true, action: 'signup', hostname: 'ai1net.com' },
|
||||
'/badaction': { success: true, action: 'login', hostname: 'ai1net.com' },
|
||||
'/badhost': { success: true, action: 'signup', hostname: 'evil.example' },
|
||||
'/nohost': { success: true, action: 'signup' },
|
||||
'/fail': { success: false, 'error-codes': ['invalid-input-response'] },
|
||||
}
|
||||
let hits = 0
|
||||
const server = createServer((req, res) => {
|
||||
hits += 1
|
||||
const body = cases[req.url] ?? { success: false, 'error-codes': ['bad-request'] }
|
||||
res.writeHead(200, { 'content-type': 'application/json' })
|
||||
res.end(JSON.stringify(body))
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const port = server.address().port
|
||||
const settings = (path) => ({
|
||||
siteKey: 'k', secret: 's', timeoutMs: 3000, action: 'signup',
|
||||
hostnames: ['ai1net.com', 'www.ai1net.com'],
|
||||
verifyUrl: `http://127.0.0.1:${port}${path}`,
|
||||
})
|
||||
try {
|
||||
assert.equal((await verifyTurnstile(settings('/ok'), 'tok')).ok, true, '三项齐备 ⇒ 放行')
|
||||
assert.equal((await verifyTurnstile(settings('/badaction'), 'tok')).error, 'action_mismatch: login')
|
||||
assert.equal((await verifyTurnstile(settings('/badhost'), 'tok')).error, 'hostname_mismatch: evil.example')
|
||||
assert.equal((await verifyTurnstile(settings('/nohost'), 'tok')).error, 'hostname_mismatch: (none)')
|
||||
assert.equal((await verifyTurnstile(settings('/fail'), 'tok')).error, 'invalid-input-response')
|
||||
|
||||
// 空 token / 超长 token:**本地就拒**,不浪费一次上游往返
|
||||
const before = hits
|
||||
assert.equal((await verifyTurnstile(settings('/ok'), '')).error, 'missing-input-response')
|
||||
assert.equal((await verifyTurnstile(settings('/ok'), 'x'.repeat(2049))).error, 'invalid-input-response')
|
||||
assert.equal(hits, before, '本地拒绝不应打到 siteverify')
|
||||
|
||||
// 缺 secret ⇒ not_configured(不请求上游)
|
||||
assert.equal((await verifyTurnstile({ ...settings('/ok'), secret: '' }, 'tok')).error, 'not_configured')
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve))
|
||||
}
|
||||
})
|
||||
|
||||
test('config: 期望主机名归一 + 派生 + ⛔ 绝不自动加 localhost', () => {
|
||||
assert.deepEqual(normalizeHostnames(['https://AI1net.com/', 'www.ai1net.com:443', ' ai1net.com ']), [
|
||||
'ai1net.com', 'www.ai1net.com',
|
||||
])
|
||||
// 未配(undefined)⇒ 从 baseDomain 派生
|
||||
assert.deepEqual(resolveTurnstileHostnames(undefined, 'ai1net.com'), ['ai1net.com', 'www.ai1net.com'])
|
||||
// 显式配 ⇒ 只用配的(可加旧域,旧域门户仍在线)
|
||||
assert.deepEqual(
|
||||
resolveTurnstileHostnames(['ai1net.com', 'alotbuy.com'], 'ai1net.com'),
|
||||
['ai1net.com', 'alotbuy.com'],
|
||||
)
|
||||
// 显式空 ⇒ 关闭(不派生)
|
||||
assert.deepEqual(resolveTurnstileHostnames([], 'ai1net.com'), [])
|
||||
// baseDomain 空 ⇒ 空(= 未配置完成 ⇒ 人机验证停用,不会静默放开)
|
||||
assert.deepEqual(resolveTurnstileHostnames(undefined, ''), [])
|
||||
assert.equal(normalizeHostnames(['localhost']).includes('ai1net.com'), false)
|
||||
})
|
||||
|
||||
/* ── 编排层:真库 + log 驱动 ─────────────────────────────────────────────── */
|
||||
|
||||
/** 直接插一条"可校验"的事件行(发码走真实路径时验证码只在邮件里,测试需要已知码)。 */
|
||||
async function seedCode(db, config, { email, username, code, createdAt = Date.now(), expiresAt }) {
|
||||
await db.recordEmailCode({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
purpose: 'register',
|
||||
codeHash: hashCode(email, 'register', code, config.encryptionSecret),
|
||||
status: 'sent',
|
||||
ip: '203.0.113.9',
|
||||
username,
|
||||
reason: null,
|
||||
createdAt,
|
||||
expiresAt: expiresAt ?? createdAt + config.emailCodeTtlMs,
|
||||
})
|
||||
}
|
||||
|
||||
test('编排:发码成功落 sent,冷却期内再发落 throttled 并回 429', async () => {
|
||||
const db = new SqliteAdapter(':memory:', 100000)
|
||||
const config = makeConfig()
|
||||
const deps = { db, config }
|
||||
try {
|
||||
const first = await requestRegisterCode(deps, { email: '[email protected]', username: 'newbie', ip: '198.51.100.7' })
|
||||
assert.equal(first.ok, true)
|
||||
assert.equal(first.retryAfterSeconds, 60)
|
||||
|
||||
const latest = await db.latestSentEmailCode('[email protected]', 'register')
|
||||
assert.ok(latest, '应留下一条可校验的 sent 行')
|
||||
assert.equal(latest.status, 'sent')
|
||||
assert.equal(latest.username, 'newbie')
|
||||
assert.equal(latest.code_hash?.length, 64, '只存哈希、不存明文')
|
||||
|
||||
const second = await requestRegisterCode(deps, { email: '[email protected]', username: 'newbie', ip: '198.51.100.7' })
|
||||
assert.equal(second.ok, false)
|
||||
assert.equal(second.status, 429)
|
||||
assert.equal(second.error, 'email_cooldown')
|
||||
assert.ok(second.retryAfterSeconds > 0 && second.retryAfterSeconds <= 60)
|
||||
|
||||
const hour = await db.emailCodeCounts('[email protected]', '198.51.100.7', Date.now() - HOUR_MS)
|
||||
assert.equal(hour.emailTotal, 2, '被拒的那次也必须计入配额')
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('编排:用户名/邮箱占用与格式错误在发码阶段就被挡(不浪费配额)', async () => {
|
||||
const db = new SqliteAdapter(':memory:', 100000)
|
||||
const config = makeConfig()
|
||||
const deps = { db, config }
|
||||
try {
|
||||
await db.createUser({ id: 'u1', username: 'taken', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' })
|
||||
|
||||
const takenName = await requestRegisterCode(deps, { email: '[email protected]', username: 'taken', ip: null })
|
||||
assert.equal(takenName.error, 'username_taken')
|
||||
assert.equal(takenName.status, 409)
|
||||
|
||||
const takenMail = await requestRegisterCode(deps, { email: '[email protected]', username: 'fresh', ip: null })
|
||||
assert.equal(takenMail.error, 'email_taken')
|
||||
assert.equal(takenMail.status, 409)
|
||||
|
||||
assert.equal((await requestRegisterCode(deps, { email: 'bad', username: 'fresh', ip: null })).error, 'invalid_email')
|
||||
assert.equal((await requestRegisterCode(deps, { email: '[email protected]', username: 'x', ip: null })).error, 'invalid_username')
|
||||
|
||||
const countsAfter = await db.emailCodeCounts('[email protected]', null, Date.now() - HOUR_MS)
|
||||
assert.equal(countsAfter.emailTotal, 0, '被前置挡掉的请求不应占用配额')
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('编排:试错递增、达上限立即作废;正确码单次使用', async () => {
|
||||
const db = new SqliteAdapter(':memory:', 100000)
|
||||
const config = makeConfig()
|
||||
const deps = { db, config }
|
||||
try {
|
||||
await seedCode(db, config, { email: '[email protected]', username: 'carl', code: '111111' })
|
||||
|
||||
const first = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
|
||||
assert.equal(first.error, 'code_invalid')
|
||||
assert.equal(first.attemptsLeft, config.emailCodeMaxAttempts - 1)
|
||||
|
||||
for (let i = 1; i < config.emailCodeMaxAttempts; i += 1) {
|
||||
await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
|
||||
}
|
||||
// 第 5 次错 ⇒ 直接作废
|
||||
const exhausted = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '000000', ip: null })
|
||||
assert.equal(exhausted.error, 'code_attempts_exceeded')
|
||||
// 此时**连正确码也不认**(必须重新获取)
|
||||
const afterExhaust = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '111111', ip: null })
|
||||
assert.equal(afterExhaust.error, 'code_attempts_exceeded')
|
||||
|
||||
// 新码:一次成功、二次被拒(单次使用)
|
||||
await seedCode(db, config, { email: '[email protected]', username: 'carl', code: '222222' })
|
||||
const ok = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '222222', ip: null })
|
||||
assert.equal(ok.ok, true)
|
||||
const reuse = await consumeRegisterCode(deps, { email: '[email protected]', username: 'carl', code: '222222', ip: null })
|
||||
assert.equal(reuse.error, 'code_used')
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('编排:过期 / 用户名不匹配 / 大小写无关', async () => {
|
||||
const db = new SqliteAdapter(':memory:', 100000)
|
||||
const config = makeConfig()
|
||||
const deps = { db, config }
|
||||
try {
|
||||
await seedCode(db, config, {
|
||||
email: '[email protected]', username: 'dora', code: '333333',
|
||||
createdAt: Date.now() - 20 * 60 * 1000, expiresAt: Date.now() - 10 * 60 * 1000,
|
||||
})
|
||||
assert.equal(
|
||||
(await consumeRegisterCode(deps, { email: '[email protected]', username: 'dora', code: '333333', ip: null })).error,
|
||||
'code_expired',
|
||||
)
|
||||
|
||||
await seedCode(db, config, { email: '[email protected]', username: 'erin', code: '444444' })
|
||||
assert.equal(
|
||||
(await consumeRegisterCode(deps, { email: '[email protected]', username: 'someone', code: '444444', ip: null })).error,
|
||||
'code_username_mismatch',
|
||||
)
|
||||
// 大小写不敏感 + 邮箱归一化
|
||||
const ok = await consumeRegisterCode(deps, { email: '[email protected]', username: 'ERIN', code: '444444', ip: null })
|
||||
assert.equal(ok.ok, true)
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('v10 迁移:users.email 大小写不敏感唯一 + findUserByEmail', async () => {
|
||||
const db = new SqliteAdapter(':memory:', 100000)
|
||||
try {
|
||||
await db.createUser({ id: 'a', username: 'alice', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' })
|
||||
const found = await db.findUserByEmail('[email protected]')
|
||||
assert.equal(found?.id, 'a')
|
||||
assert.equal(found?.email, '[email protected]')
|
||||
assert.equal(await db.findUserByEmail('[email protected]'), undefined)
|
||||
|
||||
await assert.rejects(() =>
|
||||
db.createUser({ id: 'b', username: 'bob', passHash: 'x', role: 'active', homeDir: '/h', email: '[email protected]' }),
|
||||
)
|
||||
// email 可选(老路径 / k8s 路径不受影响)
|
||||
await db.createUser({ id: 'c', username: 'carol', passHash: 'x', role: 'active', homeDir: '/h' })
|
||||
assert.equal((await db.findUserById('c'))?.email, null)
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
})
|
||||
|
||||
test('v10:事件表自维护(purge 只清旧行)', async () => {
|
||||
const db = new SqliteAdapter(':memory:', 100000)
|
||||
const now = Date.now()
|
||||
try {
|
||||
await db.recordEmailCode({
|
||||
id: 'old', email: '[email protected]', purpose: 'register', codeHash: null, status: 'throttled',
|
||||
createdAt: now - 40 * 24 * 60 * 60 * 1000,
|
||||
})
|
||||
await db.recordEmailCode({
|
||||
id: 'new', email: '[email protected]', purpose: 'register', codeHash: null, status: 'throttled', createdAt: now,
|
||||
})
|
||||
const removed = await db.purgeEmailCodes(now - 30 * 24 * 60 * 60 * 1000)
|
||||
assert.equal(removed, 1)
|
||||
const left = await db.emailCodeCounts('[email protected]', null, 0)
|
||||
assert.equal(left.emailTotal, 1)
|
||||
} finally {
|
||||
await db.close()
|
||||
}
|
||||
})
|
||||
@@ -488,7 +488,7 @@ const TH8 = { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_
|
||||
/**
|
||||
* F12 · **目录路径没有豁免权**(E1 / D1)。
|
||||
*
|
||||
* 立项依据:真机 11:43:26 实测 `wss://106… -> wss://alotbuy.com…` —— 只因"目录里的地址变了"
|
||||
* 立项依据:真机 11:43:26 实测 `wss://106… -> wss://ai1net.com…` —— 只因"目录里的地址变了"
|
||||
* 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。
|
||||
*/
|
||||
test('F12 目录路径无豁免权:origin=directory + 目标在冷却 ⇒ 必 skip(E1 / D1)', async () => {
|
||||
@@ -858,8 +858,8 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts
|
||||
const obs = new RelayCandidateObservation('manager', (l) => lines.push(l), 0)
|
||||
obs.record(
|
||||
[
|
||||
'wss://alotbuy.com/dshs-relay',
|
||||
'https://alotbuy.com/other',
|
||||
'wss://ai1net.com/dshs-relay',
|
||||
'https://ai1net.com/other',
|
||||
'wss://106.54.21.172/dshs-relay',
|
||||
],
|
||||
'cache',
|
||||
@@ -876,7 +876,7 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts
|
||||
)
|
||||
const snap = obs.snapshot()
|
||||
assert.equal(snap.count, 3, 'count = 候选**条数**(⛔ 不按主机去重)')
|
||||
assert.equal(snap.hosts, 2, 'hosts = 独立主机数(alotbuy.com 的两条算同一台 —— scheme 不参与)')
|
||||
assert.equal(snap.hosts, 2, 'hosts = 独立主机数(ai1net.com 的两条算同一台 —— scheme 不参与)')
|
||||
assert.equal(snap.source, 'cache')
|
||||
assert.equal(snap.resolves, 1)
|
||||
assert.equal(snap.unresolved, false)
|
||||
|
||||
+2
-3
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>管理台</title>
|
||||
<link rel="icon" href="/logo.svg" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/design.css" />
|
||||
</head>
|
||||
<body class="auth-bg">
|
||||
@@ -26,8 +26,7 @@
|
||||
|
||||
<div class="auth-card" style="max-width: 720px">
|
||||
<div class="auth-brand">
|
||||
<img class="logo" src="/logo.svg" alt="logo" />
|
||||
<span class="wordmark"><svg viewBox="0 0 131 24" fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M117.986 0h-3.21v19.404h3.21V0zM8.7 5.215h1.83v2.838H8.7c-1.135 0-2.28.282-3.019 1.068-.738.785-1.016 1.99-1.016 3.194s.267 2.408 1.016 3.193c.75.786 1.884 1.068 3.018 1.068 1.135 0 2.28-.282 3.018-1.068.739-.785 1.017-1.99 1.017-3.193V.649h3.21v18.755h-3.21V18.21h-.589a3.498 3.498 0 01-.192.199c-.803.733-2.034.995-3.243.995-1.894 0-3.788-.472-5.03-1.78C2.44 16.314 2 14.303 2 12.303c0-2 .45-4 1.68-5.32 1.242-1.308 3.136-1.77 5.02-1.77zM57.564 18.9h-1.83v-2.837h1.83c1.134 0 2.28-.283 3.017-1.068.739-.785 1.017-1.99 1.017-3.194s-.267-2.408-1.017-3.194c-.749-.785-1.883-1.068-3.017-1.068s-2.28.283-3.018 1.068c-.738.786-1.017 1.99-1.017 3.194v11.655h-3.21V4.712h3.21v1.194h.59a3.42 3.42 0 01.186-.194l.005-.005c.803-.733 2.034-.995 3.243-.995 1.895 0 3.788.471 5.03 1.78 1.241 1.31 1.68 3.32 1.68 5.32 0 2-.45 4-1.68 5.319-1.23 1.32-3.135 1.77-5.019 1.77zM32.05 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.264-1.843-5.223-1.843-1.958 0-3.928.492-5.222 1.843-1.295 1.35-1.755 3.434-1.755 5.497 0 2.063.47 4.136 1.755 5.498 1.284 1.36 3.264 1.843 5.222 1.843 1.959 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168a5.026 5.026 0 01-.3.367c-.77.816-1.958 1.11-3.135 1.11-1.177 0-2.365-.304-3.136-1.11-.77-.807-1.048-2.063-1.048-3.299 0-1.236.278-2.482 1.049-3.298.77-.817 1.958-1.11 3.135-1.11 1.177 0 2.365.293 3.136 1.11.535.565.834 1.34.963 2.167H23.5v2.272h8.55zM48.168 12.063v1.141h-8.55v-2.272h5.671c-.129-.827-.428-1.602-.963-2.167-.77-.817-1.959-1.11-3.136-1.11s-2.365.293-3.136 1.11c-.77.816-1.049 2.063-1.049 3.298 0 1.236.279 2.492 1.05 3.299.77.806 1.958 1.11 3.135 1.11 1.177 0 2.365-.294 3.136-1.11.107-.116.203-.241.299-.367h3.168c-.278.985-.717 1.874-1.38 2.566-1.285 1.35-3.264 1.843-5.223 1.843s-3.938-.482-5.222-1.843c-1.285-1.362-1.756-3.435-1.756-5.498s.46-4.147 1.755-5.497c1.296-1.351 3.264-1.843 5.223-1.843s3.938.481 5.222 1.843c1.296 1.36 1.756 3.434 1.756 5.497zM78.635 18.315c-1.284.806-3.263 1.089-5.222 1.089-1.958 0-3.917-.294-5.212-1.09-1.295-.795-1.755-2.03-1.755-3.246h3.767c0 .472.225.953.824 1.257.6.304 1.54.419 2.462.419.92 0 1.851-.115 2.46-.42.611-.303.825-.784.825-1.256 0-.47-.214-.952-.824-1.256-.61-.304-1.627-.419-2.547-.419-1.777 0-3.563-.293-4.73-1.09-1.167-.795-1.584-2.03-1.584-3.245 0-1.215.417-2.44 1.584-3.246 1.167-.807 2.953-1.09 4.73-1.09 1.776 0 3.564.294 4.73 1.09 1.167.795 1.584 2.031 1.584 3.246h-3.264c0-.471-.203-.942-.749-1.257-.546-.303-1.391-.419-2.226-.419s-1.68.105-2.226.42c-.556.303-.75.785-.75 1.256 0 .47.204.942.75 1.256.545.304 1.316.42 2.151.42 1.959 0 3.938.292 5.222 1.088 1.295.796 1.756 2.032 1.756 3.246 0 1.215-.471 2.44-1.756 3.247zM96.507 12.063v1.141h-8.55v-2.272h5.672c-.129-.827-.429-1.602-.963-2.167-.771-.817-1.959-1.11-3.136-1.11s-2.366.293-3.136 1.11c-.77.816-1.048 2.063-1.048 3.298 0 1.236.278 2.492 1.048 3.299.77.806 1.959 1.11 3.135 1.11 1.178 0 2.366-.294 3.137-1.11.106-.116.203-.241.3-.367h3.167c-.279.985-.717 1.874-1.38 2.566-1.284 1.35-3.265 1.843-5.224 1.843-1.957 0-3.938-.482-5.222-1.843-1.284-1.362-1.754-3.435-1.754-5.498s.46-4.147 1.754-5.497c1.296-1.351 3.265-1.843 5.222-1.843 1.96 0 3.94.481 5.224 1.843 1.294 1.36 1.754 3.434 1.754 5.497zM112.624 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.265-1.843-5.223-1.843-1.959 0-3.928.492-5.222 1.843-1.296 1.35-1.756 3.434-1.756 5.497 0 2.063.471 4.136 1.756 5.498 1.284 1.36 3.263 1.843 5.222 1.843 1.958 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168l-.012.016c-.093.12-.185.24-.288.35-.77.817-1.957 1.11-3.135 1.11-1.177 0-2.365-.303-3.136-1.11-.77-.806-1.049-2.062-1.049-3.298 0-1.236.279-2.482 1.049-3.298.771-.817 1.959-1.11 3.136-1.11 1.178 0 2.365.293 3.135 1.11.536.565.836 1.34.964 2.167h-5.672v2.272h8.55zM128.73 19.404l-5.264-7.78 5.264-6.252h-3.97l-5.265 6.251 5.265 7.78h3.97z"/></svg></span>
|
||||
<span class="wordmark">能力枢纽</span>
|
||||
</div>
|
||||
<p class="auth-sub" id="sub">仅限管理员</p>
|
||||
|
||||
|
||||
+5
-1
@@ -49,7 +49,11 @@ body {
|
||||
.auth-brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.2px; }
|
||||
.wordmark { display: inline-flex; align-items: center; }
|
||||
.wordmark svg { height: 100%; width: auto; display: block; }
|
||||
.auth-brand .wordmark { height: 22px; color: var(--ink); }
|
||||
/* 2026-09-19:登录 / 注册页的品牌标识由 **DeepSeek 图形**改为**本平台文字**
|
||||
(中文「能力枢纽」/ 英文及其他语言 CapabilityNet,走 i18n 词条 `brand.name`)。
|
||||
上面那套定高 + svg 的规则对文字不适用 ⇒ 这里补字号 / 字重 / 行高(`height:auto` 覆盖旧定高)。
|
||||
⚠️ `.topbar .wordmark`(admin / portal 顶栏)**未动** —— 那两处仍用 svg,用户本轮只要求登录 / 注册页。 */
|
||||
.auth-brand .wordmark { height: auto; font-size: 19px; font-weight: 600; line-height: 1.2; letter-spacing: 0.2px; color: var(--ink); }
|
||||
.topbar .wordmark { height: 20px; color: #dbe5ff; }
|
||||
.auth-sub { color: var(--ink-soft); font-size: 13px; margin: 6px 0 22px; }
|
||||
.field { margin-bottom: 14px; }
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" role="img" aria-label="能力枢纽">
|
||||
<title>能力枢纽</title>
|
||||
<!-- 平台自有品牌图标(2026-09-19,档案 137)。
|
||||
取「枢纽 / hub」意象:中心节点 + 三条辐条 + 三个外环节点。
|
||||
配色取自平台自己的 token:ink #0f1c33 作底、accent-2 #38d6d0 作节点。
|
||||
刻意避开原 DeepSeek 的品牌蓝 #4D6BFE,避免"看起来还是别人家的图标"。
|
||||
同时兼作 favicon 与 portal 顶栏图标(.logo 的 border-radius 与 rx 对齐)。
|
||||
⚠️ 本注释内不得出现两个连续 ASCII 连字符:XML 注释语法不允许,会导致整份 SVG 解析失败
|
||||
(即浏览器静默不显示图标)。CSS 变量写法(双连字符加名字)正是最容易踩的形态。 -->
|
||||
<rect width="32" height="32" rx="8" fill="#0f1c33"/>
|
||||
<g stroke="#38d6d0" stroke-width="2" stroke-linecap="round">
|
||||
<path d="M16 16V8"/>
|
||||
<path d="M16 16l7 4.5"/>
|
||||
<path d="M16 16l-7 4.5"/>
|
||||
</g>
|
||||
<circle cx="16" cy="16" r="3.5" fill="#ffffff"/>
|
||||
<circle cx="16" cy="8" r="2.5" fill="#38d6d0"/>
|
||||
<circle cx="23" cy="20.5" r="2.5" fill="#38d6d0"/>
|
||||
<circle cx="9" cy="20.5" r="2.5" fill="#38d6d0"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.2 KiB |
+58
@@ -37,6 +37,11 @@
|
||||
'common.hidePw': 'Hide password',
|
||||
'common.retry': 'Retry',
|
||||
|
||||
// 品牌名(2026-09-19):登录 / 注册页的品牌标识由 DeepSeek 图形改为**本平台文字标识**。
|
||||
// ⚠️ 走词条而不是写死在 HTML 里:英文/其他语言 = CapabilityNet(en 也是**其他语言的回退**,
|
||||
// `lookup()` 缺 key 时正是回退到 en),中文 = 能力枢纽。
|
||||
'brand.name': 'CapabilityNet',
|
||||
|
||||
'index.title': 'Workspace',
|
||||
|
||||
'login.title': 'Sign in',
|
||||
@@ -67,6 +72,31 @@
|
||||
'register.errTaken': 'That username is already taken',
|
||||
'register.errFailed': 'Sign-up failed',
|
||||
'register.ok': 'Signed up. Please wait for admin approval before signing in.',
|
||||
// 档案 134:邮箱验证码 + 人机验证
|
||||
'register.email': 'E-mail',
|
||||
'register.emailPh': '[email protected]',
|
||||
'register.code': 'E-mail verification code',
|
||||
'register.codePh': '6-digit code',
|
||||
'register.sendCode': 'Send code',
|
||||
'register.sendingCode': 'Sending…',
|
||||
'register.resendIn': 'Resend in {n}s',
|
||||
'register.codeSent': 'Code sent — check your inbox (and the spam folder).',
|
||||
'register.captcha': 'Human verification',
|
||||
'register.captchaLoading': 'Loading…',
|
||||
'register.errEmail': 'Enter a valid e-mail address',
|
||||
'register.errEmailTaken': 'That e-mail is already registered',
|
||||
'register.errEmailRequired': 'E-mail and verification code are required',
|
||||
'register.errCode': 'Invalid or expired code',
|
||||
'register.errCodeExhausted': 'Too many attempts — please request a new code',
|
||||
'register.errCooldown': 'Too many requests — please wait {n}s',
|
||||
'register.errMail': 'Could not send the e-mail — please try again later',
|
||||
'register.errCaptcha': 'Human verification failed — please retry',
|
||||
'register.errCaptchaLoad': 'Human verification could not load. Disable ad-blockers or switch network, then reload.',
|
||||
'register.errCaptchaToken': 'Finish the human verification first',
|
||||
'register.errUnavailable': 'E-mail verification is unavailable right now',
|
||||
'register.errUsername': 'Username must be 3–32 letters / digits / _ / -',
|
||||
'register.errPassword': 'Password must be at least 8 characters',
|
||||
'register.errBusy': 'Please wait a moment and try again',
|
||||
|
||||
'wake.title': 'Starting your workspace',
|
||||
'wake.h1': 'Starting your workspace…',
|
||||
@@ -92,6 +122,9 @@
|
||||
'common.hidePw': '隐藏密码',
|
||||
'common.retry': '重试',
|
||||
|
||||
// 品牌名(2026-09-19):中文界面显示中文名;其余语言由 en 词条(CapabilityNet)兜底。
|
||||
'brand.name': '能力枢纽',
|
||||
|
||||
'index.title': '工作台',
|
||||
|
||||
'login.title': '登录',
|
||||
@@ -122,6 +155,31 @@
|
||||
'register.errTaken': '用户名已被占用',
|
||||
'register.errFailed': '注册失败',
|
||||
'register.ok': '注册成功,请等待管理员审核后登录。',
|
||||
// 档案 134:邮箱验证码 + 人机验证
|
||||
'register.email': '邮箱',
|
||||
'register.emailPh': '[email protected]',
|
||||
'register.code': '邮箱验证码',
|
||||
'register.codePh': '6 位数字验证码',
|
||||
'register.sendCode': '获取验证码',
|
||||
'register.sendingCode': '发送中…',
|
||||
'register.resendIn': '{n} 秒后可重发',
|
||||
'register.codeSent': '验证码已发送,请查收邮件(含垃圾箱)。',
|
||||
'register.captcha': '人机验证',
|
||||
'register.captchaLoading': '加载中…',
|
||||
'register.errEmail': '请输入有效的邮箱地址',
|
||||
'register.errEmailTaken': '该邮箱已被注册',
|
||||
'register.errEmailRequired': '请填写邮箱与验证码',
|
||||
'register.errCode': '验证码错误或已过期',
|
||||
'register.errCodeExhausted': '尝试次数过多,请重新获取验证码',
|
||||
'register.errCooldown': '请求过于频繁,请等 {n} 秒后再试',
|
||||
'register.errMail': '邮件发送失败,请稍后再试',
|
||||
'register.errCaptcha': '人机验证未通过,请重试',
|
||||
'register.errCaptchaLoad': '人机验证加载失败:请关闭广告拦截插件或更换网络后刷新页面。',
|
||||
'register.errCaptchaToken': '请先完成人机验证',
|
||||
'register.errUnavailable': '邮箱验证当前不可用',
|
||||
'register.errUsername': '用户名需为 3–32 位字母 / 数字 / _ / -',
|
||||
'register.errPassword': '密码至少 8 位',
|
||||
'register.errBusy': '请稍候再试',
|
||||
|
||||
'wake.title': '正在启动工作区',
|
||||
'wake.h1': '正在启动你的工作区…',
|
||||
|
||||
+2
-3
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title data-i18n="login.title">Sign in</title>
|
||||
<link rel="icon" href="/logo.svg" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/design.css" />
|
||||
<link rel="stylesheet" href="/auth-ui.css" />
|
||||
<!-- 档案 81 · R5:平台多语言运行时(词条表 + data-i18n 就地替换;默认英语) -->
|
||||
@@ -28,8 +28,7 @@
|
||||
<body class="auth-bg">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand">
|
||||
<img class="logo" src="/logo.svg" alt="logo" />
|
||||
<span class="wordmark"><svg viewBox="0 0 131 24" fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M117.986 0h-3.21v19.404h3.21V0zM8.7 5.215h1.83v2.838H8.7c-1.135 0-2.28.282-3.019 1.068-.738.785-1.016 1.99-1.016 3.194s.267 2.408 1.016 3.193c.75.786 1.884 1.068 3.018 1.068 1.135 0 2.28-.282 3.018-1.068.739-.785 1.017-1.99 1.017-3.193V.649h3.21v18.755h-3.21V18.21h-.589a3.498 3.498 0 01-.192.199c-.803.733-2.034.995-3.243.995-1.894 0-3.788-.472-5.03-1.78C2.44 16.314 2 14.303 2 12.303c0-2 .45-4 1.68-5.32 1.242-1.308 3.136-1.77 5.02-1.77zM57.564 18.9h-1.83v-2.837h1.83c1.134 0 2.28-.283 3.017-1.068.739-.785 1.017-1.99 1.017-3.194s-.267-2.408-1.017-3.194c-.749-.785-1.883-1.068-3.017-1.068s-2.28.283-3.018 1.068c-.738.786-1.017 1.99-1.017 3.194v11.655h-3.21V4.712h3.21v1.194h.59a3.42 3.42 0 01.186-.194l.005-.005c.803-.733 2.034-.995 3.243-.995 1.895 0 3.788.471 5.03 1.78 1.241 1.31 1.68 3.32 1.68 5.32 0 2-.45 4-1.68 5.319-1.23 1.32-3.135 1.77-5.019 1.77zM32.05 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.264-1.843-5.223-1.843-1.958 0-3.928.492-5.222 1.843-1.295 1.35-1.755 3.434-1.755 5.497 0 2.063.47 4.136 1.755 5.498 1.284 1.36 3.264 1.843 5.222 1.843 1.959 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168a5.026 5.026 0 01-.3.367c-.77.816-1.958 1.11-3.135 1.11-1.177 0-2.365-.304-3.136-1.11-.77-.807-1.048-2.063-1.048-3.299 0-1.236.278-2.482 1.049-3.298.77-.817 1.958-1.11 3.135-1.11 1.177 0 2.365.293 3.136 1.11.535.565.834 1.34.963 2.167H23.5v2.272h8.55zM48.168 12.063v1.141h-8.55v-2.272h5.671c-.129-.827-.428-1.602-.963-2.167-.77-.817-1.959-1.11-3.136-1.11s-2.365.293-3.136 1.11c-.77.816-1.049 2.063-1.049 3.298 0 1.236.279 2.492 1.05 3.299.77.806 1.958 1.11 3.135 1.11 1.177 0 2.365-.294 3.136-1.11.107-.116.203-.241.299-.367h3.168c-.278.985-.717 1.874-1.38 2.566-1.285 1.35-3.264 1.843-5.223 1.843s-3.938-.482-5.222-1.843c-1.285-1.362-1.756-3.435-1.756-5.498s.46-4.147 1.755-5.497c1.296-1.351 3.264-1.843 5.223-1.843s3.938.481 5.222 1.843c1.296 1.36 1.756 3.434 1.756 5.497zM78.635 18.315c-1.284.806-3.263 1.089-5.222 1.089-1.958 0-3.917-.294-5.212-1.09-1.295-.795-1.755-2.03-1.755-3.246h3.767c0 .472.225.953.824 1.257.6.304 1.54.419 2.462.419.92 0 1.851-.115 2.46-.42.611-.303.825-.784.825-1.256 0-.47-.214-.952-.824-1.256-.61-.304-1.627-.419-2.547-.419-1.777 0-3.563-.293-4.73-1.09-1.167-.795-1.584-2.03-1.584-3.245 0-1.215.417-2.44 1.584-3.246 1.167-.807 2.953-1.09 4.73-1.09 1.776 0 3.564.294 4.73 1.09 1.167.795 1.584 2.031 1.584 3.246h-3.264c0-.471-.203-.942-.749-1.257-.546-.303-1.391-.419-2.226-.419s-1.68.105-2.226.42c-.556.303-.75.785-.75 1.256 0 .47.204.942.75 1.256.545.304 1.316.42 2.151.42 1.959 0 3.938.292 5.222 1.088 1.295.796 1.756 2.032 1.756 3.246 0 1.215-.471 2.44-1.756 3.247zM96.507 12.063v1.141h-8.55v-2.272h5.672c-.129-.827-.429-1.602-.963-2.167-.771-.817-1.959-1.11-3.136-1.11s-2.366.293-3.136 1.11c-.77.816-1.048 2.063-1.048 3.298 0 1.236.278 2.492 1.048 3.299.77.806 1.959 1.11 3.135 1.11 1.178 0 2.366-.294 3.137-1.11.106-.116.203-.241.3-.367h3.167c-.279.985-.717 1.874-1.38 2.566-1.284 1.35-3.265 1.843-5.224 1.843-1.957 0-3.938-.482-5.222-1.843-1.284-1.362-1.754-3.435-1.754-5.498s.46-4.147 1.754-5.497c1.296-1.351 3.265-1.843 5.222-1.843 1.96 0 3.94.481 5.224 1.843 1.294 1.36 1.754 3.434 1.754 5.497zM112.624 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.265-1.843-5.223-1.843-1.959 0-3.928.492-5.222 1.843-1.296 1.35-1.756 3.434-1.756 5.497 0 2.063.471 4.136 1.756 5.498 1.284 1.36 3.263 1.843 5.222 1.843 1.958 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168l-.012.016c-.093.12-.185.24-.288.35-.77.817-1.957 1.11-3.135 1.11-1.177 0-2.365-.303-3.136-1.11-.77-.806-1.049-2.062-1.049-3.298 0-1.236.279-2.482 1.049-3.298.771-.817 1.959-1.11 3.136-1.11 1.178 0 2.365.293 3.135 1.11.536.565.836 1.34.964 2.167h-5.672v2.272h8.55zM128.73 19.404l-5.264-7.78 5.264-6.252h-3.97l-5.265 6.251 5.265 7.78h3.97z"/></svg></span>
|
||||
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
|
||||
</div>
|
||||
<p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p>
|
||||
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>管理门户</title>
|
||||
<link rel="icon" href="/logo.svg" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/design.css" />
|
||||
<style>
|
||||
/* 06-工作台UI规范 Token:亮色工具后台 + 蓝主色 + 橙强调 */
|
||||
@@ -125,7 +125,7 @@
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand"><img class="logo" src="/logo.svg" alt="logo" /><span>管理门户</span></div>
|
||||
<div class="brand"><img class="logo" src="/favicon.svg" alt="" /><span>管理门户</span></div>
|
||||
<nav class="crumbs" id="crumbs"></nav>
|
||||
<div class="top-actions">
|
||||
<span class="who" id="who"></span>
|
||||
|
||||
+252
-19
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title data-i18n="register.title">Sign up</title>
|
||||
<link rel="icon" href="/logo.svg" />
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/design.css" />
|
||||
<link rel="stylesheet" href="/auth-ui.css" />
|
||||
<!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
|
||||
@@ -23,13 +23,21 @@
|
||||
.auth-lang .i18n-lang { font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer;
|
||||
border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; }
|
||||
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } }
|
||||
/* 档案 134:邮箱验证码 + 人机验证(复用 .field 的版式,只加"输入框 + 按钮同一行"的排布) */
|
||||
.code-row { display: flex; gap: 8px; align-items: stretch; }
|
||||
.code-row input { flex: 1 1 auto; min-width: 0; }
|
||||
.btn-code { flex: 0 0 auto; padding: 0 12px; min-height: 38px; border-radius: 8px; cursor: pointer;
|
||||
border: 1px solid rgba(0,0,0,.18); background: transparent; color: inherit;
|
||||
font-size: 13px; white-space: nowrap; }
|
||||
.btn-code[disabled] { opacity: .6; cursor: not-allowed; }
|
||||
.captcha-box { min-height: 65px; }
|
||||
.captcha-box .cf-note { font-size: 12px; opacity: .66; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="auth-bg">
|
||||
<div class="auth-card">
|
||||
<div class="auth-brand">
|
||||
<img class="logo" src="/logo.svg" alt="logo" />
|
||||
<span class="wordmark"><svg viewBox="0 0 131 24" fill="currentColor" fill-rule="evenodd" xmlns="http://www.w3.org/2000/svg"><title>DeepSeek</title><path d="M117.986 0h-3.21v19.404h3.21V0zM8.7 5.215h1.83v2.838H8.7c-1.135 0-2.28.282-3.019 1.068-.738.785-1.016 1.99-1.016 3.194s.267 2.408 1.016 3.193c.75.786 1.884 1.068 3.018 1.068 1.135 0 2.28-.282 3.018-1.068.739-.785 1.017-1.99 1.017-3.193V.649h3.21v18.755h-3.21V18.21h-.589a3.498 3.498 0 01-.192.199c-.803.733-2.034.995-3.243.995-1.894 0-3.788-.472-5.03-1.78C2.44 16.314 2 14.303 2 12.303c0-2 .45-4 1.68-5.32 1.242-1.308 3.136-1.77 5.02-1.77zM57.564 18.9h-1.83v-2.837h1.83c1.134 0 2.28-.283 3.017-1.068.739-.785 1.017-1.99 1.017-3.194s-.267-2.408-1.017-3.194c-.749-.785-1.883-1.068-3.017-1.068s-2.28.283-3.018 1.068c-.738.786-1.017 1.99-1.017 3.194v11.655h-3.21V4.712h3.21v1.194h.59a3.42 3.42 0 01.186-.194l.005-.005c.803-.733 2.034-.995 3.243-.995 1.895 0 3.788.471 5.03 1.78 1.241 1.31 1.68 3.32 1.68 5.32 0 2-.45 4-1.68 5.319-1.23 1.32-3.135 1.77-5.019 1.77zM32.05 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.264-1.843-5.223-1.843-1.958 0-3.928.492-5.222 1.843-1.295 1.35-1.755 3.434-1.755 5.497 0 2.063.47 4.136 1.755 5.498 1.284 1.36 3.264 1.843 5.222 1.843 1.959 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168a5.026 5.026 0 01-.3.367c-.77.816-1.958 1.11-3.135 1.11-1.177 0-2.365-.304-3.136-1.11-.77-.807-1.048-2.063-1.048-3.299 0-1.236.278-2.482 1.049-3.298.77-.817 1.958-1.11 3.135-1.11 1.177 0 2.365.293 3.136 1.11.535.565.834 1.34.963 2.167H23.5v2.272h8.55zM48.168 12.063v1.141h-8.55v-2.272h5.671c-.129-.827-.428-1.602-.963-2.167-.77-.817-1.959-1.11-3.136-1.11s-2.365.293-3.136 1.11c-.77.816-1.049 2.063-1.049 3.298 0 1.236.279 2.492 1.05 3.299.77.806 1.958 1.11 3.135 1.11 1.177 0 2.365-.294 3.136-1.11.107-.116.203-.241.299-.367h3.168c-.278.985-.717 1.874-1.38 2.566-1.285 1.35-3.264 1.843-5.223 1.843s-3.938-.482-5.222-1.843c-1.285-1.362-1.756-3.435-1.756-5.498s.46-4.147 1.755-5.497c1.296-1.351 3.264-1.843 5.223-1.843s3.938.481 5.222 1.843c1.296 1.36 1.756 3.434 1.756 5.497zM78.635 18.315c-1.284.806-3.263 1.089-5.222 1.089-1.958 0-3.917-.294-5.212-1.09-1.295-.795-1.755-2.03-1.755-3.246h3.767c0 .472.225.953.824 1.257.6.304 1.54.419 2.462.419.92 0 1.851-.115 2.46-.42.611-.303.825-.784.825-1.256 0-.47-.214-.952-.824-1.256-.61-.304-1.627-.419-2.547-.419-1.777 0-3.563-.293-4.73-1.09-1.167-.795-1.584-2.03-1.584-3.245 0-1.215.417-2.44 1.584-3.246 1.167-.807 2.953-1.09 4.73-1.09 1.776 0 3.564.294 4.73 1.09 1.167.795 1.584 2.031 1.584 3.246h-3.264c0-.471-.203-.942-.749-1.257-.546-.303-1.391-.419-2.226-.419s-1.68.105-2.226.42c-.556.303-.75.785-.75 1.256 0 .47.204.942.75 1.256.545.304 1.316.42 2.151.42 1.959 0 3.938.292 5.222 1.088 1.295.796 1.756 2.032 1.756 3.246 0 1.215-.471 2.44-1.756 3.247zM96.507 12.063v1.141h-8.55v-2.272h5.672c-.129-.827-.429-1.602-.963-2.167-.771-.817-1.959-1.11-3.136-1.11s-2.366.293-3.136 1.11c-.77.816-1.048 2.063-1.048 3.298 0 1.236.278 2.492 1.048 3.299.77.806 1.959 1.11 3.135 1.11 1.178 0 2.366-.294 3.137-1.11.106-.116.203-.241.3-.367h3.167c-.279.985-.717 1.874-1.38 2.566-1.284 1.35-3.265 1.843-5.224 1.843-1.957 0-3.938-.482-5.222-1.843-1.284-1.362-1.754-3.435-1.754-5.498s.46-4.147 1.754-5.497c1.296-1.351 3.265-1.843 5.222-1.843 1.96 0 3.94.481 5.224 1.843 1.294 1.36 1.754 3.434 1.754 5.497zM112.624 13.204v-1.14c0-2.064-.46-4.137-1.754-5.498-1.285-1.362-3.265-1.843-5.223-1.843-1.959 0-3.928.492-5.222 1.843-1.296 1.35-1.756 3.434-1.756 5.497 0 2.063.471 4.136 1.756 5.498 1.284 1.36 3.263 1.843 5.222 1.843 1.958 0 3.938-.493 5.223-1.843.663-.692 1.102-1.582 1.38-2.566h-3.168l-.012.016c-.093.12-.185.24-.288.35-.77.817-1.957 1.11-3.135 1.11-1.177 0-2.365-.303-3.136-1.11-.77-.806-1.049-2.062-1.049-3.298 0-1.236.279-2.482 1.049-3.298.771-.817 1.959-1.11 3.136-1.11 1.178 0 2.365.293 3.135 1.11.536.565.836 1.34.964 2.167h-5.672v2.272h8.55zM128.73 19.404l-5.264-7.78 5.264-6.252h-3.97l-5.265 6.251 5.265 7.78h3.97z"/></svg></span>
|
||||
<span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
|
||||
</div>
|
||||
<p class="auth-sub" data-i18n="register.sub">Your account must be approved by an admin before you can sign in</p>
|
||||
|
||||
@@ -46,6 +54,24 @@
|
||||
data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 档案 134:邮箱 + 验证码(仅在服务端启用邮箱验证时显示;配置读不到时也显示,由服务端定夺) -->
|
||||
<div class="field" id="emailField">
|
||||
<label for="email" data-i18n="register.email">E-mail</label>
|
||||
<div class="code-row">
|
||||
<input id="email" name="email" type="email" autocomplete="email"
|
||||
placeholder="[email protected]" data-i18n-attr="placeholder:register.emailPh" />
|
||||
<button class="btn-code" type="button" id="sendCode" data-i18n="register.sendCode">Send code</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="field" id="codeField">
|
||||
<label for="code" data-i18n="register.code">E-mail verification code</label>
|
||||
<input id="code" name="code" inputmode="numeric" autocomplete="one-time-code" maxlength="8"
|
||||
placeholder="6-digit code" data-i18n-attr="placeholder:register.codePh" />
|
||||
</div>
|
||||
<div class="field" id="captchaField" style="display:none">
|
||||
<label data-i18n="register.captcha">Human verification</label>
|
||||
<div class="captcha-box" id="captcha"><span class="cf-note" data-i18n="register.captchaLoading">Loading…</span></div>
|
||||
</div>
|
||||
<button class="btn primary" type="submit" data-i18n="register.submit">Sign up</button>
|
||||
<p class="msg" id="msg"></p>
|
||||
</form>
|
||||
@@ -90,27 +116,234 @@
|
||||
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle
|
||||
}
|
||||
}
|
||||
function setMsg(text, kind) { msg.className = 'msg ' + (kind || 'err'); msg.textContent = text }
|
||||
|
||||
/* ── 档案 134 · 注册配置 / 邮箱验证码 / 人机验证 ──────────────────────────
|
||||
三条设计取舍:
|
||||
① **配置由服务端下发**(`/api/auth/register/config`):sitekey 随环境变,
|
||||
写死在页面里会让"换环境忘换 key"表现为"控件在、但永远验不过"的静默失效。
|
||||
② **配置读不到时仍显示邮箱字段**(不校验、照原样提交):服务端才是权威,
|
||||
读不到配置就"整页降级成不可注册"是最差的失败方式。
|
||||
③ **Turnstile token 单次有效** ⇒ 每次提交前取**新** token(用完即作废并 reset),
|
||||
否则第二次请求必然拿到 `timeout-or-duplicate`。 */
|
||||
const EMAIL_RE = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_-]{3,32}$/
|
||||
let cfg = { captcha: { enabled: false, siteKey: '', action: 'signup' }, emailCode: { enabled: false } }
|
||||
let requireEmailCode = false
|
||||
let captchaToken = ''
|
||||
let widgetId = null
|
||||
let resolveToken = null
|
||||
let cooldownLeft = 0
|
||||
let cooldownTimer = null
|
||||
|
||||
const emailField = document.getElementById('emailField')
|
||||
const codeField = document.getElementById('codeField')
|
||||
const captchaField = document.getElementById('captchaField')
|
||||
const sendBtn = document.getElementById('sendCode')
|
||||
|
||||
function sendBtnLabel() {
|
||||
sendBtn.textContent = cooldownLeft > 0
|
||||
? I18N.t('register.resendIn', { n: cooldownLeft })
|
||||
: I18N.t('register.sendCode')
|
||||
sendBtn.disabled = cooldownLeft > 0
|
||||
}
|
||||
function startCooldown(seconds) {
|
||||
cooldownLeft = Math.max(1, Math.floor(seconds || 60))
|
||||
sendBtnLabel()
|
||||
if (cooldownTimer) clearInterval(cooldownTimer)
|
||||
cooldownTimer = setInterval(() => {
|
||||
cooldownLeft -= 1
|
||||
sendBtnLabel()
|
||||
if (cooldownLeft <= 0) { clearInterval(cooldownTimer); cooldownTimer = null }
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/** 取一个**新鲜**的人机验证 token(无 token 时 reset 并等待回调;超时返回空串)。 */
|
||||
function freshToken() {
|
||||
if (!cfg.captcha.enabled) return Promise.resolve('')
|
||||
if (captchaToken) return Promise.resolve(captchaToken)
|
||||
return new Promise((resolve) => {
|
||||
let settled = false
|
||||
const finish = (value) => { if (!settled) { settled = true; resolveToken = null; resolve(value) } }
|
||||
resolveToken = finish
|
||||
try { if (widgetId !== null && window.turnstile) window.turnstile.reset(widgetId) } catch (e) { /* 忽略 */ }
|
||||
setTimeout(() => finish(''), 12000)
|
||||
})
|
||||
}
|
||||
function consumeToken() { captchaToken = '' }
|
||||
|
||||
function mapError(code, attemptsLeft) {
|
||||
switch (code) {
|
||||
case 'username_taken': return I18N.t('register.errTaken')
|
||||
case 'email_taken': return I18N.t('register.errEmailTaken')
|
||||
case 'invalid_email':
|
||||
case 'email_required': return I18N.t('register.errEmail')
|
||||
case 'invalid_username': return I18N.t('register.errUsername')
|
||||
case 'code_attempts_exceeded': return I18N.t('register.errCodeExhausted')
|
||||
case 'code_invalid':
|
||||
case 'code_missing':
|
||||
case 'code_expired':
|
||||
case 'code_used':
|
||||
case 'code_username_mismatch':
|
||||
return attemptsLeft > 0
|
||||
? I18N.t('register.errCode') + ' (' + attemptsLeft + ')'
|
||||
: I18N.t('register.errCode')
|
||||
case 'mail_send_failed': return I18N.t('register.errMail')
|
||||
case 'captcha_failed':
|
||||
case 'captcha_unavailable': return I18N.t('register.errCaptcha')
|
||||
case 'email_code_disabled': return I18N.t('register.errUnavailable')
|
||||
default: return I18N.t('register.errFailed')
|
||||
}
|
||||
}
|
||||
|
||||
function loadScript(src) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const s = document.createElement('script')
|
||||
s.src = src
|
||||
s.async = true
|
||||
s.defer = true
|
||||
s.onload = () => resolve()
|
||||
s.onerror = () => reject(new Error('load-failed'))
|
||||
document.head.appendChild(s)
|
||||
})
|
||||
}
|
||||
|
||||
async function boot() {
|
||||
let loaded = null
|
||||
try {
|
||||
const res = await fetch('/api/auth/register/config', { headers: { accept: 'application/json' } })
|
||||
if (res.ok) loaded = await res.json()
|
||||
} catch (e) { /* 见取舍 ②:读不到就降级,不放倒整页 */ }
|
||||
|
||||
if (loaded) {
|
||||
cfg = {
|
||||
captcha: loaded.captcha || { enabled: false, siteKey: '', action: 'signup' },
|
||||
emailCode: loaded.emailCode || { enabled: false },
|
||||
}
|
||||
requireEmailCode = !!cfg.emailCode.enabled
|
||||
}
|
||||
// 字段显隐:启用时才必填;配置读不到时"显示但不强制"(服务端会给出明确错误)。
|
||||
const showEmail = loaded ? requireEmailCode : true
|
||||
emailField.style.display = showEmail ? '' : 'none'
|
||||
codeField.style.display = showEmail ? '' : 'none'
|
||||
sendBtnLabel()
|
||||
|
||||
if (!cfg.captcha.enabled || !cfg.captcha.siteKey) return
|
||||
captchaField.style.display = ''
|
||||
const box = document.getElementById('captcha')
|
||||
try {
|
||||
await loadScript('https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit')
|
||||
if (!window.turnstile) throw new Error('no-api')
|
||||
box.innerHTML = ''
|
||||
widgetId = window.turnstile.render(box, {
|
||||
sitekey: cfg.captcha.siteKey,
|
||||
// action 由服务端下发(与服务端 siteverify 校的是同一份取值)——
|
||||
// 它是「这枚 token 为哪个业务签发」的标签,⛔ 不要在前端另写一个常量。
|
||||
action: cfg.captcha.action || 'signup',
|
||||
callback: (token) => { captchaToken = token; if (resolveToken) resolveToken(token) },
|
||||
'expired-callback': () => { captchaToken = '' },
|
||||
'error-callback': () => { captchaToken = '' },
|
||||
})
|
||||
} catch (e) {
|
||||
// 加载失败 ⇒ **明确告知 + 不给提交**(提交也一定被服务端拒,不如先说清楚)
|
||||
box.innerHTML = ''
|
||||
setMsg(I18N.t('register.errCaptchaLoad'))
|
||||
form.querySelector('button[type=submit]').disabled = true
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', async () => {
|
||||
if (cooldownLeft > 0) return
|
||||
setMsg('')
|
||||
const username = form.username.value.trim()
|
||||
const email = form.email.value.trim()
|
||||
if (!USERNAME_RE.test(username)) return setMsg(I18N.t('register.errUsername'))
|
||||
if (!EMAIL_RE.test(email)) return setMsg(I18N.t('register.errEmail'))
|
||||
const token = await freshToken()
|
||||
if (cfg.captcha.enabled && token === '') return setMsg(I18N.t('register.errCaptchaToken'))
|
||||
|
||||
sendBtn.disabled = true
|
||||
sendBtn.textContent = I18N.t('register.sendingCode')
|
||||
try {
|
||||
const res = await fetch('/api/auth/register/email-code', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, email, captchaToken: token }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
consumeToken() // 无论成败,token 都已用过
|
||||
if (!res.ok) {
|
||||
const wait = Number(body.retryAfterSeconds || 0)
|
||||
if (res.status === 429) {
|
||||
if (wait > 0) startCooldown(wait)
|
||||
setMsg(I18N.t('register.errCooldown', { n: wait > 0 ? wait : 60 }))
|
||||
} else {
|
||||
setMsg(mapError(body.error, body.attemptsLeft))
|
||||
}
|
||||
} else {
|
||||
startCooldown(body.retryAfterSeconds || 60)
|
||||
setMsg(I18N.t('register.codeSent'), 'ok')
|
||||
}
|
||||
} catch (e) {
|
||||
consumeToken()
|
||||
setMsg(I18N.t('register.errMail'))
|
||||
} finally {
|
||||
if (cooldownLeft <= 0) { sendBtn.disabled = false; sendBtn.textContent = I18N.t('register.sendCode') }
|
||||
}
|
||||
})
|
||||
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault()
|
||||
msg.textContent = ''
|
||||
msg.className = 'msg err'
|
||||
setMsg('')
|
||||
const username = form.username.value.trim()
|
||||
const password = form.password.value
|
||||
if (!USERNAME_RE.test(username)) return setMsg(I18N.t('register.errUsername'))
|
||||
if (password.length < 8) return setMsg(I18N.t('register.errPassword'))
|
||||
|
||||
let email = ''
|
||||
let code = ''
|
||||
if (emailField.style.display !== 'none') {
|
||||
email = form.email.value.trim()
|
||||
code = form.code.value.trim()
|
||||
const touched = email !== '' || code !== ''
|
||||
if (requireEmailCode || touched) {
|
||||
if (!EMAIL_RE.test(email)) return setMsg(I18N.t('register.errEmail'))
|
||||
if (!/^\d{4,8}$/.test(code)) return setMsg(I18N.t('register.errEmailRequired'))
|
||||
}
|
||||
}
|
||||
|
||||
let token = ''
|
||||
if (cfg.captcha.enabled) {
|
||||
token = await freshToken()
|
||||
if (token === '') return setMsg(I18N.t('register.errCaptchaToken'))
|
||||
}
|
||||
|
||||
setBusy(true, I18N.t('register.signingUp'))
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username: form.username.value, password: form.password.value }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!res.ok) {
|
||||
msg.textContent = body.error === 'username_taken' ? I18N.t('register.errTaken') : body.message || I18N.t('register.errFailed')
|
||||
return
|
||||
try {
|
||||
const res = await fetch('/api/auth/register', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ username, password, email, code, captchaToken: token }),
|
||||
})
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (cfg.captcha.enabled) consumeToken()
|
||||
if (!res.ok) {
|
||||
setBusy(false)
|
||||
return setMsg(mapError(body.error, body.attemptsLeft))
|
||||
}
|
||||
msg.className = 'msg ok'
|
||||
msg.textContent = I18N.t('register.ok')
|
||||
setBusy(false)
|
||||
form.querySelector('button[type=submit]').disabled = true
|
||||
form.reset()
|
||||
} catch (e) {
|
||||
if (cfg.captcha.enabled) consumeToken()
|
||||
setBusy(false)
|
||||
setMsg(I18N.t('register.errFailed'))
|
||||
}
|
||||
msg.className = 'msg ok'
|
||||
msg.textContent = I18N.t('register.ok')
|
||||
setBusy(false)
|
||||
form.querySelector('button[type=submit]').disabled = true
|
||||
form.reset()
|
||||
})
|
||||
|
||||
boot()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+2
-2
@@ -89,9 +89,9 @@
|
||||
show(I18N.t('wake.failed'))
|
||||
}
|
||||
|
||||
// 只允许跳回 *.alotbuy.com,避免被 next 参数带偏
|
||||
// 只允许跳回 *.ai1net.com,避免被 next 参数带偏
|
||||
function safeNext(u) {
|
||||
try { var x = new URL(u, location.href); return /(^|\.)alotbuy\.com$/.test(x.hostname) ? x.href : '' } catch (e) { return '' }
|
||||
try { var x = new URL(u, location.href); return /(^|\.)ai1net\.com$/.test(x.hostname) ? x.href : '' } catch (e) { return '' }
|
||||
}
|
||||
|
||||
async function wake() {
|
||||
|
||||
Reference in new issue
Block a user