mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
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.
This commit is contained in:
@@ -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)_
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user