mirror of
https://github.com/UNITRONIX/BetterDesk.git
synced 2026-09-10 01:27:11 +00:00
Merge pull request #316 from UNITRONIX/hotfix/313-web-remote-panel-proxy
Hotfix: restore Web Remote after enrollment outbound gate (#313)
This commit is contained in:
@@ -1,5 +1,9 @@
|
||||
## [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.
|
||||
- **npm audit (`brace-expansion`):** override bumped to `^5.0.8` (GHSA-mh99-v99m-4gvg) so Web Console CI `npm audit --omit=dev` passes on stable.
|
||||
|
||||
### Changed
|
||||
- _(none yet)_
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+16
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "betterdesk-console",
|
||||
"version": "3.3.173",
|
||||
"version": "3.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "betterdesk-console",
|
||||
"version": "3.3.173",
|
||||
"version": "3.4.1",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"axios": "^1.9.0",
|
||||
@@ -1587,10 +1587,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
|
||||
"license": "MIT"
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "18 || 20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-js": {
|
||||
"version": "1.5.1",
|
||||
@@ -1695,13 +1698,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
|
||||
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^1.0.0",
|
||||
"concat-map": "0.0.1"
|
||||
"balanced-match": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "20 || >=22"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
@@ -2021,12 +2026,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/concat-map": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
|
||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
"js-yaml": "^3.15.0",
|
||||
"tar": "^7.5.21",
|
||||
"path-to-regexp": "^0.1.13",
|
||||
"brace-expansion": "^1.1.16",
|
||||
"brace-expansion": "^5.0.8",
|
||||
"form-data": "^4.0.6",
|
||||
"body-parser": "^1.20.6",
|
||||
"protobufjs": "^7.6.5"
|
||||
|
||||
Reference in New Issue
Block a user