Commit 6001ced
Changed files (7)
cmd/govnc/main.go
@@ -1,3 +1,9 @@
+// Command govnc is a VNC client and proxy server.
+// It connects to a VNC server over WebSocket and provides:
+// - Interactive terminal for typing to the VNC session
+// - HTTP API for programmatic control
+// - WebSocket proxy for noVNC browser clients
+// - Clipboard synchronization via files
package main
import (
@@ -21,8 +27,9 @@ import (
)
func main() {
- if err := run(); err != nil {
- slog.Error("fatal", slog.String("error", err.Error()))
+ err := run()
+ if err != nil {
+ slog.Error("startup failed", slog.String("error", err.Error()))
os.Exit(1)
}
}
@@ -123,7 +130,8 @@ func setupLogging(verbose int, silent bool) {
func handleTypeAndExit(ctx context.Context, client *rfb.Client, text string) error {
slog.Info("typing", slog.Int("len", len(text)))
- if err := client.Type(ctx, text); err != nil {
+ err := client.Type(ctx, text)
+ if err != nil {
return fmt.Errorf("typing: %w", err)
}
slog.Info("done")
@@ -141,7 +149,8 @@ func handleClipSendAndExit(ctx context.Context, client *rfb.Client, text string)
}
slog.Info("sending clipboard", slog.Int("len", len(text)))
- if err := client.SetClipboard(ctx, text); err != nil {
+ err := client.SetClipboard(ctx, text)
+ if err != nil {
return fmt.Errorf("sending clipboard: %w", err)
}
slog.Info("done")
@@ -254,7 +263,8 @@ func readStdin(ctx context.Context, client *rfb.Client, serverName string) error
line = line + "\n"
slog.Debug("typing", slog.String("line", strings.TrimSpace(line)))
- if err := client.Type(ctx, line); err != nil {
+ err = client.Type(ctx, line)
+ if err != nil {
return fmt.Errorf("typing line: %w", err)
}
}
@@ -267,7 +277,8 @@ func readInputFile(ctx context.Context, client *rfb.Client, path string) error {
}
slog.Info("typing from file", slog.String("file", path), slog.Int("len", len(data)))
- if err := client.Type(ctx, string(data)); err != nil {
+ err = client.Type(ctx, string(data))
+ if err != nil {
return fmt.Errorf("typing: %w", err)
}
pkg/input/clipboard.go
@@ -61,17 +61,20 @@ func NewClipboardManager(sender ClipboardSender, cfg *ClipboardConfig) (*Clipboa
cm.sentDir = filepath.Join(cfg.WatchDir, "sent")
// Create directories
- if err := os.MkdirAll(cm.watchDir, 0755); err != nil {
+ err := os.MkdirAll(cm.watchDir, 0755)
+ if err != nil {
return nil, fmt.Errorf("creating watch dir: %w", err)
}
- if err := os.MkdirAll(cm.sentDir, 0755); err != nil {
+ err = os.MkdirAll(cm.sentDir, 0755)
+ if err != nil {
return nil, fmt.Errorf("creating sent dir: %w", err)
}
}
if cfg.SaveDir != "" {
cm.saveDir = cfg.SaveDir
- if err := os.MkdirAll(cm.saveDir, 0755); err != nil {
+ err := os.MkdirAll(cm.saveDir, 0755)
+ if err != nil {
return nil, fmt.Errorf("creating save dir: %w", err)
}
}
@@ -94,7 +97,8 @@ func (cm *ClipboardManager) Start(ctx context.Context) error {
cm.watcher = watcher
defer watcher.Close()
- if err := watcher.Add(cm.watchDir); err != nil {
+ err = watcher.Add(cm.watchDir)
+ if err != nil {
return fmt.Errorf("watching directory: %w", err)
}
@@ -153,14 +157,16 @@ func (cm *ClipboardManager) processFile(ctx context.Context, path string) {
slog.String("file", filepath.Base(path)),
slog.Int("len", len(data)))
- if err := cm.sender.SetClipboard(ctx, string(data)); err != nil {
+ err = cm.sender.SetClipboard(ctx, string(data))
+ if err != nil {
slog.Warn("sending clipboard", slog.String("error", err.Error()))
return
}
// Move to sent/ after successful send
sentPath := filepath.Join(cm.sentDir, filepath.Base(path))
- if err := os.Rename(path, sentPath); err != nil {
+ err = os.Rename(path, sentPath)
+ if err != nil {
slog.Warn("moving sent file", slog.String("error", err.Error()))
}
}
@@ -181,7 +187,8 @@ func (cm *ClipboardManager) OnReceive(text string) {
// Save to file if configured
if cm.saveDir != "" {
filename := filepath.Join(cm.saveDir, fmt.Sprintf("%x.clip", time.Now().Unix()))
- if err := os.WriteFile(filename, []byte(text), 0644); err != nil {
+ err := os.WriteFile(filename, []byte(text), 0644)
+ if err != nil {
slog.Warn("saving clipboard", slog.String("error", err.Error()))
} else {
slog.Debug("saved clipboard", slog.String("file", filename))
@@ -190,7 +197,8 @@ func (cm *ClipboardManager) OnReceive(text string) {
// Call handler if configured
if cm.handler != nil {
- if err := cm.handler(text); err != nil {
+ err := cm.handler(text)
+ if err != nil {
slog.Warn("clipboard handler error", slog.String("error", err.Error()))
}
}
pkg/rfb/client.go
@@ -2,6 +2,7 @@ package rfb
import (
"context"
+ "errors"
"fmt"
"log/slog"
"sync"
@@ -10,6 +11,9 @@ import (
"goVNC/pkg/transport"
)
+// ErrClientClosed is returned when operations are attempted on a closed client.
+var ErrClientClosed = errors.New("client is closed")
+
// MessageHandler is called when a server message is received.
type MessageHandler func(msg *ServerMessage)
@@ -77,14 +81,14 @@ func (c *Client) Connect(ctx context.Context) (*Session, error) {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
- return nil, fmt.Errorf("client is closed")
+ return nil, ErrClientClosed
}
// Perform handshake
session, err := Handshake(ctx, c.transport, c.config.Handshake)
if err != nil {
c.mu.Unlock()
- return nil, fmt.Errorf("handshake failed: %w", err)
+ return nil, fmt.Errorf("handshaking: %w", err)
}
c.session = session
@@ -94,7 +98,8 @@ func (c *Client) Connect(ctx context.Context) (*Session, error) {
// Send encodings
if len(c.config.Encodings) > 0 {
msg := EncodeSetEncodings(c.config.Encodings)
- if err := c.transport.Write(ctx, msg); err != nil {
+ err = c.transport.Write(ctx, msg)
+ if err != nil {
c.mu.Unlock()
return nil, fmt.Errorf("sending encodings: %w", err)
}
@@ -104,7 +109,8 @@ func (c *Client) Connect(ctx context.Context) (*Session, error) {
c.mu.Unlock()
// Request initial framebuffer
- if err := c.RequestFramebuffer(ctx, false); err != nil {
+ err = c.RequestFramebuffer(ctx, false)
+ if err != nil {
return nil, fmt.Errorf("requesting initial framebuffer: %w", err)
}
@@ -137,6 +143,9 @@ func (c *Client) Listen(ctx context.Context) error {
for {
data, err := c.transport.Read(ctx)
if err != nil {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
+ return err // Return unwrapped for clean shutdown detection
+ }
return fmt.Errorf("reading message: %w", err)
}
@@ -165,7 +174,8 @@ func (c *Client) Listen(ctx context.Context) error {
switch msg.Type {
case MsgTypeFramebufferUpdate:
// Request next incremental update
- if err := c.RequestFramebuffer(ctx, true); err != nil {
+ err = c.RequestFramebuffer(ctx, true)
+ if err != nil {
return fmt.Errorf("requesting framebuffer: %w", err)
}
@@ -216,10 +226,15 @@ func (c *Client) KeyEvent(ctx context.Context, key uint32, down bool) error {
// Tap sends a key press followed by release.
func (c *Client) Tap(ctx context.Context, key uint32) error {
- if err := c.KeyEvent(ctx, key, true); err != nil {
- return err
+ err := c.KeyEvent(ctx, key, true)
+ if err != nil {
+ return fmt.Errorf("pressing key (key=%#x): %w", key, err)
+ }
+ err = c.KeyEvent(ctx, key, false)
+ if err != nil {
+ return fmt.Errorf("releasing key (key=%#x): %w", key, err)
}
- return c.KeyEvent(ctx, key, false)
+ return nil
}
// Type sends a string as key events.
@@ -232,7 +247,8 @@ func (c *Client) Type(ctx context.Context, text string) error {
}
key := RuneToKeysym(r)
- if err := c.Tap(ctx, key); err != nil {
+ err := c.Tap(ctx, key)
+ if err != nil {
return err
}
}
pkg/web/api.go
@@ -81,12 +81,14 @@ func (a *API) getClipboard(w http.ResponseWriter, _ *http.Request) {
func (a *API) setClipboard(w http.ResponseWriter, r *http.Request) {
var req ClipboardRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
- if err := a.vnc.SetClipboard(r.Context(), req.Text); err != nil {
+ err = a.vnc.SetClipboard(r.Context(), req.Text)
+ if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -102,7 +104,8 @@ func (a *API) HandleKeys(w http.ResponseWriter, r *http.Request) {
}
var req KeysRequest
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ err := json.NewDecoder(r.Body).Decode(&req)
+ if err != nil {
http.Error(w, "Invalid JSON", http.StatusBadRequest)
return
}
@@ -122,7 +125,8 @@ func (a *API) HandleKeys(w http.ResponseWriter, r *http.Request) {
return
}
}
- if err := a.vnc.KeyEvent(ctx, key, ke.Down); err != nil {
+ err = a.vnc.KeyEvent(ctx, key, ke.Down)
+ if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
@@ -134,9 +138,10 @@ func (a *API) HandleKeys(w http.ResponseWriter, r *http.Request) {
// Otherwise, type the text
if req.Text != "" {
// Add delay between keystrokes if specified
- for _, r := range req.Text {
- key := rfb.RuneToKeysym(r)
- if err := a.vnc.Tap(ctx, key); err != nil {
+ for _, ch := range req.Text {
+ key := rfb.RuneToKeysym(ch)
+ err = a.vnc.Tap(ctx, key)
+ if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
pkg/web/server.go
@@ -86,10 +86,12 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
// WebSocket proxy endpoint
wsHandler := NewWebsockifyHandler(s.mux)
+ wsHandler.SetMaxClients(s.config.MaxClients)
mux.Handle("/websockify", wsHandler)
// noVNC static files
- if err := s.setupNoVNC(mux); err != nil {
+ err := s.setupNoVNC(mux)
+ if err != nil {
return fmt.Errorf("setting up noVNC: %w", err)
}
pkg/web/websockify.go
@@ -15,19 +15,32 @@ import (
// WebsockifyHandler handles WebSocket proxy connections.
type WebsockifyHandler struct {
- mux *proxy.Multiplexer
- clientSeq uint64
+ mux *proxy.Multiplexer
+ maxClients int
+ clientSeq uint64
}
// NewWebsockifyHandler creates a new WebSocket proxy handler.
func NewWebsockifyHandler(mux *proxy.Multiplexer) *WebsockifyHandler {
return &WebsockifyHandler{
- mux: mux,
+ mux: mux,
+ maxClients: 10, // default max clients
}
}
+// SetMaxClients sets the maximum number of concurrent clients.
+func (h *WebsockifyHandler) SetMaxClients(max int) {
+ h.maxClients = max
+}
+
// ServeHTTP handles WebSocket upgrade and proxying.
func (h *WebsockifyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ // Enforce max clients limit
+ if h.maxClients > 0 && h.mux.ClientCount() >= h.maxClients {
+ http.Error(w, "too many clients", http.StatusServiceUnavailable)
+ return
+ }
+
// Accept WebSocket connection
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{
Subprotocols: []string{"binary"},
main.go
@@ -1,3 +1,6 @@
+// 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 (
@@ -22,6 +25,18 @@ import (
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 (
@@ -33,7 +48,7 @@ func main() {
flag.Parse()
err := run()
if err != nil {
- slog.Error("run failed", slog.String("error", err.Error()))
+ slog.Error("startup failed", slog.String("error", err.Error()))
os.Exit(1)
}
}
@@ -173,9 +188,9 @@ func run() error {
// drainFrames reads and logs incoming VNC messages
func drainFrames(ctx context.Context, c *websocket.Conn, width, height uint16) error {
- // Buffer for reassembling fragmented messages
- var pendingBuf []byte
- var pendingExpected int
+ // Buffer for reassembling fragmented clipboard messages
+ var clipboardReassemblyBuf []byte
+ var clipboardExpectedLen int
for {
_, b, err := c.Read(ctx)
@@ -189,38 +204,39 @@ func drainFrames(ctx context.Context, c *websocket.Conn, width, height uint16) e
}
// If we're waiting for more data on a fragmented message, append it
- if pendingBuf != nil {
- pendingBuf = append(pendingBuf, b...)
- slog.Debug("reassembly", slog.Int("have", len(pendingBuf)), slog.Int("need", pendingExpected))
+ if clipboardReassemblyBuf != nil {
+ clipboardReassemblyBuf = append(clipboardReassemblyBuf, b...)
+ slog.Debug("reassembly", slog.Int("have", len(clipboardReassemblyBuf)), slog.Int("need", clipboardExpectedLen))
- if len(pendingBuf) >= pendingExpected {
+ if len(clipboardReassemblyBuf) >= clipboardExpectedLen {
// We have enough data, process the complete message
- clipboardText := string(pendingBuf[8:pendingExpected])
+ clipboardText := string(clipboardReassemblyBuf[8:clipboardExpectedLen])
slog.Info("clipboard recv", slog.Int("len", len(clipboardText)))
slog.Debug("clipboard", slog.String("text", clipboardText))
- if err := saveClipboard(clipboardText); err != nil {
- slog.Warn("failed to save clipboard", slog.String("error", err.Error()))
+ saveErr := saveClipboard(clipboardText)
+ if saveErr != nil {
+ slog.Warn("saving clipboard", slog.String("error", saveErr.Error()))
}
- pendingBuf = nil
- pendingExpected = 0
+ clipboardReassemblyBuf = nil
+ clipboardExpectedLen = 0
}
continue
}
msgType := b[0]
switch msgType {
- case 0: // FramebufferUpdate
+ 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 1: // SetColourMapEntries
+ case _msgTypeSetColourMapEntries:
slog.Debug("recv", slog.String("type", "SetColourMapEntries"), slog.Int("len", len(b)))
- case 2: // Bell
+ case _msgTypeBell:
slog.Info("recv", slog.String("type", "Bell"))
- case 3: // ServerCutText (clipboard)
+ case _msgTypeServerCutText:
if len(b) >= 8 {
textLen := binary.BigEndian.Uint32(b[4:8])
@@ -237,8 +253,9 @@ func drainFrames(ctx context.Context, c *websocket.Conn, width, height uint16) e
clipboardText := string(b[8:expectedLen])
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()))
+ saveErr := saveClipboard(clipboardText)
+ if saveErr != nil {
+ slog.Warn("saving clipboard", slog.String("error", saveErr.Error()))
}
} else {
// Start buffering for reassembly
@@ -246,9 +263,9 @@ func drainFrames(ctx context.Context, c *websocket.Conn, width, height uint16) e
slog.Int("textLen", int(textLen)),
slog.Int("have", len(b)),
slog.String("note", "fragmented, buffering"))
- pendingBuf = make([]byte, len(b))
- copy(pendingBuf, b)
- pendingExpected = expectedLen
+ clipboardReassemblyBuf = make([]byte, len(b))
+ copy(clipboardReassemblyBuf, b)
+ clipboardExpectedLen = expectedLen
}
}
default:
@@ -265,10 +282,12 @@ func drainFrames(ctx context.Context, c *websocket.Conn, width, height uint16) e
// 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 {
+ err := os.MkdirAll("clipboard_in", 0755)
+ if err != nil {
return fmt.Errorf("creating clipboard_in dir: %w", err)
}
- if err := os.MkdirAll("clipboard_in/sent", 0755); err != nil {
+ err = os.MkdirAll("clipboard_in/sent", 0755)
+ if err != nil {
return fmt.Errorf("creating clipboard_in/sent dir: %w", err)
}
@@ -278,7 +297,8 @@ func watchClipboardIn(ctx context.Context, c *websocket.Conn) error {
}
defer watcher.Close()
- if err := watcher.Add("clipboard_in"); err != nil {
+ err = watcher.Add("clipboard_in")
+ if err != nil {
return fmt.Errorf("watching clipboard_in: %w", err)
}
@@ -327,14 +347,18 @@ func processClipboardFile(ctx context.Context, c *websocket.Conn, path string) {
}
slog.Info("clipboard send", slog.String("file", filepath.Base(path)), slog.Int("len", len(data)))
- if err := SendClipboard(ctx, c, string(data)); err != nil {
+ 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))
- os.Rename(path, sentPath)
+ 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
@@ -371,25 +395,27 @@ func readStdin(ctx context.Context, c *websocket.Conn, serverName string) error
line = line + "\n"
slog.Info("stdin", slog.String("line", strings.TrimSpace(line)))
- if err := Type(ctx, c, line); err != nil {
+ 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(context.Background(), vt.conn, string(p))
+ 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] = 0x04 // KeyEvent
+ msg[0] = _msgTypeKeyEvent
if down {
- msg[1] = 0x01 // downkey
+ msg[1] = 1
}
ctx, cancel := context.WithTimeout(sessionCtx, 1*time.Second)
@@ -403,9 +429,13 @@ func SendKey(sessionCtx context.Context, c *websocket.Conn, down bool, key uint3
func Tap(ctx context.Context, c *websocket.Conn, key uint32) error {
err := SendKey(ctx, c, true, key)
if err != nil {
- return err
+ return fmt.Errorf("pressing key (key=%#x): %w", key, err)
}
- return SendKey(ctx, c, false, key)
+ 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 {
@@ -439,7 +469,8 @@ func Type(ctx context.Context, c *websocket.Conn, s string) error {
// saveClipboard writes clipboard text to clipboard/<hex_unix_time>.clip
func saveClipboard(text string) error {
- if err := os.MkdirAll("clipboard", 0755); err != nil {
+ err := os.MkdirAll("clipboard", 0755)
+ if err != nil {
return err
}
filename := fmt.Sprintf("clipboard/%x.clip", time.Now().Unix())
@@ -451,7 +482,7 @@ func saveClipboard(text string) error {
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[0] = _msgTypeClientCutText
// msg[1:4] padding zeros
binary.BigEndian.PutUint32(msg[4:8], uint32(len(text)))
copy(msg[8:], text)
@@ -460,11 +491,11 @@ func SendClipboard(ctx context.Context, c *websocket.Conn, text string) error {
return c.Write(ctx, websocket.MessageBinary, msg)
}
-// SetEncodings: say you support RAW only (encoding 0)
+// SendSetEncodings sends encoding preferences to the server.
func SendSetEncodings(ctx context.Context, c *websocket.Conn, encs []int32) error {
- // type(1)=2, pad(1), count(2), encodings(4*N)
+ // type(1), pad(1), count(2), encodings(4*N)
msg := make([]byte, 4+4*len(encs))
- msg[0] = 2
+ msg[0] = _msgTypeSetEncodings
binary.BigEndian.PutUint16(msg[2:4], uint16(len(encs)))
off := 4
for _, e := range encs {
@@ -474,10 +505,10 @@ func SendSetEncodings(ctx context.Context, c *websocket.Conn, encs []int32) erro
return c.Write(ctx, websocket.MessageBinary, msg)
}
-// FramebufferUpdateRequest: request full screen once
+// 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] = 3 // FramebufferUpdateRequest
+ msg[0] = _msgTypeFramebufferUpdateRequest
if incremental {
msg[1] = 1
}