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
This commit is contained in:
Liu Yuan
2026-03-17 17:36:06 +08:00
committed by GitHub
parent 8a8cc35645
commit 11207186c8
3 changed files with 50 additions and 9 deletions
+7 -1
View File
@@ -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"
}
+8 -8
View File
@@ -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")
}
}
+35
View File
@@ -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.