Commit Graph

3 Commits

Author SHA1 Message Date
ddbafe822c chore(main): 回流 —— opencode Zen 免费层可用 + noVNC path 相对名修复
内容与 release/v2.0.x 完全一致(git checkout release/v2.0.x -- .):
- feat(opencode): Zen 免费层门禁四要素 + 两个 id 公式(详见 release 提交)
- fix(vnc): noVNC 的 path 必须用相对名,否则 WebSocket 路径重复拼接
2026-10-15 20:31:45 +08:00
796b30f5b0 feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口
问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它
嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且
websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。

★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里
①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。

内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理):
- plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径
- handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍
  ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一)
- 鉴权与角色门禁复用现有链路,无旁路
- statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败)
- 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost
  主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写
- 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道)
- 不向插件后端转发 Authorization/Cookie/api_key

channels 接入:
- 新增 channels/browser/proxy/:session(prefix=true)
- 会话代号 = seq-随机8位,防猜到活跃会话
- view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify
- 会话过期即回收浏览器栈并返回 404

判据(每条咬真实外部效应):
- 静态资源转发
- 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点)
- 网关凭据不泄漏(在服务端断言请求头)
- 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo)
- 跨站 Origin 403
- 未鉴权 401、过期会话 404、非子路径 404

回归:go test -tags luajit ./... 9 包全绿
2026-10-15 15:02:04 +08:00
cf53d72af4 fix: 回退两处内核改动,channels 改为插件自持持久化(照参考实现)
用户指出「你又tm改内核?」—— 核对后确认两处本可避免:
  1. internal/lua/vm.go  +32 行  新增 base64_decode 全局函数
  2. internal/lua/plugin_http_handler.go +16 行  InvokeHandler 补 markDirty
工作区已还原到 v2.0.2(git diff v2.0.2 = 0 行)。

参考实现(dsh-our-free-model/vendor/channel-pack)对同一问题的做法:
- 解 JWT:用**语言自带**的 `Buffer.from(..., 'base64url')`,从不为此改宿主。
  → 我改成了插件的纯 Lua b64url_decode(约 20 行),实测解出真 JWT 的
    nickname/sub/exp 全对。
- 状态持久化:插件**自己 writeFileSync+renameSync** 写
  `channel-pack/state.json`,从不依赖宿主 settings。
  → 参考还记下了同构教训(Gitee IKI7WT):DSH 0.1.7 移除 settings.register()
    后账号列表退化为纯内存;正解是**插件探测宿主能力、自持文档**,不是要求
    宿主改。我的 markDirty 修法方向相反:把插件的数据安全外包给宿主某条
    分发路径的实现细节。

- `.channels-store.json`(tmp + rename 原子替换),只放**索引与配置**
  (账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
  凭据仍在 .secrets/(0600),两者分离:即便文档泄露也不含 token。
- 变更点同步落盘(不用「置标志+定时 flush」):本插件无 hooks、45 个端点
  各自变更状态,还有大量内部互调;挂在某个边界就必须保证**每条**路径都
  经过它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。
  状态实测 ~1.7KB,写入只发生在运维动作上,不在请求热路径。
- 黑名单写入收口到 setModelDisabled(写点曾有 5+ 处,散着写必漏)。
- 存量迁移:读内核遗留 `.channels.state.json`(v2.0.4 内核写的)→ 写自有
  文档 → **写成功才删**遗留(写失败还删=用一次迁移丢数据);自有存储已存在
  时也删遗留,否则内核 restore() 会在启动时用它覆盖。

1. **测试污染生产目录**(严重):插件 storeDir() 读 /etc/llmsproxy/config.yaml
   决定落盘位置,而既有测试都没隔离 → 一次全量测试把 26 个测试账号写进了
   **生产** .channels-store.json(含 fixture-a/example 等夹具 id)。
   prod 文件已删除(真实账号另有来源);kernelTestCore 现在隔离插件目录,
   且尊重调用方已设的值(多次「重启」需共用同一目录)。
2. model_set_disabled 改了写入口后仍引用已删除的局部变量(nil 索引崩溃)。

- TestChannelsStoreIsPluginOwned:状态写进**插件自己的**文件、不含凭据字段、
  新实例读回。变异(markDirty 空操作)→ FAIL。
- TestChannelsMigrationKeepsLegacyOnWriteFailure:**写失败必须保留遗留文件**。
  变异(无条件删)→ FAIL。(这条单独测:迁移成功后的删除有两条路径互相掩盖,
  只测「最终文件没了」区分不出「写失败也删了」。)
- TestChannelsMigratesLegacyKernelState:遗留账号迁入内存 + 自有存储写出 +
  遗留文件删除。
- 内核对照 v2.0.2:diff 为空。

go test ./internal/core/(78s)与 ./internal/lua/(63s)全绿;
全量测试后确认生产插件目录未被写入。
2026-10-13 09:34:13 +08:00