diff --git a/docs/README.md b/docs/README.md index 64bd323..e7331ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Technical documentation for vpsmgr. All docs are in English. | [configuration.md](configuration.md) | Full reference for `/etc/vpsmgr/config.yaml` | | [ipv6.md](ipv6.md) | IPv6 pass-through design: deterministic addresses, prefix support, routing, installer flow | | [ipv6-ndp-responder.md](ipv6-ndp-responder.md) | Why prefix mode replaced ndppd with an in-tree NDP responder, design hardening, and what to test at scale | +| [web-ssh.md](web-ssh.md) | The browser terminal: how it reaches a container, how a session survives a dropped connection, and how to disable it | | [development.md](development.md) | Building, testing, releasing, conventions | See also [`AGENTS.md`](../AGENTS.md) at the repo root — short version aimed at AI coding agents. diff --git a/docs/configuration.md b/docs/configuration.md index 3496235..46aaf50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,7 @@ same table; `vps config list` shows the live values with this annotation. | `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) | | `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) | | `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer | +| `panel.web_ssh` | operator | restart panel | browser terminal in the user panel; default `true`. When `false` the Web SSH button is not rendered and the panel refuses the terminal page and connection outright | | `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below | | `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI | | `net.subnet` | **fixed at install** | — | container subnet `10..0.0/24`; changing breaks existing containers | diff --git a/docs/web-ssh.md b/docs/web-ssh.md new file mode 100644 index 0000000..ee6b615 --- /dev/null +++ b/docs/web-ssh.md @@ -0,0 +1,57 @@ +# Web SSH + +A terminal in the browser: a shell inside the user's own container, opened from +the Machine card (next to Start / Stop / Restart) in the user panel. + +It is not an SSH client. The container runs on the panel host, so the panel +talks to it through the Incus exec API over the daemon's unix socket, with a +PTY. Nothing listens on the container side, there are no keys to hand out, and +there is no extra network path to arrange. + +## Shape of it + +- **Its own window.** The button opens a separate browser window rather than a + dialog, so it can be dragged to any size, moved to another screen or + maximised. It remembers the size it was last dragged to and reopens that way. +- **Root shell.** The shell runs as root inside the account's own container, + which is the same access the user already has over SSH. The container is + always taken from the session: a client cannot name a container it does not + own. +- **One account, two shells.** A third connection is offered the option to close + the others instead of being refused, which is also how a slot held by a closed + window is reclaimed. + +## Surviving a bad connection + +The panel is often reached from far away, so the browser side is built to +survive the link rather than to be torn down by it. + +- **Liveness.** The server pings every 20 seconds and treats 60 seconds of + silence as gone. The browser answers pings from its network stack, so this + works in a backgrounded tab too, and a connection that disappears without + saying so is noticed in about a minute. +- **Reconnect.** The page hears a small heartbeat the server sends alongside the + protocol pings; hearing nothing for 45 seconds means the socket is dead even + though it has not said so. It then reconnects with exponential backoff and + jitter, up to eight attempts, after which a manual *Reconnect* is offered. +- **Resume.** The shell outlives the socket: when the client goes away the PTY + keeps running for 120 seconds and its output goes into a bounded buffer + (256 KiB, with a notice if anything is dropped). A client that comes back with + its session token resumes the *same* shell — a running build, an open editor + and the shell's variables are all still there. +- **Redraw.** A resumed terminal starts blank and is rebuilt from the buffered + output, so a full-screen program is asked to repaint itself; if one does not, + `Ctrl+L` forces it. +- **Token.** The resume token lives in `sessionStorage`, so reloading the page + comes back to the shell and closing the window lets it go. + +## Turning it off + +``` +vps config set panel.web_ssh false +``` + +The panel restarts and the feature is off: the button is not rendered, and the +terminal page, its script and the terminal connection all refuse. `true` (the +default) turns it back on. A configuration written before this feature existed +comes out enabled. diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index 4c7181c..2bd5e10 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -153,6 +153,12 @@ type PanelCfg struct { // URLPath nor AdminPath get a bare, headerless 404. AdminPath string `yaml:"admin_url_path,omitempty"` ShowFooter bool `yaml:"show_footer"` + // WebSSH enables the browser terminal in the user panel. On by default, + // including for config files written before the feature existed (a missing + // key keeps the default). An operator who does not want a shell reachable + // from the panel turns it off: the button disappears and the panel refuses + // both the terminal page and the terminal connection. + WebSSH bool `yaml:"web_ssh"` // BandwidthResetDay is the day of the month the monthly bandwidth period // rolls over. 1-28 (capped at 28 so February always has the day). BandwidthResetDay int `yaml:"bandwidth_reset_day"` @@ -289,7 +295,7 @@ type SnapshotsCfg struct { func Default() *Config { c := &Config{} - c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1} + c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, WebSSH: true, BandwidthResetDay: 1} c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Haproxy: true, UserPorts: DefaultUserPorts} c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio} c.Snapshots = SnapshotsCfg{Limit: 1, Share: false} diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 075fbcc..2a18bda 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -230,6 +230,18 @@ var Fields = []Field{ c.Panel.ShowFooter = b return nil }}, + {"panel.web_ssh", KindOperator, ApplyRestart, + "browser terminal (Web SSH) in the user panel; false hides the button and refuses terminal connections entirely", + "true or false", + getStr(func(c *Config) string { return strconv.FormatBool(c.Panel.WebSSH) }), + func(c *Config, v string) error { + b, ok := parseBool(v) + if !ok { + return fmt.Errorf("panel.web_ssh must be true/false or 1/0") + } + c.Panel.WebSSH = b + return nil + }}, {"panel.bandwidth_reset_day", KindOperator, ApplyDestructive, "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period", "1", diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 51f9d2d..c7146d9 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -1,6 +1,8 @@ package cfg import ( + "os" + "path/filepath" "strings" "testing" ) @@ -42,6 +44,11 @@ func TestFieldValueReadsConfig(t *testing.T) { if v := FieldValue(c, "net.haproxy"); v != "true" { t.Errorf("net.haproxy default = %q", v) } + // The upgrade case matters: a config written before Web SSH existed has no + // key for it, and the feature must still come out enabled. + if v := FieldValue(c, "panel.web_ssh"); v != "true" { + t.Errorf("panel.web_ssh default = %q, want true (upgrades must get it enabled)", v) + } if v := FieldValue(c, "panel.show_footer"); v != "true" { t.Errorf("panel.show_footer default = %q", v) } @@ -405,3 +412,35 @@ func TestBandwidthResetDayIsDestructive(t *testing.T) { } } } + +// A config file written before the feature existed decodes with the key absent, +// and the feature must come out enabled — that is what "upgrades get it on" +// means in practice. +func TestWebSSHDefaultsOnForAnOlderConfig(t *testing.T) { + base := t.TempDir() + t.Setenv("VPSMGR_CONFIG", filepath.Join(base, "config.yaml")) + if err := Save(Default()); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.Contains(line, "web_ssh") { + continue + } + kept = append(kept, line) + } + if err := os.WriteFile(Path(), []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + c, err := Load() + if err != nil { + t.Fatal(err) + } + if !c.Panel.WebSSH { + t.Error("an old config without the key must still enable Web SSH") + } +} diff --git a/src/internal/lx/attach.go b/src/internal/lx/attach.go index 4375e3b..5876acb 100644 --- a/src/internal/lx/attach.go +++ b/src/internal/lx/attach.go @@ -17,6 +17,13 @@ import ( "github.com/gorilla/websocket" ) +// ptyWriteWait bounds a write to the Incus side of a session. Without it a +// connection that has gone unresponsive — the daemon timed the websocket out +// while the panel was busy, a network hiccup on the way to it — blocks the +// caller (and the session's mutex) forever, which is a wedged panel goroutine +// rather than a failed terminal. +const ptyWriteWait = 5 * time.Second + // TermSize is a terminal window size in character cells. type TermSize struct { Cols int @@ -328,7 +335,9 @@ func (p *Pty) Write(b []byte) error { if p.dead { return errors.New("terminal session is closed") } - return p.conns["0"].WriteMessage(websocket.BinaryMessage, b) + ws := p.conns["0"] + _ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait)) + return ws.WriteMessage(websocket.BinaryMessage, b) } // Resize tells the PTY its new window size so full-screen programs reflow. The @@ -355,6 +364,7 @@ func (p *Pty) Resize(size TermSize) error { if err != nil { return err } + _ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait)) return ws.WriteMessage(websocket.TextMessage, msg) } diff --git a/src/internal/panel/assets/term.js b/src/internal/panel/assets/term.js index f60307a..c0abf7f 100644 --- a/src/internal/panel/assets/term.js +++ b/src/internal/panel/assets/term.js @@ -632,75 +632,207 @@ if (this._raf) cancelAnimationFrame(this._raf); }; + window.VpsmgrTerm = Term; + // ---- session ---------------------------------------------------------- - // openTerminal wires an emulator to /terminal over a WebSocket. Text frames - // in both directions carry JSON control messages; terminal output arrives as - // binary frames so arbitrary bytes survive untouched. + // The terminal is a connection to a shell on the far side of the internet, so + // the session is built to survive the link, not the other way round: + // + // - the shell outlives the socket (the server keeps it for a grace period + // and buffers what it prints), so a reconnect resumes the same shell + // rather than starting a new one. The token that claims it lives in + // sessionStorage, so reloading the page comes back to it and closing the + // window lets it go. + // - a browser cannot see protocol-level pings, so the server also sends a + // small JSON heartbeat; hearing nothing for a while means the socket is + // dead even though it has not said so. + // - a dropped connection is retried with exponential backoff and jitter, + // and giving up is a state the page can offer a manual reconnect from. + var TOKEN_KEY = "vpsmgr_ssh_token"; + var HEARTBEAT_MS = 5000; // how often we check for silence + var SILENCE_MS = 45000; // no message for this long means it is gone + var MAX_ATTEMPTS = 8; + // A handshake that neither completes nor fails would otherwise stall the + // retry loop for good — exactly the shape a flaky link produces. + var CONNECT_TIMEOUT_MS = 15000; + window.vpsmgrOpenTerminal = function (mount, url, hooks) { hooks = hooks || {}; + var msg = hooks.msg || {}; var status = function (t) { if (hooks.onStatus) hooks.onStatus(t); }; - // Declared before the emulator: it reports its size from its constructor, - // and that callback must not touch an unset socket. - var ws = null; - function control(obj) { + var setOthers = function (n) { if (hooks.onOthers) hooks.onOthers(n); }; + var askBusy = function (n, decide) { if (hooks.onBusy) hooks.onBusy(n, decide); }; + + var Term = window.VpsmgrTerm; + var term = null, ws = null, hb = null, retryT = null, retryAt = 0, connectT = null; + var attempt = 0, lastSeen = 0, finished = false; + + function token() { + try { return sessionStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; } + } + function setToken(v) { + try { if (v) sessionStorage.setItem(TOKEN_KEY, v); else sessionStorage.removeItem(TOKEN_KEY); } catch (e) {} + } + function send(obj) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } - function send(data) { control({ t: "i", data: data }); } - var term = new Term(mount, { - onTitle: function (t) { if (hooks.onTitle) hooks.onTitle(t); }, - onReply: function (s) { send(s); }, - onInput: function (s) { send(s); }, - onResize: function (c, r) { control({ t: "r", cols: c, rows: r }); } - }); + function input(data) { send({ t: "input", data: data }); } - var proto = location.protocol === "https:" ? "wss:" : "ws:"; - var q = "?cols=" + term.cols + "&rows=" + term.rows; - ws = new WebSocket(proto + "//" + location.host + url + q); - ws.binaryType = "arraybuffer"; - var closed = false; + function build() { + mount.innerHTML = ""; + term = new Term(mount, { + onTitle: function (t) { if (hooks.onTitle) hooks.onTitle(t); }, + onReply: input, + onInput: input, + onResize: function (c, r) { send({ t: "resize", cols: c, rows: r }); } + }); + } - ws.onopen = function () { - // Send the size the emulator settled on; the shell would otherwise start - // at the server's default until the window is resized. - control({ t: "r", cols: term.cols, rows: term.rows }); - term.focus(); - status(""); - }; - ws.onmessage = function (e) { - if (typeof e.data === "string") { - var msg; - try { msg = JSON.parse(e.data); } catch (err) { return; } - if (msg.t === "exit") { - term.exited = true; - status("session ended" + (msg.code ? " (exit " + msg.code + ")" : "")); - if (hooks.onExit) hooks.onExit(msg.code); - } else if (msg.t === "error") { - term.exited = true; - term.write(new TextEncoder().encode("\r\n[ " + msg.error + " ]\r\n")); - status("error"); - if (hooks.onError) hooks.onError(msg.error); + function stopHeartbeat() { if (hb) { clearInterval(hb); hb = null; } } + function stopConnectTimer() { if (connectT) { clearTimeout(connectT); connectT = null; } } + + function startHeartbeat() { + stopHeartbeat(); + hb = setInterval(function () { + if (finished) return; + if (Date.now() - lastSeen > SILENCE_MS) { + // Silent for too long: treat it as gone and let the retry logic run. + try { ws.close(); } catch (e) { /* already gone */ } + lost(); } + }, HEARTBEAT_MS); + } + + function connect() { + if (finished) return; + retryT = null; + stopHeartbeat(); + // A resumed terminal starts blank and is rebuilt from what the server + // replays, so the emulator is recreated rather than left showing stale + // output from before the drop. + build(); + status(attempt === 0 ? (msg.connecting || "connecting…") + : (msg.reconnecting || "reconnecting…") + " (" + attempt + "/" + MAX_ATTEMPTS + ")"); + + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + ws = new WebSocket(proto + "//" + location.host + url); + ws.binaryType = "arraybuffer"; + + var opened = false; + ws.onopen = function () { + opened = true; + stopConnectTimer(); + lastSeen = Date.now(); + send({ t: "hello", token: token(), cols: term.cols, rows: term.rows }); + startHeartbeat(); + }; + connectT = setTimeout(function () { + if (opened) return; + try { ws.close(); } catch (e) { /* not open anyway */ } + lost(); + }, CONNECT_TIMEOUT_MS); + ws.onmessage = onMessage; + ws.onclose = lost; + ws.onerror = function () { /* onclose follows */ }; + } + + function onMessage(e) { + lastSeen = Date.now(); + if (typeof e.data !== "string") { + if (term) term.write(new Uint8Array(e.data)); return; } - term.write(new Uint8Array(e.data)); - }; - ws.onclose = function () { - if (closed) return; - closed = true; - term.exited = true; - status("connection closed"); - if (hooks.onClose) hooks.onClose(); - }; - ws.onerror = function () { status("connection error"); }; + var m; + try { m = JSON.parse(e.data); } catch (err) { return; } + switch (m.t) { + case "ping": + return; + case "ready": + attempt = 0; + if (hooks.onReady) hooks.onReady(); + setToken(m.token); + setOthers(m.others || 0); + status(msg.connected || ""); + if (term) term.focus(); + return; + case "others": + setOthers(m.others || 0); + return; + case "busy": + status(msg.busy || "another terminal is open"); + askBusy(m.others || 0, function (take) { + if (!take) { close(); return; } + send({ t: "takeover" }); + status(msg.connecting || "connecting…"); + }); + return; + case "exit": + finish((msg.ended || "session ended") + (m.code ? " · exit " + m.code : "")); + if (hooks.onExit) hooks.onExit(m.code); + return; + case "error": + if (term) term.write(new TextEncoder().encode("\r\n[ " + m.error + " ]\r\n")); + finish(m.error); + if (hooks.onError) hooks.onError(m.error); + return; + } + } + + // finish stops for good: the shell is over, so retrying would only start a + // different one behind the user's back. + function finish(text) { + finished = true; + stopHeartbeat(); + stopConnectTimer(); + setToken(""); + status(text); + } + + // lost handles a connection that went away on its own. + function lost() { + stopHeartbeat(); + stopConnectTimer(); + if (finished || retryT) return; + attempt++; + if (attempt > MAX_ATTEMPTS) { + finished = true; + status(msg.disconnected || "disconnected"); + if (hooks.onClose) hooks.onClose(); + return; + } + // Exponential backoff with jitter, so a rack of tabs does not come back + // in lockstep. + var delay = Math.min(400 * Math.pow(2, attempt - 1), 8000) + Math.random() * 400; + retryAt = Date.now() + delay; + status((msg.reconnecting || "reconnecting…") + " (" + attempt + "/" + MAX_ATTEMPTS + ")"); + retryT = setTimeout(connect, delay); + } + + function close() { + finished = true; + stopHeartbeat(); + stopConnectTimer(); + if (retryT) { clearTimeout(retryT); retryT = null; } + setToken(""); + try { ws.close(); } catch (e) { /* already gone */ } + } + + connect(); return { - focus: function () { term.focus(); }, - close: function () { - closed = true; - try { ws.close(); } catch (e) { /* already gone */ } - term.dispose(); - } + focus: function () { if (term) term.focus(); }, + close: close, + // closeOthers ends the user's other shells and keeps this one. + closeOthers: function () { send({ t: "takeover" }); }, + // reconnect is the manual path offered once the retries are exhausted. + reconnect: function () { + finished = false; + attempt = 0; + if (retryT) { clearTimeout(retryT); retryT = null; } + connect(); + }, + retryInMs: function () { return retryT ? Math.max(0, retryAt - Date.now()) : 0; } }; }; })(); diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index c7eb897..6e5fc9b 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -225,7 +225,10 @@ type pageData struct { // impersonating admin can tell users apart. Users cannot set it themselves. ThemeColor string ShowFooter bool - Version string + // WebSSH mirrors cfg.Panel.WebSSH: the overview renders the Web SSH button + // only when the terminal is enabled. + WebSSH bool + Version string } func (s *Server) Handler() http.Handler { @@ -408,6 +411,7 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { V4Forward: s.mgr.V4ForwardLive(), ProxyEnabled: s.mgr.HaproxyLive(), ShowFooter: s.cfg.Panel.ShowFooter, + WebSSH: s.cfg.Panel.WebSSH, Version: ver.Version, InitScript: u.InitScript, MaxNotesPlaintext: cfg.MaxNotesPlaintextBytes, diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 29dff76..e3eb440 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -2067,7 +2067,7 @@ func TestOverviewKnowledgeModal(t *testing.T) { Prefix: "/" + testSecret, Knowledge: []knowledgeArticle{{ ID: 7, Title: "Getting started", UpdatedAt: "2026-08-01T10:00:00Z", - HTML: template.HTML("

Getting started

ls -la
"), + HTML: template.HTML("

Getting started

ls -la
"), Source: "REGINNAME=*#*#region#*#*", }}, }) diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index 871174b..3744124 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -332,7 +332,7 @@ footer a{color:var(--muted);text-decoration:underline}
- +{{if .WebSSH}}{{end}}
diff --git a/src/internal/panel/templates/webssh.html b/src/internal/panel/templates/webssh.html index e956950..1c6e634 100644 --- a/src/internal/panel/templates/webssh.html +++ b/src/internal/panel/templates/webssh.html @@ -21,6 +21,8 @@ header .spacer{flex:1 1 auto} header button{background:#12203a;color:#cbd5e1;border:1px solid #1e3a5f;border-radius:6px; padding:.2rem .6rem;font-size:.76rem;cursor:pointer;font-family:inherit} header button:hover{background:#18294a} +header .others{color:#fbbf24;display:flex;align-items:center;gap:.4rem} +header .others button{padding:.1rem .45rem;font-size:.72rem} main{flex:1 1 auto;min-height:0} main>div{height:100%} .term{height:100%;outline:none;padding:.5rem .6rem;background:#0f172a;overflow:hidden;cursor:text; @@ -31,17 +33,39 @@ main>div{height:100%} .term-probe{position:absolute;visibility:hidden;white-space:pre;line-height:1.35} .term-input{position:absolute;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0; resize:none;overflow:hidden} +.ask{position:fixed;inset:0;background:rgba(2,6,16,.72);display:flex;align-items:center; + justify-content:center;z-index:10} +/* display:flex above would otherwise beat the hidden attribute, leaving an + invisible full-screen sheet swallowing every click. */ +.ask[hidden]{display:none} +.ask-box{background:#0f172a;border:1px solid #1e3a5f;border-radius:10px;padding:1.1rem 1.2rem; + max-width:26rem;font-size:.85rem;line-height:1.5} +.ask-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.9rem} +.ask-actions button{background:#12203a;color:#cbd5e1;border:1px solid #1e3a5f;border-radius:6px; + padding:.35rem .8rem;font-size:.8rem;cursor:pointer;font-family:inherit} +.ask-actions button.go{background:#15803d;border-color:#15803d;color:#fff} +.ask-actions button:hover{filter:brightness(1.15)}
{{.User.Name}} {{if eq .Lang "zh"}}连接中…{{else}}connecting…{{end}} +
+