fix(signal): restore Web Remote after enrollment outbound gate (Refs #313)

Accept PunchHole/RequestRelay from PANEL_SIGNAL_PROXY_CIDRS (default loopback) so panel-proxied Web Remote works again without weakening #302.
This commit is contained in:
UNITRONIX
2026-07-28 06:46:17 +02:00
parent 6d324cade6
commit a749827ae0
8 changed files with 158 additions and 1 deletions
+3
View File
@@ -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)_
+22
View File
@@ -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
+14
View File
@@ -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)
+11 -1
View File
@@ -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
}
+69
View File
@@ -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)
}
}
+1
View File
@@ -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.
+7
View File
@@ -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