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

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

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

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

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

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

No files matched your search

+209 -3
View File
@@ -17,6 +17,9 @@ export type IsolationMode = 'soft' | 'account'
* `k8s` = multi-replica control plane spawning per-user DSH Pods via the K8s API. */
export type DeployMode = 'local' | 'k8s' | 'cluster'
/** 外发邮件驱动(注册验证码)。见 `web/mail.ts`。 */
export type MailDriver = 'brevo' | 'http' | 'log'
/** Resolved, immutable runtime configuration. */
export interface ServerConfig {
/** Bind host for the orchestrator HTTP server. */
@@ -119,7 +122,7 @@ export interface ServerConfig {
*/
clusterRendezvousUrl: string
/**
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://alotbuy.com/dshs-relay`。
* **Manager 侧**用的自研中继入口(覆盖网络 R3),如 `wss://ai1net.com/dshs-relay`。
* 仅作管理面展示 / 诊断(`RelayRendezvous.dialTarget()`);空 = 该实现不注册。
*/
relayUrl: string
@@ -195,6 +198,57 @@ export interface ServerConfig {
overlayNodeKeyFile: string
/** **本机入网凭据**文件路径(`{"doc":{…},"sig":"<base64>"}`,由离线信任根授权的签名者签发)。 */
overlayNodeGrantFile: string
// ── 注册页人机验证 + 邮箱验证码(档案 134;`web/turnstile.ts` / `web/mail.ts`)──────
/**
* Cloudflare Turnstile 站点公钥(**会下发到注册页**,不是秘密)。
* ⚠️ 与 `turnstileSecret` **必须成对**:只填一把 = 视为未配置(人机验证整体不启用)。
*/
turnstileSiteKey: string
/** Turnstile 服务端密钥。空 ⇒ 人机验证停用(注册页不渲染 widget、后端不校验)。 */
turnstileSecret: string
/**
* 期望的 `action`(渲染 widget 时声明、siteverify 时回显)。默认 `signup`。
* 不校它 ⇒ 同 sitekey 的各个入口共享 token,人机验证退化成"过一处即可用到处"。
*/
turnstileAction: string
/**
* 🔴 期望的**前端主机名**白名单(`result.hostname` 必须在此列)。
*
* sitekey 是公开的(就在页面 HTML 里)⇒ 攻击者可在**自己站点**嵌入我们的 sitekey、
* 为真人访客拿到合法 token,再拿去打我们的注册接口;只校 `success` 的话这条路完全通畅。
* `hostname` 由 **Cloudflare 服务端**判定并回显,访客篡改不了 ⇒ 只有校它才能把 token
* 真正绑到"从我们站点发出的挑战"上。
* 默认从 `baseDomain` 派生(`<domain>` + `www.<domain>`);**空数组 = 不安全 ⇒ 视为未配置完成**。
*/
turnstileHostnames: string[]
/** 邮件驱动:`brevo` / `http` / `log`。`log` 只在开发排障时用(验证码会进 journald)。 */
mailDriver: MailDriver
mailApiUrl: string
mailApiKey: string
mailAuthHeader: string
mailFrom: string
mailFromName: string
mailBodyTemplate: string
mailTimeoutMs: number
/**
* 注册是否**强制**邮箱验证码。默认 `true`,但**仅当邮件通道已配置**时才真正生效
* (见 `mailEnabled`)—— 这样"没配邮件"的环境不会因为缺配置而注册不了。
*/
registerRequireEmailCode: boolean
/** 注册是否强制人机验证。同理:只在 `turnstileSecret` 有值时才生效。 */
registerRequireCaptcha: boolean
/** 验证码有效期(毫秒)。 */
emailCodeTtlMs: number
/** 单个验证码允许的最大试错次数(达上限即作废)。 */
emailCodeMaxAttempts: number
/** 反爆破策略:限额与冷却阶梯(见 `web/register-guard.ts`)。 */
emailCodeGuard: {
emailPerHour: number
emailSentPerDay: number
ipPerHour: number
globalPerHour: number
cooldownLadderMs: number[]
}
}
/** Untyped overrides collected from argv / env. */
@@ -258,6 +312,22 @@ export interface ConfigOverrides {
overlayDirTrustedKeys?: string[]
overlayDirKeyFile?: string
overlayDirectoryCacheFile?: string
turnstileSiteKey?: string
turnstileSecret?: string
turnstileAction?: string
turnstileHostnames?: string[]
mailDriver?: MailDriver | string
mailApiUrl?: string
mailApiKey?: string
mailAuthHeader?: string
mailFrom?: string
mailFromName?: string
mailBodyTemplate?: string
mailTimeoutMs?: number | string
registerRequireEmailCode?: boolean
registerRequireCaptcha?: boolean
emailCodeTtlMs?: number | string
emailCodeMaxAttempts?: number | string
}
const DEFAULT_HOST = '127.0.0.1'
@@ -304,12 +374,30 @@ const DEFAULT_DEPLOY_MODE: DeployMode = 'local'
const DEFAULT_K8S_NAMESPACE = 'dsh'
const DEFAULT_K8S_SERVICE_ACCOUNT = 'dsh-orchestrator'
const DEFAULT_IMAGE_PULL_SECRET = 'dsh-acr-pull'
// 注册页人机验证 + 邮箱验证码(档案 134)
const DEFAULT_REGISTER_REQUIRE_EMAIL_CODE = true
const DEFAULT_REGISTER_REQUIRE_CAPTCHA = true
const DEFAULT_MAIL_TIMEOUT_MS = 10_000
const DEFAULT_TURNSTILE_TIMEOUT_MS = 8_000
/** Turnstile `action` 默认值:与注册页渲染时声明的一致(见 `web/register.html`)。 */
const DEFAULT_TURNSTILE_ACTION = 'signup'
const DEFAULT_EMAIL_CODE_TTL_MS = 10 * 60 * 1000
const DEFAULT_EMAIL_CODE_MAX_ATTEMPTS = 5
const DEFAULT_EMAIL_GUARD = {
// 6 = 冷却阶梯每一级都可达(索引 0…5);见 `web/register-guard.ts` 的说明。
emailPerHour: 6,
emailSentPerDay: 8,
ipPerHour: 20,
globalPerHour: 200,
// 索引 = 该邮箱最近一小时内已发起的次数(超出取最后一项)⇒ 冷却随重试次数递增。
cooldownLadderMs: [60_000, 60_000, 180_000, 300_000, 900_000, 1_800_000],
}
/**
* 覆盖网络 P0-2:**内置种子**(引导链的常量位)。
* 锚在已持证书的门户域名上(**不新增域名**);第二地域**留位不填**。
* ⚠️ 目录端点路径由 `net/relay/directory.ts` 的 `DIRECTORY_PATH` 决定(同源约定)。
*/
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://alotbuy.com/dshs-relay']
const DEFAULT_OVERLAY_BOOTSTRAP_SEEDS = ['https://ai1net.com/dshs-relay']
/** Load the encryption secret from env, or persist a generated one at
* `<dataRoot>/secret.key` (0600) so it survives restarts without setup. */
@@ -350,6 +438,77 @@ function parseCidrs(value: string | undefined): string[] {
return [...new Set(value.split(',').map((c) => c.trim()).filter((c) => c !== ''))]
}
/**
* 邮件驱动解析(档案 134)。**不认识的值回落 `brevo` 而不是报错** ——
* 写错一个字母就打不开注册页,代价远大于"驱动没换成"。
*/
function toMailDriver(value: string | undefined): MailDriver {
const v = (value ?? '').trim().toLowerCase()
return v === 'http' || v === 'log' || v === 'brevo' ? v : 'brevo'
}
/**
* Turnstile `action` 归一:CF 规定 1–32 字符、仅 `[A-Za-z0-9_-]`。
* 非法值 ⇒ 回落默认(而不是抛错):`action` 只是"这枚 token 属于哪个业务"的标签,
* 打错字不该让注册页起不来。
*/
function normalizeAction(value: string | undefined): string {
const v = (value ?? '').trim()
return /^[A-Za-z0-9_-]{1,32}$/.test(v) ? v : DEFAULT_TURNSTILE_ACTION
}
/**
* 主机名归一:去掉协议 / 路径 / 端口 / 首尾点,转小写并去重。
* 为什么要容错:运维很容易把 env 写成 `https://ai1net.com/`(照抄 URL 的习惯),
* 而 CF 回显的是**裸主机名** ⇒ 不归一就是"配了却永远不匹配"的静默失效。
*/
export function normalizeHostnames(values: readonly string[]): string[] {
const out: string[] = []
for (const raw of values) {
const host = raw
.trim()
.toLowerCase()
.replace(/^[a-z]+:\/\//, '')
.split('/')[0]
.split(':')[0]
.replace(/^\.+|\.+$/g, '')
if (host !== '' && !out.includes(host)) out.push(host)
}
return out
}
/**
* 期望主机名:**显式配了就用配的**(空 ⇒ 视为未配置完成,交由 `turnstileEnabled` 判停用),
* 否则从 `baseDomain` 派生 `<domain>` + `www.<domain>`。
* ⚠️ **绝不自动加 `localhost` / `127.0.0.1`** —— 生产后端的白名单里放它们 = 放开本地伪造。
*/
export function resolveTurnstileHostnames(configured: readonly string[] | undefined, baseDomain: string): string[] {
if (configured !== undefined && configured.length > 0) return normalizeHostnames(configured)
if (configured !== undefined) return [] // 显式空 = 关闭(不派生)
const domain = baseDomain.trim().toLowerCase()
return domain === '' ? [] : normalizeHostnames([domain, `www.${domain}`])
}
/** 正整数解析(0 / 负数 / 非数字 / 空 ⇒ `fallback`)。用于各种毫秒数与次数上限。 */
function toPositiveInt(value: string | number | undefined, fallback: number): number {
if (value === undefined || value === '') return fallback
const n = Number(value)
return Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback
}
/**
* 冷却阶梯解析:env 给的是**秒**的逗号分隔列表(`60,60,180,300,900,1800`),
* 内部一律用毫秒。空 / 全非法 ⇒ 回落默认阶梯。
*/
function parseSecondsLadder(value: string | undefined, fallback: number[]): number[] {
if (value === undefined || value === '') return [...fallback]
const seconds = value
.split(',')
.map((s) => Number(s.trim()))
.filter((n) => Number.isFinite(n) && n > 0)
return seconds.length === 0 ? [...fallback] : seconds.map((s) => Math.floor(s) * 1000)
}
/**
* 逗号分隔列表解析。与 {@link parseCidrs} 的区别:**没配**(`undefined`)⇒ 返回
* `undefined`,让调用方把"没配"与"配成空列表"区分开(前者走默认值、后者是显式关闭)。
@@ -395,6 +554,8 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
toDeployMode(overrides.deployMode) ??
toDeployMode(process.env.DSHS_DEPLOY_MODE) ??
DEFAULT_DEPLOY_MODE
// `baseDomain` 提前算出:Turnstile 的**期望主机名白名单**要从它派生(见下)。
const baseDomainResolved = overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN
return {
host: overrides.host ?? DEFAULT_HOST,
port: typeof port === 'number' ? port : Number(port),
@@ -447,7 +608,7 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
isolationMode,
spawnAsUserCommand: overrides.spawnAsUserCommand ?? DEFAULT_SPAWN_AS_USER_COMMAND,
baseUid: Number(overrides.baseUid ?? process.env.DSHS_BASE_UID ?? DEFAULT_BASE_UID),
baseDomain: overrides.baseDomain ?? process.env.DSHS_BASE_DOMAIN ?? DEFAULT_BASE_DOMAIN,
baseDomain: baseDomainResolved,
cookieDomain: overrides.cookieDomain ?? process.env.DSHS_COOKIE_DOMAIN ?? DEFAULT_COOKIE_DOMAIN,
enablePatch: overrides.enablePatch ?? toBool(process.env.DSHS_ENABLE_PATCH, DEFAULT_ENABLE_PATCH),
portGuard: overrides.portGuard ?? toBool(process.env.DSHS_PORT_GUARD, false),
@@ -529,5 +690,50 @@ export function resolveConfig(overrides: ConfigOverrides = {}): ServerConfig {
// 表现成"一切正常",等 relay 一开强制就整台失联)。
overlayNodeKeyFile: (overrides.overlayNodeKeyFile ?? process.env.DSHS_OVERLAY_NODE_KEY_FILE ?? '').trim(),
overlayNodeGrantFile: (overrides.overlayNodeGrantFile ?? process.env.DSHS_OVERLAY_NODE_GRANT_FILE ?? '').trim(),
// ── 注册页人机验证 + 邮箱验证码(档案 134)────────────────────────────────
// 主键一律**默认空** ⇒ 「不配任何 env = 与今天行为完全一致」;配齐了才启用。
// 这一条是刻意的:注册是平台唯一的入口,不能因为漏配一个 env 就把注册锁死。
turnstileSiteKey: (overrides.turnstileSiteKey ?? process.env.DSHS_TURNSTILE_SITE_KEY ?? '').trim(),
turnstileSecret: (overrides.turnstileSecret ?? process.env.DSHS_TURNSTILE_SECRET ?? '').trim(),
turnstileAction: normalizeAction(overrides.turnstileAction ?? process.env.DSHS_TURNSTILE_ACTION),
turnstileHostnames: resolveTurnstileHostnames(
overrides.turnstileHostnames ?? splitList(process.env.DSHS_TURNSTILE_HOSTNAMES),
baseDomainResolved,
),
mailDriver: toMailDriver(overrides.mailDriver ?? process.env.DSHS_MAIL_DRIVER),
mailApiUrl: (overrides.mailApiUrl ?? process.env.DSHS_MAIL_API_URL ?? '').trim(),
mailApiKey: (overrides.mailApiKey ?? process.env.DSHS_MAIL_API_KEY ?? '').trim(),
mailAuthHeader: (overrides.mailAuthHeader ?? process.env.DSHS_MAIL_AUTH_HEADER ?? '').trim(),
mailFrom: (overrides.mailFrom ?? process.env.DSHS_MAIL_FROM ?? '').trim(),
mailFromName: (overrides.mailFromName ?? process.env.DSHS_MAIL_FROM_NAME ?? '').trim(),
mailBodyTemplate: (overrides.mailBodyTemplate ?? process.env.DSHS_MAIL_BODY_TEMPLATE ?? '').trim(),
mailTimeoutMs: toPositiveInt(
overrides.mailTimeoutMs ?? process.env.DSHS_MAIL_TIMEOUT_MS,
DEFAULT_MAIL_TIMEOUT_MS,
),
registerRequireEmailCode:
overrides.registerRequireEmailCode ??
toBool(process.env.DSHS_REGISTER_REQUIRE_EMAIL_CODE, DEFAULT_REGISTER_REQUIRE_EMAIL_CODE),
registerRequireCaptcha:
overrides.registerRequireCaptcha ??
toBool(process.env.DSHS_REGISTER_REQUIRE_CAPTCHA, DEFAULT_REGISTER_REQUIRE_CAPTCHA),
emailCodeTtlMs: toPositiveInt(
overrides.emailCodeTtlMs ?? process.env.DSHS_EMAIL_CODE_TTL_MS,
DEFAULT_EMAIL_CODE_TTL_MS,
),
emailCodeMaxAttempts: toPositiveInt(
overrides.emailCodeMaxAttempts ?? process.env.DSHS_EMAIL_CODE_MAX_ATTEMPTS,
DEFAULT_EMAIL_CODE_MAX_ATTEMPTS,
),
emailCodeGuard: {
emailPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_EMAIL_HOUR, DEFAULT_EMAIL_GUARD.emailPerHour),
emailSentPerDay: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_EMAIL_DAY, DEFAULT_EMAIL_GUARD.emailSentPerDay),
ipPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_IP_HOUR, DEFAULT_EMAIL_GUARD.ipPerHour),
globalPerHour: toPositiveInt(process.env.DSHS_EMAIL_QUOTA_GLOBAL_HOUR, DEFAULT_EMAIL_GUARD.globalPerHour),
cooldownLadderMs: parseSecondsLadder(
process.env.DSHS_EMAIL_COOLDOWN_LADDER_SEC,
DEFAULT_EMAIL_GUARD.cooldownLadderMs,
),
},
}
}
+21
View File
@@ -19,7 +19,10 @@ import type {
DshInstance,
DshInstanceRole,
DshInstanceStatus,
EmailCodeCounts,
EmailCodeRow,
PublicUser,
RecordEmailCodeInput,
SessionRow,
SessionUser,
UpsertBusinessPluginInput,
@@ -57,6 +60,24 @@ export interface DbAdapter {
findSessionWithUser(tokenHash: string): Promise<SessionUser | undefined>
/** Whether the user has any session that has not yet expired (idle reap). */
hasActiveSession(userId: string): Promise<boolean>
/** Registration e-mail lookup (v10, case-insensitive). */
findUserByEmail(email: string): Promise<User | undefined>
// e-mail verification codes (v10) — 注册页「邮箱验证码」的存储 + 防爆破计数来源
/** Append one `email_codes` row (sent / throttled / failed). */
recordEmailCode(input: RecordEmailCodeInput): Promise<void>
/** Rolling counters for one (email, ip) pair — the brute-force guard's only input. */
emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts>
/** The newest **deliverable** code for (email, purpose), or undefined. */
latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined>
/**
* 记一次校验失败:`attempts + 1`;`consume` 为真时同时打上 `consumed_at`(= 该码作废)。
* 返回 false 表示该行已不存在(例如被并发守卫先行作废)。
*/
bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean>
/** 校验通过:打上 `consumed_at`(**单次使用**)。返回 false = 在写入前已被别人用掉。 */
consumeEmailCode(id: string, now: number): Promise<boolean>
/** 删除早于 `before` 的 `email_codes` 行(自维护,避免表无限增长)。 */
purgeEmailCodes(before: number): Promise<number>
// audit
audit(actor: string | null, action: string, detail?: string | null): Promise<void>
// workspaces / plugins
+96 -4
View File
@@ -16,6 +16,7 @@ import {
toDomain,
toDshHost,
toDshInstance,
toEmailCode,
toPublicUser,
toSession,
toUser,
@@ -33,7 +34,10 @@ import {
type DshInstance,
type DshInstanceRole,
type DshInstanceStatus,
type EmailCodeCounts,
type EmailCodeRow,
type PublicUser,
type RecordEmailCodeInput,
type SessionRow,
type SessionUser,
type UpsertBusinessPluginInput,
@@ -50,7 +54,9 @@ import {
// both backends. This is process-global and idempotent.
types.setTypeParser(20, (value: string) => Number(value))
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
const EMAIL_CODE_COLS =
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const HOST_COLS = 'id, endpoint, via, network_id, agent_token, capacity_mb, used_mb, status, last_heartbeat'
@@ -114,8 +120,9 @@ export class PgAdapter implements DbAdapter {
try {
return await withTx(this.pool, async (client) => {
const { rows } = await client.query(
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES ($1, $2, $3, $4, $5, $6) RETURNING row_id',
[input.id, input.username, input.passHash, input.role, input.homeDir, createdAt],
'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) '
+ 'VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING row_id',
[input.id, input.username, input.passHash, input.role, input.homeDir, input.email ?? null, createdAt],
)
const uid = this.baseUid + Number((rows[0] as { row_id: number }).row_id)
await client.query('UPDATE users SET uid = $1 WHERE id = $2', [uid, input.id])
@@ -129,6 +136,7 @@ export class PgAdapter implements DbAdapter {
created_at: createdAt,
approved_by: null,
uid,
email: input.email ?? null,
}
})
} catch (e) {
@@ -140,7 +148,6 @@ export class PgAdapter implements DbAdapter {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE username = $1`, [username])
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
}
async findUserBySlug(slug: string): Promise<User | undefined> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(username) = $1`, [
slug.toLowerCase(),
@@ -153,6 +160,91 @@ export class PgAdapter implements DbAdapter {
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
}
/** v10:注册邮箱查重(大小写不敏感)。 */
async findUserByEmail(email: string): Promise<User | undefined> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users WHERE LOWER(email) = $1`, [
email.toLowerCase(),
])
return rows.length > 0 ? toUser(rows[0] as Record<string, unknown>) : undefined
}
// ── v10 邮箱验证码(注册页)—— 与 SQLite 版同款语义,见 repo.ts 注释 ───────────
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
await this.pool.query(
'INSERT INTO email_codes '
+ '(id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at) '
+ 'VALUES ($1, $2, $3, $4, $5, 0, $6, $7, $8, $9, $10, NULL)',
[
input.id,
input.email,
input.purpose,
input.codeHash,
input.status,
input.ip ?? null,
input.username ?? null,
input.reason ?? null,
input.createdAt,
input.expiresAt ?? null,
],
)
}
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
const { rows } = await this.pool.query(
`SELECT
(SELECT COUNT(*) FROM email_codes WHERE email = $1 AND created_at >= $2) AS email_total,
(SELECT COUNT(*) FROM email_codes WHERE email = $1 AND created_at >= $2 AND status = 'sent') AS email_sent,
(SELECT COALESCE(MAX(created_at), 0) FROM email_codes WHERE email = $1) AS email_last_at,
(SELECT COUNT(*) FROM email_codes WHERE ip IS NOT NULL AND ip = $3 AND created_at >= $2) AS ip_total,
(SELECT COUNT(*) FROM email_codes WHERE created_at >= $2) AS global_total`,
[email, since, ip],
)
const row = rows[0] as Record<string, unknown>
return {
emailTotal: Number(row.email_total ?? 0),
emailSent: Number(row.email_sent ?? 0),
emailLastAt: Number(row.email_last_at ?? 0),
ipTotal: Number(row.ip_total ?? 0),
globalTotal: Number(row.global_total ?? 0),
}
}
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
const { rows } = await this.pool.query(
`SELECT ${EMAIL_CODE_COLS} FROM email_codes WHERE email = $1 AND purpose = $2 AND status = 'sent' `
+ 'ORDER BY created_at DESC LIMIT 1',
[email, purpose],
)
return rows.length > 0 ? toEmailCode(rows[0] as Record<string, unknown>) : undefined
}
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
const { rowCount } = consume
? await this.pool.query(
'UPDATE email_codes SET attempts = attempts + 1, consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL',
[now, id],
)
: await this.pool.query(
'UPDATE email_codes SET attempts = attempts + 1 WHERE id = $1 AND consumed_at IS NULL',
[id],
)
return (rowCount ?? 0) > 0
}
async consumeEmailCode(id: string, now: number): Promise<boolean> {
const { rowCount } = await this.pool.query(
'UPDATE email_codes SET consumed_at = $1 WHERE id = $2 AND consumed_at IS NULL',
[now, id],
)
return (rowCount ?? 0) > 0
}
async purgeEmailCodes(before: number): Promise<number> {
const { rowCount } = await this.pool.query('DELETE FROM email_codes WHERE created_at < $1', [before])
return rowCount ?? 0
}
async listPublicUsers(): Promise<PublicUser[]> {
const { rows } = await this.pool.query(`SELECT ${USER_COLS} FROM users ORDER BY created_at ASC`)
return rows.map((row) => toPublicUser(toUser(row as Record<string, unknown>)))
+80 -3
View File
@@ -17,6 +17,7 @@ import {
toDomain,
toDshHost,
toDshInstance,
toEmailCode,
toPublicUser,
toSession,
toUser,
@@ -34,7 +35,10 @@ import {
type DshInstance,
type DshInstanceRole,
type DshInstanceStatus,
type EmailCodeCounts,
type EmailCodeRow,
type PublicUser,
type RecordEmailCodeInput,
type SessionRow,
type SessionUser,
type UpsertBusinessPluginInput,
@@ -45,7 +49,9 @@ import {
type Workspace,
} from './types.js'
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid'
const USER_COLS = 'id, username, pass_hash, role, home_dir, api_key_ref, created_at, approved_by, uid, email'
const EMAIL_CODE_COLS =
'id, email, purpose, code_hash, status, attempts, ip, username, reason, created_at, expires_at, consumed_at'
const DOMAIN_COLS = 'id, user_id, domain, verified, nginx_config, updated_at'
const BUSINESS_PLUGIN_COLS = 'id, name, description, version, tgz_path, file_size, uploaded_by, created_at, updated_at'
const INSTANCE_COLS =
@@ -56,8 +62,8 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
const createdAt = Date.now()
return db.transaction((): User => {
const info = prepare(db,
'INSERT INTO users (id, username, pass_hash, role, home_dir, created_at) VALUES (?, ?, ?, ?, ?, ?)',
).run(input.id, input.username, input.passHash, input.role, input.homeDir, createdAt)
'INSERT INTO users (id, username, pass_hash, role, home_dir, email, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
).run(input.id, input.username, input.passHash, input.role, input.homeDir, input.email ?? null, createdAt)
// SQLite's implicit rowid is the per-user incrementing integer; uid = baseUid + it.
const uid = baseUid + Number(info.lastInsertRowid)
prepare(db, 'UPDATE users SET uid = ? WHERE id = ?').run(uid, input.id)
@@ -71,6 +77,7 @@ export function createUser(db: Database, input: CreateUserInput, baseUid: number
created_at: createdAt,
approved_by: null,
uid,
email: input.email ?? null,
}
})()
}
@@ -86,6 +93,76 @@ export function findUserBySlug(db: Database, slug: string): User | undefined {
return row ? toUser(row as Record<string, unknown>) : undefined
}
/** v10:注册邮箱查重(大小写不敏感)。 */
export function findUserByEmail(db: Database, email: string): User | undefined {
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE LOWER(email) = ?`).get(email.toLowerCase())
return row ? toUser(row as Record<string, unknown>) : undefined
}
// ── v10 邮箱验证码 ────────────────────────────────────────────────────────────
// 全部计数都**现算**(不维护冗余计数器):省掉"计数器与事实不一致"的整类缺陷,
// 代价只是一次带索引的 COUNT;量级(每邮箱每小时个位数行)完全够用。
export function recordEmailCode(db: Database, input: RecordEmailCodeInput): void {
prepare(db,
`INSERT INTO email_codes (${EMAIL_CODE_COLS}) VALUES (?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, NULL)`,
).run(
input.id,
input.email,
input.purpose,
input.codeHash,
input.status,
input.ip ?? null,
input.username ?? null,
input.reason ?? null,
input.createdAt,
input.expiresAt ?? null,
)
}
export function emailCodeCounts(db: Database, email: string, ip: string | null, since: number): EmailCodeCounts {
const row = prepare(db, `
SELECT
(SELECT COUNT(*) FROM email_codes WHERE email = ? AND created_at >= ?) AS email_total,
(SELECT COUNT(*) FROM email_codes WHERE email = ? AND created_at >= ? AND status = 'sent') AS email_sent,
(SELECT COALESCE(MAX(created_at), 0) FROM email_codes WHERE email = ?) AS email_last_at,
(SELECT COUNT(*) FROM email_codes WHERE ip IS NOT NULL AND ip = ? AND created_at >= ?) AS ip_total,
(SELECT COUNT(*) FROM email_codes WHERE created_at >= ?) AS global_total
`).get(email, since, email, since, email, ip, since, since) as Record<string, unknown>
return {
emailTotal: Number(row.email_total ?? 0),
emailSent: Number(row.email_sent ?? 0),
emailLastAt: Number(row.email_last_at ?? 0),
ipTotal: Number(row.ip_total ?? 0),
globalTotal: Number(row.global_total ?? 0),
}
}
export function latestSentEmailCode(db: Database, email: string, purpose: string): EmailCodeRow | undefined {
const row = prepare(db,
`SELECT ${EMAIL_CODE_COLS} FROM email_codes WHERE email = ? AND purpose = ? AND status = 'sent' `
+ 'ORDER BY created_at DESC LIMIT 1',
).get(email, purpose)
return row ? toEmailCode(row as Record<string, unknown>) : undefined
}
export function bumpEmailCodeAttempts(db: Database, id: string, consume: boolean, now: number): boolean {
const info = consume
? prepare(db, 'UPDATE email_codes SET attempts = attempts + 1, consumed_at = ? WHERE id = ? AND consumed_at IS NULL')
.run(now, id)
: prepare(db, 'UPDATE email_codes SET attempts = attempts + 1 WHERE id = ? AND consumed_at IS NULL').run(id)
return info.changes > 0
}
export function consumeEmailCode(db: Database, id: string, now: number): boolean {
const info = prepare(db, 'UPDATE email_codes SET consumed_at = ? WHERE id = ? AND consumed_at IS NULL').run(now, id)
return info.changes > 0
}
export function purgeEmailCodes(db: Database, before: number): number {
return prepare(db, 'DELETE FROM email_codes WHERE created_at < ?').run(before).changes
}
export function findUserById(db: Database, id: string): User | undefined {
const row = prepare(db, `SELECT ${USER_COLS} FROM users WHERE id = ?`).get(id)
return row ? toUser(row as Record<string, unknown>) : undefined
+61
View File
@@ -382,6 +382,66 @@ const PG_V9 = `
ALTER TABLE dsh_hosts ADD COLUMN network_id TEXT NOT NULL DEFAULT 'ops';
`
// v10(注册页人机验证 + 邮箱验证码):`users.email` + `email_codes` 事件表。
//
// 为什么放 DB 而不是进程内 Map:① 本表既是**验证码存储**,也是**防爆破计数器的唯一来源**
// (冷却 / 每时每刻配额 / 试错次数全靠 `COUNT(*)` 现算)—— 进程内的计数器**一次 restart 即清零**,
// 而 deployment 恰好天天重启,等于把限流关掉;② 发码是被外部触发的花钱动作(邮件配额),
// 必须可审计(`audit_log` 只记"发生过",记不了"每分钟多少次");③ PG/SQLite 双后端可查。
//
// 表设计取舍:**只建一张事件表**,把"发送请求"与"校验失败"都记成行(`status` 区分)——
// 比"验证码表 + 计数器表 + 黑名单表"三张表少两次 JOIN、且天然是取证时间线。
// · `status`:`sent`(已发出,可校验)/ `throttled`(被限流,占位计入配额)/ `failed`(驱动发信失败)
// · 只有 `sent` 且 `consumed_at IS NULL` 且未过期的那一行可被校验通过。
// · `code_hash` **不存明文**:sha256(email:purpose:code:pepper),pepper = 平台 `encryptionSecret`。
// · `attempts` 记该码**已被试错几次**,达上限即 `consumed_at` 打上(作废,必须重新获取)。
// ⚠️ 时间戳仍是 **epoch 毫秒 BIGINT**(与全库一致,勿用 timestamptz)。
// ⚠️ `users.email` 唯一索引:存量行全为 NULL ⇒ 两方言都允许多个 NULL ⇒ 迁移不会失败;
// 用 `LOWER(email)` 保证大小写不敏感唯一(注册时不区分大小写)。
const SQLITE_V10 = `
ALTER TABLE users ADD COLUMN email TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS email_codes (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT,
status TEXT NOT NULL DEFAULT 'sent'
CHECK (status IN ('sent','throttled','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
ip TEXT,
username TEXT,
reason TEXT,
created_at INTEGER NOT NULL,
expires_at INTEGER,
consumed_at INTEGER
);
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
`
const PG_V10 = `
ALTER TABLE users ADD COLUMN email TEXT;
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS email_codes (
id TEXT PRIMARY KEY,
email TEXT NOT NULL,
purpose TEXT NOT NULL,
code_hash TEXT,
status TEXT NOT NULL DEFAULT 'sent'
CHECK (status IN ('sent','throttled','failed')),
attempts INTEGER NOT NULL DEFAULT 0,
ip TEXT,
username TEXT,
reason TEXT,
created_at BIGINT NOT NULL,
expires_at BIGINT,
consumed_at BIGINT
);
CREATE INDEX IF NOT EXISTS idx_email_codes_email ON email_codes (email, purpose, created_at);
CREATE INDEX IF NOT EXISTS idx_email_codes_ip ON email_codes (ip, created_at);
`
interface Migration {
version: number
name: string
@@ -399,6 +459,7 @@ const MIGRATIONS: readonly Migration[] = [
{ version: 7, name: 'cluster host registry + instance lease', sqlite: SQLITE_V7, pg: PG_V7 },
{ version: 8, name: 'host reachability via (覆盖网络 S2)', sqlite: SQLITE_V8, pg: PG_V8 },
{ version: 9, name: 'host network id (覆盖网络 P0-1)', sqlite: SQLITE_V9, pg: PG_V9 },
{ version: 10, name: 'user email + email verification codes', sqlite: SQLITE_V10, pg: PG_V10 },
]
/** Apply unapplied SQLite migrations inside a single transaction. */
+39
View File
@@ -14,6 +14,8 @@ import { openDatabase, type Database } from './connection.js'
import { mapSqliteError } from './errors.js'
import {
audit as auditSync,
bumpEmailCodeAttempts as bumpEmailCodeAttemptsSync,
consumeEmailCode as consumeEmailCodeSync,
countAdmins as countAdminsSync,
createSession as createSessionSync,
createUser as createUserSync,
@@ -24,6 +26,7 @@ import {
deleteUser as deleteUserSync,
deleteUserInstances as deleteUserInstancesSync,
deleteUserSessions as deleteUserSessionsSync,
emailCodeCounts as emailCodeCountsSync,
findBusinessPlugin as findBusinessPluginSync,
findDomainById as findDomainByIdSync,
findDomainByUser as findDomainByUserSync,
@@ -31,6 +34,7 @@ import {
findSession as findSessionSync,
findSessionWithUser as findSessionWithUserSync,
hasActiveSession as hasActiveSessionSync,
findUserByEmail as findUserByEmailSync,
findUserById as findUserByIdSync,
findUserBySlug as findUserBySlugSync,
findUserByUsername as findUserByUsernameSync,
@@ -40,6 +44,7 @@ import {
getEnabledPluginIds as getEnabledPluginIdsSync,
getOrCreateWorkspace as getOrCreateWorkspaceSync,
getSharedModelEnabled as getSharedModelEnabledSync,
latestSentEmailCode as latestSentEmailCodeSync,
listBusinessPlugins as listBusinessPluginsSync,
listCredentialKeys as listCredentialKeysSync,
listCredentialLandingRows as listCredentialLandingRowsSync,
@@ -48,6 +53,8 @@ import {
listInstancesByRole as listInstancesByRoleSync,
listPublicUsers as listPublicUsersSync,
listUsersWithoutUid as listUsersWithoutUidSync,
purgeEmailCodes as purgeEmailCodesSync,
recordEmailCode as recordEmailCodeSync,
selectCredentialKey as selectCredentialKeySync,
setCredentialKey as setCredentialKeySync,
setDomainVerified as setDomainVerifiedSync,
@@ -86,7 +93,10 @@ import type {
DshInstance,
DshInstanceRole,
DshInstanceStatus,
EmailCodeCounts,
EmailCodeRow,
PublicUser,
RecordEmailCodeInput,
SessionRow,
SessionUser,
UpsertBusinessPluginInput,
@@ -176,6 +186,35 @@ export class SqliteAdapter implements DbAdapter {
auditSync(this.db, actor, action, detail)
}
// ── v10 邮箱验证码(注册页)────────────────────────────────────────────────
async findUserByEmail(email: string): Promise<User | undefined> {
return findUserByEmailSync(this.db, email)
}
async recordEmailCode(input: RecordEmailCodeInput): Promise<void> {
recordEmailCodeSync(this.db, input)
}
async emailCodeCounts(email: string, ip: string | null, since: number): Promise<EmailCodeCounts> {
return emailCodeCountsSync(this.db, email, ip, since)
}
async latestSentEmailCode(email: string, purpose: string): Promise<EmailCodeRow | undefined> {
return latestSentEmailCodeSync(this.db, email, purpose)
}
async bumpEmailCodeAttempts(id: string, consume: boolean, now: number): Promise<boolean> {
return bumpEmailCodeAttemptsSync(this.db, id, consume, now)
}
async consumeEmailCode(id: string, now: number): Promise<boolean> {
return consumeEmailCodeSync(this.db, id, now)
}
async purgeEmailCodes(before: number): Promise<number> {
return purgeEmailCodesSync(this.db, before)
}
async findWorkspaceByPath(userId: string, relPath: string): Promise<Workspace | undefined> {
return findWorkspaceByPathSync(this.db, userId, relPath)
}
+69
View File
@@ -23,8 +23,29 @@ export interface User {
approved_by: string | null
/** Assigned Linux uid; null until backfilled/assigned (legacy rows). */
uid: number | null
/** Registration e-mail (v10); null for the legacy rows created before v10. */
email: string | null
}
/** One `email_codes` row: a sent code *or* a rejected/failed send attempt. */
export interface EmailCodeRow {
id: string
email: string
purpose: string
code_hash: string | null
status: EmailCodeStatus
attempts: number
ip: string | null
username: string | null
reason: string | null
created_at: number
expires_at: number | null
consumed_at: number | null
}
/** `sent` = a deliverable code; the other two only feed the brute-force counters. */
export type EmailCodeStatus = 'sent' | 'throttled' | 'failed'
/** The user shape safe to return over the wire. */
export interface PublicUser {
id: string
@@ -194,6 +215,36 @@ export interface CreateUserInput {
passHash: string
role: UserRole
homeDir: string
/** Registration e-mail (v10, optional so the legacy/k8s paths keep working). */
email?: string | null
}
/** Insert one `email_codes` row (send attempt, throttled attempt or failed send). */
export interface RecordEmailCodeInput {
id: string
email: string
purpose: string
codeHash: string | null
status: EmailCodeStatus
ip?: string | null
username?: string | null
reason?: string | null
createdAt: number
expiresAt?: number | null
}
/** Rolling counters used by the brute-force guard (all computed from `email_codes`). */
export interface EmailCodeCounts {
/** Rows for this e-mail since `since`. */
emailTotal: number
/** Rows for this e-mail since `since` that were actually deliverable (`sent`). */
emailSent: number
/** Most recent row timestamp for this e-mail (any status), or 0 when none. */
emailLastAt: number
/** Rows from this ip since `since` (empty ip ⇒ 0). */
ipTotal: number
/** Global row count since `since` (protects the provider's daily quota). */
globalTotal: number
}
export interface CreateSessionInput {
@@ -236,6 +287,24 @@ export function toUser(row: Record<string, unknown>): User {
created_at: row.created_at as number,
approved_by: (row.approved_by as string | null) ?? null,
uid: (row.uid as number | null) ?? null,
email: (row.email as string | null) ?? null,
}
}
export function toEmailCode(row: Record<string, unknown>): EmailCodeRow {
return {
id: row.id as string,
email: row.email as string,
purpose: row.purpose as string,
code_hash: (row.code_hash as string | null) ?? null,
status: row.status as EmailCodeStatus,
attempts: Number(row.attempts ?? 0),
ip: (row.ip as string | null) ?? null,
username: (row.username as string | null) ?? null,
reason: (row.reason as string | null) ?? null,
created_at: Number(row.created_at),
expires_at: row.expires_at === null || row.expires_at === undefined ? null : Number(row.expires_at),
consumed_at: row.consumed_at === null || row.consumed_at === undefined ? null : Number(row.consumed_at),
}
}
+2 -2
View File
@@ -2,7 +2,7 @@
* 覆盖网络 · **序④(443/TCP 兜底)· L1「去 CF」** —— 地址覆盖(直连目标 IP + 保持 SNI = 域名)。
*
* ## 它解决的唯一问题
* 兜底入口 `relay-direct.alotbuy.com` 与主入口 `alotbuy.com` **同属 `*.alotbuy.com`**,
* 兜底入口 `relay-direct.ai1net.com` 与主入口 `ai1net.com` **同属 `*.ai1net.com`**,
* 而该泛解析被 Cloudflare 代理 ⇒ **两者都指向 CF**。所以"多了一条入口"并不等于
* "CF 不可用时还能连":解析层仍然把客户端送到 CF。本模块把**逐字列出的域名**的解析结果
* **钉到指定 IP** ⇒ TCP 直连该 IP,而 TLS **SNI 仍等于 URL 里的域名**(证书校验照旧,不降级)。
@@ -23,7 +23,7 @@
*
* ## 配置(**独立配置项**;⛔ 不塞进 URL、⛔ 不进签名目录 —— 交接单 §4.1-5)
* ```
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.alotbuy.com=47.77.182.89
* DSHS_OVERLAY_ADDR_OVERRIDES=relay-direct.ai1net.com=47.77.182.89
* ```
* 逗号多值;同一域名**先出现者生效**(后写的静默覆盖会让"为什么不是我以为的 IP"更难排查)。
*
+2 -2
View File
@@ -78,7 +78,7 @@ const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
* ⚠️ `config.ts` 属**基础层**、不许 import 本模块 ⇒ 那边另有一份同样的字面量,
* **改动必须两处同改**(与 `db/types.ts` 的 `DEFAULT_HOST_NETWORK` 同一纪律)。
*/
export const DEFAULT_OVERLAY_SEED = 'https://alotbuy.com/dshs-relay'
export const DEFAULT_OVERLAY_SEED = 'https://ai1net.com/dshs-relay'
/** 从环境变量取种子;**没配** ⇒ 用内置常量位。 */
export function overlayEnvSeeds(env: NodeJS.ProcessEnv = process.env): string[] {
@@ -321,7 +321,7 @@ export function buildDirectoryDocument(input: {
/**
* 引导地址 → **取目录的 URL**:同 origin、路径固定为 {@link DIRECTORY_PATH}。
*
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://alotbuy.com/dshs-relay`,
* 约定:"**引导地址 = 中继入口同源**"(D3:种子就是 `https://ai1net.com/dshs-relay`,
* 已持证书、不新增域名)⇒ 目录端点只是同一台机器上的另一个路径。
* 已经是目录地址(path 相同)⇒ 原样返回,便于"目录里直接写目录 URL"。
*/
+1 -1
View File
@@ -24,7 +24,7 @@ import type { AddressLookup, Rendezvous } from '../rendezvous.js'
import { parseLogicalName } from './network.js'
export interface RelayRendezvousOptions {
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.alotbuy.com/dshs-relay`。 */
/** relay 自身的拨号目标(诊断 / 管理面展示用),如 `wss://dsh.ai1net.com/dshs-relay`。 */
dialTargetUrl: string
/**
* **逻辑名** → `host:port` 的查表函数(会合实现不直接连 DB,与另两个实现一致)。
+2 -2
View File
@@ -329,7 +329,7 @@ export class RelayFailoverSupervisor {
*
* 两条触发路径共用本函数:**健康巡检**(`tick()` 已按冷却过滤候选)与
* **「目录地址变了」**(`refreshOverlay` 直接调 `replace`,**它不看冷却**)。
* 首轮真机实测(11:43:26):`wss://106… -> wss://alotbuy.com…` —— 而 `alotbuy.com` 十几分钟前
* 首轮真机实测(11:43:26):`wss://106… -> wss://ai1net.com…` —— 而 `ai1net.com` 十几分钟前
* **刚被冷却**,只是 `refreshOverlay` 的周期到了、按"地址变了"又把它换回来
* ⇒ **抖动抑制形同不存在**(D5 的意图被另一条路径绕开)。
* ⇒ 统一在这一处把关:**directory 路径**上,冷却期内的目标**一律不换**。
@@ -353,7 +353,7 @@ export class RelayFailoverSupervisor {
/**
* 🔴 **失败的候选也必须进冷却** —— 这是真机上想清楚才补上的一条(不是理论洁癖):
*
* 生产目录的 `relays[]` = `[alotbuy.com(47), relay-direct.alotbuy.com(47), 106]`
* 生产目录的 `relays[]` = `[ai1net.com(47), relay-direct.ai1net.com(47), 106]`
* ——**前两条落在同一台机器上**。杀 47 时,若只排除"当前 url"、不排除"刚试失败的候选",
* 那么每次巡检都会**卡在候选②上反复失败**,**永远推进不到候选③(106)** ⇒
* 链虽然"不再退化成单点",却依然**换不过去**。
+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 { readTextOrEmpty, writeHomeFile } from '../home-files.js'
import { isLocaleId, reconcileLocalePreference } from '../locale-pref.js'
import {
captchaActive,
captchaPartiallyConfigured,
consumeRegisterCode,
emailCodeActive,
guardPolicyFromConfig,
isValidEmail,
normalizeEmail,
requestRegisterCode,
turnstileSettingsFromConfig,
} from '../email-code.js'
import { verifyTurnstile } from '../turnstile.js'
import { join } from 'node:path'
import {
clearSessionCookie,
@@ -42,10 +54,87 @@ const registerSchema = {
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
password: { type: 'string', minLength: 8, maxLength: 128 },
// 档案 134:邮箱 + 验证码 + 人机验证 token 一律**可选**声明 ——
// 「必填」由配置决定(`emailCodeActive` / `captchaActive`),schema 只保证形状与长度。
// 这样同一份代码在"没配邮件的环境"里仍是老行为,不用改 schema 再发一次版。
email: { type: 'string', maxLength: 254 },
code: { type: 'string', maxLength: 16 },
captchaToken: { type: 'string', maxLength: 4096 },
},
},
} as const
const emailCodeSchema = {
body: {
type: 'object',
required: ['username', 'email'],
additionalProperties: false,
properties: {
username: { type: 'string', minLength: 3, maxLength: 32, pattern: '^[a-zA-Z0-9_-]+$' },
email: { type: 'string', minLength: 6, maxLength: 254 },
captchaToken: { type: 'string', maxLength: 4096 },
},
},
} as const
interface RegisterBody {
username: string
password: string
email?: string
code?: string
captchaToken?: string
}
/**
* 真实客户端 IP(防爆破的**次要**维度)。
*
* 站点经 Cloudflare → 宝塔 nginx → `127.0.0.1:3080`,`trustProxy` 已开 ⇒ `request.ip` 取 XFF 首项
* (由 CF 覆写,不可由访客伪造)。这里优先用 `CF-Connecting-IP`(CF 的权威口径,且不受链路上
* 其它代理拼接 XFF 的影响)。
* ⚠️ **它是次要维度**:源站若可被直连,头仍可伪造 —— 因此真正的承重维度是
* **邮箱维度**(攻击者无法伪造目标地址)与**全局闸门**(保护邮件服务商配额)。IP 维度只用来
* 让"同一台机器猛撞"更快被拦住。
*/
function clientIp(request: { headers: Record<string, unknown>; ip: string }): string | null {
const header = request.headers['cf-connecting-ip']
const value = Array.isArray(header) ? header[0] : header
if (typeof value === 'string' && value.trim() !== '') return value.trim()
return request.ip === '' ? null : request.ip
}
/** 人机验证:配置齐了才校;未配置时**放行**(老行为),并由调用方必要时告警。 */
async function checkCaptcha(
app: { config: unknown; log: { warn: (message: string) => void } },
token: string | undefined,
ip: string | null,
): Promise<{ ok: true } | { ok: false; error: string }> {
const config = app.config as Parameters<typeof captchaActive>[0]
if (!captchaActive(config)) {
// 「密钥给了但没给期望主机名」⇒ 人机验证**静默不生效**。这是最危险的形态
// (看起来配了、实际没防住),所以每次走到这里都留一条告警,让它在日志里可见。
// 去重只做在同一进程内,不影响可观测性(重启后第一条仍会打出来)。
if (captchaPartiallyConfigured(config)) captchaWarnOnce(app)
return { ok: true }
}
const verdict = await verifyTurnstile(turnstileSettingsFromConfig(config), token ?? '', ip ?? undefined)
if (verdict.ok) return { ok: true }
// 上游故障与"token 不对"必须可分:前者是运维问题,后者是访客问题。
const upstream = verdict.error !== null && verdict.error.startsWith('network_error')
app.log.warn(`[register] Turnstile 校验失败 ip=${ip ?? '-'} err=${verdict.error ?? ''}`)
return { ok: false, error: upstream ? 'captcha_unavailable' : 'captcha_failed' }
}
let captchaPartialWarned = false
/** 只在进程内第一条上告警,避免被机器人刷日志。 */
function captchaWarnOnce(app: { log: { warn: (message: string) => void } }): void {
if (captchaPartialWarned) return
captchaPartialWarned = true
app.log.warn(
'[register] Turnstile 已配 siteKey/secret,但期望主机名列表为空 ⇒ 人机验证**未启用**'
+ '(请设 DSHS_TURNSTILE_HOSTNAMES=<域名[,www.域名]>)',
)
}
const loginSchema = {
body: {
type: 'object',
@@ -64,23 +153,127 @@ interface Credentials {
}
export const authRoutes: FastifyPluginAsync = async (app) => {
/**
* 注册页需要知道的**公开**配置(档案 134)。
*
* 为什么要一个端点而不是把 sitekey 写进 HTML:
* · sitekey 随环境变(本地/测试/线上各一个 Turnstile widget),写死会让"换环境忘记换 key"
* 变成一次静默失效 —— 人机验证看起来在,其实一直在报 `invalid-input-secret`;
* · 前端据此**决定要不要渲染**控件,而不是渲染一个永远失败的控件。
* ⚠️ 只回**公钥**。服务端密钥永不出现在任何响应里。
*/
app.get('/api/auth/register/config', async (_request, reply) => {
reply.header('cache-control', 'no-store')
const captchaOn = captchaActive(app.config)
return {
captcha: {
enabled: captchaOn,
// 未启用时给空串 ⇒ 前端不会去加载 CF 脚本(少一个第三方请求)。
siteKey: captchaOn ? app.config.turnstileSiteKey : '',
// ⚠️ `action` 必须由**服务端**下发且与 siteverify 校验的一致:两边各写一份常量,
// 一旦漂掉就是"过不了验证且看不出原因"(CF 只回 action 不匹配,不说是谁配错)。
action: app.config.turnstileAction,
},
emailCode: {
enabled: emailCodeActive(app.config),
ttlSeconds: Math.round(app.config.emailCodeTtlMs / 1000),
maxAttempts: app.config.emailCodeMaxAttempts,
},
// 前端做即时校验用(避免"提交了才说格式错"),与后端同一份口径。
usernamePattern: '^[a-zA-Z0-9_-]{3,32}$',
minPasswordLength: 8,
}
})
/**
* 请求邮箱验证码(档案 134)。
*
* 顺序不可交换:**人机验证 → 配额/冷却 → 发信 → 落事件**。
* 反过来的话,机器人只需猛点就能把真用户的配额吃光(用自己的拒绝服务挡住别人)。
*/
app.post(
'/api/auth/register/email-code',
{ schema: emailCodeSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
async (request, reply) => {
const body = request.body as { username: string; email: string; captchaToken?: string }
const ip = clientIp(request as unknown as { headers: Record<string, unknown>; ip: string })
if (!emailCodeActive(app.config)) return reply.code(503).send({ error: 'email_code_disabled' })
const captcha = await checkCaptcha(app, body.captchaToken, ip)
if (!captcha.ok) return reply.code(403).send({ error: captcha.error })
const outcome = await requestRegisterCode(
{ db: app.db, config: app.config, onWarn: (message) => app.log.warn(message) },
{ email: body.email, username: body.username, ip },
)
if (!outcome.ok) {
await app.db.audit(null, 'register_code_rejected', JSON.stringify({ reason: outcome.error, ip }))
if (outcome.retryAfterSeconds > 0) reply.header('retry-after', String(outcome.retryAfterSeconds))
return reply.code(outcome.status).send({
error: outcome.error,
retryAfterSeconds: outcome.retryAfterSeconds,
})
}
await app.db.audit(null, 'register_code_sent', JSON.stringify({ ip }))
return reply.header('retry-after', String(outcome.retryAfterSeconds)).send({
ok: true,
retryAfterSeconds: outcome.retryAfterSeconds,
expiresInSeconds: outcome.expiresInSeconds,
})
},
)
app.post(
'/api/auth/register',
{ schema: registerSchema, config: { rateLimit: { max: 5, timeWindow: '1 minute' } } },
async (request, reply) => {
const { username, password } = request.body as Credentials
const { username, password } = request.body as RegisterBody
const body = request.body as RegisterBody
const ip = clientIp(request as unknown as { headers: Record<string, unknown>; ip: string })
const email = normalizeEmail(body.email ?? '')
// ① 先做**无副作用**的占用检查:命中就不必浪费一次人机验证与一个验证码。
if ((await app.db.findUserByUsername(username)) !== undefined) {
return reply.code(409).send({ error: 'username_taken' })
}
if (email !== '' && (await app.db.findUserByEmail(email)) !== undefined) {
return reply.code(409).send({ error: 'email_taken' })
}
// ② 人机验证(配置齐了才校)。
const captcha = await checkCaptcha(app, body.captchaToken, ip)
if (!captcha.ok) return reply.code(403).send({ error: captcha.error })
// ③ 邮箱验证码(配置齐了才强制)。**校验通过即消费**,同一码不能建两个账号。
if (emailCodeActive(app.config)) {
if (email === '' || !isValidEmail(email)) return reply.code(400).send({ error: 'email_required' })
const verdict = await consumeRegisterCode(
{ db: app.db, config: app.config },
{ email, username, code: (body.code ?? '').trim(), ip },
)
if (!verdict.ok) {
await app.db.audit(null, 'register_code_invalid', JSON.stringify({ reason: verdict.error, ip }))
return reply.code(verdict.status).send({ error: verdict.error, attemptsLeft: verdict.attemptsLeft })
}
}
const id = randomUUID()
const homeDir = homeRoot(userRoot(app.config.dataRoot, id))
const passHash = await hashPassword(password)
// Create the user first so `initUserRoot` resolves the DB-assigned uid
// (baseUid + row_id) instead of the hash fallback — the per-user Pods and
// the DSH Pod must run as the *same* uid or the DSH cannot write its dirs.
const user = await app.db.createUser({ id, username, passHash, role: 'pending', homeDir })
const user = await app.db.createUser({
id,
username,
passHash,
role: 'pending',
homeDir,
email: email === '' ? null : email,
})
await app.userFs.initUserRoot(id, user.uid ?? undefined)
await app.db.audit(id, 'register', JSON.stringify({ username }))
await app.db.audit(id, 'register', JSON.stringify({ username, email: email === '' ? null : email }))
return reply.code(201).send({ user: { id, username, role: 'pending' } })
},
)
+3 -3
View File
@@ -1127,9 +1127,9 @@ export async function buildServer(config: ServerConfig): Promise<FastifyInstance
await registerDshProxy(app)
// CORS for cross-subdomain API calls from dsh instances (功能插件启停 section
// runs in the browser on `<user>.dsh.alotbuy.com` and calls portal APIs on
// `dsh.alotbuy.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
// Domain=.dsh.alotbuy.com, so credentials ride along; we only need to allow
// runs in the browser on `<user>.dsh.ai1net.com` and calls portal APIs on
// `dsh.ai1net.com`). Cookie is HttpOnly + SameSite=None (secure mode) with
// Domain=.dsh.ai1net.com, so credentials ride along; we only need to allow
// the Origin. Restricted to the platform base domain and its subdomains.
app.addHook('onRequest', async (request, reply) => {
const origin = request.headers.origin
+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'
export interface RelayTunnelOptions {
/** relay 的 WebSocket 地址:`wss://alotbuy.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
/** relay 的 WebSocket 地址:`wss://ai1net.com/dshs-relay`(生产)或 `ws://127.0.0.1:20080/dshs-relay`(本机验)。 */
url: string
/** 本机在 `dsh_hosts.id` 里的标识(`w-47` / `w-106`)。 */
hostId: string
@@ -93,7 +93,7 @@ function healthOf(client: RelayClient): { state: string; attempts: number; unhea
* - `count` = 候选**条数**(= E3 的**字面**判据 `count ≥ CAND_MIN`);
* - `hosts` = **主机名**个数(按 `URL#host` 去重)—— ⛔ **只作信息输出、不作判据**:
* 🔴 **它不是"独立物理路径数"** —— 本观测**不解析 DNS**(零网络),而生产上前两条候选
* `wss://alotbuy.com/dshs-relay` 与 `wss://relay-direct.alotbuy.com/dshs-relay` **摘名不同、
* `wss://ai1net.com/dshs-relay` 与 `wss://relay-direct.ai1net.com/dshs-relay` **摘名不同、
* 落在同一台 47**(`switcher.ts` 已实证)⇒ 真机读数 `count=3` 时 `hosts` 也报 **3**,
* 而**机器级**独立路径只有 2(47 + 106)。⇒ 这个数只用来**提示**"条数够不等于冗余够",
* "冗余建成"必须由人按机器归属判(⛔ 别拿它当独立路径数用);
@@ -119,7 +119,7 @@ export function candidateObsMs(env: Record<string, string | undefined> = process
* 候选里的**主机名**个数(非法 URL 不计)。⛔ 丢 scheme ⇒ `wss://h/a` 与 `https://h/b` 算同一台。
*
* 🔴 **不解析 DNS**(观测器零网络)⇒ **摘名不同但同机的候选会被算成两个** ⇒
* 本数**不是独立物理路径数**(真机实证:`alotbuy.com` 与 `relay-direct.alotbuy.com` 都在 47,
* 本数**不是独立物理路径数**(真机实证:`ai1net.com` 与 `relay-direct.ai1net.com` 都在 47,
* 但 `count=3` 时 `hosts` 也报 3)。
*/
function candHostsOf(urls: readonly string[]): number {