//go:build js package vnc import ( "context" crand "crypto/rand" "encoding/base64" "errors" "fmt" "io" "net" "sync" "sync/atomic" "syscall/js" "time" "github.com/flynn/noise" log "github.com/sirupsen/logrus" ) var cryptoRandRead = crand.Read // vncIdentityMagic mirrors the server side in client/vnc/server/server.go. var vncIdentityMagic = []byte("NBV3") // Noise_IK_25519_ChaChaPoly_SHA256 message sizes (with empty payloads). const ( noiseInitiatorMsgLen = 96 noiseResponderMsgLen = 48 ) var vncNoiseSuite = noise.NewCipherSuite(noise.DH25519, noise.CipherChaChaPoly, noise.HashSHA256) // sessionKeyStore retains per-session X25519 keypairs so the JS layer // only sees an opaque session id + the public key; the private key never // leaves wasm. var sessionKeyStore = struct { mu sync.Mutex keys map[string]noise.DHKey }{keys: map[string]noise.DHKey{}} // NewSessionKey mints an X25519 keypair, stores the private half under a // fresh random session id, and returns (id, pubkey). func NewSessionKey() (string, []byte, error) { kp, err := noise.DH25519.GenerateKeypair(nil) if err != nil { return "", nil, fmt.Errorf("generate keypair: %w", err) } idBytes := make([]byte, 16) if _, err := cryptoRandRead(idBytes); err != nil { return "", nil, fmt.Errorf("session id randomness: %w", err) } id := base64.RawURLEncoding.EncodeToString(idBytes) sessionKeyStore.mu.Lock() sessionKeyStore.keys[id] = kp sessionKeyStore.mu.Unlock() return id, kp.Public, nil } // lookupSessionKey returns the keypair for id, or false if unknown. func lookupSessionKey(id string) (noise.DHKey, bool) { sessionKeyStore.mu.Lock() defer sessionKeyStore.mu.Unlock() kp, ok := sessionKeyStore.keys[id] return kp, ok } // dropSessionKey removes the keypair for id. Called after the VNC // connection closes (or after a connect attempt fails terminally). func dropSessionKey(id string) { sessionKeyStore.mu.Lock() delete(sessionKeyStore.keys, id) sessionKeyStore.mu.Unlock() } const ( vncProxyHost = "vnc.proxy.local" vncProxyScheme = "ws" vncDialTimeout = 15 * time.Second // Connection modes matching server/server.go constants. modeAttach byte = 0 modeSession byte = 1 // WebSocket close codes the dashboard branches on. Codes 1000-1015 // are reserved by RFC 6455; 4000-4999 are application-defined. wsCodeNormal = 1000 wsCodeAbnormal = 1006 wsCodeDialTimeout = 4001 wsCodeDialFailure = 4002 wsCodeSessionSetup = 4003 wsCodeTransport = 4004 ) // VNCProxy bridges WebSocket connections from noVNC in the browser // to TCP VNC server connections through the NetBird tunnel. type vncNBClient interface { Dial(ctx context.Context, network, address string) (net.Conn, error) } type VNCProxy struct { nbClient vncNBClient activeConnections map[string]*vncConnection destinations map[string]vncDestination // pendingHandlers holds the js.Func for handleVNCWebSocket_ 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 sessionPriv []byte sessionPub []byte sessionID uint32 width uint16 height uint16 peerPubKey []byte } 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 vncNBClient) *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 SessionID uint32 Width uint16 Height uint16 // PeerPublicKey is the destination peer's base64 X25519 public key, // used as the responder static in the Noise_IK handshake. PeerPublicKey string // KeySessionID is the handle returned by generateVNCSessionKey. The // matching private key is looked up inside wasm and never crosses // the JS boundary. KeySessionID string } // 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 := req.Hostname, req.Port, req.Mode, req.Username 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, sessionID: sessionID, width: width, height: height, } if req.KeySessionID != "" { kp, ok := lookupSessionKey(req.KeySessionID) if !ok { return rejectedPromise("unknown VNC session id") } // A session handle is single-use; drop it before the destination // holds the private bytes so a leaked handle can't be replayed. dropSessionKey(req.KeySessionID) dest.sessionPriv = kp.Private dest.sessionPub = kp.Public pub, err := decodePeerPubKey(req.PeerPublicKey) if err != nil { return rejectedPromise(fmt.Sprintf("invalid peer public key: %v", err)) } dest.peerPubKey = pub } return p.newProxyPromise(address, mode, username, dest) } // decodePeerPubKey parses a base64-encoded 32-byte X25519 public key. func decodePeerPubKey(b64 string) ([]byte, error) { if b64 == "" { return nil, errors.New("peer public key missing") } raw, err := base64.StdEncoding.DecodeString(b64) if err != nil { return nil, fmt.Errorf("base64 decode: %w", err) } if len(raw) != 32 { return nil, fmt.Errorf("expected 32 bytes, got %d", len(raw)) } return raw, nil } // rejectedPromise returns a resolved Promise carrying msg as an error // string, mirroring how CreateProxy reports earlier validation failures. func rejectedPromise(msg string) js.Value { promise := js.Global().Get("Promise") return promise.Call("resolve", js.ValueOf(msg)) } // 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. code := wsCodeDialFailure if errors.Is(err, context.DeadlineExceeded) { code = wsCodeDialTimeout } if conn.wsHandlers.Get("close").Truthy() { conn.wsHandlers.Call("close", code, 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", wsCodeSessionSetup, 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 the NetBird VNC connection header: mode + // username prefix, an optional Noise_IK handshake that authenticates the // client and the server, then the trailing sessionID / width / height // fields the daemon needs once auth is settled. func (p *VNCProxy) sendSessionHeader(conn net.Conn, dest vncDestination) error { usernameBytes := []byte(dest.username) if len(usernameBytes) > 0xFFFF { return fmt.Errorf("username too long: %d bytes (max %d)", len(usernameBytes), 0xFFFF) } prefix := make([]byte, 3+len(usernameBytes)) prefix[0] = dest.mode prefix[1] = byte(len(usernameBytes) >> 8) prefix[2] = byte(len(usernameBytes)) copy(prefix[3:], usernameBytes) if err := writeAll(conn, prefix); err != nil { return fmt.Errorf("write header prefix: %w", err) } if dest.sessionPriv == nil { return p.writeHeaderTail(conn, dest) } if err := p.runNoiseHandshake(conn, dest); err != nil { return fmt.Errorf("noise handshake: %w", err) } return p.writeHeaderTail(conn, dest) } // writeHeaderTail writes the post-auth trailing fields (sessionID, // width, height) the daemon reads regardless of whether the Noise // handshake was performed. func (p *VNCProxy) writeHeaderTail(conn net.Conn, dest vncDestination) error { tail := make([]byte, 4+4) tail[0] = byte(dest.sessionID >> 24) tail[1] = byte(dest.sessionID >> 16) tail[2] = byte(dest.sessionID >> 8) tail[3] = byte(dest.sessionID) tail[4] = byte(dest.width >> 8) tail[5] = byte(dest.width) tail[6] = byte(dest.height >> 8) tail[7] = byte(dest.height) if err := writeAll(conn, tail); err != nil { return fmt.Errorf("write header tail: %w", err) } return nil } // runNoiseHandshake performs the initiator side of a Noise_IK handshake // against the destination daemon. The session keypair authenticates the // client; the daemon's pre-known peer pubkey authenticates the server. func (p *VNCProxy) runNoiseHandshake(conn net.Conn, dest vncDestination) error { state, err := noise.NewHandshakeState(noise.Config{ CipherSuite: vncNoiseSuite, Pattern: noise.HandshakeIK, Initiator: true, StaticKeypair: noise.DHKey{Private: dest.sessionPriv, Public: dest.sessionPub}, PeerStatic: dest.peerPubKey, }) if err != nil { return fmt.Errorf("noise initiator init: %w", err) } msg1, _, _, err := state.WriteMessage(nil, nil) if err != nil { return fmt.Errorf("noise write msg1: %w", err) } out := make([]byte, 0, len(vncIdentityMagic)+len(msg1)) out = append(out, vncIdentityMagic...) out = append(out, msg1...) if err := writeAll(conn, out); err != nil { return fmt.Errorf("send noise msg1: %w", err) } if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil { return fmt.Errorf("set noise deadline: %w", err) } defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck msg2 := make([]byte, noiseResponderMsgLen) if _, err := io.ReadFull(conn, msg2); err != nil { return fmt.Errorf("read noise msg2: %w", err) } if _, _, _, err := state.ReadMessage(nil, msg2); err != nil { return fmt.Errorf("noise read msg2: %w", err) } return nil } func writeAll(conn net.Conn, buf []byte) error { for off := 0; off < len(buf); { n, err := conn.Write(buf[off:]) if err != nil { return 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 and trigger the cleanup path. A read // timeout counts as a fatal error: in a healthy session the server emits // empty FramebufferUpdate responses several times per second, so a full // idleReadDeadline of silence means the peer is dead (process gone, // machine off, network partition) and the in-browser TCP stack will // never surface that on its own. 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() { log.Debugf("VNC read deadline expired; treating peer as dead") } else if err != io.EOF { log.Debugf("read from VNC connection: %v", err) } if conn.wsHandlers.Get("close").Truthy() { conn.wsHandlers.Call("close", wsCodeTransport, "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() }