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:
1 parent
4a17a95d85
commit
5e28fc2983
15 files changed
+1163
-245
No files matched your search
@@ -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.
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
@@ -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}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
};
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
|
||||
@@ -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#*#*",
|
||||
}},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user