fix(web): honor forwarded headers for Pico websocket URLs

Build Pico websocket URLs from the externally visible host and port so reverse-proxied WebUI deployments can connect through 443 without exposing the internal launcher port.
This commit is contained in:
9ex 2026-03-28 19:38:53 +08:00
parent 30155c1c59
commit 7906ea834f
3 changed files with 73 additions and 69 deletions

View file

@ -47,6 +47,13 @@ func gatewayProbeHost(bindHost string) string {
return bindHost
}
func firstForwardedValue(raw string) string {
if raw == "" {
return ""
}
return strings.TrimSpace(strings.Split(raw, ",")[0])
}
func (h *Handler) gatewayProxyURL() *url.URL {
cfg, err := config.LoadConfig(h.configPath)
port := 18790
@ -64,17 +71,6 @@ func (h *Handler) gatewayProxyURL() *url.URL {
}
}
func requestHostName(r *http.Request) string {
reqHost, _, err := net.SplitHostPort(r.Host)
if err == nil {
return reqHost
}
if strings.TrimSpace(r.Host) != "" {
return r.Host
}
return "127.0.0.1"
}
func requestWSScheme(r *http.Request) string {
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
@ -93,16 +89,37 @@ func requestWSScheme(r *http.Request) string {
return "ws"
}
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
host := h.effectiveGatewayBindHost(cfg)
if host == "" || host == "0.0.0.0" {
host = requestHostName(r)
func requestWSAuthority(r *http.Request) string {
scheme := requestWSScheme(r)
authority := firstForwardedValue(r.Header.Get("X-Forwarded-Host"))
if authority == "" {
authority = strings.TrimSpace(r.Host)
}
// Use web server port instead of gateway port to avoid exposing extra ports
// The WebSocket connection will be proxied by the backend to the gateway
wsPort := h.serverPort
if wsPort == 0 {
wsPort = 18800 // default web server port
if authority == "" {
return "127.0.0.1"
}
return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws"
parsed, err := url.Parse("//" + authority)
if err != nil {
return authority
}
if parsed.Port() != "" {
return authority
}
forwardedPort := firstForwardedValue(r.Header.Get("X-Forwarded-Port"))
if forwardedPort == "" {
return authority
}
if (scheme == "wss" && forwardedPort == "443") || (scheme == "ws" && forwardedPort == "80") {
return authority
}
if parsed.Hostname() == "" {
return authority
}
return net.JoinHostPort(parsed.Hostname(), forwardedPort)
}
func buildWsURL(r *http.Request) string {
return requestWSScheme(r) + "://" + requestWSAuthority(r) + "/pico/ws"
}

View file

@ -31,27 +31,11 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
}
}
func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
launcherPath := launcherconfig.PathForAppConfig(configPath)
if err := launcherconfig.Save(launcherPath, launcherconfig.Config{
Port: 18800,
Public: true,
}); err != nil {
t.Fatalf("launcherconfig.Save() error = %v", err)
}
h := NewHandler(configPath)
h.SetServerOptions(18800, false, false, nil)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "127.0.0.1"
cfg.Gateway.Port = 18790
func TestBuildWsURLUsesRequestHostWhenNoForwardedHost(t *testing.T) {
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "192.168.1.9:18800"
if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" {
if got := buildWsURL(req); got != "ws://192.168.1.9:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws")
}
}
@ -136,53 +120,56 @@ func TestGetGatewayHealthUsesProbeHostForPublicLauncher(t *testing.T) {
}
func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "chat.example.com"
req.Header.Set("X-Forwarded-Proto", "https")
if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18800/pico/ws")
if got := buildWsURL(req); got != "wss://chat.example.com/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com/pico/ws")
}
}
func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
req.Host = "secure.example.com"
req.TLS = &tls.ConnectionState{}
if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18800/pico/ws")
if got := buildWsURL(req); got != "wss://secure.example.com/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com/pico/ws")
}
}
func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
configPath := filepath.Join(t.TempDir(), "config.json")
h := NewHandler(configPath)
cfg := config.DefaultConfig()
cfg.Gateway.Host = "0.0.0.0"
cfg.Gateway.Port = 18790
req := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
req.Host = "chat.example.com"
req.TLS = &tls.ConnectionState{}
req.Header.Set("X-Forwarded-Proto", "http")
if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18800/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18800/pico/ws")
if got := buildWsURL(req); got != "ws://chat.example.com/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com/pico/ws")
}
}
func TestBuildWsURLUsesForwardedHostWithoutDefaultHTTPSPort(t *testing.T) {
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "127.0.0.1:18800"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "pico.rex.cc")
req.Header.Set("X-Forwarded-Port", "443")
if got := buildWsURL(req); got != "wss://pico.rex.cc/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://pico.rex.cc/pico/ws")
}
}
func TestBuildWsURLUsesForwardedHostWithNonDefaultPort(t *testing.T) {
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
req.Host = "127.0.0.1:18800"
req.Header.Set("X-Forwarded-Proto", "https")
req.Header.Set("X-Forwarded-Host", "pico.rex.cc")
req.Header.Set("X-Forwarded-Port", "8443")
if got := buildWsURL(req); got != "wss://pico.rex.cc:8443/pico/ws" {
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://pico.rex.cc:8443/pico/ws")
}
}

View file

@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
return
}
wsURL := h.buildWsURL(r, cfg)
wsURL := buildWsURL(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
return
}
wsURL := h.buildWsURL(r, cfg)
wsURL := buildWsURL(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
return
}
wsURL := h.buildWsURL(r, cfg)
wsURL := buildWsURL(r)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{