Commit 5c647fc

bryfry <bryon@fryer.io>
2026-01-14 15:29:24
pre productize
1 parent d074d35
go.mod
@@ -2,4 +2,11 @@ module goVNC
 
 go 1.25.5
 
-require github.com/coder/websocket v1.8.14 // indirect
+require (
+	github.com/chzyer/readline v1.5.1
+	github.com/coder/websocket v1.8.14
+	github.com/fsnotify/fsnotify v1.9.0
+	golang.org/x/sync v0.19.0
+)
+
+require golang.org/x/sys v0.13.0 // indirect
go.sum
@@ -1,2 +1,15 @@
+github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
+github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
+github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
+github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
+github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
+github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
 github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
 github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
+github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
+golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
+golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
goVNC
Binary file
main.go
@@ -3,161 +3,342 @@ 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"
 )
 
+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 {
-		panic(err)
+		slog.Error("run failed", slog.String("error", err.Error()))
+		os.Exit(1)
 	}
 }
 
 func run() error {
-	u := "wss://presidentscup.cisa.gov/ctf/ng/vnc/access/websockify"
-	cookies := "__cflb=04dToQN5TLGPYN3eV6yHVETn6mH6T16iEnZix2HQaY; _ga_NVR9SKL9J6=GS2.1.s1768084717$o1$g0$t1768084717$j60$l0$h0; _ga=GA1.1.1147073997.1768084718; _ga_CSLL4ZEK4L=GS2.1.s1768084717$o1$g0$t1768084720$j57$l0$h0; session=1c5f6578-f062-4792-bce4-4f41735cea53.ikfi-Rx5BJrTWYAElmO0UibEIwc; __cf_bm=3dUIEjS8wuALtvlWPH7L4XPmgrHiuXAPTqC_nXkWdNw-1768097791-1.0.1.1-BtJV8mTvUqeKlI1ua.Ad_7qUAyOb7o8oDZkPUnzIJ2_.L9O24FpqE2Jv5y.cFPgYvIC9ZIDrK61zkhV72NAsvEh4pDqu6PQrdCzKnQoo9.Y"
+	// Get cookies from flag or env
+	cookies := *flagCookies
+	if cookies == "" {
+		cookies = os.Getenv("VNC_COOKIES")
+	}
+	if cookies == "" {
+		// Fallback to hardcoded (for testing)
+		cookies = "__cflb=04dToQN5TLGPYN3eV6yHVETn6mH6T16iEnZix2HQaY; _ga_NVR9SKL9J6=GS2.1.s1768084717$o1$g0$t1768084717$j60$l0$h0; _ga=GA1.1.1147073997.1768084718; _ga_CSLL4ZEK4L=GS2.1.s1768084717$o1$g0$t1768084720$j57$l0$h0; session=1c5f6578-f062-4792-bce4-4f41735cea53.ikfi-Rx5BJrTWYAElmO0UibEIwc; __cf_bm=3dUIEjS8wuALtvlWPH7L4XPmgrHiuXAPTqC_nXkWdNw-1768097791-1.0.1.1-BtJV8mTvUqeKlI1ua.Ad_7qUAyOb7o8oDZkPUnzIJ2_.L9O24FpqE2Jv5y.cFPgYvIC9ZIDrK61zkhV72NAsvEh4pDqu6PQrdCzKnQoo9.Y"
+	}
 	ua := "Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0"
 
-	wsURL, err := url.Parse(u)
+	wsURL, err := url.Parse(*flagURL)
 	if err != nil {
-		err = fmt.Errorf("parsing url=%q: %w", u, err)
-		return err
+		return fmt.Errorf("parsing url=%q: %w", *flagURL, err)
 	}
 
 	header := http.Header{}
 	header.Add("Cookie", cookies)
 	header.Add("User-Agent", ua)
 
-	ctx := context.Background()
-
-	// Dial(ctx, wsURL, header)
+	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 {
-		err = fmt.Errorf("connecting to url=%q: %w", wsURL.String(), err)
-		return err
+		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 {
-		err = fmt.Errorf("reading server version: %w", err)
-		return err
+		return fmt.Errorf("reading server version: %w", err)
 	}
-	s := strings.TrimSpace(string(b))
-	fmt.Printf("server version: %s %d \n", string(s), len(b))
+	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 {
-		err = fmt.Errorf("sending client version: %w", err)
-		return err
+		return fmt.Errorf("sending client version: %w", err)
 	}
 
-	// security handshake
+	// Security handshake
 	_, b, err = c.Read(ctx)
 	if err != nil {
-		err = fmt.Errorf("reading security n: %w", err)
-		return err
+		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 {
-		err = fmt.Errorf("unexpected security types message len:%d", len(b))
-		return err
+		return fmt.Errorf("unexpected security types message len:%d", len(b))
 	}
 	if b[0] != 1 || b[1] != 1 {
-		err = fmt.Errorf("expected none security got count:%d first:%d", b[0], b[1])
-		return err
+		return fmt.Errorf("expected none security got count:%d first:%d", b[0], b[1])
 	}
-	// select None security
+
+	// 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 {
-		err = fmt.Errorf("sending security choice: %w", err)
-		return err
+		return fmt.Errorf("sending security choice: %w", err)
 	}
+
 	_, b, err = c.Read(ctx)
 	if err != nil {
-		err = fmt.Errorf("reading security handshake result: %w", err)
-		return err
+		return fmt.Errorf("reading security result: %w", err)
 	}
-	fmt.Printf("security handshake result: %x %d \n", b, len(b))
+	slog.Info("recv", slog.String("type", "SecurityResult"), slog.String("raw", fmt.Sprintf("%x", b)))
 
-	// init
+	// 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 {
-		err = fmt.Errorf("sending client init: %w", err)
-		return err
+		return fmt.Errorf("sending client init: %w", err)
 	}
+
 	_, b, err = c.Read(ctx)
 	if err != nil {
-		err = fmt.Errorf("reading server init: %w", err)
-		return err
+		return fmt.Errorf("reading server init: %w", err)
 	}
-	//skip over RFC6143 ServerInit header and get the name
-	serverName := string(b[2+2+16+4:])
+	// 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])
-	fmt.Printf("server init: name=%q width=%d height=%d\n", serverName, sizeWidth, sizeHeight)
+	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)
 
