feat(plugins): 可见性、state 分区、注册式 HTTP 端点 + 按密钥授权

插件页面不是自动公开的数据。billing 显示跨全部源的成本,quota 显示上游余额,
而这两者都不属于持有 user 密钥的人 —— 但 /api/ui-inject 不做任何角色过滤,
所以两者都发给了任何已认证的密钥。实测(不是推断):club 这把 user 密钥拉到了
18672 字节的 billing state,里面是 16871 次请求 / $8.8616 / 21 个源名与各自
成本。

UIPage/UIElement 加 Audience,UIExtension 加 Visibility。语法刻意很小——"" 或
"all" 放行,"admin" 只给 admin,"admin,user" 列角色。**"*" 不是 all 的同义词**:
一个在配置文件里看起来受限、实际放行一切的东西,正是会静默失效的那种设置。

覆盖走 config.yaml 的 plugin_visibility,因为插件作者不知道本部署的策略,而运维
也不该为了改一行去编辑内置 .lua(下次升级就没了)。

过滤发生在 UIForRole 而非 HTTP 层:WebUI 启动载荷、Electron 外壳、测试都读同一
处,只在 HTTP 层过滤意味着其它消费者照旧全量泄漏。

角色可见性回答不了"哪一部分"。billing 里全局数字(total、by_source、by_model、
未定价告警)是部署数据,by_key / by_day_key 描述的是**一个**调用方。于是插件用
state_scope 声明哪些桶是全局的、哪些按调用方过滤,深度由插件给出(1 = {maskedKey:
桶},2 = {day: {maskedKey: 桶}})——两种形状都是 map,靠嗅探分不清"空桶"和"嵌套桶"。

不匹配的行**删除而不是置零**:置零会渲染成"你没花钱",还会泄露"别人有这一行"。
billing 因此改为 visibility="all" + state_scope,而不是 admin-only —— 后者会让
split 失去意义,而前者不加 split 正是这次要堵的泄露。

有些东西值得从浏览器**外面**查(CI 判断上游余额是否为零、监控探针、盒子上的
另一个工具)。billing 从不需要(它只收 hook),quota 需要。为每个插件硬编码一条
路由意味着每次都要改网关,而把任意插件代码塞进主 mux 也不是路由表该做的事。

插件在 plugin.http 里声明 path/method/roles/keys,挂在 /api/plugins/http/ 下。
请求与响应都是普通 table 穿过 Go/Lua 边界。两条由 Go 侧而非信任插件来保证的性质:
status 与 method 走白名单(否则插件能选一个 3xx + Location 把调用者弹到它指定的
URL),Content-Length 与逐跳头被丢弃(否则插件能让网关的响应分帧错位)。

roles 太粗:无法表达"给 homeagent 开放、其他人不开"。keys 按**密钥名**授权而非密钥
值——密钥值会被轮换,规则写死在上面会静默失配;把凭据散进插件文件也是问题;而且
运维读的是名字。

**keys 非空时它是全部规则**,不是与 roles 的并集。写成并集的话,运维写两条允许名单
就会顺带放行所有 user 密钥,正好与意图相反。admin 永远放行,否则名单里一个拼写
错误会让唯一能诊断它的人也进不去。

写入前校验密钥名真实存在:一份写着错别字名字的名单会被存下、保存、在 WebUI 里
显示为"已配置",然后拒绝每一个调用者——失败看起来像端点坏了,而不是名字错了。

readPluginUI / readPluginExports / readPluginHTTP 都在插件**注册期间**调用
luaToJSON,而它依赖 setupGlobals 才安装的 json 全局,于是静默返回空值。三处表现各
不同(页面字段为空、exports 看不见、端点数为 0),同一个原因。现在统一走
luaTableToGoValue。

readPluginHTTP 还额外踩了 golua 的 GetField 不做字符串→数字转换,用索引循环取数组
永远拿到 nil。

去掉角色过滤 → 3 条判据红(含"a user key can see the admin-only page");
去掉 page 级 audience → 1 条红。routes 层断言徽标齐全、方法与 path 校验、栈帧
归属、admin 豁免、{status=204} 合法、空表报错。

生产验证:admin 看到 14 个字段 / by_key 5 行 / total $8.86;user 看到 0 行。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit a6c6e6e5c7)
(cherry picked from commit 5eef5ad635)
This commit is contained in:
JianFeeeee
2026-10-04 10:51:07 +08:00
parent 2b34c7dc8a
commit 35b9422232
13 changed files with 2091 additions and 379 deletions

