mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-20 15:31:30 +02:00
Support IPv6 for the embedded VNC server and browser proxy
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
16
client/wasm/internal/netutil/network.go
Normal file
16
client/wasm/internal/netutil/network.go
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user