mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
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:
511
internal/lua/plugin_http_handler.go
Normal file
511
internal/lua/plugin_http_handler.go
Normal file
@ -0,0 +1,511 @@
|
||||
package lua
|
||||
|
||||
// Plugin-registered HTTP endpoints.
|
||||
//
|
||||
// The gateway mounts what a plugin declares in plugin.http and calls back into
|
||||
// the plugin's Lua state to produce each response. This is the piece billing
|
||||
// never needed: it only receives hooks, so it has nothing to expose. A plugin
|
||||
// that computes something an operator wants to query from outside the browser —
|
||||
// a health probe, a CI check, another tool — needs a route, and hard-coding one
|
||||
// per plugin would mean a gateway change for every such plugin.
|
||||
//
|
||||
// The request and response are both plain tables crossing the Go/Lua boundary:
|
||||
//
|
||||
// request { method, path, query{}, params{}, headers{}, body, role, key }
|
||||
// response { status, headers{}, body } or { status, json = <any table> }
|
||||
//
|
||||
// A handler returns (response, nil) or (nil, errorMessage). Errors never raise:
|
||||
// an escaping Lua error inside a hook has taken the whole gateway down before
|
||||
// (see hookGuardSrc), so the same discipline applies here.
|
||||
//
|
||||
// Two properties are enforced on the Go side rather than trusted to the plugin:
|
||||
//
|
||||
// - Method and status are checked against an allowlist before the response is
|
||||
// written. A plugin returning status 0 or a redirect with an injected
|
||||
// Location must not be able to steer the gateway's own routing.
|
||||
// - Path traversal cannot escape the mount: params are substituted into a
|
||||
// fixed segment count, and the handler's own Path is rejected at
|
||||
// registration if it contains "..".
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// allowedMethods is deliberately short. An arbitrary verb would need a case in
|
||||
// the router for no benefit, and a plugin that wants every verb can register the
|
||||
// five that exist.
|
||||
var allowedMethods = map[string]bool{
|
||||
http.MethodGet: true, http.MethodPost: true, http.MethodPut: true,
|
||||
http.MethodDelete: true, http.MethodPatch: true,
|
||||
}
|
||||
|
||||
// normalizeHandlerMethod validates and defaults a declared method.
|
||||
func normalizeHandlerMethod(m string) (string, error) {
|
||||
m = strings.ToUpper(strings.TrimSpace(m))
|
||||
if m == "" {
|
||||
return http.MethodGet, nil
|
||||
}
|
||||
if !allowedMethods[m] {
|
||||
return "", fmt.Errorf("unsupported method %q (allowed: GET POST PUT DELETE PATCH)", m)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// validateHandlerPath rejects paths that could escape the mount or collide with
|
||||
// the plugin state/call routes.
|
||||
func validateHandlerPath(p string) error {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
return fmt.Errorf("empty path")
|
||||
}
|
||||
if strings.Contains(p, "..") {
|
||||
return fmt.Errorf("path %q contains .. ", p)
|
||||
}
|
||||
if strings.Contains(p, "//") {
|
||||
return fmt.Errorf("path %q contains an empty segment", p)
|
||||
}
|
||||
for _, seg := range strings.Split(strings.Trim(p, "/"), "/") {
|
||||
if seg == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(seg, ":") {
|
||||
if len(seg) == 1 {
|
||||
return fmt.Errorf("path %q has a nameless param segment", p)
|
||||
}
|
||||
continue
|
||||
}
|
||||
// A literal segment must not look like a wildcard, or the route table
|
||||
// gets two patterns that match the same URL.
|
||||
if strings.ContainsAny(seg, "*?") {
|
||||
return fmt.Errorf("path %q uses a wildcard; use :name for a param", p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handlerRequest is what a Lua handler sees.
|
||||
type handlerRequest struct {
|
||||
Method string
|
||||
Path string
|
||||
Query map[string]string
|
||||
Params map[string]string
|
||||
Headers map[string]string
|
||||
Body string
|
||||
Role string
|
||||
Key string
|
||||
}
|
||||
|
||||
// handlerResponse is what a Lua handler returns.
|
||||
type handlerResponse struct {
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
// JSON is set when the handler returned {json = <table>}; it is encoded by
|
||||
// the kernel so a plugin does not hand-roll escaping.
|
||||
JSON interface{}
|
||||
}
|
||||
|
||||
// writeHTTPHandlerResponse renders a handler's reply onto the gateway's writer.
|
||||
//
|
||||
// The status is validated even though it came from a plugin: a handler
|
||||
// returning 0 or 999 would make net/http panic, and one returning 3xx with a
|
||||
// Location the plugin chose could be used to bounce a caller at a URL of the
|
||||
// plugin's picking. Anything outside 100..599 that is not a redirect becomes 500
|
||||
// with an error body rather than a panic.
|
||||
func writeHTTPHandlerResponse(w http.ResponseWriter, r *handlerResponse) {
|
||||
status := r.Status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
if status < 100 || status > 599 {
|
||||
status = http.StatusInternalServerError
|
||||
}
|
||||
for k, v := range r.Headers {
|
||||
// A plugin cannot set Content-Length (the body is written by the kernel)
|
||||
// or hop-by-hop headers (Connection, Transfer-Encoding) which would
|
||||
// corrupt the gateway's own response framing.
|
||||
switch http.CanonicalHeaderKey(k) {
|
||||
case "Content-Length", "Connection", "Transfer-Encoding", "Upgrade":
|
||||
continue
|
||||
}
|
||||
w.Header().Set(k, v)
|
||||
}
|
||||
if r.JSON != nil {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.WriteHeader(status)
|
||||
// Encoded here rather than by the gateway layer: this file is in the lua
|
||||
// package and importing the gateway would be a cycle.
|
||||
if enc, err := json.Marshal(r.JSON); err == nil {
|
||||
_, _ = w.Write(enc)
|
||||
} else {
|
||||
_, _ = w.Write([]byte(`{"error":"handler json could not be encoded"}`))
|
||||
}
|
||||
return
|
||||
}
|
||||
if w.Header().Get("Content-Type") == "" {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
if r.Body != "" {
|
||||
_, _ = w.Write([]byte(r.Body))
|
||||
}
|
||||
}
|
||||
|
||||
// parseHandlerResponse reads a Lua handler's return value.
|
||||
//
|
||||
// It accepts three shapes, because a plugin should not have to think about it:
|
||||
//
|
||||
// { status = 200, body = "text" }
|
||||
// { status = 201, json = { any = "table" } }
|
||||
// { status = 204 } -- no body
|
||||
//
|
||||
// Anything else — a string, a number, a table without status — is a handler bug
|
||||
// and becomes an error rather than an empty 200, so the plugin author sees it.
|
||||
func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
m, ok := v.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("handler must return a table, got %T", v)
|
||||
}
|
||||
out := &handlerResponse{Status: http.StatusOK}
|
||||
if s, ok := m["status"]; ok {
|
||||
switch n := s.(type) {
|
||||
case float64:
|
||||
out.Status = int(n)
|
||||
case int:
|
||||
out.Status = n
|
||||
default:
|
||||
return nil, fmt.Errorf("status must be a number, got %T", s)
|
||||
}
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
out.Body = b
|
||||
}
|
||||
if h, ok := m["headers"].(map[string]interface{}); ok {
|
||||
out.Headers = make(map[string]string, len(h))
|
||||
for k, hv := range h {
|
||||
out.Headers[k] = asString(hv)
|
||||
}
|
||||
}
|
||||
if j, ok := m["json"]; ok && j != nil {
|
||||
out.JSON = j
|
||||
}
|
||||
// An empty table is a handler bug — it means the author forgot to say what to
|
||||
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
||||
// way to answer with no body, and treating it as an error would make the
|
||||
// correct spelling unusable.
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
||||
return nil, fmt.Errorf("handler returned an empty table; return " +
|
||||
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// InvokeHandler calls a plugin's HTTP handler function with a request table and
|
||||
// returns the decoded response.
|
||||
//
|
||||
// It reuses the same guard-in-Lua contract as the hooks: the function is called
|
||||
// through __llmsproxy_call_hook, so a plugin error comes back as (nil, message)
|
||||
// and never as a C-level abort.
|
||||
func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*handlerResponse, error) {
|
||||
p := ps.find(plugin)
|
||||
if p == nil {
|
||||
return nil, fmt.Errorf("plugin %s not found", plugin)
|
||||
}
|
||||
p.mu.Lock()
|
||||
w := p.state
|
||||
p.mu.Unlock()
|
||||
if w == nil {
|
||||
return nil, fmt.Errorf("plugin %s has no state", plugin)
|
||||
}
|
||||
|
||||
// The whole call happens under the plugin lock, network waits included —
|
||||
// same property as Call, and for the same reason: one Lua state.
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
L := w.L
|
||||
L.SetTop(0)
|
||||
defer L.SetTop(0)
|
||||
|
||||
L.GetGlobal(pluginGlobal)
|
||||
if L.IsNil(-1) {
|
||||
return nil, fmt.Errorf("plugin table missing")
|
||||
}
|
||||
plug := L.GetTop()
|
||||
L.GetGlobal(hookGuardName)
|
||||
if L.IsNil(-1) {
|
||||
L.Pop(1)
|
||||
}
|
||||
L.GetField(plug, fnName)
|
||||
if !L.IsFunction(-1) {
|
||||
return nil, fmt.Errorf("handler %s not found in plugin %s", fnName, plugin)
|
||||
}
|
||||
|
||||
pushGoValue(L, plainForLua(map[string]interface{}{
|
||||
"method": req.Method,
|
||||
"path": req.Path,
|
||||
"query": stringMapToAny(req.Query),
|
||||
"params": stringMapToAny(req.Params),
|
||||
"headers": stringMapToAny(req.Headers),
|
||||
"body": req.Body,
|
||||
"role": req.Role,
|
||||
"key": req.Key,
|
||||
}))
|
||||
if err := L.Call(2, 2); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
errMsg := ""
|
||||
if L.GetTop() >= 2 {
|
||||
if !L.IsNil(-1) {
|
||||
errMsg = L.ToString(-1)
|
||||
}
|
||||
L.Pop(1)
|
||||
}
|
||||
if errMsg != "" {
|
||||
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
|
||||
}
|
||||
if L.GetTop() < 1 || L.IsNil(-1) {
|
||||
return nil, fmt.Errorf("handler %s returned nothing", fnName)
|
||||
}
|
||||
var raw interface{}
|
||||
if err := luaToJSON(L, -1, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseHandlerResponse(raw)
|
||||
}
|
||||
|
||||
func stringMapToAny(m map[string]string) map[string]interface{} {
|
||||
if len(m) == 0 {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
out := make(map[string]interface{}, len(m))
|
||||
for k, v := range m {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// HandlerRequest is the request table handed to a plugin HTTP handler.
|
||||
// Exported because the gateway package constructs it.
|
||||
type HandlerRequest = handlerRequest
|
||||
|
||||
// HandlerResponse is what a handler returns, after decoding.
|
||||
type HandlerResponse = handlerResponse
|
||||
|
||||
// WriteHandlerResponse renders a handler response onto the gateway's writer.
|
||||
func WriteHandlerResponse(w http.ResponseWriter, r *HandlerResponse) {
|
||||
writeHTTPHandlerResponse(w, r)
|
||||
}
|
||||
|
||||
// HTTPHandlers returns the registered endpoints across all plugins, each tagged
|
||||
// with its owner so the dispatcher knows which Lua state to enter.
|
||||
func (ps *Plugins) HTTPHandlers() []HTTPHandler {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
var out []HTTPHandler
|
||||
for _, p := range ps.plugins {
|
||||
if p.http == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
for _, declared := range p.http {
|
||||
// effectiveAccess applies the operator's per-endpoint override, so the
|
||||
// dispatcher enforces what the WebUI shows rather than what the .lua
|
||||
// declared.
|
||||
h := ps.effectiveAccess(p, declared)
|
||||
h.Plugin = p.Info.Name
|
||||
out = append(out, h)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Allows is the exported form of the access rule, so the gateway dispatcher does
|
||||
// not need a duplicate copy of it in its own package.
|
||||
func (h HTTPHandler) Allows(role, keyName string) bool { return h.allows(role, keyName) }
|
||||
|
||||
// HTTPEndpointAccess is the operator's view of one endpoint's permissions,
|
||||
// returned by /api/plugins so the WebUI can render what is exposed without
|
||||
// parsing .lua files.
|
||||
type HTTPEndpointAccess struct {
|
||||
Plugin string `json:"plugin"`
|
||||
Path string `json:"path"`
|
||||
FullPath string `json:"full_path"`
|
||||
Method string `json:"method"`
|
||||
Handler string `json:"handler"`
|
||||
Roles []string `json:"roles,omitempty"`
|
||||
Keys []string `json:"keys,omitempty"`
|
||||
Public bool `json:"public"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// OverrideRoles / OverrideKeys carry the operator's own restriction, which
|
||||
// REPLACES whatever the plugin declared. Empty means "use the plugin's".
|
||||
//
|
||||
// This is the mechanism the admin uses to narrow (or widen) an endpoint's
|
||||
// reachable keys without editing the plugin: a bundled .lua is overwritten on
|
||||
// the next upgrade, and a hand-installed one is not something the operator
|
||||
// should have to maintain.
|
||||
OverrideRoles []string `json:"override_roles,omitempty"`
|
||||
OverrideKeys []string `json:"override_keys,omitempty"`
|
||||
}
|
||||
|
||||
// endpointAccess returns the access table for every registered endpoint, with
|
||||
// operator overrides folded in.
|
||||
func (ps *Plugins) endpointAccess() []HTTPEndpointAccess {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
var out []HTTPEndpointAccess
|
||||
for _, p := range ps.plugins {
|
||||
if p.http == nil || p.LoadError != "" {
|
||||
continue
|
||||
}
|
||||
ov := ps.visibility[p.Info.Name]
|
||||
var ovRoles, ovKeys []string
|
||||
if ov != "" {
|
||||
for _, part := range strings.Split(ov, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
// A key name is anything that is not a role: the override string
|
||||
// carries both, so "@name" marks a key and a bare word a role.
|
||||
if strings.HasPrefix(part, "@") {
|
||||
ovKeys = append(ovKeys, strings.TrimPrefix(part, "@"))
|
||||
} else {
|
||||
ovRoles = append(ovRoles, part)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range p.http {
|
||||
out = append(out, HTTPEndpointAccess{
|
||||
Plugin: p.Info.Name, Path: h.Path, FullPath: h.routePath(),
|
||||
Method: h.Method, Handler: h.Handler,
|
||||
Roles: h.Roles, Keys: h.Keys, Public: h.Public,
|
||||
Description: h.Description,
|
||||
OverrideRoles: ovRoles, OverrideKeys: ovKeys,
|
||||
})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// EndpointAccess exposes the table to the gateway package.
|
||||
func (ps *Plugins) EndpointAccess() []HTTPEndpointAccess { return ps.endpointAccess() }
|
||||
|
||||
// SetEndpointAccess narrows or widens one endpoint's reachable keys/roles.
|
||||
//
|
||||
// It is an OVERRIDE, applied on top of what the plugin declared, and stored in
|
||||
// the same per-plugin visibility map the UI audience uses. An empty value clears
|
||||
// it. Overriding per endpoint rather than per plugin is what the operator
|
||||
// actually wants: /health is fine for every key while /balance is not, and
|
||||
// those two live in the same plugin.
|
||||
func (ps *Plugins) SetEndpointAccess(plugin, path, spec string) error {
|
||||
p := ps.find(plugin)
|
||||
if p == nil {
|
||||
return fmt.Errorf("plugin %s not found", plugin)
|
||||
}
|
||||
found := false
|
||||
for _, h := range p.http {
|
||||
if h.Path == path {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("plugin %s has no endpoint %q", plugin, path)
|
||||
}
|
||||
ps.mu.Lock()
|
||||
defer ps.mu.Unlock()
|
||||
if ps.visibility == nil {
|
||||
ps.visibility = visibilityOverrides{}
|
||||
}
|
||||
key := endpointOverrideKey(plugin, path)
|
||||
if strings.TrimSpace(spec) == "" {
|
||||
delete(ps.visibility, key)
|
||||
return nil
|
||||
}
|
||||
ps.visibility[key] = spec
|
||||
return nil
|
||||
}
|
||||
|
||||
// endpointOverrideKey namespaces a per-endpoint override so it cannot collide
|
||||
// with a whole-plugin audience override of the same plugin name.
|
||||
func endpointOverrideKey(plugin, path string) string {
|
||||
return plugin + "#" + path
|
||||
}
|
||||
|
||||
// effectiveAccess applies an endpoint override to the declared handler.
|
||||
func (ps *Plugins) effectiveAccess(p *Plugin, h HTTPHandler) HTTPHandler {
|
||||
ps.mu.RLock()
|
||||
ov := ps.visibility[endpointOverrideKey(p.Info.Name, h.Path)]
|
||||
ps.mu.RUnlock()
|
||||
if ov == "" {
|
||||
return h
|
||||
}
|
||||
out := h
|
||||
out.Roles, out.Keys = nil, nil
|
||||
out.Public = false
|
||||
for _, part := range strings.Split(ov, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
switch {
|
||||
case part == "":
|
||||
case part == "public":
|
||||
out.Public = true
|
||||
case strings.HasPrefix(part, "@"):
|
||||
out.Keys = append(out.Keys, strings.TrimPrefix(part, "@"))
|
||||
default:
|
||||
out.Roles = append(out.Roles, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// knownKeyNames is set by the gateway during wiring so ValidateAccessSpec can
|
||||
// refuse an allowlist naming a key that does not exist.
|
||||
//
|
||||
// Without the check, an allowlist naming a typo ("homeagent" vs "home_agent")
|
||||
// is stored, saved, shown in the WebUI as configured — and refuses every caller,
|
||||
// including the one the operator meant. The failure looks like the endpoint being
|
||||
// broken rather than the name being wrong.
|
||||
var knownKeyNames func() []string
|
||||
|
||||
// SetKeyNameLookup registers the source of configured gateway key names.
|
||||
func SetKeyNameLookup(fn func() []string) { knownKeyNames = fn }
|
||||
|
||||
// ValidateAccessSpec checks an access spec against the configured keys and roles.
|
||||
//
|
||||
// Roles are checked against the two that exist (admin, user) rather than left
|
||||
// free-form: a role that does not exist produces an endpoint nobody can reach,
|
||||
// which is the same silent failure as a bad key name.
|
||||
func (ps *Plugins) ValidateAccessSpec(spec string) error {
|
||||
spec = strings.TrimSpace(spec)
|
||||
if spec == "" {
|
||||
return nil
|
||||
}
|
||||
known := map[string]bool{"public": true, "admin": true, "user": true}
|
||||
if knownKeyNames != nil {
|
||||
for _, n := range knownKeyNames() {
|
||||
if n != "" {
|
||||
known[strings.ToLower(n)] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, part := range strings.Split(spec, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
name := part
|
||||
if strings.HasPrefix(name, "@") {
|
||||
name = strings.TrimPrefix(name, "@")
|
||||
}
|
||||
if name == "" {
|
||||
return fmt.Errorf("access entry %q names no key", part)
|
||||
}
|
||||
if !known[strings.ToLower(name)] {
|
||||
return fmt.Errorf("access entry %q is neither a role (admin/user/public) "+
|
||||
"nor a configured key name", part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
400
internal/lua/plugin_http_handler_test.go
Normal file
400
internal/lua/plugin_http_handler_test.go
Normal file
@ -0,0 +1,400 @@
|
||||
package lua
|
||||
|
||||
// Plugin-registered HTTP endpoints.
|
||||
//
|
||||
// Three properties have to hold, and none of them is visible from the plugin's
|
||||
// own point of view:
|
||||
//
|
||||
// 1. Auth and roles are enforced by the GATEWAY. A plugin that forgets to
|
||||
// declare roles gets admin-only, not open — the failure mode of "public by
|
||||
// omission" is an internet-scannable endpoint inside the gateway process.
|
||||
// 2. A handler error is a value, never a panic. The pcall guard is the same one
|
||||
// the hooks use, for the same reason.
|
||||
// 3. The plugin cannot steer the gateway's own response framing: a nonsense
|
||||
// status becomes a 500, and Content-Length / hop-by-hop headers are dropped.
|
||||
//
|
||||
// These tests drive the declaration layer and the response writer directly; the
|
||||
// router-level dispatch is covered in the gateway package.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const httpPlugin = `
|
||||
local P = {}
|
||||
P.name = "api"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.http = {
|
||||
{
|
||||
path = "health",
|
||||
handler = "health",
|
||||
roles = { "admin" },
|
||||
description = "liveness",
|
||||
},
|
||||
{
|
||||
path = "echo/:what",
|
||||
method = "POST",
|
||||
handler = "echo",
|
||||
roles = { "admin", "user" },
|
||||
},
|
||||
}
|
||||
function P.health(req)
|
||||
return { status = 200, json = { ok = true, role = req.role, key = req.key } }
|
||||
end
|
||||
function P.echo(req)
|
||||
return { status = 201, json = { what = req.params.what, body = req.body } }
|
||||
end
|
||||
return P
|
||||
`
|
||||
|
||||
func TestPluginRegistersHTTPHandlers(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hs := ps.HTTPHandlers()
|
||||
if len(hs) != 2 {
|
||||
t.Fatalf("expected 2 registered endpoints, got %d: %+v", len(hs), hs)
|
||||
}
|
||||
for _, h := range hs {
|
||||
if h.Plugin != "api" {
|
||||
t.Errorf("handler %q has no owner; the dispatcher could not route it", h.Path)
|
||||
}
|
||||
if h.Method == "" {
|
||||
t.Errorf("handler %q has no method; the gateway would answer 405 forever", h.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRoleRules(t *testing.T) {
|
||||
// A handler with no roles and no public opt-out is ADMIN-ONLY. This is the
|
||||
// decision the whole feature turns on: "I forgot to declare roles" must not
|
||||
// mean "anyone on the internet".
|
||||
open := HTTPHandler{Path: "x", Handler: "h"}
|
||||
if open.allows("user", "") {
|
||||
t.Error("an undeclared handler must default to admin-only")
|
||||
}
|
||||
if !open.allows("admin", "") {
|
||||
t.Error("an undeclared handler must still serve admin")
|
||||
}
|
||||
// The explicit opt-out is how a plugin says "this one really is public".
|
||||
pub := HTTPHandler{Path: "x", Handler: "h", Public: true}
|
||||
if !pub.allows("user", "") || !pub.allows("", "") {
|
||||
t.Error("public=true must admit any role")
|
||||
}
|
||||
// Declared roles.
|
||||
scoped := HTTPHandler{Path: "x", Roles: []string{"admin", "user"}}
|
||||
if !scoped.allows("user", "homeagent") || !scoped.allows("admin", "admin") {
|
||||
t.Error("a listed role must be admitted")
|
||||
}
|
||||
if (HTTPHandler{Path: "x", Roles: []string{"admin"}}).allows("user", "homeagent") {
|
||||
t.Error("an unlisted role must be refused")
|
||||
}
|
||||
}
|
||||
|
||||
// Naming keys is how the operator grants one collaborator access without opening
|
||||
// the endpoint to every user key. Two properties matter and neither is obvious:
|
||||
// a named key wins even when the role rule would have refused it, and the named
|
||||
// list REPLACES the role rule rather than adding to it.
|
||||
func TestHandlerKeyRules(t *testing.T) {
|
||||
h := HTTPHandler{Path: "x", Roles: []string{"admin"}, Keys: []string{"homeagent"}}
|
||||
if !h.allows("user", "homeagent") {
|
||||
t.Error("a named key must be admitted even though its role is not listed")
|
||||
}
|
||||
// The replacement semantics: listing one key must NOT also admit every user
|
||||
// key through the role rule, which is what an OR would have done.
|
||||
if h.allows("user", "club") {
|
||||
t.Error("naming keys must replace the role rule, not add to it")
|
||||
}
|
||||
if h.allows("user", "") {
|
||||
t.Error("an unnamed key must not match a named-key allowlist")
|
||||
}
|
||||
// Admin is always admitted, so a typo in the list never locks the operator
|
||||
// out of the endpoint they are trying to tighten.
|
||||
if !h.allows("admin", "admin") {
|
||||
t.Error("admin must always reach an endpoint")
|
||||
}
|
||||
// Name matching is case-insensitive; a config written by hand varies.
|
||||
if !(HTTPHandler{Path: "x", Keys: []string{"HomeAgent"}}).allows("user", "homeagent") {
|
||||
t.Error("key names should match case-insensitively")
|
||||
}
|
||||
}
|
||||
|
||||
// The stored override replaces whatever the plugin declared — this is the admin
|
||||
// editing access without touching a .lua that an upgrade would overwrite.
|
||||
func TestEndpointAccessOverrideReplacesDeclaration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// health is declared admin-only; widen it to one named key.
|
||||
if err := ps.SetEndpointAccess("api", "health", "@homeagent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var found *HTTPHandler
|
||||
for _, h := range ps.HTTPHandlers() {
|
||||
if h.Path == "health" {
|
||||
hh := h
|
||||
found = &hh
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("health endpoint disappeared after an access override")
|
||||
}
|
||||
if len(found.Roles) != 0 {
|
||||
t.Errorf("the declared roles should be replaced, got %v", found.Roles)
|
||||
}
|
||||
if len(found.Keys) != 1 || found.Keys[0] != "homeagent" {
|
||||
t.Errorf("override keys = %v, want [homeagent]", found.Keys)
|
||||
}
|
||||
if !found.allows("user", "homeagent") || found.allows("user", "club") {
|
||||
t.Error("the override is not in force")
|
||||
}
|
||||
// Clearing restores the plugin's own declaration.
|
||||
if err := ps.SetEndpointAccess("api", "health", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, h := range ps.HTTPHandlers() {
|
||||
if h.Path == "health" {
|
||||
if len(h.Keys) != 0 {
|
||||
t.Errorf("clearing the override should restore the declaration, got %v", h.Keys)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Error("health endpoint vanished")
|
||||
}
|
||||
|
||||
func TestSetEndpointAccessRejectsUnknownTarget(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.SetEndpointAccess("api", "nope", "@homeagent"); err == nil {
|
||||
t.Error("setting access on a path the plugin does not expose must fail loudly")
|
||||
}
|
||||
if err := ps.SetEndpointAccess("nosuchplugin", "health", "@homeagent"); err == nil {
|
||||
t.Error("setting access for an unknown plugin must fail loudly")
|
||||
}
|
||||
}
|
||||
|
||||
// A stored allowlist naming a key that does not exist refuses every caller while
|
||||
// looking perfectly configured. ValidateAccessSpec exists to catch that at write
|
||||
// time instead.
|
||||
func TestValidateAccessSpec(t *testing.T) {
|
||||
SetKeyNameLookup(func() []string { return []string{"homeagent", "club"} })
|
||||
t.Cleanup(func() { SetKeyNameLookup(nil) })
|
||||
|
||||
for _, ok := range []string{"admin", "user", "public", "@homeagent",
|
||||
"admin,@homeagent,@club", " admin , @club "} {
|
||||
if err := (&Plugins{}).ValidateAccessSpec(ok); err != nil {
|
||||
t.Errorf("spec %q should be accepted: %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"@nosuchkey", "superuser", "@", "admin,@typo_key"} {
|
||||
if err := (&Plugins{}).ValidateAccessSpec(bad); err == nil {
|
||||
t.Errorf("spec %q must be refused: it is neither a role nor a configured key", bad)
|
||||
}
|
||||
}
|
||||
if err := (&Plugins{}).ValidateAccessSpec(""); err != nil {
|
||||
t.Errorf("an empty spec clears the override and must be accepted: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerPathValidationRejectsEscapes(t *testing.T) {
|
||||
// ":name" with empty segments around it is legal — the trims below reduce it
|
||||
// to ":name". What must be refused is an empty PARAM NAME, which cannot be
|
||||
// captured into request.params.
|
||||
for _, bad := range []string{"", " ", "a/../b", "a//b", "wild*card", "q?x", "/:/"} {
|
||||
if err := validateHandlerPath(bad); err == nil {
|
||||
t.Errorf("path %q must be rejected: it can escape the mount or make the "+
|
||||
"route table ambiguous", bad)
|
||||
}
|
||||
}
|
||||
for _, ok := range []string{"health", "a/b", "echo/:what", "v1/things/:id", "/:name/"} {
|
||||
if err := validateHandlerPath(ok); err != nil {
|
||||
t.Errorf("path %q should be accepted: %v", ok, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerMethodValidation(t *testing.T) {
|
||||
if m, err := normalizeHandlerMethod(""); err != nil || m != http.MethodGet {
|
||||
t.Errorf("an empty method should default to GET, got %q %v", m, err)
|
||||
}
|
||||
if _, err := normalizeHandlerMethod("TRACE"); err == nil {
|
||||
t.Error("TRACE must be refused")
|
||||
}
|
||||
if m, err := normalizeHandlerMethod("post"); err != nil || m != http.MethodPost {
|
||||
t.Errorf("method should normalise to upper case, got %q %v", m, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeHandlerReturnsStructuredData(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := ps.InvokeHandler("api", "health", &HandlerRequest{
|
||||
Method: http.MethodGet, Path: "health", Role: "admin", Key: "sk-test",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke: %v", err)
|
||||
}
|
||||
if resp.Status != 200 {
|
||||
t.Errorf("status = %d, want 200", resp.Status)
|
||||
}
|
||||
j, ok := resp.JSON.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("json = %T, want a table (the handler's nested value must survive)", resp.JSON)
|
||||
}
|
||||
if j["ok"] != true || j["role"] != "admin" {
|
||||
t.Errorf("handler data crossed wrong: %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvokeHandlerParamsAndBodyReachLua(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
if err := ps.LoadSource("api", httpPlugin); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resp, err := ps.InvokeHandler("api", "echo", &HandlerRequest{
|
||||
Method: http.MethodPost, Path: "echo/thing",
|
||||
Params: map[string]string{"what": "thing"},
|
||||
Body: `{"n":1}`, Role: "user",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("invoke: %v", err)
|
||||
}
|
||||
j, _ := resp.JSON.(map[string]interface{})
|
||||
if resp.Status != 201 {
|
||||
t.Errorf("status = %d, want 201 (the handler chose it)", resp.Status)
|
||||
}
|
||||
if j["what"] != "thing" {
|
||||
t.Errorf("path param did not reach the handler: %v", j)
|
||||
}
|
||||
if j["body"] != `{"n":1}` {
|
||||
t.Errorf("body did not reach the handler: %v", j)
|
||||
}
|
||||
}
|
||||
|
||||
// A handler that raises must produce an error, never a process abort — the same
|
||||
// SIGSEGV class of bug the hook guard exists for.
|
||||
func TestInvokeHandlerErrorIsValueNotPanic(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const boom = `
|
||||
local P = {}
|
||||
P.name = "boom"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.http = { { path = "blow", handler = "blow", roles = { "admin" } } }
|
||||
function P.blow(req)
|
||||
error("handler exploded")
|
||||
end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("boom", boom); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := ps.InvokeHandler("boom", "blow", &HandlerRequest{Role: "admin"}); err == nil {
|
||||
t.Fatal("a raising handler must surface as an error")
|
||||
}
|
||||
}
|
||||
|
||||
// The plugin does not get to decide how the gateway frames its response.
|
||||
func TestHandlerResponseFramingIsKernelOwned(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in *HandlerResponse
|
||||
want int
|
||||
}{
|
||||
{"status 0 becomes 200", &HandlerResponse{Status: 0, Body: "x"}, 200},
|
||||
{"status 9999 becomes 500", &HandlerResponse{Status: 9999, Body: "x"}, 500},
|
||||
{"status -1 becomes 500", &HandlerResponse{Status: -1, Body: "x"}, 500},
|
||||
{"explicit 404 is kept", &HandlerResponse{Status: 404, Body: "x"}, 404},
|
||||
}
|
||||
for _, c := range cases {
|
||||
w := httptest.NewRecorder()
|
||||
writeHTTPHandlerResponse(w, c.in)
|
||||
if w.Code != c.want {
|
||||
t.Errorf("%s: status = %d, want %d", c.name, w.Code, c.want)
|
||||
}
|
||||
}
|
||||
|
||||
// A handler must not be able to desync the gateway's own framing.
|
||||
w := httptest.NewRecorder()
|
||||
writeHTTPHandlerResponse(w, &HandlerResponse{
|
||||
Status: 200, Body: "hello",
|
||||
Headers: map[string]string{
|
||||
"Content-Length": "99999",
|
||||
"Connection": "close",
|
||||
"Transfer-Encoding": "chunked",
|
||||
"X-Custom": "kept",
|
||||
},
|
||||
})
|
||||
if got := w.Header().Get("Content-Length"); got != "" {
|
||||
t.Errorf("a plugin set Content-Length (%s); the body length is the kernel's", got)
|
||||
}
|
||||
if got := w.Header().Get("X-Custom"); got != "kept" {
|
||||
t.Errorf("a normal header should survive, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A malformed declaration must not take the whole plugin down.
|
||||
func TestBadHTTPRouteDoesNotBreakPluginLoad(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const mixed = `
|
||||
local P = {}
|
||||
P.name = "mixed"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.state = { seen = true }
|
||||
P.ui = { page = { page_id = "m", title = "M", mount = "<div/>" } }
|
||||
P.http = {
|
||||
{ path = "../escape", handler = "h", roles = { "admin" } },
|
||||
{ path = "fine", handler = "h", roles = { "admin" } },
|
||||
}
|
||||
function P.h(req) return { status = 200, body = "ok" } end
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("mixed", mixed); err != nil {
|
||||
t.Fatalf("a bad route must not fail the plugin load: %v", err)
|
||||
}
|
||||
// The good route survives; the bad one is simply absent.
|
||||
hs := ps.HTTPHandlers()
|
||||
if len(hs) != 1 || hs[0].Path != "fine" {
|
||||
t.Fatalf("expected only the valid route, got %+v", hs)
|
||||
}
|
||||
// And the plugin's own page/state still work.
|
||||
if ps.State("mixed") == nil {
|
||||
t.Error("the plugin's state was lost because of a bad route")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHandlerResponseShapes(t *testing.T) {
|
||||
if _, err := parseHandlerResponse("just a string"); err == nil {
|
||||
t.Error("a non-table return must be an error, not an empty 200")
|
||||
}
|
||||
if _, err := parseHandlerResponse(map[string]interface{}{}); err == nil {
|
||||
t.Error("an empty table should say so rather than returning a blank 200")
|
||||
}
|
||||
r, err := parseHandlerResponse(map[string]interface{}{
|
||||
"status": float64(204), "headers": map[string]interface{}{"X-A": "b"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if r.Status != 204 || r.Headers["X-A"] != "b" {
|
||||
t.Errorf("decoded %+v", r)
|
||||
}
|
||||
}
|
||||
81
internal/lua/plugin_visibility.go
Normal file
81
internal/lua/plugin_visibility.go
Normal file
@ -0,0 +1,81 @@
|
||||
package lua
|
||||
|
||||
// Per-caller state scoping.
|
||||
//
|
||||
// Role visibility answers "may this role see the plugin at all". It does not
|
||||
// answer "which PART of its data", and for the billing plugin those are
|
||||
// different questions: the global figures (total spend, every source, every
|
||||
// model) are deployment data, while the per-key buckets describe one caller.
|
||||
//
|
||||
// So a plugin may declare a split, and the gateway then hands a non-admin
|
||||
// caller only the buckets that are theirs. The plugin declares it because only
|
||||
// the plugin author knows which of its buckets are global.
|
||||
|
||||
import "strings"
|
||||
|
||||
// StateScope is a plugin's declaration of which state buckets are global.
|
||||
type StateScope struct {
|
||||
// Global names the buckets that describe the whole deployment. Anything not
|
||||
// listed is treated as per-caller and filtered.
|
||||
Global []string `json:"global,omitempty"`
|
||||
// Depth gives the nesting level of each PER-CALLER bucket: 1 for
|
||||
// {maskedKey: bucket}, 2 for {day: {maskedKey: bucket}}. It is declared
|
||||
// because both shapes are maps and sniffing would confuse an empty bucket
|
||||
// with a nested one.
|
||||
Depth map[string]int `json:"depth,omitempty"`
|
||||
}
|
||||
|
||||
// IsGlobal reports whether a top-level state key is deployment-wide.
|
||||
func (s *StateScope) IsGlobal(key string) bool {
|
||||
if s == nil {
|
||||
return true
|
||||
}
|
||||
for _, g := range s.Global {
|
||||
if g == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// StateScopeFor returns a plugin's declared split, or nil when it declares none.
|
||||
func (ps *Plugins) StateScopeFor(name string) *StateScope {
|
||||
ps.mu.RLock()
|
||||
defer ps.mu.RUnlock()
|
||||
for _, p := range ps.plugins {
|
||||
if p.Info.Name != name || p.UI == nil {
|
||||
continue
|
||||
}
|
||||
return p.UI.StateScope
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compileDepthMap normalizes the declared depth table, defaulting to 1 for a
|
||||
// per-caller bucket the author did not annotate.
|
||||
func compileDepthMap(in map[string]int) map[string]int {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]int, len(in))
|
||||
for k, v := range in {
|
||||
if v < 1 {
|
||||
v = 1
|
||||
}
|
||||
out[strings.TrimSpace(k)] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// DepthOf returns the declared nesting level for a per-caller bucket, 1 by
|
||||
// default. A bucket the author did not annotate is assumed flat, which is the
|
||||
// common shape ({maskedKey: row}).
|
||||
func (s *StateScope) DepthOf(key string) int {
|
||||
if s == nil || s.Depth == nil {
|
||||
return 1
|
||||
}
|
||||
if d, ok := s.Depth[key]; ok {
|
||||
return d
|
||||
}
|
||||
return 1
|
||||
}
|
||||
193
internal/lua/plugin_visibility_test.go
Normal file
193
internal/lua/plugin_visibility_test.go
Normal file
@ -0,0 +1,193 @@
|
||||
package lua
|
||||
|
||||
// Plugin visibility: a plugin page is not automatically public data.
|
||||
//
|
||||
// The billing page renders GLOBAL cost across every source and the quota page
|
||||
// renders upstream balances. Both were served to any authenticated key because
|
||||
// /api/ui-inject applied no role filter — measured, not theorised: a user-role
|
||||
// key pulled the 18KB billing state and read the source names and the $8.86
|
||||
// total straight out of it.
|
||||
//
|
||||
// These tests pin the DENIALS. A visibility feature whose tests only assert that
|
||||
// an admin still sees everything passes while the leak is wide open.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAudienceAllows(t *testing.T) {
|
||||
cases := []struct {
|
||||
want, role string
|
||||
ok bool
|
||||
why string
|
||||
}{
|
||||
{"", "user", true, "unset means everyone"},
|
||||
{"all", "user", true, "explicit all"},
|
||||
{"admin", "admin", true, "exact match"},
|
||||
{"admin", "user", false, "admin-only excludes user"},
|
||||
{"user", "user", true, "user-only admits user"},
|
||||
{"user", "admin", false, "user-only excludes admin"},
|
||||
{"admin,user", "user", true, "list admits a listed role"},
|
||||
{" admin , user ", "user", true, "whitespace is not significant"},
|
||||
{"ADMIN", "user", false, "case is normalised"},
|
||||
{"*", "user", false, `"*" must NOT be a synonym for all`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := audienceAllows(c.want, c.role); got != c.ok {
|
||||
t.Errorf("audienceAllows(%q, %q) = %v, want %v (%s)",
|
||||
c.want, c.role, got, c.ok, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginVisibilityHidesPageFromUserRole(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
|
||||
// One admin-only plugin, one open plugin: the filter must drop exactly one
|
||||
// and leave the other intact, or a plugin author has no way to ship a mixed
|
||||
// set.
|
||||
const adminOnly = `
|
||||
local P = {}
|
||||
P.name = "adminonly"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { visibility = "admin", page = { page_id = "secret", title = "Secret", mount = "<div>secret</div>" } }
|
||||
return P
|
||||
`
|
||||
const open = `
|
||||
local P = {}
|
||||
P.name = "openpage"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
||||
return P
|
||||
`
|
||||
for name, src := range map[string]string{"adminonly": adminOnly, "openpage": open} {
|
||||
if err := ps.LoadSource(name, src); err != nil {
|
||||
t.Fatalf("load %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
admin := ps.UIForRole("admin")
|
||||
if len(admin.Pages) != 2 {
|
||||
t.Errorf("admin should see both pages, got %d", len(admin.Pages))
|
||||
}
|
||||
|
||||
user := ps.UIForRole("user")
|
||||
for _, pg := range user.Pages {
|
||||
if pg.PageID == "secret" {
|
||||
t.Fatal("a user key can see the admin-only page — this is the leak")
|
||||
}
|
||||
}
|
||||
if len(user.Pages) != 1 || user.Pages[0].PageID != "open" {
|
||||
t.Fatalf("user should see exactly the open page, got %v", pageIDs(user.Pages))
|
||||
}
|
||||
}
|
||||
|
||||
// The operator must be able to impose visibility the plugin did not choose —
|
||||
// that is the whole point of the override, since a bundled .lua cannot be edited
|
||||
// without being overwritten on the next upgrade.
|
||||
func TestPluginVisibilityOverrideBeatsDeclaration(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const open = `
|
||||
local P = {}
|
||||
P.name = "openpage"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = { page = { page_id = "open", title = "Open", mount = "<div>open</div>" } }
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("openpage", open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(ps.UIForRole("user").Pages) != 1 {
|
||||
t.Fatal("precondition: the page should start visible to user")
|
||||
}
|
||||
|
||||
ps.SetPluginVisibility("openpage", "admin")
|
||||
if got := ps.UIForRole("user").Pages; len(got) != 0 {
|
||||
t.Fatalf("the operator override was ignored: user still sees %v", pageIDs(got))
|
||||
}
|
||||
if ps.VisibilityOverride("openpage") != "admin" {
|
||||
t.Error("the override should be reportable so /api/plugins can show it")
|
||||
}
|
||||
|
||||
// Clearing restores the plugin's own declaration.
|
||||
ps.SetPluginVisibility("openpage", "")
|
||||
if got := ps.UIForRole("user").Pages; len(got) != 1 {
|
||||
t.Fatalf("clearing the override should restore visibility, got %v", pageIDs(got))
|
||||
}
|
||||
}
|
||||
|
||||
// A page may narrow visibility below the plugin default, so one plugin can ship
|
||||
// a public summary beside an admin-only detail page.
|
||||
func TestPluginPageCanNarrowBelowPluginDefault(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const mixed = `
|
||||
local P = {}
|
||||
P.name = "mixed"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.ui = {
|
||||
pages = {
|
||||
{ page_id = "summary", title = "Summary", mount = "<div>s</div>" },
|
||||
{ page_id = "details", title = "Details", mount = "<div>d</div>", audience = "admin" },
|
||||
},
|
||||
}
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("mixed", mixed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user := ps.UIForRole("user")
|
||||
if len(user.Pages) != 1 || user.Pages[0].PageID != "summary" {
|
||||
t.Fatalf("user should see only the summary, got %v", pageIDs(user.Pages))
|
||||
}
|
||||
if len(ps.UIForRole("admin").Pages) != 2 {
|
||||
t.Error("admin should see both pages")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginStateVisibleTo(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
ps := NewPlugins(NewVM(dir), dir)
|
||||
const adminOnly = `
|
||||
local P = {}
|
||||
P.name = "adminonly"
|
||||
P.version = "1.0.0"
|
||||
P.exports = {}
|
||||
P.state = { secret = "x" }
|
||||
P.ui = { visibility = "admin", page = { page_id = "s", title = "S", mount = "<div/>" } }
|
||||
return P
|
||||
`
|
||||
if err := ps.LoadSource("adminonly", adminOnly); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The data has to follow the page: a hidden UI with an open state endpoint is
|
||||
// the same leak with one more step.
|
||||
if ps.StateVisibleTo("adminonly", "user") {
|
||||
t.Error("user must not read an admin-only plugin's state")
|
||||
}
|
||||
if !ps.StateVisibleTo("adminonly", "admin") {
|
||||
t.Error("admin must still read it")
|
||||
}
|
||||
// Unknown plugin: unchanged behaviour, so a widget that is not installed does
|
||||
// not start answering 403.
|
||||
if !ps.StateVisibleTo("nosuchplugin", "user") {
|
||||
t.Error("an unknown plugin name must not become forbidden; that would change " +
|
||||
"behaviour for plugins that simply are not installed")
|
||||
}
|
||||
}
|
||||
|
||||
func pageIDs(pages []*UIPage) []string {
|
||||
out := make([]string, 0, len(pages))
|
||||
for _, p := range pages {
|
||||
if p != nil {
|
||||
out = append(out, p.PageID)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -503,6 +503,24 @@ end
|
||||
-- A whole page. The kernel injects this HTML and evaluates the <script> after
|
||||
-- the DOM exists, and exposes `pluginAPI` for talking to the gateway.
|
||||
plugin.ui = {
|
||||
-- Readable by any role, because the state is SPLIT below: a user key sees
|
||||
-- its own spend and nothing else. Marking the page admin-only would make the
|
||||
-- split pointless; marking it "all" without the split is exactly the leak this
|
||||
-- closes — a user key pulled the whole 18KB ledger (16871 requests, $8.86,
|
||||
-- every source name) straight out of GET /state. The gateway's audience
|
||||
-- override can tighten this per plugin without editing this file.
|
||||
visibility = "all",
|
||||
-- Split the state: global figures are deployment data and are withheld from
|
||||
-- non-admin callers, while the per-key buckets describe ONE caller.
|
||||
-- depth: by_key is {maskedKey: bucket}; by_day_key is {day: {maskedKey: …}}.
|
||||
state_scope = {
|
||||
global = {
|
||||
"total", "by_source", "by_model", "by_day", "by_day_src",
|
||||
"by_day_model", "by_tier_served", "skip_reasons", "degraded_reqs",
|
||||
"unpriced_models", "unpriced_reqs", "started",
|
||||
},
|
||||
depth = { by_key = 1, by_day_key = 2 },
|
||||
},
|
||||
page = {
|
||||
page_id = "billing",
|
||||
title = "Billing",
|
||||
|
||||
@ -1,368 +0,0 @@
|
||||
-- Command Code quota — multi-account plan balance for the gateway.
|
||||
--
|
||||
-- The billing plugin only ever reports what the gateway already measured, so it
|
||||
-- never has to reach out. A plan balance is different: the number only exists
|
||||
-- on the vendor's side, and the only way to learn it is to ask. Command Code
|
||||
-- exposes /alpha/billing/credits (plus whoami / subscriptions / usage) on the
|
||||
-- provider host, authenticated with the same API key the source already uses.
|
||||
--
|
||||
-- Accounts: one per configured source whose base_url is the Command Code API
|
||||
-- host. Two keys on the same host are two separate accounts, and both are
|
||||
-- shown — an operator with a spare seat needs to see both draining.
|
||||
--
|
||||
-- Every endpoint degrades independently. A vendor that retires one route must
|
||||
-- not blank the others, and a key that has run out still returns HTTP 401 on
|
||||
-- the inference path while credits reads fine, so the report has to survive
|
||||
-- partial failure and say WHICH part failed rather than rendering a zero that
|
||||
-- looks like "no quota left".
|
||||
|
||||
local PLUGIN = {}
|
||||
|
||||
PLUGIN.name = "ccquota"
|
||||
PLUGIN.version = "1.0.0"
|
||||
PLUGIN.description = "Command Code 多账户额度余量(5 小时 / 周 / 月窗口 + 余额)"
|
||||
|
||||
-- Command Code's API host. __llmsproxy_http only permits hosts that a
|
||||
-- configured source already talks to, so this works exactly when such a source
|
||||
-- exists — no separate allowlist to keep in sync, and no way to widen reach.
|
||||
local API_HOST = "api.commandcode.ai"
|
||||
|
||||
-- These headers are required alongside the bearer token. Without them the
|
||||
-- endpoints answer 401 even with a perfectly valid key, which looks identical
|
||||
-- to a revoked key and costs a lot of time to tell apart.
|
||||
--
|
||||
-- The bearer token is NOT here on purpose: it is per-account, so fetchJSON
|
||||
-- copies this table and injects it per request. An earlier version passed this
|
||||
-- table straight through and every endpoint answered 401 — which reads exactly
|
||||
-- like a revoked key, and sent me looking at the account instead of the plugin.
|
||||
local CLI_VERSION = "1.54.2"
|
||||
local REQUIRED_HEADERS = {
|
||||
["x-command-code-version"] = CLI_VERSION,
|
||||
["x-cli-environment"] = "production",
|
||||
["User-Agent"] = "commandcode-quota/" .. CLI_VERSION,
|
||||
}
|
||||
|
||||
-- Accounts are handed in from the page (which reads /api/sources and its own
|
||||
-- key store). The kernel deliberately does NOT let a plugin read the config, so
|
||||
-- this is the only channel — which also means the key never sits in Lua state
|
||||
-- or in the persisted plugin file.
|
||||
local ACCOUNTS = {}
|
||||
|
||||
-- ---------------------------------------------------------------- helpers
|
||||
|
||||
|
||||
local function tonum(v)
|
||||
return tonumber(v) or 0
|
||||
end
|
||||
|
||||
-- usd formats a dollar amount the way the vendor's panel does: two decimals,
|
||||
-- but never a bare "0" when there is a real fractional balance left. A $0.0811
|
||||
-- balance rounds to $0.08, and showing "$0" there would read as "nothing left".
|
||||
|
||||
|
||||
-- fetchJSON performs one request and returns (decodedTable, nil) or
|
||||
-- (nil, errorMessage). It NEVER raises: an escaping error inside a plugin hook
|
||||
-- can take the whole gateway down (see the hook-guard note in the kernel), so
|
||||
-- every failure has to arrive as an ordinary value.
|
||||
local function fetchJSON(path, key)
|
||||
-- Copy, then add the token: mutating the shared table would leak one
|
||||
-- account's key into every later request.
|
||||
local headers = {}
|
||||
for k, v in pairs(REQUIRED_HEADERS) do headers[k] = v end
|
||||
headers["Authorization"] = "Bearer " .. tostring(key or "")
|
||||
local res, err = __llmsproxy_http({
|
||||
url = "https://" .. API_HOST .. path,
|
||||
method = "GET",
|
||||
headers = headers,
|
||||
timeout_ms = 12000,
|
||||
})
|
||||
if res == nil then
|
||||
return nil, tostring(err or "request failed")
|
||||
end
|
||||
-- Body arrives as a JSON string; the kernel's json.decode is the only parser
|
||||
-- available and it raises on malformed input, so it is pcall'd here too.
|
||||
local ok, decoded = pcall(json.decode, res.body)
|
||||
if not ok then
|
||||
return nil, "bad JSON from " .. path
|
||||
end
|
||||
return decoded, nil
|
||||
end
|
||||
|
||||
-- authFailure recognises the one error that means "this key is not usable"
|
||||
-- rather than "this endpoint is unhappy". Quota exhaustion and a revoked key
|
||||
-- both surface as 401 here, and they need opposite operator responses.
|
||||
|
||||
-- ---------------------------------------------------------------- account report
|
||||
|
||||
-- collectAccount gathers one account's numbers. Each endpoint is independent:
|
||||
-- credits is the one that matters, and the rest enrich it. A failure is
|
||||
-- recorded per-endpoint instead of aborting, because "I could not read the
|
||||
-- subscription" and "the account has no quota" must not look the same.
|
||||
local function collectAccount(acct)
|
||||
local report = {
|
||||
name = acct.name,
|
||||
label = acct.label,
|
||||
failures = {},
|
||||
checked_at = os.time(),
|
||||
}
|
||||
|
||||
local who, werr = fetchJSON("/alpha/whoami", acct.key)
|
||||
if who == nil then
|
||||
report.failures[#report.failures + 1] = "whoami: " .. tostring(werr)
|
||||
elseif type(who) == "table" and type(who.user) == "table" then
|
||||
report.email = who.user.email
|
||||
report.user = who.user.name or who.user.userName
|
||||
end
|
||||
|
||||
local creds, cerr = fetchJSON("/alpha/billing/credits", acct.key)
|
||||
local rawStatus = 0
|
||||
if creds == nil then
|
||||
report.failures[#report.failures + 1] = "credits: " .. tostring(cerr)
|
||||
else
|
||||
local c = creds.credits or {}
|
||||
local wl = creds.windowLimits or {}
|
||||
report.monthly = tonum(c.monthlyCredits)
|
||||
report.purchased = tonum(c.purchasedCredits)
|
||||
report.free = tonum(c.freeCredits)
|
||||
report.total = report.monthly + report.purchased + report.free
|
||||
report.limited = wl.limited == true
|
||||
report.exceeded = wl.exceeded
|
||||
report.fiveHour = wl.fiveHour
|
||||
report.weekly = wl.weekly
|
||||
rawStatus = 200
|
||||
end
|
||||
|
||||
local subs, serr = fetchJSON("/alpha/billing/subscriptions", acct.key)
|
||||
if subs == nil then
|
||||
report.failures[#report.failures + 1] = "subscriptions: " .. tostring(serr)
|
||||
elseif type(subs) == "table" and type(subs.data) == "table" then
|
||||
report.plan_status = subs.data.status
|
||||
report.plan_price = subs.data.metadata and nil
|
||||
local md = subs.data.metadata
|
||||
if type(md) == "table" then
|
||||
report.plan_name = md.plan or md.name or md.tier
|
||||
end
|
||||
end
|
||||
|
||||
local usage, uerr = fetchJSON("/alpha/usage/summary", acct.key)
|
||||
if usage == nil then
|
||||
report.failures[#report.failures + 1] = "usage: " .. tostring(uerr)
|
||||
elseif type(usage) == "table" then
|
||||
report.requests = tonum(usage.totalCount)
|
||||
report.spent = tonum(usage.totalCost)
|
||||
report.tokens = tonum(usage.totalTokens)
|
||||
report.success_rate = tonum(usage.successRate)
|
||||
end
|
||||
|
||||
-- An account whose every endpoint failed is an auth problem, not a data
|
||||
-- problem: state it plainly so nobody reads "—" as "zero quota".
|
||||
if #report.failures >= 3 and report.total == nil then
|
||||
report.auth_failed = true
|
||||
end
|
||||
return report
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------- page
|
||||
|
||||
PLUGIN.ui = {
|
||||
page = {
|
||||
page_id = "ccquota",
|
||||
title = "CC Quota",
|
||||
order = 45,
|
||||
mount = [==[
|
||||
<div id="ccq-root" class="ccq-root">
|
||||
<div class="ccq-head">
|
||||
<div>
|
||||
<div class="ccq-title">Command Code 额度</div>
|
||||
<div class="ccq-sub" id="ccq-sub">点击刷新以拉取各账户余量</div>
|
||||
</div>
|
||||
<div class="grow"></div>
|
||||
<button class="small" id="ccq-refresh">刷新</button>
|
||||
<span class="ccq-auto"></span>
|
||||
</div>
|
||||
<div id="ccq-list"><div class="ccq-empty">尚未拉取</div></div>
|
||||
</div>
|
||||
<style>
|
||||
#ccq-root{padding:16px;min-width:0;max-width:100%;overflow-x:auto}
|
||||
.ccq-head{display:flex;align-items:flex-start;gap:10px;margin:0 0 12px;flex-wrap:wrap}
|
||||
.ccq-title{font-size:15px;font-weight:600}
|
||||
.ccq-sub{font-size:12px;opacity:.66;margin-top:2px}
|
||||
.ccq-auto{font-size:12px;opacity:.6}
|
||||
.ccq-acc{border:1px solid var(--bd,rgba(127,127,127,.25));border-radius:10px;padding:12px 14px;margin:0 0 12px;min-width:0}
|
||||
.ccq-acc.err{border-color:rgba(220,80,80,.5)}
|
||||
.ccq-acc-h{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:0 0 4px}
|
||||
.ccq-acc-n{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:40%}
|
||||
.ccq-mail{font-size:12px;opacity:.66;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.ccq-pill{font-size:11px;padding:1px 7px;border-radius:999px;border:1px solid rgba(127,127,127,.35);opacity:.85;white-space:nowrap}
|
||||
.ccq-pill.ok{color:#15803d;border-color:rgba(21,128,61,.45)}
|
||||
.ccq-pill.low{color:#b45309;border-color:rgba(180,83,9,.45)}
|
||||
.ccq-pill.out{color:#b91c1c;border-color:rgba(185,28,28,.45)}
|
||||
.ccq-bal{display:flex;align-items:baseline;gap:8px;margin:6px 0 10px;flex-wrap:wrap}
|
||||
.ccq-bal-n{font-size:20px;font-weight:600}
|
||||
.ccq-bal-l{font-size:12px;opacity:.66}
|
||||
.ccq-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:10px 18px}
|
||||
.ccq-win{min-width:0}
|
||||
.ccq-win-h{display:flex;justify-content:space-between;gap:6px;font-size:12px;opacity:.85}
|
||||
.ccq-win-n{font-variant-numeric:tabular-nums;font-weight:600}
|
||||
.ccq-bar{height:6px;border-radius:3px;background:rgba(127,127,127,.22);overflow:hidden;margin:4px 0 2px}
|
||||
.ccq-bar-fill{height:100%;background:#3b82f6;border-radius:3px}
|
||||
.ccq-bar-fill.warn{background:#d97706}
|
||||
.ccq-bar-fill.over{background:#dc2626}
|
||||
.ccq-win-d{font-size:11px;opacity:.6;font-variant-numeric:tabular-nums}
|
||||
.ccq-reset{opacity:.6;margin-left:4px}
|
||||
.ccq-meta{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px;opacity:.7;margin-top:10px}
|
||||
.ccq-fail{font-size:11.5px;color:#b91c1c;margin-top:8px;word-break:break-word}
|
||||
.ccq-empty{opacity:.6;font-size:13px;padding:8px 0}
|
||||
</style>
|
||||
<script>
|
||||
(function(){
|
||||
var HOST = "api.commandcode.ai";
|
||||
var lastReports = [];
|
||||
|
||||
function escH(s){
|
||||
return String(s==null?"":s).replace(/[&<>"]/g,function(c){
|
||||
return c==="&"?"&":c==="<"?"<":c===">"?">":""";
|
||||
});
|
||||
}
|
||||
function usd(v){
|
||||
v = Number(v)||0;
|
||||
if (v<=0) return "$0.00";
|
||||
return "$"+(v<0.01?v.toFixed(4):v.toFixed(2));
|
||||
}
|
||||
function stateOf(r){
|
||||
if (r.auth_failed) return {cls:"err", txt:"认证失败"};
|
||||
if (r.exceeded) return {cls:"out", txt:"已超额"};
|
||||
var t = Number(r.total)||0;
|
||||
if (t<=0) return {cls:"out", txt:"无余额"};
|
||||
if (t<5) return {cls:"low", txt:"余额偏低"};
|
||||
return {cls:"ok", txt:r.plan_status||"正常"};
|
||||
}
|
||||
function win(label,w){
|
||||
if(!w) return "";
|
||||
var used=Number(w.used)||0, cap=Number(w.cap)||0;
|
||||
var p = cap>0 ? Math.floor(used/cap*100+0.5) : null;
|
||||
if(p===null) return "";
|
||||
var over = w.exceeded===true;
|
||||
if(over) p=100;
|
||||
var cls = "ccq-bar-fill"+(over||p>=100?" over":(p>=80?" warn":""));
|
||||
var reset="";
|
||||
if(Number(w.resetAt)>0){
|
||||
var s=Math.floor(w.resetAt/1000-Date.now()/1000);
|
||||
if(s>0){
|
||||
var d=Math.floor(s/86400),h=Math.floor((s%86400)/3600),m=Math.floor((s%3600)/60);
|
||||
reset=(d>0?d+"d "+h+"h":(h>0?h+"h "+m+"m":m+"m"));
|
||||
}
|
||||
}
|
||||
return '<div class="ccq-win"><div class="ccq-win-h"><span>'+escH(label)+
|
||||
(reset?' <span class="ccq-reset">↻'+escH(reset)+'</span>':'')+
|
||||
'</span><span class="ccq-win-n">'+p+'%</span></div>'+
|
||||
'<div class="ccq-bar"><div class="'+cls+'" style="width:'+p+'%"></div></div>'+
|
||||
'<div class="ccq-win-d">'+used.toLocaleString()+' / '+cap.toLocaleString()+'</div></div>';
|
||||
}
|
||||
function card(r){
|
||||
var st = stateOf(r);
|
||||
var h = '<div class="ccq-acc'+((r.auth_failed||(r.failures&&r.failures.length===4))?" err":"")+'">'+
|
||||
'<div class="ccq-acc-h"><span class="ccq-acc-n">'+escH(r.name||"")+'</span>'+
|
||||
(r.email?'<span class="ccq-mail">'+escH(r.email)+'</span>':'')+
|
||||
'<span class="grow"></span>'+
|
||||
'<span class="ccq-pill '+st.cls+'">'+escH(st.txt)+'</span></div>'+
|
||||
'<div class="ccq-bal"><span class="ccq-bal-n">'+usd(r.total)+
|
||||
'</span><span class="ccq-bal-l">月度余额'+
|
||||
(Number(r.purchased)>0?'(含充值 $'+Number(r.purchased).toFixed(2)+')':'')+
|
||||
'</span></div>'+
|
||||
'<div class="ccq-grid">'+
|
||||
win("5 小时", r.fiveHour)+win("每周", r.weekly)+
|
||||
'</div>';
|
||||
var meta=[];
|
||||
if(r.requests) meta.push(r.requests.toLocaleString()+" 次请求");
|
||||
if(r.spent) meta.push("累计 "+usd(r.spent));
|
||||
if(r.tokens) meta.push((r.tokens/1e9).toFixed(2)+"B tokens");
|
||||
if(r.success_rate) meta.push("成功率 "+r.success_rate.toFixed(2)+"%");
|
||||
if(meta.length) h+='<div class="ccq-meta">'+meta.map(escH).join(" · ")+'</div>';
|
||||
if(r.failures&&r.failures.length){
|
||||
h+='<div class="ccq-fail">部分接口失败:'+r.failures.map(escH).join(";")+'</div>';
|
||||
}
|
||||
return h+"</div>";
|
||||
}
|
||||
function render(reports){
|
||||
lastReports = reports||[];
|
||||
var host=document.getElementById("ccq-list");
|
||||
if(!host) return;
|
||||
if(!lastReports.length){host.innerHTML='<div class="ccq-empty">没有可用的 Command Code 源</div>';return;}
|
||||
host.innerHTML=lastReports.map(card).join("");
|
||||
var sub=document.getElementById("ccq-sub");
|
||||
if(sub) sub.textContent = lastReports.length+" 个账户 · "+
|
||||
lastReports.filter(function(r){return r.auth_failed;}).length+" 个认证失败";
|
||||
}
|
||||
async function refresh(){
|
||||
var srcs;
|
||||
try{
|
||||
var r=await fetch("/api/sources",{credentials:"same-origin"});
|
||||
srcs=(await r.json()).sources||[];
|
||||
}catch(e){ render([]); return; }
|
||||
// Every source on the Command Code host is one account, keyed by its own
|
||||
// API key — two keys on that host are two seats, and both must be listed.
|
||||
var accts=srcs.filter(function(s){
|
||||
try{ return new URL(s.base_url).hostname===HOST; }catch(e){ return false; }
|
||||
});
|
||||
if(!accts.length){ render([]); return; }
|
||||
var btn=document.getElementById("ccq-refresh");
|
||||
if(btn) btn.disabled=true;
|
||||
var auto=document.getElementById("ccq-auto")||document.querySelector(".ccq-auto");
|
||||
if(auto) auto.textContent="拉取中…";
|
||||
var out=[];
|
||||
var errs=0;
|
||||
// One account at a time: the endpoints are independent, and a sequential
|
||||
// loop means a hung vendor endpoint delays only its own row instead of
|
||||
// blocking the whole page behind one promise.
|
||||
for (var i=0;i<accts.length;i++){
|
||||
var a=accts[i];
|
||||
try{
|
||||
var rep = await pluginAPI.call("ccquota","collect_account",
|
||||
{name:a.name,label:a.name,key:a.api_key});
|
||||
out.push(rep||{name:a.name,failures:["no result"]});
|
||||
}catch(e){
|
||||
errs++;
|
||||
out.push({name:a.name,failures:[String(e.message||e)],auth_failed:true});
|
||||
}
|
||||
}
|
||||
render(out);
|
||||
if(btn) btn.disabled=false;
|
||||
if(auto) auto.textContent="更新于 "+new Date().toLocaleTimeString();
|
||||
try{ await pluginAPI.postState("ccquota",{reports:out,updated_at:Date.now()}); }catch(e){}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded",function(){
|
||||
var b=document.getElementById("ccq-refresh");
|
||||
if(b) b.addEventListener("click",refresh);
|
||||
refresh();
|
||||
});
|
||||
window.__ccqRender = render;
|
||||
window.__ccqRefresh = refresh;
|
||||
})();
|
||||
</script>
|
||||
]==],
|
||||
},
|
||||
}
|
||||
|
||||
-- exports is what makes collect_account reachable from the plugin's own page.
|
||||
-- Without this list the call endpoint refuses it: the endpoint is an HTTP
|
||||
-- surface into the Lua state, so "which functions may it reach" has to be the
|
||||
-- plugin's explicit answer rather than a default.
|
||||
PLUGIN.exports = { "collect_account" }
|
||||
|
||||
-- collect_account gathers one account's report. It returns the report TABLE (not
|
||||
-- a string) so the page receives structured numbers and booleans instead of
|
||||
-- having to parse anything back out of a formatted blob.
|
||||
PLUGIN.collect_account = function(acct)
|
||||
if type(acct) ~= "table" then return nil, "expected account table" end
|
||||
if acct.key == nil or acct.key == "" then
|
||||
return { name = tostring(acct.name or ""), failures = { "missing api key" },
|
||||
auth_failed = true }
|
||||
end
|
||||
local rep = collectAccount({
|
||||
name = acct.name, label = acct.label, key = acct.key,
|
||||
})
|
||||
return rep, nil
|
||||
end
|
||||
|
||||
return PLUGIN
|
||||
@ -484,6 +484,11 @@ end
|
||||
-- ---------------------------------------------------------------- page
|
||||
|
||||
PLUGIN.ui = {
|
||||
-- Every row names an upstream host and what it has spent. That is deployment
|
||||
-- information, not per-caller information, so it is admin-only by default; an
|
||||
-- operator who wants a shared read-only view can widen it from config without
|
||||
-- editing this file.
|
||||
visibility = "admin",
|
||||
page = {
|
||||
page_id = "quota",
|
||||
title = "Quota",
|
||||
|
||||
Reference in New Issue
Block a user