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} +
+