Commit 88aea29

bryfry <bryon@fryer.io>
2026-01-27 10:15:46
docs
1 parent 6001ced
cmd/govnc/main.go
@@ -276,7 +276,9 @@ func readInputFile(ctx context.Context, client *rfb.Client, path string) error {
 		return fmt.Errorf("reading input file: %w", err)
 	}
 
-	slog.Info("typing from file", slog.String("file", path), slog.Int("len", len(data)))
+	slog.Info("typing from file",
+		slog.String("file", path),
+		slog.Int("len", len(data)))
 	err = client.Type(ctx, string(data))
 	if err != nil {
 		return fmt.Errorf("typing: %w", err)
pkg/input/clipboard.go
@@ -102,7 +102,8 @@ func (cm *ClipboardManager) Start(ctx context.Context) error {
 		return fmt.Errorf("watching directory: %w", err)
 	}
 
-	slog.Info("watching clipboard directory", slog.String("dir", cm.watchDir))
+	slog.Info("watching clipboard directory",
+		slog.String("dir", cm.watchDir))
 
 	for {
 		select {
@@ -199,7 +200,8 @@ func (cm *ClipboardManager) OnReceive(text string) {
 	if cm.handler != nil {
 		err := cm.handler(text)
 		if err != nil {
-			slog.Warn("clipboard handler error", slog.String("error", err.Error()))
+			slog.Warn("clipboard handler error",
+				slog.String("error", err.Error()))
 		}
 	}
 }
pkg/web/server.go
@@ -153,7 +153,8 @@ func (s *Server) setupNoVNC(mux *http.ServeMux) error {
 		// Serve from filesystem path
 		fileServer := http.FileServer(http.Dir(s.config.NoVNCPath))
 		mux.Handle("/noVNC/", http.StripPrefix("/noVNC/", fileServer))
-		slog.Info("serving noVNC from filesystem", slog.String("path", s.config.NoVNCPath))
+		slog.Info("serving noVNC from filesystem",
+			slog.String("path", s.config.NoVNCPath))
 		return nil
 	}
 
main.go
@@ -1,519 +0,0 @@
-// Package main provides a standalone VNC-over-WebSocket client.
-// This is a minimal client for testing and development purposes.
-// For production use, see cmd/govnc.
-package main
-
-import (
-	"context"
-	"encoding/binary"
-	"flag"
-	"fmt"
-	"io"
-	"log/slog"
-	"net/http"
-	"net/url"
-	"os"
-	"path/filepath"
-	"strings"
-	"time"
-
-	"github.com/chzyer/readline"
-	"github.com/coder/websocket"
-	"github.com/fsnotify/fsnotify"
-	"golang.org/x/sync/errgroup"
-)
-
-const (
-	_rfbVersion = "RFB 003.008\n"
-
-	// RFB client-to-server message types
-	_msgTypeSetEncodings             = 2
-	_msgTypeFramebufferUpdateRequest = 3
-	_msgTypeKeyEvent                 = 4
-	_msgTypeClientCutText            = 6
-
-	// RFB server-to-client message types
-	_msgTypeFramebufferUpdate   = 0
-	_msgTypeSetColourMapEntries = 1
-	_msgTypeBell                = 2
-	_msgTypeServerCutText       = 3
-)
-
-var (
-	flagCookies = flag.String("cookies", "", "Session cookies (or set VNC_COOKIES env var)")
-	flagURL     = flag.String("url", "wss://presidentscup.cisa.gov/ctf/ng/vnc/access/websockify", "WebSocket URL")
-)
-
-func main() {
-	flag.Parse()
-	err := run()
-	if err != nil {
-		slog.Error("startup failed", slog.String("error", err.Error()))
-		os.Exit(1)
-	}
-}
-
-func run() error {
-	// Get cookies from flag or env
-	cookies := *flagCookies
-	if cookies == "" {
-		cookies = os.Getenv("VNC_COOKIES")
-	}
-	ua := "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
-
-	wsURL, err := url.Parse(*flagURL)
-	if err != nil {
-		return fmt.Errorf("parsing url=%q: %w", *flagURL, err)
-	}
-
-	header := http.Header{}
-	header.Add("Cookie", cookies)
-	header.Add("User-Agent", ua)
-
-	slog.Info("connecting", slog.String("url", wsURL.String()))
-
-	ctx := context.Background()
-	c, _, err := websocket.Dial(ctx, wsURL.String(), &websocket.DialOptions{
-		HTTPHeader: header,
-	})
-	if err != nil {
-		return fmt.Errorf("connecting to url=%q: %w", wsURL.String(), err)
-	}
-	defer c.CloseNow()
-
-	// RFB Version handshake
-	_, b, err := c.Read(ctx)
-	if err != nil {
-		return fmt.Errorf("reading server version: %w", err)
-	}
-	serverVersion := strings.TrimSpace(string(b))
-	slog.Info("recv", slog.String("type", "ServerVersion"), slog.String("version", serverVersion))
-
-	slog.Info("send", slog.String("type", "ClientVersion"), slog.String("version", strings.TrimSpace(_rfbVersion)))
-	err = c.Write(ctx, websocket.MessageBinary, []byte(_rfbVersion))
-	if err != nil {
-		return fmt.Errorf("sending client version: %w", err)
-	}
-
-	// Security handshake
-	_, b, err = c.Read(ctx)
-	if err != nil {
-		return fmt.Errorf("reading security types: %w", err)
-	}
-	slog.Info("recv", slog.String("type", "SecurityTypes"), slog.Int("count", int(b[0])), slog.String("raw", fmt.Sprintf("%x", b)))
-	if len(b) < 2 {
-		return fmt.Errorf("unexpected security types message len:%d", len(b))
-	}
-	if b[0] != 1 || b[1] != 1 {
-		return fmt.Errorf("expected none security got count:%d first:%d", b[0], b[1])
-	}
-
-	// Select None security
-	slog.Info("send", slog.String("type", "SecurityChoice"), slog.Int("choice", 1))
-	err = c.Write(ctx, websocket.MessageBinary, []byte{0x01})
-	if err != nil {
-		return fmt.Errorf("sending security choice: %w", err)
-	}
-
-	_, b, err = c.Read(ctx)
-	if err != nil {
-		return fmt.Errorf("reading security result: %w", err)
-	}
-	slog.Info("recv", slog.String("type", "SecurityResult"), slog.String("raw", fmt.Sprintf("%x", b)))
-
-	// ClientInit - request shared session
-	slog.Info("send", slog.String("type", "ClientInit"), slog.Bool("shared", true))
-	err = c.Write(ctx, websocket.MessageBinary, []byte{0x01})
-	if err != nil {
-		return fmt.Errorf("sending client init: %w", err)
-	}
-
-	_, b, err = c.Read(ctx)
-	if err != nil {
-		return fmt.Errorf("reading server init: %w", err)
-	}
-	// Parse ServerInit: width(2) + height(2) + pixel_format(16) + name_len(4) + name
-	sizeWidth := binary.BigEndian.Uint16(b[0:2])
-	sizeHeight := binary.BigEndian.Uint16(b[2:4])
-	serverName := string(b[2+2+16+4:])
-	slog.Info("recv", slog.String("type", "ServerInit"),
-		slog.Int("width", int(sizeWidth)),
-		slog.Int("height", int(sizeHeight)),
-		slog.String("name", serverName))
-
-	c.SetReadLimit(1024 * 1024 * 64)
-
-	// Set encodings - noVNC typically requests these
-	// 7=Tight, 16=ZRLE, 5=Hextile, 1=CopyRect, 0=Raw
-	// -223=DesktopSize, -224=LastRect, -239=Cursor
-	encodings := []int32{7, 16, 5, 1, 0, -223, -224, -239}
-	slog.Info("send", slog.String("type", "SetEncodings"), slog.Any("encodings", encodings))
-	err = SendSetEncodings(ctx, c, encodings)
-	if err != nil {
-		return fmt.Errorf("setting encodings: %w", err)
-	}
-
-	// Request initial framebuffer
-	slog.Info("send", slog.String("type", "FramebufferUpdateRequest"),
-		slog.Bool("incremental", false),
-		slog.Int("width", int(sizeWidth)),
-		slog.Int("height", int(sizeHeight)))
-	err = SendFBURequest(ctx, c, false, sizeWidth, sizeHeight)
-	if err != nil {
-		return fmt.Errorf("sending initial FBU request: %w", err)
-	}
-
-	// Use errgroup for concurrent operations
-	g, ctx := errgroup.WithContext(ctx)
-
-	// Goroutine 1: Drain incoming frames
-	g.Go(func() error {
-		return drainFrames(ctx, c, sizeWidth, sizeHeight)
-	})
-
-	// Goroutine 2: Read stdin and send key events
-	g.Go(func() error {
-		return readStdin(ctx, c, serverName)
-	})
-
-	// Goroutine 3: Watch clipboard_in/ for outgoing clipboard data
-	g.Go(func() error {
-		return watchClipboardIn(ctx, c)
-	})
-
-	err = g.Wait()
-	slog.Info("session ended", slog.Any("error", err))
-	return err
-}
-
-// drainFrames reads and logs incoming VNC messages
-func drainFrames(ctx context.Context, c *websocket.Conn, width, height uint16) error {
-	// Buffer for reassembling fragmented clipboard messages
-	var clipboardReassemblyBuf []byte
-	var clipboardExpectedLen int
-
-	for {
-		_, b, err := c.Read(ctx)
-		if err != nil {
-			return fmt.Errorf("reading frame: %w", err)
-		}
-
-		if len(b) == 0 {
-			slog.Warn("recv", slog.String("type", "Empty"), slog.Int("len", 0))
-			continue
-		}
-
-		// If we're waiting for more data on a fragmented message, append it
-		if clipboardReassemblyBuf != nil {
-			clipboardReassemblyBuf = append(clipboardReassemblyBuf, b...)
-			slog.Debug("reassembly", slog.Int("have", len(clipboardReassemblyBuf)), slog.Int("need", clipboardExpectedLen))
-
-			if len(clipboardReassemblyBuf) >= clipboardExpectedLen {
-				// We have enough data, process the complete message
-				clipboardText := string(clipboardReassemblyBuf[8:clipboardExpectedLen])
-				slog.Info("clipboard recv", slog.Int("len", len(clipboardText)))
-				slog.Debug("clipboard", slog.String("text", clipboardText))
-				saveErr := saveClipboard(clipboardText)
-				if saveErr != nil {
-					slog.Warn("saving clipboard", slog.String("error", saveErr.Error()))
-				}
-				clipboardReassemblyBuf = nil
-				clipboardExpectedLen = 0
-			}
-			continue
-		}
-
-		msgType := b[0]
-		switch msgType {
-		case _msgTypeFramebufferUpdate:
-			if len(b) >= 4 {
-				numRects := binary.BigEndian.Uint16(b[2:4])
-				slog.Debug("recv", slog.String("type", "FramebufferUpdate"),
-					slog.Int("numRects", int(numRects)),
-					slog.Int("len", len(b)))
-			}
-		case _msgTypeSetColourMapEntries:
-			slog.Debug("recv", slog.String("type", "SetColourMapEntries"), slog.Int("len", len(b)))
-		case _msgTypeBell:
-			slog.Info("recv", slog.String("type", "Bell"))
-		case _msgTypeServerCutText:
-			if len(b) >= 8 {
-				textLen := binary.BigEndian.Uint32(b[4:8])
-
-				// Check for extended clipboard (high bit set = negative signed int32)
-				if int32(textLen) < 0 {
-					slog.Debug("recv", slog.String("type", "ExtendedClipboard"), slog.Int("flags", int(textLen&0x7FFFFFFF)))
-					// Extended clipboard - for now just log it
-					// TODO: implement extended clipboard request/response
-					continue
-				}
-
-				expectedLen := 8 + int(textLen)
-				if len(b) >= expectedLen {
-					clipboardText := string(b[8:expectedLen])
-					slog.Info("clipboard recv", slog.Int("len", int(textLen)))
-					slog.Debug("clipboard", slog.String("text", clipboardText))
-					saveErr := saveClipboard(clipboardText)
-					if saveErr != nil {
-						slog.Warn("saving clipboard", slog.String("error", saveErr.Error()))
-					}
-				} else {
-					// Start buffering for reassembly
-					slog.Info("recv", slog.String("type", "ServerCutText"),
-						slog.Int("textLen", int(textLen)),
-						slog.Int("have", len(b)),
-						slog.String("note", "fragmented, buffering"))
-					clipboardReassemblyBuf = make([]byte, len(b))
-					copy(clipboardReassemblyBuf, b)
-					clipboardExpectedLen = expectedLen
-				}
-			}
-		default:
-			slog.Warn("recv", slog.String("type", "Unknown"), slog.Int("msgType", int(msgType)), slog.Int("len", len(b)))
-		}
-
-		// Request next incremental update
-		err = SendFBURequest(ctx, c, true, width, height)
-		if err != nil {
-			return fmt.Errorf("sending FBU request: %w", err)
-		}
-	}
-}
-
-// watchClipboardIn watches clipboard_in/ directory and sends files as clipboard data
-func watchClipboardIn(ctx context.Context, c *websocket.Conn) error {
-	err := os.MkdirAll("clipboard_in", 0755)
-	if err != nil {
-		return fmt.Errorf("creating clipboard_in dir: %w", err)
-	}
-	err = os.MkdirAll("clipboard_in/sent", 0755)
-	if err != nil {
-		return fmt.Errorf("creating clipboard_in/sent dir: %w", err)
-	}
-
-	watcher, err := fsnotify.NewWatcher()
-	if err != nil {
-		return fmt.Errorf("creating watcher: %w", err)
-	}
-	defer watcher.Close()
-
-	err = watcher.Add("clipboard_in")
-	if err != nil {
-		return fmt.Errorf("watching clipboard_in: %w", err)
-	}
-
-	slog.Info("watching clipboard_in/ for files")
-
-	for {
-		select {
-		case <-ctx.Done():
-			return ctx.Err()
-		case event, ok := <-watcher.Events:
-			if !ok {
-				return nil
-			}
-			// Only process Create and Write events
-			if !event.Has(fsnotify.Create) && !event.Has(fsnotify.Write) {
-				continue
-			}
-			// Skip the sent/ subdirectory
-			if filepath.Dir(event.Name) != "clipboard_in" {
-				continue
-			}
-			// Small delay to ensure file is fully written
-			time.Sleep(10 * time.Millisecond)
-
-			processClipboardFile(ctx, c, event.Name)
-
-		case err, ok := <-watcher.Errors:
-			if !ok {
-				return nil
-			}
-			slog.Warn("watcher error", slog.String("error", err.Error()))
-		}
-	}
-}
-
-func processClipboardFile(ctx context.Context, c *websocket.Conn, path string) {
-	info, err := os.Stat(path)
-	if err != nil || info.IsDir() {
-		return
-	}
-
-	data, err := os.ReadFile(path)
-	if err != nil {
-		slog.Warn("reading clipboard file", slog.String("file", path), slog.String("error", err.Error()))
-		return
-	}
-
-	slog.Info("clipboard send", slog.String("file", filepath.Base(path)), slog.Int("len", len(data)))
-	err = SendClipboard(ctx, c, string(data))
-	if err != nil {
-		slog.Warn("sending clipboard", slog.String("error", err.Error()))
-		return
-	}
-
-	// Move to sent/ after sending
-	sentPath := filepath.Join("clipboard_in", "sent", filepath.Base(path))
-	err = os.Rename(path, sentPath)
-	if err != nil {
-		slog.Warn("moving sent file", slog.String("path", path), slog.String("error", err.Error()))
-	}
-}
-
-// readStdin reads lines from stdin with readline support and sends as key events
-func readStdin(ctx context.Context, c *websocket.Conn, serverName string) error {
-	prompt := serverName + "> "
-	rl, err := readline.NewEx(&readline.Config{
-		Prompt:          prompt,
-		HistoryFile:     ".govnc_history",
-		InterruptPrompt: "^C",
-		EOFPrompt:       "exit",
-	})
-	if err != nil {
-		return fmt.Errorf("creating readline: %w", err)
-	}
-	defer rl.Close()
-
-	for {
-		select {
-		case <-ctx.Done():
-			return ctx.Err()
-		default:
-		}
-
-		line, err := rl.Readline()
-		if err == readline.ErrInterrupt {
-			continue
-		}
-		if err == io.EOF {
-			return nil
-		}
-		if err != nil {
-			return fmt.Errorf("reading line: %w", err)
-		}
-
-		line = line + "\n"
-		slog.Info("stdin", slog.String("line", strings.TrimSpace(line)))
-		err = Type(ctx, c, line)
-		if err != nil {
-			return fmt.Errorf("typing line: %w", err)
-		}
-	}
-}
-
-type vncTyper struct {
-	ctx  context.Context
-	conn *websocket.Conn
-}
-
-func (vt vncTyper) Write(p []byte) (n int, err error) {
-	return len(p), Type(vt.ctx, vt.conn, string(p))
-}
-
-func SendKey(sessionCtx context.Context, c *websocket.Conn, down bool, key uint32) error {
-	msg := make([]byte, 8)
-	msg[0] = _msgTypeKeyEvent
-	if down {
-		msg[1] = 1
-	}
-
-	ctx, cancel := context.WithTimeout(sessionCtx, 1*time.Second)
-	defer cancel()
-
-	// msg[2:4] padding zeros
-	binary.BigEndian.PutUint32(msg[4:], key)
-	return c.Write(ctx, websocket.MessageBinary, msg)
-}
-
-func Tap(ctx context.Context, c *websocket.Conn, key uint32) error {
-	err := SendKey(ctx, c, true, key)
-	if err != nil {
-		return fmt.Errorf("pressing key (key=%#x): %w", key, err)
-	}
-	err = SendKey(ctx, c, false, key)
-	if err != nil {
-		return fmt.Errorf("releasing key (key=%#x): %w", key, err)
-	}
-	return nil
-}
-
-func Type(ctx context.Context, c *websocket.Conn, s string) error {
-	const (
-		_keyReturn = 0xff0d
-		_keyTab    = 0xff09
-	)
-	for _, ch := range s {
-		select {
-		case <-ctx.Done():
-			return ctx.Err()
-		default:
-		}
-
-		var key uint32
-		switch ch {
-		case '\n':
-			key = _keyReturn
-		case '\t':
-			key = _keyTab
-		default:
-			key = uint32(ch)
-		}
-		err := Tap(ctx, c, key)
-		if err != nil {
-			return err
-		}
-	}
-	return nil
-}
-
-// saveClipboard writes clipboard text to clipboard/<hex_unix_time>.clip
-func saveClipboard(text string) error {
-	err := os.MkdirAll("clipboard", 0755)
-	if err != nil {
-		return err
-	}
-	filename := fmt.Sprintf("clipboard/%x.clip", time.Now().Unix())
-	slog.Debug("saving clipboard", slog.String("file", filename))
-	return os.WriteFile(filename, []byte(text), 0644)
-}
-
-// SendClipboard sends text to the server's clipboard (ClientCutText message)
-func SendClipboard(ctx context.Context, c *websocket.Conn, text string) error {
-	// ClientCutText: type(1) + padding(3) + length(4) + text
-	msg := make([]byte, 8+len(text))
-	msg[0] = _msgTypeClientCutText
-	// msg[1:4] padding zeros
-	binary.BigEndian.PutUint32(msg[4:8], uint32(len(text)))
-	copy(msg[8:], text)
-
-	slog.Debug("send", slog.String("type", "ClientCutText"), slog.Int("len", len(text)))
-	return c.Write(ctx, websocket.MessageBinary, msg)
-}
-
-// SendSetEncodings sends encoding preferences to the server.
-func SendSetEncodings(ctx context.Context, c *websocket.Conn, encs []int32) error {
-	// type(1), pad(1), count(2), encodings(4*N)
-	msg := make([]byte, 4+4*len(encs))
-	msg[0] = _msgTypeSetEncodings
-	binary.BigEndian.PutUint16(msg[2:4], uint16(len(encs)))
-	off := 4
-	for _, e := range encs {
-		binary.BigEndian.PutUint32(msg[off:off+4], uint32(e))
-		off += 4
-	}
-	return c.Write(ctx, websocket.MessageBinary, msg)
-}
-
-// SendFBURequest sends a framebuffer update request to the server.
-func SendFBURequest(ctx context.Context, c *websocket.Conn, incremental bool, w, h uint16) error {
-	msg := make([]byte, 10)
-	msg[0] = _msgTypeFramebufferUpdateRequest
-	if incremental {
-		msg[1] = 1
-	}
-	// x,y = 0,0
-	binary.BigEndian.PutUint16(msg[6:8], w)
-	binary.BigEndian.PutUint16(msg[8:10], h)
-	return c.Write(ctx, websocket.MessageBinary, msg)
-}
Makefile
@@ -0,0 +1,20 @@
+.PHONY: build install clean test vet
+
+BINARY = govnc
+GOFLAGS = -ldflags="-s -w"
+
+build:
+	go build $(GOFLAGS) -o $(BINARY) ./cmd/govnc
+
+install:
+	go install $(GOFLAGS) ./cmd/govnc
+
+clean:
+	rm -f $(BINARY)
+	go clean
+
+test:
+	go test ./...
+
+vet:
+	go vet ./...
README.md
@@ -1,9 +1,40 @@
-# `goVNC`
+# goVNC
 
-Goals:
- - interact with the noVNC api
+A VNC client and proxy server for WebSocket-based VNC connections (noVNC).
 
-## Sources
+## Quick Start
 
-- https://novnc.com/noVNC/docs/API.html 
-- https://github.com/Haskely/noVNC-client
+```bash
+make build
+./govnc wss://your-vnc-server/websockify
+```
+
+## Features
+
+- **Interactive mode**: Type directly to VNC sessions via terminal
+- **Server mode**: HTTP API and WebSocket proxy for noVNC clients
+- **Clipboard sync**: File-based clipboard transfer with fragmented message support
+- **One-shot commands**: Send text or clipboard content and exit
+
+## Code Layout
+
+| Package | Description |
+|---------|-------------|
+| `cmd/govnc` | CLI entry point and mode handlers |
+| `pkg/rfb` | RFB protocol (handshake, encodings, messages) |
+| `pkg/transport` | WebSocket dialer and connection |
+| `pkg/input` | Keyboard (keysym) and clipboard handling |
+| `pkg/web` | HTTP server, API routes, WebSocket proxy |
+| `internal/cli` | Flag parsing and configuration |
+
+## API Reference
+
+Server mode (`--http :8080`):
+
+| Endpoint | Method | Description |
+|----------|--------|-------------|
+| `/api/type` | POST | Send keystrokes (body = text) |
+| `/api/clipboard` | GET | Get last received clipboard |
+| `/api/clipboard` | POST | Send clipboard to VNC |
+| `/api/key` | POST | Send key event (JSON: `{"key": "Return"}`) |
+| `/ws` | WS | WebSocket proxy to VNC server |
STYLE.md
@@ -38,6 +38,28 @@ Logging
 - Use structured logs (attributes, not formatted strings).
 - Libraries may accept loggers via options.
 
+### Formatting
+
+When a log call has multiple attributes, place each on its own line:
+
+```go
+// Good
+slog.Info("recv",
+    slog.String("type", "SecurityTypes"),
+    slog.Int("count", int(b[0])),
+    slog.String("raw", fmt.Sprintf("%x", b)))
+
+// Bad - line too long
+slog.Info("recv", slog.String("type", "SecurityTypes"), slog.Int("count", int(b[0])), slog.String("raw", fmt.Sprintf("%x", b)))
+```
+
+Single-attribute calls may stay on one line if short:
+
+```go
+slog.Info("bell received")
+slog.Warn("watcher error", slog.String("error", err.Error()))
+```
+
 HTTP
 ----