Files
netbird/client/vnc/server/extclipboard.go

156 lines
5.6 KiB
Go

//go:build !js && !ios && !android
package server
import (
"bytes"
"compress/zlib"
"encoding/binary"
"fmt"
"io"
)
// ExtendedClipboard is an RFB community extension (pseudo-encoding
// 0xC0A1E5CE) that replaces legacy CutText with a Caps/Notify/Request/
// Provide/Peek handshake. Wins versus legacy CutText:
// - UTF-8 text format (legacy is Latin-1).
// - Pull-based: a Notify announces "I have new content", the peer fetches
// via Request only when it actually needs the data. Saves bandwidth on
// a high-latency relay path versus pushing every change.
// - zlib-compressed payloads.
// - Caps negotiation so each side knows the other's per-format max size.
//
// The extension reuses message opcodes 3 (ServerCutText) and 6 (ClientCutText)
// and signals "extended" by encoding the length field as a negative int32;
// the absolute value is the payload size in bytes. The first 4 bytes of
// payload are a flags word: top byte is the action, low 16 bits are the
// format mask.
const pseudoEncExtendedClipboard = -1063131698 // 0xC0A1E5CE as int32
const (
extClipActionCaps uint32 = 0x01000000
extClipActionRequest uint32 = 0x02000000
extClipActionPeek uint32 = 0x04000000
extClipActionNotify uint32 = 0x08000000
extClipActionProvide uint32 = 0x10000000
extClipActionMask uint32 = 0x1F000000
extClipFormatText uint32 = 0x00000001
extClipFormatRTF uint32 = 0x00000002
extClipFormatHTML uint32 = 0x00000004
extClipFormatDIB uint32 = 0x00000008
extClipFormatFiles uint32 = 0x00000010
extClipFormatMask uint32 = 0x0000FFFF
// extClipMaxText caps our accepted text payload. Mirrors the legacy
// maxCutTextBytes (1 MiB); advertised in Caps and enforced on Provide.
extClipMaxText = maxCutTextBytes
// extClipMaxPayload bounds the raw on-wire payload we will read for an
// extended CutText message. Includes flags header, length prefixes, NUL,
// and zlib framing overhead on top of the text body.
extClipMaxPayload = extClipMaxText + 1024
)
// buildExtClipCaps emits the Caps payload. The flags word advertises every
// action we support in the high byte (Caps + Request + Peek + Notify +
// Provide) and every format we accept in the low 16 bits. noVNC uses these
// action bits to decide whether to auto-Request on Notify; without
// Request in our Caps it silently drops our Notify messages. After the
// flags word we emit one uint32 max size per format bit set, in ascending
// bit order.
func buildExtClipCaps() []byte {
flags := extClipActionCaps | extClipActionRequest | extClipActionPeek |
extClipActionNotify | extClipActionProvide | extClipFormatText
payload := make([]byte, 4+4)
binary.BigEndian.PutUint32(payload[0:4], flags)
binary.BigEndian.PutUint32(payload[4:8], uint32(extClipMaxText))
return payload
}
// buildExtClipNotify emits a Notify announcing that we have new clipboard
// content available in the given format mask. No data is shipped; the peer
// pulls via Request when it actually needs to paste.
func buildExtClipNotify(formats uint32) []byte {
payload := make([]byte, 4)
binary.BigEndian.PutUint32(payload, extClipActionNotify|formats)
return payload
}
// buildExtClipRequest emits a Request asking the peer to send Provide for
// the given format mask. Sent in response to an inbound Notify.
func buildExtClipRequest(formats uint32) []byte {
payload := make([]byte, 4)
binary.BigEndian.PutUint32(payload, extClipActionRequest|formats)
return payload
}
// buildExtClipProvideText emits a Provide carrying UTF-8 text. The inner
// stream (4-byte length including the trailing NUL, then UTF-8 bytes, then
// NUL) is zlib-compressed; each Provide uses an independent zlib context
// per the extension spec.
func buildExtClipProvideText(text string) ([]byte, error) {
body := make([]byte, 0, 4+len(text)+1)
var lenBuf [4]byte
binary.BigEndian.PutUint32(lenBuf[:], uint32(len(text)+1))
body = append(body, lenBuf[:]...)
body = append(body, text...)
body = append(body, 0)
var compressed bytes.Buffer
zw := zlib.NewWriter(&compressed)
if _, err := zw.Write(body); err != nil {
return nil, fmt.Errorf("zlib write: %w", err)
}
if err := zw.Close(); err != nil {
return nil, fmt.Errorf("zlib close: %w", err)
}
payload := make([]byte, 4+compressed.Len())
binary.BigEndian.PutUint32(payload[0:4], extClipActionProvide|extClipFormatText)
copy(payload[4:], compressed.Bytes())
return payload, nil
}
// parseExtClipProvideText decompresses a Provide payload (the bytes after
// the 4-byte flags header) and returns the UTF-8 text record if the text
// format bit is set. Records for other formats are skipped. The trailing
// NUL byte the spec appends to text records is stripped.
func parseExtClipProvideText(flags uint32, payload []byte) (string, error) {
zr, err := zlib.NewReader(bytes.NewReader(payload))
if err != nil {
return "", fmt.Errorf("zlib reader: %w", err)
}
defer zr.Close()
limited := io.LimitReader(zr, int64(extClipMaxText)+16)
var text string
for bit := uint32(1); bit <= extClipFormatFiles; bit <<= 1 {
if flags&bit == 0 {
continue
}
var sizeBuf [4]byte
if _, err := io.ReadFull(limited, sizeBuf[:]); err != nil {
if bit == extClipFormatText && err == io.EOF {
return "", nil
}
return "", fmt.Errorf("read record size: %w", err)
}
size := binary.BigEndian.Uint32(sizeBuf[:])
if size > uint32(extClipMaxText) {
return "", fmt.Errorf("record too large: %d", size)
}
rec := make([]byte, size)
if _, err := io.ReadFull(limited, rec); err != nil {
return "", fmt.Errorf("read record: %w", err)
}
if bit == extClipFormatText {
if len(rec) > 0 && rec[len(rec)-1] == 0 {
rec = rec[:len(rec)-1]
}
text = string(rec)
}
}
return text, nil
}