package gateway import ( "net/http" "net/http/httputil" "net/url" "strings" ) // 服务端浏览器 viewer 的反向代理(R4-f) // // 用户指明的场景:有些平台的登录会**重定向到本机回环地址**,用户机器上的浏览器 // 收不到(那个 127.0.0.1 是用户自己的),所以必须在服务端跑浏览器。而那个浏览器 // 需要给用户一个能操作的界面 —— 这就是本文件。 // // 架构:Xvfb(:N) ← chromium;x11vnc 把 :N 暴露成 VNC(:v);websockify 把 VNC 桥成 // WebSocket 并顺带伺服 noVNC 静态文件(:w)。网关把 :w 反代到 // /browser//... // // 为什么反代而不是让用户直连 :w: // // 1. **鉴权**。直连等于把一个"已经登录着用户第三方账号"的浏览器暴露在网络上, // 任何能访问该端口的人都能接着用那个会话。反代让它继承网关的登录态与角色。 // 2. **WebSocket**。noVNC 的核心是 WS 升级;httputil.ReverseProxy 从 Go 1.12 // 起原生处理 Upgrade,自己写会把这块写错。 // 3. **同源**。viewer 与网关同源,noVNC 的 WS 路径不需要跨域配置。 // browserMountPrefix 是 viewer 的挂载前缀。 const browserMountPrefix = "/browser/" // isBrowserMount 判断请求是否属于 viewer。 func isBrowserMount(p string) bool { return strings.HasPrefix(p, browserMountPrefix) } // handleBrowserMount 把 /browser//... 反代到该会话的 websockify。 // // 鉴权沿用网关的通用路径(Handler 外层已做 auth),因此只有已登录的运维能打开; // 这里额外要求 admin 角色:能操作一个已登录浏览器的人等价于能操作被登录的账号。 func (g *Gateway) handleBrowserMount(w http.ResponseWriter, r *http.Request) { if reqRole(r.Context()) != "admin" { writeError(w, http.StatusForbidden, "forbidden", "admin role required") return } rest := strings.TrimPrefix(r.URL.Path, browserMountPrefix) id := rest if i := strings.Index(rest, "/"); i >= 0 { id = rest[:i] } if id == "" { writeError(w, http.StatusNotFound, "not_found", "browser session id required") return } ps := g.core.Plugins() if ps == nil { writeError(w, http.StatusNotFound, "not_found", "no plugins loaded") return } sess, ok := ps.GetBrowserSession(id) if !ok { // An expired session is torn down on read, so "not found" is the honest // answer: the browser and its cookies are gone. writeError(w, http.StatusNotFound, "not_found", "no such browser session (it may have expired)") return } if sess.WSPort == 0 { writeError(w, http.StatusInternalServerError, "internal", "browser session has no viewer port") return } target := &url.URL{Scheme: "http", Host: "127.0.0.1:" + itoa(sess.WSPort)} proxy := httputil.NewSingleHostReverseProxy(target) // The session id is stripped from the path before proxying, so websockify // sees the SAME paths it serves locally ("/vnc.html", "/websockify"). // Without this the viewer's bundled JS would build a WS URL containing the // gateway prefix and websockify would 404 the upgrade. orig := r.URL.Path prefixLen := len(browserMountPrefix) + len(id) if prefixLen < len(orig) { r.URL.Path = orig[prefixLen:] } else { r.URL.Path = "/" } r.URL.RawPath = "" // Preserve the browser-facing Host so noVNC's relative URLs stay on the // gateway origin. r.Header.Set("X-Forwarded-Prefix", browserMountPrefix+id) // A failed upgrade is the common failure (a stale port after teardown), and // the default proxy error page says nothing useful. proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { writeError(w, http.StatusBadGateway, "browser_unavailable", "the browser viewer is not reachable: "+err.Error()) } proxy.ServeHTTP(w, r) } // itoa is a tiny integer formatter for port numbers. func itoa(n int) string { if n == 0 { return "0" } var buf [20]byte i := len(buf) for n > 0 { i-- buf[i] = byte('0' + n%10) n /= 10 } return string(buf[i:]) }