251 lines
26 KiB
Markdown
251 lines
26 KiB
Markdown
# 管理员登录门禁 与 单容器打包 —— uhd_doc 调研
|
||
|
||
来源:`/opt/uhd_doc`(Next.js 15 + Bun + SQLite/Drizzle + Docker,单容器自托管文档 CMS)。只读调研,目的是给 `/opt/uhd_media_tool`(Node 24 + Hono + Kysely/pg 后端,React Router 7 SPA 前端)抄作业。全部路径均为 `/opt/uhd_doc/` 下相对路径。
|
||
|
||
## 1. 登录 / 会话
|
||
|
||
### 1.1 密码哈希:Argon2id(`src/lib/password.ts`)
|
||
|
||
用 `Bun.password`(内置,无原生依赖)生成/校验标准 PHC 字符串,OWASP 参数:
|
||
|
||
```ts
|
||
const ARGON_OPTS = { algorithm: "argon2id", memoryCost: 19_456, timeCost: 2 };
|
||
export function hashPassword(password: string) { return bunPassword.hash(password, ARGON_OPTS); }
|
||
export async function verifyPassword(password: string, digest: string) {
|
||
try { return await bunPassword.verify(password, digest); } catch { return false; }
|
||
}
|
||
```
|
||
|
||
uhd_media_tool 是纯 Node 24,没有 `Bun.password`。**建议照搬参数、不照搬实现**:改用 `node:crypto` 无内置 argon2,需引入 `@node-rs/argon2`(原生插件,Node 24 有预编译二进制)或退回 `bcrypt`/`node:crypto scrypt`。曾经该项目就是用 `@node-rs/argon2` 后来才切到 Bun 内置版(见 password.ts 注释),说明 `@node-rs/argon2` 产出的哈希格式与 Bun 版兼容,是稳妥选择。
|
||
|
||
### 1.2 首次初始化管理员:一次性 Setup Token,不是 env 密码
|
||
|
||
- `ADMIN_USERNAME` 是 env 配置(稳定),但**密码从不经 env**,只能通过浏览器首次访问 `/admin/login` 时的「安装向导」设置,写入数据库。
|
||
- 首启防抢注:`src/lib/env.ts` 的 `resolveInitToken()` 在没显式配置 `ADMIN_INIT_TOKEN` 时自动生成 16 字节随机 token,持久化到 `data/config/runtime-secrets.json`(权限 `0600`),**从不打印到应用日志**。
|
||
- 运维通过容器命令行取 token:`docker exec -it uhd-docs bun run admin:show-init-token`(`src/scripts/admin-show-init-token.ts`,已初始化则拒绝显示)。
|
||
- 前端 setup 表单(用户名+密码+确认密码+token)提交到 `POST /api/admin-auth/setup`(`src/app/api/admin-auth/setup/route.ts`):校验 `ADMIN_USERNAME` 匹配、token 常量时间比较(`timingSafeEqual`)、`initializeAdminPassword()` 用**条件 UPSERT**(`WHERE admin_credential.password_hash IS NULL`)保证并发首启只有一个请求能设成功;成功后 `rotateAdminInitToken()` 让旧 token 失效。
|
||
- 忘记密码的恢复路径是容器内命令行脚本 `src/scripts/admin-set-password.ts`(交互式或参数传入新密码,同时禁用 TOTP 并吊销其它会话),不走 Web UI——这是刻意设计,恢复权限只给到有容器 shell 的人。
|
||
|
||
**建议照搬**:一次性 setup token + 密码只落库不落 env + CLI 兜底重置,这套模型完全适用于 Hono 后端(用 `pnpm --filter @umt/server exec tsx scripts/admin-set-password.ts` 之类等价脚本)。
|
||
|
||
### 1.3 会话实现:服务端 session 表 + HttpOnly Cookie(不是 JWT)
|
||
|
||
`src/server/admin-auth/session.ts` + `src/db/schema.ts` 的 `admin_sessions` 表:
|
||
|
||
```ts
|
||
export const adminSessions = sqliteTable("admin_sessions", {
|
||
id: id(),
|
||
sessionIdHash: text("session_id_hash").notNull(), // 只存哈希,原始 id 只在 cookie 里
|
||
adminUsername: text("admin_username").notNull(),
|
||
createdAt: createdAt(),
|
||
lastSeenAt: integer(...).notNull(),
|
||
expiresAt: integer(...).notNull(),
|
||
revokedAt: integer(...), // 软删除吊销
|
||
}, (t) => [uniqueIndex("admin_sessions_hash_uq").on(t.sessionIdHash)]);
|
||
```
|
||
|
||
- `createAdminSession()`:生成 32 字节随机 id,库里只存 `sha256Hex(sessionId)`(泄库拿不到可用 cookie 值,类似 bcrypt 但用 sha256 因为 id 本身已是高熵随机数,不需要慢哈希),cookie 明文是原始 id。
|
||
- Cookie 属性:`httpOnly: true, secure: await isHttpsRequest(), sameSite: "lax", path: "/", expires: <会话过期时间>`。Cookie 名 `admin_session`(`src/lib/cookies.ts` 的 `COOKIES.adminSession`)。
|
||
- `secure` 判定不简单看 `req.protocol`:`isHttpsRequest()` 优先信任 **`APP_URL` 是否 `https://` 开头**(运维必须配对),兜底看 `X-Forwarded-Proto`/`CF-Visitor`/`X-Forwarded-Ssl`,这是因为反代链路可能把协议头丢掉或改写,`APP_URL` 是唯一权威来源。
|
||
- 过期与续期:固定 TTL(`ADMIN_SESSION_TTL_HOURS`,默认 12 小时),**没有滑动续期**,只在 `getAdminSession()` 校验时机会性节流更新 `lastSeenAt`(超过 60 秒才写库,减少写压力)。没有"记住我"选项。
|
||
- 登出/吊销:`destroyAdminSession()` 把该行 `revokedAt` 置为当前时间(数据库软删除)+ 清 cookie(session、csrf 两个)。`revokeOtherSessions(username, keepRawId?)` 用于改密后强制下线其它所有会话(留当前会话)。
|
||
- 校验逻辑:`getAdminSession()` 查询条件是 `sessionIdHash 匹配 AND revokedAt IS NULL AND expiresAt > now`,三者缺一都视为未登录,不抛异常。
|
||
|
||
**建议照搬**:服务端 session 表(而非无状态 JWT)对这类"单管理员、低 QPS"场景明显更合适——吊销、剔线、审计都是一条 SQL,JWT 做不到即时吊销。Kysely/pg 版对应建一张 `admin_session` 表,字段照抄即可(pg 用 `timestamptz`)。
|
||
|
||
### 1.4 鉴权中间件:Next Route Handler wrapper,对应 Hono 的中间件写法
|
||
|
||
`src/server/api/wrappers.ts` 里 `withAdminApi()` 是核心:
|
||
|
||
```ts
|
||
export function withAdminApi<P>(handler) {
|
||
return async (req, ctx) => {
|
||
const session = await getAdminSession();
|
||
if (!session) throw new ApiError(ErrorCode.ADMIN_REQUIRED, "需要管理员登录");
|
||
if (WRITE_METHODS.has(req.method) && !(await verifyCsrf(req))) {
|
||
throw new ApiError(ErrorCode.CSRF_INVALID, "CSRF 校验失败,请刷新后重试");
|
||
}
|
||
return handler(req, ctx, session);
|
||
};
|
||
}
|
||
```
|
||
|
||
每个 admin API route 用 `export const POST = withAdminApi(async (req, ctx, session) => {...})` 包一层,而不是全局 middleware——Next App Router 没有"路由组中间件"的干净写法,只能靠这种高阶函数在每个 handler 外面套。
|
||
|
||
**uhd_media_tool 的对应做法(Hono)**:这一层在 Hono 里就是标准 middleware,比 Next 的 wrapper 模式更直接。参照现有 `apps/server/src/http/auth.ts` 的 `apiKeyAuth()` / `requireScope()` 写法,新增一个 `adminSessionAuth(): MiddlewareHandler` 挂在 `app.use("/api/v1/admin/*", adminSessionAuth)`,内部读 `admin_session` cookie → 查表 → 校验过期/吊销 → `c.set("adminSession", ...)`;写方法上再叠一层 `requireCsrf` middleware。不需要 Next 那种"每个 route 手动包一层"的 workaround,直接用 Hono 路由分组即可,这是 Hono 优于 Next Route Handler 的地方,**不需要照搬 wrapper 形式,只照搬校验逻辑**。
|
||
|
||
### 1.5 网络边界快速拦截:Next middleware → Hono 等价物
|
||
|
||
`src/proxy.ts`(这个项目把 Next 的 `middleware.ts` 改名/复用为 `proxy.ts`,`config.matcher` 命中 `/admin/:path*` 等路径):只做**轻量**判断——cookie 是否存在(不查库,不校验有效性),命中 `/admin/*` 且无 cookie → 302 到 `/admin/login?next=...`;已登录还访问登录页 → 302 回 `/admin`。真正的会话有效性校验留给上面第 1.4 节的 API wrapper 和 admin 布局组件。
|
||
|
||
值得抄的两个细节:
|
||
- **相对路径重定向**而不是 `NextResponse.redirect(绝对 URL)`:反代场景下 Next 内部 origin(如 `http://localhost:3001`)和外部真实 host 不一致,直接构造绝对 URL 会把浏览器导向不可达地址。它对 `/admin` 命中场景用 `originRedirect()` 从 `X-Forwarded-Host`/`Host` 头重建 URL,而不是相对路径——但注释解释了这里选相对还是绝对是有权衡的(浏览器对相对 Location 会按当前请求地址解析,更安全)。
|
||
- `next` 参数做了严格校验(`/^\/(?!\/)/` 且不含 `\\`),防 Open Redirect。
|
||
|
||
**对应做法**:React Router 7 SPA 是纯前端路由,没有 Next middleware 这种服务端网络层拦截点。等价方案是在 SPA 的根 loader / 一个 `RequireAdmin` 路由包裹组件里,挂载时先带 cookie 发一个轻量 `GET /api/v1/admin/session`(或者复用 whoami)判断是否登录,未登录 `navigate("/admin/login?next=...")`。这一层拦截**不能省**,否则未登录用户会先看到页面骨架再跳转,体验和安全性都打折——但载体从"边缘 middleware"变成"SPA 路由 loader + 后端一个薄 session 探测接口"。
|
||
|
||
### 1.6 CSRF:HMAC 派生的 Double-Submit Cookie
|
||
|
||
`src/server/admin-auth/csrf.ts`:
|
||
|
||
```ts
|
||
export async function setCsrfCookie(sessionId: string) {
|
||
store.set(COOKIES.csrf, deriveCsrfToken(sessionId), {
|
||
httpOnly: false, secure: await isHttpsRequest(), sameSite: "lax", path: "/",
|
||
});
|
||
}
|
||
export async function verifyCsrf(req: Request) {
|
||
const session = await getAdminSession();
|
||
if (!session) return false;
|
||
const provided = req.headers.get("x-csrf-token");
|
||
return provided ? safeEqual(provided, deriveCsrfToken(session.sessionId)) : false;
|
||
}
|
||
```
|
||
|
||
`deriveCsrfToken(sessionId) = HMAC-SHA256(SESSION_SECRET, "csrf:" + sessionId)`(`src/lib/crypto.ts`)。CSRF cookie **非 HttpOnly**(前端 JS 要读出来塞进请求头),伪造需要拿到 HttpOnly 的 session id,而 XSS/CSRF 单独都拿不到两者组合。只在写方法(POST/PUT/PATCH/DELETE)校验,登录成功时和 session 一起签发。
|
||
|
||
**建议照搬**:这套 HMAC 派生 double-submit 比"随机 token 存表"更轻(不用额外建表/查库),`SESSION_SECRET` 换成 pg 项目自己的签名密钥即可,逻辑原样搬。
|
||
|
||
### 1.7 登录限速 / 失败锁定
|
||
|
||
`src/server/admin-auth/login.ts`,双重限速:
|
||
- **按 IP**:5 分钟窗口内失败 ≥5 次 → 锁(`MAX_PER_IP = 5`)。
|
||
- **按账号**(IP 无关兜底):同窗口失败 ≥15 次 → 锁(`MAX_PER_ACCOUNT = 15`),防止攻击者靠伪造/轮换 `X-Real-IP` 绕过 IP 限速。
|
||
- 查询用 `adminLoginAttempts` 表按 `success=false AND createdAt >= now-5min` 计数,不是内存令牌桶——多进程/重启都一致。
|
||
- 已经被限速的请求**不再写审计**,防止扫描器把 `audit_logs` 撑爆(注释原话强调这是刻意的磁盘防护)。
|
||
- 密码正确但 TOTP 未提供,**不计入失败次数**(不烧限速预算),因为这只是流程的第二步。
|
||
|
||
此外还有一层**人机验证**(见 §1.8)在触发密码校验前拦截。
|
||
|
||
**建议照搬**:IP + 账号 双维度限速、失败计数落表而非内存,这是防"分布式撞库/凭据填充"最实用的一层,pg 版直接建等价 `admin_login_attempt` 表。
|
||
|
||
### 1.8 人机验证(PoW,非传统验证码)
|
||
|
||
登录/初始化/公开密码解锁/反馈投票等敏感端点都要求先过 **Cap**(自托管工作量证明验证码,`capjs-core`,不依赖第三方 CAPTCHA 服务)。前端组件 `src/components/security/proof-of-work.tsx`(`<ProofOfWork endpoint="/api/cap/admin-login/" onTokenChange={setCapToken} />`),拿到 `capToken` 前登录按钮和输入框全部 `disabled`。后端 `src/server/cap/service.ts`:挑战/校验用 `HMAC(SESSION_SECRET, "uhd-docs/capjs-core/v1")` 派生的域分离密钥,redeem token 一次性消费(SQLite 表 `cap_redeem_tokens`,`DELETE ... WHERE token_key=? AND scope=? AND expires_at>?` 原子销毁)。登录路由里**先烧 capToken 再做密码哈希校验**(无论成败都烧掉,一次证明只授权一次尝试)。
|
||
|
||
这套东西相对重(引入 `capjs-core` 依赖 + WASM 求解器 + 两张表),**属于"锦上添花"而非"照抄底线"**:如果 uhd_media_tool 的管理端只是内网/团队自用工具而非公网暴露的公共产品,IP+账号限速(§1.7)通常已经够。若确实要抄,思路是"敏感端点前置一次一次性 redeem token",实现上可以先跳过、后续再加。
|
||
|
||
### 1.9 TOTP(有)/ Passkey(有,可选跳过)
|
||
|
||
TOTP:`src/lib/totp.ts` 纯 `node:crypto` 实现 RFC 6238(SHA1/6 位/30s),**无第三方 OTP 依赖**,含防重放(`totpLastCounter` 记录已消费的最高计数器,`verifyTotpCounter` 拒绝 ≤ 该值的候选),代码 Node 通用可**逐字复用**。Passkey(WebAuthn):`src/server/admin-auth/passkey.ts` + 前端 `@simplewebauthn/browser`,作为密码的替代首因子(不是二次因子),复杂度不低(注册/认证两个 ceremony + `admin_passkeys` 表存 COSE 公钥),**建议后置**,先做密码+session+TOTP。
|
||
|
||
### 1.10 密码修改流程
|
||
|
||
`src/app/api/admin-auth/password/route.ts`(`withAdminApi` 包裹):校验当前密码 → 写新 `passwordHash` → **吊销除当前会话外的所有其它会话**(`revokeOtherSessions(username, session.sessionId)`)→ 记审计 `admin.password_change`。修改密码不强制改 TOTP/passkey。**建议照搬**整个流程,尤其"改密后踢掉其它设备会话"这一步很容易被忽略。
|
||
|
||
### 1.11 审计
|
||
|
||
`src/server/audit/log.ts`:一张 `audit_logs` 表,固定的 `AuditAction` 联合类型(枚举式,不是自由字符串),记录 `action/actor/targetType/targetId/detailJson/ipHash`。写入包一层 `try/catch`——**审计失败绝不能影响主流程**。管理相关动作:`admin.login_success / admin.setup / admin.password_change / admin.totp_enable / admin.totp_disable / admin.passkey_register / admin.passkey_remove / admin.passkey_login`。注意:**限速拦截掉的失败尝试不写审计**(见 §1.7),只有真正进入业务逻辑的动作才记。
|
||
|
||
**建议照搬**:枚举式 action 类型 + try/catch 兜底,这是最低成本、最高性价比的一项,pg 版直接建 `audit_log` 表照抄字段。
|
||
|
||
## 2. 登录页 UI
|
||
|
||
- 文件:`src/app/admin/login/page.tsx`(服务端组件,读初始状态:是否已初始化、品牌、passkey 是否可用、`next` 回跳路径)+ `src/app/admin/login/login-client.tsx`(453 行,客户端交互全在这)。
|
||
- **布局**:桌面端左右两栏(`lg:grid-cols-[minmax(0,1fr)_23.5rem]`)——左侧大幅品牌/标语展示区(纯装饰,`hidden lg:block`),右侧固定宽度(23.5rem)登录卡片;移动端退化为单栏卡片,顶部品牌横排。背景是一张全屏蓝图风格插画(`blueprint-light.webp` / `blueprint-dark.webp`,按 `dark:` 切换)。顶部有一条 `sticky` 品牌 header(logo + 站点名 + 语言切换 + 主题切换)。
|
||
- **交互状态机**:同一个表单组件承担三种模式——**首启向导**(`setupMode`:多一个"确认密码" + "初始化口令"输入框)、**普通登录**(用户名+密码)、**二次验证**(`needsTotp`:提交密码后如果账号开了 TOTP,后端返回 `TOTP_REQUIRED`,前端原地插入 6 位数字输入框,用户名/密码字段变 `readOnly` 但保留值,不用重新输入)。三态由后端错误码驱动切换,不是前端猜的。
|
||
- **Passkey 按钮**:仅当 `initialPasskeyAvailable`(HTTPS/localhost 安全上下文 + 已注册过 passkey)且非 setup/非 TOTP 阶段显示,放在密码表单**上方**,用一条 "或" 分隔线隔开,视觉上是主按钮(`variant={showPasskey ? "outline" : "default"}`——有 passkey 时密码登录降级为 outline 按钮,把主按钮位置让给指纹登录)。
|
||
- **错误提示**:固定高度占位容器 `<div className="min-h-5" aria-live="polite">`(避免报错时页面跳动),图标 + 文案,`role="alert"`;根据后端错误码分流文案(`RATE_LIMITED` / `TOTP_INVALID` / 其它),而不是直接透传后端 message。
|
||
- **加载态**:提交按钮里 `LoaderCircle` 转圈图标替换文字前缀,同时所有输入框 `disabled={!capToken || loading}`——**没有 PoW token 之前整个表单都是禁用的**,这个"先过人机验证才能操作表单"的顺序是刻意设计。
|
||
- **没有"记住我"**:固定会话时长(12 小时),无相关 UI 选项。
|
||
- **密码显示切换**:眼睛图标 `Eye`/`EyeOff` 切换 `type="password"|"text"`,`aria-pressed` 标注状态。
|
||
- **视觉风格要点**(供前端复用同类布局参考):大圆角(`rounded-lg`)、高输入框(`h-14`,触屏友好)、图标前缀嵌在输入框内(`absolute left-3.5`)、品牌色仅用于强调线和主按钮,大量留白,弱化的 `text-muted-foreground` 二级文案,整体是"极简 SaaS 后台登录页"风格而非"企业系统"风格。
|
||
|
||
**建议照搬**:错误码驱动的三态表单(setup/login/TOTP 复用同一组件)、"PoW/前置校验完成前禁用整个表单"的交互顺序、固定高度错误占位防跳动。React Router 7 SPA 落地时可以做成一个 `<AdminLoginPage>` + 内部 state machine,不需要 Next 的服务端组件划分,直接 CSR 请求一个 `GET /api/v1/admin/auth/state` 拿初始状态即可。
|
||
|
||
## 3. Docker:单容器打包
|
||
|
||
### 3.1 Dockerfile 多阶段结构(`Dockerfile`)
|
||
|
||
五个 stage:`bunbin`(只为拿 pin 版 Bun 二进制)→ `base`(Debian trixie-slim + ca-certificates + Bun)→ `deps`(`bun install --frozen-lockfile`)→ `caddyfetch`(下载 pin 版 Caddy 静态二进制,校验 `caddy version`)→ `builder`(`bun run build`,清 `.next/cache`,预编译 `next.config.ts → next.config.js` 让运行时免装编译器)→ `runner`(最终镜像:非 root 用户 `app`(uid 10001),`libgomp1/ca-certificates/fontconfig/gosu/libcap2-bin`,`COPY --chown=app:app` 精确控制目录属主,`setcap cap_net_bind_service=+ep` 让非 root 用户能绑 80/443,`HEALTHCHECK` 打自身 `/api/health`,`VOLUME ["/app/data"]`,`ENTRYPOINT ["docker-entrypoint.sh"]` + `CMD ["bun","--bun","server.ts"]`)。
|
||
|
||
关键取舍点(可复用的思路,不是具体命令):
|
||
- **基础镜像按"最挑剔的依赖"选**(这里是 libsimple 需要 glibc ≥2.38 → trixie,而不是官方 Bun 镜像的 bookworm),迁移到 Node 项目应类比:先看有没有原生依赖(如 argon2、sharp)对 glibc/musl 有版本要求,再定基础镜像。
|
||
- **构建产物按运行时最小闭包裁剪**:Next standalone 输出 + 手动补回被裁掉但运行时脚本仍需要的包(`next`/`drizzle-orm`/`zod`/`pinyin-pro`)。Node/Hono 项目对应:如果用 `esbuild`/`tsup` 打包成单文件,这一步天然被规避;如果是裸 `tsc` + `node_modules`,要么整包拷贝要么用 `--production` + 显式补运行时脚本依赖。
|
||
- **非 root + 目录属主用 `COPY --chown` 而非事后 `chown -R`**(省一层镜像体积)。
|
||
- **内嵌反代二进制(Caddy)做压缩/静态资源缓存**,让镜像开箱即用不必外置 nginx——这是"单容器打包"的核心手法,值得照搬。
|
||
- **HEALTHCHECK 打应用自己的回环端口**而不是反代端口,因为反代端口会随 `SITE_ADDRESS` 配置切换(3000 或 80/443),回环端口永远稳定。
|
||
|
||
### 3.2 docker-entrypoint.sh 做了什么
|
||
|
||
```bash
|
||
if [ "$(id -u)" = "0" ]; then
|
||
mkdir -p /app/data/config /app/data/uploads /app/data/backups
|
||
chown -R app:app /app/data
|
||
exec gosu app "$0" "$@" # 降权到 app 用户后重跑自己
|
||
fi
|
||
bun src/scripts/migrate.ts # 迁移
|
||
bun src/scripts/preflight.ts # 启动自检(密钥、扩展加载、非 root 等,不过就退出)
|
||
bun src/scripts/seed.ts # 空库播种(no-op if docs exist)
|
||
"$@" & # 前台进程1:Next.js
|
||
APP_PID=$!
|
||
caddy run --config /etc/caddy/Caddyfile --adapter caddyfile & # 前台进程2:Caddy
|
||
CADDY_PID=$!
|
||
trap shutdown TERM INT
|
||
wait -n "$APP_PID" "$CADDY_PID" # 任一进程退出,整体退出
|
||
```
|
||
|
||
**核心手法**:容器以 root 启动只为修数据目录权限,然后 `gosu` 降权重跑自身;`migrate → preflight → seed → start` 四段式启动;**双前台进程用 `wait -n` 互相看护**,任一挂了就 kill 另一个再退出,把"半死容器"变成"整体退出",配合 compose 的 `restart: unless-stopped` 做自愈。
|
||
|
||
**建议照搬**:四段式启动顺序(迁移→自检→(可选)播种→启动)、root 降权模式、多进程互相看护退出码,这些和技术栈无关,Node/Hono 项目原样适用(把 `bun` 换成 `node`/`tsx`)。
|
||
|
||
### 3.3 docker-compose.yml 服务划分
|
||
|
||
单服务(`uhd-docs`),没有拆 db/app 两个容器——因为用的是内嵌 SQLite,数据库就是 `/app/data` 里的一个文件,天然单容器。
|
||
|
||
**不适用**:uhd_media_tool 后端是 Kysely/**pg**,数据库是独立进程,不能照搬"单容器"这个前提。对应做法应该是 `docker-compose.yml` 拆两个服务(`app` + `postgres`,当前仓库已有 `docker-compose.dev.yml` 大概率就是这个划分,开发环境用),生产部署时 Caddy/入口容器的思路(见下)仍可以照搬,只是数据库连接串指向另一个容器/托管实例而非本机文件。
|
||
|
||
日志限制(`logging.driver: json-file, max-size: 10m, max-file: 5`)这条**建议照搬**,防止日志把磁盘写满,和数据库形态无关。
|
||
|
||
### 3.4 Caddyfile 作用
|
||
|
||
作为容器内反代:压缩(zstd/gzip,排除 `/api/*` 避免小 JSON 响应上的 CPU 浪费)、静态资源长缓存(`/_next/static/*` 一年不可变缓存)、`trusted_proxies static private_ranges` + `trusted_proxies_strict`(只信任私网段来源的 `X-Forwarded-For`,且只取最后一跳,防止客户端自己伪造跳过限速)、请求体大小分级限制(大文件上传路径 600MB,其余 API 16MB)、`SITE_ADDRESS` 驱动的自动 HTTPS(Let's Encrypt,证书存 `/app/data/caddy` 随容器数据卷持久化)。
|
||
|
||
**建议照搬**:`trusted_proxies_strict` 这条对任何有登录限速的项目都是**必须**的——没有这条,客户端可以自己塞 `X-Forwarded-For` 头把所有请求伪装成不同 IP,IP 限速形同虚设。uhd_media_tool 无论是否内嵌 Caddy,只要部署在反代后面,这条規則都要在某一层(反代或应用自己解析 `X-Forwarded-For` 的逻辑)体现。
|
||
|
||
**部分不适用**:如果 uhd_media_tool 决定不内嵌反代(直接把 Hono 服务暴露给外部 nginx/Caddy),那么压缩/静态缓存这部分职责转移给外部反代,应用只需要正确读 `X-Real-IP`/信任的 `X-Forwarded-Proto`,和 uhd_doc 的 `src/lib/request.ts` 逻辑一致即可(见 §1.3、§1.7 提到的 `getClientIp`)。
|
||
|
||
### 3.5 健康检查
|
||
|
||
`GET /api/health`(`src/app/api/health/route.ts`):无鉴权,查 `SELECT 1` 证明数据库文件可打开可读,返回 `{ok, version, database}`,`no-store`。Dockerfile 里 `HEALTHCHECK` 直接用 Bun 内置 `fetch` 打这个端点,不额外装 `curl`。
|
||
|
||
**建议照搬**:一个无鉴权、极便宜(一次 `SELECT 1`)的健康检查端点 + 用运行时自带的 HTTP 客户端做探测(Node 项目可以用 `node --input-type=module -e "fetch(...)"`,不需要额外装 curl/wget)。
|
||
|
||
### 3.6 数据目录挂载与环境变量清单
|
||
|
||
单一数据卷 `/app/data`,子目录划分:`docs.db`(数据库文件)、`uploads/`、`backups/`、`config/`(含 `runtime-secrets.json`)、`caddy/`(证书状态)。`VOLUME ["/app/data"]` 声明 + compose 挂到宿主 `./data`。
|
||
|
||
`DEPLOY.md`/`.env.example` 归纳的环境变量类别(完整清单见这两个文件,不逐条照抄):
|
||
- **必须配置类**:`APP_URL`(影响 cookie Secure 判定,生产必须是 `https://`)、`ADMIN_USERNAME`。
|
||
- **首启一次性类**:`ADMIN_INIT_TOKEN`(可留空自动生成)。
|
||
- **自动生成免配置类**:`SESSION_SECRET`/`IP_HASH_SECRET`(首启生成,持久化到 `runtime-secrets.json`,要求 ≥32 字符且不能是占位符样式如 `change_me`/`REPLACE_ME`)。
|
||
- **TTL 类**:`PASSWORD_ACCESS_TTL_HOURS`、`ADMIN_SESSION_TTL_HOURS`。
|
||
- **容器层旁路变量**(不进 Zod schema,只给 Caddy/entrypoint 用):`SITE_ADDRESS`、`MAX_UPLOAD_BODY`、`SSR_MAX_CONCURRENCY`。
|
||
|
||
**建议照搬的设计**:①密钥类("能自动生成就自动生成,但生成后要落盘且警告"),`src/lib/env.ts` 的 `resolveRuntimeSecret()` 这套"占位符检测 + 自动生成 + 持久化到 CONFIG_DIR" 模式值得原样搬到 pg 版的 `env.ts`;②敏感变量分层(应用 schema 内 vs 容器旁路变量)让 `.env.example` 保持可读。
|
||
|
||
## 4. 建议照搬 / 不适用(需改造)一览表
|
||
|
||
| 项目 | 建议照搬 | 不适用 / 需改造 |
|
||
|---|---|---|
|
||
| 密码哈希 | Argon2id 参数(`memoryCost≈19456, timeCost=2`) | Bun 内置 `Bun.password` → 换 `@node-rs/argon2`(Node 24 原生插件) |
|
||
| 首启管理员 | 一次性 setup token(自动生成+持久化+首启后失效)+ 密码只落库不落 env + CLI 兜底重置 | 无需改造,逻辑与运行时无关 |
|
||
| 会话 | 服务端 session 表(hash 存库、原文进 cookie)+ 软删除吊销 + 固定 TTL 不续期 | SQLite→pg:表结构原样搬,`integer(timestamp_ms)`→`timestamptz` |
|
||
| 鉴权中间件位置 | 校验逻辑(查 session→查过期→查吊销) | Next 的"每个 route 手动包 `withAdminApi`" → Hono 用标准 `app.use(path, middleware)`,更简洁,不必照搬包裹形式 |
|
||
| 未登录跳转 | 轻量 cookie-存在性判断做快速拦截,真正校验留给业务层 | Next `middleware.ts`(边缘层)在 SPA 里没有等价物 → 改为路由 loader + 一个薄 session 探测 API |
|
||
| CSRF | HMAC 派生 double-submit cookie(`HMAC(secret, "csrf:"+sessionId)`) | 无需改造 |
|
||
| 登录限速 | IP+账号双维度、失败计数落表、命中限速不写审计 | 无需改造 |
|
||
| 人机验证(Cap PoW) | 若确实公网暴露且介意撞库自动化,可后期引入 | 复杂度高(WASM 求解器+两张表),团队内网工具**可先跳过** |
|
||
| TOTP / Passkey | TOTP:`node:crypto` 纯手写 RFC6238,可整段复用;Passkey:替代首因子的模式 | TOTP 无需改造;Passkey 实现成本高,**建议后置**到密码+TOTP 稳定之后 |
|
||
| 密码修改流程 | 校验旧密码→改密→吊销其它会话→审计 | 无需改造 |
|
||
| 审计 | 枚举式 action 类型 + try/catch 兜底不影响主流程 | 无需改造 |
|
||
| 登录页交互 | 错误码驱动三态表单、PoW/校验完成前禁用表单、固定高度错误占位 | Next 服务端组件读初始状态 → SPA 里改成挂载时 `GET` 一个 `/auth/state` 接口 |
|
||
| Dockerfile | 多阶段构建、非root+`COPY --chown`、`HEALTHCHECK`打自身回环端口、内嵌反代做压缩/静态缓存 | 基础镜像选型逻辑不同(Node 版按自身原生依赖定,而非 libsimple/glibc 那套) |
|
||
| entrypoint | 四段式启动(迁移→自检→播种→启动)、root降权、多进程 `wait -n` 互相看护 | 无需改造,把 `bun` 换 `node`/`tsx` |
|
||
| docker-compose | 日志大小限制(`max-size/max-file`) | **单容器前提不成立**:pg 是独立进程,需拆 `app`+`postgres` 两个服务,不能照搬"一个卷搞定一切" |
|
||
| Caddyfile | `trusted_proxies_strict`(只信任私网段反代的最后一跳)是安全必需项,压缩/静态缓存可选 | 若不内嵌反代,压缩/缓存职责转给外部 nginx/Caddy,应用只需正确读 `X-Real-IP` |
|
||
| 健康检查 | 无鉴权、`SELECT 1`级别开销、用运行时自带 fetch 探测 | 无需改造 |
|
||
| 环境变量管理 | 密钥"能自动生成就自动生成+持久化+占位符检测"模式 | 无需改造 |
|
||
|
||
关键文件速查(均在 `/opt/uhd_doc/`):`src/lib/{password,totp,crypto,cookies,request,env}.ts`、`src/server/admin-auth/{session,credential,csrf,login,passkey}.ts`、`src/server/{cap/service,audit/log,api/wrappers}.ts`、`src/app/api/admin-auth/{login,logout,setup,password}/route.ts`、`src/proxy.ts`、`src/db/schema.ts`(L382–530 附近)、`src/app/admin/login/{page,login-client}.tsx`、`src/components/security/proof-of-work.tsx`、`Dockerfile`、`docker-entrypoint.sh`、`docker-compose.yml`、`Caddyfile`、`DEPLOY.md`、`.env.example`、`src/scripts/{admin-set-password,admin-show-init-token,migrate,preflight,seed}.ts`。
|