// Package gateway — 对外挂载点(R3 / Phase 6) // // 用户需求:插件与适配器可以注册**对外服务的端口**。 // // 现状:插件已经能注册 HTTP 端点,但被固定在 /api/plugins/http/ 前缀下。 // 那条前缀限制是有理由的(可 grep、可整体撤销、插件无法影子内核路由), // 但它也意味着插件无法提供"看起来像内核一部分"的接口——而对外服务恰恰 // 需要这个(例如给上游回调用的固定路径 /v1/plugins//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//… or /v1/plugins//…). // // 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 "/", 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//" or "/v1/plugins//" // 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 }