Add embedded VNC server with JWT auth and per-peer toggle

This commit is contained in:
Viktor Liu
2026-05-16 09:19:34 +02:00
parent e916f12cca
commit 9f0aa1ce26
83 changed files with 12693 additions and 1245 deletions

View File

@@ -19,8 +19,8 @@ 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/rdp"
"github.com/netbirdio/netbird/client/wasm/internal/ssh"
"github.com/netbirdio/netbird/client/wasm/internal/vnc"
"github.com/netbirdio/netbird/util"
)
@@ -364,29 +364,133 @@ func createProxyRequestMethod(client *netbird.Client) js.Func {
})
}
// createRDPProxyMethod creates the RDP proxy method
func createRDPProxyMethod(client *netbird.Client) js.Func {
// createVNCProxyMethod creates the VNC proxy method for raw TCP-over-WebSocket bridging.
// JS signature: createVNCProxy(hostname, port, mode?, username?, jwt?, sessionID?, width?, height?)
// mode: "attach" (default) or "session"
// username: required when mode is "session"
// jwt: authentication token (from OIDC session)
// sessionID: Windows session ID (0 = console/auto)
// width/height: requested viewport size for session mode (0 = server default)
func createVNCProxyMethod(client *netbird.Client) js.Func {
return js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 2 {
return js.ValueOf("error: hostname and port required")
params, err := parseVNCProxyArgs(args)
if err != nil {
if params.rejectViaPromise {
return createPromise(func(resolve, reject js.Value) {
reject.Invoke(js.ValueOf(err.Error()))
})
}
return js.ValueOf(err.Error())
}
if args[0].Type() != js.TypeString {
return createPromise(func(resolve, reject js.Value) {
reject.Invoke(js.ValueOf("hostname parameter must be a string"))
})
}
if args[1].Type() != js.TypeString {
return createPromise(func(resolve, reject js.Value) {
reject.Invoke(js.ValueOf("port parameter must be a string"))
})
}
proxy := rdp.NewRDCleanPathProxy(client)
return proxy.CreateProxy(args[0].String(), args[1].String())
proxy := vnc.NewVNCProxy(client)
return proxy.CreateProxy(vnc.ProxyRequest{
Hostname: params.hostname,
Port: params.port,
Mode: params.mode,
Username: params.username,
JWT: params.jwt,
SessionID: params.sessionID,
Width: params.width,
Height: params.height,
})
})
}
type vncProxyParams struct {
hostname string
port string
mode string
username string
jwt string
sessionID uint32
width uint16
height uint16
rejectViaPromise bool // true when the JS caller expects a rejected Promise instead of a plain string return
}
// parseVNCProxyArgs validates JS args for createVNCProxyMethod and returns
// the parsed params plus the first validation error (nil on success).
// vncProxyParams.rejectViaPromise tells the caller which JS-side response
// path to use for the returned error.
func parseVNCProxyArgs(args []js.Value) (vncProxyParams, error) {
var p vncProxyParams
if err := parseVNCProxyRequiredArgs(args, &p); err != nil {
return p, err
}
if err := parseVNCProxyOptionalStrings(args, &p); err != nil {
return p, err
}
if err := parseVNCProxyOptionalNumbers(args, &p); err != nil {
return p, err
}
return p, nil
}
func parseVNCProxyRequiredArgs(args []js.Value, p *vncProxyParams) error {
if len(args) < 2 {
return fmt.Errorf("hostname and port required")
}
if args[0].Type() != js.TypeString {
p.rejectViaPromise = true
return fmt.Errorf("hostname parameter must be a string")
}
if args[1].Type() != js.TypeString {
p.rejectViaPromise = true
return fmt.Errorf("port parameter must be a string")
}
p.hostname = args[0].String()
p.port = args[1].String()
p.mode = "attach"
return nil
}
func parseVNCProxyOptionalStrings(args []js.Value, p *vncProxyParams) error {
if len(args) > 2 && args[2].Type() == js.TypeString {
p.mode = args[2].String()
}
if p.mode != "attach" && p.mode != "session" {
p.rejectViaPromise = true
return fmt.Errorf("invalid mode %q: expected \"attach\" or \"session\"", p.mode)
}
if len(args) > 3 && args[3].Type() == js.TypeString {
p.username = args[3].String()
}
if len(args) > 4 && args[4].Type() == js.TypeString {
p.jwt = args[4].String()
}
return nil
}
func parseVNCProxyOptionalNumbers(args []js.Value, p *vncProxyParams) error {
if len(args) > 5 && args[5].Type() == js.TypeNumber {
v := args[5].Int()
if v < 0 || v > 0xFFFFFFFF {
p.rejectViaPromise = true
return fmt.Errorf("invalid sessionID %d: must be 0..0xFFFFFFFF", v)
}
p.sessionID = uint32(v)
}
// width=0 / height=0 mean "use server default"; reject only out-of-range
// non-zero values so attach mode (which omits width/height) still works.
if len(args) > 6 && args[6].Type() == js.TypeNumber {
v := args[6].Int()
if v < 0 || v > 0xFFFF {
p.rejectViaPromise = true
return fmt.Errorf("invalid width %d: must be 0..65535", v)
}
p.width = uint16(v)
}
if len(args) > 7 && args[7].Type() == js.TypeNumber {
v := args[7].Int()
if v < 0 || v > 0xFFFF {
p.rejectViaPromise = true
return fmt.Errorf("invalid height %d: must be 0..65535", v)
}
p.height = uint16(v)
}
return nil
}
// getStatusOverview is a helper to get the status overview
func getStatusOverview(client *netbird.Client) (nbstatus.OutputOverview, error) {
fullStatus, err := client.Status()
@@ -676,7 +780,7 @@ func createClientObject(client *netbird.Client) js.Value {
obj["detectSSHServerType"] = createDetectSSHServerMethod(client)
obj["createSSHConnection"] = createSSHMethod(client)
obj["proxyRequest"] = createProxyRequestMethod(client)
obj["createRDPProxy"] = createRDPProxyMethod(client)
obj["createVNCProxy"] = createVNCProxyMethod(client)
obj["status"] = createStatusMethod(client)
obj["statusSummary"] = createStatusSummaryMethod(client)
obj["statusDetail"] = createStatusDetailMethod(client)

View File

@@ -0,0 +1,427 @@
//go:build js
package vnc
import (
"context"
"fmt"
"io"
"net"
"sync"
"sync/atomic"
"syscall/js"
"time"
log "github.com/sirupsen/logrus"
)
const (
vncProxyHost = "vnc.proxy.local"
vncProxyScheme = "ws"
vncDialTimeout = 15 * time.Second
// Connection modes matching server/server.go constants.
modeAttach byte = 0
modeSession byte = 1
)
// VNCProxy bridges WebSocket connections from noVNC in the browser
// to TCP VNC server connections through the NetBird tunnel.
type VNCProxy struct {
nbClient interface {
Dial(ctx context.Context, network, address string) (net.Conn, error)
}
activeConnections map[string]*vncConnection
destinations map[string]vncDestination
// pendingHandlers holds the js.Func for handleVNCWebSocket_<id> between
// CreateProxy and handleWebSocketConnection so we can move it onto the
// vncConnection for later release.
pendingHandlers map[string]js.Func
mu sync.Mutex
nextID atomic.Uint64
}
type vncDestination struct {
address string
mode byte
username string
jwt string
sessionID uint32 // Windows session ID (0 = auto/console)
width uint16 // Requested viewport width for session mode (0 = default)
height uint16 // Requested viewport height for session mode (0 = default)
}
type vncConnection struct {
id string
destination vncDestination
mu sync.Mutex
vncConn net.Conn
wsHandlers js.Value
ctx context.Context
cancel context.CancelFunc
// Go-side callbacks exposed to JS. js.FuncOf pins the Go closure in a
// global handle map and MUST be released, otherwise every connection
// leaks the Go memory the closure captures.
wsHandlerFn js.Func
onMessageFn js.Func
onCloseFn js.Func
}
// NewVNCProxy creates a new VNC proxy.
func NewVNCProxy(client interface {
Dial(ctx context.Context, network, address string) (net.Conn, error)
}) *VNCProxy {
return &VNCProxy{
nbClient: client,
activeConnections: make(map[string]*vncConnection),
}
}
// ProxyRequest bundles the per-call parameters for CreateProxy so the JS
// boundary doesn't drown callers in a wide positional argument list.
type ProxyRequest struct {
Hostname string
Port string
Mode string
Username string
JWT string
SessionID uint32
Width uint16
Height uint16
}
// CreateProxy creates a new proxy endpoint for the given VNC destination.
// req.Mode is "attach" (capture current display) or "session" (virtual session).
// req.Username is required for session mode. req.Width/Height request the
// virtual display geometry for session mode; 0 means use the server default.
// Returns a JS Promise that resolves to the WebSocket proxy URL.
func (p *VNCProxy) CreateProxy(req ProxyRequest) js.Value {
hostname, port, mode, username, jwt := req.Hostname, req.Port, req.Mode, req.Username, req.JWT
sessionID, width, height := req.SessionID, req.Width, req.Height
address := net.JoinHostPort(hostname, port)
var m byte
if mode == "session" {
m = modeSession
}
dest := vncDestination{
address: address,
mode: m,
username: username,
jwt: jwt,
sessionID: sessionID,
width: width,
height: height,
}
return p.newProxyPromise(address, mode, username, dest)
}
// newProxyPromise wraps the JS Promise creation + executor lifecycle so
// CreateProxy stays a thin parameter-bundling entrypoint.
func (p *VNCProxy) newProxyPromise(address, mode, username string, dest vncDestination) js.Value {
var executor js.Func
executor = js.FuncOf(func(_ js.Value, args []js.Value) any {
resolve := args[0]
go func() {
defer executor.Release()
proxyID := fmt.Sprintf("vnc_proxy_%d", p.nextID.Add(1))
p.mu.Lock()
if p.destinations == nil {
p.destinations = make(map[string]vncDestination)
}
p.destinations[proxyID] = dest
p.mu.Unlock()
proxyURL := fmt.Sprintf("%s://%s/%s", vncProxyScheme, vncProxyHost, proxyID)
handlerFn := js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return js.ValueOf("error: requires WebSocket argument")
}
p.handleWebSocketConnection(args[0], proxyID)
return nil
})
p.mu.Lock()
if p.pendingHandlers == nil {
p.pendingHandlers = make(map[string]js.Func)
}
p.pendingHandlers[proxyID] = handlerFn
p.mu.Unlock()
js.Global().Set(fmt.Sprintf("handleVNCWebSocket_%s", proxyID), handlerFn)
log.Infof("created VNC proxy: %s -> %s (mode=%s, user=%s)", proxyURL, address, mode, username)
resolve.Invoke(proxyURL)
}()
return nil
})
return js.Global().Get("Promise").New(executor)
}
func (p *VNCProxy) handleWebSocketConnection(ws js.Value, proxyID string) {
p.mu.Lock()
dest, ok := p.destinations[proxyID]
handlerFn := p.pendingHandlers[proxyID]
delete(p.pendingHandlers, proxyID)
p.mu.Unlock()
if !ok {
log.Errorf("no destination for VNC proxy %s", proxyID)
return
}
ctx, cancel := context.WithCancel(context.Background())
conn := &vncConnection{
id: proxyID,
destination: dest,
wsHandlers: ws,
ctx: ctx,
cancel: cancel,
wsHandlerFn: handlerFn,
}
p.mu.Lock()
p.activeConnections[proxyID] = conn
p.mu.Unlock()
p.setupWebSocketHandlers(ws, conn)
go p.connectToVNC(conn)
log.Infof("VNC proxy WebSocket connection established for %s", proxyID)
}
func (p *VNCProxy) setupWebSocketHandlers(ws js.Value, conn *vncConnection) {
conn.onMessageFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
if len(args) < 1 {
return nil
}
data := args[0]
go p.handleWebSocketMessage(conn, data)
return nil
})
ws.Set("onGoMessage", conn.onMessageFn)
conn.onCloseFn = js.FuncOf(func(_ js.Value, _ []js.Value) any {
log.Debug("VNC WebSocket closed by JavaScript")
conn.cancel()
return nil
})
ws.Set("onGoClose", conn.onCloseFn)
}
func (p *VNCProxy) handleWebSocketMessage(conn *vncConnection, data js.Value) {
if !data.InstanceOf(js.Global().Get("Uint8Array")) {
return
}
length := data.Get("length").Int()
buf := make([]byte, length)
js.CopyBytesToGo(buf, data)
conn.mu.Lock()
vncConn := conn.vncConn
conn.mu.Unlock()
if vncConn == nil {
return
}
if _, err := vncConn.Write(buf); err != nil {
log.Debugf("write to VNC server: %v", err)
}
}
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)
if err != nil {
log.Errorf("VNC connect to %s: %v", conn.destination.address, err)
// Close the WebSocket so noVNC fires a disconnect event.
if conn.wsHandlers.Get("close").Truthy() {
conn.wsHandlers.Call("close", 1006, fmt.Sprintf("connect to peer: %v", err))
}
p.cleanupConnection(conn)
return
}
conn.mu.Lock()
conn.vncConn = vncConn
conn.mu.Unlock()
// Send the NetBird VNC session header before the RFB handshake.
if err := p.sendSessionHeader(vncConn, conn.destination); err != nil {
log.Errorf("send VNC session header: %v", err)
if conn.wsHandlers.Get("close").Truthy() {
conn.wsHandlers.Call("close", 1006, fmt.Sprintf("send session header: %v", err))
}
p.cleanupConnection(conn)
return
}
// WS→TCP is handled by the onGoMessage handler set in setupWebSocketHandlers,
// which writes directly to the VNC connection as data arrives from JS.
// Only the TCP→WS direction needs a read loop here.
go p.forwardConnToWS(conn)
<-conn.ctx.Done()
p.cleanupConnection(conn)
}
// sendSessionHeader writes mode, username, JWT, Windows session ID, and the
// requested viewport size to the VNC server.
// Format: [mode:1] [username_len:2] [username:N] [jwt_len:2] [jwt:N]
//
// [session_id:4] [width:2] [height:2]
func (p *VNCProxy) sendSessionHeader(conn net.Conn, dest vncDestination) error {
usernameBytes := []byte(dest.username)
jwtBytes := []byte(dest.jwt)
if len(usernameBytes) > 0xFFFF {
return fmt.Errorf("username too long: %d bytes (max %d)", len(usernameBytes), 0xFFFF)
}
if len(jwtBytes) > 0xFFFF {
return fmt.Errorf("jwt too long: %d bytes (max %d)", len(jwtBytes), 0xFFFF)
}
hdr := make([]byte, 3+len(usernameBytes)+2+len(jwtBytes)+4+4)
hdr[0] = dest.mode
hdr[1] = byte(len(usernameBytes) >> 8)
hdr[2] = byte(len(usernameBytes))
off := 3
copy(hdr[off:], usernameBytes)
off += len(usernameBytes)
hdr[off] = byte(len(jwtBytes) >> 8)
hdr[off+1] = byte(len(jwtBytes))
off += 2
copy(hdr[off:], jwtBytes)
off += len(jwtBytes)
hdr[off] = byte(dest.sessionID >> 24)
hdr[off+1] = byte(dest.sessionID >> 16)
hdr[off+2] = byte(dest.sessionID >> 8)
hdr[off+3] = byte(dest.sessionID)
off += 4
hdr[off] = byte(dest.width >> 8)
hdr[off+1] = byte(dest.width)
hdr[off+2] = byte(dest.height >> 8)
hdr[off+3] = byte(dest.height)
for off := 0; off < len(hdr); {
n, err := conn.Write(hdr[off:])
if err != nil {
return fmt.Errorf("write session header: %w", err)
}
off += n
}
return nil
}
func (p *VNCProxy) forwardConnToWS(conn *vncConnection) {
buf := make([]byte, 32*1024)
for {
if conn.ctx.Err() != nil {
return
}
vc, ok := conn.snapshotVNC()
if !ok {
return
}
if err := vc.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
log.Debugf("set VNC read deadline: %v", err)
}
n, err := vc.Read(buf)
if err != nil {
if p.handleConnReadError(conn, err) {
return
}
continue
}
if n > 0 {
p.sendToWebSocket(conn, buf[:n])
}
}
}
// snapshotVNC returns the current vncConn under conn.mu, with ok=false when
// the connection has already been cleaned up.
func (c *vncConnection) snapshotVNC() (net.Conn, bool) {
c.mu.Lock()
defer c.mu.Unlock()
if c.vncConn == nil {
return nil, false
}
return c.vncConn, true
}
// handleConnReadError classifies an error from the VNC read loop. Returns
// true if the caller should exit; false to retry (transient timeout).
func (p *VNCProxy) handleConnReadError(conn *vncConnection, err error) bool {
if conn.ctx.Err() != nil {
return true
}
if netErr, ok := err.(interface{ Timeout() bool }); ok && netErr.Timeout() {
// Read timeout: connection might be stale. The next iteration will
// fail too and trigger the close path.
return false
}
if err != io.EOF {
log.Debugf("read from VNC connection: %v", err)
}
// Close the WebSocket to notify noVNC, and cancel the local context so
// cleanupConnection isn't left waiting on the JS close callback that
// may never fire on hard errors.
if conn.wsHandlers.Get("close").Truthy() {
conn.wsHandlers.Call("close", 1006, "VNC connection lost")
}
conn.cancel()
return true
}
func (p *VNCProxy) sendToWebSocket(conn *vncConnection, data []byte) {
if conn.wsHandlers.Get("receiveFromGo").Truthy() {
uint8Array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(uint8Array, data)
conn.wsHandlers.Call("receiveFromGo", uint8Array.Get("buffer"))
} else if conn.wsHandlers.Get("send").Truthy() {
uint8Array := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(uint8Array, data)
conn.wsHandlers.Call("send", uint8Array.Get("buffer"))
}
}
func (p *VNCProxy) cleanupConnection(conn *vncConnection) {
log.Debugf("cleaning up VNC connection %s", conn.id)
conn.cancel()
conn.mu.Lock()
vncConn := conn.vncConn
conn.vncConn = nil
conn.mu.Unlock()
if vncConn != nil {
if err := vncConn.Close(); err != nil {
log.Debugf("close VNC connection: %v", err)
}
}
// Remove the global JS handler registered in CreateProxy.
globalName := fmt.Sprintf("handleVNCWebSocket_%s", conn.id)
js.Global().Delete(globalName)
// Release all js.Func handles; js.FuncOf pins the Go closure and the
// allocations it captures until Release is called.
conn.wsHandlerFn.Release()
conn.onMessageFn.Release()
conn.onCloseFn.Release()
p.mu.Lock()
delete(p.activeConnections, conn.id)
delete(p.destinations, conn.id)
delete(p.pendingHandlers, conn.id)
p.mu.Unlock()
}