From 51a451ab1e4488e320b51642fa7c5a17cb8e1417 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:30:48 +0000 Subject: [PATCH 1/7] feat(panel): a terminal in the browser for the account's own container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a Web SSH-style terminal to the user panel, opened from the overview page. It is deliberately not SSH. The container runs on the same host, so Incus' own exec API with a PTY is the transport: no sshd, no keys to distribute, no network path to arrange, and none of the credential handling a real SSH hop needs. - lx gains an interactive session beside the fire-and-collect Exec: stdin stays open, output streams as it is produced, and the window can be resized while the shell runs. Exec and Attach now share the request-and-dial path. - the panel serves /terminal over a WebSocket. That handshake is a GET, so the panel's POST CSRF check never sees it, and SameSite does not cover it the way it covers a form post — csrf.SameOrigin is what stands in for both. The handler also takes the container from the session and never from the request, refuses expired accounts, caps concurrent shells per user, and closes idle ones. - the browser side is a small VT emulator, hand-written because the panel ships as a single binary with no build step and a CSP that allows no third-party scripts. It covers the usual CSI/SGR set, the alternate screen, scrollback, UTF-8 including wide characters, and an IME via a hidden textarea so composed text (Chinese, Japanese) reaches the shell. Terminal output is escaped, never treated as markup. Two protocol details were worked out against the running daemon rather than assumed, and both are pinned by tests: an interactive exec returns only fds "0" and "control" (stdout is folded back into "0"), and the resize message carries its size as strings inside "args" — top-level width/height are silently ignored. Checked against a real container: prompt, line wrapping, ANSI colour, CJK output, less, a full-screen program (top), scrollback and window resize, with the session ending and reporting its exit status when the shell exits. Also fixes test isolation, which surfaced while running the suite on this host: the panel and admin test servers used the default Incus socket, so tests that drive /power, /reinstall and friends acted on whatever real instance shared a fixture user's name — on this machine, restarting the real "alice" on every run. Both now point the client at a socket that cannot exist. The suite also got faster (panels 12s -> 7s) from no longer waiting on real timeouts. --- src/internal/admin/admin_test.go | 5 + src/internal/csrf/csrf.go | 22 + src/internal/csrf/sameorigin_test.go | 38 ++ src/internal/lx/attach.go | 390 ++++++++++++ src/internal/lx/attach_test.go | 102 +++ src/internal/lx/lx.go | 64 +- src/internal/mgr/terminal.go | 20 + src/internal/panel/assets/term.js | 706 +++++++++++++++++++++ src/internal/panel/panel.go | 12 +- src/internal/panel/panel_test.go | 5 + src/internal/panel/templates/overview.html | 75 ++- src/internal/panel/terminal.go | 253 ++++++++ src/internal/panel/terminal_test.go | 139 ++++ 13 files changed, 1771 insertions(+), 60 deletions(-) create mode 100644 src/internal/csrf/sameorigin_test.go create mode 100644 src/internal/lx/attach.go create mode 100644 src/internal/lx/attach_test.go create mode 100644 src/internal/mgr/terminal.go create mode 100644 src/internal/panel/assets/term.js create mode 100644 src/internal/panel/terminal.go create mode 100644 src/internal/panel/terminal_test.go 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/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..4375e3b --- /dev/null +++ b/src/internal/lx/attach.go @@ -0,0 +1,390 @@ +package lx + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// 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") + } + return p.conns["0"].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 + } + 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..f60307a --- /dev/null +++ b/src/internal/panel/assets/term.js @@ -0,0 +1,706 @@ +/* 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); }); + 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) 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": 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": + if (this.opts.onReply) this.opts.onReply("\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); + var cursorRow = (this.vy === 0 && !this.modes.alt) ? 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); + }; + + Term.prototype._onKey = function (e) { + if (this.exited) return; + if (e.isComposing || e.keyCode === 229) return; // the IME owns this keystroke + 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; + } + if (e.ctrlKey && e.shiftKey && (e.key === "C" || e.key === "V")) return; // browser copy/paste + // Ctrl+C interrupts, unless the user is copying a selection. + if (e.ctrlKey && !e.altKey && e.key.toLowerCase() === "c" && String(window.getSelection())) return; + + var arrows = { ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D", Home: "H", End: "F" }; + if (arrows[e.key]) send = m.appCursor ? "\x1bO" + arrows[e.key] : "\x1b[" + arrows[e.key]; + else if (e.key === "Enter") send = "\r"; + else if (e.key === "Backspace") send = "\x7f"; + else if (e.key === "Tab") send = "\t"; + else if (e.key === "Escape") send = "\x1b"; + else if (e.key === "PageUp") send = "\x1b[5~"; + else if (e.key === "PageDown") send = "\x1b[6~"; + else if (e.key === "Insert") send = "\x1b[2~"; + else if (e.key === "Delete") send = "\x1b[3~"; + else if (/^F([1-9]|1[0-2])$/.test(e.key)) { + var f = parseInt(e.key.slice(1), 10); + var ss3 = { 1: "P", 2: "Q", 3: "R", 4: "S" }; + send = ss3[f] ? "\x1bO" + ss3[f] : "\x1b[" + [15, 17, 18, 19, 20, 21, 23, 24][f - 5] + "~"; + } else if (e.ctrlKey && !e.altKey && e.key.length === 1) { + var c = e.key.toUpperCase().charCodeAt(0); + if (c >= 64 && c < 128) send = String.fromCharCode(c & 0x1f); + else if (e.key === " ") send = "\x00"; + } else if (e.altKey && 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); + }; + + Term.prototype.dispose = function () { + this.disposed = true; + if (this._ro) this._ro.disconnect(); + if (this._raf) cancelAnimationFrame(this._raf); + }; + + // ---- session ---------------------------------------------------------- + + // openTerminal wires an emulator to /terminal over a WebSocket. Text frames + // in both directions carry JSON control messages; terminal output arrives as + // binary frames so arbitrary bytes survive untouched. + window.vpsmgrOpenTerminal = function (mount, url, hooks) { + hooks = hooks || {}; + var status = function (t) { if (hooks.onStatus) hooks.onStatus(t); }; + // Declared before the emulator: it reports its size from its constructor, + // and that callback must not touch an unset socket. + var ws = null; + function control(obj) { + if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); + } + function send(data) { control({ t: "i", data: data }); } + var term = new Term(mount, { + onTitle: function (t) { if (hooks.onTitle) hooks.onTitle(t); }, + onReply: function (s) { send(s); }, + onInput: function (s) { send(s); }, + onResize: function (c, r) { control({ t: "r", cols: c, rows: r }); } + }); + + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + var q = "?cols=" + term.cols + "&rows=" + term.rows; + ws = new WebSocket(proto + "//" + location.host + url + q); + ws.binaryType = "arraybuffer"; + var closed = false; + + ws.onopen = function () { + // Send the size the emulator settled on; the shell would otherwise start + // at the server's default until the window is resized. + control({ t: "r", cols: term.cols, rows: term.rows }); + term.focus(); + status(""); + }; + ws.onmessage = function (e) { + if (typeof e.data === "string") { + var msg; + try { msg = JSON.parse(e.data); } catch (err) { return; } + if (msg.t === "exit") { + term.exited = true; + status("session ended" + (msg.code ? " (exit " + msg.code + ")" : "")); + if (hooks.onExit) hooks.onExit(msg.code); + } else if (msg.t === "error") { + term.exited = true; + term.write(new TextEncoder().encode("\r\n[ " + msg.error + " ]\r\n")); + status("error"); + if (hooks.onError) hooks.onError(msg.error); + } + return; + } + term.write(new Uint8Array(e.data)); + }; + ws.onclose = function () { + if (closed) return; + closed = true; + term.exited = true; + status("connection closed"); + if (hooks.onClose) hooks.onClose(); + }; + ws.onerror = function () { status("connection error"); }; + + return { + focus: function () { term.focus(); }, + close: function () { + closed = true; + try { ws.close(); } catch (e) { /* already gone */ } + term.dispose(); + } + }; + }; +})(); diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index 75e9b89..8a98c7c 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) { @@ -239,6 +245,10 @@ 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("/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)))) diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 43e80f1..29dff76 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 diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index bb02bbe..d2adc19 100644 --- a/src/internal/panel/templates/overview.html +++ b/src/internal/panel/templates/overview.html @@ -188,8 +188,8 @@ footer a{color:var(--muted);text-decoration:underline} /* knowledge base: a large panel (nearly fullscreen on phones) with the article list and server-rendered Markdown */ .kb-modal{max-width:1000px;width:96vw;max-height:90vh;overflow:auto;display:flex;flex-direction:column;align-items:stretch} -.kb-modal .kb-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:.6rem} -.kb-modal .kb-head h3{margin:0} +.kb-modal .kb-head,.term-modal .kb-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:.6rem} +.kb-modal .kb-head h3,.term-modal .kb-head h3{margin:0} .kb-list{display:flex;flex-direction:column;gap:.4rem} .kb-item{display:flex;justify-content:space-between;align-items:center;gap:.6rem;text-align:left;width:100%;padding:.55rem .7rem;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);cursor:pointer;font:inherit} .kb-item:hover{border-color:var(--accent)} @@ -219,6 +219,17 @@ footer a{color:var(--muted);text-decoration:underline} @keyframes kbPulse{0%,100%{box-shadow:0 0 0 0 rgba(56,189,248,.9);transform:scale(1)}50%{box-shadow:0 0 0 .5rem rgba(56,189,248,0);transform:scale(1.08)}} @media (prefers-reduced-motion:reduce){.kb-copy.kb-pulse{animation:none;outline:2px solid var(--accent)}} .kb-back{align-self:flex-start;margin-bottom:.5rem} +/* Terminal: the mount is sized by CSS and the emulator measures it to work out + how many rows and columns fit. */ +.term-modal{width:min(1100px,96vw);height:min(720px,92vh);padding:.9rem;display:flex;flex-direction:column} +.term-status{font-size:.75rem;color:var(--muted)} +.term-mount{position:relative;flex:1 1 auto;min-height:0;background:#0f172a;border-radius:8px;padding:.6rem;overflow:hidden;cursor:text} +.term{outline:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;font-size:13px;line-height:1.35;color:#e2e8f0} +.term-probe{position:absolute;visibility:hidden;white-space:pre;line-height:1.35} +.term-input{position:absolute;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;resize:none;overflow:hidden} +.term-rows{white-space:pre;user-select:text} +.term-row{white-space:pre;height:1.35em;overflow:hidden} +@media (max-width:600px){.term-modal{width:100vw;height:100vh;max-height:100vh;border-radius:0}} @media (max-width:600px){ .overlay{padding:0} .kb-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;border-radius:0;padding:1rem} @@ -334,6 +345,7 @@ footer a{color:var(--muted);text-decoration:underline} + {{end}} @@ -552,6 +564,19 @@ footer a{color:var(--muted);text-decoration:underline} +
+ +
+
+ diff --git a/src/internal/panel/terminal.go b/src/internal/panel/terminal.go new file mode 100644 index 0000000..9eef44d --- /dev/null +++ b/src/internal/panel/terminal.go @@ -0,0 +1,253 @@ +package panel + +import ( + _ "embed" + "encoding/json" + "net/http" + "strconv" + "sync" + "time" + + "github.com/gorilla/websocket" + + "vpsmgr/internal/csrf" + "vpsmgr/internal/lx" +) + +const ( + // termMaxPerUser caps how many shells one account can hold open. A terminal + // is a live process per connection, so this is a resource guard as much as + // a UX one. + termMaxPerUser = 2 + // termIdleTimeout closes a session the browser stopped talking on. Pings + // keep a live one fresh, so this only fires for a closed or backgrounded + // tab — it is what stops abandoned shells from living forever. + termIdleTimeout = 15 * time.Minute + termPingEvery = 30 * time.Second + termWriteWait = 10 * time.Second + // 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"` // "i" input, "r" resize + Data string `json:"data,omitempty"` + Cols int `json:"cols,omitempty"` + Rows int `json:"rows,omitempty"` +} + +// termOut is a control message to the browser. +type termOut struct { + T string `json:"t"` // "exit" | "error" + Code int `json:"code,omitempty"` + Error string `json:"error,omitempty"` +} + +// termRegistry counts the open shells per user. The panel is a single process, +// so an in-memory count is the whole story. +type termRegistry struct { + mu sync.Mutex + open map[string]int +} + +func newTermRegistry() *termRegistry { return &termRegistry{open: map[string]int{}} } + +func (t *termRegistry) acquire(user string) bool { + t.mu.Lock() + defer t.mu.Unlock() + if t.open[user] >= termMaxPerUser { + return false + } + t.open[user]++ + return true +} + +func (t *termRegistry) release(user string) { + t.mu.Lock() + defer t.mu.Unlock() + if t.open[user] <= 1 { + delete(t.open, user) + return + } + t.open[user]-- +} + +// termSocket serialises writes: gorilla allows one writer at a time, and both +// the output pump and the keepalive pinger write. +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) +} + +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) }, +} + +// 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) { + 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} + + if !s.terms.acquire(u.Name) { + sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", + Error: "too many open terminals; close one and try again"})) + return + } + defer s.terms.release(u.Name) + + size := termSizeFrom(r) + pty, err := s.mgr.AttachTerminal(u.Name, size) + if err != nil { + sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", Error: err.Error()})) + return + } + defer pty.Close() + _ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.open") + + conn.SetReadLimit(termMaxFrame) + _ = conn.SetReadDeadline(time.Now().Add(termIdleTimeout)) + conn.SetPongHandler(func(string) error { + return conn.SetReadDeadline(time.Now().Add(termIdleTimeout)) + }) + + clientGone := make(chan struct{}) + go func() { + defer close(clientGone) + for { + _, msg, err := conn.ReadMessage() + if err != nil { + return + } + _ = conn.SetReadDeadline(time.Now().Add(termIdleTimeout)) + var in termIn + if json.Unmarshal(msg, &in) != nil { + continue + } + switch in.T { + case "i": + if err := pty.Write([]byte(in.Data)); err != nil { + return + } + case "r": + _ = pty.Resize(lx.TermSize{Cols: in.Cols, Rows: in.Rows}) + } + } + }() + + // Keepalive. A browser answers a ping automatically, which is what refreshes + // the read deadline above. + stopPing := make(chan struct{}) + defer close(stopPing) + go func() { + t := time.NewTicker(termPingEvery) + defer t.Stop() + for { + select { + case <-stopPing: + return + case <-clientGone: + return + case <-t.C: + if err := sock.write(websocket.PingMessage, nil); err != nil { + return + } + } + } + }() + + // Output pump. It runs until the PTY's output channel closes, which happens + // only after every reader has stopped — so the last bytes the shell wrote + // are always delivered, even though the session is already over. + outDone := make(chan struct{}) + go func() { + defer close(outDone) + for chunk := range pty.Output() { + if err := sock.write(websocket.BinaryMessage, chunk); err != nil { + return + } + } + }() + + select { + case <-clientGone: + case <-outDone: + code, _ := pty.ExitCode() + _ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "exit", Code: code})) + // Give the browser a moment to read the final frame before the socket + // is torn down under it. + time.Sleep(150 * time.Millisecond) + } +} + +// 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) { + w.Header().Set("Content-Type", "text/javascript; charset=utf-8") + _, _ = w.Write(termJS) +} diff --git a/src/internal/panel/terminal_test.go b/src/internal/panel/terminal_test.go new file mode 100644 index 0000000..426c39f --- /dev/null +++ b/src/internal/panel/terminal_test.go @@ -0,0 +1,139 @@ +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) + } +} + +func TestTermRegistryLimits(t *testing.T) { + reg := newTermRegistry() + for i := 0; i < termMaxPerUser; i++ { + if !reg.acquire("alice") { + t.Fatalf("acquire %d refused below the cap", i+1) + } + } + if reg.acquire("alice") { + t.Error("acquire above the cap succeeded") + } + if !reg.acquire("bob") { + t.Error("one user's sessions must not block another's") + } + for i := 0; i < termMaxPerUser; i++ { + reg.release("alice") + } + if !reg.acquire("alice") { + t.Error("released slots were not reclaimed") + } + // Release must not go negative. + reg.release("alice") + reg.release("alice") + reg.release("alice") + if !reg.acquire("alice") { + t.Error("registry went negative") + } +} From 4a17a95d851702a51ce70a76f3a9e92f29b531d7 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:29:22 +0000 Subject: [PATCH 2/7] feat(panel): give Web SSH its own window and a shell-styled button The terminal was a modal, which is the wrong container for it: a floating dialog cannot be dragged to a larger size, moved to another screen or maximised, and the terminal is exactly the thing that wants more room. - the button moves up next to Start/Stop/Restart and is renamed Web SSH, which is what people look for. It is styled apart from every other control on the page on purpose: black key, green type, monospace, with a ">_" prompt in front, so it reads as a remote shell at a glance. - clicking it opens /webssh in its own browser window. That window is resizable like any other, carries a Fullscreen button, and remembers the size it was last dragged to so the next open comes back the same way. A blocked popup says so instead of doing nothing. - the overview page no longer loads the terminal engine at all; it only exists in the shell window now. Verified against a real container in the popup: prompt, typing, and a window resize reaching the PTY (stty size moved from 26 99 to 16 86 when the terminal area changed), which is the thing a modal could never do. --- src/internal/panel/panel.go | 1 + src/internal/panel/templates/overview.html | 90 ++++++---------------- src/internal/panel/templates/webssh.html | 87 +++++++++++++++++++++ src/internal/panel/terminal.go | 23 ++++++ src/internal/panel/terminal_test.go | 53 +++++++++++++ 5 files changed, 187 insertions(+), 67 deletions(-) create mode 100644 src/internal/panel/templates/webssh.html diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index 8a98c7c..c7eb897 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -247,6 +247,7 @@ func (s *Server) Handler() http.Handler { 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)) diff --git a/src/internal/panel/templates/overview.html b/src/internal/panel/templates/overview.html index d2adc19..871174b 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} @@ -188,8 +193,8 @@ footer a{color:var(--muted);text-decoration:underline} /* knowledge base: a large panel (nearly fullscreen on phones) with the article list and server-rendered Markdown */ .kb-modal{max-width:1000px;width:96vw;max-height:90vh;overflow:auto;display:flex;flex-direction:column;align-items:stretch} -.kb-modal .kb-head,.term-modal .kb-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:.6rem} -.kb-modal .kb-head h3,.term-modal .kb-head h3{margin:0} +.kb-modal .kb-head{display:flex;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:.6rem} +.kb-modal .kb-head h3{margin:0} .kb-list{display:flex;flex-direction:column;gap:.4rem} .kb-item{display:flex;justify-content:space-between;align-items:center;gap:.6rem;text-align:left;width:100%;padding:.55rem .7rem;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);cursor:pointer;font:inherit} .kb-item:hover{border-color:var(--accent)} @@ -219,17 +224,6 @@ footer a{color:var(--muted);text-decoration:underline} @keyframes kbPulse{0%,100%{box-shadow:0 0 0 0 rgba(56,189,248,.9);transform:scale(1)}50%{box-shadow:0 0 0 .5rem rgba(56,189,248,0);transform:scale(1.08)}} @media (prefers-reduced-motion:reduce){.kb-copy.kb-pulse{animation:none;outline:2px solid var(--accent)}} .kb-back{align-self:flex-start;margin-bottom:.5rem} -/* Terminal: the mount is sized by CSS and the emulator measures it to work out - how many rows and columns fit. */ -.term-modal{width:min(1100px,96vw);height:min(720px,92vh);padding:.9rem;display:flex;flex-direction:column} -.term-status{font-size:.75rem;color:var(--muted)} -.term-mount{position:relative;flex:1 1 auto;min-height:0;background:#0f172a;border-radius:8px;padding:.6rem;overflow:hidden;cursor:text} -.term{outline:none;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;font-size:13px;line-height:1.35;color:#e2e8f0} -.term-probe{position:absolute;visibility:hidden;white-space:pre;line-height:1.35} -.term-input{position:absolute;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;resize:none;overflow:hidden} -.term-rows{white-space:pre;user-select:text} -.term-row{white-space:pre;height:1.35em;overflow:hidden} -@media (max-width:600px){.term-modal{width:100vw;height:100vh;max-height:100vh;border-radius:0}} @media (max-width:600px){ .overlay{padding:0} .kb-modal{width:100vw;max-width:100vw;height:100vh;max-height:100vh;border-radius:0;padding:1rem} @@ -338,6 +332,7 @@ footer a{color:var(--muted);text-decoration:underline}
+
@@ -345,7 +340,6 @@ footer a{color:var(--muted);text-decoration:underline} -
{{end}} @@ -564,19 +558,6 @@ footer a{color:var(--muted);text-decoration:underline} -
- -
-
- diff --git a/src/internal/panel/templates/webssh.html b/src/internal/panel/templates/webssh.html new file mode 100644 index 0000000..e956950 --- /dev/null +++ b/src/internal/panel/templates/webssh.html @@ -0,0 +1,87 @@ + + + + + +{{.Title}} + + + +
+{{.User.Name}} +{{if eq .Lang "zh"}}连接中…{{else}}connecting…{{end}} + + + +
+
+ + + + diff --git a/src/internal/panel/terminal.go b/src/internal/panel/terminal.go index 9eef44d..21b0ecd 100644 --- a/src/internal/panel/terminal.go +++ b/src/internal/panel/terminal.go @@ -102,6 +102,29 @@ var termUpgrader = websocket.Upgrader{ 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) { + 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 diff --git a/src/internal/panel/terminal_test.go b/src/internal/panel/terminal_test.go index 426c39f..95c2780 100644 --- a/src/internal/panel/terminal_test.go +++ b/src/internal/panel/terminal_test.go @@ -137,3 +137,56 @@ func TestTermRegistryLimits(t *testing.T) { t.Error("registry went negative") } } + +// The shell lives in its own window, so the panel serves a page for it rather +// than opening a modal. +func TestWebSSHPage(t *testing.T) { + srv, d := newTestServer(t) + cookie := sessionCookie(t, d, "alice") + h := srv.Handler() + + rr := doReq(t, h, http.MethodGet, srv.p("/webssh"), nil, cookie) + if rr.Code != http.StatusOK { + t.Fatalf("GET /webssh = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{ + `id="termMount"`, `id="fsBtn"`, `id="reBtn"`, + srv.p(termAssetPath), `'/terminal'`, + } { + if !strings.Contains(body, want) { + t.Errorf("webssh page missing %q", want) + } + } + // It must never carry the panel's own chrome (it is a bare terminal window). + if strings.Contains(body, `id="kbModal"`) || strings.Contains(body, "Sticky notes") { + t.Error("webssh page should be standalone, not the overview") + } + + // Without a session it is the ordinary login redirect. + rr = doReq(t, h, http.MethodGet, srv.p("/webssh"), nil, nil) + if rr.Code != http.StatusFound { + t.Errorf("GET /webssh without a session = %d, want the login redirect", rr.Code) + } +} + +// The button belongs next to Start/Stop/Restart, and must read as a shell. +func TestOverviewWebSSHButton(t *testing.T) { + srv, _ := newTestServer(t) + body := srv.renderToString(t, "overview.html", pageData{ + User: &db.User{Name: "alice"}, Prefix: "/" + testSecret, Lang: langEn, + }) + i := strings.Index(body, `id="sshBtn"`) + if i < 0 { + t.Fatal("overview is missing the Web SSH button") + } + if !strings.Contains(body[:i], `value="restart"`) { + t.Error("the Web SSH button should sit right after Start/Stop/Restart") + } + if !strings.Contains(body[i-200:i+200], `class="btn ssh"`) { + t.Error("the Web SSH button should carry the ssh style") + } + if strings.Contains(body, `id="termModal"`) { + t.Error("the terminal must no longer be a modal") + } +} From 5e28fc2983179d33dc82f610e6f482e51f997120 Mon Sep 17 00:00:00 2001 From: bluebluesoda <300733005+bluebluesoda@users.noreply.github.com> Date: Thu, 17 Sep 2026 12:13:02 +0000 Subject: [PATCH 3/7] feat(web-ssh): make the terminal survive a bad connection, and gate it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A browser reaching this panel is often a long way away, so the session is now built to survive the link rather than be torn down by it. Liveness first, because the old numbers were wrong: pings every 20s with a 60s deadline. The previous setting was a single 15-minute timer, and since the browser answers pings automatically it never fired for an idle user — the one thing it did do was take fifteen minutes to notice a peer that had vanished. - the shell outlives the socket. When the client goes away the PTY keeps running for 120s with its output landing in a 256 KiB ring (with a notice if anything is dropped). A client that comes back with its token resumes the same shell, so a tunnel, a cell handover or a sleeping laptop no longer costs a running build. The token travels in the first frame rather than the URL, is bound to the user, and lives in sessionStorage — reloading resumes, closing the window lets go. - the page has its own heartbeat: a browser cannot see protocol-level pings, so the server also sends a small JSON one and the page reconnects with exponential backoff and jitter after 45s of silence, up to eight attempts. - a handshake that neither completes nor fails would otherwise stall the retry loop for good, so an attempt that has not opened in 15s is abandoned. - at the per-user cap the client is offered its slots back instead of being refused, which is the only way to reclaim a window the user can no longer see. Where there is room, the count is just a notice. Three bugs the browser testing turned up, all of them real: - Pty writes had no deadline, so a wedged Incus connection blocked the handler forever — resume hung after any pause long enough to break that socket. - a chunk whose write failed was neither delivered nor buffered. - a session stayed resumable between Close and the end of its drain. An operator can now turn the whole thing off with `vps config set panel.web_ssh false`: the button is not rendered and the page, the script and the connection all refuse. It defaults on, including for a config written before the setting existed. --- docs/README.md | 1 + docs/configuration.md | 1 + docs/web-ssh.md | 57 ++ src/internal/cfg/cfg.go | 8 +- src/internal/cfg/registry.go | 12 + src/internal/cfg/registry_test.go | 39 ++ src/internal/lx/attach.go | 12 +- src/internal/panel/assets/term.js | 242 +++++++-- src/internal/panel/panel.go | 6 +- src/internal/panel/panel_test.go | 2 +- src/internal/panel/templates/overview.html | 2 +- src/internal/panel/templates/webssh.html | 85 ++- src/internal/panel/terminal.go | 550 ++++++++++++++++---- src/internal/panel/terminal_session_test.go | 310 +++++++++++ src/internal/panel/terminal_test.go | 81 --- 15 files changed, 1163 insertions(+), 245 deletions(-) create mode 100644 docs/web-ssh.md create mode 100644 src/internal/panel/terminal_session_test.go diff --git a/docs/README.md b/docs/README.md index 64bd323..e7331ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -8,6 +8,7 @@ Technical documentation for vpsmgr. All docs are in English. | [configuration.md](configuration.md) | Full reference for `/etc/vpsmgr/config.yaml` | | [ipv6.md](ipv6.md) | IPv6 pass-through design: deterministic addresses, prefix support, routing, installer flow | | [ipv6-ndp-responder.md](ipv6-ndp-responder.md) | Why prefix mode replaced ndppd with an in-tree NDP responder, design hardening, and what to test at scale | +| [web-ssh.md](web-ssh.md) | The browser terminal: how it reaches a container, how a session survives a dropped connection, and how to disable it | | [development.md](development.md) | Building, testing, releasing, conventions | See also [`AGENTS.md`](../AGENTS.md) at the repo root — short version aimed at AI coding agents. diff --git a/docs/configuration.md b/docs/configuration.md index 3496235..46aaf50 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,6 +35,7 @@ same table; `vps config list` shows the live values with this annotation. | `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) | | `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) | | `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer | +| `panel.web_ssh` | operator | restart panel | browser terminal in the user panel; default `true`. When `false` the Web SSH button is not rendered and the panel refuses the terminal page and connection outright | | `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below | | `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI | | `net.subnet` | **fixed at install** | — | container subnet `10..0.0/24`; changing breaks existing containers | diff --git a/docs/web-ssh.md b/docs/web-ssh.md new file mode 100644 index 0000000..ee6b615 --- /dev/null +++ b/docs/web-ssh.md @@ -0,0 +1,57 @@ +# Web SSH + +A terminal in the browser: a shell inside the user's own container, opened from +the Machine card (next to Start / Stop / Restart) in the user panel. + +It is not an SSH client. The container runs on the panel host, so the panel +talks to it through the Incus exec API over the daemon's unix socket, with a +PTY. Nothing listens on the container side, there are no keys to hand out, and +there is no extra network path to arrange. + +## Shape of it + +- **Its own window.** The button opens a separate browser window rather than a + dialog, so it can be dragged to any size, moved to another screen or + maximised. It remembers the size it was last dragged to and reopens that way. +- **Root shell.** The shell runs as root inside the account's own container, + which is the same access the user already has over SSH. The container is + always taken from the session: a client cannot name a container it does not + own. +- **One account, two shells.** A third connection is offered the option to close + the others instead of being refused, which is also how a slot held by a closed + window is reclaimed. + +## Surviving a bad connection + +The panel is often reached from far away, so the browser side is built to +survive the link rather than to be torn down by it. + +- **Liveness.** The server pings every 20 seconds and treats 60 seconds of + silence as gone. The browser answers pings from its network stack, so this + works in a backgrounded tab too, and a connection that disappears without + saying so is noticed in about a minute. +- **Reconnect.** The page hears a small heartbeat the server sends alongside the + protocol pings; hearing nothing for 45 seconds means the socket is dead even + though it has not said so. It then reconnects with exponential backoff and + jitter, up to eight attempts, after which a manual *Reconnect* is offered. +- **Resume.** The shell outlives the socket: when the client goes away the PTY + keeps running for 120 seconds and its output goes into a bounded buffer + (256 KiB, with a notice if anything is dropped). A client that comes back with + its session token resumes the *same* shell — a running build, an open editor + and the shell's variables are all still there. +- **Redraw.** A resumed terminal starts blank and is rebuilt from the buffered + output, so a full-screen program is asked to repaint itself; if one does not, + `Ctrl+L` forces it. +- **Token.** The resume token lives in `sessionStorage`, so reloading the page + comes back to the shell and closing the window lets it go. + +## Turning it off + +``` +vps config set panel.web_ssh false +``` + +The panel restarts and the feature is off: the button is not rendered, and the +terminal page, its script and the terminal connection all refuse. `true` (the +default) turns it back on. A configuration written before this feature existed +comes out enabled. diff --git a/src/internal/cfg/cfg.go b/src/internal/cfg/cfg.go index 4c7181c..2bd5e10 100644 --- a/src/internal/cfg/cfg.go +++ b/src/internal/cfg/cfg.go @@ -153,6 +153,12 @@ type PanelCfg struct { // URLPath nor AdminPath get a bare, headerless 404. AdminPath string `yaml:"admin_url_path,omitempty"` ShowFooter bool `yaml:"show_footer"` + // WebSSH enables the browser terminal in the user panel. On by default, + // including for config files written before the feature existed (a missing + // key keeps the default). An operator who does not want a shell reachable + // from the panel turns it off: the button disappears and the panel refuses + // both the terminal page and the terminal connection. + WebSSH bool `yaml:"web_ssh"` // BandwidthResetDay is the day of the month the monthly bandwidth period // rolls over. 1-28 (capped at 28 so February always has the day). BandwidthResetDay int `yaml:"bandwidth_reset_day"` @@ -289,7 +295,7 @@ type SnapshotsCfg struct { func Default() *Config { c := &Config{} - c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1} + c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, WebSSH: true, BandwidthResetDay: 1} c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Haproxy: true, UserPorts: DefaultUserPorts} c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio} c.Snapshots = SnapshotsCfg{Limit: 1, Share: false} diff --git a/src/internal/cfg/registry.go b/src/internal/cfg/registry.go index 075fbcc..2a18bda 100644 --- a/src/internal/cfg/registry.go +++ b/src/internal/cfg/registry.go @@ -230,6 +230,18 @@ var Fields = []Field{ c.Panel.ShowFooter = b return nil }}, + {"panel.web_ssh", KindOperator, ApplyRestart, + "browser terminal (Web SSH) in the user panel; false hides the button and refuses terminal connections entirely", + "true or false", + getStr(func(c *Config) string { return strconv.FormatBool(c.Panel.WebSSH) }), + func(c *Config, v string) error { + b, ok := parseBool(v) + if !ok { + return fmt.Errorf("panel.web_ssh must be true/false or 1/0") + } + c.Panel.WebSSH = b + return nil + }}, {"panel.bandwidth_reset_day", KindOperator, ApplyDestructive, "day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period", "1", diff --git a/src/internal/cfg/registry_test.go b/src/internal/cfg/registry_test.go index 51f9d2d..c7146d9 100644 --- a/src/internal/cfg/registry_test.go +++ b/src/internal/cfg/registry_test.go @@ -1,6 +1,8 @@ package cfg import ( + "os" + "path/filepath" "strings" "testing" ) @@ -42,6 +44,11 @@ func TestFieldValueReadsConfig(t *testing.T) { if v := FieldValue(c, "net.haproxy"); v != "true" { t.Errorf("net.haproxy default = %q", v) } + // The upgrade case matters: a config written before Web SSH existed has no + // key for it, and the feature must still come out enabled. + if v := FieldValue(c, "panel.web_ssh"); v != "true" { + t.Errorf("panel.web_ssh default = %q, want true (upgrades must get it enabled)", v) + } if v := FieldValue(c, "panel.show_footer"); v != "true" { t.Errorf("panel.show_footer default = %q", v) } @@ -405,3 +412,35 @@ func TestBandwidthResetDayIsDestructive(t *testing.T) { } } } + +// A config file written before the feature existed decodes with the key absent, +// and the feature must come out enabled — that is what "upgrades get it on" +// means in practice. +func TestWebSSHDefaultsOnForAnOlderConfig(t *testing.T) { + base := t.TempDir() + t.Setenv("VPSMGR_CONFIG", filepath.Join(base, "config.yaml")) + if err := Save(Default()); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(Path()) + if err != nil { + t.Fatal(err) + } + var kept []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.Contains(line, "web_ssh") { + continue + } + kept = append(kept, line) + } + if err := os.WriteFile(Path(), []byte(strings.Join(kept, "\n")), 0o600); err != nil { + t.Fatal(err) + } + c, err := Load() + if err != nil { + t.Fatal(err) + } + if !c.Panel.WebSSH { + t.Error("an old config without the key must still enable Web SSH") + } +} diff --git a/src/internal/lx/attach.go b/src/internal/lx/attach.go index 4375e3b..5876acb 100644 --- a/src/internal/lx/attach.go +++ b/src/internal/lx/attach.go @@ -17,6 +17,13 @@ import ( "github.com/gorilla/websocket" ) +// ptyWriteWait bounds a write to the Incus side of a session. Without it a +// connection that has gone unresponsive — the daemon timed the websocket out +// while the panel was busy, a network hiccup on the way to it — blocks the +// caller (and the session's mutex) forever, which is a wedged panel goroutine +// rather than a failed terminal. +const ptyWriteWait = 5 * time.Second + // TermSize is a terminal window size in character cells. type TermSize struct { Cols int @@ -328,7 +335,9 @@ func (p *Pty) Write(b []byte) error { if p.dead { return errors.New("terminal session is closed") } - return p.conns["0"].WriteMessage(websocket.BinaryMessage, b) + ws := p.conns["0"] + _ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait)) + return ws.WriteMessage(websocket.BinaryMessage, b) } // Resize tells the PTY its new window size so full-screen programs reflow. The @@ -355,6 +364,7 @@ func (p *Pty) Resize(size TermSize) error { if err != nil { return err } + _ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait)) return ws.WriteMessage(websocket.TextMessage, msg) } diff --git a/src/internal/panel/assets/term.js b/src/internal/panel/assets/term.js index f60307a..c0abf7f 100644 --- a/src/internal/panel/assets/term.js +++ b/src/internal/panel/assets/term.js @@ -632,75 +632,207 @@ if (this._raf) cancelAnimationFrame(this._raf); }; + window.VpsmgrTerm = Term; + // ---- session ---------------------------------------------------------- - // openTerminal wires an emulator to /terminal over a WebSocket. Text frames - // in both directions carry JSON control messages; terminal output arrives as - // binary frames so arbitrary bytes survive untouched. + // The terminal is a connection to a shell on the far side of the internet, so + // the session is built to survive the link, not the other way round: + // + // - the shell outlives the socket (the server keeps it for a grace period + // and buffers what it prints), so a reconnect resumes the same shell + // rather than starting a new one. The token that claims it lives in + // sessionStorage, so reloading the page comes back to it and closing the + // window lets it go. + // - a browser cannot see protocol-level pings, so the server also sends a + // small JSON heartbeat; hearing nothing for a while means the socket is + // dead even though it has not said so. + // - a dropped connection is retried with exponential backoff and jitter, + // and giving up is a state the page can offer a manual reconnect from. + var TOKEN_KEY = "vpsmgr_ssh_token"; + var HEARTBEAT_MS = 5000; // how often we check for silence + var SILENCE_MS = 45000; // no message for this long means it is gone + var MAX_ATTEMPTS = 8; + // A handshake that neither completes nor fails would otherwise stall the + // retry loop for good — exactly the shape a flaky link produces. + var CONNECT_TIMEOUT_MS = 15000; + window.vpsmgrOpenTerminal = function (mount, url, hooks) { hooks = hooks || {}; + var msg = hooks.msg || {}; var status = function (t) { if (hooks.onStatus) hooks.onStatus(t); }; - // Declared before the emulator: it reports its size from its constructor, - // and that callback must not touch an unset socket. - var ws = null; - function control(obj) { + var setOthers = function (n) { if (hooks.onOthers) hooks.onOthers(n); }; + var askBusy = function (n, decide) { if (hooks.onBusy) hooks.onBusy(n, decide); }; + + var Term = window.VpsmgrTerm; + var term = null, ws = null, hb = null, retryT = null, retryAt = 0, connectT = null; + var attempt = 0, lastSeen = 0, finished = false; + + function token() { + try { return sessionStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; } + } + function setToken(v) { + try { if (v) sessionStorage.setItem(TOKEN_KEY, v); else sessionStorage.removeItem(TOKEN_KEY); } catch (e) {} + } + function send(obj) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj)); } - function send(data) { control({ t: "i", data: data }); } - var term = new Term(mount, { - onTitle: function (t) { if (hooks.onTitle) hooks.onTitle(t); }, - onReply: function (s) { send(s); }, - onInput: function (s) { send(s); }, - onResize: function (c, r) { control({ t: "r", cols: c, rows: r }); } - }); + function input(data) { send({ t: "input", data: data }); } - var proto = location.protocol === "https:" ? "wss:" : "ws:"; - var q = "?cols=" + term.cols + "&rows=" + term.rows; - ws = new WebSocket(proto + "//" + location.host + url + q); - ws.binaryType = "arraybuffer"; - var closed = false; + function build() { + mount.innerHTML = ""; + term = new Term(mount, { + onTitle: function (t) { if (hooks.onTitle) hooks.onTitle(t); }, + onReply: input, + onInput: input, + onResize: function (c, r) { send({ t: "resize", cols: c, rows: r }); } + }); + } - ws.onopen = function () { - // Send the size the emulator settled on; the shell would otherwise start - // at the server's default until the window is resized. - control({ t: "r", cols: term.cols, rows: term.rows }); - term.focus(); - status(""); - }; - ws.onmessage = function (e) { - if (typeof e.data === "string") { - var msg; - try { msg = JSON.parse(e.data); } catch (err) { return; } - if (msg.t === "exit") { - term.exited = true; - status("session ended" + (msg.code ? " (exit " + msg.code + ")" : "")); - if (hooks.onExit) hooks.onExit(msg.code); - } else if (msg.t === "error") { - term.exited = true; - term.write(new TextEncoder().encode("\r\n[ " + msg.error + " ]\r\n")); - status("error"); - if (hooks.onError) hooks.onError(msg.error); + function stopHeartbeat() { if (hb) { clearInterval(hb); hb = null; } } + function stopConnectTimer() { if (connectT) { clearTimeout(connectT); connectT = null; } } + + function startHeartbeat() { + stopHeartbeat(); + hb = setInterval(function () { + if (finished) return; + if (Date.now() - lastSeen > SILENCE_MS) { + // Silent for too long: treat it as gone and let the retry logic run. + try { ws.close(); } catch (e) { /* already gone */ } + lost(); } + }, HEARTBEAT_MS); + } + + function connect() { + if (finished) return; + retryT = null; + stopHeartbeat(); + // A resumed terminal starts blank and is rebuilt from what the server + // replays, so the emulator is recreated rather than left showing stale + // output from before the drop. + build(); + status(attempt === 0 ? (msg.connecting || "connecting…") + : (msg.reconnecting || "reconnecting…") + " (" + attempt + "/" + MAX_ATTEMPTS + ")"); + + var proto = location.protocol === "https:" ? "wss:" : "ws:"; + ws = new WebSocket(proto + "//" + location.host + url); + ws.binaryType = "arraybuffer"; + + var opened = false; + ws.onopen = function () { + opened = true; + stopConnectTimer(); + lastSeen = Date.now(); + send({ t: "hello", token: token(), cols: term.cols, rows: term.rows }); + startHeartbeat(); + }; + connectT = setTimeout(function () { + if (opened) return; + try { ws.close(); } catch (e) { /* not open anyway */ } + lost(); + }, CONNECT_TIMEOUT_MS); + ws.onmessage = onMessage; + ws.onclose = lost; + ws.onerror = function () { /* onclose follows */ }; + } + + function onMessage(e) { + lastSeen = Date.now(); + if (typeof e.data !== "string") { + if (term) term.write(new Uint8Array(e.data)); return; } - term.write(new Uint8Array(e.data)); - }; - ws.onclose = function () { - if (closed) return; - closed = true; - term.exited = true; - status("connection closed"); - if (hooks.onClose) hooks.onClose(); - }; - ws.onerror = function () { status("connection error"); }; + var m; + try { m = JSON.parse(e.data); } catch (err) { return; } + switch (m.t) { + case "ping": + return; + case "ready": + attempt = 0; + if (hooks.onReady) hooks.onReady(); + setToken(m.token); + setOthers(m.others || 0); + status(msg.connected || ""); + if (term) term.focus(); + return; + case "others": + setOthers(m.others || 0); + return; + case "busy": + status(msg.busy || "another terminal is open"); + askBusy(m.others || 0, function (take) { + if (!take) { close(); return; } + send({ t: "takeover" }); + status(msg.connecting || "connecting…"); + }); + return; + case "exit": + finish((msg.ended || "session ended") + (m.code ? " · exit " + m.code : "")); + if (hooks.onExit) hooks.onExit(m.code); + return; + case "error": + if (term) term.write(new TextEncoder().encode("\r\n[ " + m.error + " ]\r\n")); + finish(m.error); + if (hooks.onError) hooks.onError(m.error); + return; + } + } + + // finish stops for good: the shell is over, so retrying would only start a + // different one behind the user's back. + function finish(text) { + finished = true; + stopHeartbeat(); + stopConnectTimer(); + setToken(""); + status(text); + } + + // lost handles a connection that went away on its own. + function lost() { + stopHeartbeat(); + stopConnectTimer(); + if (finished || retryT) return; + attempt++; + if (attempt > MAX_ATTEMPTS) { + finished = true; + status(msg.disconnected || "disconnected"); + if (hooks.onClose) hooks.onClose(); + return; + } + // Exponential backoff with jitter, so a rack of tabs does not come back + // in lockstep. + var delay = Math.min(400 * Math.pow(2, attempt - 1), 8000) + Math.random() * 400; + retryAt = Date.now() + delay; + status((msg.reconnecting || "reconnecting…") + " (" + attempt + "/" + MAX_ATTEMPTS + ")"); + retryT = setTimeout(connect, delay); + } + + function close() { + finished = true; + stopHeartbeat(); + stopConnectTimer(); + if (retryT) { clearTimeout(retryT); retryT = null; } + setToken(""); + try { ws.close(); } catch (e) { /* already gone */ } + } + + connect(); return { - focus: function () { term.focus(); }, - close: function () { - closed = true; - try { ws.close(); } catch (e) { /* already gone */ } - term.dispose(); - } + focus: function () { if (term) term.focus(); }, + close: close, + // closeOthers ends the user's other shells and keeps this one. + closeOthers: function () { send({ t: "takeover" }); }, + // reconnect is the manual path offered once the retries are exhausted. + reconnect: function () { + finished = false; + attempt = 0; + if (retryT) { clearTimeout(retryT); retryT = null; } + connect(); + }, + retryInMs: function () { return retryT ? Math.max(0, retryAt - Date.now()) : 0; } }; }; })(); diff --git a/src/internal/panel/panel.go b/src/internal/panel/panel.go index c7eb897..6e5fc9b 100644 --- a/src/internal/panel/panel.go +++ b/src/internal/panel/panel.go @@ -225,7 +225,10 @@ type pageData struct { // impersonating admin can tell users apart. Users cannot set it themselves. ThemeColor string ShowFooter bool - Version string + // WebSSH mirrors cfg.Panel.WebSSH: the overview renders the Web SSH button + // only when the terminal is enabled. + WebSSH bool + Version string } func (s *Server) Handler() http.Handler { @@ -408,6 +411,7 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData { V4Forward: s.mgr.V4ForwardLive(), ProxyEnabled: s.mgr.HaproxyLive(), ShowFooter: s.cfg.Panel.ShowFooter, + WebSSH: s.cfg.Panel.WebSSH, Version: ver.Version, InitScript: u.InitScript, MaxNotesPlaintext: cfg.MaxNotesPlaintextBytes, diff --git a/src/internal/panel/panel_test.go b/src/internal/panel/panel_test.go index 29dff76..e3eb440 100644 --- a/src/internal/panel/panel_test.go +++ b/src/internal/panel/panel_test.go @@ -2067,7 +2067,7 @@ func TestOverviewKnowledgeModal(t *testing.T) { Prefix: "/" + testSecret, Knowledge: []knowledgeArticle{{ ID: 7, Title: "Getting started", UpdatedAt: "2026-08-01T10:00:00Z", - HTML: template.HTML("

Getting started

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

Getting started

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