站点架构导入1527 字·2026/11/14·被检索 0 次

门户 portal · 统一入口与身份中枢

门户 portal 是站群唯一公网入口与登录中枢,零框架 Node 服务跑在 0.0.0.0:3000。它发静态页、反代 /vocab、/notes、/c-lab,并在反代前解析 pf_sid 会话、剥离伪造头、注入 X-User-* 与 x-portal-secret。本文实测路由表、portal.db 六张表、会话与限流机制及计划任务 KaoyanPortal 的启停方式。

挂靠在《四、网站架构总览》之下

版本
使用说明
v1reused-preview2026/11/14 11:16:32

门户 portal · 统一入口与身份中枢

一句话:portal 是整个站群唯一的公网入口与登录中枢,一个零框架的 Node HTTP 服务跑在 0.0.0.0:3000,既发静态页面,也把 /vocab、/notes、/c-lab 反代给三个子站,并在反代前把会话身份写成 X-User-* 头。

一、定位与入口

项实测值
公网地址https://cag.quest(Cloudflare Tunnel,主入口);https://cag.odn.cc(PassNAT frpc,备用)
监听端口0.0.0.0:3000(实测 Get-NetTCPConnection,PID 47632,命令为 node server.js)
basePath无。portal 自己就是根路径 /
计划任务KaoyanPortal,动作是 powershell -File ops\launch.ps1 -Name portal,当前 Running
代码位置C:\Users\cag\WorkBuddy\2026-10-11-06-00-16\portal
依赖仅 Node 内置模块:http、fs、path、node:sqlite、node:crypto,无 express、无 Next
数据库portal/data/portal.db(SQLite,WAL 模式)

实测 GET http://127.0.0.1:3000/healthz 返回:{"portal":"ok","vocab":"ok","notes":"ok","prob":"ok","clab":"ok"}。门户不是简单的静态首页,它同时承担健康检查聚合器。

二、技术栈与目录结构

