Commit 5c647fc
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.
+