mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
用户需求:插件与适配器可以注册对外服务的端口。 现状是插件端点被固定在 /api/plugins/http/ 下。那条限制有三个理由 (可 grep / 可整体撤销 / 不能影子内核路由),放开时必须逐条补回来: - 命名空间约束:只接受 /plugins/ 与 /v1/plugins/ - 内核路由冲突检测:ValidateCustomMount + FindMountConflict(双向) - 鉴权默认关闭:沿用 HTTPHandler 的 roles/keys/public 语义 新增 internal/gateway/mount.go: - ValidateCustomMount:命名空间 + 冲突 + 逃逸校验,错误点名冲突对象 - FindMountConflict:**双向**检测(只查一边会让 /plugins/x 与 /plugins/x/sub 互相静默吞掉) - BuildRouteTable / RouteOwner:冲突提示点名持有者("plugin:other") - handleCustomMount:**复用** handlePluginHTTP 而不是重写一遍分发 —— 重写会造出两条必须保持一致的分发路径,而只在一处生效的安全规则 比没有更糟 关键防线:TestKernelRoutesAreAllCoveredByReservedPrefixes 从 server.go 刮出路由 switch 的字面量,逐个核对是否被 kernelReservedPrefixes 覆盖 —— 将来新增内核路由却忘记更新保留表时,这条测试会失败。否则新路由会静默 变成可被插件覆盖的。 测试 9 个:命名空间/影子内核路由/保留表同步/双向冲突/冲突点名/挂载解析/ 路由表构成/端到端(自定义挂载 200 + /v1/plugins 形式 + 未注册 404 + 无 key 401 或 302)/内核路由不可拦截。
232 lines
8.2 KiB
Go
232 lines
8.2 KiB
Go
// Package gateway — 对外挂载点(R3 / Phase 6)
|
||
//
|
||
// 用户需求:插件与适配器可以注册**对外服务的端口**。
|
||
//
|
||
// 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。
|
||
// 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由),
|
||
// 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰
|
||
// 需要这个(例如给上游回调用的固定路径 /v1/plugins/<name>/callback)。
|
||
//
|
||
// 本文件放开这一点,同时把当初那条限制的**三个理由**逐条补回来:
|
||
//
|
||
// 1. 可 grep / 可整体撤销 → 每条自定义挂载都必须落在 /plugins/ 或
|
||
// /v1/plugins/ 命名空间内(不接受任意前缀)
|
||
// 2. 不能影子内核路由 → 与内核保留路由表做前缀冲突检测,撞了直接拒绝
|
||
// 3. 鉴权默认关闭 → 沿用 HTTPHandler 的 roles/keys/public 语义
|
||
package gateway
|
||
|
||
import (
|
||
"fmt"
|
||
"net/http"
|
||
"sort"
|
||
"strings"
|
||
|
||
"llmsproxy/internal/lua"
|
||
)
|
||
|
||
// allowedMountPrefixes are the only namespaces a plugin/adapters may claim.
|
||
//
|
||
// A free-form prefix would let a plugin register "/v1/chat/completions" and
|
||
// silently intercept every inference request on the gateway — the exact
|
||
// shadowing the narrow default prefix was protecting against. Constraining the
|
||
// namespace keeps the security property while still allowing a stable public
|
||
// path.
|
||
var allowedMountPrefixes = []string{"/plugins/", "/v1/plugins/"}
|
||
|
||
// kernelReservedPrefixes are prefixes the kernel owns. A custom mount that
|
||
// overlaps any of them is refused, so an operator's route table can never
|
||
// change meaning because a plugin was installed.
|
||
//
|
||
// This list is deliberately explicit rather than derived from the router's
|
||
// switch: the switch is code, and a future branch added there would silently
|
||
// become shadowable if this were inferred. The test
|
||
// TestKernelRoutesAreAllCoveredByReservedPrefixes keeps the two in sync.
|
||
var kernelReservedPrefixes = []string{
|
||
"/v1/chat/completions",
|
||
"/v1/messages",
|
||
"/v1/images/generations",
|
||
"/v1/models",
|
||
"/api/",
|
||
"/login",
|
||
"/ui",
|
||
"/",
|
||
}
|
||
|
||
// ValidateCustomMount checks that a plugin-declared mount point is allowed.
|
||
//
|
||
// Returns the normalized path (leading slash, no trailing slash) or an error
|
||
// naming the conflict. The error text is what an operator sees in the plugin's
|
||
// load-error field, so it says WHICH rule was hit.
|
||
func ValidateCustomMount(p string) (string, error) {
|
||
p = strings.TrimSpace(p)
|
||
if p == "" {
|
||
return "", fmt.Errorf("empty mount path")
|
||
}
|
||
if !strings.HasPrefix(p, "/") {
|
||
return "", fmt.Errorf("mount path %q must start with /", p)
|
||
}
|
||
if strings.Contains(p, "..") || strings.Contains(p, "//") {
|
||
return "", fmt.Errorf("mount path %q contains an empty or escaping segment", p)
|
||
}
|
||
norm := strings.TrimRight(p, "/")
|
||
if norm == "" {
|
||
norm = "/"
|
||
}
|
||
|
||
// Rule 1: must live in one of the allowed namespaces.
|
||
ok := false
|
||
for _, pre := range allowedMountPrefixes {
|
||
if strings.HasPrefix(norm+"/", pre) {
|
||
ok = true
|
||
break
|
||
}
|
||
}
|
||
if !ok {
|
||
return "", fmt.Errorf("mount path %q must be under %s",
|
||
norm, strings.Join(allowedMountPrefixes, " or "))
|
||
}
|
||
|
||
// Rule 2: must not overlap a kernel route.
|
||
for _, res := range kernelReservedPrefixes {
|
||
if res == "/" {
|
||
continue // the catch-all is handled by the namespace rule above
|
||
}
|
||
if norm == strings.TrimRight(res, "/") || strings.HasPrefix(norm, res) {
|
||
return "", fmt.Errorf("mount path %q collides with the kernel route %q", norm, res)
|
||
}
|
||
}
|
||
return norm, nil
|
||
}
|
||
|
||
// RouteOwner describes one occupied route, for conflict reporting.
|
||
type RouteOwner struct {
|
||
Path string
|
||
Owner string // "kernel", "plugin:name", "adapter:name"
|
||
}
|
||
|
||
// BuildRouteTable assembles the set of paths a plugin/adapters must not take.
|
||
// It is used both to validate new mounts and to explain a conflict to an
|
||
// operator (a bare "conflict" is not actionable; "already served by plugin X" is).
|
||
func BuildRouteTable(pluginMounts, adapterMounts map[string]string) []RouteOwner {
|
||
var out []RouteOwner
|
||
for _, p := range kernelReservedPrefixes {
|
||
if p == "/" {
|
||
continue
|
||
}
|
||
out = append(out, RouteOwner{Path: strings.TrimRight(p, "/"), Owner: "kernel"})
|
||
}
|
||
for path, owner := range pluginMounts {
|
||
out = append(out, RouteOwner{Path: path, Owner: "plugin:" + owner})
|
||
}
|
||
for path, owner := range adapterMounts {
|
||
out = append(out, RouteOwner{Path: path, Owner: "adapter:" + owner})
|
||
}
|
||
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
|
||
return out
|
||
}
|
||
|
||
// FindMountConflict reports whether candidate overlaps an occupied route and, if
|
||
// so, who holds it.
|
||
//
|
||
// Overlap is checked in BOTH directions: a new mount that CONTAINS an existing
|
||
// one shadows it just as effectively as one nested inside it, and only checking
|
||
// the prefix direction would let "/plugins/x/sub" be registered before
|
||
// "/plugins/x" and then silently swallow it.
|
||
func FindMountConflict(candidate string, table []RouteOwner) (RouteOwner, bool) {
|
||
c := strings.TrimRight(candidate, "/")
|
||
for _, r := range table {
|
||
rp := strings.TrimRight(r.Path, "/")
|
||
if rp == "" {
|
||
continue
|
||
}
|
||
if c == rp || strings.HasPrefix(c, rp+"/") || strings.HasPrefix(rp, c+"/") {
|
||
return r, true
|
||
}
|
||
}
|
||
return RouteOwner{}, false
|
||
}
|
||
|
||
// handleCustomMount dispatches a request to a plugin that claimed a custom mount
|
||
// path (/plugins/<name>/… or /v1/plugins/<name>/…).
|
||
//
|
||
// It deliberately REUSES handlePluginHTTP rather than reimplementing dispatch:
|
||
// that function already does longest-match resolution, per-endpoint
|
||
// roles/keys/public authorization and the Lua call protocol. Duplicating it here
|
||
// would create two dispatch paths that must be kept in agreement — and a
|
||
// security rule that is enforced on only one of them is worse than none.
|
||
//
|
||
// The rewrite below maps a claimed mount onto the handler table's canonical
|
||
// form: handler paths are declared as "<name>/<rest>", so the request path is
|
||
// reduced to exactly that. Anything that does not reduce cleanly (a missing
|
||
// name, a name with no registered handler) is a 404, not a fallthrough — a
|
||
// custom namespace that silently answered from the default prefix would make
|
||
// the mount validation meaningless.
|
||
func (g *Gateway) handleCustomMount(w http.ResponseWriter, r *http.Request) {
|
||
owner, rest, ok := parseMountRef(r.URL.Path)
|
||
if !ok || owner == "" {
|
||
writeError(w, http.StatusNotFound, "not_found", "malformed plugin mount path")
|
||
return
|
||
}
|
||
ps := g.core.Plugins()
|
||
if ps == nil {
|
||
writeError(w, http.StatusNotFound, "not_found", "no plugins loaded")
|
||
return
|
||
}
|
||
// Resolve which handler owns this mount, so an unclaimed namespace is a 404.
|
||
handlers := ps.HTTPHandlers()
|
||
candidate := owner
|
||
if rest != "" {
|
||
candidate = owner + "/" + rest
|
||
}
|
||
if !mountClaimed(handlers, candidate) {
|
||
writeError(w, http.StatusNotFound, "not_found",
|
||
"no plugin serves "+r.URL.Path)
|
||
return
|
||
}
|
||
// Reuse the default dispatcher by presenting the path in its own shape.
|
||
r2 := r.Clone(r.Context())
|
||
r2.URL.Path = pluginHTTPPrefix + candidate
|
||
g.handlePluginHTTP(w, r2)
|
||
}
|
||
|
||
// mountClaimed reports whether any registered handler's path is a prefix of the
|
||
// candidate (longest-match semantics: /plugins/auth/token is served by a
|
||
// handler declared as "auth").
|
||
func mountClaimed(handlers []lua.HTTPHandler, candidate string) bool {
|
||
for i := range handlers {
|
||
h := strings.Trim(handlers[i].Path, "/")
|
||
if h == "" {
|
||
continue
|
||
}
|
||
if candidate == h || strings.HasPrefix(candidate, h+"/") {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// parseMountRef splits "/plugins/<name>/<rest>" or "/v1/plugins/<name>/<rest>"
|
||
// into its owner name and remaining path.
|
||
//
|
||
// The "/v1/plugins/" form is checked first because it has the longer prefix:
|
||
// testing "/plugins/" first would match nothing for a /v1/ path, but keeping the
|
||
// ordering explicit avoids a future prefix being added that nests inside
|
||
// another.
|
||
func parseMountRef(p string) (owner, rest string, ok bool) {
|
||
prefixes := []string{"/v1/plugins/", "/plugins/"}
|
||
for _, prefix := range prefixes {
|
||
if !strings.HasPrefix(p, prefix) {
|
||
continue
|
||
}
|
||
trimmed := strings.TrimPrefix(p, prefix)
|
||
if trimmed == "" {
|
||
return "", "", false
|
||
}
|
||
if i := strings.Index(trimmed, "/"); i >= 0 {
|
||
return trimmed[:i], trimmed[i+1:], true
|
||
}
|
||
return trimmed, "", true
|
||
}
|
||
return "", "", false
|
||
}
|