mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(plugins): 给 Lua 插件加受限出站 HTTP(__llmsproxy_http)
billing 插件从不主动出站——它报告的一切都随网关转发请求时的 hook payload
一起到达。额度插件是第一个必须自己发请求的东西:套餐余量不主动问就拿不到。
问题在于这恰好是插件模型最锋利的边。插件是 WebUI 文本框里装进来的单个
.lua 文件,跑在网关进程里:给它一个通用 HTTP 客户端,等于让每次安装都成为
一件常备的 SSFP 工具,还顺带打开了读环境变量拿凭据的第二扇门。
所以这个桥刻意做窄:
- 目标必须命中某个「已配置源」已经在通信的主机。那些是运维声明过的,
不是插件声明的——插件无法靠编一个 URL 扩大自己的触及面,而源的主机
正是本部署已经愿意交付 API key 的那批上游。
- 非 loopback 主机上拒绝明文 http。凭据只该走 HTTPS。
- loopback 免登记:它出不了本机,而 billing 规则页读网关自己的 API 正是
这么做的。
- 每请求都有超时、响应体有 4MiB 上限,插件无法占死 worker 或吃光内存。
- 绝不从环境变量读凭据。需要 key 的插件必须被显式给(state 或调用方配置),
这样授权对运维可见,而不是环境里默默存在的东西。
- 不跟随重定向:白名单是逐跳检查的,跟随就等于绕过它,3xx 原样返回。
能力只装在插件运行时(bootPluginState),不装进 setupGlobals——后者被
adapter 池共用,adapter 是运维的配置而非安装的代码。
判据 8 条钉的是拒绝路径而非快乐路径:未登记主机(含 169.254.169.254、
后缀欺骗 api.example.com.evil.test)、明文 http、空名单必须全拒、不得跟随
重定向、超时与响应体被钳制、超大响应宁可报错也不截断(截断会解析成损坏
JSON,插件会报解析错而不是真因)。
测试当场抓到一个真 bug:loopback 被白名单一起拦了,与代码注释里「loopback
免登记」自相矛盾。变异(把主机检查换成 if false)被 3 条判据抓住。
Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
(cherry picked from commit 0fbb8e5e88)
This commit is contained in:
111
internal/lua/plugin_http_args.go
Normal file
111
internal/lua/plugin_http_args.go
Normal file
@ -0,0 +1,111 @@
|
||||
package lua
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
golua "github.com/aarzilli/golua/lua"
|
||||
)
|
||||
|
||||
// readHTTPRequestSpec decodes the Lua argument table:
|
||||
//
|
||||
// __llmsproxy_http({
|
||||
// url = "https://host/path",
|
||||
// method = "GET", -- optional, default GET
|
||||
// headers = { ["X-A"] = "1" }, -- optional
|
||||
// body = "...", -- optional
|
||||
// timeout_ms = 8000, -- optional, clamped to 60s
|
||||
// })
|
||||
//
|
||||
// luaTableToGoValue already turns the table into a map/slice, so the whole
|
||||
// argument is decoded in one pass instead of walking the table field by field.
|
||||
func readHTTPRequestSpec(L *golua.State) (httpRequestSpec, error) {
|
||||
var spec httpRequestSpec
|
||||
if L.GetTop() < 1 {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: expected a table argument")
|
||||
}
|
||||
if L.Type(1) != golua.LUA_TTABLE {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: expected a table, got %s",
|
||||
normalizeString(L, 1))
|
||||
}
|
||||
arg, ok := luaTableToGoValue(L, 1).(map[string]interface{})
|
||||
if !ok {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: argument must be a keyed table")
|
||||
}
|
||||
for k, v := range arg {
|
||||
switch strings.ToLower(k) {
|
||||
case "url", "uri", "endpoint":
|
||||
spec.URL = asString(v)
|
||||
case "method", "verb":
|
||||
spec.Method = asString(v)
|
||||
case "body", "data", "payload":
|
||||
spec.Body = asString(v)
|
||||
case "headers", "header":
|
||||
if m, ok := v.(map[string]interface{}); ok {
|
||||
spec.Headers = make(map[string]string, len(m))
|
||||
for hk, hv := range m {
|
||||
spec.Headers[hk] = asString(hv)
|
||||
}
|
||||
}
|
||||
case "timeout_ms", "timeoutms":
|
||||
if n, ok := asFloat(v); ok {
|
||||
spec.Timeout = time.Duration(n) * time.Millisecond
|
||||
}
|
||||
}
|
||||
}
|
||||
if spec.URL == "" {
|
||||
return spec, fmt.Errorf("__llmsproxy_http: missing url")
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// pushHTTPResponse converts the result into two Lua values: a table
|
||||
// {status, headers, body} and nil. The caller pushes the error slot itself.
|
||||
func pushHTTPResponse(L *golua.State, r *httpResult) {
|
||||
L.NewTable()
|
||||
|
||||
L.PushString("status")
|
||||
L.PushNumber(float64(r.Status))
|
||||
L.SetTable(-3)
|
||||
|
||||
L.PushString("headers")
|
||||
pushStringMap(L, r.Headers)
|
||||
L.SetTable(-3)
|
||||
|
||||
L.PushString("body")
|
||||
L.PushString(r.Body)
|
||||
L.SetTable(-3)
|
||||
}
|
||||
|
||||
func pushStringMap(L *golua.State, m map[string]string) {
|
||||
L.NewTable()
|
||||
for k, v := range m {
|
||||
L.PushString(v)
|
||||
L.SetField(-2, k)
|
||||
}
|
||||
}
|
||||
|
||||
func asString(v interface{}) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
return t
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", t)
|
||||
}
|
||||
}
|
||||
|
||||
func asFloat(v interface{}) (float64, bool) {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t, true
|
||||
case int:
|
||||
return float64(t), true
|
||||
case int64:
|
||||
return float64(t), true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user