feat(web-ssh): make the terminal survive a bad connection, and gate it

A browser reaching this panel is often a long way away, so the session is now
built to survive the link rather than be torn down by it.

Liveness first, because the old numbers were wrong: pings every 20s with a 60s
deadline. The previous setting was a single 15-minute timer, and since the
browser answers pings automatically it never fired for an idle user — the one
thing it did do was take fifteen minutes to notice a peer that had vanished.

- the shell outlives the socket. When the client goes away the PTY keeps running
  for 120s with its output landing in a 256 KiB ring (with a notice if anything
  is dropped). A client that comes back with its token resumes the same shell, so
  a tunnel, a cell handover or a sleeping laptop no longer costs a running build.
  The token travels in the first frame rather than the URL, is bound to the user,
  and lives in sessionStorage — reloading resumes, closing the window lets go.
- the page has its own heartbeat: a browser cannot see protocol-level pings, so
  the server also sends a small JSON one and the page reconnects with exponential
  backoff and jitter after 45s of silence, up to eight attempts.
- a handshake that neither completes nor fails would otherwise stall the retry
  loop for good, so an attempt that has not opened in 15s is abandoned.
- at the per-user cap the client is offered its slots back instead of being
  refused, which is the only way to reclaim a window the user can no longer see.
  Where there is room, the count is just a notice.

Three bugs the browser testing turned up, all of them real:

- Pty writes had no deadline, so a wedged Incus connection blocked the handler
  forever — resume hung after any pause long enough to break that socket.
- a chunk whose write failed was neither delivered nor buffered.
- a session stayed resumable between Close and the end of its drain.

An operator can now turn the whole thing off with `vps config set panel.web_ssh
false`: the button is not rendered and the page, the script and the connection
all refuse. It defaults on, including for a config written before the setting
existed.
This commit is contained in:
bluebluesoda committed 2026-09-17 12:13:02 +00:00
1 parent 4a17a95d85
commit 5e28fc2983
15 files changed
+1163 -245

No files matched your search

