mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(插件代理): 同源反向代理 —— VNC 不再暴露额外端口
问题:登录返回的 view_url 是 http://127.0.0.1:60100/vnc.html,WebUI 直接把它 嵌进 iframe。远程运维打开必白屏 —— 127.0.0.1 在客户端指向客户端自己,且 websockify/x11vnc 只绑回环,换服务器 IP 也连不上(实测 LAN IP 访问 000)。 ★ 教训:只验证"服务端截图有画面"就宣布 VNC 可用是不充分的。四层链路里 ①X 渲染 ②noVNC 客户端渲染 一直是好的,白屏真因在第④层「客户端如何拿到地址」。 内核新增通用「插件前缀代理」能力(不是为 channels 写的专用反向代理): - plugin.http 支持 prefix = true:声明前缀路由,承接所有子路径 - handler 返回 { proxy = "http://127.0.0.1:PORT" },内核每次请求都问一遍 ⇒ 会话停止/过期后插件不再给目标,旧地址自然失效(撤销点唯一) - 鉴权与角色门禁复用现有链路,无旁路 - statusRecorder 增加 Unwrap(),让 Hijack 穿透访问日志中间件(否则 WebSocket 升级失败) - 目标只允许带显式端口的**字面回环 IP**(用户拍板方案 A):拒绝 https、localhost 主机名、无端口、带 userinfo/path/query;理由是主机名可能被 hosts/DNS 改写 - 拒绝跨站 Origin(WebSocket 带环境 Cookie,否则第三方网页可借管理员会话开隧道) - 不向插件后端转发 Authorization/Cookie/api_key channels 接入: - 新增 channels/browser/proxy/:session(prefix=true) - 会话代号 = seq-随机8位,防猜到活跃会话 - view_url 改为网关同源路径,noVNC 的 WebSocket 走同前缀 /websockify - 会话过期即回收浏览器栈并返回 404 判据(每条咬真实外部效应): - 静态资源转发 - 真实 HTTP 101 升级 + 双向数据回传(只断言静态转发不够 —— 升级是白屏真考点) - 网关凭据不泄漏(在服务端断言请求头) - 非法目标全拒(公网/localhost/https/无端口/带 path、query、userinfo) - 跨站 Origin 403 - 未鉴权 401、过期会话 404、非子路径 404 回归:go test -tags luajit ./... 9 包全绿
This commit is contained in:
@ -53,8 +53,8 @@ func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
|
||||
if !strings.HasPrefix(view, "/api/plugins/http/channels/browser/proxy/") || !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出同源 noVNC 内嵌地址,不能让远程客户端连服务器回环端口, 实得 %q", view)
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
if sess["cdp_port"] == nil {
|
||||
|
||||
@ -55,6 +55,15 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
}
|
||||
@ -116,6 +125,16 @@ func (g *Gateway) handlePluginHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
54
internal/gateway/plugin_proxy.go
Normal file
54
internal/gateway/plugin_proxy.go
Normal file
@ -0,0 +1,54 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// servePluginProxy forwards a plugin-owned prefix to an explicit loopback
|
||||
// origin. No destination or path may be supplied by the client. Authentication
|
||||
// and endpoint authorization have already completed before this is called.
|
||||
func (g *Gateway) servePluginProxy(w http.ResponseWriter, r *http.Request, target, prefix string) {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || u.Scheme != "http" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy must name a loopback HTTP origin")
|
||||
return
|
||||
}
|
||||
ip := net.ParseIP(u.Hostname())
|
||||
if ip == nil || !ip.IsLoopback() || u.Port() == "" {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "proxy requires a literal loopback address and port")
|
||||
return
|
||||
}
|
||||
// WebSocket requests are browser-initiated and carry ambient cookies. Do not
|
||||
// allow a foreign website to use an administrator's session to open a tunnel.
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
o, e := url.Parse(origin)
|
||||
if e != nil || !strings.EqualFold(o.Host, r.Host) {
|
||||
writeError(w, http.StatusForbidden, "forbidden", "cross-origin proxy request refused")
|
||||
return
|
||||
}
|
||||
}
|
||||
proxy := httputil.NewSingleHostReverseProxy(u)
|
||||
director := proxy.Director
|
||||
proxy.Director = func(out *http.Request) {
|
||||
director(out)
|
||||
out.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
|
||||
if out.URL.Path == "" {
|
||||
out.URL.Path = "/"
|
||||
}
|
||||
out.URL.RawPath = ""
|
||||
out.Host = u.Host
|
||||
out.Header.Del("Authorization")
|
||||
out.Header.Del("Cookie")
|
||||
q := out.URL.Query()
|
||||
q.Del("api_key")
|
||||
out.URL.RawQuery = q.Encode()
|
||||
}
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
writeError(w, http.StatusBadGateway, "plugin_proxy", "plugin service unavailable")
|
||||
}
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
66
internal/gateway/plugin_proxy_security_test.go
Normal file
66
internal/gateway/plugin_proxy_security_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginProxyRejectsUnsafeTargetsAndOrigin(t *testing.T) {
|
||||
g, _ := gatewayWithSpy(t)
|
||||
for _, target := range []string{"http://example.com:80", "http://localhost:80", "https://127.0.0.1:80", "http://127.0.0.1", "http://u:p@127.0.0.1:80", "http://127.0.0.1:80/path", "http://127.0.0.1:80/?x=1"} {
|
||||
rr := httptest.NewRecorder()
|
||||
g.servePluginProxy(rr, httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/live.js", nil), target, "/api/plugins/http/relay/s")
|
||||
if rr.Code != 502 {
|
||||
t.Errorf("unsafe target %q accepted: %d", target, rr.Code)
|
||||
}
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest("GET", "http://gateway/api/plugins/http/relay/s/websockify", nil)
|
||||
req.Header.Set("Origin", "https://evil.example")
|
||||
g.servePluginProxy(rr, req, "http://127.0.0.1:12345", "/api/plugins/http/relay/s")
|
||||
if rr.Code != 403 {
|
||||
t.Fatalf("foreign origin accepted: %d", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginPrefixProxyAuthorizationAndRevocation(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { t.Error("denied request reached backend") }))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := `local p={name="relaydeny",http={{path="relaydeny/:session",prefix=true,roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req) return {status=404,body="expired session"} end
|
||||
return p`
|
||||
if err := g.core.Plugins().LoadSource("relaydeny", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
for _, tc := range []struct {
|
||||
path, key string
|
||||
status int
|
||||
}{
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "", 401},
|
||||
{"/api/plugins/http/relaydeny/expired/vnc.html", "sk-test", 404},
|
||||
{"/api/plugins/http/relaydeny-not-a-child/expired/vnc.html", "sk-test", 404},
|
||||
} {
|
||||
req, _ := http.NewRequest("GET", server.URL+tc.path, nil)
|
||||
if tc.key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+tc.key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != tc.status {
|
||||
t.Fatalf("%s got %d %s", tc.path, resp.StatusCode, b)
|
||||
}
|
||||
if tc.key != "" && strings.Contains(tc.path, "/expired/") && strings.Contains(tc.path, "/relaydeny/") && string(b) != "expired session" {
|
||||
t.Fatalf("revocation response=%s", b)
|
||||
}
|
||||
}
|
||||
}
|
||||
82
internal/gateway/plugin_proxy_test.go
Normal file
82
internal/gateway/plugin_proxy_test.go
Normal file
@ -0,0 +1,82 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Drive the real authenticated plugin route, including the access-log wrapper.
|
||||
// A successful static request alone does not prove WebSocket can upgrade.
|
||||
func TestPluginPrefixProxyHTTPAndUpgrade(t *testing.T) {
|
||||
backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "" || r.Header.Get("Cookie") != "" || r.URL.Query().Get("api_key") != "" {
|
||||
t.Error("gateway credentials leaked to plugin backend")
|
||||
}
|
||||
if r.URL.Path == "/websockify" {
|
||||
c, b, err := w.(http.Hijacker).Hijack()
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprint(b, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n")
|
||||
b.Flush()
|
||||
io.Copy(c, c)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, r.URL.Path+"?"+r.URL.RawQuery)
|
||||
}))
|
||||
defer backend.Close()
|
||||
g, _ := gatewayWithSpy(t)
|
||||
src := fmt.Sprintf(`local p={name="relay",http={{path="relay/:session",prefix=true,method="GET",roles={"admin"},handler="proxy"}}}
|
||||
function p.proxy(req)
|
||||
if req.params.session ~= "live" then return {status=404,body="expired"} end
|
||||
return {proxy=%q}
|
||||
end
|
||||
return p`, backend.URL)
|
||||
if err := g.core.Plugins().LoadSource("relay", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(g.Handler())
|
||||
defer server.Close()
|
||||
req, _ := http.NewRequest("GET", server.URL+"/api/plugins/http/relay/live/app/main.js?x=1&api_key=sk-test", nil)
|
||||
req.Header.Set("Authorization", "Bearer sk-test")
|
||||
req.AddCookie(&http.Cookie{Name: "gw_key", Value: "sk-test"})
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != 200 || string(body) != "/app/main.js?x=1" {
|
||||
t.Fatalf("HTTP proxy: %d %s", resp.StatusCode, body)
|
||||
}
|
||||
c, err := net.Dial("tcp", strings.TrimPrefix(server.URL, "http://"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer c.Close()
|
||||
fmt.Fprintf(c, "GET /api/plugins/http/relay/live/websockify HTTP/1.1\r\nHost: %s\r\nAuthorization: Bearer sk-test\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", strings.TrimPrefix(server.URL, "http://"))
|
||||
b := bufio.NewReader(c)
|
||||
reply, err := http.ReadResponse(b, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reply.StatusCode != 101 {
|
||||
t.Fatalf("upgrade failed: %s", reply.Status)
|
||||
}
|
||||
c.Write([]byte("ping"))
|
||||
got := make([]byte, 4)
|
||||
if _, err = io.ReadFull(b, got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) != "ping" {
|
||||
t.Fatalf("tunnel payload=%q", got)
|
||||
}
|
||||
}
|
||||
@ -187,6 +187,10 @@ type statusRecorder struct {
|
||||
code int
|
||||
}
|
||||
|
||||
// Unwrap preserves optional writer capabilities (notably Hijack for WebSocket)
|
||||
// through the access-log middleware via http.ResponseController.
|
||||
func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter }
|
||||
|
||||
func (s *statusRecorder) WriteHeader(code int) {
|
||||
if s.code == 0 {
|
||||
s.code = code
|
||||
|
||||
@ -100,6 +100,8 @@ type handlerRequest struct {
|
||||
|
||||
// handlerResponse is what a Lua handler returns.
|
||||
type handlerResponse struct {
|
||||
// Proxy is a loopback HTTP origin. The gateway owns streaming and upgrades.
|
||||
Proxy string
|
||||
Status int
|
||||
Headers map[string]string
|
||||
Body string
|
||||
@ -180,6 +182,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
return nil, fmt.Errorf("status must be a number, got %T", s)
|
||||
}
|
||||
}
|
||||
if target, ok := m["proxy"].(string); ok {
|
||||
out.Proxy = target
|
||||
}
|
||||
if b, ok := m["body"].(string); ok {
|
||||
out.Body = b
|
||||
}
|
||||
@ -196,9 +201,9 @@ func parseHandlerResponse(v interface{}) (*handlerResponse, error) {
|
||||
// return. A table carrying ONLY a status is not: `{status=204}` is the normal
|
||||
// way to answer with no body, and treating it as an error would make the
|
||||
// correct spelling unusable.
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && len(m) == 0 {
|
||||
if out.Body == "" && out.JSON == nil && out.Headers == nil && out.Proxy == "" && len(m) == 0 {
|
||||
return nil, fmt.Errorf("handler returned an empty table; return " +
|
||||
"{status=…, body=…}, {status=…, json=…}, or {status=…} for no body")
|
||||
"{status=…, body=…}, {status=…, json=…}, {status=…, proxy=…}, or {status=…} for no body")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@ -2359,6 +2359,8 @@ type HTTPHandler struct {
|
||||
// Path is the route suffix under /api/plugins/http/. It may contain ":name"
|
||||
// segments, captured into request.params.
|
||||
Path string `json:"path"`
|
||||
// Prefix claims this path and its descendants for a streaming proxy handler.
|
||||
Prefix bool `json:"prefix,omitempty"`
|
||||
// Method is the HTTP method; "" or GET. Only GET/POST/PUT/DELETE/PATCH are
|
||||
// accepted — an arbitrary verb would need a switch in the router for no gain.
|
||||
Method string `json:"method,omitempty"`
|
||||
@ -2483,7 +2485,7 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
continue
|
||||
}
|
||||
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
|
||||
"roles", "keys", "public")
|
||||
"roles", "keys", "public", "prefix")
|
||||
dh := HTTPHandler{
|
||||
Path: decString(h, "path"),
|
||||
Method: decString(h, "method"),
|
||||
@ -2504,6 +2506,9 @@ func readPluginHTTP(L *golua.State) []HTTPHandler {
|
||||
}
|
||||
dh.Handler = name
|
||||
}
|
||||
if p, ok := h["prefix"].(bool); ok {
|
||||
dh.Prefix = p
|
||||
}
|
||||
if p, ok := h["public"].(bool); ok {
|
||||
dh.Public = p
|
||||
}
|
||||
|
||||
@ -4269,11 +4269,22 @@ local function spawnBrowser(start_url)
|
||||
return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why)
|
||||
end
|
||||
b.pids = pids
|
||||
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq }
|
||||
local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1"
|
||||
-- 同源代理不再需要 vnc_host;后端仍只监听回环。
|
||||
local sess = tostring(seq) .. "-" .. pass:sub(1, 8)
|
||||
b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq, sess = sess,
|
||||
expires_at = (os.time and os.time() or 0) + 900 }
|
||||
-- 代理入口:把 noVNC 视图路径经网关回传,而不是让客户端去连 VNC 端口。
|
||||
-- 客户端可能来自另一台机器(127.0.0.1 指向客户端自己)且 websockify 只绑回环,
|
||||
-- 所以只能由网关同端口转发;插件只提供回环目标与绝对路径,不处理传输。
|
||||
local base = "/api/plugins/http/channels/browser/proxy/" .. sess
|
||||
local proxyPath = base .. "/vnc.html?autoconnect=1&resize=scale&password=" .. pass
|
||||
.. "&path=" .. urlencode(base:sub(2) .. "/websockify")
|
||||
return {
|
||||
view_url = ("http://%s:%d/vnc.html?autoconnect=1&host=%s&port=%d&password=%s"):format(
|
||||
host, wport, host, wport, pass),
|
||||
view_url = proxyPath,
|
||||
proxy_path = proxyPath,
|
||||
proxy_url = base .. "/vnc.html",
|
||||
session_id = sess,
|
||||
vnc_target = ("http://127.0.0.1:%d"):format(wport),
|
||||
password = pass,
|
||||
cdp_port = cdpPort,
|
||||
display = disp,
|
||||
@ -6251,6 +6262,9 @@ plugin.http = {
|
||||
roles = { "admin" }, description = "浏览器栈依赖预检(缺什么、装哪个包)" },
|
||||
{ path = "channels/browser/status", method = "GET", handler = "browser_status",
|
||||
roles = { "admin" }, description = "服务端浏览器会话状态" },
|
||||
{ path = "channels/browser/proxy/:session", method = "GET", prefix = true,
|
||||
handler = "browser_proxy", roles = { "admin" },
|
||||
description = "把会话的 noVNC 静态资源与 WebSocket 经网关同端口转发(不暴露 VNC 端口)" },
|
||||
{ path = "channels/browser/collect", method = "POST", handler = "collect_credentials",
|
||||
roles = { "admin" }, description = "从服务端浏览器取凭据(CDP)" },
|
||||
{ path = "channels/browser/stop", method = "POST", handler = "stop_browser",
|
||||
@ -7485,6 +7499,24 @@ function plugin.browser_status(req)
|
||||
return { status = 200, json = { ok = true, running = s ~= nil, session = s } }
|
||||
end
|
||||
|
||||
-- browser_proxy:前缀代理的每次请求入口。
|
||||
-- 只回答「当前会话的回环目标」;回收后 session 为空,地址即失效(404),
|
||||
-- 未运行的会话 id 也不会被转发(防目录穿越与跨会话探测)。
|
||||
function plugin.browser_proxy(req)
|
||||
sweepSessions()
|
||||
local b = browserStack()
|
||||
local s = b.session
|
||||
local want = req and req.params and req.params.session or nil
|
||||
if s and s.expires_at and s.expires_at <= os.time() then
|
||||
reapBrowser()
|
||||
s = nil
|
||||
end
|
||||
if not s or not s.ws or not s.sess or s.sess ~= want then
|
||||
return { status = 404, json = { error = "没有这个浏览器会话" } }
|
||||
end
|
||||
return { proxy = ("http://127.0.0.1:%d"):format(s.ws) }
|
||||
end
|
||||
|
||||
function plugin.inject_platforms(req)
|
||||
local args = bodyOf(req)
|
||||
if args.platforms == nil then args = req end
|
||||
|
||||
Reference in New Issue
Block a user