Commit 88aea29
Changed files (7)
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
----