diff --git a/CHANGELOG.md b/CHANGELOG.md index d7c12feb..e0fae504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [Unreleased] +### Fixed +- **Web Remote broken after enrollment outbound gate (#313, #302):** PunchHole/RequestRelay from the panel `/ws/rendezvous` proxy (default loopback CIDRs via `PANEL_SIGNAL_PROXY_CIDRS`) are accepted again without requiring a registered RustDesk peer. Unapproved clients and anonymous public initiators remain blocked. Ships via panel update (Go signal restart). Native/all-in-one needs no env change; Docker split console↔server must set `PANEL_SIGNAL_PROXY_CIDRS` to the console CIDR if still denied. + ### Changed - _(none yet)_ diff --git a/betterdesk-server/config/config.go b/betterdesk-server/config/config.go index ef49c5eb..9dafd7bf 100644 --- a/betterdesk-server/config/config.go +++ b/betterdesk-server/config/config.go @@ -75,6 +75,11 @@ type Config struct { // X-Forwarded-For / X-Real-IP. Required when TrustProxy is true — empty // means forwarded headers are ignored (security-first, issue #276). TrustedProxies []*net.IPNet + // PanelSignalProxyCIDRs is the allowlist of source IPs for the Node panel + // WebSocket→TCP proxy (/ws/rendezvous → hbbs). Web Remote never registers + // as a RustDesk peer; PunchHole/RequestRelay from these CIDRs are treated + // as panel-authorized initiators (#302 regression fix). Default: loopback. + PanelSignalProxyCIDRs []*net.IPNet RelayMaxConnsIP int // Max relay connections per IP (0 = unlimited) InitAdminUser string // Initial admin username (created on first start) InitAdminPass string // Initial admin password (auto-generated if empty) @@ -152,8 +157,13 @@ type Config struct { BillingRequireWorkReport bool // Require technician report before session close } +// DefaultPanelSignalProxyCIDRs is the loopback allowlist for the panel→hbbs +// TCP proxy used by Web Remote (all-in-one and same-host native installs). +const DefaultPanelSignalProxyCIDRs = "127.0.0.0/8,::1/128" + // DefaultConfig returns a Config with sensible defaults. func DefaultConfig() *Config { + panelCIDRs, _ := ParseTrustedProxies(DefaultPanelSignalProxyCIDRs) return &Config{ SignalPort: 21116, RelayPort: 21117, @@ -167,6 +177,7 @@ func DefaultConfig() *Config { ClientSessionMaxDays: 30, RelayMaxConnsIP: 20, EnrollmentMode: EnrollmentModeOpen, // Backward compatible default + PanelSignalProxyCIDRs: panelCIDRs, CDAPPort: 21122, CDAPEnabled: true, // Enabled by default; set CDAP_ENABLED=N for minimal installs CDAPRateLimit: 30, @@ -315,6 +326,17 @@ func (c *Config) LoadEnv() { c.TrustedProxies = nets } } + // Panel Web Remote proxy CIDRs (#302 regression). Unset keeps DefaultConfig + // loopback allowlist; set to override (e.g. Docker bridge when panel and Go + // run in separate containers). + if v := os.Getenv("PANEL_SIGNAL_PROXY_CIDRS"); v != "" { + nets, err := ParseTrustedProxies(v) + if err != nil { + log.Printf("[config] PANEL_SIGNAL_PROXY_CIDRS parse error: %v — keeping previous allowlist", err) + } else { + c.PanelSignalProxyCIDRs = nets + } + } if v := os.Getenv("RELAY_MAX_CONNS_PER_IP"); v != "" { if n, err := strconv.Atoi(v); err == nil { c.RelayMaxConnsIP = n diff --git a/betterdesk-server/config/proxy_trust.go b/betterdesk-server/config/proxy_trust.go index ce48b8aa..049d27fe 100644 --- a/betterdesk-server/config/proxy_trust.go +++ b/betterdesk-server/config/proxy_trust.go @@ -64,6 +64,20 @@ func (c *Config) RemoteAddrIsTrustedProxy(remoteAddr string) bool { return false } +// IPIsPanelSignalProxy reports whether ip is in PanelSignalProxyCIDRs +// (Node panel → hbbs TCP proxy for Web Remote). Empty allowlist → false. +func (c *Config) IPIsPanelSignalProxy(ip net.IP) bool { + if c == nil || ip == nil || len(c.PanelSignalProxyCIDRs) == 0 { + return false + } + for _, n := range c.PanelSignalProxyCIDRs { + if n != nil && n.Contains(ip) { + return true + } + } + return false +} + // ShouldHonorForwardedHeaders is true only when TrustProxy is set and the // direct connection comes from a configured trusted proxy CIDR. func (c *Config) ShouldHonorForwardedHeaders(remoteAddr string) bool { diff --git a/betterdesk-server/config/proxy_trust_test.go b/betterdesk-server/config/proxy_trust_test.go index db2c77c3..40dd77c4 100644 --- a/betterdesk-server/config/proxy_trust_test.go +++ b/betterdesk-server/config/proxy_trust_test.go @@ -54,6 +54,37 @@ func TestShouldHonorForwardedHeaders(t *testing.T) { } } +func TestIPIsPanelSignalProxy(t *testing.T) { + t.Parallel() + cfg := DefaultConfig() + if !cfg.IPIsPanelSignalProxy(net.ParseIP("127.0.0.1")) { + t.Fatal("127.0.0.1 should match default loopback allowlist") + } + if !cfg.IPIsPanelSignalProxy(net.ParseIP("::1")) { + t.Fatal("::1 should match default loopback allowlist") + } + if cfg.IPIsPanelSignalProxy(net.ParseIP("198.51.100.1")) { + t.Fatal("public IP must not match default panel proxy allowlist") + } + + cfg.PanelSignalProxyCIDRs = nil + if cfg.IPIsPanelSignalProxy(net.ParseIP("127.0.0.1")) { + t.Fatal("empty allowlist must reject") + } + + nets, err := ParseTrustedProxies("10.0.0.0/8") + if err != nil { + t.Fatal(err) + } + cfg.PanelSignalProxyCIDRs = nets + if !cfg.IPIsPanelSignalProxy(net.ParseIP("10.1.2.3")) { + t.Fatal("10.1.2.3 should match 10.0.0.0/8") + } + if cfg.IPIsPanelSignalProxy(net.ParseIP("127.0.0.1")) { + t.Fatal("loopback should not match custom 10.0.0.0/8-only allowlist") + } +} + func mustParseCIDR(t *testing.T, cidr string) *net.IPNet { t.Helper() _, n, err := net.ParseCIDR(cidr) diff --git a/betterdesk-server/signal/handler.go b/betterdesk-server/signal/handler.go index a9238a37..7b719427 100644 --- a/betterdesk-server/signal/handler.go +++ b/betterdesk-server/signal/handler.go @@ -29,6 +29,10 @@ const refuseRelayProtocolMismatch = "Protocol mismatch: WebSocket and native TCP // a peer that is not registered (or not enrollment-approved in managed/locked). const refuseInitiatorNotAuthorized = "Not authorized" +// panelWebRemoteInitiatorID is the synthetic initiator id logged when PunchHole/ +// RequestRelay arrives from the Node panel WebSocket→TCP proxy (#302 Web Remote). +const panelWebRemoteInitiatorID = "panel-web-remote" + // relayTransportMismatch reports whether initiator and target use incompatible // relay transports (WebSocket Mode vs native TCP/UDP). Signaling may still be // mixed; this gate only covers the typical case where ConnType reflects the @@ -1711,11 +1715,14 @@ func (s *Server) relayUnauthorizedResponse(relayServer string) *pb.RendezvousMes } // requireAuthorizedInitiator enforces that PunchHole/RequestRelay may only be -// started by a live registered peer (#302). +// started by a live registered peer (#302), or by the Node panel Web Remote +// proxy (trusted PANEL_SIGNAL_PROXY_CIDRS — typically loopback). // // All enrollment modes require the initiator to be present in the in-memory // peer map (closes anonymous rendezvous). Managed and locked modes additionally // require an approved DB peer row (pending enrollment alone is not enough). +// Panel proxy initiators skip the peer-map / DB checks: operator auth is +// enforced at the panel WS upgrade before TCP is bridged to hbbs. func (s *Server) requireAuthorizedInitiator(raddr *net.UDPAddr, targetID string) (string, bool) { if raddr == nil { return "", false @@ -1723,6 +1730,9 @@ func (s *Server) requireAuthorizedInitiator(raddr *net.UDPAddr, targetID string) initiator := s.peers.FindByIP(raddr.IP) if initiator == nil || initiator.IsExpired(config.RegTimeout) { + if s.cfg != nil && s.cfg.IPIsPanelSignalProxy(raddr.IP) { + return panelWebRemoteInitiatorID, true + } s.logUnauthorizedInitiator(raddr, "", targetID, "initiator_not_registered") return "", false } diff --git a/betterdesk-server/signal/handler_test.go b/betterdesk-server/signal/handler_test.go index ef35ef18..1cd60476 100644 --- a/betterdesk-server/signal/handler_test.go +++ b/betterdesk-server/signal/handler_test.go @@ -892,3 +892,72 @@ func TestOpenRegisteredInitiatorCanRequestRelay(t *testing.T) { t.Fatalf("uuid = %q", rr.Uuid) } } + +func TestPanelProxyLoopbackCanPunchHoleWithoutPeer(t *testing.T) { + srv, _ := newTestSignalServer(t, config.EnrollmentModeManaged) + putOnlinePeer(srv, "TGTWEB1", "203.0.113.90", 52000, peer.ConnTCP) + + id, ok := srv.requireAuthorizedInitiator(udpAddr("127.0.0.1", 51000), "TGTWEB1") + if !ok || id != panelWebRemoteInitiatorID { + t.Fatalf("loopback panel proxy = (%q, %v), want (%q, true)", id, ok, panelWebRemoteInitiatorID) + } + + // Web Remote: panel bridges from loopback; no RegisterPeer for the browser. + // P2P-first may return nil while forwarding to the target; unauthorized always + // returns PunchHoleResponse{Failure: ID_NOT_EXIST}. + resp := srv.handlePunchHoleRequestTCP(&pb.PunchHoleRequest{Id: "TGTWEB1"}, udpAddr("127.0.0.1", 51000)) + if phr := resp.GetPunchHoleResponse(); phr != nil && phr.Failure == pb.PunchHoleResponse_ID_NOT_EXIST { + t.Fatal("panel loopback PunchHole must not be refused as unauthorized") + } +} + +func TestPanelProxyLoopbackCanRequestRelayWithoutPeer(t *testing.T) { + srv, _ := newTestSignalServer(t, config.EnrollmentModeManaged) + putOnlinePeer(srv, "TGTWEB2", "203.0.113.91", 52000, peer.ConnTCP) + + resp := srv.handleRequestRelayTCP(&pb.RequestRelay{ + Id: "TGTWEB2", + Uuid: "web-remote-relay-uuid", + }, udpAddr("127.0.0.1", 51000), peer.ConnTCP) + rr := resp.GetRelayResponse() + if rr == nil { + t.Fatalf("expected RelayResponse, got %+v", resp) + } + if rr.RefuseReason != "" { + t.Fatalf("panel loopback relay refused: %q", rr.RefuseReason) + } + if rr.Uuid != "web-remote-relay-uuid" { + t.Fatalf("uuid = %q", rr.Uuid) + } +} + +func TestPublicAnonymousInitiatorStillRejectedWithPanelAllowlist(t *testing.T) { + srv, _ := newTestSignalServer(t, config.EnrollmentModeOpen) + putOnlinePeer(srv, "TGTPUB1", "203.0.113.92", 52000, peer.ConnTCP) + + id, ok := srv.requireAuthorizedInitiator(udpAddr("198.51.100.99", 51000), "TGTPUB1") + if ok || id != "" { + t.Fatalf("public anonymous = (%q, %v), want reject", id, ok) + } + + resp := srv.handlePunchHoleRequestTCP(&pb.PunchHoleRequest{Id: "TGTPUB1"}, udpAddr("198.51.100.99", 51000)) + phr := resp.GetPunchHoleResponse() + if phr == nil || phr.Failure != pb.PunchHoleResponse_ID_NOT_EXIST { + t.Fatalf("public anonymous PunchHole should be unauthorized, got %+v", resp) + } +} + +func TestManagedPendingStillRejectedDespitePanelAllowlist(t *testing.T) { + // Pending peer on a non-loopback IP must still be blocked (#302). + srv, database := newTestSignalServer(t, config.EnrollmentModeManaged) + putOnlinePeer(srv, "TGTPEND3", "203.0.113.93", 52000, peer.ConnUDP) + putOnlinePeer(srv, "PENDINIT3", "198.51.100.73", 51000, peer.ConnUDP) + if err := database.SetConfig("pending_device_PENDINIT3", `{"device_id":"PENDINIT3"}`); err != nil { + t.Fatalf("SetConfig: %v", err) + } + + id, ok := srv.requireAuthorizedInitiator(udpAddr("198.51.100.73", 51000), "TGTPEND3") + if ok { + t.Fatalf("pending initiator must be rejected, got id=%q", id) + } +} diff --git a/docs/important/betterdesk-enrollment.md b/docs/important/betterdesk-enrollment.md index 3c5277fc..e48e3655 100644 --- a/docs/important/betterdesk-enrollment.md +++ b/docs/important/betterdesk-enrollment.md @@ -4,4 +4,5 @@ - Outbound session initiation (`PunchHoleRequest` / `RequestRelay`) requires an authorized initiator (#302): - All modes: initiator must be a live registered peer in the signal peer map (anonymous rendezvous is refused). - Managed / locked: initiator must also exist as an approved peer in the DB (`GetPeer`); pending queue alone is not enough. + - **Panel Web Remote exception:** PunchHole/RequestRelay from `PANEL_SIGNAL_PROXY_CIDRS` (default loopback `127.0.0.0/8,::1/128`) are accepted without a peer registration. The Node panel authenticates the operator (or guest) at `/ws/rendezvous` upgrade before TCP-bridging to hbbs. Split panel↔Go installs must set the console container/host CIDR. Synthetic initiator id in audit logs: `panel-web-remote`. - Commit references for GitHub issues should use `Refs #N` (not `Fixes`) when the user wants the issue left open. diff --git a/web-nodejs/.env.example b/web-nodejs/.env.example index 408da50d..a411a4ec 100644 --- a/web-nodejs/.env.example +++ b/web-nodejs/.env.example @@ -90,6 +90,13 @@ TRUST_PROXY=false # Example (same-host Nginx/Caddy): 127.0.0.1/32,::1/128 TRUSTED_PROXIES= +# Source IPs allowed to start PunchHole/RequestRelay without a registered RustDesk peer. +# Used by the Node panel WebSocket→TCP proxy for Web Remote (/ws/rendezvous → hbbs). +# Default in Go is 127.0.0.0/8,::1/128 when unset. Override for split panel↔Go containers +# (e.g. Docker bridge CIDR of the console). Refs #302. +# PANEL_SIGNAL_PROXY_CIDRS=127.0.0.0/8,::1/128 +PANEL_SIGNAL_PROXY_CIDRS= + # WebSocket Origin allow-list (comma-separated). Same-host browser upgrades are always allowed. # WS_ALLOWED_ORIGINS=https://panel.example.com