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:
Codex
2026-07-30 17:40:31 +03:00
commit b89477fb87
24 changed files with 2057 additions and 0 deletions
+239
View File
@@ -0,0 +1,239 @@
// Package auth implements shared-password authentication using a salted
// PBKDF2-HMAC-SHA256 password hash, stateless HMAC-signed session cookies,
// and an in-memory login rate limiter. It depends only on the standard library.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
pbkdf2Iterations = 120_000
hashKeyLen = 32
hashScheme = "pbkdf2-sha256"
)
// Service handles authentication concerns for the gateway.
type Service struct {
passwordHash []byte // verified against the scheme string in parseHash
secret []byte
cookieName string
ttl time.Duration
limiter *rateLimiter
}
// New creates an auth Service.
func New(passwordHash string, secret []byte, cookieName string, ttl time.Duration) *Service {
return &Service{
passwordHash: []byte(passwordHash),
secret: secret,
cookieName: cookieName,
ttl: ttl,
limiter: newRateLimiter(5, time.Minute),
}
}
// CheckPassword verifies a plaintext password against the stored hash.
func (s *Service) CheckPassword(plain string) bool {
if len(s.passwordHash) == 0 {
return false
}
scheme, iter, salt, want, err := parseHash(string(s.passwordHash))
if err != nil {
return false
}
if scheme != hashScheme {
return false
}
got := pbkdf2Key([]byte(plain), salt, iter, hashKeyLen)
return subtle.ConstantTimeCompare(got, want) == 1
}
// HashPassword returns a self-describing salted hash for a plaintext password.
func HashPassword(plain string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := pbkdf2Key([]byte(plain), salt, pbkdf2Iterations, hashKeyLen)
return hashScheme + "$" +
strconv.Itoa(pbkdf2Iterations) + "$" +
base64.RawStdEncoding.EncodeToString(salt) + "$" +
base64.RawStdEncoding.EncodeToString(key), nil
}
func parseHash(h string) (scheme string, iter int, salt, key []byte, err error) {
parts := strings.Split(h, "$")
if len(parts) != 4 {
return "", 0, nil, nil, errors.New("invalid hash format")
}
iter, err = strconv.Atoi(parts[1])
if err != nil || iter <= 0 {
return "", 0, nil, nil, errors.New("invalid iteration count")
}
salt, err = base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return "", 0, nil, nil, err
}
key, err = base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return "", 0, nil, nil, err
}
return parts[0], iter, salt, key, nil
}
// pbkdf2Key implements PBKDF2-HMAC-SHA256 (RFC 2898).
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
prf := hmac.New(sha256.New, password)
hLen := prf.Size()
numBlocks := (keyLen + hLen - 1) / hLen
out := make([]byte, 0, numBlocks*hLen)
var block [4]byte
for i := 1; i <= numBlocks; i++ {
prf.Reset()
prf.Write(salt)
binary.BigEndian.PutUint32(block[:], uint32(i))
prf.Write(block[:])
u := prf.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for j := 1; j < iter; j++ {
prf.Reset()
prf.Write(u)
u = prf.Sum(u[:0])
for k := range t {
t[k] ^= u[k]
}
}
out = append(out, t...)
}
return out[:keyLen]
}
// IssueSession creates a signed session token (cookie value).
// Format: <expUnix>.<base64url-hmac>.
func (s *Service) IssueSession(now time.Time) string {
exp := now.Add(s.ttl).Unix()
payload := strconv.FormatInt(exp, 10)
mac := s.computeMAC(payload)
return payload + "." + base64.RawURLEncoding.EncodeToString(mac)
}
// VerifySession validates a session token and returns true if valid & not expired.
func (s *Service) VerifySession(token string, now time.Time) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
payload := parts[0]
macStr := parts[1]
mac, err := base64.RawURLEncoding.DecodeString(macStr)
if err != nil {
return false
}
expected := s.computeMAC(payload)
if !hmac.Equal(mac, expected) {
return false
}
exp, err := strconv.ParseInt(payload, 10, 64)
if err != nil {
return false
}
return exp > now.Unix()
}
func (s *Service) computeMAC(payload string) []byte {
m := hmac.New(sha256.New, s.secret)
m.Write([]byte(payload))
return m.Sum(nil)
}
// SetSessionCookie writes the session cookie on the response.
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(s.ttl.Seconds()),
})
}
// ClearSessionCookie expires the session cookie.
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// SessionFromRequest extracts and validates the session cookie.
func (s *Service) SessionFromRequest(r *http.Request, now time.Time) bool {
c, err := r.Cookie(s.cookieName)
if err != nil {
return false
}
return s.VerifySession(c.Value, now)
}
// AllowLogin enforces a per-IP rate limit on login attempts.
func (s *Service) AllowLogin(ip string) bool {
return s.limiter.allow(ip)
}
// CookieName returns the configured cookie name.
func (s *Service) CookieName() string { return s.cookieName }
// TTL returns the configured session TTL.
func (s *Service) TTL() time.Duration { return s.ttl }
// ---- rate limiter (fixed window per IP, in-memory) ----
type rateLimiter struct {
mu sync.Mutex
max int
window time.Duration
hits map[string][]time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{max: max, window: window, hits: make(map[string][]time.Time)}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-r.window)
fresh := r.hits[ip][:0]
for _, t := range r.hits[ip] {
if t.After(cutoff) {
fresh = append(fresh, t)
}
}
if len(fresh) >= r.max {
r.hits[ip] = fresh
return false
}
fresh = append(fresh, now)
r.hits[ip] = fresh
return true
}
+104
View File
@@ -0,0 +1,104 @@
// Package config holds runtime configuration for the web-vnc gateway.
package config
import (
"crypto/rand"
"flag"
"fmt"
"os"
"runtime"
"strings"
"time"
)
// Config is the resolved application configuration.
type Config struct {
ListenAddr string // HTTP/WS listen address, e.g. ":8080"
VNCAddr string // upstream VNC server address, e.g. "127.0.0.1:5900"
VNCPassword string // optional VNC server password, auto-sent to noVNC (empty = none)
PasswordHash string // hash of the shared web access password
SessionSecret []byte // HMAC key for signing session cookies
CookieName string // name of the session cookie
SessionTTL time.Duration // validity of a session cookie
WebRoot string // optional on-disk override for static files (empty = embed.FS)
SpawnVNC bool // auto-launch a VNC server on startup
SpawnCommand string // optional explicit command to launch the VNC server
NoVNCPath string // path serving the noVNC client page
RelayPath string // path of the WS relay endpoint
LoginPath string
LogoutPath string
StatusPath string // VNC health-check endpoint path
OS string // resolved host OS (GOOS) for the spawner
}
// Parse reads flags + env and returns a validated Config.
func Parse(args []string) (Config, error) {
fs := flag.NewFlagSet("web-vnc", flag.ContinueOnError)
var (
secretStr string
)
c := Config{}
fs.StringVar(&c.ListenAddr, "listen", envStr("WEBVNC_LISTEN", ":8080"), "HTTP listen address")
fs.StringVar(&c.VNCAddr, "vnc", envStr("WEBVNC_VNC", "127.0.0.1:5900"), "upstream VNC server address")
fs.StringVar(&c.VNCPassword, "vnc-password", envStr("WEBVNC_VNC_PASSWORD", ""), "VNC server password to auto-send to noVNC (empty = server has no VNC password)")
fs.StringVar(&c.PasswordHash, "password-hash", envStr("WEBVNC_PASSWORD_HASH", ""), "hash of the shared password (use --gen-hash to create one)")
fs.StringVar(&secretStr, "session-secret", envStr("WEBVNC_SESSION_SECRET", ""), "HMAC secret for signing session cookies (random if empty)")
fs.StringVar(&c.CookieName, "cookie-name", "webvnc_session", "session cookie name")
fs.DurationVar(&c.SessionTTL, "session-ttl", envDur("WEBVNC_SESSION_TTL", 8*time.Hour), "session cookie lifetime")
fs.StringVar(&c.WebRoot, "web-root", envStr("WEBVNC_WEB_ROOT", ""), "optional on-disk static files dir (overrides embedded assets)")
fs.BoolVar(&c.SpawnVNC, "spawn", envBool("WEBVNC_SPAWN", false), "auto-launch a detected VNC server on startup")
fs.StringVar(&c.SpawnCommand, "spawn-command", envStr("WEBVNC_SPAWN_COMMAND", ""), "explicit command to launch the VNC server (overrides auto-detection)")
fs.StringVar(&c.NoVNCPath, "novnc-path", "/vnc.html", "path serving the noVNC client page")
fs.StringVar(&c.RelayPath, "relay-path", "/vnc", "WebSocket relay endpoint path")
fs.StringVar(&c.LoginPath, "login-path", "/login", "login endpoint path")
fs.StringVar(&c.LogoutPath, "logout-path", "/logout", "logout endpoint path")
fs.StringVar(&c.StatusPath, "status-path", "/api/status", "VNC health check endpoint path")
if err := fs.Parse(args); err != nil {
return c, err
}
c.OS = runtime.GOOS
if secretStr != "" {
c.SessionSecret = []byte(secretStr)
} else {
c.SessionSecret = randBytes(32)
}
if c.PasswordHash == "" {
return c, fmt.Errorf("a password is required: provide --password-hash (hash) or run 'web-vnc --gen-hash <password>'")
}
return c, nil
}
func envStr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v, ok := os.LookupEnv(key); ok && v != "" {
return strings.EqualFold(v, "true") || v == "1" || strings.EqualFold(v, "yes")
}
return fallback
}
func envDur(key string, fallback time.Duration) time.Duration {
if v, ok := os.LookupEnv(key); ok && v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return b
}
+124
View File
@@ -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
}
+196
View File
@@ -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) }
+180
View File
@@ -0,0 +1,180 @@
// Package server wires HTTP/WS routes, session middleware and static assets.
package server
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/web-vnc/internal/auth"
"github.com/web-vnc/internal/config"
"github.com/web-vnc/internal/relay"
)
//go:embed all:static
var staticFS embed.FS
// Server is the configured HTTP server for the web-vnc gateway.
type Server struct {
cfg config.Config
auth *auth.Service
relay *relay.Server
static fs.FS
cookies *htmlTemplate
}
// New builds a Server from configuration.
func New(cfg config.Config, authSvc *auth.Service, relaySrv *relay.Server) (*Server, error) {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, err
}
if cfg.WebRoot != "" {
sub = os.DirFS(cfg.WebRoot)
}
s := &Server{
cfg: cfg,
auth: authSvc,
relay: relaySrv,
static: sub,
cookies: mustParseLoginTemplate(),
}
return s, nil
}
// Handler returns the configured http.Handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(s.cfg.LoginPath, s.handleLogin)
mux.HandleFunc(s.cfg.LogoutPath, s.handleLogout)
mux.Handle(s.cfg.StatusPath, s.requireSession(http.HandlerFunc(s.handleStatus)))
mux.Handle(s.cfg.RelayPath, s.requireSession(http.HandlerFunc(s.handleRelay)))
mux.Handle(s.cfg.NoVNCPath, s.requireSession(http.HandlerFunc(s.serveNoVNCPage)))
mux.Handle("/", s.requireSession(http.HandlerFunc(s.handleIndexOrStatic)))
return s.logRequest(mux)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.renderLogin(w, "")
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := clientIP(r)
if !s.auth.AllowLogin(ip) {
w.WriteHeader(http.StatusTooManyRequests)
s.renderLogin(w, "Too many attempts. Try again later.")
return
}
password := r.PostFormValue("password")
if !s.auth.CheckPassword(password) {
w.WriteHeader(http.StatusUnauthorized)
s.renderLogin(w, "Wrong password")
return
}
token := s.auth.IssueSession(time.Now())
s.auth.SetSessionCookie(w, token)
http.Redirect(w, r, s.cfg.NoVNCPath, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.auth.ClearSessionCookie(w)
http.Redirect(w, r, s.cfg.LoginPath, http.StatusSeeOther)
}
// handleStatus reports whether the upstream VNC server is reachable.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
ok := pingVNC(s.cfg.VNCAddr, 1500*time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"vnc": ok,
"vncAddr": s.cfg.VNCAddr,
"vncPassword": s.cfg.VNCPassword,
"spawned": s.cfg.SpawnVNC || s.cfg.SpawnCommand != "",
})
}
func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) {
s.relay.ServeHTTP(w, r)
}
func (s *Server) serveNoVNCPage(w http.ResponseWriter, r *http.Request) {
if data, err := fs.ReadFile(s.static, "vnc.html"); err == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}
func (s *Server) handleIndexOrStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, s.cfg.NoVNCPath, http.StatusSeeOther)
return
}
clean := strings.TrimPrefix(r.URL.Path, "/")
if clean != "" && !strings.HasPrefix(clean, ".") {
http.ServeFileFS(w, r, s.static, clean)
return
}
http.NotFound(w, r)
}
func (s *Server) requireSession(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.auth.SessionFromRequest(r, time.Now()) {
if isWebSocket(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, r, s.cfg.LoginPath, http.StatusSeeOther)
return
}
h.ServeHTTP(w, r)
})
}
// pingVNC reports whether a TCP connection to addr succeeds within the timeout.
func pingVNC(addr string, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
func isWebSocket(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
func clientIP(r *http.Request) string {
if h := r.Header.Get("X-Forwarded-For"); h != "" {
return strings.TrimSpace(strings.Split(h, ",")[0])
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
func (s *Server) logRequest(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
h.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en" style="width:100%;height:100%">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Web VNC</title>
<style>
html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}
#screen{width:100%;height:100%;display:block}
#banner{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);max-width:580px;width:90%;
background:#0b1220;color:#e2e8f0;padding:1.4rem 1.6rem;border-radius:12px;font-family:system-ui,sans-serif;
box-shadow:0 10px 40px rgba(0,0,0,.6);line-height:1.45}
#banner h2{margin:0 0 .5rem;font-size:1.05rem}
#banner .addr{color:#93c5fd;font-family:monospace;background:#050a14;padding:.5rem .6rem;border-radius:8px;display:inline-block;margin:.3rem 0}
#banner .hint{color:#94a3b8;font-size:.85rem;margin-top:.7rem}
#banner button{margin-top:.9rem;padding:.5rem .9rem;border:none;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;cursor:pointer;font-size:.9rem}
#banner button:hover{background:#1d4ed8}
</style>
</head>
<body>
<div id="screen"></div>
<div id="banner" style="display:none"></div>
<script type="module">
const wsURL = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/vnc";
const statusURL = "/api/status";
const banner = document.getElementById("banner");
let VNC_PASSWORD = "";
function showBanner(html){ banner.innerHTML = html; banner.style.display = "block"; }
function hideBanner(){ banner.style.display = "none"; }
async function fetchStatus(){
try {
const r = await fetch(statusURL, { credentials: "same-origin" });
return await r.json();
} catch (e) { return null; }
}
async function loadRFB(){
try {
const mod = await import("./core/rfb.js");
return mod.default || mod.RFB || mod;
} catch (e) {
showBanner(`
<h2>noVNC client not bundled</h2>
<div>The noVNC web client assets are missing on the server.</div>
<div class="hint">On the server, run <code>scripts/get-novnc.ps1</code> (or <code>.sh</code>), then rebuild, then restart.</div>
<div class="hint">Expected file: <code>internal/server/static/core/rfb.js</code></div>`);
throw e;
}
}
let rfb = null;
async function connect(){
const RFB = await loadRFB();
rfb = new RFB(document.getElementById("screen"), wsURL);
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.showDotCursor = true;
rfb.addEventListener("connect", hideBanner);
// Auto-send the VNC server password so the user only types the web password.
rfb.addEventListener("credentialsrequired", (ev) => {
if (VNC_PASSWORD) {
rfb.sendCredentials({ password: VNC_PASSWORD });
}
});
rfb.addEventListener("securityfailure", (ev) => {
const reason = (ev.detail && ev.detail.reason) ? ev.detail.reason : "";
showBanner(`
<h2>VNC authentication failed</h2>
<div>The VNC server rejected the password${reason ? (": " + reason) : "."}</div>
<div class="hint">Configure the VNC server (UltraVNC/TightVNC) with the same password you use for the web gate, then retry.</div>
<button onclick="location.reload()">Retry</button>`);
});
rfb.addEventListener("disconnect", (ev) => {
const d = ev.detail || {};
showBanner(`
<h2>Disconnected</h2>
<div>Reason: ${d.reason || "unknown"}</div>
<button onclick="location.reload()">Retry</button>`);
});
window.addEventListener("beforeunload", () => { try { rfb.disconnect(); } catch(e){} });
}
async function start(){
const st = await fetchStatus();
if (st) VNC_PASSWORD = st.vncPassword || "";
if (st && st.vnc === false) {
const addr = st.vncAddr || "127.0.0.1:5900";
const hint = st.spawned
? "Auto-launch was attempted but the VNC server is not listening yet. Install UltraVNC/TightVNC on Windows (or x11vnc/TigerVNC on Linux), then restart."
: "No VNC server is running at that address. Start one (UltraVNC/TightVNC on Windows, or run <code>run.bat</code> with <code>--spawn</code>), then click Retry.";
showBanner(`
<h2>VNC server is not reachable</h2>
<div>The gateway cannot connect to the VNC server at:</div>
<div class="addr">${addr}</div>
<div class="hint">${hint}</div>
<button onclick="location.reload()">Retry</button>`);
connect().catch(()=>{});
} else {
connect().catch(()=>{});
}
}
start();
</script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
package server
import (
"html/template"
"net/http"
)
type htmlTemplate = template.Template
func mustParseLoginTemplate() *htmlTemplate {
return template.Must(template.New("login").Parse(loginPageHTML))
}
func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.cookies.Execute(w, map[string]string{"Error": errMsg})
}
const loginPageHTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Web VNC</title><style>
*{box-sizing:border-box}
body{font-family:system-ui,Segoe UI,sans-serif;background:#0f1720;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{background:#111827;padding:2rem 2.25rem;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,.5);width:320px}
h1{font-size:1.3rem;margin:0 0 .25rem;text-align:center}
.sub{color:#94a3b8;font-size:.8rem;text-align:center;margin:0 0 1.4rem}
label{display:block;font-size:.8rem;margin-bottom:.35rem;color:#cbd5e1}
input{width:100%;padding:.6rem .7rem;border-radius:8px;border:1px solid #334155;background:#0b1220;color:#e2e8f0;font-size:.95rem}
button{width:100%;margin-top:1.1rem;padding:.65rem;border:none;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;font-size:.95rem;cursor:pointer}
button:hover{background:#1d4ed8}
.err{color:#f87171;font-size:.8rem;margin:.7rem 0 0;text-align:center;min-height:1em}
</style></head><body><form class="card" method="post" action="/login" autocomplete="off">
<h1>🖥️ Web VNC</h1><p class="sub">Enter the access password</p>
<label for="password">Password</label>
<input id="password" name="password" type="password" autofocus required>
<button type="submit">Connect</button>
<div class="err">{{.Error}}</div>
</form></body></html>`
+19
View File
@@ -0,0 +1,19 @@
//go:build darwin
package vncspawner
// detectDarwin enables the macOS built-in Screen Sharing service.
func candidates() []Candidate {
return []Candidate{
{
Path: "/System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart",
Args: []string{
"-activate", "-configure", "-access", "-off",
"-restart", "-agent",
"-configure", "-allowAccessFor", "-allUsers",
"-configure", "-clientopts", "-setreqperm", "-no",
},
Desc: "macOS Screen Sharing (kickstart)",
},
}
}
+22
View File
@@ -0,0 +1,22 @@
//go:build linux || freebsd
package vncspawner
import (
"os/exec"
)
// detectLinux finds x11vnc or TigerVNC on PATH.
func candidates() []Candidate {
var cands []Candidate
if p, err := exec.LookPath("x11vnc"); err == nil {
cands = append(cands, Candidate{Path: p, Args: []string{"-display", ":0", "-nopw", "-localhost"}, Desc: "x11vnc"})
}
if p, err := exec.LookPath("tigervncserver"); err == nil {
cands = append(cands, Candidate{Path: p, Args: []string{":1", "-localhost", "-SecurityTypes", "None"}, Desc: "TigerVNC"})
}
if p, err := exec.LookPath("Xvnc"); err == nil {
cands = append(cands, Candidate{Path: p, Args: []string{":1", "-SecurityTypes", "None"}, Desc: "TigerVNC Xvnc"})
}
return cands
}
+81
View File
@@ -0,0 +1,81 @@
//go:build windows
package vncspawner
import (
"os"
"path/filepath"
)
// detectWindows looks for UltraVNC and TightVNC in common install locations,
// in a project-local "vnc" folder, and in WEBVNC_VNC_DIR.
func candidates() []Candidate {
var cands []Candidate
// 1. Project-local vnc/ folder (current working directory) — used by get-vnc.ps1.
cands = append(cands, scanDir(filepath.Join(mustCwd(), "vnc"))...)
// 2. Explicit env override.
if dir := os.Getenv("WEBVNC_VNC_DIR"); dir != "" {
cands = append(cands, scanDir(dir)...)
}
// 3. Standard install paths.
for _, base := range []string{
`C:\Program Files\uvnc bvba\UltraVNC\winvnc.exe`,
`C:\Program Files (x86)\uvnc bvba\UltraVNC\winvnc.exe`,
`C:\Program Files\UltraVNC\winvnc.exe`,
`C:\Program Files (x86)\UltraVNC\winvnc.exe`,
`C:\Program Files\TightVNC\tvnserver.exe`,
`C:\Program Files (x86)\TightVNC\tvnserver.exe`,
} {
if fileExists(base) {
cands = append(cands, Candidate{Path: base, Args: []string{"-run"}, Desc: filepath.Base(base)})
}
}
// 4. Scan Program Files dirs (covers custom install paths).
for _, d := range programDirs() {
for _, pat := range []string{"UltraVNC*\\winvnc.exe", "TightVNC*\\tvnserver.exe"} {
matches, _ := filepath.Glob(filepath.Join(d, pat))
for _, m := range matches {
if fileExists(m) {
cands = append(cands, Candidate{Path: m, Args: []string{"-run"}, Desc: filepath.Base(m)})
}
}
}
}
return cands
}
// scanDir looks for winvnc.exe / tvnserver.exe in dir.
func scanDir(dir string) []Candidate {
var out []Candidate
for _, name := range []string{"winvnc.exe", "tvnserver.exe"} {
p := filepath.Join(dir, name)
if fileExists(p) {
out = append(out, Candidate{Path: p, Args: []string{"-run"}, Desc: name})
}
}
return out
}
func mustCwd() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
func programDirs() []string {
var dirs []string
for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"} {
if v := os.Getenv(env); v != "" {
dirs = append(dirs, v)
}
}
return dirs
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
+115
View File
@@ -0,0 +1,115 @@
// Package vncspawner discovers and launches a VNC server as a child process
// so the gateway can run with a single command.
package vncspawner
import (
"fmt"
"log"
"os/exec"
"runtime"
"strings"
"time"
)
// Candidate is a discovered VNC server program + args to launch it.
type Candidate struct {
Path string
Args []string
Desc string
}
// Process is a launched VNC server child process.
type Process struct {
cmd *exec.Cmd
}
// Stop terminates the child process.
func (p *Process) Stop() {
if p == nil || p.cmd == nil || p.cmd.Process == nil {
return
}
log.Printf("vncspawner: stopping %s", p.cmd.String())
_ = p.cmd.Process.Kill()
}
// Detect searches for an installed VNC server and returns a launch candidate.
func Detect() (Candidate, error) {
cands := candidates()
for _, c := range cands {
if c.Path != "" {
return c, nil
}
}
return Candidate{}, fmt.Errorf("no supported VNC server found; install one of: %s", strings.Join(detectionHints(), ", "))
}
// detectionHints returns human-readable installation hints per OS.
func detectionHints() []string {
switch runtime.GOOS {
case "windows":
return []string{"UltraVNC (winvnc.exe)", "TightVNC (tvnserver.exe)"}
case "darwin":
return []string{"macOS Screen Sharing"}
default:
return []string{"x11vnc", "TigerVNC (Xvnc/tigervncserver)"}
}
}
// Launch starts a VNC server from a candidate (or explicit command line) as a child.
// cmdLine is non-empty it takes precedence (split by spaces, simple shlex).
func Launch(c Candidate, cmdLine string) (*Process, error) {
var path string
var args []string
var desc string
if cmdLine != "" {
parts := splitArgs(cmdLine)
if len(parts) == 0 {
return nil, fmt.Errorf("empty spawn command")
}
path = parts[0]
args = parts[1:]
desc = cmdLine
} else {
if c.Path == "" {
return nil, fmt.Errorf("no VNC server candidate available")
}
path = c.Path
args = c.Args
desc = c.Desc
}
cmd := exec.Command(path, args...)
hideWindow(cmd)
log.Printf("vncspawner: launching %s -> %s", desc, cmd.String())
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("launch %s: %w", path, err)
}
p := &Process{cmd: cmd}
// Reap/wait the child in background; log exit.
go func() {
err := cmd.Wait()
if err != nil {
log.Printf("vncspawner: %s exited: %v", desc, err)
} else {
log.Printf("vncspawner: %s exited cleanly", desc)
}
}()
// Give the server a moment to start listening.
time.Sleep(1200 * time.Millisecond)
return p, nil
}
// splitArgs is a minimal whitespace splitter (no quote handling). For complex
// commands prefer an explicit binary path via configuration instead.
func splitArgs(s string) []string {
var out []string
for _, f := range strings.Fields(s) {
out = append(out, f)
}
return out
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package vncspawner
import "os/exec"
// hideWindow is a no-op on non-Windows platforms.
func hideWindow(cmd *exec.Cmd) {}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package vncspawner
import (
"os/exec"
"syscall"
)
// hideWindow detaches the child process so it does not pop up a console window.
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | 0x08000000, // DETACHED_PROCESS
}
}