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