From cde6e8261ecf074d7b710ee8e726df5bd8d12223 Mon Sep 17 00:00:00 2001 From: UNITRONIX <36471318+UNITRONIX@users.noreply.github.com> Date: Sat, 18 Jul 2026 11:54:09 +0200 Subject: [PATCH] fix(signal): fix WSS session keys and async delivery behind reverse proxy (Refs #276) Parse X-Forwarded-For/X-Real-IP without synthesizing :0, gate on TRUST_PROXY, and forward PunchHole/RelayResponse to WebSocket initiators. --- CHANGELOG.md | 3 + README.md | 2 +- betterdesk-server/peer/map.go | 23 +++- betterdesk-server/peer/map_test.go | 29 +++++ betterdesk-server/signal/handler.go | 22 +++- betterdesk-server/signal/server.go | 43 ++++++- betterdesk-server/signal/ws.go | 95 ++++++++++---- betterdesk-server/signal/ws_test.go | 191 ++++++++++++++++++++++++++-- docs/setup/HTTPS_SETUP.md | 3 + docs/setup/REVERSE_PROXY.md | 4 +- 10 files changed, 362 insertions(+), 53 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21bde2ad..9de9b50a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ### Added - **RustDesk client login → device owner (#270):** successful client login maps the device (`peers.user`) to the BetterDesk account for inventory/audit (shared logins, credential misuse). Does **not** block remote connections. +### Fixed +- **WebSocket mode behind Nginx (#276):** signal WSS no longer builds session keys as `IP:0` / `[IP:port]:0` from `X-Real-IP` / `X-Forwarded-For`. Forwarded addresses are parsed correctly when `TRUST_PROXY=Y`, and async PunchHole/RelayResponse delivery reaches WebSocket initiators (not only TCP punch connections). + ### Changed - _(none yet)_ diff --git a/README.md b/README.md index 961ebe00..060ec206 100644 --- a/README.md +++ b/README.md @@ -988,7 +988,7 @@ You can **upgrade to Let's Encrypt** or a custom certificate at any time using m | `-init-admin-pass` | *(auto)* | `INIT_ADMIN_PASS` | Initial admin password (auto-generated if omitted) | | `-version` | — | — | Show version and exit | -> Signal proxy note: UDP/TCP signal traffic on port `21116` cannot use HTTP headers such as `X-Forwarded-For`. `TRUST_PROXY` only affects HTTP/API traffic. For NGINX stream or Docker proxy deployments, set `SIGNAL_RATE_LIMIT_PER_IP` higher for very large fleets, or `0` only on trusted private networks. Current builds scope registration buckets by proxy/client address plus peer ID to avoid false positives when multiple devices share one proxy address. +> Signal proxy note: UDP/TCP signal traffic on port `21116` cannot use HTTP headers such as `X-Forwarded-For`. `TRUST_PROXY` applies to HTTP/API traffic and to signal **WebSocket** (`/ws/id`) client address headers. For NGINX stream or Docker proxy deployments, set `SIGNAL_RATE_LIMIT_PER_IP` higher for very large fleets, or `0` only on trusted private networks. Current builds scope registration buckets by proxy/client address plus peer ID to avoid false positives when multiple devices share one proxy address. ### Environment-Only Variables diff --git a/betterdesk-server/peer/map.go b/betterdesk-server/peer/map.go index 7e29e899..5bbe5a54 100644 --- a/betterdesk-server/peer/map.go +++ b/betterdesk-server/peer/map.go @@ -500,11 +500,15 @@ func (m *Map) ForEach(fn func(e *Entry)) { } } -// FindByIP returns the first peer whose UDPAddr has the given IP. -// This is used to forward messages to a peer when we only know their public IP -// (e.g., from a decoded socket_addr in RelayResponse). If multiple peers share +// FindByIP returns the first peer whose public IP matches. +// Prefers peers with a UDPAddr; otherwise matches the host portion of entry.IP +// (WebSocket/TCP peers store "ip:port" without UDPAddr). Used when forwarding +// PunchHole/RelayResponse from a decoded socket_addr. If multiple peers share // the same IP (behind NAT), only the first match is returned. func (m *Map) FindByIP(ip net.IP) *Entry { + if ip == nil { + return nil + } m.mu.RLock() defer m.mu.RUnlock() for _, e := range m.entries { @@ -512,5 +516,18 @@ func (m *Map) FindByIP(ip net.IP) *Entry { return e } } + // Second pass: WS/TCP peers keyed by IP string only. + for _, e := range m.entries { + if e.UDPAddr != nil || e.IP == "" { + continue + } + host, _, err := net.SplitHostPort(e.IP) + if err != nil { + host = e.IP + } + if parsed := net.ParseIP(host); parsed != nil && parsed.Equal(ip) { + return e + } + } return nil } diff --git a/betterdesk-server/peer/map_test.go b/betterdesk-server/peer/map_test.go index 7216fc86..4f728ebe 100644 --- a/betterdesk-server/peer/map_test.go +++ b/betterdesk-server/peer/map_test.go @@ -455,3 +455,32 @@ func TestConnTypeString(t *testing.T) { } } } + +func TestFindByIPMatchesWSPeerIPString(t *testing.T) { + m := NewMap() + m.Put(&Entry{ + ID: "WS1", + IP: "203.0.113.50:50123", + ConnType: ConnWS, + LastReg: time.Now(), + }) + m.Put(&Entry{ + ID: "UDP1", + IP: "198.51.100.1:21116", + UDPAddr: &net.UDPAddr{IP: net.ParseIP("198.51.100.1"), Port: 21116}, + ConnType: ConnUDP, + LastReg: time.Now(), + }) + + got := m.FindByIP(net.ParseIP("203.0.113.50")) + if got == nil || got.ID != "WS1" { + t.Fatalf("FindByIP WS peer = %+v, want WS1", got) + } + got = m.FindByIP(net.ParseIP("198.51.100.1")) + if got == nil || got.ID != "UDP1" { + t.Fatalf("FindByIP UDP peer = %+v, want UDP1", got) + } + if m.FindByIP(nil) != nil { + t.Fatal("FindByIP(nil) should be nil") + } +} diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index e32d4107..cfe11ba0 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -744,6 +744,10 @@ func (s *Server) handlePunchHoleRequest(msg *pb.PunchHoleRequest, raddr *net.UDP // they arrive later — this provides an update but is no longer required for the // initiator to proceed. func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net.UDPAddr) *pb.RendezvousMessage { + if raddr == nil { + log.Printf("[signal] PunchHoleRequest (TCP): nil address, ignoring") + return nil + } targetID := msg.Id if targetID == "" { return nil @@ -906,7 +910,7 @@ func (s *Server) handlePunchHoleRequestTCP(msg *pb.PunchHoleRequest, raddr *net. s.schedulePunchFallback(initiatorKey, func() { log.Printf("[signal] P2P-first (TCP): target %s did not complete hole punch in time, forwarding relay fallback to %s", targetID, initiatorKey) - s.forwardToTCPInitiator(initiatorKey, resp) + s.forwardToInitiator(initiatorKey, resp) }) return nil } @@ -1019,9 +1023,9 @@ func (s *Server) handlePunchHoleSent(phs *pb.PunchHoleSent, senderAddr *net.UDPA log.Printf("[signal] P2P-first: cancelled relay fallback for %s — direct P2P response incoming", addrStr) } - // Try TCP delivery first (initiator may have an open TCP connection). - if s.forwardToTCPInitiator(addrStr, resp) { - log.Printf("[signal] PunchHoleResponse forwarded via TCP to %s (target=%s)", addrStr, phs.Id) + // Try TCP then WebSocket delivery (initiator may be on either transport). + if s.forwardToInitiator(addrStr, resp) { + log.Printf("[signal] PunchHoleResponse forwarded to %s (target=%s)", addrStr, phs.Id) return } @@ -1189,6 +1193,10 @@ func (s *Server) handleRequestRelay(msg *pb.RequestRelay, raddr *net.UDPAddr) { // Previous behavior (sending nothing back and waiting for the target's // RelayResponse) caused timeouts for TCP signaling clients (e.g. logged-in users). func (s *Server) handleRequestRelayTCP(msg *pb.RequestRelay, raddr *net.UDPAddr) *pb.RendezvousMessage { + if raddr == nil { + log.Printf("[signal] RequestRelay (TCP): nil address, ignoring") + return nil + } targetID := msg.Id // Generate UUID if the client sent an empty one (see handleRequestRelay comment). @@ -1395,9 +1403,9 @@ func (s *Server) handleRelayResponseForward(msg *pb.RendezvousMessage, senderAdd }, } - // Primary delivery: TCP forwarding via tcpPunchConns. - if s.forwardToTCPInitiator(addrStr, initiatorResp) { - log.Printf("[signal] RelayResponse forwarded via TCP to %s (uuid=%s, relay=%s, signedPk=%d bytes)", addrStr, rr.Uuid, relayServer, len(signedPk)) + // Primary delivery: TCP punch map or WebSocket peer (#276). + if s.forwardToInitiator(addrStr, initiatorResp) { + log.Printf("[signal] RelayResponse forwarded to %s (uuid=%s, relay=%s, signedPk=%d bytes)", addrStr, rr.Uuid, relayServer, len(signedPk)) return } diff --git a/betterdesk-server/signal/server.go b/betterdesk-server/signal/server.go index ada35c1b..458d8045 100644 --- a/betterdesk-server/signal/server.go +++ b/betterdesk-server/signal/server.go @@ -644,7 +644,6 @@ func (s *Server) forwardToTCPInitiator(initiatorAddr string, msg *pb.RendezvousM normAddr := normalizeAddrKey(initiatorAddr) val, ok := s.tcpPunchConns.Load(normAddr) if !ok { - log.Printf("[signal] TCP forwarding: no conn found for key %q (raw=%q)", normAddr, initiatorAddr) return false } pc := val.(*tcpPunchConn) @@ -655,6 +654,48 @@ func (s *Server) forwardToTCPInitiator(initiatorAddr string, msg *pb.RendezvousM return true } +// forwardToInitiator delivers an async punch/relay message to the initiator +// over TCP (tcpPunchConns) or WebSocket (peer map). Required for WSS clients +// behind reverse proxies which are never registered in tcpPunchConns (#276). +func (s *Server) forwardToInitiator(initiatorAddr string, msg *pb.RendezvousMessage) bool { + if s.forwardToTCPInitiator(initiatorAddr, msg) { + return true + } + if s.forwardToWSInitiator(initiatorAddr, msg) { + return true + } + log.Printf("[signal] initiator forwarding: no TCP/WS conn for key %q (raw=%q)", + normalizeAddrKey(initiatorAddr), initiatorAddr) + return false +} + +// forwardToWSInitiator looks up a WebSocket peer by the public IP in +// initiatorAddr and writes msg on its bound WSConn. +func (s *Server) forwardToWSInitiator(initiatorAddr string, msg *pb.RendezvousMessage) bool { + normAddr := normalizeAddrKey(initiatorAddr) + host, _, err := net.SplitHostPort(normAddr) + if err != nil { + host = normAddr + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + entry := s.peers.FindByIP(ip) + if entry == nil || entry.ConnType != peer.ConnWS || entry.WSConn == nil { + return false + } + wsc, ok := entry.WSConn.(*codec.WSConn) + if !ok { + return false + } + if err := wsc.WriteMessage(msg); err != nil { + log.Printf("[signal] WS forward write to peer %s (addr=%s): %v", entry.ID, initiatorAddr, err) + return false + } + return true +} + // serveNAT accepts TCP connections on the NAT test port (21115). // Handles TestNatRequest and OnlineRequest. func (s *Server) serveNAT() { diff --git a/betterdesk-server/signal/ws.go b/betterdesk-server/signal/ws.go index 37668c2e..4f08067e 100644 --- a/betterdesk-server/signal/ws.go +++ b/betterdesk-server/signal/ws.go @@ -83,7 +83,7 @@ func (s *Server) handleWSUpgrade(w http.ResponseWriter, r *http.Request) { log.Printf("[signal] WS upgrade error: %v", err) return } - remoteAddr := wsEffectiveRemoteAddr(r) + remoteAddr := wsEffectiveRemoteAddr(r, s.cfg.TrustProxy) log.Printf("[signal] WS upgrade remote=%s effective=%s path=%s origin=%q ua=%q xff=%q xri=%q", r.RemoteAddr, remoteAddr, r.URL.Path, @@ -100,22 +100,49 @@ func (s *Server) handleWSUpgrade(w http.ResponseWriter, r *http.Request) { } // wsEffectiveRemoteAddr returns the client address for WS signal registration. -// When behind a reverse proxy, prefer X-Real-IP then the first X-Forwarded-For -// hop (same behaviour as rustdesk-server WS upgrade). -func wsEffectiveRemoteAddr(r *http.Request) string { - clientIP := strings.TrimSpace(r.Header.Get("X-Real-IP")) - if clientIP == "" { +// When TrustProxy is enabled, prefer X-Real-IP then the first X-Forwarded-For +// hop. Forwarded values are parsed with net.SplitHostPort / net.ParseIP so +// IP-only headers keep the proxy connection port (never synthesise :0) and +// IP:port headers are not double-wrapped into malformed [IP:port]:0 keys +// (issue #276). +func wsEffectiveRemoteAddr(r *http.Request, trustProxy bool) string { + if !trustProxy { + return r.RemoteAddr + } + fwd := strings.TrimSpace(r.Header.Get("X-Real-IP")) + if fwd == "" { if xff := r.Header.Get("X-Forwarded-For"); xff != "" { - clientIP = strings.TrimSpace(strings.SplitN(xff, ",", 2)[0]) + fwd = strings.TrimSpace(strings.SplitN(xff, ",", 2)[0]) } } - if clientIP != "" { - if strings.Contains(clientIP, ":") { - return fmt.Sprintf("[%s]:0", clientIP) - } - return fmt.Sprintf("%s:0", clientIP) + if fwd == "" { + return r.RemoteAddr } - return r.RemoteAddr + return joinForwardedClientAddr(fwd, r.RemoteAddr) +} + +// joinForwardedClientAddr builds a host:port session key from a forwarded +// client address and the direct RemoteAddr (used for the port when the +// forwarded value is IP-only). +func joinForwardedClientAddr(fwd, remoteAddr string) string { + if host, port, err := net.SplitHostPort(fwd); err == nil { + if ip := net.ParseIP(host); ip != nil { + return net.JoinHostPort(ip.String(), port) + } + return net.JoinHostPort(host, port) + } + // Bracketed IPv6 without port: "[2001:db8::1]" + if len(fwd) >= 2 && fwd[0] == '[' && fwd[len(fwd)-1] == ']' { + fwd = fwd[1 : len(fwd)-1] + } + if ip := net.ParseIP(fwd); ip != nil { + _, port, err := net.SplitHostPort(remoteAddr) + if err != nil || port == "" { + return remoteAddr + } + return net.JoinHostPort(ip.String(), port) + } + return remoteAddr } func bindPeerWSConn(s *Server, peerID string, wsc *codec.WSConn) { @@ -191,7 +218,11 @@ func (s *Server) wsSignalLoop(wsc *codec.WSConn) { } case msg.GetPunchHoleRequest() != nil: - fakeAddr, _ := net.ResolveUDPAddr("udp", remoteAddr) + fakeAddr, err := net.ResolveUDPAddr("udp", remoteAddr) + if err != nil || fakeAddr == nil { + log.Printf("[signal] WS PunchHoleRequest: invalid remote addr %q: %v", remoteAddr, err) + continue + } resp := s.handlePunchHoleRequestTCP(msg.GetPunchHoleRequest(), fakeAddr) if resp != nil { wsc.WriteMessage(resp) @@ -199,7 +230,11 @@ func (s *Server) wsSignalLoop(wsc *codec.WSConn) { case msg.GetTestNatRequest() != nil: // NAT test over WS — extract port from remote address (limited value) - fakeAddr, _ := net.ResolveTCPAddr("tcp", remoteAddr) + fakeAddr, err := net.ResolveTCPAddr("tcp", remoteAddr) + if err != nil || fakeAddr == nil { + log.Printf("[signal] WS TestNatRequest: invalid remote addr %q: %v", remoteAddr, err) + continue + } resp := s.handleTestNat(msg.GetTestNatRequest(), fakeAddr) if resp != nil { wsc.WriteMessage(resp) @@ -215,25 +250,31 @@ func (s *Server) wsSignalLoop(wsc *codec.WSConn) { // Use the TCP handler which returns an immediate RelayResponse with // signed PK — the UDP handler would send the response via UDP which // the WebSocket client cannot receive. - fakeAddr, _ := net.ResolveUDPAddr("udp", remoteAddr) - if fakeAddr != nil { - resp := s.handleRequestRelayTCP(msg.GetRequestRelay(), fakeAddr) - if resp != nil { - wsc.WriteMessage(resp) - } + fakeAddr, err := net.ResolveUDPAddr("udp", remoteAddr) + if err != nil || fakeAddr == nil { + log.Printf("[signal] WS RequestRelay: invalid remote addr %q: %v", remoteAddr, err) + continue + } + resp := s.handleRequestRelayTCP(msg.GetRequestRelay(), fakeAddr) + if resp != nil { + wsc.WriteMessage(resp) } case msg.GetFetchLocalAddr() != nil: - fakeAddr, _ := net.ResolveUDPAddr("udp", remoteAddr) - if fakeAddr != nil { - s.handleFetchLocalAddr(msg.GetFetchLocalAddr(), fakeAddr) + fakeAddr, err := net.ResolveUDPAddr("udp", remoteAddr) + if err != nil || fakeAddr == nil { + log.Printf("[signal] WS FetchLocalAddr: invalid remote addr %q: %v", remoteAddr, err) + continue } + s.handleFetchLocalAddr(msg.GetFetchLocalAddr(), fakeAddr) case msg.GetLocalAddr() != nil: - fakeAddr, _ := net.ResolveUDPAddr("udp", remoteAddr) - if fakeAddr != nil { - s.handleLocalAddr(msg.GetLocalAddr(), fakeAddr) + fakeAddr, err := net.ResolveUDPAddr("udp", remoteAddr) + if err != nil || fakeAddr == nil { + log.Printf("[signal] WS LocalAddr: invalid remote addr %q: %v", remoteAddr, err) + continue } + s.handleLocalAddr(msg.GetLocalAddr(), fakeAddr) case msg.GetHc() != nil: resp := &pb.RendezvousMessage{ diff --git a/betterdesk-server/signal/ws_test.go b/betterdesk-server/signal/ws_test.go index 0d35ddc1..fa38974e 100644 --- a/betterdesk-server/signal/ws_test.go +++ b/betterdesk-server/signal/ws_test.go @@ -2,8 +2,10 @@ package signal import ( "context" + "net" "net/http" "net/url" + "strings" "testing" "time" @@ -398,18 +400,86 @@ func TestWSSignalOnlineRequest(t *testing.T) { } func TestWSEffectiveRemoteAddr(t *testing.T) { - req := httptestNewRequest("GET", "/ws/id", "203.0.113.50:60000") - req.Header.Set("X-Forwarded-For", "203.0.113.50, 10.0.0.1") - got := wsEffectiveRemoteAddr(req) - if got != "203.0.113.50:0" { - t.Fatalf("effective addr = %q, want 203.0.113.50:0", got) + t.Parallel() + cases := []struct { + name string + trustProxy bool + remoteAddr string + xri string + xff string + want string + }{ + { + name: "no proxy trust ignores headers", + trustProxy: false, + remoteAddr: "10.0.0.2:50123", + xri: "203.0.113.10", + want: "10.0.0.2:50123", + }, + { + name: "xff ip-only uses remote port", + trustProxy: true, + remoteAddr: "10.0.0.2:50123", + xff: "203.0.113.10, 10.0.0.1", + want: "203.0.113.10:50123", + }, + { + name: "x-real-ip preferred over xff", + trustProxy: true, + remoteAddr: "10.0.0.10:48438", + xri: "203.0.113.99", + xff: "198.51.100.1", + want: "203.0.113.99:48438", + }, + { + name: "xff with port is not double-wrapped", + trustProxy: true, + remoteAddr: "10.0.0.2:50124", + xff: "203.0.113.10:50200", + want: "203.0.113.10:50200", + }, + { + name: "x-real-ip with port", + trustProxy: true, + remoteAddr: "10.0.0.2:50124", + xri: "203.0.113.10:50200", + want: "203.0.113.10:50200", + }, + { + name: "ipv6 forwarded with remote port", + trustProxy: true, + remoteAddr: "10.0.0.2:50125", + xri: "2001:db8::1", + want: "[2001:db8::1]:50125", + }, + { + name: "ipv6 hostport in header", + trustProxy: true, + remoteAddr: "10.0.0.2:50125", + xri: "[2001:db8::1]:443", + want: "[2001:db8::1]:443", + }, + { + name: "no forwarded headers", + trustProxy: true, + remoteAddr: "203.0.113.50:60000", + want: "203.0.113.50:60000", + }, } - - req = httptestNewRequest("GET", "/ws/id", "10.0.0.10:48438") - req.Header.Set("X-Real-IP", "203.0.113.99") - got = wsEffectiveRemoteAddr(req) - if got != "203.0.113.99:0" { - t.Fatalf("effective addr = %q, want 203.0.113.99:0", got) + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptestNewRequest("GET", "/ws/id", tc.remoteAddr) + if tc.xri != "" { + req.Header.Set("X-Real-IP", tc.xri) + } + if tc.xff != "" { + req.Header.Set("X-Forwarded-For", tc.xff) + } + got := wsEffectiveRemoteAddr(req, tc.trustProxy) + if got != tc.want { + t.Fatalf("effective addr = %q, want %q", got, tc.want) + } + }) } } @@ -546,6 +616,7 @@ func TestWSSignalXForwardedFor(t *testing.T) { cfg := config.DefaultConfig() cfg.SignalPort = 29170 cfg.RelayPort = 29171 + cfg.TrustProxy = true dir := t.TempDir() cfg.DBPath = dir + "/test.db" @@ -596,7 +667,101 @@ func TestWSSignalXForwardedFor(t *testing.T) { if entry == nil { t.Fatal("peer XFFWS01 should exist") } - if entry.IP != "203.0.113.50:0" { - t.Fatalf("peer IP = %q, want 203.0.113.50:0", entry.IP) + if !strings.HasPrefix(entry.IP, "203.0.113.50:") { + t.Fatalf("peer IP = %q, want prefix 203.0.113.50:", entry.IP) + } + if strings.HasSuffix(entry.IP, ":0") { + t.Fatalf("peer IP = %q must not use synthetic :0 (issue #276)", entry.IP) + } + _, err = net.ResolveUDPAddr("udp", entry.IP) + if err != nil { + t.Fatalf("peer IP %q must parse as UDP addr: %v", entry.IP, err) + } +} + +func TestWSPunchHoleSentForwardsToWSInitiator(t *testing.T) { + cfg := config.DefaultConfig() + cfg.SignalPort = 29180 + cfg.RelayPort = 29181 + cfg.TrustProxy = true + cfg.P2PFirst = true + + dir := t.TempDir() + cfg.DBPath = dir + "/test.db" + cfg.KeyFile = dir + "/id_ed25519" + + database, err := db.OpenSQLite(cfg.DBPath) + if err != nil { + t.Fatal(err) + } + database.Migrate() + defer database.Close() + + kp, err := crypto.LoadOrGenerateKeyPair(cfg.KeyFile) + if err != nil { + t.Fatal(err) + } + + srv := New(cfg, kp, database) + ctx := t.Context() + if err := srv.Start(ctx); err != nil { + t.Fatal(err) + } + defer srv.Stop() + time.Sleep(200 * time.Millisecond) + + ws, _, err := websocket.Dial(ctx, "ws://127.0.0.1:29182/ws/id", &websocket.DialOptions{ + HTTPHeader: http.Header{ + "X-Forwarded-For": []string{"203.0.113.77"}, + }, + }) + if err != nil { + t.Fatalf("WS dial: %v", err) + } + defer ws.CloseNow() + + reg := &pb.RendezvousMessage{ + Union: &pb.RendezvousMessage_RegisterPeer{ + RegisterPeer: &pb.RegisterPeer{Id: "INITWS01", Serial: 1}, + }, + } + data, _ := proto.Marshal(reg) + if err := ws.Write(ctx, websocket.MessageBinary, data); err != nil { + t.Fatalf("WS write: %v", err) + } + readWSProtoSkippingKeepAlive(t, ctx, ws) + + initiator := srv.PeerMap().Get("INITWS01") + if initiator == nil || initiator.WSConn == nil { + t.Fatal("INITWS01 should be registered with WSConn") + } + initiatorAddr, err := net.ResolveUDPAddr("udp", initiator.IP) + if err != nil { + t.Fatalf("resolve initiator IP %q: %v", initiator.IP, err) + } + + targetAddr := &net.UDPAddr{IP: net.ParseIP("198.51.100.10"), Port: 21116} + srv.PeerMap().Put(&peer.Entry{ + ID: "TARGWS01", + PK: make([]byte, 32), + IP: targetAddr.String(), + UDPAddr: targetAddr, + ConnType: peer.ConnUDP, + LastReg: time.Now(), + }) + + srv.handlePunchHoleSent(&pb.PunchHoleSent{ + Id: "TARGWS01", + SocketAddr: crypto.EncodeAddr(initiatorAddr), + NatType: pb.NatType_ASYMMETRIC, + }, targetAddr, false) + + resp := readWSProtoSkippingKeepAlive(t, ctx, ws) + phr := resp.GetPunchHoleResponse() + if phr == nil { + t.Fatalf("expected PunchHoleResponse on WS, got: %v", resp) + } + if len(phr.SocketAddr) == 0 { + t.Fatal("PunchHoleResponse should carry target socket addr") } } diff --git a/docs/setup/HTTPS_SETUP.md b/docs/setup/HTTPS_SETUP.md index c7b46cd6..4a8fad1f 100644 --- a/docs/setup/HTTPS_SETUP.md +++ b/docs/setup/HTTPS_SETUP.md @@ -436,6 +436,8 @@ When using a reverse proxy (Caddy/Nginx), keep `HTTPS_ENABLED=false` and let the The proxy must send **`X-Forwarded-Proto: https`** so secure cookies and redirects work. Caddy does this by default; for Nginx use `proxy_set_header X-Forwarded-Proto $scheme`. +For RustDesk **WebSocket Mode** (`allow-websocket=Y` / `wss://…/ws/id`), `TRUST_PROXY=Y` is also required so the Go signal server can use `X-Real-IP` / `X-Forwarded-For` for client session keys. Use IP-only values in those headers (standard Nginx `$remote_addr` / `$proxy_add_x_forwarded_for`); do not put `IP:port` in `X-Real-IP` unless your proxy documents that form. + See [REVERSE_PROXY.md](REVERSE_PROXY.md) for the full checklist, generated snippets from `betterdesk.sh`, and RustDesk WSS routing. ### RustDesk WSS Symptom Guide @@ -448,6 +450,7 @@ See [REVERSE_PROXY.md](REVERSE_PROXY.md) for the full checklist, generated snipp | `Rendezvous connection is reset by the peer` ~30s after handshake | Peer marked offline; keepalive not reaching server | Same as above; confirm `/ws/id` reaches port `21118`, not console `:5000` | | `HTTP/1.1 401` or `403` on WebSocket upgrade | Console session / origin check (panel paths, not RustDesk `/ws/id`) | Route `/ws/id` and `/ws/relay` to Go ports `21118` / `21119` | | Server log `WS read ... EOF` immediately after `101`, client retries in a loop (`allow-websocket=Y`) | Client closed before the first protobuf frame; often proxy idle timeout or desktop `RegisterPk` delay (~1s) | Update BetterDesk (fix in [#229](https://github.com/UNITRONIX/BetterDesk/issues/229)); set `WS_DEBUG_FRAMES=1` on the Go server and retest; use `ws-register-test --mode=register-pk --delay-ms=1000 ws://127.0.0.1:21118/ws/id PEERID` | +| Server log `TCP forwarding: no conn found for key "…:0"` / `effective=…:0` / relay timeout with WebSocket Mode | Invalid port in proxied WSS session key; PunchHole/RelayResponse not delivered to WS initiator | Update BetterDesk (fix in [#276](https://github.com/UNITRONIX/BetterDesk/issues/276)); set `TRUST_PROXY=Y`; confirm Nginx sends `X-Real-IP` / `X-Forwarded-For` as IP-only | **Diagnostic commands** (run from the reverse-proxy host): diff --git a/docs/setup/REVERSE_PROXY.md b/docs/setup/REVERSE_PROXY.md index 053b703a..3e58eb0d 100644 --- a/docs/setup/REVERSE_PROXY.md +++ b/docs/setup/REVERSE_PROXY.md @@ -87,12 +87,14 @@ sudo systemctl restart betterdesk-console betterdesk-server ### Go server trust proxy -The Go REST API uses `X-Forwarded-For` for rate limits only when proxy trust is enabled. +The Go REST API uses `X-Forwarded-For` for rate limits only when proxy trust is enabled. The Go **signal WebSocket** (`/ws/id` on port `21118`) also uses `X-Real-IP` / `X-Forwarded-For` for client session keys when trust is enabled — required for RustDesk WebSocket Mode behind Nginx/Caddy ([#276](https://github.com/UNITRONIX/BetterDesk/issues/276)). Set **`TRUST_PROXY=Y`** in `betterdesk-server.service` (installer does this automatically), or add `-trust-proxy` to `ExecStart`. > **Note:** `TRUST_PROXY=Y` in `.env` enables trust for **both** the Node.js panel and the Go server. Node also accepts `1` / `yes`; Go requires **`Y`**. +> **UDP/TCP signal** on port **21116** cannot use HTTP headers. `TRUST_PROXY` does not apply to native UDP/TCP rendezvous. + ### Bind addresses | Setting | Same-host proxy | Remote proxy (Caddy on another server) |