mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(gateway): 插件可注册自定义对外挂载点(R3)
用户需求:插件与适配器可以注册对外服务的端口。 现状是插件端点被固定在 /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)/内核路由不可拦截。
This commit is contained in:
231
internal/gateway/mount.go
Normal file
231
internal/gateway/mount.go
Normal file
@ -0,0 +1,231 @@
|
||||
// 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
|
||||
}
|
||||
324
internal/gateway/mount_test.go
Normal file
324
internal/gateway/mount_test.go
Normal file
@ -0,0 +1,324 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/config"
|
||||
"llmsproxy/internal/core"
|
||||
)
|
||||
|
||||
// 本文件锁定 R3 的两条不变量:
|
||||
// 1. 自定义挂载只能落在允许的命名空间内
|
||||
// 2. 任何内核路由都不能被插件声明覆盖 —— 且这条在新增内核路由时**不会失效**
|
||||
|
||||
// TestValidateCustomMountNamespace 只有 /plugins/ 与 /v1/plugins/ 可被占用。
|
||||
func TestValidateCustomMountNamespace(t *testing.T) {
|
||||
ok := []string{
|
||||
"/plugins/auth/token",
|
||||
"/plugins/auth",
|
||||
"/v1/plugins/callback",
|
||||
"/v1/plugins/callback/recv",
|
||||
"/plugins/nested/deep/path",
|
||||
}
|
||||
for _, p := range ok {
|
||||
if _, err := ValidateCustomMount(p); err != nil {
|
||||
t.Errorf("合法挂载 %q 被拒: %v", p, err)
|
||||
}
|
||||
}
|
||||
bad := []string{
|
||||
"",
|
||||
"plugins/x", // 没有前导斜杠
|
||||
"/v1/chat/completions", // 影子内核路由(且不在允许命名空间)
|
||||
"/api/anything", // 内核命名空间
|
||||
"/callback", // 任意前缀
|
||||
"/plugins/../etc", // 逃逸
|
||||
"/plugins//double", // 空段
|
||||
"/v1/plugins/../../x",
|
||||
}
|
||||
for _, p := range bad {
|
||||
if _, err := ValidateCustomMount(p); err == nil {
|
||||
t.Errorf("非法挂载 %q 被接受", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomMountCannotShadowKernelRoute 逐条核对内核保留前缀。
|
||||
func TestCustomMountCannotShadowKernelRoute(t *testing.T) {
|
||||
// 这些是要保护的公开接口;任何能覆盖它们的挂载都必须被拒。
|
||||
for _, kernel := range []string{"/v1/chat/completions", "/v1/messages", "/v1/models"} {
|
||||
// 尝试用允许命名空间里的路径去"贴近"内核路由 —— 例如
|
||||
// /v1/plugins/../../v1/models(逃逸),必须被拒。
|
||||
cand := "/v1/plugins/../../" + strings.TrimPrefix(kernel, "/")
|
||||
if _, err := ValidateCustomMount(cand); err == nil {
|
||||
t.Errorf("逃逸路径 %q 应被拒", cand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestKernelRoutesAreAllCoveredByReservedPrefixes 是这道防线**不腐坏**的保证。
|
||||
//
|
||||
// 它从 server.go 里把路由 switch 的分支字符串刮出来,逐个检查是否被
|
||||
// kernelReservedPrefixes 覆盖。将来有人新增一条内核路由却忘了更新保留表时,
|
||||
// 这条测试会失败 —— 否则新路由会静默变成可被插件覆盖的。
|
||||
func TestKernelRoutesAreAllCoveredByReservedPrefixes(t *testing.T) {
|
||||
src, err := os.ReadFile("server.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 抓 switch 里的字面量路径:case r.URL.Path == "/x" / HasPrefix(..., "/x")
|
||||
re := regexp.MustCompile(`r\.URL\.Path\s*==\s*"(/[^"]*)"`)
|
||||
matches := re.FindAllStringSubmatch(string(src), -1)
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("没有从 server.go 抓到任何路由字面量 —— 测试自身失效")
|
||||
}
|
||||
covered := func(p string) bool {
|
||||
// "/" is the UI catch-all (serveUI). It is the default branch, not a
|
||||
// path a plugin could shadow, and it is covered by the namespace rule:
|
||||
// a custom mount must start with /plugins/ or /v1/plugins/, neither of
|
||||
// which is "/".
|
||||
if p == "/" {
|
||||
return true
|
||||
}
|
||||
for _, res := range kernelReservedPrefixes {
|
||||
if res == "/" {
|
||||
continue
|
||||
}
|
||||
rp := strings.TrimRight(res, "/")
|
||||
if p == rp || strings.HasPrefix(p, rp+"/") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// /api/plugins/... 由 pluginHTTPPrefix 与 /api/ 覆盖
|
||||
if strings.HasPrefix(p, "/api/") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
for _, m := range matches {
|
||||
p := m[1]
|
||||
if !covered(p) {
|
||||
t.Errorf("内核路由 %q 未被 kernelReservedPrefixes 覆盖 —— "+
|
||||
"新增路由时必须同步更新保留表,否则插件可影子它", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountConflictDetectionBothDirections 冲突检测必须双向。
|
||||
//
|
||||
// 只查一个方向会让「先注册 /plugins/x,后注册 /plugins/x/sub」或反过来时的
|
||||
// 一个被另一个静默吞掉。
|
||||
func TestMountConflictDetectionBothDirections(t *testing.T) {
|
||||
table := []RouteOwner{{Path: "/plugins/x", Owner: "plugin:a"}}
|
||||
// 被包含
|
||||
if _, conflict := FindMountConflict("/plugins/x/sub", table); !conflict {
|
||||
t.Error("嵌套在已有挂载下的路径应判冲突")
|
||||
}
|
||||
// 包含
|
||||
if _, conflict := FindMountConflict("/plugins", table); !conflict {
|
||||
t.Error("包含已有挂载的路径应判冲突")
|
||||
}
|
||||
// 相同
|
||||
if _, conflict := FindMountConflict("/plugins/x", table); !conflict {
|
||||
t.Error("完全相同的路径应判冲突")
|
||||
}
|
||||
// 无关(前缀相似但不是路径段)
|
||||
if _, conflict := FindMountConflict("/plugins/xy", table); conflict {
|
||||
t.Error("/plugins/xy 与 /plugins/x 不是同一个路径段,不该判冲突")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMountConflictNamesTheOwner 冲突信息必须点名持有者(否则不可行动)。
|
||||
func TestMountConflictNamesTheOwner(t *testing.T) {
|
||||
table := []RouteOwner{{Path: "/plugins/taken", Owner: "plugin:other"}}
|
||||
owner, conflict := FindMountConflict("/plugins/taken/deep", table)
|
||||
if !conflict {
|
||||
t.Fatal("应判冲突")
|
||||
}
|
||||
if owner.Owner != "plugin:other" {
|
||||
t.Errorf("应点名持有者 plugin:other, 实得 %q", owner.Owner)
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseMountRef 解析两种命名空间。
|
||||
func TestParseMountRef(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, owner, rest string
|
||||
ok bool
|
||||
}{
|
||||
{"/plugins/auth/token", "auth", "token", true},
|
||||
{"/plugins/auth", "auth", "", true},
|
||||
{"/v1/plugins/cb/recv", "cb", "recv", true},
|
||||
{"/v1/plugins/cb", "cb", "", true},
|
||||
{"/plugins/", "", "", false},
|
||||
{"/plugins", "", "", false},
|
||||
{"/other/x", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
owner, rest, ok := parseMountRef(c.in)
|
||||
if ok != c.ok || owner != c.owner || rest != c.rest {
|
||||
t.Errorf("parseMountRef(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||||
c.in, owner, rest, ok, c.owner, c.rest, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestBuildRouteTableIncludesKernel 路由表含内核保留项(供冲突提示用)。
|
||||
func TestBuildRouteTableIncludesKernel(t *testing.T) {
|
||||
table := BuildRouteTable(
|
||||
map[string]string{"/plugins/a": "alpha"},
|
||||
map[string]string{"/v1/plugins/b": "beta"},
|
||||
)
|
||||
var hasKernel, hasPlugin, hasAdapter bool
|
||||
for _, r := range table {
|
||||
if r.Owner == "kernel" {
|
||||
hasKernel = true
|
||||
}
|
||||
if r.Owner == "plugin:alpha" {
|
||||
hasPlugin = true
|
||||
}
|
||||
if r.Owner == "adapter:beta" {
|
||||
hasAdapter = true
|
||||
}
|
||||
}
|
||||
if !hasKernel || !hasPlugin || !hasAdapter {
|
||||
t.Errorf("路由表应含 kernel/plugin/adapter 三类: %+v", table)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPluginServesCustomMountEndToEnd 是 R3 的端到端验收:
|
||||
// 插件声明一个自定义对外挂载点,客户端按该路径访问,得到插件的应答。
|
||||
func TestPluginServesCustomMountEndToEnd(t *testing.T) {
|
||||
ts, key := messagesGateway(t)
|
||||
// messagesGateway 用的 core 没有插件目录配置,这里直接建一个带插件的。
|
||||
_ = ts
|
||||
_ = key
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := &config.Config{
|
||||
Path: filepath.Join(dir, "config.yaml"),
|
||||
AdapterDir: filepath.Join(dir, "adapters"),
|
||||
PluginDir: filepath.Join(dir, "plugins"),
|
||||
RuntimeFile: filepath.Join(dir, "runtime.json"),
|
||||
Listen: "127.0.0.1:0",
|
||||
DefaultModel: "AUTO",
|
||||
GatewayKeys: []string{"sk-mount-0001"},
|
||||
Sources: []config.Source{{
|
||||
Name: "s", BaseURL: "http://127.0.0.1:1/v1", Adapter: "openai",
|
||||
APIKey: "k", MaxConcurrent: 1,
|
||||
Models: []config.Model{{ID: "m", Kind: "chat"}},
|
||||
}},
|
||||
}
|
||||
c, err := core.NewFromConfig(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
gw, err := New(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ts2 := httptest.NewServer(gw.Handler())
|
||||
defer ts2.Close()
|
||||
|
||||
// 装一个声明自定义挂载的插件
|
||||
code := `
|
||||
local plugin = { name = "publicapi", version = "1.0" }
|
||||
plugin.http = {
|
||||
{ path = "publicapi/token", method = "GET", handler = "token",
|
||||
roles = { "admin", "user" }, description = "对外 token 端点" },
|
||||
}
|
||||
function plugin.token(req)
|
||||
return { status = 200, body = '{"token":"abc"}' }
|
||||
end
|
||||
return plugin
|
||||
`
|
||||
if err := c.Plugins().LoadSource("publicapi", code); err != nil {
|
||||
t.Fatalf("load plugin: %v", err)
|
||||
}
|
||||
|
||||
// 按自定义挂载访问
|
||||
req, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
t.Fatalf("自定义挂载 status = %d: %s", resp.StatusCode, body)
|
||||
}
|
||||
if !strings.Contains(string(body), "abc") {
|
||||
t.Errorf("自定义挂载未返回插件应答: %s", body)
|
||||
}
|
||||
|
||||
// /v1/plugins/ 形式同样可用
|
||||
req2, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
|
||||
req2.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp2.Body.Close()
|
||||
if resp2.StatusCode != 200 {
|
||||
t.Errorf("/v1/plugins/ 形式 status = %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// 未声明的命名空间 404(不能因为落在允许命名空间就放行)
|
||||
req3, _ := http.NewRequest("GET", ts2.URL+"/plugins/nobody/here", nil)
|
||||
req3.Header.Set("Authorization", "Bearer "+cfg.GatewayKeys[0])
|
||||
resp3, err := http.DefaultClient.Do(req3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp3.Body.Close()
|
||||
if resp3.StatusCode != 404 {
|
||||
t.Errorf("未注册的挂载应 404, 实得 %d", resp3.StatusCode)
|
||||
}
|
||||
|
||||
// 鉴权仍生效:无 key 时,
|
||||
// - API 路径(/v1/plugins/…)应 401(isAPIPath 判定)
|
||||
// - 浏览器路径(/plugins/…)应 302 跳登录页
|
||||
req4, _ := http.NewRequest("GET", ts2.URL+"/v1/plugins/publicapi/token", nil)
|
||||
noRedirect := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}}
|
||||
resp4, err := noRedirect.Do(req4)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp4.Body.Close()
|
||||
if resp4.StatusCode != 401 {
|
||||
t.Errorf("无 key 访问 /v1/plugins/ 应 401, 实得 %d", resp4.StatusCode)
|
||||
}
|
||||
|
||||
req5, _ := http.NewRequest("GET", ts2.URL+"/plugins/publicapi/token", nil)
|
||||
resp5, err := noRedirect.Do(req5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp5.Body.Close()
|
||||
if resp5.StatusCode != 302 {
|
||||
t.Errorf("无 key 访问 /plugins/ 应 302 跳登录, 实得 %d", resp5.StatusCode)
|
||||
}
|
||||
if loc := resp5.Header.Get("Location"); !strings.Contains(loc, "/login") {
|
||||
t.Errorf("应跳转到 /login, 实得 %q", loc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCustomMountCannotInterceptKernelRoute 端到端确认内核路由不受影响:
|
||||
// 即使有插件在用 /plugins/,/v1/chat/completions 与 /v1/models 仍归内核。
|
||||
func TestCustomMountCannotInterceptKernelRoute(t *testing.T) {
|
||||
for _, p := range []string{"/v1/chat/completions", "/v1/models", "/v1/messages", "/api/stats"} {
|
||||
if _, err := ValidateCustomMount(p); err == nil {
|
||||
t.Errorf("内核路由 %q 必须不能被声明为自定义挂载", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -252,6 +252,12 @@ func (g *Gateway) routes(w http.ResponseWriter, r *http.Request) {
|
||||
// Before the generic plugin router: these paths belong to a PLUGIN, not to
|
||||
// the plugin manager, and "http" is a valid sub-resource name otherwise.
|
||||
g.handlePluginHTTP(w, r)
|
||||
case strings.HasPrefix(r.URL.Path, "/plugins/") || strings.HasPrefix(r.URL.Path, "/v1/plugins/"):
|
||||
// Custom mounts a plugin or adapter claimed (R3). Checked AFTER every
|
||||
// kernel route so a claim can never shadow one — ValidateCustomMount
|
||||
// refuses the overlap at load time, and this ordering is the second line
|
||||
// of defence for a table that somehow slipped through.
|
||||
g.handleCustomMount(w, r)
|
||||
case r.URL.Path == "/api/plugins" || strings.HasPrefix(r.URL.Path, "/api/plugins/"):
|
||||
g.handlePluginsAPI(w, r)
|
||||
case r.URL.Path == "/api/chat":
|
||||
|
||||
Reference in New Issue
Block a user