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:
parent
30155c1c59
commit
7906ea834f
3 changed files with 73 additions and 69 deletions
|
|
@ -47,6 +47,13 @@ func gatewayProbeHost(bindHost string) string {
|
||||||
return bindHost
|
return bindHost
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstForwardedValue(raw string) string {
|
||||||
|
if raw == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(strings.Split(raw, ",")[0])
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) gatewayProxyURL() *url.URL {
|
func (h *Handler) gatewayProxyURL() *url.URL {
|
||||||
cfg, err := config.LoadConfig(h.configPath)
|
cfg, err := config.LoadConfig(h.configPath)
|
||||||
port := 18790
|
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 {
|
func requestWSScheme(r *http.Request) string {
|
||||||
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
if forwarded := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto")); forwarded != "" {
|
||||||
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
|
proto := strings.ToLower(strings.TrimSpace(strings.Split(forwarded, ",")[0]))
|
||||||
|
|
@ -93,16 +89,37 @@ func requestWSScheme(r *http.Request) string {
|
||||||
return "ws"
|
return "ws"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) buildWsURL(r *http.Request, cfg *config.Config) string {
|
func requestWSAuthority(r *http.Request) string {
|
||||||
host := h.effectiveGatewayBindHost(cfg)
|
scheme := requestWSScheme(r)
|
||||||
if host == "" || host == "0.0.0.0" {
|
authority := firstForwardedValue(r.Header.Get("X-Forwarded-Host"))
|
||||||
host = requestHostName(r)
|
if authority == "" {
|
||||||
|
authority = strings.TrimSpace(r.Host)
|
||||||
}
|
}
|
||||||
// Use web server port instead of gateway port to avoid exposing extra ports
|
if authority == "" {
|
||||||
// The WebSocket connection will be proxied by the backend to the gateway
|
return "127.0.0.1"
|
||||||
wsPort := h.serverPort
|
|
||||||
if wsPort == 0 {
|
|
||||||
wsPort = 18800 // default web server port
|
|
||||||
}
|
}
|
||||||
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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,27 +31,11 @@ func TestGatewayHostOverrideUsesExplicitRuntimePublic(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildWsURLUsesRequestHostWhenLauncherPublicSaved(t *testing.T) {
|
func TestBuildWsURLUsesRequestHostWhenNoForwardedHost(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
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
req := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
||||||
req.Host = "192.168.1.9:18800"
|
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")
|
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) {
|
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 := httptest.NewRequest("GET", "http://launcher.local/api/pico/token", nil)
|
||||||
req.Host = "chat.example.com"
|
req.Host = "chat.example.com"
|
||||||
req.Header.Set("X-Forwarded-Proto", "https")
|
req.Header.Set("X-Forwarded-Proto", "https")
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); 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:18800/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://chat.example.com/pico/ws")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildWsURLUsesWSSWhenRequestIsTLS(t *testing.T) {
|
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 := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
|
||||||
req.Host = "secure.example.com"
|
req.Host = "secure.example.com"
|
||||||
req.TLS = &tls.ConnectionState{}
|
req.TLS = &tls.ConnectionState{}
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); 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:18800/pico/ws")
|
t.Fatalf("buildWsURL() = %q, want %q", got, "wss://secure.example.com/pico/ws")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildWsURLPrefersForwardedHTTPOverTLS(t *testing.T) {
|
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 := httptest.NewRequest("GET", "https://launcher.local/api/pico/token", nil)
|
||||||
req.Host = "chat.example.com"
|
req.Host = "chat.example.com"
|
||||||
req.TLS = &tls.ConnectionState{}
|
req.TLS = &tls.ConnectionState{}
|
||||||
req.Header.Set("X-Forwarded-Proto", "http")
|
req.Header.Set("X-Forwarded-Proto", "http")
|
||||||
|
|
||||||
if got := h.buildWsURL(req, cfg); 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:18800/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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ func (h *Handler) handleGetPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := h.buildWsURL(r, cfg)
|
wsURL := buildWsURL(r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|
@ -81,7 +81,7 @@ func (h *Handler) handleRegenPicoToken(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := h.buildWsURL(r, cfg)
|
wsURL := buildWsURL(r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|
@ -146,7 +146,7 @@ func (h *Handler) handlePicoSetup(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
wsURL := h.buildWsURL(r, cfg)
|
wsURL := buildWsURL(r)
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
json.NewEncoder(w).Encode(map[string]any{
|
json.NewEncoder(w).Encode(map[string]any{
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue