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

452
internal/lua/kernel_api.go Normal file
View File

@ -0,0 +1,452 @@
package lua
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
)
// Package lua — 插件可调用的内核动作面(R4-c)
//
// 用户需求(原话):「现在这个接口不全,可以多给一些接口让 lua 调用内核。」
//
// 在此之前,插件只能通过 __llmsproxy_adapters 管适配器 —— 那是三个手写闭包。
// 内核另有十几个公开动作(重载配置、清冷却、重探上游、增删源/密钥、改 AUTO 链…),
// 插件一个都调不到。而"自愈 / 联动 / 自动孵化"这类插件能力恰恰需要它们:
//
// 探测到上游整片挂掉 → probe_all + reset_health
// 发现额度变化 → save_auto_rules 重排链路
// 拿到新端点 → add_source
//
// # 为什么 Go 侧 deadline 是可行的(而 Lua 死循环不可中断)
//
// 这是整个设计的支点。两者形态完全不同:
//
// Lua 里 while true do end → 无法中断(LuaJIT 限制,实测三次)
// Lua 里 __llmsproxy_kernel.call(...) → **可以**超时
//
// 因为后者 Lua 只是在等一个返回值,不占用 Lua 栈的执行权。所以每次调用在 Go
// 侧包一层 select + ctx deadline:超时就把 (nil, err) 还给 Lua。这比"Lua 投递
// 请求、Go 队列执行"干净 —— 队列还要定义"超时后积压的命令算什么",而 deadline
// 的语义是闭合的。
//
// # 权限:全开,但默认不授予
//
// 用户选定「全开」(13 个动作都可达)+ 「设置页可配权限」。全开描述的是**能力
// 存在**,不是**默认授予**:一个插件能删密钥这件事必须由运维在设置页点过确认。
// 因此默认拒绝,授权记录在 .access.json 里(复用现有覆盖机制)。
//
// danger 级动作必须**逐个**授权,不接受通配符 —— 否则运维图省事写个 "*",
// "删密钥"就和"读状态"同权了。
// KernelAPI 是内核暴露给插件的能力面。由 internal/core 实现并注入。
//
// 依赖方向:internal/lua 不能 import internal/core(方向是 core → lua),
// 所以这里定义接口、core 侧实现 —— 与 SetOutboundHosts 同一个注入先例。
type KernelAPI interface {
// Actions 列出可调用的动作。设置页据此渲染权限开关,插件据此发现能力。
Actions() []KernelAction
// Invoke 执行一个动作。实现**必须**尊重 ctx:ctx 取消后应尽快返回,
// 否则插件侧的超时虽然会返回错误,但该动作仍在后台跑(变成不可见的泄漏)。
Invoke(ctx context.Context, name string, args map[string]interface{}) (interface{}, error)
}
// 危险级。它决定权限授予的严格程度,也决定设置页用什么颜色提示。
const (
// DangerRead 无副作用,只读。仍然需要授权,但可以一次授予全部。
DangerRead = "read"
// DangerWrite 修改运行状态或配置,但可恢复(重载、清冷却、改链路)。
DangerWrite = "write"
// DangerDestructive 删除类,或让服务中断。**必须逐个授权**。
DangerDestructive = "destructive"
)
// KernelAction 描述一个可调用动作。
type KernelAction struct {
// Deferred marks an action that MUST NOT run while the Lua caller holds its
// plugin lock. The Lua entry points (hook, exported call, scheduled task) all
// hold p.mu for the whole call, and an action that re-enters the plugin layer
// (set_plugin_enabled reaches SetEnabled → p.mu.Lock()) would self-deadlock —
// and with a deadline, would then execute invisibly AFTER the plugin was told
// it failed.
//
// Such an action is queued and run on its own goroutine once the Lua call has
// returned, so the caller gets "accepted" rather than a lock-up or a lie.
Deferred bool
Name string `json:"name"`
Title string `json:"title"`
Description string `json:"description"`
Danger string `json:"danger"`
Params []string `json:"params,omitempty"`
// TimeoutMS is the Go-side deadline for one invocation. Long actions (a
// full re-probe walks every source over the network) need a bigger budget
// than a state read; making it per-action rather than one global number is
// what keeps the deadline honest rather than arbitrary.
TimeoutMS int `json:"timeout_ms"`
}
// kernelCatalog is the full action set. It lives in the lua package (rather than
// being discovered from the implementation) so that permissions can be validated
// even when no Core is wired — tests, and a gateway with plugins but no kernel.
//
// Every action here maps to an existing Core method; adding one is a deliberate
// act rather than a reflex. ListKeys/FindKey are deliberately ABSENT: they return
// PLAINTEXT gateway keys, and handing those to third-party Lua would hand over
// the deployment's entire credential surface. Key-related reads go through
// masked forms instead.
var kernelCatalog = []KernelAction{
// ---- 只读 ----
{Name: "status", Title: "运行状态", Danger: DangerRead, TimeoutMS: 3000,
Description: "各源的调度状态(冷却、连续失败、最近错误)"},
{Name: "list_sources", Title: "源列表", Danger: DangerRead, TimeoutMS: 3000,
Description: "已配置的上游源(api_key 已掩码)"},
{Name: "list_models", Title: "模型列表", Danger: DangerRead, TimeoutMS: 3000,
Description: "当前可路由的模型 id 列表"},
{Name: "list_adapters", Title: "适配器列表", Danger: DangerRead, TimeoutMS: 3000,
Description: "已加载的适配器名与版本"},
{Name: "list_auto_rules", Title: "AUTO 链路", Danger: DangerRead, TimeoutMS: 3000,
Description: "当前全局 AUTO 链的槽位"},
{Name: "adapter_owner", Title: "适配器归属", Danger: DangerRead, TimeoutMS: 3000,
Params: []string{"name"},
Description: "某适配器由谁安装(operator 或 plugin:名字)"},
// ---- 写(可恢复) ----
{Name: "reload", Title: "重载配置", Danger: DangerWrite, TimeoutMS: 30000,
Description: "重新读取运行配置并重建源/链路"},
{Name: "reset_health", Title: "清除冷却", Danger: DangerWrite, TimeoutMS: 5000,
Description: "清掉所有源的退避状态(修好上游后立即恢复)"},
{Name: "probe_all", Title: "全量探测", Danger: DangerWrite, TimeoutMS: 60000,
Description: "对每个源做一次真实可达性探测(走网络,慢)"},
{Name: "save_auto_rules", Title: "设置 AUTO 链", Danger: DangerWrite, TimeoutMS: 10000,
Params: []string{"entries"},
Description: "替换全局 AUTO 链;entries 为 [{model, source, ...}] 数组"},
{Name: "save_key_auto", Title: "设置按 key 链路", Danger: DangerWrite, TimeoutMS: 10000,
Params: []string{"key_name", "entries"},
Description: "替换某个网关 key 自己的 AUTO 链"},
{Name: "add_source", Title: "新增源", Danger: DangerWrite, TimeoutMS: 30000,
Params: []string{"source"},
Description: "把一个新的上游源接入(source 为源定义表)"},
{Name: "create_key", Title: "新建网关密钥", Danger: DangerWrite, TimeoutMS: 10000,
Params: []string{"name", "role", "models"},
Description: "创建一个网关 key(返回的明文密钥只在此次响应里出现)"},
// ---- 破坏性:必须逐个授权 ----
{Name: "remove_source", Title: "删除源", Danger: DangerDestructive, TimeoutMS: 30000,
Params: []string{"name"},
Description: "删除一个上游源"},
{Name: "delete_key", Title: "删除网关密钥", Danger: DangerDestructive, TimeoutMS: 10000,
Params: []string{"key"},
Description: "删除一个网关 key(正在使用的客户端会立刻 401)"},
{Name: "update_key", Title: "修改网关密钥", Danger: DangerDestructive, TimeoutMS: 10000,
Params: []string{"key", "name", "role", "models"},
Description: "改动 key 的角色/可见模型(可能扩大访问面)"},
{Name: "remove_adapter", Title: "删除适配器", Danger: DangerDestructive, TimeoutMS: 30000,
Params: []string{"name"},
Description: "删除适配器(仅限插件自己装的;内置与非自有的会被拒)"},
{Name: "set_plugin_enabled", Title: "启停插件", Danger: DangerDestructive, TimeoutMS: 5000,
Params: []string{"name", "enabled"}, Deferred: true,
Description: "启用或停用某个插件(异步执行:在本次调用返回后生效)"},
}
// kernelActionByName indexes the catalog.
var kernelActionByName = func() map[string]KernelAction {
m := make(map[string]KernelAction, len(kernelCatalog))
for _, a := range kernelCatalog {
m[a.Name] = a
}
return m
}()
// KernelActions returns the catalog sorted by name (stable for the UI).
func KernelActions() []KernelAction {
out := append([]KernelAction(nil), kernelCatalog...)
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// LookupKernelAction finds an action, reporting whether it exists.
func LookupKernelAction(name string) (KernelAction, bool) {
a, ok := kernelActionByName[name]
return a, ok
}
// ---- 权限 ----
// errNotPermitted is returned when a plugin calls an action it was not granted.
// It names the action and the plugin so the operator can act on it directly.
func notPermitted(plugin, action string) error {
return fmt.Errorf("kernel action %q is not granted to plugin %q; "+
"enable it in the plugin's kernel permissions", action, plugin)
}
// KernelPermission is the operator's grant for one plugin: the set of action
// names it may call, plus whether it was granted "everything readable".
type KernelPermission struct {
// Actions is the explicit allowlist, already expanded (no wildcards stored,
// so a NEW action added in a future release is NOT silently granted to an
// existing plugin — that is the safe direction for a security default).
Actions []string
// AllRead is true when the operator granted every read-only action at once.
// Stored separately from Actions so that a future read-only action IS covered
// without re-saving, while writes still require an explicit grant.
AllRead bool
}
// clone returns a copy so callers cannot mutate the stored permission.
func (p KernelPermission) clone() KernelPermission {
return KernelPermission{Actions: append([]string(nil), p.Actions...), AllRead: p.AllRead}
}
// allows reports whether the permission covers action.
//
// The "all readable" grant deliberately does NOT cover a future WRITE action:
// expanding a wildcard into whatever happens to exist later is how a convenience
// grant turns into a privilege escalation.
func (p KernelPermission) allows(a KernelAction) bool {
if p.AllRead && a.Danger == DangerRead {
return true
}
for _, n := range p.Actions {
if n == a.Name {
return true
}
}
return false
}
// ---- 调用(带 Go 侧超时) ----
// ErrKernelUnavailable is returned when no KernelAPI was injected (a gateway
// running plugins without a wired core, e.g. some tests).
var ErrKernelUnavailable = errors.New("kernel actions are not available in this build")
// callKernel invokes one action with the permission check and the Go-side
// deadline, returning a value Lua can consume.
//
// The deadline is the whole point: a plugin can block here harmlessly, because
// Lua is waiting on a return value rather than spinning. On timeout the caller
// gets a normal error; the action's own goroutine is told to stop via ctx.
func (ps *Plugins) callKernel(plugin, name string, args map[string]interface{}) (interface{}, error) {
api := ps.kernel()
if api == nil {
return nil, ErrKernelUnavailable
}
action, ok := LookupKernelAction(name)
if !ok {
return nil, fmt.Errorf("unknown kernel action %q", name)
}
if !ps.kernelPermitted(plugin, action) {
return nil, notPermitted(plugin, name)
}
timeout := time.Duration(action.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
// The caller's identity is injected HERE, not taken from args: an action that
// needs to know who is calling (remove_adapter, to enforce ownership) must not
// be able to be lied to by the plugin passing someone else's name.
if args == nil {
args = map[string]interface{}{}
}
args["plugin"] = plugin
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
type result struct {
v interface{}
err error
}
// A deferred action must not run under the caller's plugin lock, so it is
// queued and returns "accepted" immediately. Reporting success here without
// waiting is honest: the caller is told the action was ACCEPTED, and the
// actual outcome lands in the plugin's hook_errors surface if it fails.
if action.Deferred {
ps.runDeferred(action, args)
return map[string]interface{}{
"accepted": true,
"note": "this action runs asynchronously; check the plugin state/errors for the outcome",
}, nil
}
// The action runs on its own goroutine so this call can give up on time.
// Buffered so a late-finishing action never blocks on sending.
done := make(chan result, 1)
go func() {
defer func() {
// A panicking kernel action must not take the gateway down through a
// Lua call. Convert it into an error the plugin sees.
if rec := recover(); rec != nil {
done <- result{err: fmt.Errorf("kernel action %s panicked: %v", name, rec)}
}
}()
v, err := api.Invoke(ctx, name, args)
done <- result{v: v, err: err}
}()
select {
case r := <-done:
return r.v, r.err
case <-ctx.Done():
return nil, fmt.Errorf("kernel action %q exceeded its %s budget", name, timeout)
}
}
// runDeferred executes a lock-re-entrant action outside the Lua call.
//
// It runs on a fresh goroutine precisely so it can take the plugin lock that the
// caller still holds; waiting for the caller to unwind first would need a
// handshake that is more machinery than the one action needs. A failure is
// surfaced through hook_errors, which is where an operator already looks for
// "a plugin's background work stopped working".
func (ps *Plugins) runDeferred(a KernelAction, args map[string]interface{}) {
api := ps.kernel()
if api == nil {
return
}
timeout := time.Duration(a.TimeoutMS) * time.Millisecond
if timeout <= 0 {
timeout = 10 * time.Second
}
// Copy the args: the caller's map is still owned by the Lua call frame.
cp := make(map[string]interface{}, len(args))
for k, v := range args {
cp[k] = v
}
go func() {
defer func() {
if rec := recover(); rec != nil {
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s panicked: %v", a.Name, rec))
}
}()
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
if _, err := api.Invoke(ctx, a.Name, cp); err != nil {
ps.hookErr.note(StageRequestEnd, fmt.Sprintf("deferred kernel action %s failed: %v", a.Name, err))
}
}()
}
// kernelPermitted applies the stored grant, with one deliberate exception: a
// plugin's OWN adapter may always be removed by that plugin (it is not a kernel
// resource, it is one the plugin created). Everything else needs a grant.
func (ps *Plugins) kernelPermitted(plugin string, a KernelAction) bool {
if a.Name == "remove_adapter" {
return true
}
ps.mu.RLock()
perm := ps.kernelPerms[plugin]
ps.mu.RUnlock()
return perm.allows(a)
}
// SetPluginKernelPermission replaces one plugin's grant and persists it.
//
// Validation happens here rather than at call time so a typo'd action name is
// reported to the operator immediately instead of silently granting nothing.
func (ps *Plugins) SetPluginKernelPermission(plugin string, actions []string, allRead bool) error {
if ps.find(plugin) == nil {
return fmt.Errorf("plugin %s not found", plugin)
}
clean := make([]string, 0, len(actions))
seen := map[string]bool{}
for _, raw := range actions {
name := strings.TrimSpace(raw)
if name == "" || seen[name] {
continue
}
a, ok := LookupKernelAction(name)
if !ok {
return fmt.Errorf("unknown kernel action %q", name)
}
seen[name] = true
clean = append(clean, a.Name)
}
sort.Strings(clean)
ps.mu.Lock()
if ps.kernelPerms == nil {
ps.kernelPerms = kernelPermissionSet{}
}
if len(clean) == 0 && !allRead {
delete(ps.kernelPerms, plugin)
} else {
ps.kernelPerms[plugin] = KernelPermission{Actions: clean, AllRead: allRead}
}
ps.saveKernelPermissionsLocked()
ps.mu.Unlock()
return nil
}
// PluginKernelPermission returns a copy of one plugin's grant.
func (ps *Plugins) PluginKernelPermission(plugin string) KernelPermission {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.kernelPerms[plugin].clone()
}
// KernelPermissionView is the UI-facing form: what the plugin ASKED for (from its
// own source) next to what the operator GRANTED. The distinction matters — a
// plugin declaring `plugin.kernel = {...}` is a request, not a privilege.
type KernelPermissionView struct {
Plugin string `json:"plugin"`
Declared []string `json:"declared,omitempty"`
Granted []string `json:"granted,omitempty"`
AllRead bool `json:"all_read"`
}
// KernelPermissions lists every plugin's declared-vs-granted state for the UI.
func (ps *Plugins) KernelPermissions() []KernelPermissionView {
ps.mu.RLock()
defer ps.mu.RUnlock()
out := make([]KernelPermissionView, 0, len(ps.plugins))
for _, p := range ps.plugins {
perm := ps.kernelPerms[p.Info.Name]
out = append(out, KernelPermissionView{
Plugin: p.Info.Name,
Declared: append([]string(nil), p.KernelDeclared...),
Granted: append([]string(nil), perm.Actions...),
AllRead: perm.AllRead,
})
}
sort.Slice(out, func(i, j int) bool { return out[i].Plugin < out[j].Plugin })
return out
}
// kernel returns the injected API, or nil.
func (ps *Plugins) kernel() KernelAPI {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.kernelAPI
}
// SetKernelAPI injects the kernel action implementation. Called during wiring,
// mirroring SetOutboundHosts.
func (ps *Plugins) SetKernelAPI(api KernelAPI) {
ps.mu.Lock()
defer ps.mu.Unlock()
ps.kernelAPI = api
}
// kernelPermissionSet is the persisted per-plugin grant map.
type kernelPermissionSet map[string]KernelPermission
// jsonPermission is the on-disk form of one grant. Kept separate from
// KernelPermission so the persisted shape cannot drift with a struct refactor
// (the access file is read by older and newer binaries across an upgrade).
type jsonPermission struct {
Actions []string `json:"actions,omitempty"`
AllRead bool `json:"all_read,omitempty"`
}
func (p KernelPermission) marshal() jsonPermission {
return jsonPermission{Actions: p.Actions, AllRead: p.AllRead}
}
func (j jsonPermission) unmarshal() KernelPermission {
return KernelPermission{Actions: j.Actions, AllRead: j.AllRead}
}

View File

@ -0,0 +1,91 @@
package lua
import (
"encoding/json"
"os"
)
// Kernel action grants are OPERATOR policy, persisted separately from both
// plugin state and the endpoint/visibility overrides.
//
// Why a separate file rather than reusing .access.json:
//
// - .access.json is a flat map[string]string ("name" or "name#path" -> spec).
// Kernel grants are a list plus a boolean, so folding them in would either
// change the shape of a file an older binary still reads, or force an
// encoding that cannot round-trip. An upgrade must not corrupt a running
// deployment's restrictions.
// - The two answer different questions — "who may call this endpoint" vs
// "what may this plugin do to the kernel" — and an operator looking at one
// should not have to parse the other.
//
// Shape: { "<plugin>": { "actions": ["reload", ...], "all_read": true } }
const kernelPermsFileName = ".kernel-perms.json"
func (ps *Plugins) kernelPermsFile() string {
if ps.dir == "" {
return ""
}
return ps.dir + string(os.PathSeparator) + kernelPermsFileName
}
// LoadKernelPermissions reads the persisted grants. A missing file is the normal
// fresh-install state (nothing granted); a corrupt file is logged and ignored
// rather than fatal — losing a grant is recoverable, refusing to boot is not.
func (ps *Plugins) LoadKernelPermissions() error {
path := ps.kernelPermsFile()
if path == "" {
return nil
}
b, err := os.ReadFile(path)
if os.IsNotExist(err) {
return nil
}
if err != nil {
return err
}
raw := map[string]jsonPermission{}
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.kernelPerms == nil {
ps.kernelPerms = kernelPermissionSet{}
}
for name, jp := range raw {
ps.kernelPerms[name] = jp.unmarshal()
}
return nil
}
// saveKernelPermissionsLocked flushes the grant map. Caller holds ps.mu.
//
// Grants drop unknown action names on write. If an operator authorized an action
// that a later release removed, keeping the name would leave a stale entry that
// nothing can ever satisfy — and it would reappear in the UI as if it were live.
func (ps *Plugins) saveKernelPermissionsLocked() {
path := ps.kernelPermsFile()
if path == "" {
return
}
out := map[string]jsonPermission{}
for name, perm := range ps.kernelPerms {
valid := make([]string, 0, len(perm.Actions))
for _, a := range perm.Actions {
if _, ok := LookupKernelAction(a); ok {
valid = append(valid, a)
}
}
if len(valid) == 0 && !perm.AllRead {
continue // nothing granted: presence would be a lie
}
out[name] = KernelPermission{Actions: valid, AllRead: perm.AllRead}.marshal()
}
if len(out) == 0 {
_ = os.Remove(path)
return
}
writeJSONAtomic(path, out)
}

View File

@ -0,0 +1,245 @@
package lua
import (
"sort"
"strings"
golua "github.com/aarzilli/golua/lua"
)
// Package lua — Lua 绑定:凭据保险库 + 内核动作面
//
// 这两个全局把"插件自己主导链接认证"所需的两块补齐:
//
// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹
// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…)
//
// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装,
// 因为适配器共用 setupGlobals 而不该拿到这些能力。
//
// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField),
// 该写法在 json 表上长期稳定,也是内核自己的用法。
// secretGlobal 是凭据保险库的 Lua 名字。
const secretGlobal = "__llmsproxy_secrets"
// kernelGlobal 是内核动作面的 Lua 名字。
const kernelGlobal = "__llmsproxy_kernel"
// registerSecretBridge installs the credential vault onto a plugin's state.
//
// The plugin name is bound HERE, at boot, rather than read from the Lua state at
// call time. That is what makes "a plugin can only read its OWN credentials" a
// mechanism instead of a convention: a shard or any other holder of the compiled
// script cannot pass a different name.
func registerSecretBridge(L *golua.State, ps *Plugins, name string) {
L.NewTable()
L.PushGoClosure(luaSecretSet(ps, name))
L.SetField(-2, "set")
L.PushGoClosure(luaSecretGet(ps, name))
L.SetField(-2, "get")
L.PushGoClosure(luaSecretDelete(ps, name))
L.SetField(-2, "delete")
L.PushGoClosure(luaSecretKeys(ps, name))
L.SetField(-2, "keys")
L.SetGlobal(secretGlobal)
}
// luaSecretSet: set(key, value) -> (true, nil) | (nil, err)
func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
key := L.ToString(1)
val := L.ToString(2)
if err := ps.SetSecret(name, key, val); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields
// (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no
// credential stored yet" case is the common one for a fresh install, and making
// it an error would force every caller to string-match on a message.
func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
key := L.ToString(1)
v, ok, err := ps.GetSecret(name, key)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
if !ok {
L.PushNil()
L.PushNil()
return 2
}
L.PushString(v)
L.PushNil()
return 2
}
}
// luaSecretDelete: delete(key) -> (true, nil) | (nil, err)
func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
key := L.ToString(1)
if err := ps.DeleteSecret(name, key); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can
// render "which accounts are configured" without ever echoing token bytes.
func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
keys, err := ps.SecretKeys(name)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.NewTable()
for i, k := range keys {
L.PushString(k)
L.RawSeti(-2, i+1)
}
L.PushNil()
return 2
}
}
// registerKernelBridge installs the kernel action surface onto a plugin's state.
func registerKernelBridge(L *golua.State, ps *Plugins, name string) {
L.NewTable()
L.PushGoClosure(luaKernelActions(ps, name))
L.SetField(-2, "actions")
L.PushGoClosure(luaKernelCall(ps, name))
L.SetField(-2, "call")
L.SetGlobal(kernelGlobal)
}
// luaKernelActions: actions() -> (array of {name,title,danger,description,params,
// granted}, nil)
//
// It reports GRANTED alongside each action so a plugin can degrade gracefully
// (skip the part that needs a permission it does not have) instead of
// discovering it by hitting an error mid-operation.
func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
perm := ps.PluginKernelPermission(name)
L.NewTable()
for i, a := range KernelActions() {
L.NewTable()
L.PushString(a.Name)
L.SetField(-2, "name")
L.PushString(a.Title)
L.SetField(-2, "title")
L.PushString(a.Danger)
L.SetField(-2, "danger")
L.PushString(a.Description)
L.SetField(-2, "description")
L.PushBoolean(perm.allows(a))
L.SetField(-2, "granted")
L.NewTable()
for j, p := range a.Params {
L.PushString(p)
L.RawSeti(-2, j+1)
}
L.SetField(-2, "params")
L.RawSeti(-2, i+1)
}
L.PushNil()
return 2
}
}
// luaKernelCall: call(name, args) -> (result, nil) | (nil, err)
//
// One entry point rather than a function per action: the action set will change,
// and a table field per action would mean editing bootPluginState — the exact
// place that produced a cgo crash during earlier work.
func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
action := L.ToString(1)
args := map[string]interface{}{}
if L.Type(2) == golua.LUA_TTABLE {
var raw interface{}
if err := luaToJSON(L, 2, &raw); err == nil {
if m, ok := raw.(map[string]interface{}); ok {
args = m
}
}
}
v, err := ps.callKernel(name, action, args)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
if v == nil {
L.PushNil()
} else {
pushGoValue(L, v)
}
L.PushNil()
return 2
}
}
// readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for.
//
// The shape is a list of names:
//
// plugin.kernel = { "status", "reset_health" }
//
// Unknown names are kept rather than dropped so the settings page can show a
// typo as "requested but not a real action" — silently discarding it would make
// a plugin's intent invisible at exactly the moment the operator is deciding
// what to grant.
func readPluginKernelDeclared(L *golua.State) []string {
raw, ok := pluginFieldValue(L, "kernel")
if !ok {
return nil
}
items, isList := raw.([]interface{})
if !isList {
if one, isTable := raw.(map[string]interface{}); isTable {
// A single string field or a set-style table; accept both spellings.
if v, ok := one["actions"].([]interface{}); ok {
items = v
} else {
return nil
}
} else {
return nil
}
}
out := make([]string, 0, len(items))
seen := map[string]bool{}
for _, it := range items {
name, ok := it.(string)
if !ok {
continue
}
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
sort.Strings(out)
return out
}

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

View File

@ -244,6 +244,11 @@ type Plugin struct {
// was supposed to inherit from.
persisted bool
// KernelDeclared lists the kernel actions this plugin asks for in its own
// source (`plugin.kernel = {...}`). It is a REQUEST, shown to the operator
// beside what was actually granted; it grants nothing by itself.
KernelDeclared []string
// Schedule is the periodic work this plugin declared, and Runtime is the
// scheduler that runs it on THIS plugin's own VM (p.state) under p.mu — the
// same state and the same lock a request hook uses. That is what makes a
@ -293,6 +298,15 @@ type Plugins struct {
// nil means deny-all: an unconfigured gateway must not silently grant a
// plugin unrestricted network access.
allowHTTP *httpAllowlist
// kernelAPI is the injected kernel action surface (nil when unwired).
// See kernel_api.go: a plugin calling __llmsproxy_kernel reaches the core
// through this interface, never through a direct import (core depends on
// lua, not the other way round).
kernelAPI KernelAPI
// kernelPerms records what the OPERATOR granted each plugin. Separate from
// what a plugin DECLARES it wants (Plugin.KernelDeclared): a declaration is
// a request, a grant is a privilege. Persisted in .access.json.
kernelPerms kernelPermissionSet
// save coalesces state writes into one background goroutine.
//
// A hook must NOT write synchronously: the billing plugin mutates state on
@ -543,6 +557,12 @@ func (ps *Plugins) LoadDir() error {
if err := ps.LoadAccessOverrides(); err != nil {
log.Printf("[core] plugin access overrides not loaded: %v", err)
}
// Kernel grants load beside them and for the same reasons: an in-memory map
// would make "this plugin may reload config" look enforced right up to the
// next restart, at which point the grant silently vanished.
if err := ps.LoadKernelPermissions(); err != nil {
log.Printf("[core] plugin kernel permissions not loaded: %v", err)
}
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
return nil
}
@ -609,6 +629,12 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
// as __llmsproxy_http: adapters share setupGlobals and must not get it.
if ps != nil {
registerAdapterBridge(L, ps, name)
// R4-d: the credential vault and the kernel action surface. Both are
// plugin-only, for the same reason as the bridges above — an adapter
// shares setupGlobals and must not be able to read credentials or drive
// the kernel.
registerSecretBridge(L, ps, name)
registerKernelBridge(L, ps, name)
}
if err := L.DoString(code); err != nil {
L.Close()
@ -743,6 +769,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
p.UI = ui
}
// ---- declared kernel requests ----
// A declaration is a REQUEST, not a grant (see kernel_api.go). Reading it at
// load time lets the settings page show "this plugin wants add_source"
// beside what was actually granted.
p.KernelDeclared = readPluginKernelDeclared(w.L)
w.L.SetTop(0)
// ---- http endpoints ----
p.http = readPluginHTTP(w.L)