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,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