Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
dec25e1
video: refcount the capture pipeline
mcuelenaere May 1, 2026
389b9fb
internal/rfb: protocol primitives
mcuelenaere May 1, 2026
b7ca78a
internal/rfb: keysym to HID Usage ID mapping
mcuelenaere May 1, 2026
53bee10
internal/rfb: placeholder framebuffer for non-H.264 clients
mcuelenaere May 1, 2026
54319ea
vnc: scaffold (config, JSON-RPC, logger)
mcuelenaere May 1, 2026
88058f2
vnc: server, frame fan-out, HID forwarding
mcuelenaere May 1, 2026
b5522bc
ui: VNC settings panel
mcuelenaere May 1, 2026
776a5f6
vnc: drop VncAllowOverWAN, always allow non-loopback bind
mcuelenaere May 6, 2026
1d41410
vnc: pin H.264 codec when VNC starts the capture pipeline
mcuelenaere May 6, 2026
ccc66b3
vnc: cycle codec to H.264 when VNC is enabled mid-session
mcuelenaere May 6, 2026
6dcd42c
vnc: fix deadlock + linter + bugbot review issues
mcuelenaere May 6, 2026
a816f53
internal/rfb: move splitAnnexB into the rfb package
mcuelenaere May 6, 2026
cb65bcf
vnc: fix RFB->HID button bit swap and vncServer data race
mcuelenaere May 6, 2026
ad1c334
vnc: trace every KeyEvent for keymap debugging
mcuelenaere May 7, 2026
3508879
vnc: trace every PointerEvent, document side-button situation
mcuelenaere May 7, 2026
9a05352
vnc: reduce H.264 scroll artifacts (TCP_NODELAY, larger buffer, drop …
mcuelenaere May 7, 2026
588f70d
vnc: drop the per-rect emission trace logs
mcuelenaere May 7, 2026
5367ff7
vnc: add macOS-client keymap to fix TightVNC modifier mismapping
mcuelenaere May 7, 2026
ba5cd0a
vnc: implement Extended Mouse Buttons (RFB encoding -316)
mcuelenaere May 7, 2026
c259a9b
vnc: tighten TCP write path + skip duplicate SPS/PPS prepend
mcuelenaere May 7, 2026
83ca5c2
vnc: shorten the broken-decoder window after a frame drop
mcuelenaere May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,26 @@ type Config struct {
VideoCodecPreference string `json:"video_codec_preference"`
NativeMaxRestart uint `json:"native_max_restart_attempts"`
MqttConfig *MQTTConfig `json:"mqtt_config"`

// VncEnabled toggles the VNC server. When true, the WebRTC video
// codec is forced to H.264 — the OpenH264 RFB pseudo-encoding (50)
// only carries H.264, and a mid-stream H.265 negotiation would
// desync any connected VNC client's decoder.
VncEnabled bool `json:"vnc_enabled"`
// VncPort is the TCP port the VNC server listens on. Default 5900.
VncPort int `json:"vnc_port"`
// VncPassword is the plaintext VNCAuth password (max 8 characters
// used; longer values are truncated). An empty value disables
// authentication entirely. VNCAuth transmits responses over plain
// TCP and is trivially crackable from a captured handshake; tunnel
// via SSH or Tailscale on untrusted networks.
VncPassword string `json:"vnc_password"`
// VncKeymap selects per-client keysym overrides for VNC clients
// whose keysym mapping doesn't match the X11 convention the
// keysym table assumes. Empty / "default" applies no overrides.
// "macos" applies the TightVNC-on-macOS quirks (Mode_switch ->
// LeftOption, Alt_L -> LeftCmd, Super_L -> RightCmd).
VncKeymap string `json:"vnc_keymap"`
}

// GetUpdateAPIURL returns the update API URL
Expand Down Expand Up @@ -212,6 +232,10 @@ func getDefaultConfig() Config {
EnableActions: true,
DebounceMs: 500,
},
VncEnabled: false,
VncPort: 5900,
VncPassword: "",
VncKeymap: "default",
}
}

Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ require (
github.com/coder/websocket v1.8.14
github.com/coreos/go-oidc/v3 v3.16.0
github.com/creack/pty v1.1.24
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/erikdubbelboer/gspt v0.0.0-20210805194459-ce36a5128377
github.com/fsnotify/fsnotify v1.9.0
github.com/gin-contrib/logger v1.2.6
Expand Down Expand Up @@ -37,6 +38,7 @@ require (
github.com/vishvananda/netlink v1.3.1
go.bug.st/serial v1.6.4
golang.org/x/crypto v0.43.0
golang.org/x/image v0.18.0
golang.org/x/net v0.46.0
golang.org/x/sys v0.37.0
google.golang.org/grpc v1.76.0
Expand All @@ -54,7 +56,6 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/creack/goselect v0.1.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/eclipse/paho.mqtt.golang v1.5.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
Expand Down
127 changes: 127 additions & 0 deletions internal/rfb/conn.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
package rfb

import (
"bufio"
"encoding/binary"
"io"
"net"
"time"
)

// Conn wraps a net.Conn with framing buffers for the RFB protocol.
//
// The Conn assumes a single-writer model: at most one goroutine
// invokes write methods on the same Conn at a time. In typical use
// the handshake runs sequentially on one goroutine and is then
// followed by a per-connection dispatcher (also single goroutine)
// while a separate reader goroutine only calls read methods. Do not
// share writes between goroutines without external synchronization.
type Conn struct {
nc net.Conn
r *bufio.Reader
w *bufio.Writer

// extendedMouseButtons indicates that the Extended Mouse Buttons
// extension (encoding -316) has been negotiated. The PointerEvent
// reader uses it to pick the legacy (6-byte) or extended (7-byte)
// wire format. Set by SetExtendedMouseButtons after the server
// has sent the announce rectangle. Single-writer model also
// applies to this flag.
extendedMouseButtons bool
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Data race on extendedMouseButtons between goroutines

Medium Severity

The extendedMouseButtons field on rfb.Conn is a plain bool written by the dispatch goroutine via SetExtendedMouseButtons(true) and read concurrently by the read goroutine in ReadClientMessage. This is a data race under Go's memory model — no synchronization (mutex, atomic, or channel) ensures the reader sees the updated value. If the reader sees a stale false, it will parse an extended 7-byte PointerEvent as the legacy 6-byte format, consuming one byte too few and permanently misaligning all subsequent message parsing on that connection.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit ba5cd0a. Configure here.

}

// SetExtendedMouseButtons enables decoding of the Extended Mouse
// Buttons extension on the next PointerEvent message. Call once
// after the announce rectangle has been written to the client.
func (c *Conn) SetExtendedMouseButtons(enabled bool) { c.extendedMouseButtons = enabled }

// writeBufferSize is the bufio.Writer buffer for outbound bytes.
// Picked to comfortably hold one typical 1080p H.264 P-frame so a
// FramebufferUpdate with the rect headers + length/flags + payload
// goes out in a single Write to the underlying TCP socket — the
// default 4 KB caused ~12 syscalls per frame and as many TCP segments
// when NoDelay forced an immediate flush.
const writeBufferSize = 64 * 1024

// NewConn wraps a net.Conn with read/write buffers.
func NewConn(nc net.Conn) *Conn {
return &Conn{
nc: nc,
r: bufio.NewReader(nc),
w: bufio.NewWriterSize(nc, writeBufferSize),
}
}

// Close terminates the underlying connection.
func (c *Conn) Close() error { return c.nc.Close() }

// RemoteAddr returns the peer's network address.
func (c *Conn) RemoteAddr() net.Addr { return c.nc.RemoteAddr() }

// SetReadDeadline forwards to the underlying net.Conn.
func (c *Conn) SetReadDeadline(t time.Time) error { return c.nc.SetReadDeadline(t) }

// SetWriteDeadline forwards to the underlying net.Conn.
func (c *Conn) SetWriteDeadline(t time.Time) error { return c.nc.SetWriteDeadline(t) }

// Flush flushes the write buffer to the underlying connection.
func (c *Conn) Flush() error { return c.w.Flush() }

// readByte reads one byte.
func (c *Conn) readByte() (byte, error) { return c.r.ReadByte() }

// readFull fills buf entirely.
func (c *Conn) readFull(buf []byte) error {
_, err := io.ReadFull(c.r, buf)
return err
}

// readU16 reads a big-endian uint16.
func (c *Conn) readU16() (uint16, error) {
var b [2]byte
if err := c.readFull(b[:]); err != nil {
return 0, err
}
return binary.BigEndian.Uint16(b[:]), nil
}

// readU32 reads a big-endian uint32.
func (c *Conn) readU32() (uint32, error) {
var b [4]byte
if err := c.readFull(b[:]); err != nil {
return 0, err
}
return binary.BigEndian.Uint32(b[:]), nil
}

// readS32 reads a big-endian int32.
func (c *Conn) readS32() (int32, error) {
v, err := c.readU32()
return int32(v), err
}

// writeRaw writes buf verbatim to the buffered writer.
func (c *Conn) writeRaw(buf []byte) error {
_, err := c.w.Write(buf)
return err
}

// writeByte writes a single byte.
func (c *Conn) writeByte(b byte) error { return c.w.WriteByte(b) }

// writeU16 writes a big-endian uint16.
func (c *Conn) writeU16(v uint16) error {
var b [2]byte
binary.BigEndian.PutUint16(b[:], v)
return c.writeRaw(b[:])
}

// writeU32 writes a big-endian uint32.
func (c *Conn) writeU32(v uint32) error {
var b [4]byte
binary.BigEndian.PutUint32(b[:], v)
return c.writeRaw(b[:])
}

// writeS32 writes a big-endian int32.
func (c *Conn) writeS32(v int32) error { return c.writeU32(uint32(v)) }
101 changes: 101 additions & 0 deletions internal/rfb/encodings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package rfb

import "fmt"

// Rect describes a rectangle that will be sent inside a
// FramebufferUpdate message header. The encoding-specific payload
// follows separately.
type Rect struct {
X, Y, W, H uint16
Encoding EncodingType
}

// BeginFramebufferUpdate writes the FramebufferUpdate message header
// (RFC 6143 §7.6.1) for `n` rectangles. The caller must follow up
// with `n` WriteRectHeader+payload pairs and a final Conn.Flush.
// The Conn type assumes a single-writer model — see Conn's doc.
func (c *Conn) BeginFramebufferUpdate(n uint16) error {
if err := c.writeByte(byte(ServerMsgFramebufferUpdate)); err != nil {
return err
}
if err := c.writeByte(0); err != nil { // padding
return err
}
return c.writeU16(n)
}

// WriteRectHeader writes a 12-byte rectangle header (x, y, w, h,
// encoding). The caller is responsible for writing the
// encoding-specific payload immediately after.
func (c *Conn) WriteRectHeader(r Rect) error {
if err := c.writeU16(r.X); err != nil {
return err
}
if err := c.writeU16(r.Y); err != nil {
return err
}
if err := c.writeU16(r.W); err != nil {
return err
}
if err := c.writeU16(r.H); err != nil {
return err
}
return c.writeS32(int32(r.Encoding))
}

// WriteRawRect writes a Raw-encoded rectangle. `pixels` must be
// width*height*(bitsPerPixel/8) bytes laid out in row-major order
// using the format previously requested by the client (or the
// server's default if SetPixelFormat was never sent).
func (c *Conn) WriteRawRect(r Rect, pixels []byte) error {
if r.Encoding != EncodingRaw {
return fmt.Errorf("rfb: WriteRawRect called with encoding %d", r.Encoding)
}
if err := c.WriteRectHeader(r); err != nil {
return err
}
return c.writeRaw(pixels)
}

// WriteOpenH264Rect writes an "Open H.264 Encoding" rectangle
// (pseudo-encoding 50). The H.264 payload must be a valid Annex-B
// bytestream — typically one or more NAL units concatenated with
// 00 00 00 01 (or 00 00 01) start codes.
//
// `flags` is the U32 flags field; commonly OpenH264FlagResetContext
// when the rectangle should reset the client's decoder state (e.g.
// on first frame, after a resolution change, or after any non-H.264
// fallback rectangle).
func (c *Conn) WriteOpenH264Rect(r Rect, flags uint32, h264 []byte) error {
if r.Encoding != EncodingOpenH264 {
return fmt.Errorf("rfb: WriteOpenH264Rect called with encoding %d", r.Encoding)
}
if err := c.WriteRectHeader(r); err != nil {
return err
}
if err := c.writeU32(uint32(len(h264))); err != nil {
return err
}
if err := c.writeU32(flags); err != nil {
return err
}
return c.writeRaw(h264)
}

// WriteDesktopSizeRect emits a DesktopSize pseudo-encoding rectangle
// (-223). The framebuffer's new dimensions are encoded in the rect's
// W/H fields; X/Y are zero. There is no payload.
func (c *Conn) WriteDesktopSizeRect(width, height uint16) error {
r := Rect{X: 0, Y: 0, W: width, H: height, Encoding: EncodingDesktopSize}
return c.WriteRectHeader(r)
}

// WriteExtendedMouseButtonsAnnounceRect emits the fake 0×0 rectangle
// that confirms server-side support for the Extended Mouse Buttons
// extension (encoding -316). After writing it, callers should
// transition the connection's reader to extended mode via
// SetExtendedMouseButtons(true).
func (c *Conn) WriteExtendedMouseButtonsAnnounceRect() error {
r := Rect{X: 0, Y: 0, W: 0, H: 0, Encoding: EncodingExtendedMouseButtons}
return c.WriteRectHeader(r)
}
Loading
Loading