mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它 嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且 websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。 ★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里 ①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。 内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理): - plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径 - handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍 ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一) - 鉴权与角色门禁复用现有链路,无旁路 - statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败) - 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost 主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写 - 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道) - 不向插件后端转发 Authorization/Cookie/api_key channels 接入: - 新增 channels/browser/proxy/:session(prefix=true) - 会话代号 = seq-随机8位,防猜到活跃会话 - view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify - 会话过期即回收浏览器栈并返回 404 判据(每条咬真实外部效应): - 静态资源转发 - 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点) - 网关凭据不泄漏(在服务端断言请求头) - 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo) - 跨站 Origin 403 - 未鉴权 401、过期会话 404、非子路径 404 回归:go test -tags luajit ./... 9 包全绿
286 lines
9.5 KiB
Go
286 lines
9.5 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"
|
|
"sort"
|
|
"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
|
|
// contenders holds the handlers that matched at the current best depth with
|
|
// the SAME literal route pattern — i.e. two plugins that both declared this
|
|
// exact path. That is the contested case the operator must arbitrate (user
|
|
// requirement A): rather than let load order pick a silent winner, a
|
|
// contested path with no recorded decision returns 409 naming the candidates.
|
|
var contenders []lua.HTTPHandler
|
|
for i := range matches {
|
|
h := matches[i]
|
|
p, ok := matchHandlerPath(h.Path, rest)
|
|
if h.Prefix {
|
|
pattern := strings.Trim(h.Path, "/")
|
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
n := len(strings.Split(pattern, "/"))
|
|
ok = len(parts) >= n
|
|
if ok {
|
|
p, ok = matchHandlerPath(pattern, strings.Join(parts[:n], "/"))
|
|
}
|
|
}
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch {
|
|
case chosen == nil || segmentCount(h.Path) > segmentCount(chosen.Path):
|
|
chosen, params = &matches[i], p
|
|
contenders = []lua.HTTPHandler{matches[i]}
|
|
case segmentCount(h.Path) == segmentCount(chosen.Path) && h.Path == chosen.Path:
|
|
// Same route pattern, a different plugin. The winner is arbitrated below.
|
|
contenders = append(contenders, matches[i])
|
|
}
|
|
}
|
|
if chosen == nil {
|
|
writeError(w, http.StatusNotFound, "not_found",
|
|
"no plugin serves this path")
|
|
return
|
|
}
|
|
if len(contenders) > 1 {
|
|
resolved := g.resolveContestedRoute(w, ps, chosen.Path, contenders)
|
|
if resolved == nil {
|
|
return // 409 already written, naming the candidates
|
|
}
|
|
chosen = resolved
|
|
}
|
|
|
|
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
|
|
}
|
|
if resp.Proxy != "" {
|
|
if !chosen.Prefix {
|
|
writeError(w, http.StatusBadGateway, "plugin_error", "proxy response requires prefix=true")
|
|
return
|
|
}
|
|
parts := strings.Split(strings.Trim(rest, "/"), "/")
|
|
n := len(strings.Split(strings.Trim(chosen.Path, "/"), "/"))
|
|
g.servePluginProxy(w, r, resp.Proxy, pluginHTTPPrefix+strings.Join(parts[:n], "/"))
|
|
return
|
|
}
|
|
lua.WriteHandlerResponse(w, resp)
|
|
}
|
|
|
|
// resolveContestedRoute arbitrates a service path that more than one plugin
|
|
// declared (user requirement A). It returns the winning handler, or nil after
|
|
// writing a 409 when the operator has not yet chosen.
|
|
//
|
|
// This is the crux of the "report, don't crash, don't silently pick" rule: with
|
|
// two plugins both serving e.g. "stats", load order used to decide the winner.
|
|
// Now an unresolved contest is a 409 that NAMES the candidates, and the operator
|
|
// picks one in the WebUI (POST /api/plugins/conflicts). A recorded decision is
|
|
// honoured here on every request thereafter.
|
|
func (g *Gateway) resolveContestedRoute(w http.ResponseWriter, ps *lua.Plugins, path string, contenders []lua.HTTPHandler) *lua.HTTPHandler {
|
|
winner := ps.RouteWinner(path)
|
|
if winner != "" {
|
|
for i := range contenders {
|
|
if contenders[i].Plugin == winner {
|
|
return &contenders[i]
|
|
}
|
|
}
|
|
// A decision exists but points at a plugin that no longer serves this
|
|
// path (it was removed or edited). Fall through to 409 rather than honour
|
|
// a stale winner that cannot answer.
|
|
}
|
|
names := make([]string, 0, len(contenders))
|
|
for i := range contenders {
|
|
names = append(names, contenders[i].Plugin)
|
|
}
|
|
sort.Strings(names)
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
writeJSON(w, http.StatusConflict, map[string]interface{}{
|
|
"error": map[string]interface{}{
|
|
"type": "route_conflict",
|
|
"message": "service path \"" + path + "\" is claimed by " + strconv.Itoa(len(names)) + " plugins (" + strings.Join(names, ", ") + "); an operator must choose one in the admin UI (Conflicts) or POST /api/plugins/conflicts",
|
|
"path": path,
|
|
"candidates": names,
|
|
},
|
|
})
|
|
return nil
|
|
}
|
|
|
|
// 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
|