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 侧桥接可用。
This commit is contained in:
JianFeeeee
2026-10-11 13:52:29 +08:00
parent dafc3b3fc3
commit a59295e826
11 changed files with 2062 additions and 0 deletions

View File

@ -0,0 +1,279 @@
package lua
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// Package lua — 插件凭据保险库(R4-d)
//
// 用户点明的能力:插件**自己主导外部源的链接认证、自动领取额度**。这类插件
// (对照 dsh-our-free-model 的渠道插件形态)要长期持有 OAuth token、session、
// 设备指纹等凭据,并在定时任务里拿去换额度、换上游。
//
// 在保险库之前,插件唯一的持久化落点是 plugin.state,而它有两个问题:
//
// 1. **权限**:.state.json 是业务累计量(计费总额、签到天数),和凭据混在同一个
// 文件里。它没有 0600,落在插件目录(0755)下 —— 存 token 等于把凭据摊开。
// 2. **语义**:state 会被 PUT /api/plugins/{name}/state 整体替换,会被
// `prices` 分离逻辑触碰,会进 WebUI。凭据进去就有可能被一次"重置状态"
// 或一次 UI 回显带走。
//
// 保险库把凭据隔离成独立文件:
//
// <plugin_dir>/.secrets/<plugin>.json 权限 0600,目录 0700
//
// 与状态分离,不参与 UI 回显,不参与 state 替换。
//
// # 谁能读
//
// 只有该插件自己(按启动时的固定名字)。跨插件读会被拒 —— 记名方式是插件名
// 而非任意引用,因为参考插件的 `credentialRef` 模式(任意字符串引用)在这里
// 会变成一个可以指向别人凭据的键空间。
//
// # 与内核动作的关系
//
// 凭据本身不进 Lua 的返回值上限之外的地方:取出后由插件用于自己的 HTTP 调用。
// 保险库不把凭据交给内核,内核也不需要读它。
// secretsDirName is the vault directory inside plugin_dir. The leading dot keeps
// it out of a casual `ls` of the plugin directory, which is where operators look
// for "what plugins do I have" — the vault is not a plugin.
const secretsDirName = ".secrets"
// secretsDir returns the vault directory (empty when there is no plugin dir, in
// which case the vault is disabled — same posture as state persistence).
func (ps *Plugins) secretsDir() string {
if ps.dir == "" {
return ""
}
return filepath.Join(ps.dir, secretsDirName)
}
// secretFile returns one plugin's vault path.
func (ps *Plugins) secretFile(plugin string) string {
d := ps.secretsDir()
if d == "" {
return ""
}
return filepath.Join(d, plugin+".json")
}
// validateSecretKey rejects keys that could not round-trip through the vault.
//
// The vault is a flat string->string map serialized as JSON, so a key only needs
// to be non-empty and not absurdly long. Traversal is not a concern because keys
// are never used as path components; the FILE name comes from the plugin name,
// which is already validated when the plugin loads.
func validateSecretKey(key string) error {
if strings.TrimSpace(key) == "" {
return fmt.Errorf("secret key must not be empty")
}
if len(key) > 200 {
return fmt.Errorf("secret key too long (%d chars, max 200)", len(key))
}
return nil
}
// loadSecretsLocked reads one plugin's vault. Caller holds ps.mu.
//
// A missing file means "nothing stored yet" — the normal first-run state. A
// corrupt file returns an error rather than an empty map: silently treating
// unreadable credentials as absent would make a plugin re-login and overwrite
// whatever the file still held, which is how a recoverable parse problem becomes
// a lost session.
func (ps *Plugins) loadSecretsLocked(plugin string) (map[string]string, error) {
path := ps.secretFile(plugin)
if path == "" {
return map[string]string{}, nil
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return map[string]string{}, nil
}
if err != nil {
return nil, err
}
m := map[string]string{}
if len(b) == 0 {
return m, nil
}
if err := json.Unmarshal(b, &m); err != nil {
return nil, fmt.Errorf("vault for plugin %s is unreadable: %w", plugin, err)
}
return m, nil
}
// writeSecretsLocked persists one plugin's vault with 0600.
//
// The mode is set on the temp file BEFORE the rename, so the secret is never
// visible under a looser mode at any instant — a chmod-after-write would leave a
// window where the token sits in a world-readable file.
func (ps *Plugins) writeSecretsLocked(plugin string, m map[string]string) error {
path := ps.secretFile(plugin)
if path == "" {
return fmt.Errorf("no plugin directory configured; the credential vault is disabled")
}
dir := ps.secretsDir()
if err := os.MkdirAll(dir, 0o700); err != nil {
return err
}
// Tighten an existing directory too: MkdirAll is a no-op when it exists, so a
// vault created before this mode was enforced would keep its old permissions.
_ = os.Chmod(dir, 0o700)
b, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(dir, plugin+".*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
cleanup := func() { _ = os.Remove(tmpName) }
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
cleanup()
return err
}
if _, err := tmp.Write(b); err != nil {
tmp.Close()
cleanup()
return err
}
if err := tmp.Close(); err != nil {
cleanup()
return err
}
if err := os.Rename(tmpName, path); err != nil {
cleanup()
return err
}
return nil
}
// ---- 导出给 Lua 的 API ----
// SetSecret stores one credential for a plugin.
func (ps *Plugins) SetSecret(plugin, key, value string) error {
if plugin == "" {
return fmt.Errorf("plugin name required")
}
if err := validateSecretKey(key); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
m, err := ps.loadSecretsLocked(plugin)
if err != nil {
return err
}
m[key] = value
return ps.writeSecretsLocked(plugin, m)
}
// GetSecret reads one credential. The second return reports whether it existed,
// which lets a plugin distinguish "no token stored yet" from "token is empty"
// without a sentinel value.
func (ps *Plugins) GetSecret(plugin, key string) (string, bool, error) {
if plugin == "" {
return "", false, fmt.Errorf("plugin name required")
}
if err := validateSecretKey(key); err != nil {
return "", false, err
}
ps.mu.RLock()
defer ps.mu.RUnlock()
m, err := ps.loadSecretsLocked(plugin)
if err != nil {
return "", false, err
}
v, ok := m[key]
return v, ok, nil
}
// DeleteSecret removes one credential. Removing a missing key is not an error —
// "make sure nothing is stored under this key" is the useful postcondition.
func (ps *Plugins) DeleteSecret(plugin, key string) error {
if plugin == "" {
return fmt.Errorf("plugin name required")
}
if err := validateSecretKey(key); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
m, err := ps.loadSecretsLocked(plugin)
if err != nil {
return err
}
delete(m, key)
return ps.writeSecretsLocked(plugin, m)
}
// SecretKeys lists the KEY NAMES a plugin has stored (never the values), so a
// plugin can show "which accounts are configured" without exposing token bytes.
func (ps *Plugins) SecretKeys(plugin string) ([]string, error) {
if plugin == "" {
return nil, fmt.Errorf("plugin name required")
}
ps.mu.RLock()
defer ps.mu.RUnlock()
m, err := ps.loadSecretsLocked(plugin)
if err != nil {
return nil, err
}
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out, nil
}
// DeleteAllSecrets wipes a plugin's vault. Used on plugin removal so uninstalling
// a plugin does not leave live credentials behind.
func (ps *Plugins) DeleteAllSecrets(plugin string) {
ps.mu.Lock()
defer ps.mu.Unlock()
if path := ps.secretFile(plugin); path != "" {
_ = os.Remove(path)
}
}
// PurgeOrphanVaults removes vault files whose plugin no longer exists.
//
// Vaults outlive the plugin file only if a removal path forgot to clean up, so
// this is a startup sweep rather than a hot path. Its value is that a deleted
// plugin cannot leave a live OAuth token on disk indefinitely.
func (ps *Plugins) PurgeOrphanVaults() {
dir := ps.secretsDir()
if dir == "" {
return
}
entries, err := os.ReadDir(dir)
if err != nil {
return
}
ps.mu.RLock()
live := make(map[string]bool, len(ps.plugins))
for _, p := range ps.plugins {
live[p.Info.Name] = true
}
ps.mu.RUnlock()
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
name := strings.TrimSuffix(e.Name(), ".json")
if live[name] {
continue
}
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}