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" "modelrouter/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