mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
生产实证:channels 插件的 auth/state 在主机已授权后仍被拒
(auth/state 失败: host "copilot.tencent.com" is not an allowed outbound destination),
而 /api/plugins/channels/kernel 明明显示 hosts_granted=26。
缺陷一(授权对已启动 worker 无效):
httpAllowlist 是不可变快照;bootPluginState 在 worker 启动时把指针绑进 Lua
闭包,闭包与 worker 同寿。GrantExtraHosts 之后 ps.allowHTTP 被**整个换新**
(newHTTPAllowlist 返回新实例),已启动的 worker 仍持旧指针 → 授权接口返回
ok、设置页显示已批,请求却继续被拒。分片 VM(plugin_shard.go)同样中招。
修法:allowlist 改为带 RWMutex 的**可变共享对象**,GrantExtraHosts 与
rebuildOutboundAllowlistLocked 原地 add —— 所有闭包立即看到新授权,worker
无需重建。rebuild 从"替换实例"改为"原地折叠",并在注释里写明为什么。
缺陷二(授权不持久化):
.kernel-perms.json 只写 actions;hosts 授权只存在运行态 map,重启即失。
设置页看不出异常(actions 有持久化,读起来像"都持久化了")。
修法:文件行改为 jsonPluginPolicy{actions,all_read,hosts}(旧文件向后兼容,
纯 actions 行照常解析);Load 恢复 extraHosts;Save 把每个插件的 host 授权
与 actions 一起写入(支持"只批了主机、没批动作"的行)。
回归测试(internal/lua/plugin_http_test.go):
- TestGrantExtraHostsVisibleToAlreadyBootedWorker:按事故顺序构造(先启动
worker → 再授权),断言同一批 worker 立即可用新授权 + 旧引用不再失效。
- TestHostGrantsPersistAcrossRestart:授权落盘 + 新 registry 读回 + 插件重载
后授权保留。
go test ./internal/lua/ 全绿;build/vet 通过。
272 lines
8.7 KiB
Go
272 lines
8.7 KiB
Go
package lua
|
|
|
|
// Outbound HTTP for plugins.
|
|
//
|
|
// The billing plugin never needed this: everything it reports arrives on a hook
|
|
// payload that the gateway produced while forwarding a request. A quota plugin
|
|
// is the first thing that must REACH OUT on its own — Command Code exposes
|
|
// /alpha/billing/credits and friends, and there is no way to learn a plan's
|
|
// remaining balance without asking.
|
|
//
|
|
// That capability is also the sharpest edge in the plugin model. A plugin is a
|
|
// single .lua file the WebUI installs from a textarea: untrusted, browser-facing
|
|
// code that runs in the gateway process. Handing it a general-purpose HTTP
|
|
// client would make every install a standing SSRF primitive against whatever
|
|
// the gateway can reach — the cloud metadata endpoint, the cluster control
|
|
// plane, whatever else sits on 127.0.0.1 in this deployment. Reading the
|
|
// environment for credentials is a second door out.
|
|
//
|
|
// So the bridge is deliberately narrow:
|
|
//
|
|
// - Destination must match a host that a CONFIGURED SOURCE already talks to.
|
|
// Those are operator-declared, not plugin-declared: a plugin cannot widen
|
|
// its own reach by inventing a URL, and an existing source's host is
|
|
// exactly the set of upstreams this deployment already trusts with API keys.
|
|
// - http:// on a non-loopback host is refused. Credentials belong in HTTPS.
|
|
// - Loopback needs no explicit opt-in: it is the only way a plugin can read
|
|
// the gateway's own API (the billing rules UI does exactly that), and it
|
|
// cannot leave the machine.
|
|
// - Every request is bounded by a timeout and the response is size-capped, so
|
|
// a plugin cannot wedge a worker goroutine or exhaust memory.
|
|
// - Credentials are NEVER read from the environment here. A plugin that needs
|
|
// a key must be given it (state, or the caller's own config), which keeps
|
|
// the grant visible to the operator instead of silently ambient.
|
|
//
|
|
// Redirects are NOT followed: the allowlist is checked per hop, and a redirect
|
|
// to an unlisted host would otherwise walk straight past it. A 3xx is returned
|
|
// as-is so the plugin can see it.
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
golua "github.com/aarzilli/golua/lua"
|
|
)
|
|
|
|
// httpDefaultTimeout bounds a whole request; httpMaxResponseBytes caps the body
|
|
// a plugin can pull into the gateway's heap.
|
|
const (
|
|
httpDefaultTimeout = 15 * time.Second
|
|
httpMaxTimeout = 60 * time.Second
|
|
httpMaxResponse = 4 << 20 // 4 MiB
|
|
)
|
|
|
|
// httpClient is shared by every plugin request. It deliberately has no
|
|
// ProxyFromEnvironment: an operator who proxies upstreams configures the proxy
|
|
// per source, and a plugin request should not silently inherit one.
|
|
var httpClient = &http.Client{
|
|
Timeout: httpDefaultTimeout,
|
|
Transport: &http.Transport{
|
|
Proxy: nil,
|
|
DialContext: (&net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
|
ForceAttemptHTTP2: true,
|
|
MaxIdleConns: 16,
|
|
IdleConnTimeout: 60 * time.Second,
|
|
TLSHandshakeTimeout: 10 * time.Second,
|
|
ExpectContinueTimeout: time.Second,
|
|
},
|
|
// See above: a redirect is a hop the allowlist has not seen.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
}
|
|
|
|
// httpGlobal is the Lua name of the outbound bridge.
|
|
const httpGlobal = "__llmsproxy_http"
|
|
|
|
// httpAllowlist is the set of hosts plugins may contact: the host of every
|
|
// configured source's base_url, plus every operator-granted extra host.
|
|
//
|
|
// It is a LIVE, shared object, not a snapshot. This matters: bootPluginState
|
|
// binds one *httpAllowlist into every Lua closure at state-boot time, and those
|
|
// closures live for the plugin's lifetime. Granting an extra host after a
|
|
// worker booted must therefore MUTATE the same object the closures hold —
|
|
// replacing ps.allowHTTP with a fresh instance would leave every already-booted
|
|
// worker checking the STALE list, and a grant would look accepted (the API
|
|
// answers ok, the UI shows it) while requests kept getting refused with
|
|
// "not an allowed outbound destination". That exact failure shipped once.
|
|
//
|
|
// Mutations are rare (operator approves a host); lookups are per request.
|
|
type httpAllowlist struct {
|
|
mu sync.RWMutex
|
|
hosts map[string]bool
|
|
}
|
|
|
|
func newHTTPAllowlist(hosts []string) *httpAllowlist {
|
|
a := &httpAllowlist{hosts: make(map[string]bool, len(hosts))}
|
|
a.add(hosts)
|
|
return a
|
|
}
|
|
|
|
// add merges hosts into the live allowlist. Called by rebuild (source hosts
|
|
// change on hot reload) and by GrantExtraHosts (operator approves a host).
|
|
func (a *httpAllowlist) add(hosts []string) {
|
|
a.mu.Lock()
|
|
defer a.mu.Unlock()
|
|
for _, h := range hosts {
|
|
if h != "" {
|
|
a.hosts[strings.ToLower(h)] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// allows reports whether host:port may be contacted. Ports 80/443 are implied
|
|
// by the bare host so a source configured as https://api.example.com/v1 does not
|
|
// have to spell the port.
|
|
func (a *httpAllowlist) allows(host string, port string) bool {
|
|
host = strings.ToLower(host)
|
|
if host == "" {
|
|
return false
|
|
}
|
|
a.mu.RLock()
|
|
defer a.mu.RUnlock()
|
|
if a.hosts[host] {
|
|
return true
|
|
}
|
|
switch port {
|
|
case "", "80", "443":
|
|
return a.hosts[host]
|
|
}
|
|
return a.hosts[net.JoinHostPort(host, port)]
|
|
}
|
|
|
|
// checkURL validates a plugin-supplied URL against the allowlist. It returns
|
|
// the parsed URL plus a reason when it is refused.
|
|
func (a *httpAllowlist) checkURL(raw string) (*url.URL, error) {
|
|
u, err := url.Parse(strings.TrimSpace(raw))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("bad url: %v", err)
|
|
}
|
|
switch strings.ToLower(u.Scheme) {
|
|
case "https":
|
|
case "http":
|
|
// Plain HTTP is only tolerated on loopback, where it stays on this
|
|
// machine and is how the gateway's own plain-HTTP dev port is reached.
|
|
if !isLoopbackHost(u.Hostname()) {
|
|
return nil, fmt.Errorf("refusing plain http to non-loopback host %q: use https", u.Hostname())
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("unsupported scheme %q", u.Scheme)
|
|
}
|
|
if !a.allows(u.Hostname(), u.Port()) && !isLoopbackHost(u.Hostname()) {
|
|
// Loopback needs no entry: it cannot leave the machine, and the billing
|
|
// rules UI reads the gateway's own API over plain HTTP in dev setups.
|
|
return nil, fmt.Errorf("host %q is not an allowed outbound destination "+
|
|
"(allowed: the hosts of configured sources, plus loopback)", u.Hostname())
|
|
}
|
|
return u, nil
|
|
}
|
|
|
|
func isLoopbackHost(h string) bool {
|
|
if h == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(strings.Trim(h, "[]"))
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
// httpRequestSpec is the decoded argument table Lua passes to __llmsproxy_http.
|
|
type httpRequestSpec struct {
|
|
URL string
|
|
Method string
|
|
Headers map[string]string
|
|
Body string
|
|
Timeout time.Duration
|
|
}
|
|
|
|
// luaHTTPFn builds the golua closure. It takes one Lua table argument and
|
|
// returns (responseTable, nil) or (nil, errorMessage) — never raises, so a
|
|
// plugin mistake cannot take the gateway with it.
|
|
func luaHTTPFn(L *golua.State, allow *httpAllowlist) golua.LuaGoFunction {
|
|
return func(L *golua.State) int {
|
|
spec, err := readHTTPRequestSpec(L)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
u, err := allow.checkURL(spec.URL)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
resp, err := performHTTP(u, spec)
|
|
if err != nil {
|
|
L.PushNil()
|
|
L.PushString(err.Error())
|
|
return 2
|
|
}
|
|
pushHTTPResponse(L, resp)
|
|
L.PushNil()
|
|
return 2
|
|
}
|
|
}
|
|
|
|
func performHTTP(u *url.URL, spec httpRequestSpec) (*httpResult, error) {
|
|
method := strings.ToUpper(spec.Method)
|
|
if method == "" {
|
|
method = http.MethodGet
|
|
}
|
|
timeout := spec.Timeout
|
|
if timeout <= 0 || timeout > httpMaxTimeout {
|
|
timeout = httpDefaultTimeout
|
|
}
|
|
|
|
var body io.Reader
|
|
if spec.Body != "" {
|
|
body = strings.NewReader(spec.Body)
|
|
}
|
|
req, err := http.NewRequest(method, u.String(), body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("build request: %v", err)
|
|
}
|
|
for k, v := range spec.Headers {
|
|
req.Header.Set(k, v)
|
|
}
|
|
if req.Header.Get("User-Agent") == "" {
|
|
req.Header.Set("User-Agent", "ModelRouter-plugin")
|
|
}
|
|
|
|
client := *httpClient
|
|
client.Timeout = timeout
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("request failed: %v", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
data, err := io.ReadAll(io.LimitReader(resp.Body, httpMaxResponse+1))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read body: %v", err)
|
|
}
|
|
if len(data) > httpMaxResponse {
|
|
return nil, fmt.Errorf("response exceeds %d byte cap", httpMaxResponse)
|
|
}
|
|
return &httpResult{
|
|
Status: resp.StatusCode,
|
|
Headers: flattenHeaders(resp.Header),
|
|
Body: string(data),
|
|
}, nil
|
|
}
|
|
|
|
type httpResult struct {
|
|
Status int
|
|
Headers map[string]string
|
|
Body string
|
|
}
|
|
|
|
func flattenHeaders(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
|
|
}
|