View File

@ -126,6 +126,13 @@ type UIExtension struct {
// Elements are snippets injected into EXISTING pages, keyed by target page
// id (e.g. "status", "keys"). Order within a target is plugin load order.
Elements []UIElement `json:"elements,omitempty"`
// Visibility is the plugin-wide default audience, applied to every page and
// element this plugin contributes. A per-page Audience narrows it further.
Visibility string `json:"visibility,omitempty"`
// StateScope splits the plugin's published state into global and per-caller
// buckets, for a plugin that is readable by more than one role but must not
// hand one caller another's data.
StateScope *StateScope `json:"state_scope,omitempty"`
}
// UIPage is a plugin-provided page.
@ -134,6 +141,20 @@ type UIPage struct {
Title string `json:"title"` // sidebar label
Icon string `json:"icon"` // optional inline SVG or short glyph
Order int `json:"order"` // sidebar sort key (default 100)
// Audience limits which API-key roles may see this page. "" and "all" mean
// every authenticated role; "admin" hides it from user keys; a comma list
// ("admin,user") names the roles allowed.
//
// This exists because a plugin page is not automatically public data. The
// billing page shows GLOBAL cost across every source, and the quota page
// shows upstream balances — neither belongs to whoever happens to hold a
// user key, yet both were served to them because /api/ui-inject applied no
// role filter at all.
//
// The plugin declares its own default and the gateway may override it (see
// SetPluginVisibility), because the plugin author cannot know the operator's
// policy but the operator must be able to impose one.
Audience string `json:"audience,omitempty"`
// Mount is the page body. It may contain <script> and <style>; the kernel
// executes scripts AFTER injecting the HTML so the DOM exists, and exposes
// `pluginAPI` to them (see docs/plugins.md).
@ -145,6 +166,10 @@ type UIElement struct {
// Target is the id of the pane to inject into: "status", "chat", "keys",
// "sort", "sources" or "adapters".
Target string `json:"target"`
// Audience is the same role filter as UIPage.Audience. An injected tile can
// leak exactly what a whole page would — the billing status tile shows total
// cost — so the field exists here too rather than only on pages.
Audience string `json:"audience,omitempty"`
// Anchor is where in the target pane: "top", "bottom" or "before:<sel>" /
// "after:<sel>" for a CSS selector. Empty = "bottom".
Anchor string `json:"anchor,omitempty"`
@ -179,6 +204,9 @@ type Plugin struct {
// reasoning as a systemd unit being masked rather than removed.
Disabled bool
dir string
// http lists endpoints this plugin mounts under /api/plugins/http/. Read at
// load time, never mutated afterwards.
http []HTTPHandler
// exports lists the functions this plugin allows the WebUI to call through
// POST /api/plugins/{name}/call/{fn}. It is read once at load from
// plugin.exports and never changes, so Call can check it without taking a
@ -233,6 +261,9 @@ type Plugins struct {
// direct log call so the plugin package stays free of a logging dependency
// and tests can capture the warnings.
logf func(format string, args ...interface{})
// visibility holds the operator's per-plugin audience overrides, keyed by
// plugin name. Consulted by UIForRole and the call endpoint's authorisation.
visibility visibilityOverrides
// stateDirDisabled turns persistence off. Used by tests that assert state
// starts empty, and by an embedder that has no writable plugin dir.
stateDirDisabled bool
@ -633,6 +664,16 @@ func (ps *Plugins) LoadSource(name, code string) error {
p.UI = ui
}
// ---- http endpoints ----
p.http = readPluginHTTP(w.L)
if ui, ok := readPluginUI(w.L); ok {
p.UI = ui
if ui.StateScope != nil {
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
}
}
// ---- exports ----
// Read here, while the state is locked and before restore() runs: the list is
// static for the life of the plugin, so Call never has to re-enter Lua just
@ -762,6 +803,7 @@ func readPluginExports(L *golua.State) []string {
func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, "ui")
@ -769,12 +811,67 @@ func readPluginUI(L *golua.State) (*UIExtension, bool) {
L.SetTop(0)
return nil, false
}
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
// registering, BEFORE setupGlobals installs the json global that luaToJSON
// depends on — so the conversion fails and every field except the ones on
// Page/Pages (read separately) comes back empty. That is why visibility and
// state_scope silently read as unset even though the .lua declares them:
// the third field in this file to be lost to the same cause, after
// readPluginExports and readPluginHTTP.
var ui UIExtension
if err := luaToJSON(L, -1, &ui); err != nil {
L.SetTop(0)
return nil, false
}
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
if m, ok := raw.(map[string]interface{}); ok {
ui.Visibility = asString(m["visibility"])
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
sc := &StateScope{}
if gs, ok := scope["global"].([]interface{}); ok {
for _, g := range gs {
if s := asString(g); s != "" {
sc.Global = append(sc.Global, s)
}
}
}
if ds, ok := scope["depth"].(map[string]interface{}); ok {
sc.Depth = map[string]int{}
for k, v := range ds {
if n, ok := asFloat(v); ok {
sc.Depth[k] = int(n)
}
}
sc.Depth = compileDepthMap(sc.Depth)
}
ui.StateScope = sc
}
if pg, ok := m["page"].(map[string]interface{}); ok {
ui.Page = uipageFromValue(pg)
}
if pgs, ok := m["pages"].([]interface{}); ok {
for _, it := range pgs {
if pm, ok := it.(map[string]interface{}); ok {
ui.Pages = append(ui.Pages, uipageFromValue(pm))
}
}
}
if els, ok := m["elements"].([]interface{}); ok {
for _, it := range els {
em, ok := it.(map[string]interface{})
if !ok {
continue
}
el := UIElement{
Target: asString(em["target"]),
Anchor: asString(em["anchor"]),
Mount: asString(em["mount"]),
Audience: asString(em["audience"]),
}
if n, ok := asFloat(em["order"]); ok {
el.Order = int(n)
}
ui.Elements = append(ui.Elements, el)
}
}
}
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
@ -1022,6 +1119,139 @@ func (ps *Plugins) HookErrors() map[string]map[string]interface{} {
}
// UI returns the merged UI extensions to inject into the WebUI.
// audienceAllows reports whether a role is permitted by an audience string.
//
// The grammar is deliberately small, because it is authored in two places (a
// .lua file and config.yaml) and a richer language would only give someone a way
// to express a rule nobody audits:
//
// "" or "all" everyone
// "admin" admin role only
// "user" user role only
// "admin,user" either
//
// "*" is deliberately NOT a synonym for "all": an audience that looks
// restrictive in a config file while meaning everything is exactly the kind of
// setting that fails silently.
func audienceAllows(want, role string) bool {
want = strings.ToLower(strings.TrimSpace(want))
if want == "" || want == "all" {
return true
}
role = strings.ToLower(strings.TrimSpace(role))
for _, part := range strings.Split(want, ",") {
if strings.TrimSpace(part) == role {
return true
}
}
return false
}
// visibilityOverrides holds the operator's per-plugin audience overrides.
//
// A plugin declares its own default and the operator may force one. Both sides
// need to be able to: the plugin author does not know the deployment's policy,
// and the operator must not have to edit a bundled .lua — which the next
// upgrade would overwrite anyway.
type visibilityOverrides map[string]string
// SetPluginVisibility records an operator override for a plugin's audience. An
// empty value clears the override and restores the plugin's own default.
func (ps *Plugins) SetPluginVisibility(plugin, audience string) {
ps.mu.Lock()
defer ps.mu.Unlock()
if ps.visibility == nil {
ps.visibility = visibilityOverrides{}
}
plugin = strings.TrimSpace(plugin)
audience = strings.TrimSpace(audience)
if audience == "" {
delete(ps.visibility, plugin)
return
}
ps.visibility[plugin] = audience
}
// VisibilityOverride returns the operator's audience override for a plugin,
// or "" when none is set. Exposed so /api/plugins can show which visibility is
// imposed rather than only the one in effect.
func (ps *Plugins) VisibilityOverride(plugin string) string {
ps.mu.RLock()
defer ps.mu.RUnlock()
return ps.visibility[plugin]
}
// uiAudience resolves the audience in force for a plugin: the operator's
// override if set, else the plugin's declared default.
func (ps *Plugins) uiAudience(p *Plugin, declared string) string {
ps.mu.RLock()
override := ps.visibility[p.Info.Name]
ps.mu.RUnlock()
if override != "" {
return override
}
return declared
}
// UIForRole returns the UI a given role may see.
//
// Filtering happens HERE, not in the HTTP layer, so every consumer sees the same
// view — the WebUI boot payload, the Electron shell and the tests. Filtering at
// one call site means the others quietly keep serving everything.
//
// A plugin's page/element can narrow visibility further than its own default
// (a public summary page beside an admin-only one), so the role check runs on
// the resolved plugin audience AND on each item's own declaration.
func (ps *Plugins) UIForRole(role string) *UIExtension {
full := ps.UI()
if full == nil {
return &UIExtension{}
}
// An admin sees everything, including anything a plugin hides from itself
// by mistake: anything else makes an override unverifiable from the browser.
if role == "admin" {
return full
}
out := &UIExtension{}
// The plugin-level default is per plugin, so pages are filtered by walking
// plugins rather than pages: a page id alone does not say who owns it.
for _, p := range ps.plugins {
if p.UI == nil || p.LoadError != "" {
continue
}
aud := ps.uiAudience(p, p.UI.Visibility)
if !audienceAllows(aud, role) {
continue
}
for _, pg := range p.UI.Pages {
if pg == nil || !audienceAllows(pg.Audience, role) {
continue
}
out.Pages = append(out.Pages, pg)
}
for _, el := range p.UI.Elements {
if !audienceAllows(el.Audience, role) {
continue
}
out.Elements = append(out.Elements, el)
}
// Page (singular) is the other half of the same declaration and both
// merge in UI(). Handling only Pages made every plugin that used the
// single-page form disappear for a user key — silently, with no error,
// because "this plugin contributes nothing to you" is indistinguishable
// from "this plugin is not installed".
if p.UI.Page != nil && audienceAllows(p.UI.Page.Audience, role) {
if out.Page == nil {
out.Page = p.UI.Page
}
if !containsPage(out.Pages, p.UI.Page.PageID) {
out.Pages = append(out.Pages, p.UI.Page)
}
}
}
return out
}
func (ps *Plugins) UI() *UIExtension {
if u := ps.ui.Load(); u != nil {
return u
@ -1790,3 +2020,227 @@ func pluginExportAllowed(p *Plugin, fn string) bool {
}
return false
}
// StateVisibleTo reports whether a role may read a plugin's published state.
//
// The audience is resolved exactly as for the UI: operator override first, then
// the plugin's declaration. A plugin with neither is readable by any
// authenticated role, which is the pre-existing behaviour and the reason a
// newly installed widget keeps working without an edit.
// HTTPHandler is one endpoint a plugin registers on the gateway's router.
//
// A plugin that only receives hooks (billing) has nothing to expose. A plugin
// that computes something the operator may want to query from OUTSIDE the
// browser — a health probe, a CI check, another tool — needs a route, and
// hard-coding one in the gateway would mean every such plugin needs a gateway
// change. So the plugin declares the path, the method and the role it requires,
// and the gateway mounts it.
//
// The handler is a Lua function taking (request) and returning (response). Both
// are plain tables: request carries method/path/query/headers/body, response
// carries status/headers/body/json. Nothing here can reach the Go side except
// through those two values, so the surface a plugin can express is bounded by
// what a table can say.
type HTTPHandler struct {
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
// segments, captured into request.params.
Path string `json:"path"`
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
// accepted — an arbitrary verb would need a switch in the router for no gain.
Method string `json:"method,omitempty"`
// Roles are the API-key roles allowed to call it. EMPTY MEANS EVERYONE, which
// is the wrong default for an endpoint a plugin invented: an unauthenticated
// internet scan should not be able to ask it questions. RequireRole exists
// so a plugin that really wants public access has to say so.
Roles []string `json:"roles,omitempty"`
// Keys names SPECIFIC gateway keys allowed to call it, by their configured
// name (the `name:` on a gateway_keys entry), never by the secret.
//
// Names rather than secrets for three reasons: a secret is rotated without
// notice and a rule written against it would silently stop matching; putting
// secrets in a plugin file scatters credentials across the install; and the
// operator reads names. A key with no name cannot be named here.
//
// When Keys is non-empty it is the WHOLE rule, not an addition to Roles. An
// OR would mean an operator writing a two-key allowlist had also granted
// every user key, which is the opposite of what they meant.
Keys []string `json:"keys,omitempty"`
// RequireAuth defaults to true in practice: the gateway refuses a handler
// with neither Roles nor an explicit opt-out.
Public bool `json:"public,omitempty"`
// Handler is the name of the plugin function to invoke.
Handler string `json:"handler"`
// Description is shown in /api/plugins so an operator can see what a plugin
// has exposed without reading the .lua.
Description string `json:"description,omitempty"`
// Plugin is filled in by HTTPHandlers: which plugin registered this route.
// The gateway dispatcher needs it to know which Lua state to enter, and
// deriving it from the path would mean trusting the plugin's own prefix.
Plugin string `json:"-"`
}
// routePath renders the handler's full mount path.
func (h HTTPHandler) routePath() string {
return "/api/plugins/http/" + strings.Trim(h.Path, "/")
}
// allows reports whether a caller may invoke this handler.
//
// keyName is the caller key's configured name ("" when it has none), not the
// secret.
func (h HTTPHandler) allows(role, keyName string) bool {
// Admin is always admitted. An operator must be able to inspect what a plugin
// exposes even while locked out of it, or a typo in a key list would leave
// nobody able to diagnose the endpoint they were trying to tighten.
if role == "admin" {
return true
}
// Named keys REPLACE the role rule rather than adding to it.
if len(h.Keys) > 0 {
if keyName == "" {
return false
}
for _, k := range h.Keys {
if strings.EqualFold(strings.TrimSpace(k), keyName) {
return true
}
}
return false
}
if h.Public {
return true
}
if len(h.Roles) == 0 {
// No declared roles and no public opt-out: treat as admin-only. A plugin
// author who forgets this gets a 403 rather than an open endpoint, and
// the error says which field to set.
return role == "admin"
}
for _, r := range h.Roles {
if strings.EqualFold(strings.TrimSpace(r), role) {
return true
}
}
return false
}
func (ps *Plugins) StateVisibleTo(name, role string) bool {
if role == "admin" {
return true
}
ps.mu.RLock()
defer ps.mu.RUnlock()
for _, p := range ps.plugins {
if p.Info.Name != name {
continue
}
override := ps.visibility[name]
if override != "" {
return audienceAllows(override, role)
}
declared := ""
if p.UI != nil {
declared = p.UI.Visibility
}
return audienceAllows(declared, role)
}
// An unknown plugin name: no visibility can be established, and this endpoint
// answers with an empty state rather than an error, so refusing here would
// change behaviour for a name that may simply not be installed.
return true
}
func containsPage(pages []*UIPage, id string) bool {
for _, p := range pages {
if p != nil && p.PageID == id {
return true
}
}
return false
}
// readPluginHTTP collects plugin.http, the endpoints the plugin asks the gateway
// to mount.
//
// A malformed entry is DROPPED rather than failing the plugin: a plugin with one
// bad route should still load its hooks and pages, and the bad route simply does
// not exist. Failing the whole plugin would take a working billing widget down
// over a typo in an experimental endpoint.
func readPluginHTTP(L *golua.State) []HTTPHandler {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "http")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
//
// luaToJSON needs the json global, which is not installed yet while a plugin
// is registering. And golua's GetField does NOT convert a numeric key: with
// a string index "1" it returns nil, so an index loop silently produced an
// empty list and every plugin appeared to declare no endpoints at all.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
items, ok := raw.([]interface{})
if !ok {
return nil
}
var list []HTTPHandler
for _, it := range items {
m, ok := it.(map[string]interface{})
if !ok {
continue
}
h := HTTPHandler{
Path: asString(m["path"]),
Method: asString(m["method"]),
Handler: asString(m["handler"]),
Description: asString(m["description"]),
Public: m["public"] == true,
}
if roles, ok := m["roles"].([]interface{}); ok {
for _, r := range roles {
if s := asString(r); s != "" {
h.Roles = append(h.Roles, s)
}
}
}
if keys, ok := m["keys"].([]interface{}); ok {
for _, k := range keys {
if s := asString(k); s != "" {
h.Keys = append(h.Keys, s)
}
}
}
// A malformed entry is dropped rather than fatal: one bad route must not
// cost a plugin its hooks and pages. That is also why the mounted count
// can be lower than the declared count — /api/plugins lists what mounted.
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
h.Method = method
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
list = append(list, h)
}
}
}
return list
}
// uipageFromValue builds a UIPage from a decoded Lua table.
func uipageFromValue(m map[string]interface{}) *UIPage {
pg := &UIPage{
PageID: asString(m["page_id"]),
Title: asString(m["title"]),
Icon: asString(m["icon"]),
Mount: asString(m["mount"]),
Audience: asString(m["audience"]),
}
if n, ok := asFloat(m["order"]); ok {
pg.Order = int(n)
}
return pg
}