From cec9ea8c0030662479d5c3f402ceba46ce046cc8 Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Sun, 12 Jul 2026 18:46:47 +0200 Subject: [PATCH] Support IPv6 for the embedded VNC server and browser proxy --- client/internal/engine_vnc.go | 23 +++++++ client/vnc/server/server.go | 83 +++++++++++++++++++------ client/vnc/server/server_darwin.go | 12 ++-- client/vnc/server/server_test.go | 48 ++++++++++++-- client/vnc/server/server_windows.go | 12 ++-- client/vnc/server/server_x11.go | 6 +- client/wasm/cmd/main.go | 23 +++---- client/wasm/internal/netutil/network.go | 16 +++++ client/wasm/internal/ssh/client.go | 9 +-- client/wasm/internal/vnc/proxy.go | 14 ++++- 10 files changed, 183 insertions(+), 63 deletions(-) create mode 100644 client/wasm/internal/netutil/network.go diff --git a/client/internal/engine_vnc.go b/client/internal/engine_vnc.go index 7aff50f90..aa2fd7a50 100644 --- a/client/internal/engine_vnc.go +++ b/client/internal/engine_vnc.go @@ -24,6 +24,7 @@ import ( type vncServer interface { Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error + AddListener(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error Stop() error ActiveSessions() []vncserver.ActiveSessionInfo } @@ -43,6 +44,15 @@ func (e *Engine) setupVNCPortRedirection() error { } log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vnc.ExternalPort, localAddr, vnc.InternalPort) + if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() { + v6 := wgAddr.IPv6 + if err := e.firewall.AddInboundDNAT(v6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil { + log.Warnf("failed to add IPv6 VNC port redirection: %v", err) + } else { + log.Infof("VNC port redirection: [%s]:%d -> [%s]:%d", v6, vnc.ExternalPort, v6, vnc.InternalPort) + } + } + return nil } @@ -60,6 +70,12 @@ func (e *Engine) cleanupVNCPortRedirection() error { return fmt.Errorf("remove VNC port redirection: %w", err) } + if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() { + if err := e.firewall.RemoveInboundDNAT(wgAddr.IPv6, firewallManager.ProtocolTCP, vnc.ExternalPort, vnc.InternalPort); err != nil { + log.Debugf("failed to remove IPv6 VNC port redirection: %v", err) + } + } + return nil } @@ -134,6 +150,13 @@ func (e *Engine) startVNCServer() error { return fmt.Errorf("start VNC server: %w", err) } + if wgAddr := e.wgInterface.Address(); wgAddr.HasIPv6() { + v6Addr := netip.AddrPortFrom(wgAddr.IPv6, vnc.InternalPort) + if err := srv.AddListener(e.ctx, v6Addr, wgAddr.IPv6Net); err != nil { + log.Warnf("failed to add IPv6 VNC listener: %v", err) + } + } + e.vncSrv = srv if netstackNet := e.wgInterface.GetNet(); netstackNet != nil { diff --git a/client/vnc/server/server.go b/client/vnc/server/server.go index 3ed69d909..6f4581220 100644 --- a/client/vnc/server/server.go +++ b/client/vnc/server/server.go @@ -169,15 +169,22 @@ type Server struct { localAddr netip.Addr // network is the NetBird overlay network. network netip.Prefix - log *log.Entry + // localAddr6 and network6 are the v6 overlay address and network, set + // when a v6 listener is added; zero when the overlay has no v6. + localAddr6 netip.Addr + network6 netip.Prefix + log *log.Entry - mu sync.Mutex - listener net.Listener - ctx context.Context - cancel context.CancelFunc - vmgr virtualSessionManager - authorizer *sshauth.Authorizer - netstackNet *netstack.Net + mu sync.Mutex + listener net.Listener + // extraListeners holds additional listeners (e.g. the v6 overlay), closed + // alongside listener on Stop. + extraListeners []net.Listener + ctx context.Context + cancel context.CancelFunc + vmgr virtualSessionManager + authorizer *sshauth.Authorizer + netstackNet *netstack.Net // agentToken holds the raw token bytes for agent-mode auth. agentToken []byte // invalidAgentToken latches when AgentTokenHex was provided but failed @@ -609,21 +616,54 @@ func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.P } if s.serviceMode { - go s.serviceAcceptLoop() + go s.serviceAcceptLoop(s.listener) } else { - go s.acceptLoop() + go s.acceptLoop(s.listener) } s.log.Infof("started on %s (service_mode=%v)", listenDesc, s.serviceMode) return nil } +// AddListener opens an additional overlay listener (e.g. the v6 overlay +// address) and serves it with the same accept path as the primary listener. +// The server must already be running. Mirrors the primary listener's mode so +// service-mode connections still route through the per-session agent proxy. +func (s *Server) AddListener(_ context.Context, addr netip.AddrPort, network netip.Prefix) error { + s.mu.Lock() + if s.listener == nil { + s.mu.Unlock() + return fmt.Errorf("server not running") + } + ln, desc, err := s.openOverlayListener(addr, network) + if err != nil { + s.mu.Unlock() + return err + } + s.extraListeners = append(s.extraListeners, ln) + serviceMode := s.serviceMode + s.mu.Unlock() + + s.log.Infof("also listening on %s (service_mode=%v)", desc, serviceMode) + if serviceMode { + go s.serviceAcceptLoop(ln) + } else { + go s.acceptLoop(ln) + } + return nil +} + func (s *Server) openOverlayListener(addr netip.AddrPort, network netip.Prefix) (net.Listener, string, error) { if !network.IsValid() { return nil, "", fmt.Errorf("invalid overlay network prefix") } - s.localAddr = addr.Addr() - s.network = network + if addr.Addr().Is6() { + s.localAddr6 = addr.Addr() + s.network6 = network + } else { + s.localAddr = addr.Addr() + s.network = network + } if s.netstackNet != nil { ln, err := s.netstackNet.ListenTCPAddrPort(addr) if err != nil { @@ -658,6 +698,12 @@ func (s *Server) Stop() error { listenerErr = s.listener.Close() s.listener = nil } + for _, ln := range s.extraListeners { + if err := ln.Close(); err != nil && listenerErr == nil { + listenerErr = err + } + } + s.extraListeners = nil s.closeActiveSessions() if s.vmgr != nil { @@ -681,10 +727,7 @@ func (s *Server) Stop() error { } // acceptLoop handles VNC connections directly (user session mode). -func (s *Server) acceptLoop() { - s.mu.Lock() - ln := s.listener - s.mu.Unlock() +func (s *Server) acceptLoop(ln net.Listener) { if ln == nil { return } @@ -790,16 +833,18 @@ func (s *Server) isAllowedSource(addr net.Addr) bool { return true } - if remoteIP == s.localAddr { + if remoteIP == s.localAddr || (s.localAddr6.IsValid() && remoteIP == s.localAddr6) { s.log.Warnf("connection rejected from own IP %s", remoteIP) return false } - if !s.network.IsValid() { + if !s.network.IsValid() && !s.network6.IsValid() { s.log.Warnf("connection rejected: overlay network not configured") return false } - if !s.network.Contains(remoteIP) { + inV4 := s.network.IsValid() && s.network.Contains(remoteIP) + inV6 := s.network6.IsValid() && s.network6.Contains(remoteIP) + if !inV4 && !inV6 { s.log.Warnf("connection rejected from non-NetBird IP %s", remoteIP) return false } diff --git a/client/vnc/server/server_darwin.go b/client/vnc/server/server_darwin.go index 593de3e79..1924c3556 100644 --- a/client/vnc/server/server_darwin.go +++ b/client/vnc/server/server_darwin.go @@ -24,18 +24,16 @@ func (s *Server) platformSessionManager() virtualSessionManager { // to the per-user agent darwinAgentManager spawns via launchctl asuser // (the only spawn mode that lands a child in the user's Aqua session with // WindowServer + TCC access). -func (s *Server) serviceAcceptLoop() { +func (s *Server) serviceAcceptLoop(ln net.Listener) { + if ln == nil { + return + } + mgr := newDarwinAgentManager(s.ctx) defer mgr.stop() log.Info("service mode, proxying connections to per-user agent over Unix socket") - s.mu.Lock() - ln := s.listener - s.mu.Unlock() - if ln == nil { - return - } for { conn, err := ln.Accept() if err != nil { diff --git a/client/vnc/server/server_test.go b/client/vnc/server/server_test.go index fa42ea81a..10b7e196a 100644 --- a/client/vnc/server/server_test.go +++ b/client/vnc/server/server_test.go @@ -145,11 +145,13 @@ func TestAuth_NoUnauthBytesPastHeader(t *testing.T) { func TestIsAllowedSource(t *testing.T) { tests := []struct { - name string - localAddr netip.Addr - network netip.Prefix - remote net.Addr - want bool + name string + localAddr netip.Addr + network netip.Prefix + localAddr6 netip.Addr + network6 netip.Prefix + remote net.Addr + want bool }{ { // Unix-domain remotes (per-session agent path) are local IPC, @@ -202,12 +204,48 @@ func TestIsAllowedSource(t *testing.T) { remote: &net.TCPAddr{IP: net.ParseIP("10.99.99.2"), Port: 5900}, want: false, }, + { + name: "v6 overlay IP allowed", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + localAddr6: netip.MustParseAddr("fd00:1234::1"), + network6: netip.MustParsePrefix("fd00:1234::/64"), + remote: &net.TCPAddr{IP: net.ParseIP("fd00:1234::2"), Port: 5900}, + want: true, + }, + { + name: "v6 own IP rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + localAddr6: netip.MustParseAddr("fd00:1234::1"), + network6: netip.MustParsePrefix("fd00:1234::/64"), + remote: &net.TCPAddr{IP: net.ParseIP("fd00:1234::1"), Port: 5900}, + want: false, + }, + { + name: "v6 outside overlay rejected", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + localAddr6: netip.MustParseAddr("fd00:1234::1"), + network6: netip.MustParsePrefix("fd00:1234::/64"), + remote: &net.TCPAddr{IP: net.ParseIP("2001:db8::5"), Port: 5900}, + want: false, + }, + { + name: "v6 rejected when only v4 overlay configured", + localAddr: netip.MustParseAddr("10.99.99.1"), + network: netip.MustParsePrefix("10.99.0.0/16"), + remote: &net.TCPAddr{IP: net.ParseIP("fd00:1234::2"), Port: 5900}, + want: false, + }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { srv := New(Config{Capturer: &testCapturer{}, Injector: &StubInputInjector{}}) srv.localAddr = tc.localAddr srv.network = tc.network + srv.localAddr6 = tc.localAddr6 + srv.network6 = tc.network6 assert.Equal(t, tc.want, srv.isAllowedSource(tc.remote)) }) } diff --git a/client/vnc/server/server_windows.go b/client/vnc/server/server_windows.go index 88ebf74f8..74ef18407 100644 --- a/client/vnc/server/server_windows.go +++ b/client/vnc/server/server_windows.go @@ -241,20 +241,16 @@ func (s *Server) platformInit() { // serviceAcceptLoop runs in Session 0. It validates the source IP and // hands accepted connections to handleServiceConnection, which runs the // Noise_IK handshake before proxying to the user-session agent. -func (s *Server) serviceAcceptLoop() { +func (s *Server) serviceAcceptLoop(ln net.Listener) { + if ln == nil { + return + } sm := newSessionManager() go sm.run() log.Info("service mode, proxying connections to agent over Unix socket") - s.mu.Lock() - ln := s.listener - s.mu.Unlock() - if ln == nil { - sm.Stop() - return - } for { conn, err := ln.Accept() if err != nil { diff --git a/client/vnc/server/server_x11.go b/client/vnc/server/server_x11.go index 6c0b6b643..e10846410 100644 --- a/client/vnc/server/server_x11.go +++ b/client/vnc/server/server_x11.go @@ -2,14 +2,16 @@ package server +import "net" + func (s *Server) platformInit() { // no-op on X11 } // serviceAcceptLoop is not supported on Linux. -func (s *Server) serviceAcceptLoop() { +func (s *Server) serviceAcceptLoop(ln net.Listener) { s.log.Warn("service mode not supported on Linux, falling back to direct mode") - s.acceptLoop() + s.acceptLoop(ln) } func (s *Server) platformSessionManager() virtualSessionManager { diff --git a/client/wasm/cmd/main.go b/client/wasm/cmd/main.go index 611d0e8b2..e58d94ab1 100644 --- a/client/wasm/cmd/main.go +++ b/client/wasm/cmd/main.go @@ -20,6 +20,7 @@ import ( nbstatus "github.com/netbirdio/netbird/client/status" wasmcapture "github.com/netbirdio/netbird/client/wasm/internal/capture" "github.com/netbirdio/netbird/client/wasm/internal/http" + "github.com/netbirdio/netbird/client/wasm/internal/netutil" "github.com/netbirdio/netbird/client/wasm/internal/rdp" "github.com/netbirdio/netbird/client/wasm/internal/ssh" "github.com/netbirdio/netbird/client/wasm/internal/vnc" @@ -264,7 +265,7 @@ func performPingTCP(client *netbird.Client, hostname string, port, ipVersion int ctx, cancel := context.WithTimeout(context.Background(), pingTimeout) defer cancel() - network := ipVersionNetwork("tcp", ipVersion) + network := netutil.TCPNetwork(ipVersion) address := net.JoinHostPort(hostname, fmt.Sprintf("%d", port)) start := time.Now() @@ -410,7 +411,7 @@ func createGenerateVNCSessionKeyMethod() js.Func { } // createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging. -// JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?) +// JS signature: createVNCProxy(hostname, port, mode?, username?, keySessionID?, sessionID?, width?, height?, peerPublicKey?, ipVersion?) // // mode: "attach" (default) or "session" // username: required when mode is "session" @@ -418,6 +419,7 @@ func createGenerateVNCSessionKeyMethod() js.Func { // sessionID: Windows session ID (0 = console/auto) // width/height: requested viewport size for session mode (0 = server default) // peerPublicKey: base64 X25519 static pubkey of the destination peer (required for auth) +// ipVersion: address family to dial: 4, 6, or 0/omitted for automatic func createVNCProxyMethod(client *netbird.Client) js.Func { return js.FuncOf(func(_ js.Value, args []js.Value) any { params, err := parseVNCProxyArgs(args) @@ -440,6 +442,7 @@ func createVNCProxyMethod(client *netbird.Client) js.Func { Height: params.height, PeerPublicKey: params.peerPublicKey, KeySessionID: params.keySessionID, + IPVersion: params.ipVersion, }) }) } @@ -454,6 +457,7 @@ type vncProxyParams struct { width uint16 height uint16 peerPublicKey string + ipVersion int rejectViaPromise bool } @@ -540,6 +544,9 @@ func parseVNCProxyOptionalNumbers(args []js.Value, p *vncProxyParams) error { if len(args) > 8 && args[8].Type() == js.TypeString { p.peerPublicKey = args[8].String() } + if len(args) > 9 { + p.ipVersion = jsIPVersion(args[9]) + } return nil } @@ -662,18 +669,6 @@ func createSetLogLevelMethod(client *netbird.Client) js.Func { }) } -// ipVersionNetwork appends "4" or "6" to a base network string (e.g. "tcp" -> "tcp4"). -func ipVersionNetwork(base string, ipVersion int) string { - switch ipVersion { - case 4: - return base + "4" - case 6: - return base + "6" - default: - return base - } -} - // jsIPVersion extracts an IP version (4 or 6) from a JS string or number. func jsIPVersion(v js.Value) int { switch v.Type() { diff --git a/client/wasm/internal/netutil/network.go b/client/wasm/internal/netutil/network.go new file mode 100644 index 000000000..c802756e8 --- /dev/null +++ b/client/wasm/internal/netutil/network.go @@ -0,0 +1,16 @@ +// Package netutil holds small networking helpers shared across the wasm +// client's proxy paths (SSH, VNC, ping). +package netutil + +// TCPNetwork maps an IP-version selector to the net package's TCP network +// string: 4 -> "tcp4", 6 -> "tcp6", anything else (0/automatic) -> "tcp". +func TCPNetwork(ipVersion int) string { + switch ipVersion { + case 4: + return "tcp4" + case 6: + return "tcp6" + default: + return "tcp" + } +} diff --git a/client/wasm/internal/ssh/client.go b/client/wasm/internal/ssh/client.go index 9cfe65266..80ef48449 100644 --- a/client/wasm/internal/ssh/client.go +++ b/client/wasm/internal/ssh/client.go @@ -15,6 +15,7 @@ import ( netbird "github.com/netbirdio/netbird/client/embed" nbssh "github.com/netbirdio/netbird/client/ssh" + "github.com/netbirdio/netbird/client/wasm/internal/netutil" ) const ( @@ -64,13 +65,7 @@ func (c *Client) Connect(host string, port int, username, jwtToken string, ipVer Timeout: sshDialTimeout, } - network := "tcp" - switch ipVersion { - case 4: - network = "tcp4" - case 6: - network = "tcp6" - } + network := netutil.TCPNetwork(ipVersion) ctx, cancel := context.WithTimeout(context.Background(), sshDialTimeout) defer cancel() diff --git a/client/wasm/internal/vnc/proxy.go b/client/wasm/internal/vnc/proxy.go index 5df541edf..fa3472782 100644 --- a/client/wasm/internal/vnc/proxy.go +++ b/client/wasm/internal/vnc/proxy.go @@ -17,6 +17,8 @@ import ( "github.com/flynn/noise" log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/client/wasm/internal/netutil" ) var cryptoRandRead = crand.Read @@ -133,6 +135,7 @@ type VNCProxy struct { type vncDestination struct { address string + network string mode byte username string sessionPriv []byte @@ -188,6 +191,10 @@ type ProxyRequest struct { // matching private key is looked up inside wasm and never crosses // the JS boundary. KeySessionID string + // IPVersion selects the address family for the dial to the destination: + // 4, 6, or 0 for automatic selection. Mirrors the SSH proxy so the + // dashboard can resolve a peer label to a specific family. + IPVersion int } // CreateProxy creates a new proxy endpoint for the given VNC destination. @@ -207,6 +214,7 @@ func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value { dest := vncDestination{ address: address, + network: netutil.TCPNetwork(req.IPVersion), mode: m, username: username, sessionID: sessionID, @@ -394,7 +402,11 @@ func (p *VNCProxy) connectToVNC(conn *vncConnection) { ctx, cancel := context.WithTimeout(conn.ctx, vncDialTimeout) defer cancel() - vncConn, err := p.nbClient.Dial(ctx, "tcp", conn.destination.address) + network := conn.destination.network + if network == "" { + network = "tcp" + } + vncConn, err := p.nbClient.Dial(ctx, network, conn.destination.address) if err != nil { log.Errorf("VNC connect to %s: %v", conn.destination.address, err) // Close the WebSocket so noVNC fires a disconnect event.