From 11207186c8d99e4286ad8fb8a6e3c7dee0974c05 Mon Sep 17 00:00:00 2001 From: Liu Yuan Date: Tue, 17 Mar 2026 17:36:06 +0800 Subject: [PATCH] fix: proxy WebSocket through web server port (#1665) - Modify buildWsURL to use web server port (18800) instead of gateway port (18790) - Add WebSocket proxy handler to forward /pico/ws to gateway - Gateway port is read from config (cfg.Gateway.Port), defaults to 18790 - This allows WebSocket connections through the same port as the web UI, avoiding the need to expose extra ports for Tailscale/Docker --- web/backend/api/gateway_host.go | 8 ++++++- web/backend/api/gateway_host_test.go | 16 ++++++------- web/backend/api/pico.go | 35 ++++++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 9 deletions(-) diff --git a/web/backend/api/gateway_host.go b/web/backend/api/gateway_host.go index 5ef3ba2c5..8dde29b76 100644 --- a/web/backend/api/gateway_host.go +++ b/web/backend/api/gateway_host.go @@ -80,5 +80,11 @@ func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string { if host == "" || host == "0.0.0.0" { host = requestHostName(r) } - return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(cfg.Gateway.Port)) + "/pico/ws" + // 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 + } + return requestWSScheme(r) + "://" + net.JoinHostPort(host, strconv.Itoa(wsPort)) + "/pico/ws" } diff --git a/web/backend/api/gateway_host_test.go b/web/backend/api/gateway_host_test.go index 43e84ff0e..3fffeb893 100644 --- a/web/backend/api/gateway_host_test.go +++ b/web/backend/api/gateway_host_test.go @@ -48,8 +48,8 @@ func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(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:18790/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18790/pico/ws") + if got := h.buildWsURL(req, cfg); got != "ws://192.168.1.9:18800/pico/ws" { + t.Fatalf("buildWsURL() = %q, want %q", got, "ws://192.168.1.9:18800/pico/ws") } } @@ -71,8 +71,8 @@ func TestBuildWsURLUsesWSSWhenForwardedProtoIsHTTPS(t *testing.T) { req.Host = "chat.example.com" req.Header.Set("X-Forwarded-Proto", "https") - if got := h.buildWsURL(req, cfg); got != "wss://chat.example.com:18790/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com:18790/pico/ws") + 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") } } @@ -88,8 +88,8 @@ func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) { req.Host = "secure.example.com" req.TLS = &tls.ConnectionState{} - if got := h.buildWsURL(req, cfg); got != "wss://secure.example.com:18790/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com:18790/pico/ws") + 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") } } @@ -106,7 +106,7 @@ func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) { req.TLS = &tls.ConnectionState{} req.Header.Set("X-Forwarded-Proto", "http") - if got := h.buildWsURL(req, cfg); got != "ws://chat.example.com:18790/pico/ws" { - t.Fatalf("buildWsURL() = %q, want %q", got, "ws://chat.example.com:18790/pico/ws") + 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") } } diff --git a/web/backend/api/pico.go b/web/backend/api/pico.go index 2d2201e16..d11f7bc5e 100644 --- a/web/backend/api/pico.go +++ b/web/backend/api/pico.go @@ -6,6 +6,8 @@ import ( "encoding/json" "fmt" "net/http" + "net/http/httputil" + "net/url" "time" "github.com/sipeed/picoclaw/pkg/config" @@ -16,6 +18,39 @@ func (h *Handler) registerPicoRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /api/pico/token", h.handleGetPicoToken) mux.HandleFunc("POST /api/pico/token", h.handleRegenPicoToken) mux.HandleFunc("POST /api/pico/setup", h.handlePicoSetup) + + // WebSocket proxy: forward /pico/ws to gateway + // This allows the frontend to connect via the same port as the web UI, + // avoiding the need to expose extra ports for WebSocket communication. + wsProxy := h.createWsProxy() + mux.HandleFunc("GET /pico/ws", h.handleWebSocketProxy(wsProxy)) +} + +// createWsProxy creates a reverse proxy to the gateway WebSocket endpoint. +// The gateway port is read from the configuration. +func (h *Handler) createWsProxy() *httputil.ReverseProxy { + cfg, err := config.LoadConfig(h.configPath) + gatewayPort := 18790 // default + if err == nil && cfg.Gateway.Port != 0 { + gatewayPort = cfg.Gateway.Port + } + gatewayURL, _ := url.Parse(fmt.Sprintf("http://127.0.0.1:%d", gatewayPort)) + wsProxy := httputil.NewSingleHostReverseProxy(gatewayURL) + wsProxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + http.Error(w, "Gateway unavailable: "+err.Error(), http.StatusBadGateway) + } + return wsProxy +} + +// handleWebSocketProxy wraps a reverse proxy to handle WebSocket connections. +// It ensures the Connection and Upgrade headers are properly forwarded. +func (h *Handler) handleWebSocketProxy(proxy *httputil.ReverseProxy) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + // Set headers for WebSocket upgrade + r.Header.Set("Connection", "upgrade") + r.Header.Set("Upgrade", "websocket") + proxy.ServeHTTP(w, r) + } } // handleGetPicoToken returns the current WS token and URL for the frontend.