diff --git a/browsergateway/main.go b/browsergateway/main.go index 0317167..2c580ab 100644 --- a/browsergateway/main.go +++ b/browsergateway/main.go @@ -51,6 +51,7 @@ func New(cfg Config) *Gateway { func (g *Gateway) RegisterHandlers(mux *http.ServeMux) { mux.HandleFunc("/rdp", g.HandleRDP) mux.HandleFunc("/ssh", g.HandleSSH) + mux.HandleFunc("/vnc", g.handleVNC) } // HandleRDP is an http.HandlerFunc for RDP-over-WebSocket connections. diff --git a/browsergateway/vnc.go b/browsergateway/vnc.go new file mode 100644 index 0000000..57a93a7 --- /dev/null +++ b/browsergateway/vnc.go @@ -0,0 +1,96 @@ +package browsergateway + +import ( + "context" + "crypto/subtle" + "io" + "log" + "net" + "net/http" + "time" + + "github.com/coder/websocket" +) + +const ( + vncDialTimeout = 10 * time.Second + vncKeepAlive = 30 * time.Second + vncForwardBufSize = 32 * 1024 +) + +// handleVNC proxies a noVNC WebSocket connection to a raw TCP VNC backend. +// It follows the same auth-token-in-query-param pattern as handleSSH. +// +// Query parameters: +// +// authToken – shared secret matching the -auth-token flag +// host – VNC backend hostname or IP +// port – VNC backend port (default: 5900) +func (g *Gateway) handleVNC(w http.ResponseWriter, r *http.Request) { + if subtle.ConstantTimeCompare([]byte(r.URL.Query().Get("authToken")), []byte(g.authToken)) != 1 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + host := r.URL.Query().Get("host") + port := r.URL.Query().Get("port") + if host == "" { + http.Error(w, "missing host", http.StatusBadRequest) + return + } + if port == "" { + port = "5900" + } + target := net.JoinHostPort(host, port) + + // Accept the WebSocket. noVNC negotiates the "binary" subprotocol; + // fall back gracefully when the client sends "base64" as well. + ws, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + Subprotocols: []string{"binary", "base64"}, + }) + if err != nil { + log.Printf("vnc: websocket upgrade failed: %v", err) + return + } + ws.SetReadLimit(-1) + defer ws.CloseNow() //nolint:errcheck + + ctx := r.Context() + if err := serveVNC(ctx, ws, target); err != nil { + log.Printf("vnc: session error (%s): %v", target, err) + } +} + +func serveVNC(ctx context.Context, ws *websocket.Conn, target string) error { + // Dial the VNC backend TCP server. + dialer := &net.Dialer{ + Timeout: vncDialTimeout, + KeepAlive: vncKeepAlive, + } + conn, err := dialer.DialContext(ctx, "tcp", target) + if err != nil { + return err + } + defer conn.Close() //nolint:errcheck + + // Expose the WebSocket as a plain net.Conn byte stream (binary frames). + stream := websocket.NetConn(ctx, ws, websocket.MessageBinary) + defer stream.Close() //nolint:errcheck + + // Proxy bidirectionally: VNC backend <-> browser. + errc := make(chan error, 2) + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(conn, stream, buf) + errc <- err + }() + go func() { + buf := make([]byte, vncForwardBufSize) + _, err := io.CopyBuffer(stream, conn, buf) + errc <- err + }() + + // Return when either direction closes. + err = <-errc + return err +}