feat: web-vnc single-binary browser VNC gateway with password access
- Go stdlib-only gateway: serves noVNC client, password auth, WS<->TCP relay - auth: PBKDF2-HMAC-SHA256 password hash, HMAC session cookies, login rate limit - relay: hand-written RFC 6455 WebSocket + transparent RFB bridge - vncspawner: cross-OS VNC server detection/launch (Windows/Linux/macOS) - server: /login /logout /vnc /api/status routes, session middleware, embed.FS - scripts: get-novnc, get-vnc (zip-verified), list-ips, open-firewall - run.bat: one-click launcher (asks only password), lists access IPs - README/AGENTS.md (Russian), .gitignore (root-anchored binaries)
This commit is contained in:
@@ -0,0 +1,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>`
|
||||
Reference in New Issue
Block a user