3 Commits

Author SHA1 Message Date
028e42b036 fix(plugins): 出站主机授权两处真缺陷 —— 已启动 worker 看不到新授权、授权不落盘
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。

缺陷一(授权对已启动 worker 无效):
  httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
  闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
  (newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
  ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
  修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
  rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
  无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。

缺陷二(授权不持久化):
  .kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
  设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
  修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
  纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
  与 actions 一起写入(支持"只批了主机、没批动作"的行)。

回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
  worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
  后授权保留。

go test ./internal/lua/ 全绿;build/vet 通过。
2026-10-13 06:57:28 +08:00
c3b858d5ba feat(lua): 多流程登录会话抽象(R4-e)+ 内核权限设置页
用户决策:**多流程抽象**(VNC 作逃生舱)+ **单插件多平台**。

## 为什么不是 VNC 为主路径(实测参考插件后的结论)

grep 参考插件(dsh-our-free-model,13 个账号渠道)的源码:

  puppeteer / playwright / chromium / headless / vnc / selenium  →  0 处

它的登录全部是**把 URL 交给用户自己的浏览器**,插件轮询凭据是否就绪:
loginUrl 113 次 · cookie 133 次 · oauth 43 次 · deviceCode 8 次 · qrcode 7 次。

不以 VNC 为主路径的四条具体理由:
1. Passkey/WebAuthn 基本不可用:凭据绑在用户设备,VNC 里是另一台机器。
2. 无头浏览器更容易被判为机器人 —— 登录场景最不能踩这个。
3. 安全面:llmsproxy 以 root 运行(master.key 0600),systemd 已 ProtectHome=yes。
   从 root 服务开 VNC 通道到"即将登录用户各平台账号"的浏览器 = 提权面绑高价值目标。
4. 最关键:VNC 不解决取凭据。登录后凭据活在浏览器 profile 的 cookie 里,
   还是要提取 profile —— 那正是最脆弱的一环。轮询流程直接产出 token。

VNC 唯一不可替代的场景(服务端与用户不同网 + 平台要求登录 IP 与调用 IP 一致)
使它作为逃生舱存在:**默认关闭**,需运维显式开启。

## 登录会话抽象(plugin_login.go)

四种流程:link-poll(首选)· callback(复用 Phase 6 自定义挂载)· paste(兜住
cookie 类渠道)· vnc(逃生舱)。内核只管会话生命周期,不含任何平台知识。

- 会话只存**状态**不存凭据:凭据由插件写进自己的保险库,会话里放一个
  UI 可见的记录会让 token 有机会被列表接口带出去
- nonce 只在 begin 时给(后续读取剥离),空 nonce 不匹配任何会话
- 读取时判定过期,无后台定时器(没有登录请求时零开销)
- 跨插件读不可行,且与"不存在"不可区分(否则会确认 id 存在)

## 关键阻碍与修法:登录主机不在出站白名单里

**实测确认**:`auth.huaweicloud.com` 被白名单拒绝
("not an allowed outbound destination (allowed: the hosts of configured sources)"),
整个登录流程无法工作 —— 因为登录主机不是上游源。

修法与内核权限同一姿态:插件在源码 `plugin.auth_hosts` **声明**,运维在设置页
**批准**后才放行。只能批准插件声明过的 host(否则声明只是装饰),逐个批准
不连带(否则一个"登录"理由就能打开整张名单)。

## 设置页

插件表格新增"内核动作"列 + 点击展开的权限面板:动作清单按危险级排序、勾选即
授权、**申请中但未授权会标红**(那通常意味着插件某功能静默失效)。
另加登录主机批准面板(已批准/待批准)。

后端 /api/plugins/{name}/kernel(GET 看 declared vs granted,PUT 改)
与 /api/plugins/{name}/login(列在飞会话,供页面显示"等待你完成登录"+ URL)。

开发中自己引入并修掉一个缺陷:renderPlugins → refreshKernelSummary →
renderPlugins 无限递归;改为只原地 patch 受影响的单元格。

测试 8 个:会话生命周期/跨插件隔离/callback nonce/登录主机门控/只能批准已声明/
过期判定/VNC 需显式开启/未知流程名被拒。node --check 验证内联 JS 可解析。
2026-10-11 13:56:35 +08:00
a59295e826 feat(lua): 插件可调内核动作面(R4-c)+ 凭据保险库(R4-d)
用户指出「接口不全」——此前插件只有 __llmsproxy_adapters 三个手写闭包,
够不到内核。对照参考插件(dsh-our-free-model,13 个账号渠道)的真实能力,
缺的正是这两块:

  插件自己驱动链接认证 → 存凭据 → 定时领额度 → 把源接进内核

## R4-c 内核动作面:__llmsproxy_kernel

18 个动作,全部映射到既有 Core 方法(不是新造能力):

  只读 6:status / list_sources / list_models / list_adapters /
          list_auto_rules / adapter_owner
  写   8:reload / reset_health / probe_all / save_auto_rules /
          save_key_auto / add_source / create_key / update_key
  破坏性 4:remove_source / delete_key / remove_adapter / set_plugin_enabled

**add_source 是「插件注册自己的上游」的落点**——插件拿到凭据后可以自己把
端点接进网关,无需运维改 config.yaml。已端到端验证:插件调它 → 内核源列表
出现 → 模型进入可路由列表。

依赖方向:lua 不能 import core,所以接口定义在 lua、实现放 core
(kernel_actions.go),走 SetKernelAPI 注入——与 SetOutboundHosts 同一先例。

**凭据边界**:动作面**有意排除** list_keys/find_key。Core.ListKeys() 返回
明文网关密钥,交给第三方 Lua 等于交出整个部署的凭据面。有回归测试锁死
这条(TestKernelActionSurfaceExcludesPlaintextKeys)。list_sources 返回的
api_key 也强制掩码(只给 ***+后4位)。

**权限**(用户选「全开+设置页可配」):18 个动作全部可达,但**默认拒绝**,
授权落盘 .kernel-perms.json(独立文件,不并进 .access.json——后者是
map[string]string,塞进去会破坏旧二进制能读的格式)。danger 级不接受通配符。
插件可在源码 plugin.kernel={...} **申请**,设置页显示申请 vs 已授予。

**Go 侧 deadline**(用户选定):每个动作有超时预算,超时把 (nil,err) 还给
Lua。这可行的原因:Lua 只是等返回值,不占 Lua 栈执行权——与「Lua 死循环
不可中断」是两件事。

## R4-d 凭据保险库:__llmsproxy_secrets

此前插件存 token 只能写 plugin.state:0755 目录、明文、与计费累计量同文件、
会被 PUT /state 整体替换、会进 WebUI。现在独立文件:

  <plugin_dir>/.secrets/<plugin>.json   文件 0600,目录 0700

临时文件**先 chmod 再 rename**,全程不存在宽权限窗口。读取身份在插件 boot
时绑定(不从 Lua 状态现读)⇒ 跨插件读不可行是机制而非约定。插件删除时清空
(DeleteAllSecrets + 启动 PurgeOrphanVaults)。

## 开发中被测试抓出的两个真实缺陷(我自己引入的)

写了 TestNoKernelActionDeadlocksFromLua 对**每个**动作断言「在插件 Lua 调用
(此时持有 p.mu)内不得死锁」,抓出:

1. **自死锁**:set_plugin_enabled → SetEnabled → p.mu.Lock(),而死锁发生在
   调用者已持有的同一把锁上。
2. **更隐蔽的迟到副作用**:超时把 error 返回给插件后,动作仍在幕后执行完成——
   插件以为失败,实际已生效。

修法:标注 Deferred 的动作在本次调用返回后异步执行,如实返回 accepted:true
而不是谎报成功。18 个动作全部复验无死锁,另有单独测试确认副作用真的落地
(只测「不死锁」的话,一个全空转的 dispatch 也能通过)。

测试 11 个(core 8 + lua 3):动作可用/密钥掩码/默认拒绝/授权计数/
插件注册源并变为可路由/授权落盘重启保留/未知动作名被拒/凭据面排除明文/
全动作无死锁/破坏性动作生效/Lua 侧桥接可用。
2026-10-11 13:52:29 +08:00