portal/
  server.js              唯一入口:路由 + 反代 + 静态服务(247 行)
  index.html             门户首页,四张站点卡片
  login.html             登录页
  register.html          注册页
  me.html                个人主页
  notices.html           公告与反馈
  features.html          功能说明
  assets/
    portal.css           全站共享样式
    portal.js            门户交互脚本
    guide.js             跨站新手引导(笔记站也复用同源 /assets/guide.js)
  lib/
    db.js                node:sqlite 建表与连接(6 张表)
    auth.js              scrypt 密码、pf_sid 会话、限流、身份头
    api.js               /auth/* 与 /api/portal/* 的具体处理
    repo.js              用户、公告、反馈的数据访问
    secret.js            门户与子站共享密钥的生成与轮换
  data/
    portal.db            SQLite 主库
    portal-secret.txt    共享密钥(32 字节随机数的十六进制,64 字符)

server.js 顶部就写死了四个下游端口:VOCAB_PORT=3001、NOTES_PORT=3102、CLAB_PORT=3103,概率游戏则是同机另一个项目目录的静态文件。没有环境变量配置中心,端口是常量。

三、数据模型

portal.db 实测 6 张业务表(不含 sqlite_*),行数取自当前库:

表用途关键字段关系
users账号本体id、username(全小写唯一)、display_name、password_hash、role(user/admin)、disabled首位注册者自动 admin;sessions.user_id 外键指向它
sessions登录会话sid 主键、user_id、expires_at、ua、ip多对一 users,ON DELETE CASCADE
login_log登录流水username、ok、reason、ip、ua、at失败也记,用于识别撞库
announcements管理员公告title、body、level、pinned作者为 users.id
announcement_reads公告已读回执复合主键 (announcement_id, user_id)、read_at用于未读角标
reports问题反馈kind、title、body、page、status、reply匿名时 user_id 可为空

实测当前有 5 个用户、7 条会话、8 条登录流水、0 条公告、0 条反馈。建表全部是 CREATE TABLE IF NOT EXISTS,属于纯增量迁移,不破坏旧库。连接上执行 PRAGMA journal_mode=WAL、foreign_keys=ON、busy_timeout=5000。

四、请求链路

以打开 https://cag.quest/notes/note/xxx/ 为例,逐跳如下:

  1. 浏览器 → Cloudflare 边缘。ops/cf/config.yml 只声明一个主机名 cag.quest(以及 www),ingress 指向 http://127.0.0.1:3000,并刻意不改写 Host。
  2. Cloudflare → cloudflared 进程(实测 PID 37808,tunnel run kaoyan),QUIC 回源到本机 3000。
  3. portal server.js 收到请求,先解析 Cookie: pf_sid=...,查 sessions JOIN users 得到 {id, username, displayName, role}。会话过期或用户被禁用会立即删除会话并判为匿名。
  4. 命中 /notes 前缀,进入 proxy(req,res,3102,u=>u):
    • 复制原始请求头;
    • 删除客户端可能伪造的 x-user-id、x-user-name、x-user-username、x-user-role、x-portal-secret;
    • 无条件写入 x-portal-secret(来自 portal/data/portal-secret.txt);
    • 已登录时再写入 x-user-id、x-user-name(URL 编码)、x-user-username、x-user-role;
    • 保留原始 Host,否则 Next 的 308 会跳到 127.0.0.1,公网用户打不开。
  5. portal 以 http.request 连 127.0.0.1:3102,把响应状态与响应头原样回写给浏览器。下游不可达时返回 502 的 HTML 提示页。
  6. 笔记站 lib/identity.ts 再次校验 x-portal-secret,一致才信任 X-User-*,否则视为匿名。

五、关键机制

1)反代规则表(实测自 server.js)

路径目标路径处理
/vocab、/vocab/*127.0.0.1:3001原样转发,仅把裸 /vocab 补成 /vocab/
/api/*127.0.0.1:3001重写为 /vocab/api/*,并补尾斜杠,兼容词汇站前端写的绝对路径 fetch("/api/...")
/notes、/notes/*127.0.0.1:3102原样转发,笔记站自己带 /notes basePath
/c-lab、/c-lab/*127.0.0.1:3103原样转发,C-Lab 自己剥前缀
/prob/*静态目录指向项目外的 2026-10-12-20-02-26\概率勇者考研秘境
/assets/*portal/assets门户共享 CSS/JS,笔记站引导脚本也走这里
/auth/*、/api/portal/*门户本地注册、登录、登出、改资料、改密码、登录记录、公告、反馈
/healthz门户本地并发探测三个上游 + 概率目录,返回聚合 JSON

注意:任务描述里写的 /lab 在实际代码中不存在,真实前缀是 /c-lab;端口也不是 3200,而是 3103。这是本次勘查第一个与预想不符、以实测为准的点。

2)身份头的「先删后注」。IDENTITY_HEADERS 是白名单式清理,客户端带什么都会被抹掉。x-user-name 可能是中文,HTTP 头只允许 ASCII,所以用 encodeURIComponent 编码,由下游 decodeURIComponent 还原。x-portal-secret 也是先删再注入,避免外部直接带一个旧密钥。

3)pf_sid 会话。Cookie 名固定 pf_sid,值是 32 字节随机数的 hex(64 字符),只存 HttpOnly,SameSite=Lax,HTTPS 下加 Secure。有效期 30 天;剩余不足 7 天时滑动续期。服务端可随时 DELETE FROM sessions 强制下线,这也是不用 JWT 的理由:JWT 无法立即失效。

4)共享密钥校验。lib/secret.js 首次访问自动生成 32 字节随机密钥写入 portal-secret.txt,权限 0600;每次读取都 stat 比对 mtime,密钥轮换后运行中的进程立刻跟上。子站拿不到文件时会降级为不校验(保证旧部署不 500),这是安全上的已知折衷。

5)登录限流。进程内滑动窗口,同一 IP + 小写用户名 在 10 分钟内失败 8 次即拒绝。Map 超过 2000 个 key 会整体清空,防止无界增长。重启即清零,属于单机够用、集群不够用的实现。

六、发布与运维

门户没有构建步骤,改完 server.js 或 HTML 直接重启进程即可。标准操作是:

Stop-ScheduledTask  -TaskName KaoyanPortal
Start-ScheduledTask -TaskName KaoyanPortal

ops/launch.ps1 -Name portal 会先查 3000 端口的监听者并 Stop-Process,再以隐藏窗口启动 node server.js,日志重定向到 portal/portal-server.log 与 .err。这套「先杀端口再启动」的逻辑保证重复执行任务幂等。

常见故障:

  • 子站 502:portal 本身正常,但 3001/3102/3103 有一个没起来。先看 /healthz 哪一项不是 ok,再单独重启对应任务。
  • 登录后子站仍是匿名:检查 portal-secret.txt 是否被重建,导致子站缓存的旧密钥不匹配;再确认请求确实经过 3000,而不是直连 3102。
  • 308 跳到 127.0.0.1:反代或隧道改写了 Host。config.yml 里不能写 httpHostHeader,portal 也必须保留原始 Host。
  • 端口占用:launch.ps1 会杀监听者;若进程是 SYSTEM 权限的孤儿,普通权限杀不掉,需要管理员 PowerShell。

七、现状与改进点

结构债一:端口硬编码。 server.js 里 3001/3102/3103 是常量,launch.ps1 里又写了一遍,config.yml 里再写一次 3000。改一个端口要改三处,且没有单一配置源。

结构债二:身份与业务同进程。 门户既是静态服务器、又是认证服务、又是反代、又是健康检查器;server.js 已经 247 行且路由是手写 if 链,继续加站点会越来越难读。

结构债三:限流与会话都在进程内。 重启丢限流计数;多实例部署时会话表虽然共享 SQLite,但限流不共享。

风险:/api/* 被整体重写给词汇站。 这是历史兼容逻辑,意味着门户自己以后想加 /api/xxx 必须放在 /api/portal/ 下,否则会被误转发。新人最容易在这里踩坑。

建议:把端口、上游地址、密钥路径抽到 portal/config.json,由 server.js 与 launch.ps1 共同读取;把反代规则做成表驱动;给 /healthz 增加超时与降级标记;长期看可以把认证抽成独立小服务,但以当前单机规模,优先级低于「配置集中化」。