commit b89477fb8752e7ba795423874d046ba5df52dc8d Author: Codex Date: Thu Jul 30 17:40:31 2026 +0300 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) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9b0095b --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Build artifacts (root-anchored so they don't match the cmd/web-vnc package dir) +/web-vnc +/web-vnc.exe +/web-vnc-linux +/web-vnc-macos +*.exe +dist/ + +# Go +/vendor/ +.gocache/ +*.test + +# Local config / secrets +*.local.yaml +.env + +# Logs +*.log +*.err + +# OS +Thumbs.db +.DS_Store + +# Downloaded noVNC assets (installed via scripts/get-novnc.*) +internal/server/static/core/ +internal/server/static/app/ +internal/server/static/vendor/ +internal/server/static/utils/ +internal/server/static/novnc-original.html + +# Downloaded VNC server (installed via scripts/get-vnc.ps1) +/vnc/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..98ad4ee --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,85 @@ +# AGENTS.md + +Руководство для агентов (и людей), работающих с этим репозиторием. + +## Что это + +`web-vnc` — один Go-бинарник, который открывает доступ к рабочему столу (VNC) +через браузер (noVNC) с защитой по паролю. Гейтвей сам раздаёт noVNC-клиент, +проверяет пароль, ставит HMAC-сессию и прозрачно релеит WebSocket браузера в +TCP VNC-сервера (RFB). Опционально сам находит и запускает VNC-сервер. + +## Ключевые принципы (НЕ нарушать) + +- **Только стандартная библиотека Go.** Внешних Go-модулей нет и быть не должно — + проект собирается офлайн (в среде сборки нет интернета/Go-proxy). + WebSocket (RFC 6455) и хэш пароля (PBKDF2-HMAC-SHA256) реализованы вручную + в `internal/relay` и `internal/auth`. +- **noVNC-клиент скачивается отдельно** (`scripts/get-novnc.*`) во встроенную + статику `internal/server/static/`. Эти папки (`core/`,`app/`,`vendor/`, + `utils/`,`novnc-original.html`) в git не коммитятся (см. `.gitignore`). + Наша собственная обёртка — `internal/server/static/vnc.html` (коммитится). +- **Пароль** — один общий для всех (по требованию). Веб-пароль (PBKDF2) хранится + как `--password-hash`; тот же пароль может передаваться VNC-серверу через + `--vnc-password`/`WEBVNC_VNC_PASSWORD`, чтобы noVNC авторизовался + автоматически (одно поле ввода для пользователя). + +## Сборка и запуск + +```powershell +# среда без интернета: Go уже установлен, прокси недоступен -> stdlib-only +$env:GOCACHE = "$env:TEMP\go-build" # дефолтный кэш бывает без прав на запись +go build -o web-vnc.exe ./cmd/web-vnc +.\web-vnc.exe --gen-hash "пароль" # напечатает хэш +.\web-vnc.exe --password-hash <хэш> --spawn +``` + +Проверка: `go vet ./...`, `gofmt -l internal cmd` (должно быть пусто). + +## Структура + +``` +cmd/web-vnc/main.go CLI: флаги, спавн VNC, запуск сервера, --gen-hash +internal/config флаги + env (WEBVNC_*) +internal/auth PBKDF2-HMAC-SHA256, HMAC session-cookie, rate-limit +internal/relay websocket.go — RFC6455 на stdlib; relay.go — WS<->TCP +internal/vncspawner кросс-ОС поиск/запуск VNC-сервера (build-теги по ОС) +internal/server HTTP-роуты, /api/status, middleware сессии, embed.FS +internal/server/static встроенные ассеты (vnc.html + noVNC core/app/vendor) +scripts/get-novnc.{ps1,sh} скачать noVNC +scripts/get-vnc.ps1 скачать портативный UltraVNC в vnc/ (с верификацией zip) +scripts/list-ips.ps1 список IPv4 машины (используется run.bat) +scripts/open-firewall.bat открыть порт 8080 в Windows Firewall (от админа) +run.bat запуск в один клик (спрашивает только пароль) +``` + +## Соглашения и подводные камни + +- **Кодировка файлов `.bat`:** сохранять в кодировке **cp866** с окончаниями строк + **CRLF**. PowerShell `Set-Content -Encoding UTF8` добавляет BOM и пишет LF — + не использовать для `.bat`. Пиши через + `[System.IO.File]::WriteAllText(path, content -replace "(?:8080`) и запускает сервер с флагом `--spawn`. + +После запуска откройте в браузере один из выведенных адресов, +введите тот же пароль — и попадёте на рабочий стол. + +## Быстрый старт вручную (Windows) + +```powershell +# 1. Подтянуть noVNC-клиент во встроенную статику (один раз) +.\scripts\get-novnc.ps1 + +# 2. Собрать бинарник +go build -o web-vnc.exe .\cmd\web-vnc + +# 3. Сгенерировать хэш пароля +$hash = .\web-vnc.exe --gen-hash "ваш-пароль" + +# 4. Запустить (сам найдёт/запустит VNC-сервер и поднимется на :8080) +.\web-vnc.exe --password-hash $hash --spawn +``` + +## Быстрый старт (Linux / macOS) + +```bash +./scripts/get-novnc.sh +go build -o web-vnc ./cmd/web-vnc +hash=$(./web-vnc --gen-hash "ваш-пароль") +./web-vnc --password-hash "$hash" --spawn +``` + +## Как это работает + +1. Пользователь открывает `http://хост:8080/` и попадает на `/login`. +2. Вводит общий пароль. Он сверяется с **солёным PBKDF2-HMAC-SHA256**-хэшем + (120 000 итераций). При успехе сервер ставит **HMAC-подписанную, + HttpOnly**-куку сессии (по умолчанию 8 ч, без серверного хранилища). +3. Браузер загружает `/vnc.html` (только с валидной сессией). Клиент noVNC + открывает WebSocket на `/vnc`. +4. `web-vnc` проверяет сессию **до** апгрейда WebSocket и затем прозрачно + релеит байты между WebSocket и локальным VNC-сервером + (`127.0.0.1:5900` по умолчанию). RFB-протокол проходит нетронутым, + как у `websockify`. + +> VNC-сервер должен слушать на **loopback (127.0.0.1)** и может работать +> **без VNC-пароля** — защита по паролю теперь на веб-гейтвее. Если же ваш +> VNC-сервер требует свой пароль, noVNC спросит и его. + +## Авто-запуск VNC-сервера (`--spawn`) + +С `--spawn` программа ищет установленный VNC-сервер и запускает его как +дочерний процесс (отдельно, без окна на Windows), после чего подключается к нему. + +| ОС | Что ищет | +|---------|-------------------------------------------------------| +| Windows | UltraVNC (`winvnc.exe`), TightVNC (`tvnserver.exe`) | +| Linux | `x11vnc`, `tigervncserver` / `Xvnc` | +| macOS | встроенный Screen Sharing (`kickstart`) | + +Переопределить авто-поиск своей командой: + +```powershell +.\web-vnc.exe --password-hash $hash --spawn-command "C:\Path\To\winvnc.exe -run" +``` + +Без `--spawn` убедитесь, что VNC-сервер уже слушает по адресу из `--vnc`. + +## Настройка + +Все флаги дублируются переменными окружения (`WEBVNC_*`). + +| Флаг | Env | По умолчанию | Описание | +|--------------------|---------------------------|--------------------|------------------------------------------------| +| `--listen` | `WEBVNC_LISTEN` | `:8080` | адрес HTTP/WS | +| `--vnc` | `WEBVNC_VNC` | `127.0.0.1:5900` | адрес вышестоящего VNC-сервера | +| `--password-hash` | `WEBVNC_PASSWORD_HASH` | (обязателен) | хэш PBKDF2 из `--gen-hash` | +| `--session-secret` | `WEBVNC_SESSION_SECRET` | случайный при старте | HMAC-ключ для подписи куки сессии | +| `--session-ttl` | `WEBVNC_SESSION_TTL` | `8h` | время жизни куки сессии | +| `--spawn` | `WEBVNC_SPAWN` | false | авто-запуск найденного VNC-сервера | +| `--spawn-command` | `WEBVNC_SPAWN_COMMAND` | (нет) | явная команда запуска VNC-сервера | +| `--web-root` | `WEBVNC_WEB_ROOT` | (встроенные) | раздавать статику с диска вместо embed | +| `--novnc-path` | | `/vnc.html` | путь страницы noVNC-клиента | +| `--relay-path` | | `/vnc` | endpoint WebSocket-релея | + +### Сгенерировать хэш пароля + +```bash +web-vnc --gen-hash "ваш-пароль" +# напечатает, например: pbkdf2-sha256$120000$<соль>$<ключ> +``` + +Формат самодокументируемый: +`pbkdf2-sha256$<итерации>$$`. + +### Запуск одной строкой + +```powershell +.\web-vnc.exe --password-hash (. \web-vnc.exe --gen-hash "secret") --spawn +``` + +## Безопасность + +- **Без TLS**: рассчитано на **приватную сеть**. Если выставляете наружу — + поставьте перед ним reverse-proxy с TLS (nginx/caddy). +- Логин с лимитом попыток (5 в минуту на IP, в памяти). +- Кука сессии подписана `--session-secret`. Задайте фиксированный + `--session-secret`, чтобы сессии переживали перезапуск (иначе секрет + меняется при каждом старте, и старые сессии инвалидируются). +- VNC-сервер держите на `127.0.0.1`, чтобы до него нельзя было достучаться + в обход гейтвея. + + +## Устранение неполадок + +- **После ввода пароля — ошибка / нет картинки.** + Значит, веб-гейтвей не смог подключиться к VNC-серверу (по адресу `--vnc`, + по умолчанию `127.0.0.1:5900`). На странице теперь показывается понятное + сообщение вместо криптографической ошибки noVNC. + Решение: должен работать VNC-сервер, который отдаёт рабочий стол: + - Windows: установите **UltraVNC** или **TightVNC**, либо запустите + `scripts\get-vnc.ps1` (скачает портативный UltraVNC в папку `vnc\`, и + `run.bat` сам его запустит). + - Linux: `x11vnc` или `TigerVNC`. + - macOS: встроенный Screen Sharing. + Важно: VNC-сервер должен слушать на `127.0.0.1:5900`. На Windows для захвата + экрана может потребоваться запуск от имени администратора (UAC). + +- **С другого компьютера страница не открывается (таймаут/недоступно).** + По умолчанию Windows Firewall блокирует входящие подключения. Один раз + выполните от имени администратора: + ```bat + scripts\open-firewall.bat + ``` + Это откроет входящий TCP-порт 8080. (`run.bat` выводит адреса и подсказку.) + +- **noVNC-клиент не встроен (страница-заглушка).** + Запустите `scripts\get-novnc.ps1` (нужен интернет), пересоберите и перезапустите. +## Структура проекта + +``` +cmd/web-vnc/main.go точка входа CLI: флаги, спавн VNC, запуск сервера +internal/config конфигурация (флаги + env) +internal/auth хэш пароля PBKDF2, HMAC-куки сессии, rate-limit +internal/relay WebSocket на stdlib (RFC 6455) + мост WS↔TCP +internal/vncspawner кросс-ОС поиск и запуск VNC-сервера +internal/server HTTP-роуты, middleware сессии, встроенная статика +internal/server/static встроенные веб-ассеты (vnc.html + core/app/vendor noVNC) +scripts/get-novnc.{ps1,sh} скачать noVNC во встроенную статику +scripts/get-vnc.ps1 скачать портативный VNC-сервер (UltraVNC) в папку vnc/ +scripts/list-ips.ps1 список IPv4 машины (используется run.bat) +scripts/open-firewall.bat открыть порт 8080 в Windows Firewall (один раз, от админа) +run.bat запуск в один клик (спрашивает только пароль) +``` + +## Кросс-компиляция под другую ОС + +```bash +GOOS=linux GOARCH=amd64 go build -o web-vnc-linux ./cmd/web-vnc +GOOS=windows GOARCH=amd64 go build -o web-vnc.exe ./cmd/web-vnc +GOOS=darwin GOARCH=arm64 go build -o web-vnc-macos ./cmd/web-vnc +``` + +## Лицензия + +MIT. Встроенные ассеты noVNC сохраняют свою лицензию (MPL-2.0) — +см. `internal/server/static/LICENSE` после запуска `get-novnc`. \ No newline at end of file diff --git a/cmd/web-vnc/main.go b/cmd/web-vnc/main.go new file mode 100644 index 0000000..20f7596 --- /dev/null +++ b/cmd/web-vnc/main.go @@ -0,0 +1,100 @@ +// Command web-vnc is a single-binary browser VNC gateway with password access. +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "github.com/web-vnc/internal/auth" + "github.com/web-vnc/internal/config" + "github.com/web-vnc/internal/relay" + "github.com/web-vnc/internal/server" + "github.com/web-vnc/internal/vncspawner" +) + +func main() { + if err := run(os.Args[1:]); err != nil { + fmt.Fprintf(os.Stderr, "web-vnc: %v\n", err) + os.Exit(1) + } +} + +func run(args []string) error { + // Subcommand: generate a bcrypt hash for a password. + if len(args) > 0 && (args[0] == "--gen-hash" || args[0] == "-gen-hash") { + if len(args) < 2 { + return fmt.Errorf("usage: web-vnc --gen-hash ") + } + hash, err := auth.HashPassword(args[1]) + if err != nil { + return err + } + fmt.Println(hash) + return nil + } + + cfg, err := config.Parse(args) + if err != nil { + return err + } + + // Optionally launch a VNC server as a child process. + if cfg.SpawnVNC || cfg.SpawnCommand != "" { + var cand vncspawner.Candidate + if cfg.SpawnCommand == "" { + c, derr := vncspawner.Detect() + if derr != nil { + log.Printf("vncspawner: %v", derr) + log.Printf("vncspawner: skipping auto-launch; connect to existing VNC server at %s", cfg.VNCAddr) + } else { + cand = c + } + } + proc, lerr := vncspawner.Launch(cand, cfg.SpawnCommand) + if lerr != nil { + log.Printf("vncspawner: launch failed: %v", lerr) + log.Printf("vncspawner: continuing; make sure a VNC server is reachable at %s", cfg.VNCAddr) + } else { + defer proc.Stop() + } + } else { + log.Printf("not auto-launching VNC server; expecting one at %s", cfg.VNCAddr) + } + + authSvc := auth.New(cfg.PasswordHash, cfg.SessionSecret, cfg.CookieName, cfg.SessionTTL) + relaySrv := relay.New(cfg.VNCAddr) + + srv, err := server.New(cfg, authSvc, relaySrv) + if err != nil { + return err + } + + httpSrv := &http.Server{ + Addr: cfg.ListenAddr, + Handler: srv.Handler(), + ReadHeaderTimeout: 10 * time.Second, + } + + stop := make(chan os.Signal, 1) + signal.Notify(stop, os.Interrupt, syscall.SIGTERM) + go func() { + <-stop + log.Printf("shutting down...") + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = httpSrv.Shutdown(ctx) + }() + + log.Printf("web-vnc listening on http://%s (VNC upstream %s)", cfg.ListenAddr, cfg.VNCAddr) + log.Printf("open the noVNC client at http://%s", cfg.NoVNCPath) + if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + return err + } + return nil +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b52e43f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/web-vnc + +go 1.22 diff --git a/internal/auth/auth.go b/internal/auth/auth.go new file mode 100644 index 0000000..0b32aa5 --- /dev/null +++ b/internal/auth/auth.go @@ -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: .. +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 +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2fc92ae --- /dev/null +++ b/internal/config/config.go @@ -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 '") + } + 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 +} diff --git a/internal/relay/relay.go b/internal/relay/relay.go new file mode 100644 index 0000000..8f0ec99 --- /dev/null +++ b/internal/relay/relay.go @@ -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 +} diff --git a/internal/relay/websocket.go b/internal/relay/websocket.go new file mode 100644 index 0000000..9312ac4 --- /dev/null +++ b/internal/relay/websocket.go @@ -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) } diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..1cec066 --- /dev/null +++ b/internal/server/server.go @@ -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)) + }) +} diff --git a/internal/server/static/vnc.html b/internal/server/static/vnc.html new file mode 100644 index 0000000..7b83794 --- /dev/null +++ b/internal/server/static/vnc.html @@ -0,0 +1,106 @@ + + + + + +Web VNC + + + +
+ + + + \ No newline at end of file diff --git a/internal/server/template.go b/internal/server/template.go new file mode 100644 index 0000000..5d5730e --- /dev/null +++ b/internal/server/template.go @@ -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 = ` + + +Web VNC
+

🖥️ Web VNC

Enter the access password

+ + + +
{{.Error}}
+
` diff --git a/internal/vncspawner/detect_darwin.go b/internal/vncspawner/detect_darwin.go new file mode 100644 index 0000000..1b2c026 --- /dev/null +++ b/internal/vncspawner/detect_darwin.go @@ -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)", + }, + } +} diff --git a/internal/vncspawner/detect_linux.go b/internal/vncspawner/detect_linux.go new file mode 100644 index 0000000..13d1d42 --- /dev/null +++ b/internal/vncspawner/detect_linux.go @@ -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 +} diff --git a/internal/vncspawner/detect_windows.go b/internal/vncspawner/detect_windows.go new file mode 100644 index 0000000..c8754ee --- /dev/null +++ b/internal/vncspawner/detect_windows.go @@ -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 +} diff --git a/internal/vncspawner/spawner.go b/internal/vncspawner/spawner.go new file mode 100644 index 0000000..311c9ad --- /dev/null +++ b/internal/vncspawner/spawner.go @@ -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 +} diff --git a/internal/vncspawner/sysproc_unix.go b/internal/vncspawner/sysproc_unix.go new file mode 100644 index 0000000..73de636 --- /dev/null +++ b/internal/vncspawner/sysproc_unix.go @@ -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) {} diff --git a/internal/vncspawner/sysproc_windows.go b/internal/vncspawner/sysproc_windows.go new file mode 100644 index 0000000..a192dbd --- /dev/null +++ b/internal/vncspawner/sysproc_windows.go @@ -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 + } +} diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..7de77c7 --- /dev/null +++ b/run.bat @@ -0,0 +1,121 @@ +@echo off +REM ============================================================ +REM web-vnc launcher - . 訢 ⮫쪮 ஫. +REM ============================================================ +chcp 866 >nul +cd /d "%~dp0" + +echo. +echo === web-vnc: VNC 㧥 === +echo. + +REM --- 1. ୨ --- +if exist "web-vnc.exe" goto havebin +where go >nul 2>nul +if not errorlevel 1 goto dogobuild +echo [訡] web-vnc.exe , Go ⠭. +echo ⠭ Go https://go.dev/dl/ ᮡ࠭ web-vnc.exe 冷. +echo. +pause +exit /b 1 +:dogobuild +echo web-vnc.exe ... +go build -o web-vnc.exe .\cmd\web-vnc +if errorlevel 1 goto buildfail +:havebin + +REM --- 2. noVNC- --- +if exist "internal\server\static\core\rfb.js" goto havenovnc +echo noVNC- ஥. ᪠ ... +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\get-novnc.ps1" +if errorlevel 1 goto novncfail +echo ᮡ noVNC ... +go build -o web-vnc.exe .\cmd\web-vnc +goto havenovnc +:novncfail +echo [।०] 㤠 ᪠ noVNC ( ୥?). +echo 㧥 ஥ ࠭ ᪠. : scripts\get-novnc.ps1 +echo. +:havenovnc + +REM --- 3. VNC-ࢥ (/㧪) --- +set "SPAWNARGS=" +set "SPAWN_CMD=" +if exist "vnc\winvnc.exe" goto uselocalvnc +if exist "vnc\tvnserver.exe" goto uselocalvnc +if exist "C:\Program Files\UltraVNC\winvnc.exe" goto autodetectvnc +if exist "C:\Program Files (x86)\UltraVNC\winvnc.exe" goto autodetectvnc +if exist "C:\Program Files\uvnc bvba\UltraVNC\winvnc.exe" goto autodetectvnc +if exist "C:\Program Files (x86)\uvnc bvba\UltraVNC\winvnc.exe" goto autodetectvnc +if exist "C:\Program Files\TightVNC\tvnserver.exe" goto autodetectvnc +if exist "C:\Program Files (x86)\TightVNC\tvnserver.exe" goto autodetectvnc +echo VNC-ࢥ . ᪠ ⨢ UltraVNC ... +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\get-vnc.ps1" +if errorlevel 1 goto novncserver +if exist "vnc\winvnc.exe" goto uselocalvnc +if exist "vnc\tvnserver.exe" goto uselocalvnc +goto novncserver +:uselocalvnc +if exist "vnc\winvnc.exe" set "SPAWN_CMD=vnc\winvnc.exe -run" +if exist "vnc\tvnserver.exe" if not defined SPAWN_CMD set "SPAWN_CMD=vnc\tvnserver.exe -run" +set "SPAWNARGS=--spawn-command "%SPAWN_CMD%"" +echo VNC-ࢥ: %SPAWN_CMD% +goto vncdone +:autodetectvnc +echo VNC-ࢥ ⥬ (-। ᪥). +goto vncdone +:novncserver +echo [।०] VNC-ࢥ 㤠 . +echo VNC-ࢥ ࠡ稩 ⮫ 㤥 ࠭᫨஢. +echo ⠭ UltraVNC/TightVNC : scripts\get-vnc.ps1 +echo. +:vncdone + +REM --- 4. ஫ --- +:getpw +set "PW=" +set /p "PW= ஫ 㯠: " +if "%PW%"=="" goto emptypw +goto gotpw +:emptypw +echo ஫ . +goto getpw +:gotpw + +REM --- 5. ஫ + ஫ VNC-ࢥ --- +echo ஫ ... +for /f "delims=" %%i in ('web-vnc.exe --gen-hash "%PW%"') do set "HASH=%%i" +if "%HASH%"=="" goto hashfail +REM ஫ 㤥 ⮬᪨ । noVNC ਧ樨 VNC-ࢥ. +set "WEBVNC_VNC_PASSWORD=%PW%" +set "PW=" + +REM --- 6. 㯭 --- +echo. +echo === 祭 ( 8080) === +echo http://localhost:8080 +for /f "delims=" %%a in ('powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0scripts\list-ips.ps1"') do echo http://%%a:8080 +echo. +echo ᫨ 㣮 設 뢠 - ࠧ 믮 +echo : scripts\open-firewall.bat +echo. + +REM --- 7. --- +echo ᪠ web-vnc http://localhost:8080 +echo ⠭: Ctrl+C +echo. +web-vnc.exe --password-hash "%HASH%" %SPAWNARGS% --spawn --listen :8080 +echo. +echo ࢥ ⠭. +pause +exit /b + +:buildfail +echo ઠ 㤠. +pause +exit /b 1 + +:hashfail +echo [訡] 㤠 ᣥ஢ ஫. +pause +exit /b 1 \ No newline at end of file diff --git a/scripts/get-novnc.ps1 b/scripts/get-novnc.ps1 new file mode 100644 index 0000000..40c03b7 --- /dev/null +++ b/scripts/get-novnc.ps1 @@ -0,0 +1,46 @@ +# get-novnc.ps1 +# Downloads the noVNC web client and installs its assets into the embedded +# static directory so they get baked into the single web-vnc binary. +# +# Usage (from repo root): .\scripts\get-novnc.ps1 +# Optionally pass a version: .\scripts\get-novnc.ps1 -Version v1.4.0 +param( + [string]$Version = "v1.4.0" +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$staticDir = Join-Path $repoRoot "internal\server\static" +New-Item -ItemType Directory -Force -Path $staticDir | Out-Null + +$work = Join-Path $env:TEMP "webvnc-novnc-$([guid]::NewGuid())" +New-Item -ItemType Directory -Force -Path $work | Out-Null +try { + $archive = Join-Path $work "novnc.tar.gz" + $url = "https://github.com/novnc/noVNC/archive/refs/tags/$Version.tar.gz" + Write-Host "Downloading noVNC $Version from $url" + Invoke-WebRequest -Uri $url -OutFile $archive -UseBasicParsing + + Write-Host "Extracting..." + tar -xzf $archive -C $work + $extracted = Get-ChildItem -Directory -Path $work | Where-Object { $_.Name -like "noVNC-*" } | Select-Object -First 1 + if (-not $extracted) { throw "Extraction produced no noVNC-* directory" } + + # Copy noVNC assets, but keep our custom vnc.html wrapper intact. + foreach ($sub in @("core","app","vendor","utils")) { + $src = Join-Path $extracted.FullName $sub + if (Test-Path $src) { + Copy-Item -Path $src -Destination $staticDir -Recurse -Force + Write-Host " installed $sub/" + } + } + # Optionally keep noVNC's own page under a different name for reference. + if (Test-Path (Join-Path $extracted.FullName "vnc.html")) { + Copy-Item -Path (Join-Path $extracted.FullName "vnc.html") -Destination (Join-Path $staticDir "novnc-original.html") -Force + Write-Host " copied noVNC vnc.html -> novnc-original.html (our vnc.html stays)" + } + Write-Host "Done. Rebuild web-vnc to embed the new client: go build -o web-vnc.exe .\cmd\web-vnc" +} +finally { + Remove-Item -Path $work -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/get-novnc.sh b/scripts/get-novnc.sh new file mode 100644 index 0000000..8481731 --- /dev/null +++ b/scripts/get-novnc.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# get-novnc.sh +# Downloads the noVNC web client and installs its assets into the embedded +# static directory so they get baked into the single web-vnc binary. +# +# Usage (from repo root): ./scripts/get-novnc.sh [version] +set -euo pipefail +VERSION="${1:-v1.4.0}" +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +STATIC_DIR="$REPO_ROOT/internal/server/static" +mkdir -p "$STATIC_DIR" + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +URL="https://github.com/novnc/noVNC/archive/refs/tags/$VERSION.tar.gz" +echo "Downloading noVNC $VERSION from $URL" +curl -fL "$URL" -o "$WORK/novnc.tar.gz" + +echo "Extracting..." +tar -xzf "$WORK/novnc.tar.gz" -C "$WORK" +EXTRACTED="$(find "$WORK" -maxdepth 1 -type d -name 'noVNC-*' | head -n1)" +[ -z "$EXTRACTED" ] && { echo "Extraction produced no noVNC-* directory"; exit 1; } + +for sub in core app vendor utils; do + [ -d "$EXTRACTED/$sub" ] || continue + cp -R "$EXTRACTED/$sub" "$STATIC_DIR/" + echo " installed $sub/" +done +[ -f "$EXTRACTED/vnc.html" ] && cp "$EXTRACTED/vnc.html" "$STATIC_DIR/novnc-original.html" && echo " copied noVNC vnc.html -> novnc-original.html (our vnc.html stays)" +echo "Done. Rebuild web-vnc to embed the new client: go build -o web-vnc ./cmd/web-vnc" diff --git a/scripts/get-vnc.ps1 b/scripts/get-vnc.ps1 new file mode 100644 index 0000000..80b9d6c --- /dev/null +++ b/scripts/get-vnc.ps1 @@ -0,0 +1,115 @@ +# get-vnc.ps1 +# Downloads a portable VNC server (UltraVNC) for Windows and places its +# files into a local "vnc" folder so web-vnc can auto-launch it with --spawn. +# +# Usage (from repo root): .\scripts\get-vnc.ps1 +# Override the download URL(s): .\scripts\get-vnc.ps1 -Url "https://.../UltraVNC_x64.zip" +# +# If all download attempts fail, the script opens https://uvnc.com/downloads.html +# in your browser — download the UltraVNC .zip (NOT the installer), extract it and +# copy winvnc.exe together with its companion .dll/.dsm files into the project's +# "vnc" folder, then run `run.bat`. + +[CmdletBinding()] +param( + [string[]]$Url = @( + "https://downloads.sourceforge.net/project/ultravnc/UltraVNC%201.4.3.0%20bin/UltraVNC_1.4.3.0_x64.zip", + "https://downloads.sourceforge.net/project/ultravnc/UltraVNC_1.4.3.0/UltraVNC_1.4.3.0_x64.zip", + "https://sourceforge.net/projects/ultravnc/files/UltraVNC%201.4.3.0%20bin/UltraVNC_1.4.3.0_x64.zip/download" + ) +) + +$ErrorActionPreference = "Stop" +$repoRoot = Resolve-Path (Join-Path $PSScriptRoot "..") +$vncDir = Join-Path $repoRoot "vnc" +New-Item -ItemType Directory -Force -Path $vncDir | Out-Null + +$work = Join-Path $env:TEMP ("webvnc-vnc-" + [guid]::NewGuid()) +New-Item -ItemType Directory -Force -Path $work | Out-Null + +function Test-Zip([string]$path) { + if (-not (Test-Path $path)) { return $false } + $fs = [System.IO.File]::OpenRead($path) + try { + $b = New-Object byte[] 4 + $n = $fs.Read($b, 0, 4) + return ($n -ge 2 -and $b[0] -eq 0x50 -and $b[1] -eq 0x4B) # "PK" + } finally { $fs.Close() } +} + +function Get-Zip { + param([string[]]$urls) + $ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 web-vnc-installer" + foreach ($u in $urls) { + $archive = Join-Path $work "vnc.zip" + Remove-Item $archive -ErrorAction SilentlyContinue + Write-Host "" + Write-Host "Trying: $u" + foreach ($attempt in 1..2) { + try { + Invoke-WebRequest -Uri $u -OutFile $archive -UseBasicParsing -TimeoutSec 300 -UserAgent $ua -MaximumRedirection 20 + if (Test-Zip $archive) { + $len = [Math]::Round((Get-Item $archive).Length / 1MB, 1) + Write-Host " downloaded zip (${len} MB)" + return $archive + } + Write-Host " response was not a zip (HTML page?), retrying ..." + Remove-Item $archive -ErrorAction SilentlyContinue + Start-Sleep -Seconds 2 + } catch { + Write-Host " attempt ${attempt} failed: $($_.Exception.Message)" + Start-Sleep -Seconds 2 + } + } + } + return $null +} + +function Manual-Fallback { + $page = "https://uvnc.com/downloads.html" + Write-Host "" + Write-Host "============================================================" -ForegroundColor Yellow + Write-Host "Automatic download failed. Opening the UltraVNC download page." -ForegroundColor Yellow + Write-Host "============================================================" -ForegroundColor Yellow + try { Start-Process $page } catch { Write-Host "Open manually: $page" } + Write-Host "" + Write-Host "Manual steps:" + Write-Host " 1. On the page, download the UltraVNC .zip archive (the portable" + Write-Host " package, NOT the installer)." + Write-Host " 2. Extract it." + Write-Host " 3. Copy winvnc.exe AND its companion .dll/.dsm files (everything" + Write-Host " from the extracted folder) into this folder:" + Write-Host " $vncDir" + Write-Host " 4. Run: .\run.bat" + Write-Host "" +} + +try { + $archive = Get-Zip -urls $Url + if (-not $archive) { Manual-Fallback; exit 1 } + + Write-Host "Extracting ..." + Expand-Archive -Path $archive -DestinationPath $work -Force + + $server = Get-ChildItem -Recurse -Path $work -Filter "winvnc.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $server) { + $server = Get-ChildItem -Recurse -Path $work -Filter "tvnserver.exe" -ErrorAction SilentlyContinue | Select-Object -First 1 + } + if (-not $server) { + Write-Host "winvnc.exe not found in the archive." -ForegroundColor Yellow + Manual-Fallback + exit 1 + } + + $srcDir = $server.DirectoryName + Write-Host "Found VNC server in: $srcDir" + Write-Host "Copying files into: $vncDir" + Copy-Item -Path (Join-Path $srcDir "*") -Destination $vncDir -Recurse -Force + + Write-Host "" + Write-Host "Done. Installed: $(Join-Path $vncDir $server.Name)" -ForegroundColor Green + Write-Host "Now run: .\run.bat" +} +finally { + Remove-Item -Path $work -Recurse -Force -ErrorAction SilentlyContinue +} \ No newline at end of file diff --git a/scripts/list-ips.ps1 b/scripts/list-ips.ps1 new file mode 100644 index 0000000..933c89c --- /dev/null +++ b/scripts/list-ips.ps1 @@ -0,0 +1,33 @@ +# list-ips.ps1 — prints reachable IPv4 addresses of this machine (one per line). +# Used by run.bat to show http://:8080 access URLs. +$ErrorActionPreference = "SilentlyContinue" +$ips = New-Object System.Collections.Generic.List[string] + +# Primary: pure .NET network interfaces (locale independent). +try { + [System.Net.NetworkInformation.NetworkInterface]::GetAllNetworkInterfaces() | + Where-Object { + $_.OperationalStatus -eq [System.Net.NetworkInformation.OperationalStatus]::Up -and + $_.NetworkInterfaceType -ne [System.Net.NetworkInformation.NetworkInterfaceType]::Loopback + } | + ForEach-Object { $_.GetIPProperties().UnicastAddresses } | + Where-Object { + $_.Address.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork -and + $_.Address.IPAddressToString -notlike "127.*" -and + $_.Address.IPAddressToString -notlike "169.254.*" + } | + ForEach-Object { $ips.Add($_.Address.IPAddressToString) } +} catch {} + +# Fallback: parse ipconfig (handles localized output via the "IPv4" token). +if ($ips.Count -eq 0) { + ipconfig | Select-String -Pattern "IPv4" | ForEach-Object { + if ($_ -match "(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})") { + $ip = $matches[1] + if ($ip -notlike "127.*" -and $ip -notlike "169.254.*") { $ips.Add($ip) } + } + } +} + +# Deduplicate and print. +$ips | Sort-Object -Unique | ForEach-Object { Write-Output $_ } \ No newline at end of file diff --git a/scripts/open-firewall.bat b/scripts/open-firewall.bat new file mode 100644 index 0000000..7fd2de2 --- /dev/null +++ b/scripts/open-firewall.bat @@ -0,0 +1,37 @@ +@echo off +REM ============================================================ +REM open-firewall.bat - 뢠 8080 Windows Firewall. +REM ᪠ ( ࠧ). +REM : 䠩 ᠬ ࠢ . +REM ============================================================ +chcp 866 >nul +cd /d "%~dp0" + +REM ஢ઠ ࠢ -襭. +net session >nul 2>nul +if errorlevel 1 ( + echo ࠢ ... + powershell -Command "Start-Process -FilePath '%~f0' -Verb RunAs" + exit /b +) + +set PORT=8080 +set RULE=web-vnc + +echo ஥ ࠢ (᫨ ) ... +netsh advfirewall firewall delete rule name="%RULE%" >nul 2>nul + +echo ࠧ饥 ࠢ 室饣 TCP-䨪 %PORT% ... +netsh advfirewall firewall add rule name="%RULE%" dir=in action=allow protocol=TCP localport=%PORT% +if errorlevel 1 ( + echo [訡] 㤠 ࠢ. + pause + exit /b 1 +) + +echo. +echo ⮢. %PORT% 室 祭. +echo web-vnc 㣨 設 : +echo http://IP__:%PORT% +echo. +pause \ No newline at end of file