feat: web-vnc single-binary browser VNC gateway with password access
- Go stdlib-only gateway: serves noVNC client, password auth, WS<->TCP relay - auth: PBKDF2-HMAC-SHA256 password hash, HMAC session cookies, login rate limit - relay: hand-written RFC 6455 WebSocket + transparent RFB bridge - vncspawner: cross-OS VNC server detection/launch (Windows/Linux/macOS) - server: /login /logout /vnc /api/status routes, session middleware, embed.FS - scripts: get-novnc, get-vnc (zip-verified), list-ips, open-firewall - run.bat: one-click launcher (asks only password), lists access IPs - README/AGENTS.md (Russian), .gitignore (root-anchored binaries)
This commit is contained in:
@@ -0,0 +1,124 @@
|
||||
package relay
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Server bridges WS connections on RelayPath to the upstream VNC TCP server.
|
||||
type Server struct {
|
||||
vncAddr string
|
||||
}
|
||||
|
||||
// New returns a relay Server targeting the given VNC TCP address.
|
||||
func New(vncAddr string) *Server {
|
||||
return &Server{vncAddr: vncAddr}
|
||||
}
|
||||
|
||||
// ServeHTTP upgrades to WebSocket and bridges to the VNC server.
|
||||
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
ws, err := handshake(w, r)
|
||||
if err != nil {
|
||||
// handshake failed before hijack; respond with an error.
|
||||
http.Error(w, "websocket handshake failed: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
defer ws.close()
|
||||
|
||||
vnc, err := net.DialTimeout("tcp", s.vncAddr, 10*time.Second)
|
||||
if err != nil {
|
||||
log.Printf("relay: dial vnc %s failed: %v", s.vncAddr, err)
|
||||
ws.writeClose()
|
||||
return
|
||||
}
|
||||
defer vnc.Close()
|
||||
|
||||
bridge(ws, vnc)
|
||||
}
|
||||
|
||||
// bridge pumps bytes between the WebSocket and the TCP connection until either side closes.
|
||||
func bridge(ws *wsConn, vnc net.Conn) {
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
// TCP -> WS
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := vnc.Read(buf)
|
||||
if n > 0 {
|
||||
if werr := ws.writeBinary(buf[:n]); werr != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if err != io.EOF {
|
||||
log.Printf("relay: vnc read: %v", err)
|
||||
}
|
||||
ws.writeClose()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// WS -> TCP
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
opcode, payload, err := ws.readFrame()
|
||||
if err != nil {
|
||||
if err != io.EOF && !isClosedConnErr(err) {
|
||||
log.Printf("relay: ws read: %v", err)
|
||||
}
|
||||
_ = vnc.Close()
|
||||
return
|
||||
}
|
||||
switch opcode {
|
||||
case opBinary, opText, opContinuation:
|
||||
if len(payload) > 0 {
|
||||
if _, err := vnc.Write(payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
case opPing:
|
||||
_ = ws.writePong(payload)
|
||||
case opPong:
|
||||
// ignore
|
||||
case opClose:
|
||||
ws.writeClose()
|
||||
_ = vnc.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func isClosedConnErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
s := err.Error()
|
||||
return contains(s, "use of closed network connection") ||
|
||||
contains(s, "connection reset") ||
|
||||
contains(s, "EOF")
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
func indexOf(s, sub string) int {
|
||||
for i := 0; i+len(sub) <= len(s); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
// Package relay bridges a noVNC WebSocket client to a raw TCP VNC server,
|
||||
// transparently carrying the RFB byte-stream in both directions.
|
||||
//
|
||||
// This file implements a minimal RFC 6455 WebSocket server using only the
|
||||
// Go standard library, tailored to the needs of noVNC: binary message
|
||||
// frames in both directions, with ping/pong and close handling.
|
||||
package relay
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha1"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
||||
|
||||
// wsConn wraps a hijacked HTTP connection as a WebSocket (server side).
|
||||
type wsConn struct {
|
||||
nc net.Conn
|
||||
br *bufio.Reader
|
||||
bw *bufio.Writer
|
||||
}
|
||||
|
||||
// handshake performs the WebSocket upgrade and returns a wsConn.
|
||||
func handshake(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
|
||||
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") ||
|
||||
!headerContains(r.Header, "Connection", "upgrade") {
|
||||
return nil, errors.New("not a websocket upgrade request")
|
||||
}
|
||||
key := r.Header.Get("Sec-WebSocket-Key")
|
||||
if key == "" {
|
||||
return nil, errors.New("missing Sec-WebSocket-Key")
|
||||
}
|
||||
|
||||
hj, ok := w.(http.Hijacker)
|
||||
if !ok {
|
||||
return nil, errors.New("response writer does not support hijacking")
|
||||
}
|
||||
nc, brw, err := hj.Hijack()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accept := wsAcceptKey(key)
|
||||
_, _ = fmt.Fprintf(brw, "HTTP/1.1 101 Switching Protocols\r\n")
|
||||
_, _ = fmt.Fprintf(brw, "Upgrade: websocket\r\n")
|
||||
_, _ = fmt.Fprintf(brw, "Connection: Upgrade\r\n")
|
||||
_, _ = fmt.Fprintf(brw, "Sec-WebSocket-Accept: %s\r\n", accept)
|
||||
_, _ = fmt.Fprintf(brw, "\r\n")
|
||||
if err := brw.Flush(); err != nil {
|
||||
_ = nc.Close()
|
||||
return nil, err
|
||||
}
|
||||
return &wsConn{nc: nc, br: brw.Reader, bw: brw.Writer}, nil
|
||||
}
|
||||
|
||||
func wsAcceptKey(key string) string {
|
||||
h := sha1.New()
|
||||
h.Write([]byte(key + wsGUID))
|
||||
return base64.StdEncoding.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
func headerContains(h http.Header, name, value string) bool {
|
||||
for _, v := range h[http.CanonicalHeaderKey(name)] {
|
||||
for _, tok := range strings.Split(v, ",") {
|
||||
if strings.EqualFold(strings.TrimSpace(tok), value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wsFrame opcodes
|
||||
const (
|
||||
opContinuation = 0x0
|
||||
opText = 0x1
|
||||
opBinary = 0x2
|
||||
opClose = 0x8
|
||||
opPing = 0x9
|
||||
opPong = 0xA
|
||||
)
|
||||
|
||||
// readFrame reads one WebSocket frame from the client.
|
||||
// It returns the opcode and (de-masked) payload. Control frames keep their
|
||||
// own opcode; data frames (text/binary/continuation) are returned as-is and
|
||||
// the caller is responsible for stream semantics.
|
||||
func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) {
|
||||
var hdr [2]byte
|
||||
if _, err = io.ReadFull(c.br, hdr[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
opcode = hdr[0] & 0x0F
|
||||
masked := hdr[1]&0x80 != 0
|
||||
length := int64(hdr[1] & 0x7F)
|
||||
|
||||
switch length {
|
||||
case 126:
|
||||
var ext [2]byte
|
||||
if _, err = io.ReadFull(c.br, ext[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint16(ext[:]))
|
||||
case 127:
|
||||
var ext [8]byte
|
||||
if _, err = io.ReadFull(c.br, ext[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
length = int64(binary.BigEndian.Uint64(ext[:]))
|
||||
}
|
||||
|
||||
var mask [4]byte
|
||||
if masked {
|
||||
if _, err = io.ReadFull(c.br, mask[:]); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if length < 0 {
|
||||
return 0, nil, errors.New("invalid frame length")
|
||||
}
|
||||
payload = make([]byte, length)
|
||||
if length > 0 {
|
||||
if _, err = io.ReadFull(c.br, payload); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
if masked {
|
||||
for i := range payload {
|
||||
payload[i] ^= mask[i%4]
|
||||
}
|
||||
}
|
||||
return opcode, payload, nil
|
||||
}
|
||||
|
||||
// writeBinary sends a single binary frame to the client (server frames are unmasked).
|
||||
func (c *wsConn) writeBinary(p []byte) error {
|
||||
var hdr []byte
|
||||
n := len(p)
|
||||
switch {
|
||||
case n < 126:
|
||||
hdr = []byte{0x82, byte(n)}
|
||||
case n < 65536:
|
||||
hdr = make([]byte, 4)
|
||||
hdr[0] = 0x82
|
||||
hdr[1] = 126
|
||||
binary.BigEndian.PutUint16(hdr[2:], uint16(n))
|
||||
default:
|
||||
hdr = make([]byte, 10)
|
||||
hdr[0] = 0x82
|
||||
hdr[1] = 127
|
||||
binary.BigEndian.PutUint64(hdr[2:], uint64(n))
|
||||
}
|
||||
if _, err := c.bw.Write(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.bw.Write(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.bw.Flush()
|
||||
}
|
||||
|
||||
// writePong sends a pong frame with the given payload.
|
||||
func (c *wsConn) writePong(p []byte) error {
|
||||
if len(p) > 125 {
|
||||
return errors.New("control frame payload too large")
|
||||
}
|
||||
hdr := []byte{0x8A, byte(len(p))}
|
||||
if _, err := c.bw.Write(hdr); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := c.bw.Write(p); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.bw.Flush()
|
||||
}
|
||||
|
||||
// writeClose sends a close frame and flushes.
|
||||
func (c *wsConn) writeClose() {
|
||||
_, _ = c.bw.Write([]byte{0x88, 0x00})
|
||||
_ = c.bw.Flush()
|
||||
}
|
||||
|
||||
// close closes the underlying connection.
|
||||
func (c *wsConn) close() error { return c.nc.Close() }
|
||||
|
||||
// setWriteDeadline forwards to the underlying connection.
|
||||
func (c *wsConn) setWriteDeadline(t time.Time) error { return c.nc.SetWriteDeadline(t) }
|
||||
Reference in New Issue
Block a user