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,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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>`
|
||||
@@ -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)",
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user