diff --git a/docs/README.md b/docs/README.md index 64bd323..e7331ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Technical documentation for vpsmgr. All docs are in English. | [configuration.md](configuration.md) | Full reference for `/etc/vpsmgr/config.yaml` | | [ipv6.md](ipv6.md) | IPv6 pass-through design: deterministic addresses, prefix support, routing, installer flow | | [ipv6-ndp-responder.md](ipv6-ndp-responder.md) | Why prefix mode replaced ndppd with an in-tree NDP responder, design hardening, and what to test at scale | +| [web-ssh.md](web-ssh.md) | The browser terminal: how it reaches a container, how a session survives a dropped connection, and how to disable it | | [development.md](development.md) | Building, testing, releasing, conventions | See also [`AGENTS.md`](../AGENTS.md) at the repo root — short version aimed at AI coding agents. diff --git a/docs/configuration.md b/docs/configuration.md index 3496235..46aaf50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,7 @@ same table; `vps config list` shows the live values with this annotation. | `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) | | `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) | | `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer | +| `panel.web_ssh` | operator | restart panel | browser terminal in the user panel; default `true`. When `false` the Web SSH button is not rendered and the panel refuses the terminal page and connection outright | | `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below | | `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI | | `net.subnet` | **fixed at install** | — | container subnet `10..0.0/24`; changing breaks existing containers | diff --git a/docs/web-ssh.md b/docs/web-ssh.md new file mode 100644 index 0000000..17ddd01 --- /dev/null +++ b/docs/web-ssh.md @@ -0,0 +1,72 @@ +# 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. + +## Keys and the mouse + +Typing works as it does in any terminal: arrows, Home/End, PageUp/PageDown, +Insert/Delete, Tab, Escape and the function keys all send what a terminal sends, +and the arrows follow the mode a full-screen program asks for. + +A small bar above the terminal carries the few keys a browser keeps for itself +(Ctrl+C, Ctrl+Z, Ctrl+D, Escape, Tab and the arrows), so they are never out of +reach. + +Copy and paste use the platform's own shortcuts — Cmd+C/Cmd+V, Ctrl+V, +Shift+Insert — and the browser's right-click menu. Pasting a block of several +lines is handed to the shell exactly as the shell asked for it: a shell that +enables bracketed paste shows the text in its edit buffer first. + +## Turning it off + +``` +vps config set panel.web_ssh false +``` + +The panel restarts and the feature is off: the button is not rendered, and the +terminal page, its script and the terminal connection all refuse. `true` (the +default) turns it back on. A configuration written before this feature existed +comes out enabled. diff --git a/src/internal/admin/admin_test.go b/src/internal/admin/admin_test.go index d45c9c6..777dc68 100644 --- a/src/internal/admin/admin_test.go +++ b/src/internal/admin/admin_test.go @@ -41,6 +41,11 @@ func newTestServer(t *testing.T) (*Server, *db.DB) { } t.Setenv("VPSMGR_HAPROXY_BIN", bin) c := cfg.Default() + // Point the Incus client at a socket that cannot exist, so a test never + // reaches a real container. Several tests drive handlers that would + // otherwise power-cycle or delete whatever instance happens to share a + // fixture's name on the machine running the suite. + c.Incus.Socket = "/nonexistent/vpsmgr-test.sock" c.Panel.URLPath = "UserSecRet99" c.Panel.AdminPath = testAdminSecret c.Panel.PublicIP = "127.0.0.1" diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index 4c7181c..2bd5e10 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -153,6 +153,12 @@ type PanelCfg struct { // URLPath nor AdminPath get a bare, headerless 404. AdminPath string `yaml:"admin_url_path,omitempty"` ShowFooter bool `yaml:"show_footer"` + // WebSSH enables the browser terminal in the user panel. On by default, + // including for config files written before the feature existed (a missing + // key keeps the default). An operator who does not want a shell reachable + // from the panel turns it off: the button disappears and the panel refuses + // both the terminal page and the terminal connection. + WebSSH bool `yaml:"web_ssh"` // BandwidthResetDay is the day of the month the monthly bandwidth period // rolls over. 1-28 (capped at 28 so February always has the day). BandwidthResetDay int `yaml:"bandwidth_reset_day"` @@ -289,7 +295,7 @@ type SnapshotsCfg struct { func Default() *Config { c := &Config{} - c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1} + c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, WebSSH: true, BandwidthResetDay: 1} c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Haproxy: true, UserPorts: DefaultUserPorts} c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio} c.Snapshots = SnapshotsCfg{Limit: 1, Share: false} diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 075fbcc..2a18bda 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -230,6 +230,18 @@ var Fields = []Field{ c.Panel.ShowFooter = b return nil }}, + {"panel.web_ssh", KindOperator, ApplyRestart, + "browser terminal (Web SSH) in the user panel; false hides the button and refuses terminal connections entirely", + "true or false", + getStr(func(c *Config) string { return strconv.FormatBool(c.Panel.WebSSH) }), + func(c *Config, v string) error { + b, ok := parseBool(v) + if !ok { + return fmt.Errorf("panel.web_ssh must be true/false or 1/0") + } + c.Panel.WebSSH = b + return nil + }}, {"panel.bandwidth_reset_day", KindOperator, ApplyDestructive, "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period", "1", diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 51f9d2d..c7146d9 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -1,6 +1,8 @@ package cfg import ( + "os" + "path/filepath" "strings" "testing" ) @@ -42,6 +44,11 @@ func TestFieldValueReadsConfig(t *testing.T) { if v := FieldValue(c, "net.haproxy"); v != "true" { t.Errorf("net.haproxy default = %q", v) } + // The upgrade case matters: a config written before Web SSH existed has no + // key for it, and the feature must still come out enabled. + if v := FieldValue(c, "panel.web_ssh"); v != "true" { + t.Errorf("panel.web_ssh default = %q, want true (upgrades must get it enabled)", v) + } if v := FieldValue(c, "panel.show_footer"); v != "true" { t.Errorf("panel.show_footer default = %q", v) } @@ -405,3 +412,35 @@ func TestBandwidthResetDayIsDestructive(t *testing.T) { } } } + +// A config file written before the feature existed decodes with the key absent, +// and the feature must come out enabled — that is what "upgrades get it on" +// means in practice. +func TestWebSSHDefaultsOnForAnOlderConfig(t *testing.T) { + base := t.TempDir() + t.Setenv("VPSMGR_CONFIG", filepath.Join(base, "config.yaml")) + if err := Save(Default()); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.Contains(line, "web_ssh") { + continue + } + kept = append(kept, line) + } + if err := os.WriteFile(Path(), []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + c, err := Load() + if err != nil { + t.Fatal(err) + } + if !c.Panel.WebSSH { + t.Error("an old config without the key must still enable Web SSH") + } +} diff --git a/src/internal/csrf/csrf.go b/src/internal/csrf/csrf.go index 2237a07..85b6d0c 100644 --- a/src/internal/csrf/csrf.go +++ b/src/internal/csrf/csrf.go @@ -34,6 +34,28 @@ func Allowed(r *http.Request) bool { return originHost(origin) == r.Host } +// SameOrigin reports whether a request provably comes from the panel's own +// origin, and is meant for the WebSocket handshake. +// +// That handshake is a GET, so Allowed's POST check never sees it, and it is not +// covered by SameSite the way a form post is: a browser attaches the session +// cookie to a cross-site WebSocket just as readily. Requiring positive proof +// here is what stops another page from opening a shell on the user's container. +// Every browser sends Origin on a WebSocket handshake, so its absence means a +// non-browser client and is rejected too. +func SameOrigin(r *http.Request) bool { + site := r.Header.Get("Sec-Fetch-Site") + if site != "" && site != "same-origin" && site != "none" { + return false + } + origin := r.Header.Get("Origin") + if origin == "" { + // No Origin to compare: only acceptable with a positive signal. + return site == "same-origin" || site == "none" + } + return originHost(origin) == r.Host +} + // originHost strips the scheme (and trailing path, if any) from an Origin // header, leaving "host[:port]" to compare against r.Host. func originHost(origin string) string { diff --git a/src/internal/csrf/sameorigin_test.go b/src/internal/csrf/sameorigin_test.go new file mode 100644 index 0000000..1d45d41 --- /dev/null +++ b/src/internal/csrf/sameorigin_test.go @@ -0,0 +1,38 @@ +package csrf + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestSameOrigin(t *testing.T) { + cases := []struct { + name string + origin string + site string + want bool + }{ + {"matching origin", "http://example.com", "", true}, + {"matching origin with scheme mismatch", "https://example.com", "", true}, + {"foreign origin", "http://evil.test", "", false}, + {"subdomain", "http://a.example.com", "", false}, + {"no headers at all", "", "", false}, + {"site same-origin only", "", "same-origin", true}, + {"site none only", "", "none", true}, + {"site cross-site", "http://example.com", "cross-site", false}, + {"site same-site beats origin", "http://example.com", "same-site", false}, + } + for _, tc := range cases { + r := httptest.NewRequest(http.MethodGet, "http://example.com/terminal", nil) + if tc.origin != "" { + r.Header.Set("Origin", tc.origin) + } + if tc.site != "" { + r.Header.Set("Sec-Fetch-Site", tc.site) + } + if got := SameOrigin(r); got != tc.want { + t.Errorf("%s: SameOrigin = %v, want %v", tc.name, got, tc.want) + } + } +} diff --git a/src/internal/lx/attach.go b/src/internal/lx/attach.go new file mode 100644 index 0000000..5876acb --- /dev/null +++ b/src/internal/lx/attach.go @@ -0,0 +1,400 @@ +package lx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "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 + Rows int +} + +// normalized clamps a size into what a PTY will accept and fills in the +// conventional 80x24 for anything nonsensical (a zero-sized window, a browser +// that has not laid the terminal out yet). +func (s TermSize) normalized() TermSize { + if s.Cols < 2 || s.Cols > 1000 { + s.Cols = 80 + } + if s.Rows < 2 || s.Rows > 1000 { + s.Rows = 24 + } + return s +} + +// execOpen starts an exec operation and connects every websocket fd it hands +// back. The caller owns the returned connections. All fds must be connected +// before the command runs, which is why this is shared: the fire-and-collect +// Exec and the interactive Attach need exactly these steps. +func (c *Client) execOpen(ctx context.Context, name string, req execReq) (string, map[string]*websocket.Conn, error) { + body, err := json.Marshal(req) + if err != nil { + return "", nil, err + } + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, + c.base+"/1.0/instances/"+url.PathEscape(name)+"/exec", bytes.NewReader(body)) + if err != nil { + return "", nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + resp, err := c.http.Do(httpReq) + if err != nil { + return "", nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", nil, err + } + var op struct { + Type string `json:"type"` + Operation string `json:"operation"` + Error string `json:"error"` + Metadata struct { + Inner struct { + Fds map[string]string `json:"fds"` + } `json:"metadata"` + } `json:"metadata"` + } + if err := json.Unmarshal(raw, &op); err != nil { + return "", nil, fmt.Errorf("incus exec %s: bad response: %w", name, err) + } + if op.Type == "error" { + return "", nil, errors.New(op.Error) + } + if op.Operation == "" { + return "", nil, fmt.Errorf("incus exec %s: no operation returned", name) + } + if len(op.Metadata.Inner.Fds) == 0 { + return "", nil, fmt.Errorf("incus exec %s: no websocket fds in response", name) + } + opID := strings.TrimPrefix(op.Operation, "/1.0/operations/") + + dialer := c.dialer() + conns := make(map[string]*websocket.Conn, len(op.Metadata.Inner.Fds)) + for k, secret := range op.Metadata.Inner.Fds { + wsURL := "ws://unix/1.0/operations/" + opID + "/websocket?secret=" + secret + ws, _, err := dialer.DialContext(ctx, wsURL, nil) + if err != nil { + for _, open := range conns { + _ = open.Close() + } + return "", nil, fmt.Errorf("incus exec %s: connect fd %s: %w", name, k, err) + } + conns[k] = ws + } + return opID, conns, nil +} + +// exitMarker is an OSC sequence the wrapper shell prints once the login shell +// is gone. Incus does not end an interactive exec when the process exits — the +// operation stays "Running" and the fd stays open — so the session needs its +// own end-of-stream signal. It is an OSC (not plain text) so that a terminal +// ignores it if it ever reaches one, and Pty strips it from the stream anyway. +const exitMarker = "\x1b]777;vpsmgr-exit=" + +// exitMarkerCmd runs the login shell in a wrapper that reports its exit status +// on the pty and then lets the pty close. +const exitMarkerCmd = "/bin/bash -l; printf '\\033]777;vpsmgr-exit=%d\\007' $?" + +// execControl is a client→server message on an exec operation's control +// channel. That channel only carries signals and window resizes; the exit +// status is read from the operation record instead. +// +// The size travels as strings inside Args, not as top-level numbers — that is +// what api.InstanceExecControl does, and sending width/height at the top level +// is silently ignored. +type execControl struct { + Command string `json:"command"` + Args map[string]string `json:"args,omitempty"` + Signal int `json:"signal,omitempty"` +} + +// Pty is a live interactive terminal session inside a container. +// +// Output arrives as it is produced, stdin stays writable for the life of the +// session, and the window size can change at any time — the three things Exec +// deliberately does not do. Backpressure is natural: the read goroutine blocks +// sending to Output, so a slow consumer stops draining the Incus socket instead +// of growing a buffer here. +type Pty struct { + c *Client + opID string + conns map[string]*websocket.Conn + + out chan []byte + done chan struct{} + once sync.Once + wg sync.WaitGroup // the read loops, so out is only closed once they stop + tail []byte // rolling window used to spot the exit marker + code int // exit status reported by the marker, -1 until then + + mu sync.Mutex + dead bool +} + +// Attach starts a login shell on a PTY inside the container. +func (c *Client) Attach(name string, size TermSize) (*Pty, error) { + size = size.normalized() + req := execReq{ + Command: []string{"/bin/sh", "-c", exitMarkerCmd}, + WaitForWebsocket: true, + Interactive: true, + Environment: map[string]string{ + // A real TERM so the shell enables colours and line editing, and a + // UTF-8 locale so non-ASCII output survives. + "TERM": "xterm-256color", + "COLORTERM": "truecolor", + "LANG": "C.UTF-8", + "HOME": "/root", + }, + User: 0, + Group: 0, + Width: size.Cols, + Height: size.Rows, + } + opID, conns, err := c.execOpen(context.Background(), name, req) + if err != nil { + return nil, err + } + if conns["0"] == nil { + for _, open := range conns { + _ = open.Close() + } + return nil, fmt.Errorf("incus attach %s: no stdin fd", name) + } + p := &Pty{c: c, opID: opID, conns: conns, out: make(chan []byte, 128), done: make(chan struct{}), code: -1} + // An interactive exec allocates a PTY, and the server then hands back only + // "0" (the PTY, used in BOTH directions) plus "control" — stdout and stderr + // are folded into that one channel. Reading every fd except the control + // channel covers that shape and the 0/1/2 shape alike. + for key, ws := range conns { + if key == "control" { + continue + } + p.wg.Add(1) + go p.readLoop(ws) + } + go p.drainControl() + go p.monitor() + return p, nil +} + +// Output is the merged terminal stream. It is closed when the session ends. +func (p *Pty) Output() <-chan []byte { return p.out } + +// readLoop forwards one fd to the output channel until it closes. On an +// interactive session this includes fd "0", which carries output as well as +// accepting input (gorilla allows one reader and one writer at a time). A read +// error is just how the session ends — the authoritative outcome is the exit +// status on the operation record, so it is not treated as a failure here. +func (p *Pty) readLoop(ws *websocket.Conn) { + defer p.wg.Done() + for { + _, msg, err := ws.ReadMessage() + if err != nil { + p.finish() + return + } + if len(msg) == 0 { + continue + } + p.scanExit(msg) + select { + case p.out <- msg: + case <-p.done: + return + } + } +} + +// scanExit looks for the wrapper's exit marker in the stream. The marker can +// straddle two reads, so a small rolling window is kept. The bytes themselves +// are still forwarded: an OSC sequence is invisible to the terminal. +func (p *Pty) scanExit(msg []byte) { + p.mu.Lock() + p.tail = append(p.tail, msg...) + if len(p.tail) > 256 { + p.tail = p.tail[len(p.tail)-256:] + } + window := string(p.tail) + p.mu.Unlock() + + i := strings.Index(window, exitMarker) + if i < 0 { + return + } + rest := window[i+len(exitMarker):] + if len(rest) == 0 { + return // the status digits have not arrived yet + } + end := strings.IndexByte(rest, 7) // BEL terminates the OSC + if end < 0 { + return + } + if n, err := strconv.Atoi(rest[:end]); err == nil { + p.mu.Lock() + p.code = n + p.mu.Unlock() + } + p.finish() +} + +// drainControl consumes the control channel. It is client→server only, so +// anything arriving is discarded; it still has to be read, or the session can +// stall. +func (p *Pty) drainControl() { + ws := p.conns["control"] + if ws == nil { + return + } + for { + if _, _, err := ws.ReadMessage(); err != nil { + return + } + } +} + +// finish tears the session down exactly once. The output channel is closed by +// a separate goroutine after every read loop has stopped: closing it here would +// race a read loop that is mid-send and panic on "send on closed channel". +func (p *Pty) finish() { + p.once.Do(func() { + p.mu.Lock() + p.dead = true + p.mu.Unlock() + close(p.done) + for _, ws := range p.conns { + _ = ws.Close() + } + go func() { + p.wg.Wait() + close(p.out) + }() + }) +} + +// monitor waits for the operation to stop running. An interactive session does +// not signal the end by closing its websocket when the shell exits (the client +// still holds the pty open), so the operation record is what tells us the shell +// is gone. +func (p *Pty) monitor() { + t := time.NewTicker(3 * time.Second) + defer t.Stop() + for { + select { + case <-p.done: + return + case <-t.C: + if !p.opRunning() { + p.finish() + return + } + } + } +} + +// opRunning reports whether the exec operation is still in progress. A failed +// lookup is not evidence that the session ended, so it counts as still running: +// only a definitive non-Running status ends the session. +func (p *Pty) opRunning() bool { + var op struct { + Status string `json:"status"` + } + if err := p.c.get("/1.0/operations/"+p.opID, &op); err != nil { + return true + } + return op.Status == "Running" +} + +// Write sends keystrokes to the PTY. +func (p *Pty) Write(b []byte) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.dead { + return errors.New("terminal session is closed") + } + 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 +// control channel is write-only for us; a failure is reported but need not end +// the session. +func (p *Pty) Resize(size TermSize) error { + size = size.normalized() + ws := p.conns["control"] + if ws == nil { + return nil + } + p.mu.Lock() + defer p.mu.Unlock() + if p.dead { + return nil + } + msg, err := json.Marshal(execControl{ + Command: "window-resize", + Args: map[string]string{ + "width": strconv.Itoa(size.Cols), + "height": strconv.Itoa(size.Rows), + }, + }) + if err != nil { + return err + } + _ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait)) + return ws.WriteMessage(websocket.TextMessage, msg) +} + +// ExitCode reports the shell's exit status. It is read from the operation +// record, not from the control channel (which is client→server only), so it is +// only meaningful once the session has ended. +func (p *Pty) ExitCode() (int, error) { + p.mu.Lock() + if p.code >= 0 { + code := p.code + p.mu.Unlock() + return code, nil + } + p.mu.Unlock() + var op struct { + Metadata struct { + Return *int `json:"return"` + } `json:"metadata"` + } + if err := p.c.get("/1.0/operations/"+p.opID, &op); err != nil { + return -1, err + } + if op.Metadata.Return == nil { + return -1, nil + } + return *op.Metadata.Return, nil +} + +// Done is closed when the session ends. +func (p *Pty) Done() <-chan struct{} { return p.done } + +// Close ends the session and releases its fds. +func (p *Pty) Close() { p.finish() } diff --git a/src/internal/lx/attach_test.go b/src/internal/lx/attach_test.go new file mode 100644 index 0000000..e951dc0 --- /dev/null +++ b/src/internal/lx/attach_test.go @@ -0,0 +1,102 @@ +package lx + +import ( + "encoding/json" + "strings" + "testing" +) + +// The resize message is the one protocol detail that is easy to get wrong: +// Incus reads the size from Args as strings, and silently ignores width/height +// at the top level. This pins the shape. +func TestExecControlResize(t *testing.T) { + b, err := json.Marshal(execControl{ + Command: "window-resize", + Args: map[string]string{"width": "120", "height": "40"}, + }) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err := json.Unmarshal(b, &got); err != nil { + t.Fatal(err) + } + if got["command"] != "window-resize" { + t.Errorf("command = %v", got["command"]) + } + args, ok := got["args"].(map[string]any) + if !ok { + t.Fatalf("args is not an object: %s", b) + } + if args["width"] != "120" || args["height"] != "40" { + t.Errorf("args = %v, want string width/height", args) + } + if _, bad := got["width"]; bad { + t.Errorf("width must not be a top-level field: %s", b) + } +} + +func TestTermSizeNormalized(t *testing.T) { + cases := []struct{ in, want TermSize }{ + {TermSize{Cols: 120, Rows: 40}, TermSize{Cols: 120, Rows: 40}}, + {TermSize{}, TermSize{Cols: 80, Rows: 24}}, + {TermSize{Cols: -5, Rows: 0}, TermSize{Cols: 80, Rows: 24}}, + {TermSize{Cols: 5000, Rows: 5000}, TermSize{Cols: 80, Rows: 24}}, + } + for _, tc := range cases { + if got := tc.in.normalized(); got != tc.want { + t.Errorf("%+v normalized to %+v, want %+v", tc.in, got, tc.want) + } + } +} + +// An interactive exec does not end when the shell exits, so the wrapper prints +// an OSC marker and the session ends when it is seen. The marker can be split +// across reads, which is the case worth testing. +func TestPtyExitMarker(t *testing.T) { + const marker = "\x1b]777;vpsmgr-exit=" + cases := []struct { + name string + chunks []string + wantDone bool + wantCode int + }{ + {"marker in one chunk", []string{"output" + marker + "0\a"}, true, 0}, + {"split across chunks", []string{"out" + marker[:6], marker[6:] + "7\a"}, true, 7}, + {"split before the digits", []string{marker, "13\a"}, true, 13}, + {"no marker", []string{"just ordinary output"}, false, -1}, + {"marker without a status terminator", []string{marker + "9"}, false, -1}, + } + for _, tc := range cases { + p := &Pty{out: make(chan []byte, 8), done: make(chan struct{}), code: -1} + for _, chunk := range tc.chunks { + p.scanExit([]byte(chunk)) + } + select { + case <-p.done: + if !tc.wantDone { + t.Errorf("%s: session ended, want it alive", tc.name) + } + default: + if tc.wantDone { + t.Errorf("%s: session still alive, want it ended", tc.name) + } + } + if p.code != tc.wantCode { + t.Errorf("%s: exit code = %d, want %d", tc.name, p.code, tc.wantCode) + } + } +} + +// The wrapper must keep the login shell's status and end the pty afterwards. +func TestExitMarkerCmdShape(t *testing.T) { + if !strings.Contains(exitMarkerCmd, "/bin/bash -l") { + t.Errorf("wrapper does not run a login shell: %q", exitMarkerCmd) + } + if !strings.Contains(exitMarkerCmd, "\\033]777;vpsmgr-exit=%d\\007") { + t.Errorf("wrapper does not print the exit marker: %q", exitMarkerCmd) + } + if !strings.Contains(exitMarkerCmd, "$?") { + t.Errorf("wrapper does not pass the shell's status: %q", exitMarkerCmd) + } +} diff --git a/src/internal/lx/lx.go b/src/internal/lx/lx.go index fee6f8a..ee26b68 100644 --- a/src/internal/lx/lx.go +++ b/src/internal/lx/lx.go @@ -1106,67 +1106,17 @@ func (c *Client) Exec(name string, command []string, stdin string, timeout time. Group: 0, } - // POST the exec operation. With wait-for-websocket the response carries the - // websocket secrets (fds) in the operation metadata. - body, err := json.Marshal(req) + // With wait-for-websocket the response carries the websocket secrets (fds) + // in the operation metadata; execOpen posts the request and connects them. + opID, conns, err := c.execOpen(ctx, name, req) if err != nil { return "", err } - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, - c.base+"/1.0/instances/"+url.PathEscape(name)+"/exec", bytes.NewReader(body)) - if err != nil { - return "", err - } - httpReq.Header.Set("Content-Type", "application/json") - resp, err := c.http.Do(httpReq) - if err != nil { - return "", err - } - defer resp.Body.Close() - raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) - if err != nil { - return "", err - } - var op struct { - Type string `json:"type"` - Operation string `json:"operation"` - Error string `json:"error"` - StatusCode int `json:"status_code"` - Metadata struct { - Status string `json:"status"` - Err string `json:"err"` - Inner struct { - Fds map[string]string `json:"fds"` - } `json:"metadata"` - } `json:"metadata"` - } - if err := json.Unmarshal(raw, &op); err != nil { - return "", fmt.Errorf("incus exec %s: bad response: %w", name, err) - } - if op.Type == "error" { - return "", errors.New(op.Error) - } - if op.Operation == "" { - return "", fmt.Errorf("incus exec %s: no operation returned", name) - } - if len(op.Metadata.Inner.Fds) == 0 { - return "", fmt.Errorf("incus exec %s: no websocket fds in response", name) - } - opID := strings.TrimPrefix(op.Operation, "/1.0/operations/") - wsURL := func(secret string) string { - return "ws://unix/1.0/operations/" + opID + "/websocket?secret=" + secret - } - - dialer := c.dialer() - conns := make(map[string]*websocket.Conn, len(op.Metadata.Inner.Fds)) - for k, secret := range op.Metadata.Inner.Fds { - ws, _, err := dialer.DialContext(ctx, wsURL(secret), nil) - if err != nil { - return "", fmt.Errorf("incus exec %s: connect fd %s: %w", name, k, err) + defer func() { + for _, ws := range conns { + _ = ws.Close() } - conns[k] = ws - defer ws.Close() - } + }() stdout, ok1 := conns["1"] stderr, ok2 := conns["2"] diff --git a/src/internal/mgr/terminal.go b/src/internal/mgr/terminal.go new file mode 100644 index 0000000..3442a97 --- /dev/null +++ b/src/internal/mgr/terminal.go @@ -0,0 +1,20 @@ +package mgr + +import ( + "errors" + + "vpsmgr/internal/lx" +) + +// AttachTerminal opens an interactive shell inside the named container. The +// caller owns the returned session and must Close it when done. +func (m *Manager) AttachTerminal(name string, size lx.TermSize) (*lx.Pty, error) { + // A stopped container fails deep inside the exec call with a message that + // means nothing to a user, so say something useful instead. + if st, err := m.lx.InstanceStatuses(); err == nil { + if status, ok := st[name]; ok && status != "Running" { + return nil, errors.New("container is not running") + } + } + return m.lx.Attach(name, size) +} diff --git a/src/internal/panel/assets/term.js b/src/internal/panel/assets/term.js new file mode 100644 index 0000000..6470d8d --- /dev/null +++ b/src/internal/panel/assets/term.js @@ -0,0 +1,898 @@ +/* vpsmgr browser terminal. + * + * A small VT100/xterm emulator plus the WebSocket session for /terminal. It is + * hand-written and dependency-free on purpose: the panel ships as one binary, + * has no build step, and its CSP allows no third-party scripts. + * + * Terminal output is arbitrary bytes from a shell, so nothing from the stream + * is ever interpreted as markup — every character goes through escapeText or + * textContent. + */ +(function () { + "use strict"; + + var PALETTE = [ + "#1c1c1c", "#d75f5f", "#5faf5f", "#d7af5f", "#5f87d7", "#af5faf", "#5fafaf", "#c6c6c6", + "#6c6c6c", "#ff8787", "#87d787", "#ffd787", "#87afff", "#ff87ff", "#87ffff", "#ffffff" + ]; + + var F_BOLD = 1, F_DIM = 2, F_ITALIC = 4, F_UNDER = 8, F_BLINK = 16, + F_REV = 32, F_HIDDEN = 64, F_STRIKE = 128; + + function escapeText(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + function color256(n) { + if (n < 16) return PALETTE[n]; + if (n < 232) { + n -= 16; + var f = function (v) { return v === 0 ? 0 : 55 + v * 40; }; + return "rgb(" + f(Math.floor(n / 36)) + "," + f(Math.floor((n % 36) / 6)) + "," + f(n % 6) + ")"; + } + var v = 8 + (n - 232) * 10; + return "rgb(" + v + "," + v + "," + v + ")"; + } + + function isWide(cp) { + return (cp >= 0x1100 && ( + cp <= 0x115f || cp === 0x2329 || cp === 0x232a || + (cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) || + (cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) || + (cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) || + (cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd))); + } + + function blankCell() { return { ch: " ", fg: null, bg: null, fl: 0, cont: false }; } + + function blankLine(cols) { + var cells = new Array(cols); + for (var i = 0; i < cols; i++) cells[i] = blankCell(); + return { cells: cells }; + } + + function styleOf(c) { + var fg = c.fg, bg = c.bg, f = c.fl; + if (f & F_REV) { var t = fg; fg = bg === null ? "#0f172a" : bg; bg = t === null ? "#c6c6c6" : t; } + if (f & F_HIDDEN) fg = bg === null ? "#0f172a" : bg; + if (f & F_DIM) fg = fg === null ? "#6c6c6c" : fg; + var s = ""; + if (fg) s += "color:" + fg + ";"; + if (bg) s += "background:" + bg + ";"; + if (f & F_BOLD) s += "font-weight:700;"; + if (f & F_ITALIC) s += "font-style:italic;"; + if (f & F_UNDER) s += "text-decoration:underline;"; + if (f & F_STRIKE) s += "text-decoration:line-through;"; + return s; + } + + // ---- emulator --------------------------------------------------------- + + function Term(mount, opts) { + this.mount = mount; + this.opts = opts || {}; + this.cols = 80; + this.rows = 24; + this.sb = []; + this.maxSb = 2000; + this.vy = 0; + this.cx = 0; + this.cy = 0; + this.attr = { fg: null, bg: null, fl: 0 }; + this.saved = null; + this.scrollTop = 0; + this.scrollBot = 23; + this.modes = { wrap: true, cursor: true, appCursor: false, bracketed: false, insert: false, alt: false }; + this.altSaved = null; + this.state = "ground"; + this.buf = ""; + this.params = []; + this.priv = false; + this.osc = ""; + this.dirty = new Set(); + this.needFull = true; + this.lastCursorRow = -1; + this.decoder = new TextDecoder("utf-8"); + this.disposed = false; + this.exited = false; + this.screen = []; + for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols)); + this._buildDom(); + this._measure(); + this._onResize(); + } + + Term.prototype._buildDom = function () { + var self = this; + this.mount.classList.add("term"); + this.rowsEl = document.createElement("div"); + this.rowsEl.className = "term-rows"; + this.mount.appendChild(this.rowsEl); + this.rowEls = []; + for (var i = 0; i < 24; i++) { + var d = document.createElement("div"); + d.className = "term-row"; + this.rowsEl.appendChild(d); + this.rowEls.push(d); + } + // A hidden textarea is what receives IME composition and any text the + // browser inserts without key events (mobile keyboards, autocomplete). + // Key events we handle ourselves are preventDefault'ed, so nothing lands + // here for ordinary typing and the two paths never double up. + this.input = document.createElement("textarea"); + this.input.className = "term-input"; + this.input.setAttribute("autocapitalize", "off"); + this.input.setAttribute("autocorrect", "off"); + this.input.setAttribute("autocomplete", "off"); + this.input.setAttribute("spellcheck", "false"); + this.input.setAttribute("aria-label", "terminal input"); + this.mount.appendChild(this.input); + this.composing = false; + this.input.addEventListener("input", function () { + if (self.composing) return; + var v = self.input.value; + if (v) { self.input.value = ""; self._send(v); } + }); + this.input.addEventListener("compositionstart", function () { self.composing = true; }); + this.input.addEventListener("compositionend", function () { + self.composing = false; + var v = self.input.value; + if (v) { self.input.value = ""; self._send(v); } + }); + this.mount.addEventListener("keydown", function (e) { self._onKey(e); }); + // A browser can move focus off the page's own element (Escape is one way), + // and then every keystroke goes nowhere until the user clicks back in. This + // window sits on a page that IS the terminal, so take the key instead of + // making the user find the focus again. + window.addEventListener("keydown", function (e) { + if (self.disposed || self.exited) return; + if (self.mount.contains(document.activeElement)) return; // already ours + self.input.focus(); + self._onKey(e); + }); + this.mount.addEventListener("paste", function (e) { self._onPaste(e); }); + this.mount.addEventListener("wheel", function (e) { self._onWheel(e); }, { passive: false }); + // A click must leave focus on the hidden input, never on the mount itself: + // the mount is focusable so it can host the cursor, and the browser's own + // focus handling runs after mousedown, hence the deferred call. + this.mount.addEventListener("mousedown", function () { + setTimeout(function () { self.input.focus(); }, 0); + }); + this.mount.addEventListener("focus", function () { self.input.focus(); }); + this.mount.addEventListener("focus", function () { self.needFull = true; self._scheduleRender(); }); + this.mount.addEventListener("blur", function () { self.needFull = true; self._scheduleRender(); }); + this._ro = new ResizeObserver(function () { self._onResize(); }); + this._ro.observe(this.mount); + }; + + Term.prototype._measure = function () { + var probe = document.createElement("span"); + probe.className = "term-probe"; + probe.textContent = new Array(101).join("W"); + this.mount.appendChild(probe); + var r = probe.getBoundingClientRect(); + this.cellW = r.width > 0 ? r.width / 100 : 8; + this.cellH = r.height > 0 ? r.height : 17; + this.mount.removeChild(probe); + }; + + Term.prototype._onResize = function () { + var st = getComputedStyle(this.mount); + var padX = parseFloat(st.paddingLeft) + parseFloat(st.paddingRight); + var padY = parseFloat(st.paddingTop) + parseFloat(st.paddingBottom); + var cols = Math.max(2, Math.floor((this.mount.clientWidth - padX) / this.cellW)); + var rows = Math.max(2, Math.floor((this.mount.clientHeight - padY) / this.cellH)); + if (cols === this.cols && rows === this.rows) return; + this.resize(cols, rows); + if (this.opts.onResize) this.opts.onResize(cols, rows); + }; + + Term.prototype.resize = function (cols, rows) { + var all = this.sb.concat(this.screen); + this.cols = cols; + this.rows = rows; + // Existing lines are truncated or padded rather than re-wrapped: good + // enough for a resize, and it never loses the line being worked on. + for (var i = 0; i < all.length; i++) { + var line = all[i]; + if (line.cells.length > cols) line.cells.length = cols; + while (line.cells.length < cols) line.cells.push(blankCell()); + } + while (all.length < rows) all.push(blankLine(cols)); + this.screen = all.slice(all.length - rows); + this.sb = all.slice(0, all.length - rows); + if (this.sb.length > this.maxSb) this.sb = this.sb.slice(this.sb.length - this.maxSb); + + while (this.rowEls.length < rows) { + var d = document.createElement("div"); + d.className = "term-row"; + this.rowsEl.appendChild(d); + this.rowEls.push(d); + } + for (var j = 0; j < this.rowEls.length; j++) { + this.rowEls[j].style.display = j < rows ? "" : "none"; + } + this.scrollTop = 0; + this.scrollBot = rows - 1; + if (this.cy >= rows) this.cy = rows - 1; + if (this.cx >= cols) this.cx = cols - 1; + this.needFull = true; + this._scheduleRender(); + }; + + Term.prototype.line = function (y) { return this.screen[y]; }; + + Term.prototype._scrollUp = function () { + this.sb.push(this.screen[this.scrollTop]); + if (this.sb.length > this.maxSb) this.sb.shift(); + this.screen.splice(this.scrollTop, 1); + this.screen.push(blankLine(this.cols)); + this.needFull = true; + }; + + Term.prototype._scrollDown = function () { + this.screen.splice(this.scrollBot, 1); + this.screen.splice(this.scrollTop, 0, blankLine(this.cols)); + this.needFull = true; + }; + + Term.prototype._lf = function () { + if (this.cy === this.scrollBot) this._scrollUp(); + else if (this.cy < this.rows - 1) this.cy++; + }; + + // ---- output ----------------------------------------------------------- + + Term.prototype.write = function (bytes) { + var s = this.decoder.decode(bytes, { stream: true }); + if (!s) return; + this._parse(s); + this._scheduleRender(); + }; + + Term.prototype._parse = function (s) { + for (var i = 0; i < s.length; i++) { + var ch = s[i], code = s.charCodeAt(i); + switch (this.state) { + case "ground": + if (code === 0x1b) { this.state = "esc"; break; } + if (code === 0x0d) { this.cx = 0; break; } + if (code === 0x0a || code === 0x0b || code === 0x0c) { this._lf(); break; } + if (code === 0x08) { if (this.cx > 0) this.cx--; break; } + if (code === 0x09) { this.cx = Math.min(this.cols - 1, (this.cx + 8) & ~7); break; } + if (code < 0x20) break; // BEL and the rest have no visual effect + if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { ch = s.substr(i, 2); i++; } + this._put(ch, ch.codePointAt(0)); + break; + case "esc": + if (ch === "[") { this.state = "csi"; this.params = []; this.priv = false; this.buf = ""; } + else if (ch === "]") { this.state = "osc"; this.osc = ""; } + else if (ch === "7") { this.saved = { x: this.cx, y: this.cy, a: Object.assign({}, this.attr) }; this.state = "ground"; } + else if (ch === "8") { + if (this.saved) { this.cx = this.saved.x; this.cy = this.saved.y; this.attr = Object.assign({}, this.saved.a); } + this.state = "ground"; + } + else if (ch === "D") { this._lf(); this.state = "ground"; } + else if (ch === "M") { if (this.cy === this.scrollTop) this._scrollDown(); else if (this.cy > 0) this.cy--; this.state = "ground"; } + else if (ch === "E") { this.cx = 0; this._lf(); this.state = "ground"; } + else if (ch === "c") { this._reset(); this.state = "ground"; } + else if (ch === "(" || ch === ")" || ch === "*" || ch === "+") { this.state = "charset"; } + else if (ch === "P" || ch === "_" || ch === "^" || ch === "X") { this.state = "dcs"; } + else this.state = "ground"; + break; + case "charset": + this.state = "ground"; + break; + case "dcs": + if (code === 0x07) this.state = "ground"; + else if (code === 0x1b) this.state = "dcsEsc"; + break; + case "dcsEsc": + this.state = ch === "\\" ? "ground" : "dcs"; + break; + case "osc": + if (code === 0x07) { this._osc(); this.state = "ground"; } + else if (code === 0x1b) { this._osc(); this.state = "oscEsc"; } + else this.osc += ch; + break; + case "oscEsc": + this.state = ch === "\\" ? "ground" : "osc"; + break; + case "csi": + if (code >= 0x40 && code <= 0x7e) { this._csi(ch); this.state = "ground"; } + else if (code === 0x3f || code === 0x3c || code === 0x3d || code === 0x3e) { + // The private/extension markers are ? < = >. All four have to be + // recognised: ignoring one lets its parameters leak into the final + // byte, which is how xterm's modifyOtherKeys (CSI > 4 ; 2 m) used to + // arrive as "underline on" and stay on for the rest of the session. + this.priv = true; + } + else if (code >= 0x30 && code <= 0x39) this.buf += ch; + else if (code === 0x3b) { this.params.push(parseInt(this.buf, 10)); this.buf = ""; } + break; + } + } + }; + + Term.prototype._osc = function () { + var m = /^(\d+);?(.*)$/.exec(this.osc); + if (!m) return; + var n = parseInt(m[1], 10); + // 777 is the session's own end-of-session marker; only the title is used. + if ((n === 0 || n === 2) && this.opts.onTitle) this.opts.onTitle(m[2]); + }; + + Term.prototype._params = function () { + var p = this.params.slice(); + if (this.buf !== "") p.push(parseInt(this.buf, 10)); + if (p.length === 0) p.push(0); + for (var i = 0; i < p.length; i++) if (isNaN(p[i])) p[i] = 0; + return p; + }; + + Term.prototype._csi = function (final) { + var p = this._params(); + var n = function (i, d) { return p[i] === 0 ? d : p[i]; }; + var i, c, k; + switch (final) { + case "A": this.cy = Math.max(this.scrollTop, this.cy - n(0, 1)); break; + case "B": this.cy = Math.min(this.scrollBot, this.cy + n(0, 1)); break; + case "C": this.cx = Math.min(this.cols - 1, this.cx + n(0, 1)); break; + case "D": this.cx = Math.max(0, this.cx - n(0, 1)); break; + case "E": this.cy = Math.min(this.rows - 1, this.cy + n(0, 1)); this.cx = 0; break; + case "F": this.cy = Math.max(0, this.cy - n(0, 1)); this.cx = 0; break; + case "G": this.cx = Math.min(this.cols - 1, n(0, 1) - 1); break; + case "d": this.cy = Math.min(this.rows - 1, n(0, 1) - 1); break; + case "H": case "f": + this.cy = Math.min(this.rows - 1, n(0, 1) - 1); + this.cx = Math.min(this.cols - 1, n(1, 1) - 1); + break; + case "J": + if (p[0] === 0) { this._eraseLine(this.cy, this.cx, this.cols - 1); for (i = this.cy + 1; i < this.rows; i++) this._eraseLine(i, 0, this.cols - 1); } + else if (p[0] === 1) { this._eraseLine(this.cy, 0, this.cx); for (i = 0; i < this.cy; i++) this._eraseLine(i, 0, this.cols - 1); } + else if (p[0] === 2 || p[0] === 3) { for (i = 0; i < this.rows; i++) this._eraseLine(i, 0, this.cols - 1); } + break; + case "K": + if (p[0] === 0) this._eraseLine(this.cy, this.cx, this.cols - 1); + else if (p[0] === 1) this._eraseLine(this.cy, 0, this.cx); + else if (p[0] === 2) this._eraseLine(this.cy, 0, this.cols - 1); + break; + case "L": + for (i = 0; i < n(0, 1); i++) this.screen.splice(this.scrollBot, 0, blankLine(this.cols)); + this.screen.length = this.rows; + this.needFull = true; + break; + case "M": + for (i = 0; i < n(0, 1); i++) { this.screen.splice(this.cy, 1); this.screen.splice(this.scrollBot, 0, blankLine(this.cols)); } + this.needFull = true; + break; + case "P": + c = this.line(this.cy); + for (i = 0; i < n(0, 1); i++) { c.cells.splice(this.cx, 1); c.cells.push(blankCell()); } + this.dirty.add(this.cy); + break; + case "@": + c = this.line(this.cy); + for (i = 0; i < n(0, 1); i++) { c.cells.splice(this.cx, 0, blankCell()); c.cells.length = this.cols; } + this.dirty.add(this.cy); + break; + case "X": + this._eraseLine(this.cy, this.cx, Math.min(this.cols - 1, this.cx + n(0, 1) - 1)); + break; + case "S": for (i = 0; i < n(0, 1); i++) this._scrollUp(); break; + case "T": for (i = 0; i < n(0, 1); i++) this._scrollDown(); break; + case "r": + this.scrollTop = Math.max(0, n(0, 1) - 1); + this.scrollBot = Math.min(this.rows - 1, n(1, this.rows) - 1); + if (this.scrollBot <= this.scrollTop) { this.scrollTop = 0; this.scrollBot = this.rows - 1; } + this.cx = 0; this.cy = this.scrollTop; + break; + case "m": if (!this.priv) this._sgr(p); break; + case "h": this._mode(p, true); break; + case "l": this._mode(p, false); break; + case "n": + if (p[0] === 6 && this.opts.onReply) this.opts.onReply("\x1b[" + (this.cy + 1) + ";" + (this.cx + 1) + "R"); + break; + case "c": + // A private ">" query asks for the secondary attributes; answering with + // the primary form (or not at all) makes a program wait for its timeout. + if (this.opts.onReply) this.opts.onReply(this.priv ? "\x1b[>0;0;0c" : "\x1b[?1;2c"); + break; + } + this._scheduleRender(); + }; + + Term.prototype._eraseLine = function (y, from, to) { + var line = this.line(y); + if (!line) return; + for (var i = Math.max(0, from); i <= to && i < this.cols; i++) { + line.cells[i] = blankCell(); + // Erasing must not leave the trailing half of a wide character behind. + if (i > 0 && line.cells[i - 1].ch) { + var prev = line.cells[i - 1]; + if (!prev.cont && isWide(prev.ch.codePointAt(0))) line.cells[i - 1] = blankCell(); + } + } + this.dirty.add(y); + }; + + Term.prototype._sgr = function (p) { + if (p.length === 0) p = [0]; + for (var i = 0; i < p.length; i++) { + var v = p[i]; + if (v === 0) this.attr = { fg: null, bg: null, fl: 0 }; + else if (v === 1) this.attr.fl |= F_BOLD; + else if (v === 2) this.attr.fl |= F_DIM; + else if (v === 3) this.attr.fl |= F_ITALIC; + else if (v === 4) this.attr.fl |= F_UNDER; + else if (v === 5) this.attr.fl |= F_BLINK; + else if (v === 7) this.attr.fl |= F_REV; + else if (v === 8) this.attr.fl |= F_HIDDEN; + else if (v === 9) this.attr.fl |= F_STRIKE; + else if (v === 22) this.attr.fl &= ~(F_BOLD | F_DIM); + else if (v === 23) this.attr.fl &= ~F_ITALIC; + else if (v === 24) this.attr.fl &= ~F_UNDER; + else if (v === 25) this.attr.fl &= ~F_BLINK; + else if (v === 27) this.attr.fl &= ~F_REV; + else if (v === 28) this.attr.fl &= ~F_HIDDEN; + else if (v === 29) this.attr.fl &= ~F_STRIKE; + else if (v >= 30 && v <= 37) this.attr.fg = PALETTE[v - 30]; + else if (v === 39) this.attr.fg = null; + else if (v >= 40 && v <= 47) this.attr.bg = PALETTE[v - 40]; + else if (v === 49) this.attr.bg = null; + else if (v >= 90 && v <= 97) this.attr.fg = PALETTE[v - 82]; + else if (v >= 100 && v <= 107) this.attr.bg = PALETTE[v - 92]; + else if (v === 38 || v === 48) { + var t = v === 38 ? "fg" : "bg"; + if (p[i + 1] === 5) { this.attr[t] = color256(p[i + 2]); i += 2; } + else if (p[i + 1] === 2) { + this.attr[t] = "rgb(" + p[i + 2] + "," + p[i + 3] + "," + p[i + 4] + ")"; + i += 4; + } + } + } + }; + + Term.prototype._mode = function (p, on) { + for (var i = 0; i < p.length; i++) { + var v = p[i]; + if (!this.priv) { if (v === 4) this.modes.insert = on; continue; } + if (v === 25) this.modes.cursor = on; + else if (v === 7) this.modes.wrap = on; + else if (v === 1) this.modes.appCursor = on; + else if (v === 2004) this.modes.bracketed = on; + else if (v === 47 || v === 1047 || v === 1049) { + if (on && !this.modes.alt) { + this.altSaved = { screen: this.screen, cx: this.cx, cy: this.cy }; + this.screen = []; + for (var k = 0; k < this.rows; k++) this.screen.push(blankLine(this.cols)); + this.cx = 0; this.cy = 0; this.vy = 0; + this.modes.alt = true; + this.needFull = true; + } else if (!on && this.modes.alt && this.altSaved) { + this.screen = this.altSaved.screen; + this.cx = this.altSaved.cx; + this.cy = this.altSaved.cy; + this.modes.alt = false; + this.needFull = true; + } + } + } + }; + + Term.prototype._put = function (ch, cp) { + var wide = isWide(cp); + if (this.cx >= this.cols) { + if (!this.modes.wrap) this.cx = this.cols - 1; + else { this.cx = 0; this._lf(); } + } + if (wide && this.cx === this.cols - 1) { this.cx = 0; this._lf(); } + var line = this.line(this.cy); + if (this.modes.insert) { line.cells.splice(this.cx, 0, blankCell()); line.cells.length = this.cols; } + line.cells[this.cx] = { ch: ch, fg: this.attr.fg, bg: this.attr.bg, fl: this.attr.fl, cont: false }; + this.cx++; + if (wide && this.cx < this.cols) { + line.cells[this.cx] = { ch: "", fg: this.attr.fg, bg: this.attr.bg, fl: this.attr.fl, cont: true }; + this.cx++; + } + this.dirty.add(this.cy); + }; + + Term.prototype._reset = function () { + this.attr = { fg: null, bg: null, fl: 0 }; + this.modes.alt = false; + this.screen = []; + for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols)); + this.cx = 0; this.cy = 0; this.vy = 0; + this.scrollTop = 0; this.scrollBot = this.rows - 1; + this.needFull = true; + }; + + // ---- rendering -------------------------------------------------------- + + Term.prototype._scheduleRender = function () { + if (this._raf || this.disposed) return; + var self = this; + this._raf = requestAnimationFrame(function () { self._raf = 0; self._render(); }); + }; + + Term.prototype._render = function () { + if (this.disposed) return; + var total = this.sb.length + this.screen.length; + var maxVy = Math.max(0, total - this.rows); + if (this.vy > maxVy) this.vy = maxVy; + var start = total - this.rows - this.vy; + var focused = this.mount.contains(document.activeElement); + // The alternate screen is where the cursor matters most: vi, less and htop + // all run there, and an editor with no cursor tells you nothing about where + // you are typing. + var cursorRow = this.vy === 0 ? this.cy : -1; + + for (var y = 0; y < this.rows; y++) { + var idx = start + y; + var line = idx < this.sb.length ? this.sb[idx] : this.screen[idx - this.sb.length]; + var live = (y === cursorRow); + // Repaint only what changed, plus the two rows the cursor can have moved + // between. + if (!this.needFull && !this.dirty.has(y) && !live && y !== this.lastCursorRow) continue; + this.rowEls[y].innerHTML = this._rowHtml(line, live, focused); + } + this.dirty.clear(); + this.needFull = false; + this.lastCursorRow = cursorRow; + }; + + Term.prototype._rowHtml = function (line, live, focused) { + if (!line) return ""; + var html = "", style = null, buf = ""; + var flush = function () { + if (!buf) return; + html += style ? '' + escapeText(buf) + "" : escapeText(buf); + buf = ""; + }; + for (var x = 0; x < this.cols; x++) { + var c = line.cells[x]; + if (!c || c.cont) continue; + var s = styleOf(c); + if (x === this.cx && live && focused && this.modes.cursor) s += "background:#38bdf8;color:#0f172a;"; + if (s !== style) { flush(); style = s; } + buf += c.ch === "" ? " " : c.ch; + } + flush(); + if (live && focused && this.modes.cursor && this.cx >= this.cols) { + html += ' '; + } + return html; + }; + + // ---- input ------------------------------------------------------------ + + Term.prototype._send = function (s) { if (this.opts.onInput) this.opts.onInput(s); }; + + Term.prototype.focus = function () { this.input.focus(); }; + + Term.prototype._scrollBy = function (lines) { + var total = this.sb.length + this.screen.length; + var max = Math.max(0, total - this.rows); + this.vy = Math.min(max, Math.max(0, this.vy + lines)); + this.needFull = true; + this._scheduleRender(); + }; + + Term.prototype._onWheel = function (e) { + if (this.modes.alt) return; // full-screen apps scroll themselves + var step = e.deltaMode === 1 ? 3 : 1; + var d = (e.deltaY > 0 ? -1 : 1) * step; + if (this.vy === 0 && d < 0) return; // already at the bottom + this._scrollBy(d); + e.preventDefault(); + }; + + Term.prototype._onPaste = function (e) { + var text = (e.clipboardData || window.clipboardData).getData("text"); + if (!text) return; + e.preventDefault(); + if (this.modes.bracketed) this._send("\x1b[200~" + text + "\x1b[201~"); + else this._send(text); + }; + + // keySequence is what a named key sends. The arrows depend on the mode the + // running program asked for (application cursor keys), which is exactly why + // the on-screen bar cannot hard-code them. + Term.prototype.keySequence = function (name) { + var m = this.modes; + var arrows = { ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D", Home: "H", End: "F" }; + if (arrows[name]) return m.appCursor ? "\x1bO" + arrows[name] : "\x1b[" + arrows[name]; + switch (name) { + case "Enter": return "\r"; + case "Backspace": return "\x7f"; + case "Tab": return "\t"; + case "Escape": return "\x1b"; + case "PageUp": return "\x1b[5~"; + case "PageDown": return "\x1b[6~"; + case "Insert": return "\x1b[2~"; + case "Delete": return "\x1b[3~"; + } + if (/^F([1-9]|1[0-2])$/.test(name)) { + var f = parseInt(name.slice(1), 10); + var ss3 = { 1: "P", 2: "Q", 3: "R", 4: "S" }; + return ss3[f] ? "\x1bO" + ss3[f] : "\x1b[" + [15, 17, 18, 19, 20, 21, 23, 24][f - 5] + "~"; + } + return null; + }; + + Term.prototype._onKey = function (e) { + if (this.exited) return; + // The IME owns a keystroke while a composition is running. Which that is + // has to come from our own compositionstart/end tracking rather than from + // e.isComposing alone: a browser can leave that flag set after an IME + // candidate is cancelled (Escape), and then every later key is dropped + // until the user clicks somewhere else. Same for a key the IME is + // consuming, which browsers report as "Process". + if (this.composing || e.key === "Process" || e.key === "Unidentified") return; + var m = this.modes; + var send = null; + + if (e.shiftKey && (e.key === "PageUp" || e.key === "PageDown")) { + this._scrollBy(e.key === "PageUp" ? this.rows : -this.rows); + e.preventDefault(); + return; + } + // Shortcuts the platform uses for copy and paste are left alone, so the + // browser fires the paste event we actually want. Ctrl+V and Shift+Insert + // paste on Windows and X11, Cmd+V on macOS, Ctrl+Shift+Insert on X11. + if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === "v" || e.key === "V")) return; + if (e.shiftKey && e.key === "Insert") return; + if (e.ctrlKey && e.shiftKey && (e.key === "C" || e.key === "V")) return; + // Ctrl+C interrupts, unless the user is copying a selection. + if (e.ctrlKey && !e.altKey && e.key.toLowerCase() === "c" && String(window.getSelection())) return; + + send = this.keySequence(e.key); + if (send === null) { + if (e.ctrlKey && !e.altKey && e.key.length === 1) { + // The physical key is what a terminal wants: on a layout where Ctrl+A + // composes into something else, e.key is that something else. + var ck = /^Key([A-Z])$/.exec(e.code || ""); + if (ck) send = String.fromCharCode(ck[1].charCodeAt(0) - 64); + else if (e.key === " ") send = "\x00"; + else { + // Non-letters keep the plain ASCII mapping, which is what makes + // Ctrl+[ an Escape and Ctrl+6 Ctrl+^. + var cc = e.key.toUpperCase().charCodeAt(0); + if (cc >= 64 && cc < 128) send = String.fromCharCode(cc & 0x1f); + } + } else if (e.altKey && !e.metaKey) { + // Again the physical key: on macOS Option+a arrives as "å", so sending + // e.key would put that in the shell instead of Meta-a. + var ak = /^Key([A-Z])$/.exec(e.code || ""); + if (ak) send = "\x1b" + ak[1].toLowerCase(); + else if (e.key.length === 1) send = "\x1b" + e.key; + } else if (!e.ctrlKey && !e.metaKey && e.key.length === 1) { + send = e.key; + } + } + + if (send === null) { + // Swallow named keys we do not map so they cannot scroll the page. + if (e.key.length > 1 && !/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Dead|Fn|ContextMenu|OS)$/.test(e.key)) { + e.preventDefault(); + } + return; + } + e.preventDefault(); + if (this.vy !== 0) this._scrollBy(-this.vy); + this._send(send); + }; + + window.VpsmgrTerm = Term; + + // ---- session ---------------------------------------------------------- + + // 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); }; + 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 input(data) { send({ t: "input", data: data }); } + + 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 }); } + }); + } + + 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; + } + 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 () { if (term) term.focus(); }, + close: close, + // closeOthers ends the user's other shells and keeps this one. + closeOthers: function () { send({ t: "takeover" }); }, + // key and raw are what the on-screen key bar uses: the bar is a fallback + // for keys a browser will not hand over, not the primary way to type. + key: function (name) { + if (!term) return; + var seq = term.keySequence(name); + if (seq !== null) { input(seq); term.focus(); } + }, + raw: function (text) { input(text); if (term) term.focus(); }, + // reconnect is the manual path offered once the retries are exhausted. + reconnect: function () { + finished = false; + attempt = 0; + if (retryT) { clearTimeout(retryT); retryT = null; } + connect(); + }, + retryInMs: function () { return retryT ? Math.max(0, retryAt - Date.now()) : 0; } + }; + }; +})(); diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index 75e9b89..6e5fc9b 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -91,10 +91,16 @@ type Server struct { mgr *mgr.Manager limiter *loginLimiter flash *flashStore + terms *termRegistry } func New(c *cfg.Config, d *db.DB, m *mgr.Manager) *Server { - return &Server{cfg: c, db: d, mgr: m, limiter: newLoginLimiter(), flash: newFlashStore()} + return &Server{ + cfg: c, db: d, mgr: m, + limiter: newLoginLimiter(), + flash: newFlashStore(), + terms: newTermRegistry(), + } } func (s *Server) templates() (*template.Template, error) { @@ -219,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 { @@ -239,6 +248,11 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/domain-del", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainDel)))) mux.HandleFunc("/domain-update", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainUpdate)))) mux.HandleFunc("/init-script", s.requireAuth(s.requireActive(s.requirePost(s.handleInitScript)))) + // The terminal is a WebSocket upgrade (a GET), so it checks expiry itself + // rather than going through requireActive's redirect. + mux.HandleFunc("/webssh", s.requireAuth(s.handleWebSSH)) + mux.HandleFunc("/terminal", s.requireAuth(s.handleTerminal)) + mux.HandleFunc(termAssetPath, s.requireAuth(s.handleTermAsset)) mux.HandleFunc("/stats", s.requireAuth(s.handleStats)) mux.HandleFunc("/images", s.requireAuth(s.requirePost(s.handleImages))) mux.HandleFunc("/snapshot", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshot)))) @@ -397,6 +411,7 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { V4Forward: s.mgr.V4ForwardLive(), ProxyEnabled: s.mgr.HaproxyLive(), ShowFooter: s.cfg.Panel.ShowFooter, + WebSSH: s.cfg.Panel.WebSSH, Version: ver.Version, InitScript: u.InitScript, MaxNotesPlaintext: cfg.MaxNotesPlaintextBytes, diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 43e80f1..e3eb440 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -38,6 +38,11 @@ func useTempProxyLayout(t *testing.T) { func newTestServer(t *testing.T) (*Server, *db.DB) { t.Helper() c := cfg.Default() + // Point the Incus client at a socket that cannot exist, so a test never + // reaches a real container. Several tests drive handlers that would + // otherwise power-cycle or delete whatever instance happens to share a + // fixture's name on the machine running the suite. + c.Incus.Socket = "/nonexistent/vpsmgr-test.sock" c.Panel.URLPath = testSecret c.Panel.PublicIP = "127.0.0.1" c.Panel.SessionDays = 3 @@ -2062,7 +2067,7 @@ func TestOverviewKnowledgeModal(t *testing.T) { Prefix: "/" + testSecret, Knowledge: []knowledgeArticle{{ ID: 7, Title: "Getting started", UpdatedAt: "2026-08-01T10:00:00Z", - HTML: template.HTML("

Getting started

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

Getting started

ls -la
"), Source: "REGINNAME=*#*#region#*#*", }}, }) diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index bb02bbe..3744124 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -60,6 +60,11 @@ code.copyable.copied{outline:1px solid var(--ok);outline-offset:2px;color:var(-- .warn{background:var(--warn);color:#fff;border-color:var(--warn)} .danger{background:var(--danger);color:#fff;border-color:var(--danger)} .violet{background:var(--violet-bg);color:var(--violet-fg);border-color:var(--violet-border)} +/* The Web SSH button deliberately breaks from the panel's palette: a black key + with green type is what a remote shell looks like everywhere else. */ +.ssh{background:#04120a;color:#22c55e;border-color:#15803d; + font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em} +.ssh:hover{filter:none;background:#062012;box-shadow:0 0 0 3px rgba(34,197,94,.2)} .btnrow{display:flex;flex-wrap:wrap;gap:.45rem;margin-bottom:.55rem} .btnrow:last-child{margin-bottom:0} form.inline{display:inline} @@ -327,6 +332,7 @@ footer a{color:var(--muted);text-decoration:underline}
+{{if .WebSSH}}{{end}}
@@ -2112,6 +2118,27 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}} }); }).catch(function(){showEnable();}); })(); +// Web SSH: opens the shell in its own browser window. A window can be dragged +// to any size, moved between screens and maximised; a modal cannot, and the +// terminal is exactly the thing that wants more room. +(function(){ + var btn=document.getElementById('sshBtn'); + if(!btn)return; + var LANG='{{.Lang}}'; + var P='{{.Prefix}}'; + btn.addEventListener('click',function(){ + var size=[1040,660]; + try{ + var v=JSON.parse(localStorage.getItem('vpsmgr_ssh_size')); + if(v&&v[0]>=420&&v[1]>=320)size=v; // the size the user last dragged it to + }catch(e){} + var feat='width='+size[0]+',height='+size[1]+ + ',menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes'; + var w=window.open(P+'/webssh','vpsmgr_ssh',feat); + if(w){ w.focus(); return; } + toast(LANG==='zh'?'弹窗被浏览器拦截,请允许本站点弹出窗口':'popup blocked — allow popups for this site',true); + }); +})(); diff --git a/src/internal/panel/templates/webssh.html b/src/internal/panel/templates/webssh.html new file mode 100644 index 0000000..f962aeb --- /dev/null +++ b/src/internal/panel/templates/webssh.html @@ -0,0 +1,199 @@ + + + + + +{{.Title}} + + + +
+{{.User.Name}} +{{if eq .Lang "zh"}}连接中…{{else}}connecting…{{end}} + + + + +
+
+ + + + + + + + + + + +
+
+ + + + + diff --git a/src/internal/panel/terminal.go b/src/internal/panel/terminal.go new file mode 100644 index 0000000..2d0520e --- /dev/null +++ b/src/internal/panel/terminal.go @@ -0,0 +1,623 @@ +package panel + +import ( + "crypto/rand" + _ "embed" + "encoding/hex" + "encoding/json" + "errors" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" + + "vpsmgr/internal/csrf" + "vpsmgr/internal/lx" +) + +const ( + // 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 + + // 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 +) + +// termJS is the browser terminal engine, embedded like the templates so the +// panel keeps shipping as a single self-contained binary. +// +//go:embed assets/term.js +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"` // hello | input | resize | takeover + 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"` // 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"` +} + +// 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() +} + +// termSink is where a session's output goes while a client is attached. +type termSink interface { + write(messageType int, data []byte) error +} + +// 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 +} + +func (t *termSocket) write(messageType int, data []byte) error { + t.mu.Lock() + defer t.mu.Unlock() + _ = t.conn.SetWriteDeadline(time.Now().Add(termWriteWait)) + 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, + // Belt and braces: the handler checks this too, but a refused handshake + // here never reaches the handler at all. + CheckOrigin: func(r *http.Request) bool { return csrf.SameOrigin(r) }, +} + +// handleWebSSH serves the standalone terminal window. +// +// It is a page rather than a modal on purpose: the terminal is the one thing in +// the panel that wants more room, and only a real browser window can be dragged +// 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) + return + } + if s.isExpired(r) { + s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_account_expired")) + return + } + s.render(w, r, "webssh.html", pageData{ + User: u, + Prefix: s.p(""), + Title: u.Name + " — Web SSH", + }) +} + +// handleTerminal serves an interactive shell for the session's own container. +// +// The container is always taken from the session, never from the request: the +// client cannot name a container it does not own. The handshake is a GET, so it +// 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) + return + } + if !csrf.SameOrigin(r) { + w.WriteHeader(http.StatusForbidden) + return + } + // Expired accounts are locked out of their container; a shell is exactly + // what that lock is meant to prevent. Answered as a plain 403 rather than + // the usual redirect, which a WebSocket client cannot follow. + if s.isExpired(r) { + w.WriteHeader(http.StatusForbidden) + return + } + + conn, err := termUpgrader.Upgrade(w, r, nil) + if err != nil { + return // Upgrade has already written a response + } + defer conn.Close() + sock := &termSocket{conn: conn} + conn.SetReadLimit(termMaxFrame) + + // 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 + } + 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") + } + } + + pty, err := s.mgr.AttachTerminal(u.Name, size) + if err != nil { + _ = 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 + } + _ = 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) +} + +// 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(termReadWait)) + }) + + stopPing := make(chan struct{}) + defer close(stopPing) + go func() { + t := time.NewTicker(termPingEvery) + defer t.Stop() + for { + select { + case <-stopPing: + 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"})) + } + } + }() + + 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) + } + } +} + +// 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 { + q := r.URL.Query() + cols, _ := strconv.Atoi(q.Get("cols")) + rows, _ := strconv.Atoi(q.Get("rows")) + return lx.TermSize{Cols: cols, Rows: rows} +} + +func mustJSON(v any) []byte { + b, err := json.Marshal(v) + if err != nil { + return []byte(`{"t":"error","error":"internal error"}`) + } + return b +} + +// termAssetPath is the URL the terminal engine is served from. +const termAssetPath = "/assets/term.js" + +// handleTermAsset serves the browser terminal engine. It is embedded in the +// 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) +} diff --git a/src/internal/panel/terminal_session_test.go b/src/internal/panel/terminal_session_test.go new file mode 100644 index 0000000..e0545bd --- /dev/null +++ b/src/internal/panel/terminal_session_test.go @@ -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") + } +} diff --git a/src/internal/panel/terminal_test.go b/src/internal/panel/terminal_test.go new file mode 100644 index 0000000..eab432d --- /dev/null +++ b/src/internal/panel/terminal_test.go @@ -0,0 +1,111 @@ +package panel + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" + + "vpsmgr/internal/db" +) + +// The WebSocket handshake is a GET, so the panel's POST CSRF check never sees +// it and SameSite does not apply the way it does to a form post. These are the +// assertions that matter: without them any page could open a shell on the +// user's container with the user's cookie attached. +func TestTerminalRejectsForeignOrigin(t *testing.T) { + srv, d := newTestServer(t) + cookie := sessionCookie(t, d, "alice") + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + url := "ws" + strings.TrimPrefix(ts.URL, "http") + srv.p("/terminal") + cases := []struct { + name string + origin string + }{ + {"foreign origin", "http://evil.test"}, + {"sibling subdomain", "http://sub.example.test"}, + {"no origin at all", ""}, + } + for _, tc := range cases { + h := http.Header{} + h.Set("Cookie", cookie.String()) + if tc.origin != "" { + h.Set("Origin", tc.origin) + } + conn, resp, err := websocket.DefaultDialer.Dial(url, h) + if err == nil { + conn.Close() + t.Errorf("%s: handshake succeeded, want it refused", tc.name) + continue + } + if resp == nil || resp.StatusCode != http.StatusForbidden { + code := 0 + if resp != nil { + code = resp.StatusCode + } + t.Errorf("%s: status = %d, want 403", tc.name, code) + } + } +} + +// sessionCookie creates a user and a live panel session for it. +func sessionCookie(t *testing.T, d *db.DB, name string) *http.Cookie { + t.Helper() + u, err := d.CreateUser(name, "hash", "10.115.0.9", 9, 30009, 10900, 10, 1024, 10) + if err != nil { + t.Fatal(err) + } + sess, err := d.CreateSession(u.ID, 3) + if err != nil { + t.Fatal(err) + } + return &http.Cookie{Name: "vpsmgr_session", Value: sess.Token} +} + +// A same-origin handshake still needs a session; without one the request goes +// through the ordinary auth redirect, which is not a websocket upgrade. +func TestTerminalRequiresSession(t *testing.T) { + srv, _ := newTestServer(t) + ts := httptest.NewServer(srv.Handler()) + defer ts.Close() + + url := "ws" + strings.TrimPrefix(ts.URL, "http") + srv.p("/terminal") + h := http.Header{} + h.Set("Origin", ts.URL) + conn, resp, err := websocket.DefaultDialer.Dial(url, h) + if err == nil { + conn.Close() + t.Fatal("handshake succeeded without a session") + } + if resp == nil || resp.StatusCode == http.StatusSwitchingProtocols { + t.Fatalf("expected no upgrade, got %+v", resp) + } +} + +// The engine is embedded and served from the panel prefix, because the panel's +// CSP allows no third-party scripts. +func TestTerminalAssetServed(t *testing.T) { + srv, _ := newTestServer(t) + h := srv.Handler() + rr := doReq(t, h, http.MethodGet, srv.p(termAssetPath), nil, nil) + if rr.Code != http.StatusFound { // no session yet: redirected to login + t.Fatalf("asset without a session = %d, want the login redirect", rr.Code) + } +} + +func TestTerminalSizeFromQuery(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, "/terminal?cols=132&rows=43", nil) + got := termSizeFrom(r) + if got.Cols != 132 || got.Rows != 43 { + t.Errorf("size = %+v, want 132x43", got) + } + r = httptest.NewRequest(http.MethodGet, "/terminal", nil) + got = termSizeFrom(r) + if got.Cols != 0 || got.Rows != 0 { + t.Errorf("size = %+v, want zeroes for the lx layer to normalise", got) + } +}