feat(auth): 注册页人机验证 + 邮箱验证码;品牌标识去 DeepSeek(附域名迁移线 序㊿ 补提交)

三条线合并入库 —— 均已完成并上线(源码与生产一致,此前只部署未入仓)。
⚠️ 其中域名迁移线为**另一会话**产出,本会话只做入库、**未复验其正确性**(它自报零回归)。

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

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

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

验证(本会话两条线):新增单测 21 条全通过|全量 221 pass / 0 fail / 1 skipped|
verify-static 全合格|其余 10 个 verify 脚本全 OK|线上实测:Turnstile 假 token 403、
发码 delivered、四页 deepseek 命中 0、favicon 200。
This commit is contained in:
admin committed 2026-09-19 09:11:24 +08:00
1 parent d2ef362a98
commit 971ccc3703
56 files changed
+3498 -193

No files matched your search

+3 -3
View File
@@ -238,9 +238,9 @@ node /opt/dshs/ensure-role-profile-patch.cjs [--restart] <username>
| 项 | 值 | | 项 | 值 |
|---|---| |---|---|
| 门户 / 用户实例 | `https://alotbuy.com` / `https://<用户名>.alotbuy.com`(旧域 `dsh.alotbuy.com` 及子域 **301**,含用户名映射) | | 门户 / 用户实例 | `https://ai1net.com` / `https://<用户名>.ai1net.com`(旧域 `alotbuy.com` 自 **2026-09-19** 降为 301 过渡装置) |
| 环境变量 | `DSHS_BASE_DOMAIN=alotbuy.com`、`COOKIE_DOMAIN=.alotbuy.com`(备份 `/etc/dshs.env.bak-*`) | | 环境变量 | `DSHS_BASE_DOMAIN=ai1net.com`、`COOKIE_DOMAIN=.ai1net.com`(备份 `/etc/dshs.env.bak-*`) |
| 证书 | `/etc/letsencrypt/live/alotbuy.com/`(`alotbuy.com + *.alotbuy.com`,DNS-01);旧域证书仅用于 301 | | 证书 | `/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 已写死该值) | | **签发/续期必须带传播等待** | `certbot … --dns-cloudflare-propagation-seconds 60`(默认 10 s 会失败;renewal conf 已写死该值) |
| 切换脚本 | `/opt/dsh/switch-domain-alotbuy.sh`(改 env → drain → 重启,幂等) | | 切换脚本 | `/opt/dsh/switch-domain-alotbuy.sh`(改 env → drain → 重启,幂等) |
| Cloudflare | token 仅 DNS 权限;回源实测走 **443**;`*.alotbuy.com` 为共享通配(gao/mao/work 在用,勿改) | | 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**(并行线产物,本档案不重复)。
+5 -5
View File
@@ -2,20 +2,20 @@
> **用途**:30 秒掌握「现在是什么样、正在做什么、该读哪一篇」。只写**结论与指针**,细节的单一来源见每行括注。 > **用途**:30 秒掌握「现在是什么样、正在做什么、该读哪一篇」。只写**结论与指针**,细节的单一来源见每行括注。
> **防漂移**:本文件是**人工维护的摘要层**,不复制细节;`scripts/docs-audit.py` 会检查本文件引用的档案号是否存在、域名/阈值等关键串是否与来源文件一致。 > **防漂移**:本文件是**人工维护的摘要层**,不复制细节;`scripts/docs-audit.py` 会检查本文件引用的档案号是否存在、域名/阈值等关键串是否与来源文件一致。
> **最后人工核对**:2026-09-16 10:5x > **最后人工核对**:2026-09-19 06:4x
--- ---
## 1. 一句话 ## 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 反向隧道)。 **自 2026-09-15 起为集群形态**:`47.77.182.89` = **Manager**(cluster 模式,兼本地 Worker `w-47`)|**106 = Worker `w-106`**(经 SSH 反向隧道)。
## 2. 现行事实(结论层 · 改任何东西前先对齐这里) ## 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` | | **服务** | 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` | | **集群拓扑** | 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` | | **运行时** | 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 | | **admin 管理面** | 门户:服务 / **密钥** / 用户 / 技能 / 插件管理 + 运行环境;会话内 `portal-entry` 卡片**一键整页跳门户** | `portal.html` / 档案 47 |
| **清理阈值** | `ws` ≥ **2048 MB** → 清 T1 平台产物 + T2 超 **90 天**顶层脚本;`sessions` ≥ **1024 MB** → 清超 **365 天**;回收站 **30 天** | 档案 28 / `02 §C.7` | | **清理阈值** | `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` | | **定时任务** | 每小时 :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 §红线` | | **红线** | 不自动升级 dsh;不改官方 dsh 主程序与缓存;client bundle 禁 `exports.default`;服务器 docs 保持 root 600;未明确要求不 commit/push;⛔ **禁止人工删锁 / 接管**(**R9**) | `README §红线` |
## 3. 当前待办(**单一来源 = `03-路线图与待办.md`** + **已规划的单子见 `交接单/README.md §一`**) ## 3. 当前待办(**单一来源 = `03-路线图与待办.md`** + **已规划的单子见 `交接单/README.md §一`**)
@@ -73,7 +73,7 @@
## 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 是空号,勿补占**。 **归档编号**:至 **112**(其中 103–112 = 覆盖网络 / 客户端化 10 份规划);**编号 63 是空号,勿补占**。
**本机工作区(2026-09-13 起)**:`E:\ProgramData\AI技能\aliyun-dsh-server`(文档库工作树 = `dsh-server-docs/`);代码仓仍为 `D:\github\dsh_shenxian`。历史文档里出现的 `D:\AI技能\...` 一律是**迁移前旧值**,勿照抄执行。 **本机工作区(2026-09-13 起)**:`E:\ProgramData\AI技能\aliyun-dsh-server`(文档库工作树 = `dsh-server-docs/`);代码仓仍为 `D:\github\dsh_shenxian`。历史文档里出现的 `D:\AI技能\...` 一律是**迁移前旧值**,勿照抄执行。
+8 -8
View File
@@ -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. 拓扑与入口 ## 1. 拓扑与入口
| 项 | 值 | | 项 | 值 |
|---|---| |---|---|
| 服务器 | `47.77.182.89`(Alibaba Cloud Linux 8,阿里云)|SSH 别名 `bt-server`(端口 32022,root) | | 服务器 | `47.77.182.89`(Alibaba Cloud Linux 8,阿里云)|SSH 别名 `bt-server`(端口 32022,root) |
| 门户 / 用户实例 | **`https://alotbuy.com`** / **`https://<用户名>.alotbuy.com`**(Cloudflare 代理,回源走 443) | | 门户 / 用户实例 | **`https://ai1net.com`** / **`https://<用户名>.ai1net.com`**(Cloudflare 代理,回源走 443) |
| 旧域名 | `dsh.alotbuy.com`(含 `*.dsh.*`)→ **301** 到新域(保留用户名映射) | | 旧域名 | `alotbuy.com`(含 `*.alotbuy.com`、`*.dsh.alotbuy.com`)→ **301** 到新域(2026-09-19 降为过渡装置;`relay-direct.alotbuy.com` 与 `/dshs-relay`、`/dshs-overlay/bootstrap` 仍透传) |
| 面板 | 宝塔(站点 vhost 由面板管理,注意:面板保存会**重写** vhost,可能抹掉自定义指令) | | 面板 | 宝塔(站点 vhost 由面板管理,注意:面板保存会**重写** vhost,可能抹掉自定义指令) |
| 编排器 | 自研 `dshs`(Node/TS),systemd 单元 `dshs`,监听 `127.0.0.1:3080` | | 编排器 | 自研 `dshs`(Node/TS),systemd 单元 `dshs`,监听 `127.0.0.1:3080` |
| 被托管运行时 | 官方 **`@deepseek-ai/[email protected]`**(全局安装于 `/usr/local/lib/node_modules/@deepseek-ai/dsh`,**平台不改其任何文件**) | | 被托管运行时 | 官方 **`@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) | | `<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` | | `<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) | | `/opt/dsh/` | 运维脚本与备份:`switch-domain-alotbuy.sh`、`backups/`、`artifacts/`(插件 tgz) |
| `/www/server/panel/vhost/nginx/` | `alotbuy.com.conf`(现役)、`dsh.alotbuy.com.conf`(301 跳转)+ 多份 `.bak-*` | | `/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/{alotbuy.com,dsh.alotbuy.com}/` | 证书(DNS-01 via `/etc/cloudflare.ini`) | | `/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`) ## 3. 环境变量(`/etc/dshs.env`)
@@ -32,8 +32,8 @@
|---|---| |---|---|
| `DSHS_PORT` | 编排器监听端口(127.0.0.1:3080) | | `DSHS_PORT` | 编排器监听端口(127.0.0.1:3080) |
| `DSHS_DATA_ROOT` | dataRoot | | `DSHS_DATA_ROOT` | dataRoot |
| `DSHS_BASE_DOMAIN` | 门户/用户子域基域(现 `alotbuy.com`) | | `DSHS_BASE_DOMAIN` | 门户/用户子域基域(现 `ai1net.com`) |
| `DSHS_COOKIE_DOMAIN` | 会话 Cookie 域(现 `.alotbuy.com`;见档案 22 §五 取舍说明) | | `DSHS_COOKIE_DOMAIN` | 会话 Cookie 域(现 `.ai1net.com`;见档案 22 §五 取舍说明) |
| `DSHS_SECURE_COOKIES` | Cookie `Secure` 标记(HTTPS 下应开) | | `DSHS_SECURE_COOKIES` | Cookie `Secure` 标记(HTTPS 下应开) |
| `DSHS_DSH_BIN` | 子实例可执行路径 | | `DSHS_DSH_BIN` | 子实例可执行路径 |
| `DSHS_ISOLATION_MODE` | 隔离模式(本部署 `account` = bwrap + setpriv + systemd-run scope) | | `DSHS_ISOLATION_MODE` | 隔离模式(本部署 `account` = bwrap + setpriv + systemd-run scope) |
+7 -3
View File
@@ -3,7 +3,7 @@
> **首读 `BRIEF.md`(现状卡)**:30 秒对齐现行事实;本文件只管"定位篇目 + 状态"。 > **首读 `BRIEF.md`(现状卡)**:30 秒对齐现行事实;本文件只管"定位篇目 + 状态"。
> **双端模型**:本机 `D:\github\dsh_shenxian\dsh-server-docs\`(**工作树**,git 仓库 `dsh_shenxian_doc`)↔ 服务器 `/opt/dsh/docs`(**部署镜像**,root 600,**无 .git**,靠 scp)。 > **双端模型**:本机 `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 = 全绿)。 > **对账**:`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 | | **重建 / 交接部署** | **`DEPLOY-本部署.md`**(拓扑·目录·env·依赖·脚本族·构建/部署/回滚) | 档案 19 §C9 |
| 理解架构 / 多租户隔离 | `01-规划与架构.md`(一~五、九、十三) | `archive/` 全文 | | 理解架构 / 多租户隔离 | `01-规划与架构.md`(一~五、九、十三) | `archive/` 全文 |
| 日常运维(重启/备份/token/KEY/排障) | `02-运维手册.md` | `03-路线图与待办.md` | | 日常运维(重启/备份/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` 即刷新。 > **分层与机读明细**(路径 / 日期 / 字符数 / 被引次数 / 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 机制级已验 > 图例:✅已落地 | 🔄维护中 | 🧪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-128 | 📋 | **DSH 桌面客户端开发方案 —— 基于官方 Electron 壳迭代**(待评审 · 只做规划不含代码):复用官方壳 vs 自建的取舍与分发/版本矩阵 |
| 04-129 | ✅ | **日志采集 · 实时巡检 · 事后溯源(方案 C 实现)**:不装新日志服务(否 Amber / 否 Loki+Alloy),用 `scripts/dshlog.mjs` 把 47/106 journald 拉回本机按天归档 + 跨机时间线 + 8 条巡检规则;**服务器侧零安装零端口**;⚠️ 最大剩余缺口 = 实例 `dsh --profile` 的 stdout 不进 journald | | 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-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 章 | | — | 🗄 | `archive/dsh-improvement-plan-20260909-full.md`:拆分前 19 章 |
| — | ✅ | `skills/dsh-change-workflow/SKILL.md`:六阶段 + **红线 R1-R11**(工作副本在本机 `.workbuddy/skills/`)| | — | ✅ | `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/`)| | — | ✅ | `skills/dsh-decision-method/SKILL.md`:**改造决策方法论**(用户有效决策 U1-U12 / AI 有效决策 A1-A13 / 反例 X1-X8 + 确认最优解十问 + 交互 UI 专项清单)(工作副本在本机 `.workbuddy/skills/`)|
+2 -2
View File
@@ -1,7 +1,7 @@
# dsh 多租户平台 — 改造方案文档库 # dsh 多租户平台 — 改造方案文档库
> 服务器:47.77.182.89(Alibaba Cloud Linux al8)| 平台:dshs + dsh 0.1.2-rc.1(account 硬隔离) > 服务器: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`** > 服务:systemd `dshs`(127.0.0.1:3080)| 现行状态与部署细节见 **`DEPLOY-本部署.md`**
> ⚠️ **两处 `docs/` 勿混**(档案 19 §C10): > ⚠️ **两处 `docs/` 勿混**(档案 19 §C10):
@@ -16,7 +16,7 @@
1. **两套编号互不相同**:根级 `01/02/03/06` 是**长期文档**(架构、运维、路线图、UI 规范);`04-调整方案/NN-` 是**一次性改造成档案**。说"档案 NN"时**默认指后者**。 1. **两套编号互不相同**:根级 `01/02/03/06` 是**长期文档**(架构、运维、路线图、UI 规范);`04-调整方案/NN-` 是**一次性改造成档案**。说"档案 NN"时**默认指后者**。
2. **单一来源**:**清单与状态 = `INDEX.md` §二**;**待办与优先级 = `03-路线图与待办.md`**;**部署/构建/回滚 = `DEPLOY-本部署.md`**。其余文件提到这些内容时**只做指针,不复制**(历史上 README/INDEX 各存一份清单,已漂移 —— 见档案 53)。 2. **单一来源**:**清单与状态 = `INDEX.md` §二**;**待办与优先级 = `03-路线图与待办.md`**;**部署/构建/回滚 = `DEPLOY-本部署.md`**。其余文件提到这些内容时**只做指针,不复制**(历史上 README/INDEX 各存一份清单,已漂移 —— 见档案 53)。
3. **术语以档案 38b 为准**:统一称「**功能插件**」(早期档案写"业务插件"= 同一事物;历史档案保留原文,不回改)。 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/`(源码)—— 需要时再按需打开。 5. **AI 默认不读**:`archive/`(历史全文)、`scripts/` `ops/`(工具与配置)、`*.bak*`、`04-调整方案/poc/`(源码)—— 需要时再按需打开。
6. **档案头部格式**:`- 日期:` + `- 状态:`(✅已完成 / 🔄进行中 / ⏸暂缓 / ❌关闭)+ `- 触发:`;正文用「需求 → 改动点 → 验证」三段。 6. **档案头部格式**:`- 日期:` + `- 状态:`(✅已完成 / 🔄进行中 / ⏸暂缓 / ❌关闭)+ `- 触发:`;正文用「需求 → 改动点 → 验证」三段。
+101 -26
View File
@@ -1,31 +1,31 @@
{ {
"generatedFrom": "scripts/docs-manifest.py", "generatedFrom": "scripts/docs-manifest.py",
"counts": { "counts": {
"files": 189, "files": 194,
"chars": 1982749 "chars": 2050190
}, },
"tiers": { "tiers": {
"hot": 6, "hot": 6,
"cur": 25, "cur": 25,
"warm": 61, "warm": 61,
"cold": 38, "cold": 42,
"doc": 59 "doc": 60
}, },
"domains": { "domains": {
"platform": 74, "platform": 75,
"method": 47, "method": 47,
"ui": 15, "ui": 17,
"plugin": 27, "plugin": 27,
"ops": 14, "ops": 16,
"?": 8, "?": 8,
"external": 4 "external": 4
}, },
"layers": { "layers": {
"L0": 1, "L0": 1,
"L5": 145, "L5": 149,
"L4": 22, "L4": 22,
"L2": 3, "L2": 3,
"?": 3, "?": 4,
"L1": 2, "L1": 2,
"L3": 13 "L3": 13
}, },
@@ -51,7 +51,7 @@
"title": "六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)", "title": "六、单实例迁移执行步骤(从当前 data/ 形态 → users/main 形态)",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 14585, "chars": 14656,
"lines": 338, "lines": 338,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -548,7 +548,7 @@
"date": "04-12", "date": "04-12",
"chars": 4117, "chars": 4117,
"lines": 77, "lines": 77,
"refs": 4, "refs": 6,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "warm", "tier": "warm",
"layer": "L5", "layer": "L5",
@@ -713,7 +713,7 @@
"date": "04-13", "date": "04-13",
"chars": 2444, "chars": 2444,
"lines": 58, "lines": 58,
"refs": 5, "refs": 9,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "warm", "tier": "warm",
"layer": "L5", "layer": "L5",
@@ -735,6 +735,66 @@
"domain": "method", "domain": "method",
"tldr": "" "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", "path": "04-调整方案/14-实例会话敏感信息暴露面审计与加固.md",
"num": "14", "num": "14",
@@ -2076,7 +2136,7 @@
"title": "BRIEF — 现状卡(**AI / 人 首读这一份**)", "title": "BRIEF — 现状卡(**AI / 人 首读这一份**)",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 5965, "chars": 6424,
"lines": 80, "lines": 80,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2103,10 +2163,10 @@
{ {
"path": "DEPLOY-本部署.md", "path": "DEPLOY-本部署.md",
"num": null, "num": null,
"title": "DEPLOY — 本部署说明(alotbuy.com / 47.77.182.89)", "title": "DEPLOY — 本部署说明(ai1net.com / 47.77.182.89)",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 4415, "chars": 4659,
"lines": 102, "lines": 102,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2121,8 +2181,8 @@
"title": "dsh 平台文档导航(INDEX)", "title": "dsh 平台文档导航(INDEX)",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 22828, "chars": 24468,
"lines": 264, "lines": 268,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "doc", "tier": "doc",
@@ -2136,7 +2196,7 @@
"title": "dsh 多租户平台 — 改造方案文档库", "title": "dsh 多租户平台 — 改造方案文档库",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 10835, "chars": 10935,
"lines": 103, "lines": 103,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2340,6 +2400,21 @@
"domain": "plugin", "domain": "plugin",
"tldr": "" "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", "path": "ops/接续入口_覆盖网络线_20260916.md",
"num": null, "num": null,
@@ -2391,7 +2466,7 @@
"title": "dsh-change-workflow — dsh 平台改造工作流", "title": "dsh-change-workflow — dsh 平台改造工作流",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 70412, "chars": 70439,
"lines": 1039, "lines": 1039,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2406,7 +2481,7 @@
"title": "dsh-decision-method — 平台改造的思考与决策方法", "title": "dsh-decision-method — 平台改造的思考与决策方法",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 15995, "chars": 15994,
"lines": 360, "lines": 360,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2496,7 +2571,7 @@
"title": "dsh-feature-first — 功能优先协作协议", "title": "dsh-feature-first — 功能优先协作协议",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 14920, "chars": 14919,
"lines": 361, "lines": 361,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2511,7 +2586,7 @@
"title": "dsh-instance-diagnose — DSH 实例故障诊断", "title": "dsh-instance-diagnose — DSH 实例故障诊断",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 12545, "chars": 12542,
"lines": 252, "lines": 252,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2541,7 +2616,7 @@
"title": "dsh-opensource-release — 开源导出与版本迭代", "title": "dsh-opensource-release — 开源导出与版本迭代",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 48137, "chars": 48133,
"lines": 595, "lines": 595,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2556,7 +2631,7 @@
"title": "DSH 业务插件故障诊断", "title": "DSH 业务插件故障诊断",
"status": "?", "status": "?",
"date": "", "date": "",
"chars": 5919, "chars": 5917,
"lines": 121, "lines": 121,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
@@ -2856,8 +2931,8 @@
"title": "交接单 · 低熵块治理(**测熵先行** + C 域分离 + D 非确定性)", "title": "交接单 · 低熵块治理(**测熵先行** + C 域分离 + D 非确定性)",
"status": "待执行", "status": "待执行",
"date": "", "date": "",
"chars": 19215, "chars": 43309,
"lines": 313, "lines": 620,
"refs": 0, "refs": 0,
"refsCurrent": 0, "refsCurrent": 0,
"tier": "doc", "tier": "doc",
+64 -31
View File
@@ -1,12 +1,29 @@
# alotbuy.com —— DSH 平台站点(2026-09-10 建立,档案 21 场景延伸) # alotbuy.com —— 旧域(2026-09-10 建立为门户站点;**2026-09-19 降级为 301 过渡装置**)
# 走 Cloudflare 代理(橙云),故: #
# 1) 用 set_real_ip_from + CF-Connecting-IP 还原真实客户端 IP(否则日志/风控里全是 CF 的 IP) # 为什么"降级"而不是"删除"(判据见 04-调整方案/135):
# 2) 80 端口「代理」而非 301:CF 若为 Flexible,源站 301 会造成 CF 侧循环;改成代理两种模式都能用 # ① 老书签 / 外链不 404(301 到新域**同名子域**,保留用户名映射)
# —— 但**必须**把 CF 的 SSL/TLS 模式设为 Full (Strict),否则 CF→源站是明文(凭据裸奔) # ② 已入网节点的**旧域中继入口**仍可透传 ⇒ 不断线(见下方保留的 location)
# 3) 继承 dsh 站点的关键优化:proxy_buffering off(流式)、gzip_proxied any(930KB bundle 压缩) # ③ 软回滚路径保留:`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 { server {
# CF 回源 IP → 还原真实客户端 IP(仅本 server 生效) # CF 回源 IP → 还原真实客户端 IP(仅本 server 生效)
set_real_ip_from 173.245.48.0/20; set_real_ip_from 173.245.48.0/20;
@@ -34,25 +51,40 @@ server {
real_ip_header CF-Connecting-IP; real_ip_header CF-Connecting-IP;
listen 80; listen 80;
server_name alotbuy.com www.alotbuy.com *.alotbuy.com; 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_pass http://127.0.0.1:3080;
proxy_set_header Host $host; 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_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1; 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_buffering off;
proxy_cache off;
gzip_proxied any;
} }
location / { return 301 https://$alotbuy_301_host$request_uri; }
access_log /www/wwwlogs/alotbuy.com.log; access_log /www/wwwlogs/alotbuy.com.log;
error_log /www/wwwlogs/alotbuy.com.error.log; error_log /www/wwwlogs/alotbuy.com.error.log;
} }
# ---- HTTPS:门户 + 用户实例子域 ---- # ---- HTTPS:301 到新域(同上)----
server { server {
# CF 回源 IP → 还原真实客户端 IP(仅本 server 生效) # CF 回源 IP → 还原真实客户端 IP(仅本 server 生效)
set_real_ip_from 173.245.48.0/20; 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 /etc/letsencrypt/live/alotbuy.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/alotbuy.com/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/alotbuy.com/privkey.pem;
location / { location ^~ /.well-known/acme-challenge/ {
proxy_pass http://127.0.0.1:3080; root /www/server/nginx/html;
proxy_set_header Host $host; default_type text/plain;
proxy_set_header X-Real-IP $remote_addr; access_log off;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; }
proxy_set_header X-Forwarded-Proto $scheme;
# ── 覆盖网络中继(保留)──
location /dshs-relay {
proxy_pass http://127.0.0.1:20080;
proxy_http_version 1.1; 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 Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
# 流式(dsh 回复/思考逐块下发)+ 反代压缩 + 解除缓冲
proxy_buffering off;
proxy_cache off;
proxy_send_timeout 3600s; proxy_send_timeout 3600s;
gzip_proxied any; proxy_buffering off;
} }
# 内容哈希命名的静态资源 → 长缓存 location = /dshs-overlay/bootstrap {
location ~* ^/assets/ {
proxy_pass http://127.0.0.1:3080; proxy_pass http://127.0.0.1:3080;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_buffering off; proxy_buffering off;
gzip_proxied any;
expires 30d;
} }
location / { return 301 https://$alotbuy_301_host$request_uri; }
access_log /www/wwwlogs/alotbuy.com.log; access_log /www/wwwlogs/alotbuy.com.log;
error_log /www/wwwlogs/alotbuy.com.error.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 入网。
+6 -4
View File
@@ -9,11 +9,13 @@ import time
import urllib.request import urllib.request
CRED = '/etc/cloudflare.ini' 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' API = 'https://api.cloudflare.com/client/v4'
def token(): def token(zone=None):
txt = open(CRED, 'r', encoding='utf-8').read() 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) m = re.search(r'dns_cloudflare_api_token\s*=\s*([A-Za-z0-9_\-]+)', txt)
if not m: if not m:
sys.exit('未找到 token') sys.exit('未找到 token')
@@ -52,9 +54,9 @@ def show(title, name, typ):
def main(): 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 name = sys.argv[2] if len(sys.argv) > 2 else '_acme-challenge.' + zone
tok = token() tok = token(zone)
zid = '' zid = ''
for z in (req('GET', '/zones?name=' + zone, tok).get('result') or []): for z in (req('GET', '/zones?name=' + zone, tok).get('result') or []):
+2 -2
View File
@@ -21,9 +21,9 @@ docs-consistency.py —— 文档库「事实一致性」校验(只读,可
- 豁免(历史事实,只增不改):`04-调整方案/**`、`archive/**`、`01-规划与架构.md`、`02-运维手册.md` - 豁免(历史事实,只增不改):`04-调整方案/**`、`archive/**`、`01-规划与架构.md`、`02-运维手册.md`
—— 它们写的时候那个值是对的,回改反而破坏历史。 —— 它们写的时候那个值是对的,回改反而破坏历史。
⚠️ **刻意不查**「旧域名 dsh.alotbuy.com」「旧配额 512M」:这两者在库里几乎都是 ⚠️ **刻意不查**「旧域名 `dsh.alotbuy.com` / `alotbuy.com`」「旧配额 512M」:这些在库里几乎都是
"旧域已 301" / "512M→384M" 的**合法历史表述**,正则无法可靠区分,误报率过高。 "旧域已 301" / "512M→384M" 的**合法历史表述**,正则无法可靠区分,误报率过高。
改由人工在改域名/改配额时顺手核(档案 22 / 58 是权威)。 改由人工在改域名/改配额时顺手核(档案 22 / 58 是权威;2026-09-19 域迁 `ai1net.com` 后旧域仅作过渡装置)。
用法: 用法:
python3 scripts/docs-consistency.py python3 scripts/docs-consistency.py
@@ -1,6 +1,6 @@
--- ---
name: dsh-change-workflow 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 version: 2.9.2-playwright-banned
updated_at: 2026-09-15 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 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 踩过)。 > ——对它抓页面只会拿到 301 HTML,**别误判为"改动没生效"**(2026-09-11 踩过)。
> 平台页由门户直出(`127.0.0.1:3080`),生效副本只有 `/opt/dshs/web/` 一份。 > 平台页由门户直出(`127.0.0.1:3080`),生效副本只有 `/opt/dshs/web/` 一份。
> 存量会话提示(档案 45)落在 `web/login.html`:告知「shell 频繁要求审批 → **新开一个会话**即可」。 > 存量会话提示(档案 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`, > 再由门户的 `src/supervisor/proxy.ts` 转发到实例 → **想改"子域访问行为"要改 `proxy.ts`,
> nginx 的 `error_page` / `location` 到不了实例层**(档案 49 实测)。 > nginx 的 `error_page` / `location` 到不了实例层**(档案 49 实测)。
> 实例空闲回收后首次访问的链路(档案 49):**导航**(GET+`Accept: text/html`)→ 立刻 302 到 > 实例空闲回收后首次访问的链路(档案 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 禁止使用,加到规则中**」): > 🔴 **浏览器自动化:只允许两件工具;Playwright 全面禁止**(用户 2026-09-13 明令:「**playwright 禁止使用,加到规则中**」):
> ① **`agent-browser`**(WorkBuddy 内置技能,**独立浏览器,不碰用户环境**)—— **本项目默认走这条**(2026-09-13 选定); > ① **`agent-browser`**(WorkBuddy 内置技能,**独立浏览器,不碰用户环境**)—— **本项目默认走这条**(2026-09-13 选定);
> ② **`browser-harness`**(CDP **9223**,**附着用户正在用的 Chrome**)—— 只在需要看用户现场时用, > ② **`browser-harness`**(CDP **9223**,**附着用户正在用的 Chrome**)—— 只在需要看用户现场时用,
> &nbsp;&nbsp;⛔ **动手前必须先 `list_tabs()`**;旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.alotbuy.com` 的 `sid`**。 > &nbsp;&nbsp;⛔ **动手前必须先 `list_tabs()`**;旧事故:在其浏览器里登录测试号 → **覆盖了用户的 `.ai1net.com` 的 `sid`**。
> ⛔ **禁止 Playwright / `playwright-core`**(含「独立无头」用法)—— 本行**更正**此前把它写成「只读验收默认路径」的错 > ⛔ **禁止 Playwright / `playwright-core`**(含「独立无头」用法)—— 本行**更正**此前把它写成「只读验收默认路径」的错
> &nbsp;&nbsp;(那是 2026-09-11 加的,与同页「禁 Playwright」**自相矛盾**;2026-09-13 已按用户明令统一为**禁止**)。 > &nbsp;&nbsp;(那是 2026-09-11 加的,与同页「禁 Playwright」**自相矛盾**;2026-09-13 已按用户明令统一为**禁止**)。
> 静态页与 API 取数优先 `curl` / WebFetch,**别动浏览器**。 > 静态页与 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`) | | 服务器 | 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) | | 门户 | 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) | | 数据 | `/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) | | 实例 | 门户 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/` 已废弃」——那是**更早的状态,现已作废** | | 文档 | **本机 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/` 机制**已不存在**(原文作废) | | 文档同步 | 改完跑**四件套**:`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/代理): 4. 本机直连验证(绕 DNS/代理):
```bash ```bash
# SECURE_COOKIES=true → 必须走 TLS;用 --resolve 把域名指到回环 # 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>" # 带登录态:-H "Cookie: sid=<token>"
``` ```
(明文 `http://127.0.0.1:3080` 在 SECURE_COOKIES 下不通) (明文 `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 实证: **browser-harness 会附着到本机**正在运行的**用户日常 Chrome**(CDP **9223**)——2026-09-11 实证:
`BU_CDP_URL=9223` **不被尊重**(daemon 常驻共享,`BH_RUNTIME_DIR_SHARED=1`),换 `BU_NAME` + 非共享 runtime 重建 daemon 后 `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 就是用户浏览器的调试端口**。 `curl --noproxy '*' http://127.0.0.1:9223/json/list` 直接证实 **9223 就是用户浏览器的调试端口**。
**所以:脚本第一步永远是 `print([t["url"] for t in list_tabs()])`。** **所以:脚本第一步永远是 `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` 关掉自己开的标签。** 补救 = 删掉测试账号(cookie 失效 → 用户重新登录即可)。**收尾必须 `close_tab` 关掉自己开的标签。**
### 正确调用(Windows,2026-09-11 实测可用) ### 正确调用(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 后 **登录态**: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)。 用户侧真实浏览器完全不受影响(不覆盖其 sid)。
**三个必踩坑**: **三个必踩坑**:
@@ -1,6 +1,6 @@
--- ---
name: dsh-decision-method 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 version: 2.8.0
updated_at: 2026-09-16 updated_at: 2026-09-16
created_from: 本工作区 62 份改造档案 + 5 天工作日志(2026-09-08 ~ 09-12)全量提炼 created_from: 本工作区 62 份改造档案 + 5 天工作日志(2026-09-08 ~ 09-12)全量提炼
@@ -1,6 +1,6 @@
--- ---
name: dsh-feature-first 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 version: 1.7.1
updated_at: 2026-09-16 updated_at: 2026-09-16
agent_created: true agent_created: true
@@ -1,6 +1,6 @@
--- ---
name: dsh-instance-diagnose 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 version: 1.1.0
updated_at: 2026-09-18 updated_at: 2026-09-18
last_change: 2026-09-15:死亡路径由 2 条扩到 4 条 —— 新增 C(bwrap 挂载点被嵌套 tmpfs 遮蔽(`Can't chdir to <正常路径>`))与 D(迁移复现不了启动参数(`Can't chdir to :` 空路径)),含分诊判据与「中间目录必须统一前置」的修法。 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();\"" 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` 读)**: **判定用的哨兵与元素(直接 `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('.')` 去最左一段),彻底去硬编码 | — | | ↳ **特例** | `web/wake.html` 的 `safeNext()` 正则 | 改成**运行时从 `location.hostname` 推导注册域**(`split('.')` 去最左一段),彻底去硬编码 | — |
| 服务器公网 IP | `47.77.182.89` | `<SERVER_PUBLIC_IP>` | 1 | | 服务器公网 IP | `47.77.182.89` | `<SERVER_PUBLIC_IP>` | 1 |
| 宿主内网 IP | `172.18.16.212` | `<HOST_LAN_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` 做比较基准。** 1. **`text` 被重新赋值导致替换未落盘** —— 脱敏函数里 `text = text.replace(...)` 之后再用 `if text2 != text` 判「是否需要写盘」⇒ 全局替换**永远不落盘**(只落行级改动)。**必须留 `orig = text` 做比较基准。**
2. **行级重写的 key 必须写「全局替换之后的文本」** —— 例:原行 `(档案 70 的 anysearch 事故…)`,key 要写 `(档案 70 的 该插件 事故…)`。用替换前的原文当 key ⇒ 静默不命中,留下半吊子句子。 2. **行级重写的 key 必须写「全局替换之后的文本」** —— 例:原行 `(档案 70 的 anysearch 事故…)`,key 要写 `(档案 70 的 该插件 事故…)`。用替换前的原文当 key ⇒ 静默不命中,留下半吊子句子。
3. **行级重写会丢缩进** —— 按 `strip()` 匹配就必须把**原行的 indent 补回去**(多行替换值还要给后续行也加 indent),否则 TS 缩进错乱。 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 文件逐字节一致。 5. **重建会抹掉手写文件** —— 所以有 `OVERLAY`(先快照 `_overlay/` 后恢复)+ `--force` 安全闸。**验证过一次**:重建后 5/5 个 overlay 文件逐字节一致。
6. **`.git` 绝不能带过去** —— 历史里含全部内部信息;必须是**全新仓库**。 6. **`.git` 绝不能带过去** —— 历史里含全部内部信息;必须是**全新仓库**。
7. **Windows 下 `install.sh` 没有执行位** —— README 一律写 `sudo bash install.sh`;要执行位就 `chmod +x` 后再提交。 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 "/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>" diff -u "D:/github/dsh_shenxian/<f>" "dsh-web-platform/<f>"
@@ -1,6 +1,6 @@
--- ---
name: dsh-plugin-diagnose 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 version: 1.0.0
updated_at: 2026-09-13 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 条实测踩坑。 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. 取实例页面(**不碰用户浏览器**,用临时会话走平台代理): 1. 取实例页面(**不碰用户浏览器**,用临时会话走平台代理):
```bash ```bash
SID=$(node /opt/dshs/mksess-guest.cjs) # 需对应用户;用完必须删会话 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 正则——会漏行): 2. 从页面里取**实际下发的客户端插件集合**(权威清单,别用 manifest 正则——会漏行):
```python ```python
+2 -2
View File
@@ -23,8 +23,8 @@
"dev": "node lib/cli.js", "dev": "node lib/cli.js",
"typecheck": "tsc -p tsconfig.json --noEmit", "typecheck": "tsc -p tsconfig.json --noEmit",
"check:layering": "node scripts/check-layering.mjs", "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", "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/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", "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": "node scripts/smoke.mjs",
"smoke:admin": "node scripts/smoke-admin.mjs", "smoke:admin": "node scripts/smoke-admin.mjs",
"smoke:auth": "node scripts/smoke-auth.mjs", "smoke:auth": "node scripts/smoke-auth.mjs",
+9 -4
View File
@@ -942,14 +942,19 @@ async function main() {
/* ═══ 幕 4(序⑧):**冷却语义拆分**的真机判据(E9 / 构 A) ═══ /* ═══ 幕 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 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住 * 都在 47)⇒ 一次 47 故障会把它们**同时**耗进冷却 ⇒ 杀另一台时"唯一可能的出路"被自己设的冷却挡住
* ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。 * ⇒ 真机读数 `仍在冷却(剩 59201ms / 共 300000ms)` ⇒ **最长 ~300 s 不切流**。
* *
* 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106 * 序列:① 归零(重启 Manager ⇒ 通道回目录首位 47)→ ② 停 47 ⇒ 切 106
* (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`alotbuy` = switched-away) * (47 的**两条**候选各按自己的原因进冷却:`relay-direct` = open-failed,`ai1net` = switched-away)
* → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。 * → ③ 恢复 47 → ④ 停 106 ⇒ 此刻"当前挂了 + 所有候选都在冷却" = **D6 现场**。
* *
* 🔴 **2026-09-19 域迁 `ai1net.com` 修正**:本幕判定"目标是不是 47 那台"原先**写死** `alotbuy.com`,
* 域一切就恒判失败(**假红**:豁免实际已生效并切回 47,仅字面匹配不上)。
* ⇒ 现一律改用参数表的 `DRILL_KILLED_MATCH`(= 目录 `relays[]` 首位的 host),
* ⛔ 不再在脚本里写死域名 —— 下次换域只改参数表一处。
*
* ⚠️ 本幕**会重启 Manager**(为了①的确定性 + 施加演练期覆盖);`finally` 一律清覆盖并重启回生产值。 * ⚠️ 本幕**会重启 Manager**(为了①的确定性 + 施加演练期覆盖);`finally` 一律清覆盖并重启回生产值。
*/ */
const runScene4 = async (variant) => { const runScene4 = async (variant) => {
@@ -1037,7 +1042,7 @@ async function main() {
`${tag}-C 冷却过期后**自然**切回 47 ⇒ 归因收敛到"冷却语义"而非方向逻辑(E9-b 归因)`, `${tag}-C 冷却过期后**自然**切回 47 ⇒ 归因收敛到"冷却语义"而非方向逻辑(E9-b 归因)`,
hit3.err !== '' hit3.err !== ''
? 'FAIL' ? 'FAIL'
: hit3.ok && m3 !== undefined && m3.includes('alotbuy.com') : hit3.ok && m3 !== undefined && m3.includes(killedMatch)
? 'PASS' ? 'PASS'
: 'FAIL', : 'FAIL',
hit3.err !== '' hit3.err !== ''
@@ -1051,7 +1056,7 @@ async function main() {
const hit2 = await waitSwitch(sinceB, detectBudgetMs) const hit2 = await waitSwitch(sinceB, detectBudgetMs)
const last2 = hit2.ok ? hit2.lines.split('\n').slice(-1)[0] : '' const last2 = hit2.ok ? hit2.lines.split('\n').slice(-1)[0] : ''
const m2 = hit2.ok ? (/->\s*(\S+?)((|$)/.exec(last2) ?? [])[1] : undefined 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 exemptMark = /|豁免 kind=(switched-away|open-failed) 剩 \d+ms/.test(last2)
const healthReason = last2.includes('原因:当前通道不健康') const healthReason = last2.includes('原因:当前通道不健康')
if (hit2.ok) process.stdout.write(`# ${tag} ④ 后的切换原文:${last2}\n`) if (hit2.ok) process.stdout.write(`# ${tag} ④ 后的切换原文:${last2}\n`)
+1 -1
View File
@@ -1637,7 +1637,7 @@ function main() {
* ② **`count ≥ CAND_MIN`**(每连接候选数 ≥ 2)。 * ② **`count ≥ CAND_MIN`**(每连接候选数 ≥ 2)。
* *
* 🔑 **`hosts`(主机名个数)只作信息输出、⛔ 不作判据** —— 且它**不是「独立物理路径数」**: * 🔑 **`hosts`(主机名个数)只作信息输出、⛔ 不作判据** —— 且它**不是「独立物理路径数」**:
* 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `alotbuy.com` 与 `relay-direct.alotbuy.com` * 观测器**不解析 DNS**(零网络),而生产目录前两条候选 `ai1net.com` 与 `relay-direct.ai1net.com`
* **摘名不同、同落 47** ⇒ **真机实测 `count=3` 时 `hosts` 也报 3**,而机器级独立路径只有 **2**(47 + 106)。 * **摘名不同、同落 47** ⇒ **真机实测 `count=3` 时 `hosts` 也报 3**,而机器级独立路径只有 **2**(47 + 106)。
* 故本项**照实判「条数」**,把 `hosts` 打出来供人判「冗余建成」,⛔ **不放宽判据凑绿**; * 故本项**照实判「条数」**,把 `hosts` 打出来供人判「冗余建成」,⛔ **不放宽判据凑绿**;
* 真机首轮读数(2026-09-18 00:0x)已实证 **worker 侧 `count=1`**(根因见回报)。 * 真机首轮读数(2026-09-18 00:0x)已实证 **worker 侧 `count=1`**(根因见回报)。
+3 -3
View File
@@ -2,7 +2,7 @@
* T08 · **域名形态访问**验证(在演练环境做:不动生产、不动 DNS、不动证书)。 * T08 · **域名形态访问**验证(在演练环境做:不动生产、不动 DNS、不动证书)。
* *
* 要回答的问题:生产切到 cluster(Manager 在 47、实例在 106)后, * 要回答的问题:生产切到 cluster(Manager 在 47、实例在 106)后,
* **按域名形态访问**(`<用户名>.alotbuy.com`)还能不能正常落到 106 上的实例? * **按域名形态访问**(`<用户名>.ai1net.com`)还能不能正常落到 106 上的实例?
* *
* 做法:给演练 Manager 设一个**测试 baseDomain**,用**显式 `Host` 头**打进去。 * 做法:给演练 Manager 设一个**测试 baseDomain**,用**显式 `Host` 头**打进去。
* *
@@ -10,7 +10,7 @@
* (Fetch 规范把它列为禁止头,undici 直接忽略)⇒ 请求落到"无租户"的门户路由、回 200 门户页, * (Fetch 规范把它列为禁止头,undici 直接忽略)⇒ 请求落到"无租户"的门户路由、回 200 门户页,
* 看起来"验证通过"其实是假的。⇒ **必须用 curl(`-H Host:`)**,且判据不能只看状态码。 * 看起来"验证通过"其实是假的。⇒ **必须用 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 \ * AGENT=http://127.0.0.1:19000 AGENT_TOKEN=cross-machine-token \
* node scripts/verify-cluster-domain.mjs * node scripts/verify-cluster-domain.mjs
*/ */
@@ -18,7 +18,7 @@ import { execFileSync } from 'node:child_process'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
const MANAGER = process.env.MANAGER ?? 'http://127.0.0.1:13080' 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 AGENT = process.env.AGENT ?? 'http://127.0.0.1:19000'
const TOKEN = process.env.AGENT_TOKEN ?? 'cross-machine-token' const TOKEN = process.env.AGENT_TOKEN ?? 'cross-machine-token'
const ADMIN_PW = process.env.ADMIN_PW ?? 'crossmgr123' const ADMIN_PW = process.env.ADMIN_PW ?? 'crossmgr123'
+2 -2
View File
@@ -34,7 +34,7 @@ const jsxRuntime = { jsx, jsxs: jsx, Fragment: "Fragment" };
let def = null; let def = null;
const win = { const win = {
__ModuleLoader__: { load: (d) => { def = d; } }, __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; }, open: (u) => { globalThis.__OPENED = u; },
document: { document: {
createElement: () => { createElement: () => {
@@ -80,7 +80,7 @@ const sandbox = {
Math, Date, encodeURIComponent, decodeURIComponent, Math, Date, encodeURIComponent, decodeURIComponent,
window: win, document: win.document, window: win, document: win.document,
fetch: async (url) => { 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]; 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 }; return { ok: body !== undefined, status: body === undefined ? 404 : 200, json: async () => body };
}, },
+21
View File
@@ -81,5 +81,26 @@ for (const f of readdirSync(WEB).filter((f) => /\.(css|js)$/.test(f))) {
else console.log(' ✓ ' + 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 + ' 项不合格 ❌' : '结论:全部合格 ✅') console.log(bad ? '结论:' + bad + ' 项不合格 ❌' : '结论:全部合格 ✅')
process.exit(bad ? 1 : 0) process.exit(bad ? 1 : 0)
+209 -3
View File
@@ -17,6 +17,9 @@ export type IsolationMode = 'soft' | 'account'
* `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */ * `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */
export type DeployMode = 'local' | 'k8s' | 'cluster' export type DeployMode = 'local' | 'k8s' | 'cluster'
/** 外发邮件驱动(注册验证码)。见 `web/mail.ts`。 */
export type MailDriver = 'brevo' | 'http' | 'log'
/** Resolved, immutable runtime configuration. */ /** Resolved, immutable runtime configuration. */
export interface ServerConfig { export interface ServerConfig {
/** Bind host for the orchestrator HTTP server. */ /** Bind host for the orchestrator HTTP server. */
@@ -119,7 +122,7 @@ export interface ServerConfig {
*/ */
clusterRendezvousUrl: string clusterRendezvousUrl: string
/** /**
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。 * **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://ai1net.com/dshs-relay`。
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。 * 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
*/ */
relayUrl: string relayUrl: string
@@ -195,6 +198,57 @@ export interface ServerConfig {
overlayNodeKeyFile: string overlayNodeKeyFile: string
/** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */ /** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */
overlayNodeGrantFile: string 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. */ /** Untyped overrides collected from argv / env. */
@@ -258,6 +312,22 @@ export interface ConfigOverrides {
overlayDirTrustedKeys?: string[] overlayDirTrustedKeys?: string[]
overlayDirKeyFile?: string overlayDirKeyFile?: string
overlayDirectoryCacheFile?: 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' 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_NAMESPACE = 'dsh'
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator' const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull' 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:**内置种子**(引导链的常量位)。 * 覆盖网络 P0-2:**内置种子**(引导链的常量位)。
* 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。 * 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。
* ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。 * ⚠️ 目录端点路径由 `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 /** Load the encryption secret from env, or persist a generated one at
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */ * `<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 !== ''))] 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`)⇒ 返回 * 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回
* `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。 * `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。
@@ -395,6 +554,8 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
toDeployMode(overrides.deployMode) ?? toDeployMode(overrides.deployMode) ??
toDeployMode(process.env.DSHS_DEPLOY_MODE) ?? toDeployMode(process.env.DSHS_DEPLOY_MODE) ??
DEFAULT_DEPLOY_MODE DEFAULT_DEPLOY_MODE
// `baseDomain` 提前算出:Turnstile 的**期望主机名白名单**要从它派生(见下)。
const baseDomainResolved = overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN
return { return {
host: overrides.host ?? DEFAULT_HOST, host: overrides.host ?? DEFAULT_HOST,
port: typeof port === 'number' ? port : Number(port), port: typeof port === 'number' ? port : Number(port),
@@ -447,7 +608,7 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
isolationMode, isolationMode,
spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND, spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND,
baseUid: Number(overrides.baseUid ?? process.env.DSHS_BASE_UID ?? DEFAULT_BASE_UID), 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, cookieDomain: overrides.cookieDomain ?? process.env.DSHS_COOKIE_DOMAIN ?? DEFAULT_COOKIE_DOMAIN,
enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH), enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH),
portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false), portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false),
@@ -529,5 +690,50 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
// 表现成"一切正常",等 relay 一开强制就整台失联)。 // 表现成"一切正常",等 relay 一开强制就整台失联)。
overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(), overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(),
overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_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,
),
},
} }
} }
+21
View File
@@ -19,7 +19,10 @@ import type {
DshInstance, DshInstance,
DshInstanceRole, DshInstanceRole,
DshInstanceStatus, DshInstanceStatus,
EmailCodeCounts,
EmailCodeRow,
PublicUser, PublicUser,
RecordEmailCodeInput,
SessionRow, SessionRow,
SessionUser, SessionUser,
UpsertBusinessPluginInput, UpsertBusinessPluginInput,
@@ -57,6 +60,24 @@ export interface DbAdapter {
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined> findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
/** Whether the user has any session that has not yet expired (idle reap). */ /** Whether the user has any session that has not yet expired (idle reap). */
hasActiveSession(userId: string): Promise<boolean> 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
audit(actor: string | null, action: string, detail?: string | null): Promise<void> audit(actor: string | null, action: string, detail?: string | null): Promise<void>
// workspaces / plugins // workspaces / plugins
+96 -4
View File
@@ -16,6 +16,7 @@ import {
toDomain, toDomain,
toDshHost, toDshHost,
toDshInstance, toDshInstance,
toEmailCode,
toPublicUser, toPublicUser,
toSession, toSession,
toUser, toUser,
@@ -33,7 +34,10 @@ import {
type DshInstance, type DshInstance,
type DshInstanceRole, type DshInstanceRole,
type DshInstanceStatus, type DshInstanceStatus,
type EmailCodeCounts,
type EmailCodeRow,
type PublicUser, type PublicUser,
type RecordEmailCodeInput,
type SessionRow, type SessionRow,
type SessionUser, type SessionUser,
type UpsertBusinessPluginInput, type UpsertBusinessPluginInput,
@@ -50,7 +54,9 @@ import {
// both backends. This is process-global and idempotent. // both backends. This is process-global and idempotent.
types.setTypeParser(20, (value: string) => Number(value)) 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 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 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' 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 { try {
return await withTx(this.pool, async (client) => { return await withTx(this.pool, async (client) => {
const { rows } = await client.query( 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', 'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) '
[input.id, input.username, input.passHash, input.role, input.homeDir, createdAt], + '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) 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]) 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, created_at: createdAt,
approved_by: null, approved_by: null,
uid, uid,
email: input.email ?? null,
} }
}) })
} catch (e) { } 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]) 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 return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
} }
async findUserBySlug(slug: string): Promise<User | undefined> { async findUserBySlug(slug: string): Promise<User | undefined> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [ const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [
slug.toLowerCase(), slug.toLowerCase(),
@@ -153,6 +160,91 @@ export class PgAdapter implements DbAdapter {
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined 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[]> { async listPublicUsers(): Promise<PublicUser[]> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`) 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>))) return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>)))
+80 -3
View File
@@ -17,6 +17,7 @@ import {
toDomain, toDomain,
toDshHost, toDshHost,
toDshInstance, toDshInstance,
toEmailCode,
toPublicUser, toPublicUser,
toSession, toSession,
toUser, toUser,
@@ -34,7 +35,10 @@ import {
type DshInstance, type DshInstance,
type DshInstanceRole, type DshInstanceRole,
type DshInstanceStatus, type DshInstanceStatus,
type EmailCodeCounts,
type EmailCodeRow,
type PublicUser, type PublicUser,
type RecordEmailCodeInput,
type SessionRow, type SessionRow,
type SessionUser, type SessionUser,
type UpsertBusinessPluginInput, type UpsertBusinessPluginInput,
@@ -45,7 +49,9 @@ import {
type Workspace, type Workspace,
} from './types.js' } 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 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 BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const INSTANCE_COLS = const INSTANCE_COLS =
@@ -56,8 +62,8 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
const createdAt = Date.now() const createdAt = Date.now()
return db.transaction((): User => { return db.transaction((): User => {
const info = prepare(db, const info = prepare(db,
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)', '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, createdAt) ).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. // SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
const uid = baseUid + Number(info.lastInsertRowid) const uid = baseUid + Number(info.lastInsertRowid)
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id) 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, created_at: createdAt,
approved_by: null, approved_by: null,
uid, 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 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 { export function findUserById(db: Database, id: string): User | undefined {
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id) const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id)
return row ? toUser(row as Record<string, unknown>) : undefined return row ? toUser(row as Record<string, unknown>) : undefined
+61
View File
@@ -382,6 +382,66 @@ const PG_V9 = `
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops'; 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 { interface Migration {
version: number version: number
name: string 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: 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: 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: 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. */ /** Apply unapplied SQLite migrations inside a single transaction. */
+39
View File
@@ -14,6 +14,8 @@ import { openDatabase, type Database } from './connection.js'
import { mapSqliteError } from './errors.js' import { mapSqliteError } from './errors.js'
import { import {
audit as auditSync, audit as auditSync,
bumpEmailCodeAttempts as bumpEmailCodeAttemptsSync,
consumeEmailCode as consumeEmailCodeSync,
countAdmins as countAdminsSync, countAdmins as countAdminsSync,
createSession as createSessionSync, createSession as createSessionSync,
createUser as createUserSync, createUser as createUserSync,
@@ -24,6 +26,7 @@ import {
deleteUser as deleteUserSync, deleteUser as deleteUserSync,
deleteUserInstances as deleteUserInstancesSync, deleteUserInstances as deleteUserInstancesSync,
deleteUserSessions as deleteUserSessionsSync, deleteUserSessions as deleteUserSessionsSync,
emailCodeCounts as emailCodeCountsSync,
findBusinessPlugin as findBusinessPluginSync, findBusinessPlugin as findBusinessPluginSync,
findDomainById as findDomainByIdSync, findDomainById as findDomainByIdSync,
findDomainByUser as findDomainByUserSync, findDomainByUser as findDomainByUserSync,
@@ -31,6 +34,7 @@ import {
findSession as findSessionSync, findSession as findSessionSync,
findSessionWithUser as findSessionWithUserSync, findSessionWithUser as findSessionWithUserSync,
hasActiveSession as hasActiveSessionSync, hasActiveSession as hasActiveSessionSync,
findUserByEmail as findUserByEmailSync,
findUserById as findUserByIdSync, findUserById as findUserByIdSync,
findUserBySlug as findUserBySlugSync, findUserBySlug as findUserBySlugSync,
findUserByUsername as findUserByUsernameSync, findUserByUsername as findUserByUsernameSync,
@@ -40,6 +44,7 @@ import {
getEnabledPluginIds as getEnabledPluginIdsSync, getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync, getOrCreateWorkspace as getOrCreateWorkspaceSync,
getSharedModelEnabled as getSharedModelEnabledSync, getSharedModelEnabled as getSharedModelEnabledSync,
latestSentEmailCode as latestSentEmailCodeSync,
listBusinessPlugins as listBusinessPluginsSync, listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync, listCredentialKeys as listCredentialKeysSync,
listCredentialLandingRows as listCredentialLandingRowsSync, listCredentialLandingRows as listCredentialLandingRowsSync,
@@ -48,6 +53,8 @@ import {
listInstancesByRole as listInstancesByRoleSync, listInstancesByRole as listInstancesByRoleSync,
listPublicUsers as listPublicUsersSync, listPublicUsers as listPublicUsersSync,
listUsersWithoutUid as listUsersWithoutUidSync, listUsersWithoutUid as listUsersWithoutUidSync,
purgeEmailCodes as purgeEmailCodesSync,
recordEmailCode as recordEmailCodeSync,
selectCredentialKey as selectCredentialKeySync, selectCredentialKey as selectCredentialKeySync,
setCredentialKey as setCredentialKeySync, setCredentialKey as setCredentialKeySync,
setDomainVerified as setDomainVerifiedSync, setDomainVerified as setDomainVerifiedSync,
@@ -86,7 +93,10 @@ import type {
DshInstance, DshInstance,
DshInstanceRole, DshInstanceRole,
DshInstanceStatus, DshInstanceStatus,
EmailCodeCounts,
EmailCodeRow,
PublicUser, PublicUser,
RecordEmailCodeInput,
SessionRow, SessionRow,
SessionUser, SessionUser,
UpsertBusinessPluginInput, UpsertBusinessPluginInput,
@@ -176,6 +186,35 @@ export class SqliteAdapter implements DbAdapter {
auditSync(this.db, actor, action, detail) 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> { async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
return findWorkspaceByPathSync(this.db, userId, relPath) return findWorkspaceByPathSync(this.db, userId, relPath)
} }
+69
View File
@@ -23,8 +23,29 @@ export interface User {
approved_by: string | null approved_by: string | null
/** Assigned Linux uid; null until backfilled/assigned (legacy rows). */ /** Assigned Linux uid; null until backfilled/assigned (legacy rows). */
uid: number | null 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. */ /** The user shape safe to return over the wire. */
export interface PublicUser { export interface PublicUser {
id: string id: string
@@ -194,6 +215,36 @@ export interface CreateUserInput {
passHash: string passHash: string
role: UserRole role: UserRole
homeDir: string 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 { export interface CreateSessionInput {
@@ -236,6 +287,24 @@ export function toUser(row: Record<string, unknown>): User {
created_at: row.created_at as number, created_at: row.created_at as number,
approved_by: (row.approved_by as string | null) ?? null, approved_by: (row.approved_by as string | null) ?? null,
uid: (row.uid as number | 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 -2
View File
@@ -2,7 +2,7 @@
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。 * 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
* *
* ## 它解决的唯一问题 * ## 它解决的唯一问题
* 兜底入口 `relay-direct.alotbuy.com` 与主入口 `alotbuy.com` **同属 `*.alotbuy.com`**, * 兜底入口 `relay-direct.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**,
* 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于 * 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于
* "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果 * "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果
* **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。 * **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。
@@ -23,7 +23,7 @@
* *
* ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5) * ## 配置(**独立配置项**;⛔ 不塞进 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"更难排查)。 * 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。
* *
+2 -2
View File
@@ -78,7 +78,7 @@ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
* ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量, * ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量,
* **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。 * **改动必须两处同改**(与 `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[] { export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] {
@@ -321,7 +321,7 @@ export function buildDirectoryDocument(input: {
/** /**
* 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。 * 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。
* *
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://alotbuy.com/dshs-relay`, * 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://ai1net.com/dshs-relay`,
* 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。 * 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。
* 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。 * 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。
*/ */
+1 -1
View File
@@ -24,7 +24,7 @@ import type { AddressLookup, Rendezvous } from '../rendezvous.js'
import { parseLogicalName } from './network.js' import { parseLogicalName } from './network.js'
export interface RelayRendezvousOptions { export interface RelayRendezvousOptions {
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.alotbuy.com/dshs-relay`。 */ /** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.ai1net.com/dshs-relay`。 */
dialTargetUrl: string dialTargetUrl: string
/** /**
* **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。 * **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。
+2 -2
View File
@@ -329,7 +329,7 @@ export class RelayFailoverSupervisor {
* *
* 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与 * 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与
* **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。 * **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。
* 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前 * 首轮真机实测(11:43:26):`wss://106… -> wss://ai1net.com…` —— 而 `ai1net.com` 十几分钟前
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来 * **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。 * ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。 * ⇒ 统一在这一处把关:**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"、不排除"刚试失败的候选", * ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒ * 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
* 链虽然"不再退化成单点",却依然**换不过去**。 * 链虽然"不再退化成单点",却依然**换不过去**。
+362
View File
@@ -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
View File
@@ -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 }
}
+125
View File
@@ -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
View File
@@ -14,6 +14,18 @@ import { catalogDiagnostics, isCatalogProvider, isCnProvider, listCatalogProvide
import { PROTOCOLS } from '../model-landing.js' import { PROTOCOLS } from '../model-landing.js'
import { readTextOrEmpty, writeHomeFile } from '../home-files.js' import { readTextOrEmpty, writeHomeFile } from '../home-files.js'
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.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 { join } from 'node:path'
import { import {
clearSessionCookie, clearSessionCookie,
@@ -42,10 +54,87 @@ const registerSchema = {
properties: { properties: {
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' }, username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
password: { type: 'string', minLength: 8, maxLength: 128 }, 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 } 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 = { const loginSchema = {
body: { body: {
type: 'object', type: 'object',
@@ -64,23 +153,127 @@ interface Credentials {
} }
export const authRoutes: FastifyPluginAsync = async (app) => { 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( app.post(
'/api/auth/register', '/api/auth/register',
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } }, { schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
async (request, reply) => { 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) { if ((await app.db.findUserByUsername(username)) !== undefined) {
return reply.code(409).send({ error: 'username_taken' }) 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 id = randomUUID()
const homeDir = homeRoot(userRoot(app.config.dataRoot, id)) const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
const passHash = await hashPassword(password) const passHash = await hashPassword(password)
// Create the user first so `initUserRoot` resolves the DB-assigned uid // Create the user first so `initUserRoot` resolves the DB-assigned uid
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and // (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. // 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.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' } }) return reply.code(201).send({ user: { id, username, role: 'pending' } })
}, },
) )
+3 -3
View File
@@ -1127,9 +1127,9 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
await registerDshProxy(app) await registerDshProxy(app)
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section // CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
// runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on // runs in the browser on `<user>.dsh.ai1net.com` and calls portal APIs on
// `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with // `dsh.ai1net.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
// Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow // Domain=.dsh.ai1net.com, so credentials ride along; we only need to allow
// the Origin. Restricted to the platform base domain and its subdomains. // the Origin. Restricted to the platform base domain and its subdomains.
app.addHook('onRequest', async (request, reply) => { app.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin const origin = request.headers.origin
+115
View File
@@ -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)}` }
}
}
+3 -3
View File
@@ -32,7 +32,7 @@ import type { RelayChannelHandle, RelayFailoverThresholds } from '../net/relay/s
import type { WorkerTunnel } from './tunnel.js' import type { WorkerTunnel } from './tunnel.js'
export interface RelayTunnelOptions { 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 url: string
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */ /** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
hostId: string hostId: string
@@ -93,7 +93,7 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea
* - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`); * - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`);
* - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**: * - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**:
* 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选 * 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 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**, * 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够", * 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用); * "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
@@ -119,7 +119,7 @@ export function candidateObsMs(env: Record<string, string | undefined> = process
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。 * 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
* *
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒ * 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
* 本数**不是独立物理路径数**(真机实证:`alotbuy.com` 与 `relay-direct.alotbuy.com` 都在 47, * 本数**不是独立物理路径数**(真机实证:`ai1net.com` 与 `relay-direct.ai1net.com` 都在 47,
* 但 `count=3` 时 `hosts` 也报 3)。 * 但 `count=3` 时 `hosts` 也报 3)。
*/ */
function candHostsOf(urls: readonly string[]): number { function candHostsOf(urls: readonly string[]): number {
+93
View File
@@ -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})`)
}
})
+19 -19
View File
@@ -126,7 +126,7 @@ function refusingFetch(calls) {
test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => { test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
// 同源约定:引导地址(中继入口)→ 目录端点 = 同 origin + 固定路径 // 同源约定:引导地址(中继入口)→ 目录端点 = 同 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('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}`) 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}`) assert.equal(directoryUrlFor('wss://a.example/dshs-relay'), `https://a.example${DIRECTORY_PATH}`)
// 中继地址归一化:只改协议、⛔ 不动 path(`/dshs-relay` 是 nginx location 的判据) // 中继地址归一化:只改协议、⛔ 不动 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('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('wss://a.example/x'), 'wss://a.example/x')
assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝') assert.equal(toRelayUrl('file:///etc/passwd'), undefined, '非 http/ws 协议必须拒绝')
@@ -178,20 +178,20 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
'http://100.64.7.7/dshs-relay', 'http://100.64.7.7/dshs-relay',
'http://relaybox/dshs-relay', 'http://relaybox/dshs-relay',
'http://[::1]/dshs-relay', 'http://[::1]/dshs-relay',
'https://alotbuy.com/dshs-relay', 'https://ai1net.com/dshs-relay',
'https://relay.example.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) // 语义去重:`wss://host/dshs-relay` 与 `https://host/dshs-relay` 是**同一个端点**(都走 443)
// ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继) // ⇒ 目录里只该出现第一条(否则读目录的人会以为有两个中继)
assert.deepEqual( assert.deepEqual(
publicRelayEntries(['wss://alotbuy.com/dshs-relay', 'https://alotbuy.com/dshs-relay']), publicRelayEntries(['wss://ai1net.com/dshs-relay', 'https://ai1net.com/dshs-relay']),
['wss://alotbuy.com/dshs-relay'], ['wss://ai1net.com/dshs-relay'],
) )
// …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留 // …而 `http://`(80)与 `wss://`(443)**不是**同一个端点 ⇒ 两条都留
assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [ assert.deepEqual(publicRelayEntries(['wss://a.example/x', 'http://a.example/x']), [
@@ -210,7 +210,7 @@ test('B1 纯函数:载荷稳定、路径派生、地址清洗', () => {
test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => { test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律拒绝', () => {
const keys = makeKeys() const keys = makeKeys()
const other = 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) const { doc, sig } = signedDoc(origin, keys.privatePem)
// 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验 // 正例:PEM 与**裸 32 字节 hex**两条解析路径都要能验
@@ -248,7 +248,7 @@ test('B2 验签:正例通过;改内容 / 换密钥 / 无受信密钥一律
test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => { test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级', async () => {
const keys = makeKeys() const keys = makeKeys()
const seed = 'https://alotbuy.com/dshs-relay' const seed = 'https://ai1net.com/dshs-relay'
// ① env 显式 ⇒ 压制引导链(**一次网络都不发**) // ① env 显式 ⇒ 压制引导链(**一次网络都不发**)
{ {
@@ -279,7 +279,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
fetchImpl: refusingFetch(calls), fetchImpl: refusingFetch(calls),
}) })
assert.equal(r.source, 'cache') 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, [], '新鲜缓存不许联网') assert.deepEqual(calls, [], '新鲜缓存不许联网')
} finally { } finally {
rmSync(dir, { recursive: true, force: true }) rmSync(dir, { recursive: true, force: true })
@@ -298,7 +298,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
fetchImpl: refusingFetch(calls), fetchImpl: refusingFetch(calls),
}) })
assert.equal(r.source, 'seed-fallback') 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.ok(calls.length >= 1, '应当尝试过取目录')
assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存') assert.equal(existsSync(file), false, '取不到目录**不许**留下缓存')
} finally { } finally {
@@ -320,7 +320,7 @@ test('B3 决策:env 压制一切 / 新鲜缓存不联网 / 取不到则降级'
fetchImpl: refusingFetch([]), fetchImpl: refusingFetch([]),
}) })
assert.equal(r.source, 'stale-cache') 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) assert.equal(readCachedDirectory(file, [makeKeys().publicPem], Date.now()), undefined)
} finally { } finally {
@@ -685,8 +685,8 @@ test('序④·L1-E 撤销后回到未配状态(可回滚)', async () => {
* **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」—— * **同源优先**(序④):没有它,「多一条兜底入口」落不成「CF / 门户 conf 挂时还能连」——
* `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。 * `relays[]` 首位 = 主入口,客户端会一直去连它,兜底项永远轮不到。
*/ */
const FB_MAIN = 'https://alotbuy.com/dshs-relay' const FB_MAIN = 'https://ai1net.com/dshs-relay'
const FB_ALT = 'https://relay-direct.alotbuy.com/dshs-relay' const FB_ALT = 'https://relay-direct.ai1net.com/dshs-relay'
/** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */ /** 造一份"两个入口都在"的目录,并只让**兜底 origin** 答得出(主 origin 抛错)。 */
function twoEntryDoc(keys) { function twoEntryDoc(keys) {
@@ -704,7 +704,7 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的
const logs = [] const logs = []
const fetchImpl = async (url) => { const fetchImpl = async (url) => {
calls.push(String(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' } }) return new Response(body, { status: 200, headers: { 'content-type': 'application/json' } })
} }
const r = await resolveOverlayRelay({ const r = await resolveOverlayRelay({
@@ -717,14 +717,14 @@ test('序④·L1-F 同源优先:主 origin 不可达时采用兜底 origin 的
// ① 逐个 origin 试,主 origin 被拒后才到兜底 // ① 逐个 origin 试,主 origin 被拒后才到兜底
assert.equal(calls.length, 2) assert.equal(calls.length, 2)
assert.ok( 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 拒绝原因"这一行', '缺"逐 origin 拒绝原因"这一行',
) )
// ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据) // ② 采用的是**兜底项**,而不是 relays[] 首位(这是本单 D6 的实质判据)
assert.equal(r.source, 'seed-directory') 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( 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, fetchImpl,
log: (l) => logs.push(l), 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, '首位命中时不该有多余日志') assert.equal(logs.some((l) => l.includes('同源优先')), false, '首位命中时不该有多余日志')
}) })
+489
View File
@@ -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()
}
})
+4 -4
View File
@@ -488,7 +488,7 @@ const TH8 = { minAttempts: 3, graceMs: 1000, cooldownMs: 60_000, deadlineMs: 30_
/** /**
* F12 · **目录路径没有豁免权**(E1 / D1)。 * F12 · **目录路径没有豁免权**(E1 / D1)。
* *
* 立项依据:真机 11:43:26 实测 `wss://106… -> wss://alotbuy.com…` —— 只因"目录里的地址变了" * 立项依据:真机 11:43:26 实测 `wss://106… -> wss://ai1net.com…` —— 只因"目录里的地址变了"
* 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。 * 就把刚被冷却的 47 换回来 ⇒ D5 的抖动抑制被另一条路径绕开。
*/ */
test('F12 目录路径无豁免权:origin=directory + 目标在冷却 ⇒ 必 skip(E1 / D1)', async () => { 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) const obs = new RelayCandidateObservation('manager', (l) => lines.push(l), 0)
obs.record( obs.record(
[ [
'wss://alotbuy.com/dshs-relay', 'wss://ai1net.com/dshs-relay',
'https://alotbuy.com/other', 'https://ai1net.com/other',
'wss://106.54.21.172/dshs-relay', 'wss://106.54.21.172/dshs-relay',
], ],
'cache', 'cache',
@@ -876,7 +876,7 @@ test('O1 观测行格式锁定:固定 key 序 + count 为条数 + hosts
) )
const snap = obs.snapshot() const snap = obs.snapshot()
assert.equal(snap.count, 3, 'count = 候选**条数**(⛔ 不按主机去重)') 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.source, 'cache')
assert.equal(snap.resolves, 1) assert.equal(snap.resolves, 1)
assert.equal(snap.unresolved, false) assert.equal(snap.unresolved, false)
+2 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理台</title> <title>管理台</title>
<link rel="icon" href="/logo.svg" /> <link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" /> <link rel="stylesheet" href="/design.css" />
</head> </head>
<body class="auth-bg"> <body class="auth-bg">
@@ -26,8 +26,7 @@
<div class="auth-card" style="max-width: 720px"> <div class="auth-card" style="max-width: 720px">
<div class="auth-brand"> <div class="auth-brand">
<img class="logo" src="/logo.svg" alt="logo" /> <span class="wordmark">能力枢纽</span>
<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>
</div> </div>
<p class="auth-sub" id="sub">仅限管理员</p> <p class="auth-sub" id="sub">仅限管理员</p>
+5 -1
View File
@@ -49,7 +49,11 @@ body {
.auth-brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.2px; } .auth-brand h1 { font-size: 19px; margin: 0; letter-spacing: 0.2px; }
.wordmark { display: inline-flex; align-items: center; } .wordmark { display: inline-flex; align-items: center; }
.wordmark svg { height: 100%; width: auto; display: block; } .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; } .topbar .wordmark { height: 20px; color: #dbe5ff; }
.auth-sub { color: var(--ink-soft); font-size: 13px; margin: 6px 0 22px; } .auth-sub { color: var(--ink-soft); font-size: 13px; margin: 6px 0 22px; }
.field { margin-bottom: 14px; } .field { margin-bottom: 14px; }
+20
View File
@@ -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
View File
@@ -37,6 +37,11 @@
'common.hidePw': 'Hide password', 'common.hidePw': 'Hide password',
'common.retry': 'Retry', 'common.retry': 'Retry',
// 品牌名(2026-09-19):登录 / 注册页的品牌标识由 DeepSeek 图形改为**本平台文字标识**。
// ⚠️ 走词条而不是写死在 HTML 里:英文/其他语言 = CapabilityNet(en 也是**其他语言的回退**,
// `lookup()` 缺 key 时正是回退到 en),中文 = 能力枢纽。
'brand.name': 'CapabilityNet',
'index.title': 'Workspace', 'index.title': 'Workspace',
'login.title': 'Sign in', 'login.title': 'Sign in',
@@ -67,6 +72,31 @@
'register.errTaken': 'That username is already taken', 'register.errTaken': 'That username is already taken',
'register.errFailed': 'Sign-up failed', 'register.errFailed': 'Sign-up failed',
'register.ok': 'Signed up. Please wait for admin approval before signing in.', '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.title': 'Starting your workspace',
'wake.h1': 'Starting your workspace…', 'wake.h1': 'Starting your workspace…',
@@ -92,6 +122,9 @@
'common.hidePw': '隐藏密码', 'common.hidePw': '隐藏密码',
'common.retry': '重试', 'common.retry': '重试',
// 品牌名(2026-09-19):中文界面显示中文名;其余语言由 en 词条(CapabilityNet)兜底。
'brand.name': '能力枢纽',
'index.title': '工作台', 'index.title': '工作台',
'login.title': '登录', 'login.title': '登录',
@@ -122,6 +155,31 @@
'register.errTaken': '用户名已被占用', 'register.errTaken': '用户名已被占用',
'register.errFailed': '注册失败', 'register.errFailed': '注册失败',
'register.ok': '注册成功,请等待管理员审核后登录。', '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.title': '正在启动工作区',
'wake.h1': '正在启动你的工作区…', 'wake.h1': '正在启动你的工作区…',
+2 -3
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n="login.title">Sign in</title> <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="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" /> <link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(词条表 + data-i18n 就地替换;默认英语) --> <!-- 档案 81 · R5:平台多语言运行时(词条表 + data-i18n 就地替换;默认英语) -->
@@ -28,8 +28,7 @@
<body class="auth-bg"> <body class="auth-bg">
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"> <div class="auth-brand">
<img class="logo" src="/logo.svg" alt="logo" /> <span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
<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>
</div> </div>
<p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p> <p class="auth-sub" data-i18n="login.sub">Welcome to the AI world</p>
+2 -2
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>管理门户</title> <title>管理门户</title>
<link rel="icon" href="/logo.svg" /> <link rel="icon" href="/favicon.svg" />
<link rel="stylesheet" href="/design.css" /> <link rel="stylesheet" href="/design.css" />
<style> <style>
/* 06-工作台UI规范 Token:亮色工具后台 + 蓝主色 + 橙强调 */ /* 06-工作台UI规范 Token:亮色工具后台 + 蓝主色 + 橙强调 */
@@ -125,7 +125,7 @@
</head> </head>
<body> <body>
<header class="topbar"> <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> <nav class="crumbs" id="crumbs"></nav>
<div class="top-actions"> <div class="top-actions">
<span class="who" id="who"></span> <span class="who" id="who"></span>
+252 -19
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n="register.title">Sign up</title> <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="/design.css" />
<link rel="stylesheet" href="/auth-ui.css" /> <link rel="stylesheet" href="/auth-ui.css" />
<!-- 档案 81 · R5:平台多语言运行时(默认英语) --> <!-- 档案 81 · R5:平台多语言运行时(默认英语) -->
@@ -23,13 +23,21 @@
.auth-lang .i18n-lang { font-size: 13px; padding: 3px 8px; border-radius: 6px; cursor: pointer; .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; } border: 1px solid rgba(0,0,0,.16); background: transparent; color: inherit; }
@media (prefers-reduced-motion: reduce) { .btn .sp { animation: none; } } @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> </style>
</head> </head>
<body class="auth-bg"> <body class="auth-bg">
<div class="auth-card"> <div class="auth-card">
<div class="auth-brand"> <div class="auth-brand">
<img class="logo" src="/logo.svg" alt="logo" /> <span class="wordmark" data-i18n="brand.name">CapabilityNet</span>
<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>
</div> </div>
<p class="auth-sub" data-i18n="register.sub">Your account must be approved by an admin before you can sign in</p> <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> data-i18n-attr="aria-label:common.showPw" aria-label="Show password" aria-pressed="false">Show</button>
</div> </div>
</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> <button class="btn primary" type="submit" data-i18n="register.submit">Sign up</button>
<p class="msg" id="msg"></p> <p class="msg" id="msg"></p>
</form> </form>
@@ -90,27 +116,234 @@
if (btn.dataset.idle) btn.innerHTML = btn.dataset.idle 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) => { form.addEventListener('submit', async (event) => {
event.preventDefault() event.preventDefault()
msg.textContent = '' setMsg('')
msg.className = 'msg err' 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')) setBusy(true, I18N.t('register.signingUp'))
const res = await fetch('/api/auth/register', { try {
method: 'POST', const res = await fetch('/api/auth/register', {
headers: { 'content-type': 'application/json' }, method: 'POST',
body: JSON.stringify({ username: form.username.value, password: form.password.value }), headers: { 'content-type': 'application/json' },
}) body: JSON.stringify({ username, password, email, code, captchaToken: token }),
const body = await res.json().catch(() => ({})) })
if (!res.ok) { const body = await res.json().catch(() => ({}))
msg.textContent = body.error === 'username_taken' ? I18N.t('register.errTaken') : body.message || I18N.t('register.errFailed') if (cfg.captcha.enabled) consumeToken()
return 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> </script>
</body> </body>
</html> </html>
+2 -2
View File
@@ -89,9 +89,9 @@
show(I18N.t('wake.failed')) show(I18N.t('wake.failed'))
} }
// 只允许跳回 *.alotbuy.com,避免被 next 参数带偏 // 只允许跳回 *.ai1net.com,避免被 next 参数带偏
function safeNext(u) { 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() { async function wake() {