feat: web-vnc single-binary browser VNC gateway with password access

- Go stdlib-only gateway: serves noVNC client, password auth, WS<->TCP relay
- auth: PBKDF2-HMAC-SHA256 password hash, HMAC session cookies, login rate limit
- relay: hand-written RFC 6455 WebSocket + transparent RFB bridge
- vncspawner: cross-OS VNC server detection/launch (Windows/Linux/macOS)
- server: /login /logout /vnc /api/status routes, session middleware, embed.FS
- scripts: get-novnc, get-vnc (zip-verified), list-ips, open-firewall
- run.bat: one-click launcher (asks only password), lists access IPs
- README/AGENTS.md (Russian), .gitignore (root-anchored binaries)
This commit is contained in:
Codex
2026-07-30 17:40:31 +03:00
commit b89477fb87
24 changed files with 2057 additions and 0 deletions
+34
View File
@@ -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/
+85
View File
@@ -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 "(?<!\r)\n","`r`n", [Text.Encoding]::GetEncoding(866))`.
- **Go-файлы:** UTF-8 **без BOM**, LF. То же самое правило — не использовать
`Set-Content -Encoding UTF8` (ставит BOM). Используй
`[System.IO.File]::WriteAllText(path, content, [Text.UTF8Encoding]::new($false))`.
- **`.gitignore`:** бинарники игнорируются **root-anchored** (`/web-vnc`,
`/web-vnc.exe`), иначе шаблон `web-vnc` ловит пакет `cmd/web-vnc/`.
- **vncspawner:** функции поиска VNC-сервера определены через build-теги
(`detect_windows.go`/`detect_linux.go`/`detect_darwin.go`), каждый файл
определяет `func candidates() []Candidate`. Не ссылаться на
платформо-специфичные функции из общих файлов.
- **Тесты в этой среде:** интернет отсутствует; проверяй через локальные
заглушки (TCP-эхо-сервер на 5900, `Get-NetIPAddress` часто пуст —
`list-ips.ps1` имеет фолбэк на `ipconfig`). Запущенный web-vnc.exe из
фонового job может остаться «зомби» (Stop-Process иногда access denied);
используй разные порты для тестов и по возможности запускай killable-способом.
- **Hijack WebSocket:** `internal/relay` сам делает апгрейд через
`http.Hijacker`; гейтвей НЕ использует gorilla/websocket.
## Что делать дальше (известные TODO)
- Надёжный авто-скачиватель VNC-сервера (URL UltraVNC на SourceForge нестабилен —
`get-vnc.ps1` теперь верифицирует zip-магию и даёт фолбэк на ручную установку).
- На Windows захват экрана может требовать запуск VNC-сервера от администратора.
- VNC-сервер требует свой пароль; `--vnc-password` передаёт его noVNC
автоматически, но пароль VNC-сервера нужно один раз настроить под тот же.
+203
View File
@@ -0,0 +1,203 @@
# web-vnc
Один бинарник, который открывает доступ к рабочему столу (VNC) через браузер
(noVNC) с защитой по **паролю**. Программа `web-vnc`:
- раздаёт HTML5-клиент noVNC,
- пускает зрителя по общему паролю,
- прокидывает WebSocket браузера к локальному VNC-серверу (RFB поверх WS),
- и (по желанию) сама запускает VNC-сервер.
Написано на чистом Go, **только стандартная библиотека** — никаких внешних
Go-модулей, собирается офлайн.
```
Браузер (noVNC) ──ws──▶ web-vnc (один бинарник) ──tcp RFB──▶ VNC-сервер (127.0.0.1:5900)
├─ вход по паролю (PBKDF2-HMAC-SHA256)
├─ HMAC session-cookie
└─ прозрачный релей WS↔TCP
```
## Самый простой запуск (Windows) — одним батником
В папке проекта есть `run.bat`. Он делает всё сам и спрашивает **только пароль**:
```bat
run.bat
```
Что делает `run.bat`:
1. Если `web-vnc.exe` отсутствует — собирает его (нужен установленный Go).
Если Go нет — подсказывает, что сделать.
2. Если noVNC-клиент ещё не встроен — пытается его скачать (нужен интернет).
Без интернета продолжит работу, но в браузере будет страница-заглушка.
3. Спрашивает пароль доступа.
4. Генерирует из него хэш.
5. Ищет VNC-сервер: если он не установлен — пытается скачать портативный
UltraVNC (`scripts\get-vnc.ps1`) и запускать его.
6. Выводит список всех адресов для подключения (`http://localhost:8080` и
`http://<каждый IPv4 машины>: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$<итерации>$<base64-соль>$<base64-ключ>`.
### Запуск одной строкой
```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`.
+100
View File
@@ -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 <password>")
}
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://<this-host>%s", cfg.NoVNCPath)
if err := httpSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
+3
View File
@@ -0,0 +1,3 @@
module github.com/web-vnc
go 1.22
+239
View File
@@ -0,0 +1,239 @@
// Package auth implements shared-password authentication using a salted
// PBKDF2-HMAC-SHA256 password hash, stateless HMAC-signed session cookies,
// and an in-memory login rate limiter. It depends only on the standard library.
package auth
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/binary"
"errors"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
const (
pbkdf2Iterations = 120_000
hashKeyLen = 32
hashScheme = "pbkdf2-sha256"
)
// Service handles authentication concerns for the gateway.
type Service struct {
passwordHash []byte // verified against the scheme string in parseHash
secret []byte
cookieName string
ttl time.Duration
limiter *rateLimiter
}
// New creates an auth Service.
func New(passwordHash string, secret []byte, cookieName string, ttl time.Duration) *Service {
return &Service{
passwordHash: []byte(passwordHash),
secret: secret,
cookieName: cookieName,
ttl: ttl,
limiter: newRateLimiter(5, time.Minute),
}
}
// CheckPassword verifies a plaintext password against the stored hash.
func (s *Service) CheckPassword(plain string) bool {
if len(s.passwordHash) == 0 {
return false
}
scheme, iter, salt, want, err := parseHash(string(s.passwordHash))
if err != nil {
return false
}
if scheme != hashScheme {
return false
}
got := pbkdf2Key([]byte(plain), salt, iter, hashKeyLen)
return subtle.ConstantTimeCompare(got, want) == 1
}
// HashPassword returns a self-describing salted hash for a plaintext password.
func HashPassword(plain string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := pbkdf2Key([]byte(plain), salt, pbkdf2Iterations, hashKeyLen)
return hashScheme + "$" +
strconv.Itoa(pbkdf2Iterations) + "$" +
base64.RawStdEncoding.EncodeToString(salt) + "$" +
base64.RawStdEncoding.EncodeToString(key), nil
}
func parseHash(h string) (scheme string, iter int, salt, key []byte, err error) {
parts := strings.Split(h, "$")
if len(parts) != 4 {
return "", 0, nil, nil, errors.New("invalid hash format")
}
iter, err = strconv.Atoi(parts[1])
if err != nil || iter <= 0 {
return "", 0, nil, nil, errors.New("invalid iteration count")
}
salt, err = base64.RawStdEncoding.DecodeString(parts[2])
if err != nil {
return "", 0, nil, nil, err
}
key, err = base64.RawStdEncoding.DecodeString(parts[3])
if err != nil {
return "", 0, nil, nil, err
}
return parts[0], iter, salt, key, nil
}
// pbkdf2Key implements PBKDF2-HMAC-SHA256 (RFC 2898).
func pbkdf2Key(password, salt []byte, iter, keyLen int) []byte {
prf := hmac.New(sha256.New, password)
hLen := prf.Size()
numBlocks := (keyLen + hLen - 1) / hLen
out := make([]byte, 0, numBlocks*hLen)
var block [4]byte
for i := 1; i <= numBlocks; i++ {
prf.Reset()
prf.Write(salt)
binary.BigEndian.PutUint32(block[:], uint32(i))
prf.Write(block[:])
u := prf.Sum(nil)
t := make([]byte, len(u))
copy(t, u)
for j := 1; j < iter; j++ {
prf.Reset()
prf.Write(u)
u = prf.Sum(u[:0])
for k := range t {
t[k] ^= u[k]
}
}
out = append(out, t...)
}
return out[:keyLen]
}
// IssueSession creates a signed session token (cookie value).
// Format: <expUnix>.<base64url-hmac>.
func (s *Service) IssueSession(now time.Time) string {
exp := now.Add(s.ttl).Unix()
payload := strconv.FormatInt(exp, 10)
mac := s.computeMAC(payload)
return payload + "." + base64.RawURLEncoding.EncodeToString(mac)
}
// VerifySession validates a session token and returns true if valid & not expired.
func (s *Service) VerifySession(token string, now time.Time) bool {
parts := strings.SplitN(token, ".", 2)
if len(parts) != 2 {
return false
}
payload := parts[0]
macStr := parts[1]
mac, err := base64.RawURLEncoding.DecodeString(macStr)
if err != nil {
return false
}
expected := s.computeMAC(payload)
if !hmac.Equal(mac, expected) {
return false
}
exp, err := strconv.ParseInt(payload, 10, 64)
if err != nil {
return false
}
return exp > now.Unix()
}
func (s *Service) computeMAC(payload string) []byte {
m := hmac.New(sha256.New, s.secret)
m.Write([]byte(payload))
return m.Sum(nil)
}
// SetSessionCookie writes the session cookie on the response.
func (s *Service) SetSessionCookie(w http.ResponseWriter, token string) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: int(s.ttl.Seconds()),
})
}
// ClearSessionCookie expires the session cookie.
func (s *Service) ClearSessionCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: s.cookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// SessionFromRequest extracts and validates the session cookie.
func (s *Service) SessionFromRequest(r *http.Request, now time.Time) bool {
c, err := r.Cookie(s.cookieName)
if err != nil {
return false
}
return s.VerifySession(c.Value, now)
}
// AllowLogin enforces a per-IP rate limit on login attempts.
func (s *Service) AllowLogin(ip string) bool {
return s.limiter.allow(ip)
}
// CookieName returns the configured cookie name.
func (s *Service) CookieName() string { return s.cookieName }
// TTL returns the configured session TTL.
func (s *Service) TTL() time.Duration { return s.ttl }
// ---- rate limiter (fixed window per IP, in-memory) ----
type rateLimiter struct {
mu sync.Mutex
max int
window time.Duration
hits map[string][]time.Time
}
func newRateLimiter(max int, window time.Duration) *rateLimiter {
return &rateLimiter{max: max, window: window, hits: make(map[string][]time.Time)}
}
func (r *rateLimiter) allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now()
cutoff := now.Add(-r.window)
fresh := r.hits[ip][:0]
for _, t := range r.hits[ip] {
if t.After(cutoff) {
fresh = append(fresh, t)
}
}
if len(fresh) >= r.max {
r.hits[ip] = fresh
return false
}
fresh = append(fresh, now)
r.hits[ip] = fresh
return true
}
+104
View File
@@ -0,0 +1,104 @@
// Package config holds runtime configuration for the web-vnc gateway.
package config
import (
"crypto/rand"
"flag"
"fmt"
"os"
"runtime"
"strings"
"time"
)
// Config is the resolved application configuration.
type Config struct {
ListenAddr string // HTTP/WS listen address, e.g. ":8080"
VNCAddr string // upstream VNC server address, e.g. "127.0.0.1:5900"
VNCPassword string // optional VNC server password, auto-sent to noVNC (empty = none)
PasswordHash string // hash of the shared web access password
SessionSecret []byte // HMAC key for signing session cookies
CookieName string // name of the session cookie
SessionTTL time.Duration // validity of a session cookie
WebRoot string // optional on-disk override for static files (empty = embed.FS)
SpawnVNC bool // auto-launch a VNC server on startup
SpawnCommand string // optional explicit command to launch the VNC server
NoVNCPath string // path serving the noVNC client page
RelayPath string // path of the WS relay endpoint
LoginPath string
LogoutPath string
StatusPath string // VNC health-check endpoint path
OS string // resolved host OS (GOOS) for the spawner
}
// Parse reads flags + env and returns a validated Config.
func Parse(args []string) (Config, error) {
fs := flag.NewFlagSet("web-vnc", flag.ContinueOnError)
var (
secretStr string
)
c := Config{}
fs.StringVar(&c.ListenAddr, "listen", envStr("WEBVNC_LISTEN", ":8080"), "HTTP listen address")
fs.StringVar(&c.VNCAddr, "vnc", envStr("WEBVNC_VNC", "127.0.0.1:5900"), "upstream VNC server address")
fs.StringVar(&c.VNCPassword, "vnc-password", envStr("WEBVNC_VNC_PASSWORD", ""), "VNC server password to auto-send to noVNC (empty = server has no VNC password)")
fs.StringVar(&c.PasswordHash, "password-hash", envStr("WEBVNC_PASSWORD_HASH", ""), "hash of the shared password (use --gen-hash to create one)")
fs.StringVar(&secretStr, "session-secret", envStr("WEBVNC_SESSION_SECRET", ""), "HMAC secret for signing session cookies (random if empty)")
fs.StringVar(&c.CookieName, "cookie-name", "webvnc_session", "session cookie name")
fs.DurationVar(&c.SessionTTL, "session-ttl", envDur("WEBVNC_SESSION_TTL", 8*time.Hour), "session cookie lifetime")
fs.StringVar(&c.WebRoot, "web-root", envStr("WEBVNC_WEB_ROOT", ""), "optional on-disk static files dir (overrides embedded assets)")
fs.BoolVar(&c.SpawnVNC, "spawn", envBool("WEBVNC_SPAWN", false), "auto-launch a detected VNC server on startup")
fs.StringVar(&c.SpawnCommand, "spawn-command", envStr("WEBVNC_SPAWN_COMMAND", ""), "explicit command to launch the VNC server (overrides auto-detection)")
fs.StringVar(&c.NoVNCPath, "novnc-path", "/vnc.html", "path serving the noVNC client page")
fs.StringVar(&c.RelayPath, "relay-path", "/vnc", "WebSocket relay endpoint path")
fs.StringVar(&c.LoginPath, "login-path", "/login", "login endpoint path")
fs.StringVar(&c.LogoutPath, "logout-path", "/logout", "logout endpoint path")
fs.StringVar(&c.StatusPath, "status-path", "/api/status", "VNC health check endpoint path")
if err := fs.Parse(args); err != nil {
return c, err
}
c.OS = runtime.GOOS
if secretStr != "" {
c.SessionSecret = []byte(secretStr)
} else {
c.SessionSecret = randBytes(32)
}
if c.PasswordHash == "" {
return c, fmt.Errorf("a password is required: provide --password-hash (hash) or run 'web-vnc --gen-hash <password>'")
}
return c, nil
}
func envStr(key, fallback string) string {
if v, ok := os.LookupEnv(key); ok && v != "" {
return v
}
return fallback
}
func envBool(key string, fallback bool) bool {
if v, ok := os.LookupEnv(key); ok && v != "" {
return strings.EqualFold(v, "true") || v == "1" || strings.EqualFold(v, "yes")
}
return fallback
}
func envDur(key string, fallback time.Duration) time.Duration {
if v, ok := os.LookupEnv(key); ok && v != "" {
if d, err := time.ParseDuration(v); err == nil {
return d
}
}
return fallback
}
func randBytes(n int) []byte {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return b
}
+124
View File
@@ -0,0 +1,124 @@
package relay
import (
"io"
"log"
"net"
"net/http"
"sync"
"time"
)
// Server bridges WS connections on RelayPath to the upstream VNC TCP server.
type Server struct {
vncAddr string
}
// New returns a relay Server targeting the given VNC TCP address.
func New(vncAddr string) *Server {
return &Server{vncAddr: vncAddr}
}
// ServeHTTP upgrades to WebSocket and bridges to the VNC server.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
ws, err := handshake(w, r)
if err != nil {
// handshake failed before hijack; respond with an error.
http.Error(w, "websocket handshake failed: "+err.Error(), http.StatusBadRequest)
return
}
defer ws.close()
vnc, err := net.DialTimeout("tcp", s.vncAddr, 10*time.Second)
if err != nil {
log.Printf("relay: dial vnc %s failed: %v", s.vncAddr, err)
ws.writeClose()
return
}
defer vnc.Close()
bridge(ws, vnc)
}
// bridge pumps bytes between the WebSocket and the TCP connection until either side closes.
func bridge(ws *wsConn, vnc net.Conn) {
var wg sync.WaitGroup
wg.Add(2)
// TCP -> WS
go func() {
defer wg.Done()
buf := make([]byte, 4096)
for {
n, err := vnc.Read(buf)
if n > 0 {
if werr := ws.writeBinary(buf[:n]); werr != nil {
return
}
}
if err != nil {
if err != io.EOF {
log.Printf("relay: vnc read: %v", err)
}
ws.writeClose()
return
}
}
}()
// WS -> TCP
go func() {
defer wg.Done()
for {
opcode, payload, err := ws.readFrame()
if err != nil {
if err != io.EOF && !isClosedConnErr(err) {
log.Printf("relay: ws read: %v", err)
}
_ = vnc.Close()
return
}
switch opcode {
case opBinary, opText, opContinuation:
if len(payload) > 0 {
if _, err := vnc.Write(payload); err != nil {
return
}
}
case opPing:
_ = ws.writePong(payload)
case opPong:
// ignore
case opClose:
ws.writeClose()
_ = vnc.Close()
return
}
}
}()
wg.Wait()
}
func isClosedConnErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return contains(s, "use of closed network connection") ||
contains(s, "connection reset") ||
contains(s, "EOF")
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || indexOf(s, sub) >= 0)
}
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
+196
View File
@@ -0,0 +1,196 @@
// Package relay bridges a noVNC WebSocket client to a raw TCP VNC server,
// transparently carrying the RFB byte-stream in both directions.
//
// This file implements a minimal RFC 6455 WebSocket server using only the
// Go standard library, tailored to the needs of noVNC: binary message
// frames in both directions, with ping/pong and close handling.
package relay
import (
"bufio"
"crypto/sha1"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const wsGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
// wsConn wraps a hijacked HTTP connection as a WebSocket (server side).
type wsConn struct {
nc net.Conn
br *bufio.Reader
bw *bufio.Writer
}
// handshake performs the WebSocket upgrade and returns a wsConn.
func handshake(w http.ResponseWriter, r *http.Request) (*wsConn, error) {
if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") ||
!headerContains(r.Header, "Connection", "upgrade") {
return nil, errors.New("not a websocket upgrade request")
}
key := r.Header.Get("Sec-WebSocket-Key")
if key == "" {
return nil, errors.New("missing Sec-WebSocket-Key")
}
hj, ok := w.(http.Hijacker)
if !ok {
return nil, errors.New("response writer does not support hijacking")
}
nc, brw, err := hj.Hijack()
if err != nil {
return nil, err
}
accept := wsAcceptKey(key)
_, _ = fmt.Fprintf(brw, "HTTP/1.1 101 Switching Protocols\r\n")
_, _ = fmt.Fprintf(brw, "Upgrade: websocket\r\n")
_, _ = fmt.Fprintf(brw, "Connection: Upgrade\r\n")
_, _ = fmt.Fprintf(brw, "Sec-WebSocket-Accept: %s\r\n", accept)
_, _ = fmt.Fprintf(brw, "\r\n")
if err := brw.Flush(); err != nil {
_ = nc.Close()
return nil, err
}
return &wsConn{nc: nc, br: brw.Reader, bw: brw.Writer}, nil
}
func wsAcceptKey(key string) string {
h := sha1.New()
h.Write([]byte(key + wsGUID))
return base64.StdEncoding.EncodeToString(h.Sum(nil))
}
func headerContains(h http.Header, name, value string) bool {
for _, v := range h[http.CanonicalHeaderKey(name)] {
for _, tok := range strings.Split(v, ",") {
if strings.EqualFold(strings.TrimSpace(tok), value) {
return true
}
}
}
return false
}
// wsFrame opcodes
const (
opContinuation = 0x0
opText = 0x1
opBinary = 0x2
opClose = 0x8
opPing = 0x9
opPong = 0xA
)
// readFrame reads one WebSocket frame from the client.
// It returns the opcode and (de-masked) payload. Control frames keep their
// own opcode; data frames (text/binary/continuation) are returned as-is and
// the caller is responsible for stream semantics.
func (c *wsConn) readFrame() (opcode byte, payload []byte, err error) {
var hdr [2]byte
if _, err = io.ReadFull(c.br, hdr[:]); err != nil {
return 0, nil, err
}
opcode = hdr[0] & 0x0F
masked := hdr[1]&0x80 != 0
length := int64(hdr[1] & 0x7F)
switch length {
case 126:
var ext [2]byte
if _, err = io.ReadFull(c.br, ext[:]); err != nil {
return 0, nil, err
}
length = int64(binary.BigEndian.Uint16(ext[:]))
case 127:
var ext [8]byte
if _, err = io.ReadFull(c.br, ext[:]); err != nil {
return 0, nil, err
}
length = int64(binary.BigEndian.Uint64(ext[:]))
}
var mask [4]byte
if masked {
if _, err = io.ReadFull(c.br, mask[:]); err != nil {
return 0, nil, err
}
}
if length < 0 {
return 0, nil, errors.New("invalid frame length")
}
payload = make([]byte, length)
if length > 0 {
if _, err = io.ReadFull(c.br, payload); err != nil {
return 0, nil, err
}
}
if masked {
for i := range payload {
payload[i] ^= mask[i%4]
}
}
return opcode, payload, nil
}
// writeBinary sends a single binary frame to the client (server frames are unmasked).
func (c *wsConn) writeBinary(p []byte) error {
var hdr []byte
n := len(p)
switch {
case n < 126:
hdr = []byte{0x82, byte(n)}
case n < 65536:
hdr = make([]byte, 4)
hdr[0] = 0x82
hdr[1] = 126
binary.BigEndian.PutUint16(hdr[2:], uint16(n))
default:
hdr = make([]byte, 10)
hdr[0] = 0x82
hdr[1] = 127
binary.BigEndian.PutUint64(hdr[2:], uint64(n))
}
if _, err := c.bw.Write(hdr); err != nil {
return err
}
if _, err := c.bw.Write(p); err != nil {
return err
}
return c.bw.Flush()
}
// writePong sends a pong frame with the given payload.
func (c *wsConn) writePong(p []byte) error {
if len(p) > 125 {
return errors.New("control frame payload too large")
}
hdr := []byte{0x8A, byte(len(p))}
if _, err := c.bw.Write(hdr); err != nil {
return err
}
if _, err := c.bw.Write(p); err != nil {
return err
}
return c.bw.Flush()
}
// writeClose sends a close frame and flushes.
func (c *wsConn) writeClose() {
_, _ = c.bw.Write([]byte{0x88, 0x00})
_ = c.bw.Flush()
}
// close closes the underlying connection.
func (c *wsConn) close() error { return c.nc.Close() }
// setWriteDeadline forwards to the underlying connection.
func (c *wsConn) setWriteDeadline(t time.Time) error { return c.nc.SetWriteDeadline(t) }
+180
View File
@@ -0,0 +1,180 @@
// Package server wires HTTP/WS routes, session middleware and static assets.
package server
import (
"embed"
"encoding/json"
"io/fs"
"log"
"net"
"net/http"
"os"
"strings"
"time"
"github.com/web-vnc/internal/auth"
"github.com/web-vnc/internal/config"
"github.com/web-vnc/internal/relay"
)
//go:embed all:static
var staticFS embed.FS
// Server is the configured HTTP server for the web-vnc gateway.
type Server struct {
cfg config.Config
auth *auth.Service
relay *relay.Server
static fs.FS
cookies *htmlTemplate
}
// New builds a Server from configuration.
func New(cfg config.Config, authSvc *auth.Service, relaySrv *relay.Server) (*Server, error) {
sub, err := fs.Sub(staticFS, "static")
if err != nil {
return nil, err
}
if cfg.WebRoot != "" {
sub = os.DirFS(cfg.WebRoot)
}
s := &Server{
cfg: cfg,
auth: authSvc,
relay: relaySrv,
static: sub,
cookies: mustParseLoginTemplate(),
}
return s, nil
}
// Handler returns the configured http.Handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc(s.cfg.LoginPath, s.handleLogin)
mux.HandleFunc(s.cfg.LogoutPath, s.handleLogout)
mux.Handle(s.cfg.StatusPath, s.requireSession(http.HandlerFunc(s.handleStatus)))
mux.Handle(s.cfg.RelayPath, s.requireSession(http.HandlerFunc(s.handleRelay)))
mux.Handle(s.cfg.NoVNCPath, s.requireSession(http.HandlerFunc(s.serveNoVNCPage)))
mux.Handle("/", s.requireSession(http.HandlerFunc(s.handleIndexOrStatic)))
return s.logRequest(mux)
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
s.renderLogin(w, "")
return
}
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
ip := clientIP(r)
if !s.auth.AllowLogin(ip) {
w.WriteHeader(http.StatusTooManyRequests)
s.renderLogin(w, "Too many attempts. Try again later.")
return
}
password := r.PostFormValue("password")
if !s.auth.CheckPassword(password) {
w.WriteHeader(http.StatusUnauthorized)
s.renderLogin(w, "Wrong password")
return
}
token := s.auth.IssueSession(time.Now())
s.auth.SetSessionCookie(w, token)
http.Redirect(w, r, s.cfg.NoVNCPath, http.StatusSeeOther)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
s.auth.ClearSessionCookie(w)
http.Redirect(w, r, s.cfg.LoginPath, http.StatusSeeOther)
}
// handleStatus reports whether the upstream VNC server is reachable.
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
ok := pingVNC(s.cfg.VNCAddr, 1500*time.Millisecond)
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"vnc": ok,
"vncAddr": s.cfg.VNCAddr,
"vncPassword": s.cfg.VNCPassword,
"spawned": s.cfg.SpawnVNC || s.cfg.SpawnCommand != "",
})
}
func (s *Server) handleRelay(w http.ResponseWriter, r *http.Request) {
s.relay.ServeHTTP(w, r)
}
func (s *Server) serveNoVNCPage(w http.ResponseWriter, r *http.Request) {
if data, err := fs.ReadFile(s.static, "vnc.html"); err == nil {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(data)
return
}
http.NotFound(w, r)
}
func (s *Server) handleIndexOrStatic(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
http.Redirect(w, r, s.cfg.NoVNCPath, http.StatusSeeOther)
return
}
clean := strings.TrimPrefix(r.URL.Path, "/")
if clean != "" && !strings.HasPrefix(clean, ".") {
http.ServeFileFS(w, r, s.static, clean)
return
}
http.NotFound(w, r)
}
func (s *Server) requireSession(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !s.auth.SessionFromRequest(r, time.Now()) {
if isWebSocket(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
http.Redirect(w, r, s.cfg.LoginPath, http.StatusSeeOther)
return
}
h.ServeHTTP(w, r)
})
}
// pingVNC reports whether a TCP connection to addr succeeds within the timeout.
func pingVNC(addr string, timeout time.Duration) bool {
conn, err := net.DialTimeout("tcp", addr, timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
}
func isWebSocket(r *http.Request) bool {
return strings.EqualFold(r.Header.Get("Upgrade"), "websocket")
}
func clientIP(r *http.Request) string {
if h := r.Header.Get("X-Forwarded-For"); h != "" {
return strings.TrimSpace(strings.Split(h, ",")[0])
}
host := r.RemoteAddr
if i := strings.LastIndex(host, ":"); i > 0 {
host = host[:i]
}
return host
}
func (s *Server) logRequest(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
h.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
})
}
+106
View File
@@ -0,0 +1,106 @@
<!doctype html>
<html lang="en" style="width:100%;height:100%">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Web VNC</title>
<style>
html,body{margin:0;padding:0;width:100%;height:100%;background:#000;overflow:hidden}
#screen{width:100%;height:100%;display:block}
#banner{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);max-width:580px;width:90%;
background:#0b1220;color:#e2e8f0;padding:1.4rem 1.6rem;border-radius:12px;font-family:system-ui,sans-serif;
box-shadow:0 10px 40px rgba(0,0,0,.6);line-height:1.45}
#banner h2{margin:0 0 .5rem;font-size:1.05rem}
#banner .addr{color:#93c5fd;font-family:monospace;background:#050a14;padding:.5rem .6rem;border-radius:8px;display:inline-block;margin:.3rem 0}
#banner .hint{color:#94a3b8;font-size:.85rem;margin-top:.7rem}
#banner button{margin-top:.9rem;padding:.5rem .9rem;border:none;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;cursor:pointer;font-size:.9rem}
#banner button:hover{background:#1d4ed8}
</style>
</head>
<body>
<div id="screen"></div>
<div id="banner" style="display:none"></div>
<script type="module">
const wsURL = (location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/vnc";
const statusURL = "/api/status";
const banner = document.getElementById("banner");
let VNC_PASSWORD = "";
function showBanner(html){ banner.innerHTML = html; banner.style.display = "block"; }
function hideBanner(){ banner.style.display = "none"; }
async function fetchStatus(){
try {
const r = await fetch(statusURL, { credentials: "same-origin" });
return await r.json();
} catch (e) { return null; }
}
async function loadRFB(){
try {
const mod = await import("./core/rfb.js");
return mod.default || mod.RFB || mod;
} catch (e) {
showBanner(`
<h2>noVNC client not bundled</h2>
<div>The noVNC web client assets are missing on the server.</div>
<div class="hint">On the server, run <code>scripts/get-novnc.ps1</code> (or <code>.sh</code>), then rebuild, then restart.</div>
<div class="hint">Expected file: <code>internal/server/static/core/rfb.js</code></div>`);
throw e;
}
}
let rfb = null;
async function connect(){
const RFB = await loadRFB();
rfb = new RFB(document.getElementById("screen"), wsURL);
rfb.scaleViewport = true;
rfb.resizeSession = false;
rfb.showDotCursor = true;
rfb.addEventListener("connect", hideBanner);
// Auto-send the VNC server password so the user only types the web password.
rfb.addEventListener("credentialsrequired", (ev) => {
if (VNC_PASSWORD) {
rfb.sendCredentials({ password: VNC_PASSWORD });
}
});
rfb.addEventListener("securityfailure", (ev) => {
const reason = (ev.detail && ev.detail.reason) ? ev.detail.reason : "";
showBanner(`
<h2>VNC authentication failed</h2>
<div>The VNC server rejected the password${reason ? (": " + reason) : "."}</div>
<div class="hint">Configure the VNC server (UltraVNC/TightVNC) with the same password you use for the web gate, then retry.</div>
<button onclick="location.reload()">Retry</button>`);
});
rfb.addEventListener("disconnect", (ev) => {
const d = ev.detail || {};
showBanner(`
<h2>Disconnected</h2>
<div>Reason: ${d.reason || "unknown"}</div>
<button onclick="location.reload()">Retry</button>`);
});
window.addEventListener("beforeunload", () => { try { rfb.disconnect(); } catch(e){} });
}
async function start(){
const st = await fetchStatus();
if (st) VNC_PASSWORD = st.vncPassword || "";
if (st && st.vnc === false) {
const addr = st.vncAddr || "127.0.0.1:5900";
const hint = st.spawned
? "Auto-launch was attempted but the VNC server is not listening yet. Install UltraVNC/TightVNC on Windows (or x11vnc/TigerVNC on Linux), then restart."
: "No VNC server is running at that address. Start one (UltraVNC/TightVNC on Windows, or run <code>run.bat</code> with <code>--spawn</code>), then click Retry.";
showBanner(`
<h2>VNC server is not reachable</h2>
<div>The gateway cannot connect to the VNC server at:</div>
<div class="addr">${addr}</div>
<div class="hint">${hint}</div>
<button onclick="location.reload()">Retry</button>`);
connect().catch(()=>{});
} else {
connect().catch(()=>{});
}
}
start();
</script>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
package server
import (
"html/template"
"net/http"
)
type htmlTemplate = template.Template
func mustParseLoginTemplate() *htmlTemplate {
return template.Must(template.New("login").Parse(loginPageHTML))
}
func (s *Server) renderLogin(w http.ResponseWriter, errMsg string) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_ = s.cookies.Execute(w, map[string]string{"Error": errMsg})
}
const loginPageHTML = `<!doctype html>
<html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Web VNC</title><style>
*{box-sizing:border-box}
body{font-family:system-ui,Segoe UI,sans-serif;background:#0f1720;color:#e2e8f0;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
.card{background:#111827;padding:2rem 2.25rem;border-radius:14px;box-shadow:0 10px 40px rgba(0,0,0,.5);width:320px}
h1{font-size:1.3rem;margin:0 0 .25rem;text-align:center}
.sub{color:#94a3b8;font-size:.8rem;text-align:center;margin:0 0 1.4rem}
label{display:block;font-size:.8rem;margin-bottom:.35rem;color:#cbd5e1}
input{width:100%;padding:.6rem .7rem;border-radius:8px;border:1px solid #334155;background:#0b1220;color:#e2e8f0;font-size:.95rem}
button{width:100%;margin-top:1.1rem;padding:.65rem;border:none;border-radius:8px;background:#2563eb;color:#fff;font-weight:600;font-size:.95rem;cursor:pointer}
button:hover{background:#1d4ed8}
.err{color:#f87171;font-size:.8rem;margin:.7rem 0 0;text-align:center;min-height:1em}
</style></head><body><form class="card" method="post" action="/login" autocomplete="off">
<h1>🖥️ Web VNC</h1><p class="sub">Enter the access password</p>
<label for="password">Password</label>
<input id="password" name="password" type="password" autofocus required>
<button type="submit">Connect</button>
<div class="err">{{.Error}}</div>
</form></body></html>`
+19
View File
@@ -0,0 +1,19 @@
//go:build darwin
package vncspawner
// detectDarwin enables the macOS built-in Screen Sharing service.
func candidates() []Candidate {
return []Candidate{
{
Path: "/System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart",
Args: []string{
"-activate", "-configure", "-access", "-off",
"-restart", "-agent",
"-configure", "-allowAccessFor", "-allUsers",
"-configure", "-clientopts", "-setreqperm", "-no",
},
Desc: "macOS Screen Sharing (kickstart)",
},
}
}
+22
View File
@@ -0,0 +1,22 @@
//go:build linux || freebsd
package vncspawner
import (
"os/exec"
)
// detectLinux finds x11vnc or TigerVNC on PATH.
func candidates() []Candidate {
var cands []Candidate
if p, err := exec.LookPath("x11vnc"); err == nil {
cands = append(cands, Candidate{Path: p, Args: []string{"-display", ":0", "-nopw", "-localhost"}, Desc: "x11vnc"})
}
if p, err := exec.LookPath("tigervncserver"); err == nil {
cands = append(cands, Candidate{Path: p, Args: []string{":1", "-localhost", "-SecurityTypes", "None"}, Desc: "TigerVNC"})
}
if p, err := exec.LookPath("Xvnc"); err == nil {
cands = append(cands, Candidate{Path: p, Args: []string{":1", "-SecurityTypes", "None"}, Desc: "TigerVNC Xvnc"})
}
return cands
}
+81
View File
@@ -0,0 +1,81 @@
//go:build windows
package vncspawner
import (
"os"
"path/filepath"
)
// detectWindows looks for UltraVNC and TightVNC in common install locations,
// in a project-local "vnc" folder, and in WEBVNC_VNC_DIR.
func candidates() []Candidate {
var cands []Candidate
// 1. Project-local vnc/ folder (current working directory) — used by get-vnc.ps1.
cands = append(cands, scanDir(filepath.Join(mustCwd(), "vnc"))...)
// 2. Explicit env override.
if dir := os.Getenv("WEBVNC_VNC_DIR"); dir != "" {
cands = append(cands, scanDir(dir)...)
}
// 3. Standard install paths.
for _, base := range []string{
`C:\Program Files\uvnc bvba\UltraVNC\winvnc.exe`,
`C:\Program Files (x86)\uvnc bvba\UltraVNC\winvnc.exe`,
`C:\Program Files\UltraVNC\winvnc.exe`,
`C:\Program Files (x86)\UltraVNC\winvnc.exe`,
`C:\Program Files\TightVNC\tvnserver.exe`,
`C:\Program Files (x86)\TightVNC\tvnserver.exe`,
} {
if fileExists(base) {
cands = append(cands, Candidate{Path: base, Args: []string{"-run"}, Desc: filepath.Base(base)})
}
}
// 4. Scan Program Files dirs (covers custom install paths).
for _, d := range programDirs() {
for _, pat := range []string{"UltraVNC*\\winvnc.exe", "TightVNC*\\tvnserver.exe"} {
matches, _ := filepath.Glob(filepath.Join(d, pat))
for _, m := range matches {
if fileExists(m) {
cands = append(cands, Candidate{Path: m, Args: []string{"-run"}, Desc: filepath.Base(m)})
}
}
}
}
return cands
}
// scanDir looks for winvnc.exe / tvnserver.exe in dir.
func scanDir(dir string) []Candidate {
var out []Candidate
for _, name := range []string{"winvnc.exe", "tvnserver.exe"} {
p := filepath.Join(dir, name)
if fileExists(p) {
out = append(out, Candidate{Path: p, Args: []string{"-run"}, Desc: name})
}
}
return out
}
func mustCwd() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
return wd
}
func programDirs() []string {
var dirs []string
for _, env := range []string{"ProgramFiles", "ProgramFiles(x86)", "ProgramW6432"} {
if v := os.Getenv(env); v != "" {
dirs = append(dirs, v)
}
}
return dirs
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}
+115
View File
@@ -0,0 +1,115 @@
// Package vncspawner discovers and launches a VNC server as a child process
// so the gateway can run with a single command.
package vncspawner
import (
"fmt"
"log"
"os/exec"
"runtime"
"strings"
"time"
)
// Candidate is a discovered VNC server program + args to launch it.
type Candidate struct {
Path string
Args []string
Desc string
}
// Process is a launched VNC server child process.
type Process struct {
cmd *exec.Cmd
}
// Stop terminates the child process.
func (p *Process) Stop() {
if p == nil || p.cmd == nil || p.cmd.Process == nil {
return
}
log.Printf("vncspawner: stopping %s", p.cmd.String())
_ = p.cmd.Process.Kill()
}
// Detect searches for an installed VNC server and returns a launch candidate.
func Detect() (Candidate, error) {
cands := candidates()
for _, c := range cands {
if c.Path != "" {
return c, nil
}
}
return Candidate{}, fmt.Errorf("no supported VNC server found; install one of: %s", strings.Join(detectionHints(), ", "))
}
// detectionHints returns human-readable installation hints per OS.
func detectionHints() []string {
switch runtime.GOOS {
case "windows":
return []string{"UltraVNC (winvnc.exe)", "TightVNC (tvnserver.exe)"}
case "darwin":
return []string{"macOS Screen Sharing"}
default:
return []string{"x11vnc", "TigerVNC (Xvnc/tigervncserver)"}
}
}
// Launch starts a VNC server from a candidate (or explicit command line) as a child.
// cmdLine is non-empty it takes precedence (split by spaces, simple shlex).
func Launch(c Candidate, cmdLine string) (*Process, error) {
var path string
var args []string
var desc string
if cmdLine != "" {
parts := splitArgs(cmdLine)
if len(parts) == 0 {
return nil, fmt.Errorf("empty spawn command")
}
path = parts[0]
args = parts[1:]
desc = cmdLine
} else {
if c.Path == "" {
return nil, fmt.Errorf("no VNC server candidate available")
}
path = c.Path
args = c.Args
desc = c.Desc
}
cmd := exec.Command(path, args...)
hideWindow(cmd)
log.Printf("vncspawner: launching %s -> %s", desc, cmd.String())
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("launch %s: %w", path, err)
}
p := &Process{cmd: cmd}
// Reap/wait the child in background; log exit.
go func() {
err := cmd.Wait()
if err != nil {
log.Printf("vncspawner: %s exited: %v", desc, err)
} else {
log.Printf("vncspawner: %s exited cleanly", desc)
}
}()
// Give the server a moment to start listening.
time.Sleep(1200 * time.Millisecond)
return p, nil
}
// splitArgs is a minimal whitespace splitter (no quote handling). For complex
// commands prefer an explicit binary path via configuration instead.
func splitArgs(s string) []string {
var out []string
for _, f := range strings.Fields(s) {
out = append(out, f)
}
return out
}
+8
View File
@@ -0,0 +1,8 @@
//go:build !windows
package vncspawner
import "os/exec"
// hideWindow is a no-op on non-Windows platforms.
func hideWindow(cmd *exec.Cmd) {}
+16
View File
@@ -0,0 +1,16 @@
//go:build windows
package vncspawner
import (
"os/exec"
"syscall"
)
// hideWindow detaches the child process so it does not pop up a console window.
func hideWindow(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{
HideWindow: true,
CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP | 0x08000000, // DETACHED_PROCESS
}
}
+121
View File
@@ -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
+46
View File
@@ -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
}
+31
View File
@@ -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"
+115
View File
@@ -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
}
+33
View File
@@ -0,0 +1,33 @@
# list-ips.ps1 — prints reachable IPv4 addresses of this machine (one per line).
# Used by run.bat to show http://<ip>: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 $_ }
+37
View File
@@ -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