Files
ModelRouter/internal/gateway/mount_test.go
JianFeeeee 24e00cdc50 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)/内核路由不可拦截。
2026-10-11 10:47:17 +08:00

325 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}
}