mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
插件页面不是自动公开的数据。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)
207 lines
6.3 KiB
Go
207 lines
6.3 KiB
Go
package gateway
|
|
|
|
// Mount for plugin-registered HTTP endpoints.
|
|
//
|
|
// The gateway owns the listener, the authentication, the role check and the
|
|
// response framing; the plugin owns the logic. That split is the point: a plugin
|
|
// cannot open a socket, cannot bypass auth, and cannot choose a path outside
|
|
// /api/plugins/http/.
|
|
//
|
|
// Why this exists rather than "plugins can call each other": some things are
|
|
// worth querying from OUTSIDE the browser — a CI job asking whether the upstream
|
|
// balance is above zero, a monitoring probe, another tool on the box. billing
|
|
// never wanted this (it only receives hooks); a quota plugin does. Hard-coding a
|
|
// route per plugin would mean a gateway change for every such plugin, and an
|
|
// arbitrary plugin's code in the main mux is not something the routing table
|
|
// should ever grow.
|
|
|
|
import (
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"llmsproxy/internal/lua"
|
|
)
|
|
|
|
// pluginHTTPPrefix is the mount point. It is deliberately narrow: a plugin's
|
|
// routes live under one prefix, which makes them greppable, makes them easy to
|
|
// revoke wholesale, and means a plugin cannot shadow /api/keys or /api/stats.
|
|
const pluginHTTPPrefix = "/api/plugins/http/"
|
|
|
|
// handlePluginHTTP dispatches a request to whichever plugin registered the
|
|
// longest matching path.
|
|
//
|
|
// Longest-match rather than first-match: a plugin may register /health and
|
|
// another /health/deep, and both must work. With registration order deciding it,
|
|
// the outcome would depend on load order and change when a plugin is added.
|
|
func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
|
rest := strings.TrimPrefix(r.URL.Path, pluginHTTPPrefix)
|
|
ps := g.core.Plugins()
|
|
if ps == nil {
|
|
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
|
return
|
|
}
|
|
|
|
matches := ps.HTTPHandlers()
|
|
var chosen *lua.HTTPHandler
|
|
var params map[string]string
|
|
for i := range matches {
|
|
h := matches[i]
|
|
if p, ok := matchHandlerPath(h.Path, rest); ok {
|
|
if chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path) {
|
|
chosen, params = &h, p
|
|
}
|
|
}
|
|
}
|
|
if chosen == nil {
|
|
writeError(w, http.StatusNotFound, "not_found",
|
|
"no plugin serves this path")
|
|
return
|
|
}
|
|
|
|
if !strings.EqualFold(r.Method, chosen.Method) {
|
|
w.Header().Set("Allow", chosen.Method)
|
|
writeError(w, http.StatusMethodNotAllowed, "method_not_allowed",
|
|
"this endpoint accepts "+chosen.Method+" only")
|
|
return
|
|
}
|
|
|
|
role := reqRole(r.Context())
|
|
if !chosen.Allows(role, reqKeyName(r.Context())) {
|
|
writeError(w, http.StatusForbidden, "forbidden",
|
|
handlerAccessHint(*chosen))
|
|
return
|
|
}
|
|
|
|
body, err := readBody(r)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid_request", err.Error())
|
|
return
|
|
}
|
|
req := &lua.HandlerRequest{
|
|
Method: r.Method,
|
|
Path: rest,
|
|
Query: flattenQuery(r.URL.Query()),
|
|
Params: params,
|
|
Headers: flattenHeader(r.Header),
|
|
Body: body,
|
|
Role: role,
|
|
Key: reqKey(r.Context()),
|
|
}
|
|
resp, err := ps.InvokeHandler(handlerOwner(chosen), chosen.Handler, req)
|
|
if err != nil {
|
|
// A plugin error is a 502, not a 500: the gateway is fine, the plugin is
|
|
// not, and the operator needs to tell those apart in their logs.
|
|
writeError(w, http.StatusBadGateway, "plugin_error", err.Error())
|
|
return
|
|
}
|
|
lua.WriteHandlerResponse(w, resp)
|
|
}
|
|
|
|
// handlerAccessHint explains, in the refusal, exactly which declaration would
|
|
// open this endpoint. A 403 with no reason is the kind of thing an operator
|
|
// opens the source to work out, and then the fix is a guess.
|
|
func handlerAccessHint(h lua.HTTPHandler) string {
|
|
switch {
|
|
case len(h.Keys) > 0:
|
|
return "this endpoint allows only the named keys (" +
|
|
strings.Join(h.Keys, ", ") + "); authenticate with one of them"
|
|
case len(h.Roles) > 0:
|
|
return "this endpoint requires role " + strings.Join(h.Roles, " or ") +
|
|
" (widen it in the plugin's http declaration, or set public=true)"
|
|
default:
|
|
return "this endpoint declares no roles, so it is admin-only; " +
|
|
"set roles=… or keys=… in the plugin's http declaration"
|
|
}
|
|
}
|
|
|
|
// handlerOwner returns the plugin name that registered h. The gateway stores the
|
|
// owner alongside the handler precisely so this does not have to be guessed from
|
|
// the path prefix.
|
|
func handlerOwner(h *lua.HTTPHandler) string { return h.Plugin }
|
|
|
|
// segmentCount is how deep a route is, used for longest-match. A path with more
|
|
// segments wins, so /health/deep beats /health when both are registered.
|
|
func segmentCount(p string) int { return strings.Count(p, "/") }
|
|
|
|
// matchHandlerPath matches a route pattern against a concrete path, capturing
|
|
// ":name" segments.
|
|
//
|
|
// Returns ok=false on a segment-count mismatch or a missing value for a
|
|
// param — a route must not match with an empty capture, or two different URLs
|
|
// would reach the same handler indistinguishably.
|
|
func matchHandlerPath(pattern, path string) (map[string]string, bool) {
|
|
pp := strings.Split(strings.Trim(pattern, "/"), "/")
|
|
sp := strings.Split(strings.Trim(path, "/"), "/")
|
|
if len(pp) != len(sp) {
|
|
return nil, false
|
|
}
|
|
var params map[string]string
|
|
for i := range pp {
|
|
if strings.HasPrefix(pp[i], ":") {
|
|
name := pp[i][1:]
|
|
if sp[i] == "" {
|
|
return nil, false
|
|
}
|
|
if params == nil {
|
|
params = map[string]string{}
|
|
}
|
|
params[name] = sp[i]
|
|
continue
|
|
}
|
|
if pp[i] != sp[i] {
|
|
return nil, false
|
|
}
|
|
}
|
|
return params, true
|
|
}
|
|
|
|
func flattenQuery(q map[string][]string) map[string]string {
|
|
out := make(map[string]string, len(q))
|
|
for k, v := range q {
|
|
if len(v) > 0 {
|
|
out[k] = v[0]
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func flattenHeader(h http.Header) map[string]string {
|
|
out := make(map[string]string, len(h))
|
|
for k := range h {
|
|
out[strings.ToLower(k)] = h.Get(k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func readBody(r *http.Request) (string, error) {
|
|
if r.Body == nil {
|
|
return "", nil
|
|
}
|
|
defer r.Body.Close()
|
|
// A cap, because the handler runs inside the gateway process: an unbounded
|
|
// body from an authenticated caller is a memory exhaustion vector.
|
|
const maxBody = 4 << 20
|
|
buf := make([]byte, 0, 4096)
|
|
tmp := make([]byte, 32*1024)
|
|
for {
|
|
n, err := r.Body.Read(tmp)
|
|
if n > 0 {
|
|
if len(buf)+n > maxBody {
|
|
return "", &bodyTooLarge{}
|
|
}
|
|
buf = append(buf, tmp[:n]...)
|
|
}
|
|
if err != nil {
|
|
break
|
|
}
|
|
}
|
|
return string(buf), nil
|
|
}
|
|
|
|
type bodyTooLarge struct{}
|
|
|
|
func (bodyTooLarge) Error() string { return "request body exceeds 4 MiB" }
|
|
|
|
// limitBody is kept for the numeric form some callers want in a log line.
|
|
var _ = strconv.Itoa |