+1
View File
@@ -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.
+1
View File
@@ -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.<n>.0.0/24`; changing breaks existing containers |
+57
View File
@@ -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.
+7 -1
View File
@@ -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}
+12
View File
@@ -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",
+39
View File
@@ -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")
}
}
+11 -1
View File
@@ -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)
}
+187 -55
View File
@@ -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; }
};
};
})();
+5 -1
View File
@@ -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,
+1 -1
View File
@@ -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("<h1>Getting started</h1><pre><code>ls -la</code></pre>"),
HTML: template.HTML("<h1>Getting started</h1><pre><code>ls -la</code></pre>"),
Source: "REGINNAME=*#*#region#*#*",
}},
})
+1 -1
View File
@@ -332,7 +332,7 @@ footer a{color:var(--muted);text-decoration:underline}
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="start"><button class="btn ok">{{if eq .Lang "zh"}}开机{{else}}Start{{end}}</button></form>
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="stop"><button class="btn warn">{{if eq .Lang "zh"}}关机{{else}}Stop{{end}}</button></form>
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="restart"><button class="btn">{{if eq .Lang "zh"}}重启{{else}}Restart{{end}}</button></form>
<button class="btn ssh" type="button" id="sshBtn" title="{{if eq .Lang "zh"}}在独立窗口里打开一个 shell{{else}}Open a shell in its own window{{end}}">Web SSH</button>
{{if .WebSSH}}<button class="btn ssh" type="button" id="sshBtn" title="{{if eq .Lang "zh"}}在独立窗口里打开一个 shell{{else}}Open a shell in its own window{{end}}">Web SSH</button>{{end}}
</div>
<div class="btnrow">
<form action="{{.Prefix}}/root-reset" method="post" class="inline"><button class="btn accent" id="resetBtn">{{if eq .Lang "zh"}}重置 root 密码{{else}}Reset root password{{end}}</button></form>
+80 -5
View File
@@ -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)}
</style>
</head>
<body>
<header>
<span class="host">{{.User.Name}}</span>
<span class="status" id="status">{{if eq .Lang "zh"}}连接中…{{else}}connecting…{{end}}</span>
<span class="others" id="others" hidden></span>
<span class="spacer"></span>
<button type="button" id="fsBtn">{{if eq .Lang "zh"}}全屏{{else}}Fullscreen{{end}}</button>
<button type="button" id="reBtn">{{if eq .Lang "zh"}}重连{{else}}Reconnect{{end}}</button>
</header>
<main><div id="termMount" tabindex="0"></div></main>
<div class="ask" id="ask" hidden>
<div class="ask-box">
<p id="askText"></p>
<div class="ask-actions">
<button type="button" id="askCancel">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
<button type="button" id="askTake" class="go">{{if eq .Lang "zh"}}关闭其他终端并继续{{else}}Close the others and continue{{end}}</button>
</div>
</div>
</div>
<script src="{{.Prefix}}/assets/term.js"></script>
<script>
(function(){
@@ -49,21 +73,72 @@ main>div{height:100%}
var P='{{.Prefix}}';
var mount=document.getElementById('termMount');
var status=document.getElementById('status');
var othersEl=document.getElementById('others');
var ask=document.getElementById('ask');
var askText=document.getElementById('askText');
var session=null;
var pendingDecide=null;
var T=function(zh,en){ return LANG==='zh'?zh:en; };
function setStatus(t){ status.textContent=t; }
// The notice stays visible while other windows hold shells, and offers the
// one action that matters: take their slots back.
function setOthers(n){
if(!othersEl)return;
if(n<=0){ othersEl.hidden=true; othersEl.textContent=''; return; }
othersEl.hidden=false;
othersEl.textContent=T('另有 '+n+' 个终端在运行',' +'+n+' other terminal(s)');
var b=document.createElement('button');
b.type='button';
b.textContent=T('全部关闭','Close them');
b.addEventListener('click',function(){ if(session)session.closeOthers(); });
othersEl.appendChild(b);
}
function start(){
if(session){ try{session.close();}catch(e){} session=null; }
mount.innerHTML='';
setStatus(LANG==='zh'?'连接中…':'connecting…');
ask.hidden=true;
setStatus(T('连接中…','connecting…'));
session=window.vpsmgrOpenTerminal(mount, P+'/terminal', {
msg:{
connecting:T('连接中…','connecting…'),
connected:T('已连接','connected'),
reconnecting:T('正在重新连接','reconnecting'),
ended:T('会话已结束','session ended'),
disconnected:T('已断开,点「重连」再试','disconnected — press Reconnect'),
busy:T('已有其他终端在运行','another terminal is already open')
},
onStatus:setStatus,
onExit:function(code){ setStatus((LANG==='zh'?'会话已结束':'session ended')+(code?(' · exit '+code):'')); },
onClose:function(){ setStatus(LANG==='zh'?'连接已断开':'connection closed'); }
onOthers:setOthers,
onReady:function(){ ask.hidden=true; pendingDecide=null; },
onBusy:function(n,decide){
askText.textContent=T(
'你的账号下还有 '+n+' 个终端在运行(可能是关掉的窗口,或刚断线还在等待恢复的会话)。要关闭它们并在这里继续吗?',
'Your account still has '+n+' other terminal(s) open — a closed window, or a session waiting to be resumed. Close them and continue here?');
ask.hidden=false;
pendingDecide=decide;
},
onExit:function(code){ setStatus(T('会话已结束','session ended')+(code?(' · exit '+code):'')); },
onClose:function(){ setStatus(T('已断开,点「重连」再试','disconnected — press Reconnect')); }
});
}
document.getElementById('reBtn').addEventListener('click',start);
function answerAsk(take){
var d=pendingDecide;
pendingDecide=null;
ask.hidden=true;
if(d)d(take);
}
document.getElementById('askTake').addEventListener('click',function(){ answerAsk(true); });
document.getElementById('askCancel').addEventListener('click',function(){ answerAsk(false); });
// Reconnect does double duty: a fresh start, and the manual path once the
// automatic retries have given up.
document.getElementById('reBtn').addEventListener('click',function(){
if(session&&session.reconnect){ session.reconnect(); return; }
start();
});
document.getElementById('fsBtn').addEventListener('click',function(){
if(document.fullscreenElement){ document.exitFullscreen(); return; }
if(document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
+451 -99
View File
@@ -1,8 +1,11 @@
package panel
import (
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"sync"
@@ -15,16 +18,33 @@ import (
)
const (
// termMaxPerUser caps how many shells one account can hold open. A terminal
// is a live process per connection, so this is a resource guard as much as
// a UX one.
// termMaxPerUser caps how many shells one account may hold, counting the
// ones sitting in their reconnect grace period. A terminal is a live
// process per connection, so this is a resource guard as much as a UX one.
termMaxPerUser = 2
// termIdleTimeout closes a session the browser stopped talking on. Pings
// keep a live one fresh, so this only fires for a closed or backgrounded
// tab — it is what stops abandoned shells from living forever.
termIdleTimeout = 15 * time.Minute
termPingEvery = 30 * time.Second
termWriteWait = 10 * time.Second
// Liveness. A browser answers a WebSocket ping from its network stack,
// without JavaScript, so this works even in a throttled background tab.
// The interval has to sit well under the deadline: the deadline is what
// notices a peer that vanished (a dropped NAT mapping can otherwise stay
// silent for hours), and every message and every pong refreshes it.
termPingEvery = 20 * time.Second
termReadWait = 60 * time.Second
termWriteWait = 10 * time.Second
// termGrace is how long a shell outlives the client that opened it. A
// connection from far away drops for all sorts of reasons — a tunnel, a
// cell handover, a sleeping laptop — and losing a running build or an open
// editor to a three-second blip is the worst part of a web terminal. Two
// minutes covers those without leaving an unattended root shell much longer.
termGrace = 120 * time.Second
// termReplayMax bounds what a returning client is caught up with; anything
// older is dropped and the reader is told.
termReplayMax = 256 << 10
// termHelloWait bounds the first frame, before any shell exists.
termHelloWait = 10 * time.Second
// termMaxFrame caps a single message from the browser (a paste).
termMaxFrame = 1 << 20
)
@@ -38,50 +58,42 @@ var termJS []byte
// termIn is a message from the browser. Terminal output travels the other way
// as raw binary frames; only control messages are JSON.
type termIn struct {
T string `json:"t"` // "i" input, "r" resize
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
T string `json:"t"` // hello | input | resize | takeover | cancel | bye
Token string `json:"token,omitempty"`
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
func (t termIn) size() lx.TermSize { return lx.TermSize{Cols: t.Cols, Rows: t.Rows} }
// termOut is a control message to the browser.
type termOut struct {
T string `json:"t"` // "exit" | "error"
Code int `json:"code,omitempty"`
Error string `json:"error,omitempty"`
T string `json:"t"` // ready | others | busy | ping | exit | error
Token string `json:"token,omitempty"`
Code int `json:"code,omitempty"`
Others int `json:"others,omitempty"`
Error string `json:"error,omitempty"`
}
// termRegistry counts the open shells per user. The panel is a single process,
// so an in-memory count is the whole story.
type termRegistry struct {
mu sync.Mutex
open map[string]int
// termPTY is the part of lx.Pty a session uses. It is an interface so the
// session's own logic — the grace period, the replay buffer, resuming — can be
// exercised without a container behind it.
type termPTY interface {
Output() <-chan []byte
Write([]byte) error
Resize(lx.TermSize) error
ExitCode() (int, error)
Close()
}
func newTermRegistry() *termRegistry { return &termRegistry{open: map[string]int{}} }
func (t *termRegistry) acquire(user string) bool {
t.mu.Lock()
defer t.mu.Unlock()
if t.open[user] >= termMaxPerUser {
return false
}
t.open[user]++
return true
// termSink is where a session's output goes while a client is attached.
type termSink interface {
write(messageType int, data []byte) error
}
func (t *termRegistry) release(user string) {
t.mu.Lock()
defer t.mu.Unlock()
if t.open[user] <= 1 {
delete(t.open, user)
return
}
t.open[user]--
}
// termSocket serialises writes: gorilla allows one writer at a time, and both
// the output pump and the keepalive pinger write.
// termSocket serialises writes: gorilla allows one writer at a time, and the
// output pump, the keepalive pinger and the control replies all write.
type termSocket struct {
mu sync.Mutex
conn *websocket.Conn
@@ -94,6 +106,265 @@ func (t *termSocket) write(messageType int, data []byte) error {
return t.conn.WriteMessage(messageType, data)
}
// termSession is one shell. It outlives the WebSocket that opened it: when the
// client goes away the PTY keeps running for termGrace with its output landing
// in a bounded buffer, so a client that comes back resumes the same shell
// rather than starting a new one.
type termSession struct {
token string
user string
pty termPTY
reg *termRegistry
mu sync.Mutex
sink termSink // nil while detached
ring []byte
ringOver bool
expire *time.Timer
closed bool
}
// attach hands the session to a client and catches it up on what the shell
// produced while nobody was listening.
func (s *termSession) attach(sink termSink, size lx.TermSize) {
s.mu.Lock()
if s.expire != nil {
s.expire.Stop()
s.expire = nil
}
s.sink = sink
replay, dropped := s.ring, s.ringOver
s.ring, s.ringOver = nil, false
s.mu.Unlock()
if dropped {
_ = sink.write(websocket.BinaryMessage,
[]byte("\r\n\x1b[33m[ output was dropped while you were disconnected ]\x1b[0m\r\n"))
}
if len(replay) > 0 {
_ = sink.write(websocket.BinaryMessage, replay)
}
// A returning terminal is blank, so anything painting a full screen has to
// be told to paint again. Nudging the size by one row is what makes the
// kernel raise SIGWINCH; the second message restores it.
_ = s.pty.Resize(lx.TermSize{Cols: size.Cols, Rows: size.Rows - 1})
_ = s.pty.Resize(size)
}
// detach takes the client away and starts the clock on the shell's grace
// period. It is idempotent: the reader loop and a failed write both land here.
func (s *termSession) detach() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || s.sink == nil {
return
}
s.sink = nil
if s.expire == nil {
s.expire = time.AfterFunc(termGrace, s.close)
}
}
// run starts the output pump. It belongs to the session, not to a connection,
// which is the whole point: it is still there once the client is gone.
func (s *termSession) run() {
go func() {
for chunk := range s.pty.Output() {
s.mu.Lock()
sink := s.sink
if sink == nil {
s.bufferLocked(chunk)
s.mu.Unlock()
continue
}
s.mu.Unlock()
// Written outside the lock so a slow client cannot block a reattach.
if err := sink.write(websocket.BinaryMessage, chunk); err != nil {
// The client never received this chunk, so keep it before
// falling back to buffering: otherwise a client that drops
// mid-write loses the last thing the shell printed.
s.mu.Lock()
s.bufferLocked(chunk)
s.mu.Unlock()
s.detach()
}
}
s.finish()
}()
}
func (s *termSession) bufferLocked(chunk []byte) {
s.ring = append(s.ring, chunk...)
if len(s.ring) > termReplayMax {
s.ring = s.ring[len(s.ring)-termReplayMax:]
s.ringOver = true
}
}
// close ends the shell now, whatever the grace timer was waiting for.
func (s *termSession) close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
if s.expire != nil {
s.expire.Stop()
s.expire = nil
}
s.mu.Unlock()
// Unregister immediately: until the pump drains, a resume must not be able
// to attach to a shell that is already on its way out.
s.reg.remove(s)
s.pty.Close() // the pump drains and then calls finish
}
// finish runs once the shell is gone: tell whoever is attached, then let go.
func (s *termSession) finish() {
s.mu.Lock()
s.closed = true
sink := s.sink
s.sink = nil
if s.expire != nil {
s.expire.Stop()
s.expire = nil
}
s.mu.Unlock()
if sink != nil {
code, _ := s.pty.ExitCode()
_ = sink.write(websocket.TextMessage, mustJSON(termOut{T: "exit", Code: code}))
}
s.reg.remove(s)
}
// termRegistry owns every live shell. Sessions are keyed by a token handed to
// the client, which is what lets a reconnect claim back its own shell.
type termRegistry struct {
mu sync.Mutex
sessions map[string]*termSession
byUser map[string]map[string]bool
}
func newTermRegistry() *termRegistry {
return &termRegistry{sessions: map[string]*termSession{}, byUser: map[string]map[string]bool{}}
}
var errTermLimit = errors.New("too many open terminals")
// create registers a new shell. It refuses once the user is at the cap; the
// caller decides whether to offer a takeover.
func (r *termRegistry) create(user string, pty termPTY) (*termSession, error) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.byUser[user]) >= termMaxPerUser {
return nil, errTermLimit
}
token, err := newTermToken()
if err != nil {
return nil, err
}
s := &termSession{token: token, user: user, pty: pty, reg: r}
r.sessions[token] = s
if r.byUser[user] == nil {
r.byUser[user] = map[string]bool{}
}
r.byUser[user][token] = true
return s, nil
}
// lookup finds a session by token. The owner check is not decoration: a token
// is only ever handed to one user, and someone else's token must never resume
// their shell.
func (r *termRegistry) lookup(token, user string) (*termSession, bool) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.sessions[token]
if !ok || s.user != user {
return nil, false
}
s.mu.Lock()
closing := s.closed
s.mu.Unlock()
if closing {
return nil, false
}
return s, true
}
// others counts the user's sessions other than except.
func (r *termRegistry) others(user, except string) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for token := range r.byUser[user] {
if token != except {
n++
}
}
return n
}
func (r *termRegistry) atCap(user string) bool {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.byUser[user]) >= termMaxPerUser
}
// killOthers ends the user's other shells — how a window takes back the slots
// held by windows the user can no longer see.
func (r *termRegistry) killOthers(user, except string) int {
r.mu.Lock()
var doomed []*termSession
for token := range r.byUser[user] {
if token != except {
if s, ok := r.sessions[token]; ok {
doomed = append(doomed, s)
}
}
}
r.mu.Unlock()
for _, s := range doomed {
s.close()
}
return len(doomed)
}
func (r *termRegistry) remove(s *termSession) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.sessions, s.token)
if set := r.byUser[s.user]; set != nil {
delete(set, s.token)
if len(set) == 0 {
delete(r.byUser, s.user)
}
}
}
// attached returns the user's live sessions, used to push a fresh "others"
// count to each of them.
func (r *termRegistry) attached(user string) []*termSession {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*termSession, 0, len(r.byUser[user]))
for token := range r.byUser[user] {
if s, ok := r.sessions[token]; ok {
out = append(out, s)
}
}
return out
}
func newTermToken() (string, error) {
buf := make([]byte, 18)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
var termUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
@@ -109,6 +380,10 @@ var termUpgrader = websocket.Upgrader{
// to any size, moved to another screen or maximised. The page remembers the
// size it was last resized to, so the next open comes back the same way.
func (s *Server) handleWebSSH(w http.ResponseWriter, r *http.Request) {
if !s.cfg.Panel.WebSSH {
featureless404(w)
return
}
u := s.currentUser(r)
if u == nil {
http.Redirect(w, r, s.p("/login"), http.StatusFound)
@@ -132,6 +407,13 @@ func (s *Server) handleWebSSH(w http.ResponseWriter, r *http.Request) {
// is the one place in the panel where the POST CSRF check does not apply — the
// same-origin check below is what stands in for it.
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
// The operator switch is a real gate, not just a hidden button: with the
// terminal disabled nothing here may start or interact with a shell, even
// for someone replaying the endpoint by hand.
if !s.cfg.Panel.WebSSH {
featureless404(w)
return
}
u := s.currentUser(r)
if u == nil {
w.WriteHeader(http.StatusUnauthorized)
@@ -155,55 +437,86 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
}
defer conn.Close()
sock := &termSocket{conn: conn}
conn.SetReadLimit(termMaxFrame)
if !s.terms.acquire(u.Name) {
sock.write(websocket.TextMessage, mustJSON(termOut{T: "error",
Error: "too many open terminals; close one and try again"}))
// The size arrives in the first frame, before anything is created, so a
// stray connection cannot spawn a shell by accident.
_ = conn.SetReadDeadline(time.Now().Add(termHelloWait))
hello, err := readTermMessage(conn)
if err != nil {
return
}
defer s.terms.release(u.Name)
size := hello.size()
if size.Cols == 0 || size.Rows == 0 {
size = lx.TermSize{Cols: 80, Rows: 24}
}
// A returning client owns its shell: resuming is not a new session, so the
// per-user cap must not turn it away.
if hello.Token != "" {
if sess, ok := s.terms.lookup(hello.Token, u.Name); ok {
sess.attach(sock, size)
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.resume")
// The resuming client still has to be told it is connected: without
// this it never learns the attempt succeeded, keeps the backoff
// counter where it was, and shows "reconnecting" over a live shell.
_ = sock.write(websocket.TextMessage, mustJSON(termOut{
T: "ready", Token: sess.token, Others: s.terms.others(u.Name, sess.token),
}))
s.notifyOthers(u.Name)
s.serveTermClient(conn, sock, sess, u.Name)
return
}
}
// At the cap the client is offered its slots back rather than being left
// with a ghost it cannot see: a closed tab, or one still in its grace
// period.
if s.terms.atCap(u.Name) {
_ = sock.write(websocket.TextMessage, mustJSON(termOut{
T: "busy", Others: s.terms.others(u.Name, ""),
}))
reply, err := readTermMessage(conn)
if err != nil || reply.T != "takeover" {
return
}
if n := s.terms.killOthers(u.Name, ""); n > 0 {
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.takeover")
}
}
size := termSizeFrom(r)
pty, err := s.mgr.AttachTerminal(u.Name, size)
if err != nil {
sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", Error: err.Error()}))
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", Error: err.Error()}))
return
}
sess, err := s.terms.create(u.Name, pty)
if err != nil {
pty.Close()
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", Error: err.Error()}))
return
}
defer pty.Close()
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.open")
sess.run()
sess.attach(sock, size)
_ = sock.write(websocket.TextMessage, mustJSON(termOut{
T: "ready", Token: sess.token, Others: s.terms.others(u.Name, sess.token),
}))
s.notifyOthers(u.Name)
s.serveTermClient(conn, sock, sess, u.Name)
}
conn.SetReadLimit(termMaxFrame)
_ = conn.SetReadDeadline(time.Now().Add(termIdleTimeout))
// serveTermClient runs the connection until the client goes away, then leaves
// the shell in its grace period instead of killing it.
func (s *Server) serveTermClient(conn *websocket.Conn, sock *termSocket, sess *termSession, user string) {
defer sess.detach()
defer s.notifyOthers(user)
_ = conn.SetReadDeadline(time.Now().Add(termReadWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(termIdleTimeout))
return conn.SetReadDeadline(time.Now().Add(termReadWait))
})
clientGone := make(chan struct{})
go func() {
defer close(clientGone)
for {
_, msg, err := conn.ReadMessage()
if err != nil {
return
}
_ = conn.SetReadDeadline(time.Now().Add(termIdleTimeout))
var in termIn
if json.Unmarshal(msg, &in) != nil {
continue
}
switch in.T {
case "i":
if err := pty.Write([]byte(in.Data)); err != nil {
return
}
case "r":
_ = pty.Resize(lx.TermSize{Cols: in.Cols, Rows: in.Rows})
}
}
}()
// Keepalive. A browser answers a ping automatically, which is what refreshes
// the read deadline above.
stopPing := make(chan struct{})
defer close(stopPing)
go func() {
@@ -213,40 +526,75 @@ func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
select {
case <-stopPing:
return
case <-clientGone:
return
case <-t.C:
if err := sock.write(websocket.PingMessage, nil); err != nil {
return
}
// A browser cannot see protocol-level pings, so the page needs
// its own heartbeat to notice a server that went quiet.
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "ping"}))
}
}
}()
// Output pump. It runs until the PTY's output channel closes, which happens
// only after every reader has stopped — so the last bytes the shell wrote
// are always delivered, even though the session is already over.
outDone := make(chan struct{})
go func() {
defer close(outDone)
for chunk := range pty.Output() {
if err := sock.write(websocket.BinaryMessage, chunk); err != nil {
for {
_ = conn.SetReadDeadline(time.Now().Add(termReadWait))
in, err := readTermMessage(conn)
if err != nil {
return
}
switch in.T {
case "input":
if err := sess.pty.Write([]byte(in.Data)); err != nil {
return
}
case "resize":
_ = sess.pty.Resize(in.size())
case "takeover":
// Asked for at any time, not only when the cap is hit: it is how a
// window takes back the slots held by sessions the user cannot see.
if n := s.terms.killOthers(user, sess.token); n > 0 {
_ = s.db.AddAuditLog("000+"+user, "terminal.takeover")
}
s.notifyOthers(user)
case "bye":
// The window is going away for good: end the shell now rather than
// holding it for the grace period.
sess.close()
return
}
}()
select {
case <-clientGone:
case <-outDone:
code, _ := pty.ExitCode()
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "exit", Code: code}))
// Give the browser a moment to read the final frame before the socket
// is torn down under it.
time.Sleep(150 * time.Millisecond)
}
}
// notifyOthers pushes the current count of *other* shells to each of the user's
// attached windows, so the notice line stays true as windows come and go.
func (s *Server) notifyOthers(user string) {
for _, sess := range s.terms.attached(user) {
sess.mu.Lock()
sink := sess.sink
sess.mu.Unlock()
if sink == nil {
continue
}
_ = sink.write(websocket.TextMessage, mustJSON(termOut{
T: "others", Others: s.terms.others(user, sess.token),
}))
}
}
// readTermMessage reads one control frame.
func readTermMessage(conn *websocket.Conn) (termIn, error) {
_, msg, err := conn.ReadMessage()
if err != nil {
return termIn{}, err
}
var in termIn
if err := json.Unmarshal(msg, &in); err != nil {
return termIn{}, err
}
return in, nil
}
// termSizeFrom reads the size the browser measured. Values are clamped by the
// lx layer, so anything nonsensical degrades to 80x24.
func termSizeFrom(r *http.Request) lx.TermSize {
@@ -271,6 +619,10 @@ const termAssetPath = "/assets/term.js"
// binary like the templates, and it is served from the panel prefix so it is
// same-origin (the panel's CSP allows no third-party scripts).
func (s *Server) handleTermAsset(w http.ResponseWriter, r *http.Request) {
if !s.cfg.Panel.WebSSH {
featureless404(w)
return
}
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
_, _ = w.Write(termJS)
}
+310
View File
@@ -0,0 +1,310 @@
package panel
import (
"bytes"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"vpsmgr/internal/lx"
)
// fakePTY stands in for a container's shell so the session logic — the grace
// period, the replay buffer, resuming — is testable without an Incus daemon.
type fakePTY struct {
out chan []byte
mu sync.Mutex
closed bool
writes bytes.Buffer
resize []lx.TermSize
}
func newFakePTY() *fakePTY { return &fakePTY{out: make(chan []byte, 64)} }
func (f *fakePTY) Output() <-chan []byte { return f.out }
func (f *fakePTY) Write(b []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
f.writes.Write(b)
return nil
}
func (f *fakePTY) Resize(s lx.TermSize) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resize = append(f.resize, s)
return nil
}
func (f *fakePTY) ExitCode() (int, error) { return 0, nil }
func (f *fakePTY) Close() {
f.mu.Lock()
defer f.mu.Unlock()
if f.closed {
return
}
f.closed = true
close(f.out)
}
func (f *fakePTY) sizes() []lx.TermSize {
f.mu.Lock()
defer f.mu.Unlock()
return append([]lx.TermSize(nil), f.resize...)
}
// fakeSink records what a client was sent.
type fakeSink struct {
mu sync.Mutex
binary bytes.Buffer
ctl []termOut
fail bool
}
var errSinkWedged = errors.New("sink is wedged")
func (s *fakeSink) write(messageType int, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.fail {
return errSinkWedged
}
if messageType == websocket.BinaryMessage {
s.binary.Write(data)
return nil
}
var out termOut
if err := json.Unmarshal(data, &out); err == nil {
s.ctl = append(s.ctl, out)
}
return nil
}
func (s *fakeSink) text() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.binary.String()
}
// waitFor polls until cond is true or the budget runs out.
func waitFor(t *testing.T, cond func() bool) bool {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(2 * time.Millisecond)
}
return false
}
func TestTermRegistryCapAndOwnership(t *testing.T) {
reg := newTermRegistry()
a, err := reg.create("alice", newFakePTY())
if err != nil {
t.Fatal(err)
}
if reg.atCap("alice") {
t.Error("one session must not be the cap")
}
b, err := reg.create("alice", newFakePTY())
if err != nil {
t.Fatal(err)
}
if !reg.atCap("alice") {
t.Error("two sessions should be the cap")
}
if _, err := reg.create("alice", newFakePTY()); err != errTermLimit {
t.Errorf("third create = %v, want errTermLimit", err)
}
if _, err := reg.create("bob", newFakePTY()); err != nil {
t.Errorf("another account must be unaffected: %v", err)
}
if a.token == b.token {
t.Error("tokens must be unique")
}
// A token is good for its own user and its own shell only.
if got, ok := reg.lookup(a.token, "alice"); !ok || got != a {
t.Error("lookup of one's own token failed")
}
if _, ok := reg.lookup(a.token, "bob"); ok {
t.Error("another user's token must not resume a shell")
}
if _, ok := reg.lookup("nope", "alice"); ok {
t.Error("an unknown token must not resolve")
}
}
func TestTermRegistryTakeoverAndRemove(t *testing.T) {
reg := newTermRegistry()
a, _ := reg.create("alice", newFakePTY())
b, _ := reg.create("alice", newFakePTY())
if n := reg.others("alice", a.token); n != 1 {
t.Errorf("others = %d, want 1", n)
}
if n := reg.killOthers("alice", a.token); n != 1 {
t.Errorf("killOthers = %d, want 1", n)
}
// A killed session stops being resumable straight away, even though its
// pump may still be draining.
if _, ok := reg.lookup(b.token, "alice"); ok && b.closed {
t.Error("a closed session must not be resumable")
}
reg.remove(b)
if n := reg.others("alice", a.token); n != 0 {
t.Errorf("others after removal = %d, want 0", n)
}
if _, ok := reg.lookup(b.token, "alice"); ok {
t.Error("a removed session must not be resumable")
}
}
// While nobody is attached the shell keeps producing output; a client that
// comes back has to receive it, or a resumed session would look frozen.
func TestTermSessionBuffersWhileDetachedAndReplays(t *testing.T) {
reg := newTermRegistry()
pty := newFakePTY()
sess, err := reg.create("alice", pty)
if err != nil {
t.Fatal(err)
}
sess.run()
// Detached from the start, so everything lands in the replay buffer.
pty.out <- []byte("before-1 ")
pty.out <- []byte("before-2 ")
sink := &fakeSink{}
sess.attach(sink, lx.TermSize{Cols: 80, Rows: 24})
if !waitFor(t, func() bool { return strings.Contains(sink.text(), "before-2 ") }) {
t.Fatalf("buffered output was not replayed: %q", sink.text())
}
// Live output now goes straight through.
pty.out <- []byte("live")
if !waitFor(t, func() bool { return strings.Contains(sink.text(), "live") }) {
t.Fatalf("live output never arrived: %q", sink.text())
}
if got := sink.text(); !strings.Contains(got, "before-1 before-2 live") {
t.Errorf("resumed stream = %q, want the buffered output then the live one", got)
}
// The redraw nudge: one row short, then the real size.
sizes := pty.sizes()
if len(sizes) < 2 || sizes[len(sizes)-1] != (lx.TermSize{Cols: 80, Rows: 24}) {
t.Errorf("expected a SIGWINCH nudge ending at the real size, got %v", sizes)
}
}
func TestTermSessionDetachStartsGrace(t *testing.T) {
reg := newTermRegistry()
sess, err := reg.create("alice", newFakePTY())
if err != nil {
t.Fatal(err)
}
sess.attach(&fakeSink{}, lx.TermSize{Cols: 80, Rows: 24})
sess.detach()
sess.mu.Lock()
armed, attached := sess.expire != nil, sess.sink != nil
sess.mu.Unlock()
if !armed {
t.Error("detaching should arm the grace period")
}
if attached {
t.Error("detaching should clear the sink")
}
sess.detach() // idempotent
// A reattach inside the window keeps the shell and disarms the timer.
sess.attach(&fakeSink{}, lx.TermSize{Cols: 100, Rows: 30})
sess.mu.Lock()
armedAfter := sess.expire != nil
sess.mu.Unlock()
if armedAfter {
t.Error("reattaching should disarm the grace period")
}
if _, ok := reg.lookup(sess.token, "alice"); !ok {
t.Error("the resumed session must still be registered")
}
}
func TestTermSessionReplayIsBounded(t *testing.T) {
chunk := bytes.Repeat([]byte("x"), 64<<10)
sess := &termSession{pty: newFakePTY()}
for i := 0; i < 8; i++ {
sess.mu.Lock()
sess.bufferLocked(chunk)
sess.mu.Unlock()
}
sess.mu.Lock()
size, over := len(sess.ring), sess.ringOver
sess.mu.Unlock()
if size > termReplayMax {
t.Errorf("replay buffer = %d bytes, want <= %d", size, termReplayMax)
}
if !over {
t.Error("dropping output must set the truncation notice")
}
sink := &fakeSink{}
sess.attach(sink, lx.TermSize{Cols: 80, Rows: 24})
if !strings.Contains(sink.text(), "dropped") {
t.Error("the client must be told that output was dropped")
}
}
// A write that fails means the client is wedged; the session has to fall back
// to buffering rather than losing the shell.
func TestTermSessionFallsBackToBufferingOnWriteFailure(t *testing.T) {
pty := newFakePTY()
sess := &termSession{pty: pty}
sess.run()
sess.attach(&fakeSink{fail: true}, lx.TermSize{Cols: 80, Rows: 24})
pty.out <- []byte("while-wedged")
if !waitFor(t, func() bool {
sess.mu.Lock()
defer sess.mu.Unlock()
return sess.sink == nil
}) {
t.Fatal("a failing sink should have detached the session")
}
sess.mu.Lock()
buffered := string(sess.ring)
sess.mu.Unlock()
if !strings.Contains(buffered, "while-wedged") {
t.Errorf("output was lost when the client stalled: %q", buffered)
}
}
// Closing the shell must remove it, so a slot is never leaked.
func TestTermSessionCloseReleasesTheSlot(t *testing.T) {
reg := newTermRegistry()
pty := newFakePTY()
sess, err := reg.create("alice", pty)
if err != nil {
t.Fatal(err)
}
sess.run()
sess.attach(&fakeSink{}, lx.TermSize{Cols: 80, Rows: 24})
sess.close()
if !waitFor(t, func() bool { return !reg.atCap("alice") && reg.others("alice", "") == 0 }) {
t.Error("closing a session must release its slot")
}
if _, ok := reg.lookup(sess.token, "alice"); ok {
t.Error("a closed session must not be resumable")
}
}
-81
View File
@@ -109,84 +109,3 @@ func TestTerminalSizeFromQuery(t *testing.T) {
t.Errorf("size = %+v, want zeroes for the lx layer to normalise", got)
}
}
func TestTermRegistryLimits(t *testing.T) {
reg := newTermRegistry()
for i := 0; i < termMaxPerUser; i++ {
if !reg.acquire("alice") {
t.Fatalf("acquire %d refused below the cap", i+1)
}
}
if reg.acquire("alice") {
t.Error("acquire above the cap succeeded")
}
if !reg.acquire("bob") {
t.Error("one user's sessions must not block another's")
}
for i := 0; i < termMaxPerUser; i++ {
reg.release("alice")
}
if !reg.acquire("alice") {
t.Error("released slots were not reclaimed")
}
// Release must not go negative.
reg.release("alice")
reg.release("alice")
reg.release("alice")
if !reg.acquire("alice") {
t.Error("registry went negative")
}
}
// The shell lives in its own window, so the panel serves a page for it rather
// than opening a modal.
func TestWebSSHPage(t *testing.T) {
srv, d := newTestServer(t)
cookie := sessionCookie(t, d, "alice")
h := srv.Handler()
rr := doReq(t, h, http.MethodGet, srv.p("/webssh"), nil, cookie)
if rr.Code != http.StatusOK {
t.Fatalf("GET /webssh = %d, want 200", rr.Code)
}
body := rr.Body.String()
for _, want := range []string{
`id="termMount"`, `id="fsBtn"`, `id="reBtn"`,
srv.p(termAssetPath), `'/terminal'`,
} {
if !strings.Contains(body, want) {
t.Errorf("webssh page missing %q", want)
}
}
// It must never carry the panel's own chrome (it is a bare terminal window).
if strings.Contains(body, `id="kbModal"`) || strings.Contains(body, "Sticky notes") {
t.Error("webssh page should be standalone, not the overview")
}
// Without a session it is the ordinary login redirect.
rr = doReq(t, h, http.MethodGet, srv.p("/webssh"), nil, nil)
if rr.Code != http.StatusFound {
t.Errorf("GET /webssh without a session = %d, want the login redirect", rr.Code)
}
}
// The button belongs next to Start/Stop/Restart, and must read as a shell.
func TestOverviewWebSSHButton(t *testing.T) {
srv, _ := newTestServer(t)
body := srv.renderToString(t, "overview.html", pageData{
User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: langEn,
})
i := strings.Index(body, `id="sshBtn"`)
if i < 0 {
t.Fatal("overview is missing the Web SSH button")
}
if !strings.Contains(body[:i], `value="restart"`) {
t.Error("the Web SSH button should sit right after Start/Stop/Restart")
}
if !strings.Contains(body[i-200:i+200], `class="btn ssh"`) {
t.Error("the Web SSH button should carry the ssh style")
}
if strings.Contains(body, `id="termModal"`) {
t.Error("the terminal must no longer be a modal")
}
}