门户 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/ 为例,逐跳如下:
- 浏览器 → Cloudflare 边缘。
ops/cf/config.yml只声明一个主机名cag.quest(以及www),ingress 指向http://127.0.0.1:3000,并刻意不改写 Host。 - Cloudflare →
cloudflared进程(实测 PID 37808,tunnel run kaoyan),QUIC 回源到本机 3000。 - portal
server.js收到请求,先解析Cookie: pf_sid=...,查sessions JOIN users得到{id, username, displayName, role}。会话过期或用户被禁用会立即删除会话并判为匿名。 - 命中
/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,公网用户打不开。
- portal 以
http.request连127.0.0.1:3102,把响应状态与响应头原样回写给浏览器。下游不可达时返回 502 的 HTML 提示页。 - 笔记站
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 增加超时与降级标记;长期看可以把认证抽成独立小服务,但以当前单机规模,优先级低于「配置集中化」。