Files
ModelRouter/internal/gateway/plugins_api.go
JianFeeeee 5948ad19ca feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
2026-10-12 12:32:10 +08:00

752 lines
27 KiB
Go
Raw Permalink 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 gateway
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"llmsproxy/internal/lua"
)
var (
errPluginName = errors.New("plugin name must be non-empty and contain no path separator or dot")
errNoPluginDir = errors.New("no plugin_dir configured; set plugin_dir in config.yaml to enable plugins")
)
// Plugin management API.
//
// GET /api/plugins list loaded plugins + their hooks/UI/errors
// POST /api/plugins upload/replace one plugin (.lua), hot-applied
// DELETE /api/plugins/{name} remove a plugin
// GET /api/plugins/{name}/state the plugin's own published state
// PUT /api/plugins/{name}/state replace that state (admin only)
//
// Why state is a first-class endpoint: request_end is the LAST stage, so a
// hook's return value has no downstream consumer inside the gateway. A plugin
// that accumulates numbers (the billing plugin does exactly this) therefore
// keeps them in its own Lua state and serves them here, which is what its UI
// component fetches. This keeps plugin data clearly separated from the
// gateway's own stats — see the accounting note in docs/plugins.md: the
// gateway's quota accounting stays authoritative, a plugin only reports.
// handlePluginUI serves the merged UI extensions the WebUI injects at boot.
//
// It is a single GET (not per-plugin) because the browser needs ALL extensions
// before it can build the sidebar: a page contributed by one plugin and an
// element contributed by another land in the same payload, and fetching them
// separately would mean the sidebar has to be rebuilt as each arrives.
//
// Served to any authenticated role: the WebUI is authenticated before it asks,
// and a plugin's own widgets need to render for the user whose key they show.
func (g *Gateway) handlePluginUI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "use GET")
return
}
ps := g.core.Plugins()
if ps == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"ui": lua.UIExtension{}})
return
}
// AllStages, not a hand-written list: an earlier version enumerated the
// three stages literally here, so when chain_step was added it was silently
// missing from this response — a plugin author reading the discovery
// document would have believed the stage did not exist. Deriving it from
// the one place that defines the order is the whole point of having it.
writeJSON(w, http.StatusOK, map[string]interface{}{
"ui": ps.UI(),
"stages": pipelineStageNames(),
})
}
// stageNames returns the pipeline stage names in firing order, for the
// discovery payload and for tests.
func pipelineStageNames() []string {
out := make([]string, 0, len(lua.AllStages))
for _, s := range lua.AllStages {
out = append(out, string(s))
}
return out
}
func (g *Gateway) handlePluginsAPI(w http.ResponseWriter, r *http.Request) {
ps := g.core.Plugins()
if ps == nil {
writeJSON(w, http.StatusOK, map[string]interface{}{"plugins": []interface{}{}})
return
}
path := strings.TrimPrefix(r.URL.Path, "/api/plugins")
path = strings.Trim(path, "/")
// /api/plugins/conflicts — the arbitration surface (user requirement).
// GET lists every contested route (A) and every suspended adapter-name
// install (B), each with its candidates/incumbent and any recorded
// decision.
// POST {kind,path|name,winner} records the operator's choice and persists
// it. Must be routed BEFORE the single-segment {name} handler below,
// which would otherwise read "conflicts" as a plugin name.
if path == "conflicts" {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"routes": ps.RouteConflicts(),
"adapters": ps.AdapterConflicts(),
})
case http.MethodPost:
var body struct {
Kind string `json:"kind"` // "route" or "adapter"
Path string `json:"path"`
Name string `json:"name"`
Winner string `json:"winner"` // route: plugin name; adapter: "incumbent"|"candidate"
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
switch body.Kind {
case "route":
if err := ps.ResolveRouteConflict(body.Path, body.Winner); err != nil {
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "path": body.Path, "winner": body.Winner})
case "adapter":
if err := ps.ResolveAdapterConflict(body.Name, body.Winner); err != nil {
writeError(w, http.StatusBadRequest, "conflict_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name, "winner": body.Winner})
default:
writeError(w, http.StatusBadRequest, "invalid_request", "kind must be \"route\" or \"adapter\"")
}
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
return
}
// /api/plugins/{name}/state
if strings.HasSuffix(path, "/state") {
name := strings.TrimSuffix(path, "/state")
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
return
}
g.handlePluginState(w, r, name)
return
}
// /api/plugins/{name}/http/{path}/access — the operator's view of, and edit
// surface for, which keys an endpoint answers.
if i := strings.Index(path, "/http/"); i >= 0 {
name := path[:i]
rest := path[i+len("/http/"):]
if strings.HasSuffix(rest, "/access") {
epPath := strings.TrimSuffix(rest, "/access")
g.handleEndpointAccess(w, r, name, epPath)
return
}
}
// /api/plugins/{name}/kernel — the operator's view of, and edit surface for,
// which kernel actions a plugin may call, plus its declared extra outbound hosts.
if strings.HasSuffix(path, "/kernel") {
name := strings.TrimSuffix(path, "/kernel")
if name == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "plugin name required")
return
}
g.handlePluginKernel(w, r, name)
return
}
// /api/plugins/{name}/login — REMOVED. Login sessions are plugin domain:
// channels.lua owns its own session state and serves it through its own
// mount. The kernel has no login concept.
// /api/plugins/{name}/call/{fn}
if i := strings.Index(path, "/call/"); i >= 0 {
name := path[:i]
fn := path[i+len("/call/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "call needs a plugin and a function name")
return
}
g.handlePluginCall(w, r, name, fn)
return
}
// /api/plugins/{name}/fanout/{fn} — one call per argument set, in
// parallel, from one request. See handlePluginFanout for why the
// request-per-item form cannot be made concurrent from the client.
if i := strings.Index(path, "/fanout/"); i >= 0 {
name := path[:i]
fn := path[i+len("/fanout/"):]
if name == "" || fn == "" {
writeError(w, http.StatusBadRequest, "invalid_request", "fanout needs a plugin and a function name")
return
}
g.handlePluginFanout(w, r, name, fn)
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if path == "" {
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugins": ps.List(),
"on_disk": ps.OnDisk(),
"hook_errors": ps.HookErrors(),
"plugin_dir": g.pluginDir(),
// Visibility overrides and the endpoint access table, so the
// plugin page renders what is IN FORCE rather than making the
// operator read .lua files or config.yaml to find out.
"visibility": ps.VisibilityOverrides(),
"http_access": ps.EndpointAccess(),
})
case http.MethodPost:
var body struct {
Name string `json:"name"`
Code string `json:"code"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := g.installPlugin(body.Name, body.Code); err != nil {
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "name": body.Name})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
return
}
// /api/plugins/{name} — source read (GET), enable/disable (PUT), delete.
// Multi-segment paths (…/state) are handled earlier and never reach here.
if !strings.Contains(path, "/") {
switch r.Method {
case http.MethodGet:
// Reading the SOURCE (not the runtime state) is what an editor
// needs; the state endpoint is /state and returns accumulated data
// instead. Mixing the two would make a "save what I read"
// round-trip impossible.
code, err := g.readPluginSource(path)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"name": path, "code": code,
"enabled": g.core.Plugins().Enabled(path),
})
case http.MethodPut:
// Enable / disable. The intent rides in the body rather than the
// verb, because "enable" and "disable" are the same resource and
// PUT /{name} is the one the docs advertise.
var body struct {
Enabled *bool `json:"enabled"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if body.Enabled == nil {
writeError(w, http.StatusBadRequest, "invalid_request", `body must be {"enabled":true|false}`)
return
}
if err := g.core.Plugins().SetEnabled(path, *body.Enabled); err != nil {
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"ok": true, "name": path, "enabled": *body.Enabled,
})
case http.MethodDelete:
if err := g.removePlugin(path); err != nil {
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
return
}
writeError(w, http.StatusNotFound, "not_found", "unknown plugin sub-resource: "+path)
}
// readPluginSource returns a plugin's file contents for the editor.
func (g *Gateway) readPluginSource(name string) (string, error) {
if err := validPluginName(name); err != nil {
return "", err
}
if g.pluginDir() == "" {
return "", errNoPluginDir
}
b, err := os.ReadFile(filepath.Join(g.pluginDir(), name+".lua"))
if err != nil {
return "", fmt.Errorf("no plugin source named %q", name)
}
return string(b), nil
}
// validPluginName rejects anything that could escape the plugin directory.
// Shared by install / remove / read so the check cannot drift between them.
func validPluginName(name string) error {
if name == "" || strings.ContainsAny(name, `/\.`) {
return errPluginName
}
return nil
}
// handlePluginState serves GET (read state) and PUT (replace state).
func (g *Gateway) handlePluginState(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
// The plugin's audience gates its DATA as well as its page. Hiding the UI
// while leaving GET /state open would put the numbers one curl away, which
// is the leak the audience field exists to close — and billing's state is
// a global ledger, not the caller's own usage.
//
// A plugin that declares no audience is unchanged (every role may read
// it), so a single-purpose widget keeps working without edits.
role := reqRole(r.Context())
if !ps.StateVisibleTo(name, role) {
writeError(w, http.StatusForbidden, "forbidden",
"plugin "+name+" is not visible to role "+role)
return
}
// prices rides along: the billing rules editor must show the table
// that is actually in effect, not a reconstruction from config. It is
// absent for plugins with no prices (every plugin but billing).
state := ps.State(name)
// A plugin may SPLIT its state: global buckets stay deployment-wide while
// per-caller buckets are narrowed to the authenticated key. Role
// visibility alone cannot express this — the same role is allowed to read
// the plugin, just not another caller's row.
if scope := ps.StateScopeFor(name); scope != nil && role != "admin" {
state = splitStateForKey(state, scope, reqKey(r.Context()))
}
payload := map[string]interface{}{
"plugin": name,
"state": state,
}
if pr := ps.Prices(name); pr != nil {
payload["prices"] = pr
}
writeJSON(w, http.StatusOK, payload)
case http.MethodPut:
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
var state interface{}
if err := json.NewDecoder(r.Body).Decode(&state); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if err := ps.SetState(name, state); err != nil {
writeError(w, http.StatusBadRequest, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// Call invokes a named function on a plugin's Lua state and returns its result.
//
// This exists because plugin pages otherwise have NO way to reach their own
// plugin code: pluginAPI offers state read/write and a couple of callbacks, and
// that is enough for billing (whose numbers arrive on hooks during request
// forwarding) but useless for a plugin that must do work on demand. A quota
// page has to ask the vendor when the operator clicks Refresh, and no hook
// fires at that moment.
//
// It is deliberately narrow:
// - The function must be one the plugin explicitly lists in plugin.exports.
// Anything else is refused, so this endpoint cannot become a generic "run
// arbitrary Lua with my credentials" oracle over a plugin's private state.
// - The caller must be admin, and the result is JSON — no Lua value is ever
// handed back to Go as a live closure.
// - Errors come back as (nil, message) from the plugin, matching every other
// plugin entry point, and surface as a 502 rather than raising.
func (g *Gateway) handlePluginCall(w http.ResponseWriter, r *http.Request, name, fn string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
return
}
var args map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&args); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
result, err := g.core.Plugins().Call(name, fn, args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"result": result})
}
// handlePluginFanout runs one plugin export per element of a list, in parallel,
// from a SINGLE request.
//
// Why this exists: the quota page has twenty cards, and it used to fire twenty
// browser requests at /call/. They arrived concurrently and then serialised
// inside Plugins.Call, which holds the plugin mutex across the Lua function —
// including its network wait. Measured on the live gateway: 20 probes took 29s of
// wall clock while a single probe with no contention took 1.4–3.3s, so the page
// sat on "probing…" for half a minute. The browser-side concurrency was real and
// made no difference.
//
// A fan-out cannot be assembled from the client either: any request-per-item
// arrangement is exactly the shape that queues. The parallelism has to happen
// where the fan-out is received.
func (g *Gateway) handlePluginFanout(w http.ResponseWriter, r *http.Request, name, fn string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
return
}
var req struct {
// Args is a LIST of argument objects, one per call. A bare object is
// rejected rather than coerced: "args" meaning "one call" and "args"
// meaning "many" depending on its shape is how a caller ends up probing
// one source and believing it probed twenty.
Args []map[string]interface{} `json:"args"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
if len(req.Args) == 0 {
writeError(w, http.StatusBadRequest, "invalid_request", "args must be a non-empty list of argument objects")
return
}
// The page legitimately has one card per configured source. The bound is not
// arbitrary: it stops one request from asking for thousands of VMs, and it is
// enforced before any work starts rather than discovered shard by shard.
if len(req.Args) > fanoutMaxArgs {
writeError(w, http.StatusBadRequest, "invalid_request",
fmt.Sprintf("args has %d entries, limit is %d", len(req.Args), fanoutMaxArgs))
return
}
results, err := g.core.Plugins().ShardCall(name, fn, req.Args)
if err != nil {
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
return
}
// A shard that failed contributes a null in its slot. That is deliberate and
// is NOT the same as the whole request failing: one unreachable upstream must
// not turn a page of twenty cards into a single error.
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
}
// fanoutMaxArgs bounds one fan-out request. Twenty is the measured real source
// count on the gateway this was built for; the headroom is for operators with
// more sources configured.
const fanoutMaxArgs = 64
func (g *Gateway) pluginDir() string {
if c := g.core.Config(); c != nil {
return c.PluginDir
}
return ""
}
// installPlugin writes a plugin to disk and hot-loads it. A syntax error is
// returned to the caller AND the file is left on disk so the operator can fix
// it, matching how adapters behave (the file is authoritative once present).
func (g *Gateway) installPlugin(name, code string) error {
if err := validPluginName(name); err != nil {
return err
}
dir := g.pluginDir()
if dir == "" {
return errNoPluginDir
}
if err := os.MkdirAll(dir, 0755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dir, name+".lua"), []byte(code), 0644); err != nil {
return err
}
return g.core.Plugins().LoadSource(name, code)
}
func (g *Gateway) removePlugin(name string) error {
if err := validPluginName(name); err != nil {
return err
}
if g.pluginDir() == "" {
return errNoPluginDir
}
_ = os.Remove(filepath.Join(g.pluginDir(), name+".lua"))
return g.core.Plugins().Unload(name)
}
// handleEndpointAccess serves the operator's view and edit surface for one
// plugin endpoint's reachable keys.
//
// GET …/http/{path}/access the declared rules plus any override
// PUT …/http/{path}/access body {"access": "admin,@homeagent"}
// DELETE …/http/{path}/access clear the override
//
// The spec string is a comma list: a bare word is a role, "@name" is a gateway
// key's configured name, and the literal "public" opens it to any
// authenticated key. That is the same grammar the visibility override uses, and
// keeping one grammar means an operator learns it once.
//
// Key names are validated against the configured keys BEFORE being stored. An
// allowlist naming a key that does not exist is a silent no-op — every caller
// is refused — and the operator would have no way to tell that apart from the
// endpoint simply working.
func (g *Gateway) handleEndpointAccess(w http.ResponseWriter, r *http.Request, name, epPath string) {
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
ps := g.core.Plugins()
// epPath "*" is the WHOLE-PLUGIN visibility override, not an endpoint. It
// shares this route (and therefore this store) on purpose: an operator
// setting "who can see this plugin" and "who can call this endpoint" is
// asking the same question at two granularities, and two stores would let
// them disagree. SetEndpointAccess cannot serve it — it validates that the
// endpoint exists, which "*" by definition does not.
if epPath == pluginWholeOverride {
g.handlePluginVisibility(w, r, name)
return
}
switch r.Method {
case http.MethodGet:
for _, e := range ps.EndpointAccess() {
if e.Plugin == name && e.Path == epPath {
writeJSON(w, http.StatusOK, map[string]interface{}{"endpoint": e})
return
}
}
writeError(w, http.StatusNotFound, "not_found", "no such endpoint")
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetEndpointAccess(name, epPath, spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "access": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "")
}
}
// splitStateForKey rewrites a plugin state so that a non-admin caller sees only
// the buckets that belong to them.
//
// The plugin declares which buckets are global and which are per-key. A per-key
// bucket is keyed by the MASKED key ("***a1b2c3" — the same value the audit log
// uses), so the caller's own row is found by masking the presented secret and
// matching the last six characters.
//
// Keys that do not match are DROPPED, not zeroed: a zeroed row would render as
// "you spent nothing" and, worse, would leak that a row exists for someone else.
func splitStateForKey(state interface{}, scope *lua.StateScope, key string) interface{} {
m, ok := state.(map[string]interface{})
if !ok || scope == nil || key == "" {
return state
}
masked := maskKey(key)
out := make(map[string]interface{}, len(m))
for k, v := range m {
if !scope.IsGlobal(k) {
// Per-key bucket: keep only the caller's rows, at every nesting level
// the scope mentions (by_day_key is {day: {masked: bucket}}).
out[k] = filterKeyBuckets(v, masked, scope.DepthOf(k))
continue
}
out[k] = v
}
return out
}
// filterKeyBuckets narrows a keyed structure to one masked key.
//
// depth 1 is {masked: bucket}; depth 2 is {day: {masked: bucket}}. The level is
// declared rather than sniffed, because both shapes are dicts and sniffing would
// confuse an empty bucket with a nested one.
func filterKeyBuckets(v interface{}, masked string, depth int) interface{} {
switch depth {
case 0:
return v // not a per-key bucket; leave alone
case 1:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
if row, ok := m[masked]; ok {
return map[string]interface{}{masked: row}
}
return map[string]interface{}{}
default:
m, ok := v.(map[string]interface{})
if !ok {
return v
}
out := make(map[string]interface{}, len(m))
for day, inner := range m {
im, ok := inner.(map[string]interface{})
if !ok {
continue
}
if row, ok := im[masked]; ok {
out[day] = map[string]interface{}{masked: row}
}
}
return out
}
}
// pluginWholeOverride is the epPath that addresses a plugin's overall visibility
// rather than one of its endpoints.
const pluginWholeOverride = "*"
// handlePluginVisibility is the whole-plugin half of the access API. The
// audience string uses the same vocabulary as an endpoint spec (roles, @key
// names, public) so the UI can drive both from one input.
func (g *Gateway) handlePluginVisibility(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
switch r.Method {
case http.MethodGet:
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"visibility": ps.VisibilityOverrides()[name],
})
case http.MethodPut, http.MethodDelete:
spec := ""
if r.Method == http.MethodPut {
var body struct {
Access string `json:"access"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", "invalid json: "+err.Error())
return
}
spec = body.Access
}
if err := ps.ValidateAccessSpec(spec); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
ps.SetPluginVisibility(name, spec)
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true, "visibility": spec})
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", r.Method+" is not allowed here")
}
}
// handlePluginKernel is the operator's surface for a plugin's kernel grants and
// declared extra outbound hosts.
//
// GET returns what the plugin DECLARED beside what was GRANTED. That pairing is
// the point of the endpoint: a plugin declaring `add_source` is a request, and
// the operator needs to see the request next to the current answer to decide.
func (g *Gateway) handlePluginKernel(w http.ResponseWriter, r *http.Request, name string) {
ps := g.core.Plugins()
if ps == nil {
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
return
}
if reqRole(r.Context()) != "admin" {
writeError(w, http.StatusForbidden, "forbidden", "admin role required")
return
}
switch r.Method {
case http.MethodGet:
perm := ps.PluginKernelPermission(name)
declared := []string(nil)
for _, v := range ps.KernelPermissions() {
if v.Plugin == name {
declared = v.Declared
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{
"plugin": name,
"actions": ps.KernelActionsView(name),
"declared": declared,
"granted": perm.Actions,
"all_read": perm.AllRead,
// Extra outbound hosts: declared vs granted, same request/privilege
// split as kernel actions. What the plugin does with a host (login,
// claim quotas...) is plugin business — the kernel only bounds egress.
"hosts_declared": ps.PluginExtraHosts(name),
"hosts_granted": ps.ExtraHostsGranted()[name],
})
return
case http.MethodPut, http.MethodPost:
var body struct {
Actions []string `json:"actions"`
AllRead bool `json:"all_read"`
Hosts []string `json:"hosts"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if err := ps.SetPluginKernelPermission(name, body.Actions, body.AllRead); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
if body.Hosts != nil {
if err := ps.GrantExtraHosts(name, body.Hosts); err != nil {
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]interface{}{"ok": true})
return
default:
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed", "GET or PUT")
}
}