Files
ModelRouter/internal/lua/plugin_secrets.go
JianFeeeee 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

280 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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()))
}
}