-	err = SendSetEncodings(ctx, c, []int32{0}) // RAW
+	// 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 {
-		err = fmt.Errorf("setting raw encoding: %w", err)
-		return err
+		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 {
 	for {
 		_, b, err := c.Read(ctx)
 		if err != nil {
-			return err
+			return fmt.Errorf("reading frame: %w", err)
 		}
-		fmt.Printf("drain: %d", len(b))
-		msg := make([]byte, 10)
-		msg[0] = 3
-		msg[1] = 1
-		err = c.Write(ctx, websocket.MessageBinary, msg)
+
+		if len(b) == 0 {
+			slog.Warn("recv", slog.String("type", "Empty"), slog.Int("len", 0))
+			continue
+		}
+
+		msgType := b[0]
+		switch msgType {
+		case 0: // FramebufferUpdate
+			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 1: // SetColourMapEntries
+			slog.Debug("recv", slog.String("type", "SetColourMapEntries"), slog.Int("len", len(b)))
+		case 2: // Bell
+			slog.Info("recv", slog.String("type", "Bell"))
+		case 3: // ServerCutText (clipboard)
+			if len(b) >= 8 {
+				textLen := binary.BigEndian.Uint32(b[4:8])
+				if len(b) >= 8+int(textLen) {
+					clipboardText := string(b[8 : 8+textLen])
+					slog.Info("clipboard recv", slog.Int("len", int(textLen)))
+					slog.Debug("clipboard", slog.String("text", clipboardText))
+					if err := saveClipboard(clipboardText); err != nil {
+						slog.Warn("failed to save clipboard", slog.String("error", err.Error()))
+					}
+				} else {
+					slog.Info("recv", slog.String("type", "ServerCutText"), slog.Int("textLen", int(textLen)), slog.String("note", "incomplete"))
+				}
+			}
+		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 err
+			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 {
+	if err := os.MkdirAll("clipboard_in", 0755); err != nil {
+		return fmt.Errorf("creating clipboard_in dir: %w", err)
+	}
+	if err := os.MkdirAll("clipboard_in/sent", 0755); 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()
+
+	if err := watcher.Add("clipboard_in"); err != nil {
+		return fmt.Errorf("watching clipboard_in: %w", err)
+	}
 
-	//drainCtx, drainCancel := context.WithCancel(ctx)
-	//defer drainCancel()
-	//go func() {
-	//	err := Drain(drainCtx, c)
-	//	if err != nil {
-	//		slog.Error("drain exited",
-	//			slog.String("error", err.Error()))
-	//	}
-	//}()
-
-	////Type(ctx, c, "ls -al; pwd; whoami; w\n")
-
-	////Tap(ctx, c, uint32('t'))
-	////Tap(ctx, c, uint32('e'))
-	////Tap(ctx, c, uint32('s'))
-	////Tap(ctx, c, uint32('t'))
-	////Tap(ctx, c, 0xff0d) // Return
-
-	//scanner := bufio.NewScanner(os.Stdin)
-	//for scanner.Scan() {
-	//	line := scanner.Text() + "\n"
-	//	err = Type(ctx, c, line)
-	//	if err != nil {
-	//		return err
-	//	}
-	//}
-	//return nil
+	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)))
+	if err := SendClipboard(ctx, c, string(data)); 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))
+	os.Rename(path, sentPath)
+}
+
+// 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)))
+		if err := Type(ctx, c, line); err != nil {
+			return fmt.Errorf("typing line: %w", err)
+		}
+	}
 }
 
 type vncTyper struct {
@@ -192,36 +373,57 @@ func Tap(ctx context.Context, c *websocket.Conn, key uint32) error {
 }
 
 func Type(ctx context.Context, c *websocket.Conn, s string) error {
-	fmt.Println("sending", s)
 	const (
 		_keyReturn = 0xff0d
 		_keyTab    = 0xff09
 	)
 	for _, ch := range s {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		default:
+		}
+
+		var key uint32
 		switch ch {
 		case '\n':
-			err := Tap(ctx, c, _keyReturn)
-			if err != nil {
-				return err
-			}
-			continue
+			key = _keyReturn
 		case '\t':
-			err := Tap(ctx, c, _keyTab)
-			if err != nil {
-				return err
-			}
-			continue
+			key = _keyTab
 		default:
-			err := Tap(ctx, c, uint32(ch))
-			if err != nil {
-				return err
-			}
-			continue
+			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 {
+	if err := os.MkdirAll("clipboard", 0755); 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] = 6 // ClientCutText
+	// 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)
+}
+
 // SetEncodings: say you support RAW only (encoding 0)
 func SendSetEncodings(ctx context.Context, c *websocket.Conn, encs []int32) error {
 	// type(1)=2, pad(1), count(2), encodings(4*N)
STYLE.md
@@ -0,0 +1,46 @@
+Go Style
+========
+
+This document captures local Go conventions. It is *normative* and *minimal*.  
+For everything not covered here, follow `uber-go/guide`. :contentReference[oaicite:1]{index=1}
+
+Errors
+------
+
+### Err Checks
+
+- Do not write `if err := f(); err != nil { ... }`.
+- Separate call and check into two statements.
+
+### Wrapping
+
+- Wrap errors only when adding *new actionable context*.
+- Do not wrap with repetitive or duplicate information.
+- Use `%w` for wrapping.
+- Do not restate underlying identifiers the callee already logs.
+
+### Format
+
+- Wrapping message format:  
+  `doing X (id=..., name=...): %w`
+- Keep lowercase, no punctuation, no “failed to”.
+
+### Inspection
+
+- Use `errors.Is` and `errors.As` for inspection in our own code.
+- Define sentinel errors for inspectable cases: `var ErrFoo = errors.New(...)`.
+
+Logging
+-------
+
+- Use only `log/slog`.
+- Do not log an error then return it unchanged.
+- Use structured logs (attributes, not formatted strings).
+- Libraries may accept loggers via options.
+
+HTTP
+----
+
+- Do not use HTTP frameworks.
+- Use `net/http` directly.
+