Merge dev-webssh: a browser terminal for the container

This commit is contained in:
bluebluesoda committed 2026-09-17 13:47:37 +00:00
commit 18a680b924
21 files changed
+2917 -61

No files matched your search

+1
View File
@@ -8,6 +8,7 @@ Technical documentation for vpsmgr. All docs are in English.
| [configuration.md](configuration.md) | Full reference for `/etc/vpsmgr/config.yaml` |
| [ipv6.md](ipv6.md) | IPv6 pass-through design: deterministic addresses, prefix support, routing, installer flow |
| [ipv6-ndp-responder.md](ipv6-ndp-responder.md) | Why prefix mode replaced ndppd with an in-tree NDP responder, design hardening, and what to test at scale |
| [web-ssh.md](web-ssh.md) | The browser terminal: how it reaches a container, how a session survives a dropped connection, and how to disable it |
| [development.md](development.md) | Building, testing, releasing, conventions |
See also [`AGENTS.md`](../AGENTS.md) at the repo root — short version aimed at AI coding agents.
+1
View File
@@ -35,6 +35,7 @@ same table; `vps config list` shows the live values with this annotation.
| `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) |
| `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) |
| `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer |
| `panel.web_ssh` | operator | restart panel | browser terminal in the user panel; default `true`. When `false` the Web SSH button is not rendered and the panel refuses the terminal page and connection outright |
| `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below |
| `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI |
| `net.subnet` | **fixed at install** | — | container subnet `10.<n>.0.0/24`; changing breaks existing containers |
+72
View File
@@ -0,0 +1,72 @@
# Web SSH
A terminal in the browser: a shell inside the user's own container, opened from
the Machine card (next to Start / Stop / Restart) in the user panel.
It is not an SSH client. The container runs on the panel host, so the panel
talks to it through the Incus exec API over the daemon's unix socket, with a
PTY. Nothing listens on the container side, there are no keys to hand out, and
there is no extra network path to arrange.
## Shape of it
- **Its own window.** The button opens a separate browser window rather than a
dialog, so it can be dragged to any size, moved to another screen or
maximised. It remembers the size it was last dragged to and reopens that way.
- **Root shell.** The shell runs as root inside the account's own container,
which is the same access the user already has over SSH. The container is
always taken from the session: a client cannot name a container it does not
own.
- **One account, two shells.** A third connection is offered the option to close
the others instead of being refused, which is also how a slot held by a closed
window is reclaimed.
## Surviving a bad connection
The panel is often reached from far away, so the browser side is built to
survive the link rather than to be torn down by it.
- **Liveness.** The server pings every 20 seconds and treats 60 seconds of
silence as gone. The browser answers pings from its network stack, so this
works in a backgrounded tab too, and a connection that disappears without
saying so is noticed in about a minute.
- **Reconnect.** The page hears a small heartbeat the server sends alongside the
protocol pings; hearing nothing for 45 seconds means the socket is dead even
though it has not said so. It then reconnects with exponential backoff and
jitter, up to eight attempts, after which a manual *Reconnect* is offered.
- **Resume.** The shell outlives the socket: when the client goes away the PTY
keeps running for 120 seconds and its output goes into a bounded buffer
(256 KiB, with a notice if anything is dropped). A client that comes back with
its session token resumes the *same* shell — a running build, an open editor
and the shell's variables are all still there.
- **Redraw.** A resumed terminal starts blank and is rebuilt from the buffered
output, so a full-screen program is asked to repaint itself; if one does not,
`Ctrl+L` forces it.
- **Token.** The resume token lives in `sessionStorage`, so reloading the page
comes back to the shell and closing the window lets it go.
## Keys and the mouse
Typing works as it does in any terminal: arrows, Home/End, PageUp/PageDown,
Insert/Delete, Tab, Escape and the function keys all send what a terminal sends,
and the arrows follow the mode a full-screen program asks for.
A small bar above the terminal carries the few keys a browser keeps for itself
(Ctrl+C, Ctrl+Z, Ctrl+D, Escape, Tab and the arrows), so they are never out of
reach.
Copy and paste use the platform's own shortcuts — Cmd+C/Cmd+V, Ctrl+V,
Shift+Insert — and the browser's right-click menu. Pasting a block of several
lines is handed to the shell exactly as the shell asked for it: a shell that
enables bracketed paste shows the text in its edit buffer first.
## Turning it off
```
vps config set panel.web_ssh false
```
The panel restarts and the feature is off: the button is not rendered, and the
terminal page, its script and the terminal connection all refuse. `true` (the
default) turns it back on. A configuration written before this feature existed
comes out enabled.
+5
View File
@@ -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"
+7 -1
View File
@@ -153,6 +153,12 @@ type PanelCfg struct {
// URLPath nor AdminPath get a bare, headerless 404.
AdminPath string `yaml:"admin_url_path,omitempty"`
ShowFooter bool `yaml:"show_footer"`
// WebSSH enables the browser terminal in the user panel. On by default,
// including for config files written before the feature existed (a missing
// key keeps the default). An operator who does not want a shell reachable
// from the panel turns it off: the button disappears and the panel refuses
// both the terminal page and the terminal connection.
WebSSH bool `yaml:"web_ssh"`
// BandwidthResetDay is the day of the month the monthly bandwidth period
// rolls over. 1-28 (capped at 28 so February always has the day).
BandwidthResetDay int `yaml:"bandwidth_reset_day"`
@@ -289,7 +295,7 @@ type SnapshotsCfg struct {
func Default() *Config {
c := &Config{}
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1}
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, WebSSH: true, BandwidthResetDay: 1}
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Haproxy: true, UserPorts: DefaultUserPorts}
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio}
c.Snapshots = SnapshotsCfg{Limit: 1, Share: false}
+12
View File
@@ -230,6 +230,18 @@ var Fields = []Field{
c.Panel.ShowFooter = b
return nil
}},
{"panel.web_ssh", KindOperator, ApplyRestart,
"browser terminal (Web SSH) in the user panel; false hides the button and refuses terminal connections entirely",
"true or false",
getStr(func(c *Config) string { return strconv.FormatBool(c.Panel.WebSSH) }),
func(c *Config, v string) error {
b, ok := parseBool(v)
if !ok {
return fmt.Errorf("panel.web_ssh must be true/false or 1/0")
}
c.Panel.WebSSH = b
return nil
}},
{"panel.bandwidth_reset_day", KindOperator, ApplyDestructive,
"day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period",
"1",
+39
View File
@@ -1,6 +1,8 @@
package cfg
import (
"os"
"path/filepath"
"strings"
"testing"
)
@@ -42,6 +44,11 @@ func TestFieldValueReadsConfig(t *testing.T) {
if v := FieldValue(c, "net.haproxy"); v != "true" {
t.Errorf("net.haproxy default = %q", v)
}
// The upgrade case matters: a config written before Web SSH existed has no
// key for it, and the feature must still come out enabled.
if v := FieldValue(c, "panel.web_ssh"); v != "true" {
t.Errorf("panel.web_ssh default = %q, want true (upgrades must get it enabled)", v)
}
if v := FieldValue(c, "panel.show_footer"); v != "true" {
t.Errorf("panel.show_footer default = %q", v)
}
@@ -405,3 +412,35 @@ func TestBandwidthResetDayIsDestructive(t *testing.T) {
}
}
}
// A config file written before the feature existed decodes with the key absent,
// and the feature must come out enabled — that is what "upgrades get it on"
// means in practice.
func TestWebSSHDefaultsOnForAnOlderConfig(t *testing.T) {
base := t.TempDir()
t.Setenv("VPSMGR_CONFIG", filepath.Join(base, "config.yaml"))
if err := Save(Default()); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(Path())
if err != nil {
t.Fatal(err)
}
var kept []string
for _, line := range strings.Split(string(raw), "\n") {
if strings.Contains(line, "web_ssh") {
continue
}
kept = append(kept, line)
}
if err := os.WriteFile(Path(), []byte(strings.Join(kept, "\n")), 0o600); err != nil {
t.Fatal(err)
}
c, err := Load()
if err != nil {
t.Fatal(err)
}
if !c.Panel.WebSSH {
t.Error("an old config without the key must still enable Web SSH")
}
}
+22
View File
@@ -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 {
+38
View File
@@ -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)
}
}
}
+400
View File
@@ -0,0 +1,400 @@
package lx
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// ptyWriteWait bounds a write to the Incus side of a session. Without it a
// connection that has gone unresponsive — the daemon timed the websocket out
// while the panel was busy, a network hiccup on the way to it — blocks the
// caller (and the session's mutex) forever, which is a wedged panel goroutine
// rather than a failed terminal.
const ptyWriteWait = 5 * time.Second
// TermSize is a terminal window size in character cells.
type TermSize struct {
Cols int
Rows int
}
// normalized clamps a size into what a PTY will accept and fills in the
// conventional 80x24 for anything nonsensical (a zero-sized window, a browser
// that has not laid the terminal out yet).
func (s TermSize) normalized() TermSize {
if s.Cols < 2 || s.Cols > 1000 {
s.Cols = 80
}
if s.Rows < 2 || s.Rows > 1000 {
s.Rows = 24
}
return s
}
// execOpen starts an exec operation and connects every websocket fd it hands
// back. The caller owns the returned connections. All fds must be connected
// before the command runs, which is why this is shared: the fire-and-collect
// Exec and the interactive Attach need exactly these steps.
func (c *Client) execOpen(ctx context.Context, name string, req execReq) (string, map[string]*websocket.Conn, error) {
body, err := json.Marshal(req)
if err != nil {
return "", nil, err
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.base+"/1.0/instances/"+url.PathEscape(name)+"/exec", bytes.NewReader(body))
if err != nil {
return "", nil, err
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.http.Do(httpReq)
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
raw, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return "", nil, err
}
var op struct {
Type string `json:"type"`
Operation string `json:"operation"`
Error string `json:"error"`
Metadata struct {
Inner struct {
Fds map[string]string `json:"fds"`
} `json:"metadata"`
} `json:"metadata"`
}
if err := json.Unmarshal(raw, &op); err != nil {
return "", nil, fmt.Errorf("incus exec %s: bad response: %w", name, err)
}
if op.Type == "error" {
return "", nil, errors.New(op.Error)
}
if op.Operation == "" {
return "", nil, fmt.Errorf("incus exec %s: no operation returned", name)
}
if len(op.Metadata.Inner.Fds) == 0 {
return "", nil, fmt.Errorf("incus exec %s: no websocket fds in response", name)
}
opID := strings.TrimPrefix(op.Operation, "/1.0/operations/")
dialer := c.dialer()
conns := make(map[string]*websocket.Conn, len(op.Metadata.Inner.Fds))
for k, secret := range op.Metadata.Inner.Fds {
wsURL := "ws://unix/1.0/operations/" + opID + "/websocket?secret=" + secret
ws, _, err := dialer.DialContext(ctx, wsURL, nil)
if err != nil {
for _, open := range conns {
_ = open.Close()
}
return "", nil, fmt.Errorf("incus exec %s: connect fd %s: %w", name, k, err)
}
conns[k] = ws
}
return opID, conns, nil
}
// exitMarker is an OSC sequence the wrapper shell prints once the login shell
// is gone. Incus does not end an interactive exec when the process exits — the
// operation stays "Running" and the fd stays open — so the session needs its
// own end-of-stream signal. It is an OSC (not plain text) so that a terminal
// ignores it if it ever reaches one, and Pty strips it from the stream anyway.
const exitMarker = "\x1b]777;vpsmgr-exit="
// exitMarkerCmd runs the login shell in a wrapper that reports its exit status
// on the pty and then lets the pty close.
const exitMarkerCmd = "/bin/bash -l; printf '\\033]777;vpsmgr-exit=%d\\007' $?"
// execControl is a client→server message on an exec operation's control
// channel. That channel only carries signals and window resizes; the exit
// status is read from the operation record instead.
//
// The size travels as strings inside Args, not as top-level numbers — that is
// what api.InstanceExecControl does, and sending width/height at the top level
// is silently ignored.
type execControl struct {
Command string `json:"command"`
Args map[string]string `json:"args,omitempty"`
Signal int `json:"signal,omitempty"`
}
// Pty is a live interactive terminal session inside a container.
//
// Output arrives as it is produced, stdin stays writable for the life of the
// session, and the window size can change at any time — the three things Exec
// deliberately does not do. Backpressure is natural: the read goroutine blocks
// sending to Output, so a slow consumer stops draining the Incus socket instead
// of growing a buffer here.
type Pty struct {
c *Client
opID string
conns map[string]*websocket.Conn
out chan []byte
done chan struct{}
once sync.Once
wg sync.WaitGroup // the read loops, so out is only closed once they stop
tail []byte // rolling window used to spot the exit marker
code int // exit status reported by the marker, -1 until then
mu sync.Mutex
dead bool
}
// Attach starts a login shell on a PTY inside the container.
func (c *Client) Attach(name string, size TermSize) (*Pty, error) {
size = size.normalized()
req := execReq{
Command: []string{"/bin/sh", "-c", exitMarkerCmd},
WaitForWebsocket: true,
Interactive: true,
Environment: map[string]string{
// A real TERM so the shell enables colours and line editing, and a
// UTF-8 locale so non-ASCII output survives.
"TERM": "xterm-256color",
"COLORTERM": "truecolor",
"LANG": "C.UTF-8",
"HOME": "/root",
},
User: 0,
Group: 0,
Width: size.Cols,
Height: size.Rows,
}
opID, conns, err := c.execOpen(context.Background(), name, req)
if err != nil {
return nil, err
}
if conns["0"] == nil {
for _, open := range conns {
_ = open.Close()
}
return nil, fmt.Errorf("incus attach %s: no stdin fd", name)
}
p := &Pty{c: c, opID: opID, conns: conns, out: make(chan []byte, 128), done: make(chan struct{}), code: -1}
// An interactive exec allocates a PTY, and the server then hands back only
// "0" (the PTY, used in BOTH directions) plus "control" — stdout and stderr
// are folded into that one channel. Reading every fd except the control
// channel covers that shape and the 0/1/2 shape alike.
for key, ws := range conns {
if key == "control" {
continue
}
p.wg.Add(1)
go p.readLoop(ws)
}
go p.drainControl()
go p.monitor()
return p, nil
}
// Output is the merged terminal stream. It is closed when the session ends.
func (p *Pty) Output() <-chan []byte { return p.out }
// readLoop forwards one fd to the output channel until it closes. On an
// interactive session this includes fd "0", which carries output as well as
// accepting input (gorilla allows one reader and one writer at a time). A read
// error is just how the session ends — the authoritative outcome is the exit
// status on the operation record, so it is not treated as a failure here.
func (p *Pty) readLoop(ws *websocket.Conn) {
defer p.wg.Done()
for {
_, msg, err := ws.ReadMessage()
if err != nil {
p.finish()
return
}
if len(msg) == 0 {
continue
}
p.scanExit(msg)
select {
case p.out <- msg:
case <-p.done:
return
}
}
}
// scanExit looks for the wrapper's exit marker in the stream. The marker can
// straddle two reads, so a small rolling window is kept. The bytes themselves
// are still forwarded: an OSC sequence is invisible to the terminal.
func (p *Pty) scanExit(msg []byte) {
p.mu.Lock()
p.tail = append(p.tail, msg...)
if len(p.tail) > 256 {
p.tail = p.tail[len(p.tail)-256:]
}
window := string(p.tail)
p.mu.Unlock()
i := strings.Index(window, exitMarker)
if i < 0 {
return
}
rest := window[i+len(exitMarker):]
if len(rest) == 0 {
return // the status digits have not arrived yet
}
end := strings.IndexByte(rest, 7) // BEL terminates the OSC
if end < 0 {
return
}
if n, err := strconv.Atoi(rest[:end]); err == nil {
p.mu.Lock()
p.code = n
p.mu.Unlock()
}
p.finish()
}
// drainControl consumes the control channel. It is client→server only, so
// anything arriving is discarded; it still has to be read, or the session can
// stall.
func (p *Pty) drainControl() {
ws := p.conns["control"]
if ws == nil {
return
}
for {
if _, _, err := ws.ReadMessage(); err != nil {
return
}
}
}
// finish tears the session down exactly once. The output channel is closed by
// a separate goroutine after every read loop has stopped: closing it here would
// race a read loop that is mid-send and panic on "send on closed channel".
func (p *Pty) finish() {
p.once.Do(func() {
p.mu.Lock()
p.dead = true
p.mu.Unlock()
close(p.done)
for _, ws := range p.conns {
_ = ws.Close()
}
go func() {
p.wg.Wait()
close(p.out)
}()
})
}
// monitor waits for the operation to stop running. An interactive session does
// not signal the end by closing its websocket when the shell exits (the client
// still holds the pty open), so the operation record is what tells us the shell
// is gone.
func (p *Pty) monitor() {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-p.done:
return
case <-t.C:
if !p.opRunning() {
p.finish()
return
}
}
}
}
// opRunning reports whether the exec operation is still in progress. A failed
// lookup is not evidence that the session ended, so it counts as still running:
// only a definitive non-Running status ends the session.
func (p *Pty) opRunning() bool {
var op struct {
Status string `json:"status"`
}
if err := p.c.get("/1.0/operations/"+p.opID, &op); err != nil {
return true
}
return op.Status == "Running"
}
// Write sends keystrokes to the PTY.
func (p *Pty) Write(b []byte) error {
p.mu.Lock()
defer p.mu.Unlock()
if p.dead {
return errors.New("terminal session is closed")
}
ws := p.conns["0"]
_ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait))
return ws.WriteMessage(websocket.BinaryMessage, b)
}
// Resize tells the PTY its new window size so full-screen programs reflow. The
// control channel is write-only for us; a failure is reported but need not end
// the session.
func (p *Pty) Resize(size TermSize) error {
size = size.normalized()
ws := p.conns["control"]
if ws == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
if p.dead {
return nil
}
msg, err := json.Marshal(execControl{
Command: "window-resize",
Args: map[string]string{
"width": strconv.Itoa(size.Cols),
"height": strconv.Itoa(size.Rows),
},
})
if err != nil {
return err
}
_ = ws.SetWriteDeadline(time.Now().Add(ptyWriteWait))
return ws.WriteMessage(websocket.TextMessage, msg)
}
// ExitCode reports the shell's exit status. It is read from the operation
// record, not from the control channel (which is client→server only), so it is
// only meaningful once the session has ended.
func (p *Pty) ExitCode() (int, error) {
p.mu.Lock()
if p.code >= 0 {
code := p.code
p.mu.Unlock()
return code, nil
}
p.mu.Unlock()
var op struct {
Metadata struct {
Return *int `json:"return"`
} `json:"metadata"`
}
if err := p.c.get("/1.0/operations/"+p.opID, &op); err != nil {
return -1, err
}
if op.Metadata.Return == nil {
return -1, nil
}
return *op.Metadata.Return, nil
}
// Done is closed when the session ends.
func (p *Pty) Done() <-chan struct{} { return p.done }
// Close ends the session and releases its fds.
func (p *Pty) Close() { p.finish() }
+102
View File
@@ -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)
}
}
+7 -57
View File
@@ -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"]
+20
View File
@@ -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)
}
+898
View File
@@ -0,0 +1,898 @@
/* vpsmgr browser terminal.
*
* A small VT100/xterm emulator plus the WebSocket session for /terminal. It is
* hand-written and dependency-free on purpose: the panel ships as one binary,
* has no build step, and its CSP allows no third-party scripts.
*
* Terminal output is arbitrary bytes from a shell, so nothing from the stream
* is ever interpreted as markup — every character goes through escapeText or
* textContent.
*/
(function () {
"use strict";
var PALETTE = [
"#1c1c1c", "#d75f5f", "#5faf5f", "#d7af5f", "#5f87d7", "#af5faf", "#5fafaf", "#c6c6c6",
"#6c6c6c", "#ff8787", "#87d787", "#ffd787", "#87afff", "#ff87ff", "#87ffff", "#ffffff"
];
var F_BOLD = 1, F_DIM = 2, F_ITALIC = 4, F_UNDER = 8, F_BLINK = 16,
F_REV = 32, F_HIDDEN = 64, F_STRIKE = 128;
function escapeText(s) {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function color256(n) {
if (n < 16) return PALETTE[n];
if (n < 232) {
n -= 16;
var f = function (v) { return v === 0 ? 0 : 55 + v * 40; };
return "rgb(" + f(Math.floor(n / 36)) + "," + f(Math.floor((n % 36) / 6)) + "," + f(n % 6) + ")";
}
var v = 8 + (n - 232) * 10;
return "rgb(" + v + "," + v + "," + v + ")";
}
function isWide(cp) {
return (cp >= 0x1100 && (
cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
(cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
(cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd)));
}
function blankCell() { return { ch: " ", fg: null, bg: null, fl: 0, cont: false }; }
function blankLine(cols) {
var cells = new Array(cols);
for (var i = 0; i < cols; i++) cells[i] = blankCell();
return { cells: cells };
}
function styleOf(c) {
var fg = c.fg, bg = c.bg, f = c.fl;
if (f & F_REV) { var t = fg; fg = bg === null ? "#0f172a" : bg; bg = t === null ? "#c6c6c6" : t; }
if (f & F_HIDDEN) fg = bg === null ? "#0f172a" : bg;
if (f & F_DIM) fg = fg === null ? "#6c6c6c" : fg;
var s = "";
if (fg) s += "color:" + fg + ";";
if (bg) s += "background:" + bg + ";";
if (f & F_BOLD) s += "font-weight:700;";
if (f & F_ITALIC) s += "font-style:italic;";
if (f & F_UNDER) s += "text-decoration:underline;";
if (f & F_STRIKE) s += "text-decoration:line-through;";
return s;
}
// ---- emulator ---------------------------------------------------------
function Term(mount, opts) {
this.mount = mount;
this.opts = opts || {};
this.cols = 80;
this.rows = 24;
this.sb = [];
this.maxSb = 2000;
this.vy = 0;
this.cx = 0;
this.cy = 0;
this.attr = { fg: null, bg: null, fl: 0 };
this.saved = null;
this.scrollTop = 0;
this.scrollBot = 23;
this.modes = { wrap: true, cursor: true, appCursor: false, bracketed: false, insert: false, alt: false };
this.altSaved = null;
this.state = "ground";
this.buf = "";
this.params = [];
this.priv = false;
this.osc = "";
this.dirty = new Set();
this.needFull = true;
this.lastCursorRow = -1;
this.decoder = new TextDecoder("utf-8");
this.disposed = false;
this.exited = false;
this.screen = [];
for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols));
this._buildDom();
this._measure();
this._onResize();
}
Term.prototype._buildDom = function () {
var self = this;
this.mount.classList.add("term");
this.rowsEl = document.createElement("div");
this.rowsEl.className = "term-rows";
this.mount.appendChild(this.rowsEl);
this.rowEls = [];
for (var i = 0; i < 24; i++) {
var d = document.createElement("div");
d.className = "term-row";
this.rowsEl.appendChild(d);
this.rowEls.push(d);
}
// A hidden textarea is what receives IME composition and any text the
// browser inserts without key events (mobile keyboards, autocomplete).
// Key events we handle ourselves are preventDefault'ed, so nothing lands
// here for ordinary typing and the two paths never double up.
this.input = document.createElement("textarea");
this.input.className = "term-input";
this.input.setAttribute("autocapitalize", "off");
this.input.setAttribute("autocorrect", "off");
this.input.setAttribute("autocomplete", "off");
this.input.setAttribute("spellcheck", "false");
this.input.setAttribute("aria-label", "terminal input");
this.mount.appendChild(this.input);
this.composing = false;
this.input.addEventListener("input", function () {
if (self.composing) return;
var v = self.input.value;
if (v) { self.input.value = ""; self._send(v); }
});
this.input.addEventListener("compositionstart", function () { self.composing = true; });
this.input.addEventListener("compositionend", function () {
self.composing = false;
var v = self.input.value;
if (v) { self.input.value = ""; self._send(v); }
});
this.mount.addEventListener("keydown", function (e) { self._onKey(e); });
// A browser can move focus off the page's own element (Escape is one way),
// and then every keystroke goes nowhere until the user clicks back in. This
// window sits on a page that IS the terminal, so take the key instead of
// making the user find the focus again.
window.addEventListener("keydown", function (e) {
if (self.disposed || self.exited) return;
if (self.mount.contains(document.activeElement)) return; // already ours
self.input.focus();
self._onKey(e);
});
this.mount.addEventListener("paste", function (e) { self._onPaste(e); });
this.mount.addEventListener("wheel", function (e) { self._onWheel(e); }, { passive: false });
// A click must leave focus on the hidden input, never on the mount itself:
// the mount is focusable so it can host the cursor, and the browser's own
// focus handling runs after mousedown, hence the deferred call.
this.mount.addEventListener("mousedown", function () {
setTimeout(function () { self.input.focus(); }, 0);
});
this.mount.addEventListener("focus", function () { self.input.focus(); });
this.mount.addEventListener("focus", function () { self.needFull = true; self._scheduleRender(); });
this.mount.addEventListener("blur", function () { self.needFull = true; self._scheduleRender(); });
this._ro = new ResizeObserver(function () { self._onResize(); });
this._ro.observe(this.mount);
};
Term.prototype._measure = function () {
var probe = document.createElement("span");
probe.className = "term-probe";
probe.textContent = new Array(101).join("W");
this.mount.appendChild(probe);
var r = probe.getBoundingClientRect();
this.cellW = r.width > 0 ? r.width / 100 : 8;
this.cellH = r.height > 0 ? r.height : 17;
this.mount.removeChild(probe);
};
Term.prototype._onResize = function () {
var st = getComputedStyle(this.mount);
var padX = parseFloat(st.paddingLeft) + parseFloat(st.paddingRight);
var padY = parseFloat(st.paddingTop) + parseFloat(st.paddingBottom);
var cols = Math.max(2, Math.floor((this.mount.clientWidth - padX) / this.cellW));
var rows = Math.max(2, Math.floor((this.mount.clientHeight - padY) / this.cellH));
if (cols === this.cols && rows === this.rows) return;
this.resize(cols, rows);
if (this.opts.onResize) this.opts.onResize(cols, rows);
};
Term.prototype.resize = function (cols, rows) {
var all = this.sb.concat(this.screen);
this.cols = cols;
this.rows = rows;
// Existing lines are truncated or padded rather than re-wrapped: good
// enough for a resize, and it never loses the line being worked on.
for (var i = 0; i < all.length; i++) {
var line = all[i];
if (line.cells.length > cols) line.cells.length = cols;
while (line.cells.length < cols) line.cells.push(blankCell());
}
while (all.length < rows) all.push(blankLine(cols));
this.screen = all.slice(all.length - rows);
this.sb = all.slice(0, all.length - rows);
if (this.sb.length > this.maxSb) this.sb = this.sb.slice(this.sb.length - this.maxSb);
while (this.rowEls.length < rows) {
var d = document.createElement("div");
d.className = "term-row";
this.rowsEl.appendChild(d);
this.rowEls.push(d);
}
for (var j = 0; j < this.rowEls.length; j++) {
this.rowEls[j].style.display = j < rows ? "" : "none";
}
this.scrollTop = 0;
this.scrollBot = rows - 1;
if (this.cy >= rows) this.cy = rows - 1;
if (this.cx >= cols) this.cx = cols - 1;
this.needFull = true;
this._scheduleRender();
};
Term.prototype.line = function (y) { return this.screen[y]; };
Term.prototype._scrollUp = function () {
this.sb.push(this.screen[this.scrollTop]);
if (this.sb.length > this.maxSb) this.sb.shift();
this.screen.splice(this.scrollTop, 1);
this.screen.push(blankLine(this.cols));
this.needFull = true;
};
Term.prototype._scrollDown = function () {
this.screen.splice(this.scrollBot, 1);
this.screen.splice(this.scrollTop, 0, blankLine(this.cols));
this.needFull = true;
};
Term.prototype._lf = function () {
if (this.cy === this.scrollBot) this._scrollUp();
else if (this.cy < this.rows - 1) this.cy++;
};
// ---- output -----------------------------------------------------------
Term.prototype.write = function (bytes) {
var s = this.decoder.decode(bytes, { stream: true });
if (!s) return;
this._parse(s);
this._scheduleRender();
};
Term.prototype._parse = function (s) {
for (var i = 0; i < s.length; i++) {
var ch = s[i], code = s.charCodeAt(i);
switch (this.state) {
case "ground":
if (code === 0x1b) { this.state = "esc"; break; }
if (code === 0x0d) { this.cx = 0; break; }
if (code === 0x0a || code === 0x0b || code === 0x0c) { this._lf(); break; }
if (code === 0x08) { if (this.cx > 0) this.cx--; break; }
if (code === 0x09) { this.cx = Math.min(this.cols - 1, (this.cx + 8) & ~7); break; }
if (code < 0x20) break; // BEL and the rest have no visual effect
if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { ch = s.substr(i, 2); i++; }
this._put(ch, ch.codePointAt(0));
break;
case "esc":
if (ch === "[") { this.state = "csi"; this.params = []; this.priv = false; this.buf = ""; }
else if (ch === "]") { this.state = "osc"; this.osc = ""; }
else if (ch === "7") { this.saved = { x: this.cx, y: this.cy, a: Object.assign({}, this.attr) }; this.state = "ground"; }
else if (ch === "8") {
if (this.saved) { this.cx = this.saved.x; this.cy = this.saved.y; this.attr = Object.assign({}, this.saved.a); }
this.state = "ground";
}
else if (ch === "D") { this._lf(); this.state = "ground"; }
else if (ch === "M") { if (this.cy === this.scrollTop) this._scrollDown(); else if (this.cy > 0) this.cy--; this.state = "ground"; }
else if (ch === "E") { this.cx = 0; this._lf(); this.state = "ground"; }
else if (ch === "c") { this._reset(); this.state = "ground"; }
else if (ch === "(" || ch === ")" || ch === "*" || ch === "+") { this.state = "charset"; }
else if (ch === "P" || ch === "_" || ch === "^" || ch === "X") { this.state = "dcs"; }
else this.state = "ground";
break;
case "charset":
this.state = "ground";
break;
case "dcs":
if (code === 0x07) this.state = "ground";
else if (code === 0x1b) this.state = "dcsEsc";
break;
case "dcsEsc":
this.state = ch === "\\" ? "ground" : "dcs";
break;
case "osc":
if (code === 0x07) { this._osc(); this.state = "ground"; }
else if (code === 0x1b) { this._osc(); this.state = "oscEsc"; }
else this.osc += ch;
break;
case "oscEsc":
this.state = ch === "\\" ? "ground" : "osc";
break;
case "csi":
if (code >= 0x40 && code <= 0x7e) { this._csi(ch); this.state = "ground"; }
else if (code === 0x3f || code === 0x3c || code === 0x3d || code === 0x3e) {
// The private/extension markers are ? < = >. All four have to be
// recognised: ignoring one lets its parameters leak into the final
// byte, which is how xterm's modifyOtherKeys (CSI > 4 ; 2 m) used to
// arrive as "underline on" and stay on for the rest of the session.
this.priv = true;
}
else if (code >= 0x30 && code <= 0x39) this.buf += ch;
else if (code === 0x3b) { this.params.push(parseInt(this.buf, 10)); this.buf = ""; }
break;
}
}
};
Term.prototype._osc = function () {
var m = /^(\d+);?(.*)$/.exec(this.osc);
if (!m) return;
var n = parseInt(m[1], 10);
// 777 is the session's own end-of-session marker; only the title is used.
if ((n === 0 || n === 2) && this.opts.onTitle) this.opts.onTitle(m[2]);
};
Term.prototype._params = function () {
var p = this.params.slice();
if (this.buf !== "") p.push(parseInt(this.buf, 10));
if (p.length === 0) p.push(0);
for (var i = 0; i < p.length; i++) if (isNaN(p[i])) p[i] = 0;
return p;
};
Term.prototype._csi = function (final) {
var p = this._params();
var n = function (i, d) { return p[i] === 0 ? d : p[i]; };
var i, c, k;
switch (final) {
case "A": this.cy = Math.max(this.scrollTop, this.cy - n(0, 1)); break;
case "B": this.cy = Math.min(this.scrollBot, this.cy + n(0, 1)); break;
case "C": this.cx = Math.min(this.cols - 1, this.cx + n(0, 1)); break;
case "D": this.cx = Math.max(0, this.cx - n(0, 1)); break;
case "E": this.cy = Math.min(this.rows - 1, this.cy + n(0, 1)); this.cx = 0; break;
case "F": this.cy = Math.max(0, this.cy - n(0, 1)); this.cx = 0; break;
case "G": this.cx = Math.min(this.cols - 1, n(0, 1) - 1); break;
case "d": this.cy = Math.min(this.rows - 1, n(0, 1) - 1); break;
case "H": case "f":
this.cy = Math.min(this.rows - 1, n(0, 1) - 1);
this.cx = Math.min(this.cols - 1, n(1, 1) - 1);
break;
case "J":
if (p[0] === 0) { this._eraseLine(this.cy, this.cx, this.cols - 1); for (i = this.cy + 1; i < this.rows; i++) this._eraseLine(i, 0, this.cols - 1); }
else if (p[0] === 1) { this._eraseLine(this.cy, 0, this.cx); for (i = 0; i < this.cy; i++) this._eraseLine(i, 0, this.cols - 1); }
else if (p[0] === 2 || p[0] === 3) { for (i = 0; i < this.rows; i++) this._eraseLine(i, 0, this.cols - 1); }
break;
case "K":
if (p[0] === 0) this._eraseLine(this.cy, this.cx, this.cols - 1);
else if (p[0] === 1) this._eraseLine(this.cy, 0, this.cx);
else if (p[0] === 2) this._eraseLine(this.cy, 0, this.cols - 1);
break;
case "L":
for (i = 0; i < n(0, 1); i++) this.screen.splice(this.scrollBot, 0, blankLine(this.cols));
this.screen.length = this.rows;
this.needFull = true;
break;
case "M":
for (i = 0; i < n(0, 1); i++) { this.screen.splice(this.cy, 1); this.screen.splice(this.scrollBot, 0, blankLine(this.cols)); }
this.needFull = true;
break;
case "P":
c = this.line(this.cy);
for (i = 0; i < n(0, 1); i++) { c.cells.splice(this.cx, 1); c.cells.push(blankCell()); }
this.dirty.add(this.cy);
break;
case "@":
c = this.line(this.cy);
for (i = 0; i < n(0, 1); i++) { c.cells.splice(this.cx, 0, blankCell()); c.cells.length = this.cols; }
this.dirty.add(this.cy);
break;
case "X":
this._eraseLine(this.cy, this.cx, Math.min(this.cols - 1, this.cx + n(0, 1) - 1));
break;
case "S": for (i = 0; i < n(0, 1); i++) this._scrollUp(); break;
case "T": for (i = 0; i < n(0, 1); i++) this._scrollDown(); break;
case "r":
this.scrollTop = Math.max(0, n(0, 1) - 1);
this.scrollBot = Math.min(this.rows - 1, n(1, this.rows) - 1);
if (this.scrollBot <= this.scrollTop) { this.scrollTop = 0; this.scrollBot = this.rows - 1; }
this.cx = 0; this.cy = this.scrollTop;
break;
case "m": if (!this.priv) this._sgr(p); break;
case "h": this._mode(p, true); break;
case "l": this._mode(p, false); break;
case "n":
if (p[0] === 6 && this.opts.onReply) this.opts.onReply("\x1b[" + (this.cy + 1) + ";" + (this.cx + 1) + "R");
break;
case "c":
// A private ">" query asks for the secondary attributes; answering with
// the primary form (or not at all) makes a program wait for its timeout.
if (this.opts.onReply) this.opts.onReply(this.priv ? "\x1b[>0;0;0c" : "\x1b[?1;2c");
break;
}
this._scheduleRender();
};
Term.prototype._eraseLine = function (y, from, to) {
var line = this.line(y);
if (!line) return;
for (var i = Math.max(0, from); i <= to && i < this.cols; i++) {
line.cells[i] = blankCell();
// Erasing must not leave the trailing half of a wide character behind.
if (i > 0 && line.cells[i - 1].ch) {
var prev = line.cells[i - 1];
if (!prev.cont && isWide(prev.ch.codePointAt(0))) line.cells[i - 1] = blankCell();
}
}
this.dirty.add(y);
};
Term.prototype._sgr = function (p) {
if (p.length === 0) p = [0];
for (var i = 0; i < p.length; i++) {
var v = p[i];
if (v === 0) this.attr = { fg: null, bg: null, fl: 0 };
else if (v === 1) this.attr.fl |= F_BOLD;
else if (v === 2) this.attr.fl |= F_DIM;
else if (v === 3) this.attr.fl |= F_ITALIC;
else if (v === 4) this.attr.fl |= F_UNDER;
else if (v === 5) this.attr.fl |= F_BLINK;
else if (v === 7) this.attr.fl |= F_REV;
else if (v === 8) this.attr.fl |= F_HIDDEN;
else if (v === 9) this.attr.fl |= F_STRIKE;
else if (v === 22) this.attr.fl &= ~(F_BOLD | F_DIM);
else if (v === 23) this.attr.fl &= ~F_ITALIC;
else if (v === 24) this.attr.fl &= ~F_UNDER;
else if (v === 25) this.attr.fl &= ~F_BLINK;
else if (v === 27) this.attr.fl &= ~F_REV;
else if (v === 28) this.attr.fl &= ~F_HIDDEN;
else if (v === 29) this.attr.fl &= ~F_STRIKE;
else if (v >= 30 && v <= 37) this.attr.fg = PALETTE[v - 30];
else if (v === 39) this.attr.fg = null;
else if (v >= 40 && v <= 47) this.attr.bg = PALETTE[v - 40];
else if (v === 49) this.attr.bg = null;
else if (v >= 90 && v <= 97) this.attr.fg = PALETTE[v - 82];
else if (v >= 100 && v <= 107) this.attr.bg = PALETTE[v - 92];
else if (v === 38 || v === 48) {
var t = v === 38 ? "fg" : "bg";
if (p[i + 1] === 5) { this.attr[t] = color256(p[i + 2]); i += 2; }
else if (p[i + 1] === 2) {
this.attr[t] = "rgb(" + p[i + 2] + "," + p[i + 3] + "," + p[i + 4] + ")";
i += 4;
}
}
}
};
Term.prototype._mode = function (p, on) {
for (var i = 0; i < p.length; i++) {
var v = p[i];
if (!this.priv) { if (v === 4) this.modes.insert = on; continue; }
if (v === 25) this.modes.cursor = on;
else if (v === 7) this.modes.wrap = on;
else if (v === 1) this.modes.appCursor = on;
else if (v === 2004) this.modes.bracketed = on;
else if (v === 47 || v === 1047 || v === 1049) {
if (on && !this.modes.alt) {
this.altSaved = { screen: this.screen, cx: this.cx, cy: this.cy };
this.screen = [];
for (var k = 0; k < this.rows; k++) this.screen.push(blankLine(this.cols));
this.cx = 0; this.cy = 0; this.vy = 0;
this.modes.alt = true;
this.needFull = true;
} else if (!on && this.modes.alt && this.altSaved) {
this.screen = this.altSaved.screen;
this.cx = this.altSaved.cx;
this.cy = this.altSaved.cy;
this.modes.alt = false;
this.needFull = true;
}
}
}
};
Term.prototype._put = function (ch, cp) {
var wide = isWide(cp);
if (this.cx >= this.cols) {
if (!this.modes.wrap) this.cx = this.cols - 1;
else { this.cx = 0; this._lf(); }
}
if (wide && this.cx === this.cols - 1) { this.cx = 0; this._lf(); }
var line = this.line(this.cy);
if (this.modes.insert) { line.cells.splice(this.cx, 0, blankCell()); line.cells.length = this.cols; }
line.cells[this.cx] = { ch: ch, fg: this.attr.fg, bg: this.attr.bg, fl: this.attr.fl, cont: false };
this.cx++;
if (wide && this.cx < this.cols) {
line.cells[this.cx] = { ch: "", fg: this.attr.fg, bg: this.attr.bg, fl: this.attr.fl, cont: true };
this.cx++;
}
this.dirty.add(this.cy);
};
Term.prototype._reset = function () {
this.attr = { fg: null, bg: null, fl: 0 };
this.modes.alt = false;
this.screen = [];
for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols));
this.cx = 0; this.cy = 0; this.vy = 0;
this.scrollTop = 0; this.scrollBot = this.rows - 1;
this.needFull = true;
};
// ---- rendering --------------------------------------------------------
Term.prototype._scheduleRender = function () {
if (this._raf || this.disposed) return;
var self = this;
this._raf = requestAnimationFrame(function () { self._raf = 0; self._render(); });
};
Term.prototype._render = function () {
if (this.disposed) return;
var total = this.sb.length + this.screen.length;
var maxVy = Math.max(0, total - this.rows);
if (this.vy > maxVy) this.vy = maxVy;
var start = total - this.rows - this.vy;
var focused = this.mount.contains(document.activeElement);
// The alternate screen is where the cursor matters most: vi, less and htop
// all run there, and an editor with no cursor tells you nothing about where
// you are typing.
var cursorRow = this.vy === 0 ? this.cy : -1;
for (var y = 0; y < this.rows; y++) {
var idx = start + y;
var line = idx < this.sb.length ? this.sb[idx] : this.screen[idx - this.sb.length];
var live = (y === cursorRow);
// Repaint only what changed, plus the two rows the cursor can have moved
// between.
if (!this.needFull && !this.dirty.has(y) && !live && y !== this.lastCursorRow) continue;
this.rowEls[y].innerHTML = this._rowHtml(line, live, focused);
}
this.dirty.clear();
this.needFull = false;
this.lastCursorRow = cursorRow;
};
Term.prototype._rowHtml = function (line, live, focused) {
if (!line) return "";
var html = "", style = null, buf = "";
var flush = function () {
if (!buf) return;
html += style ? '<span style="' + style + '">' + escapeText(buf) + "</span>" : 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 += '<span style="background:#38bdf8;color:#0f172a;"> </span>';
}
return html;
};
// ---- input ------------------------------------------------------------
Term.prototype._send = function (s) { if (this.opts.onInput) this.opts.onInput(s); };
Term.prototype.focus = function () { this.input.focus(); };
Term.prototype._scrollBy = function (lines) {
var total = this.sb.length + this.screen.length;
var max = Math.max(0, total - this.rows);
this.vy = Math.min(max, Math.max(0, this.vy + lines));
this.needFull = true;
this._scheduleRender();
};
Term.prototype._onWheel = function (e) {
if (this.modes.alt) return; // full-screen apps scroll themselves
var step = e.deltaMode === 1 ? 3 : 1;
var d = (e.deltaY > 0 ? -1 : 1) * step;
if (this.vy === 0 && d < 0) return; // already at the bottom
this._scrollBy(d);
e.preventDefault();
};
Term.prototype._onPaste = function (e) {
var text = (e.clipboardData || window.clipboardData).getData("text");
if (!text) return;
e.preventDefault();
if (this.modes.bracketed) this._send("\x1b[200~" + text + "\x1b[201~");
else this._send(text);
};
// keySequence is what a named key sends. The arrows depend on the mode the
// running program asked for (application cursor keys), which is exactly why
// the on-screen bar cannot hard-code them.
Term.prototype.keySequence = function (name) {
var m = this.modes;
var arrows = { ArrowUp: "A", ArrowDown: "B", ArrowRight: "C", ArrowLeft: "D", Home: "H", End: "F" };
if (arrows[name]) return m.appCursor ? "\x1bO" + arrows[name] : "\x1b[" + arrows[name];
switch (name) {
case "Enter": return "\r";
case "Backspace": return "\x7f";
case "Tab": return "\t";
case "Escape": return "\x1b";
case "PageUp": return "\x1b[5~";
case "PageDown": return "\x1b[6~";
case "Insert": return "\x1b[2~";
case "Delete": return "\x1b[3~";
}
if (/^F([1-9]|1[0-2])$/.test(name)) {
var f = parseInt(name.slice(1), 10);
var ss3 = { 1: "P", 2: "Q", 3: "R", 4: "S" };
return ss3[f] ? "\x1bO" + ss3[f] : "\x1b[" + [15, 17, 18, 19, 20, 21, 23, 24][f - 5] + "~";
}
return null;
};
Term.prototype._onKey = function (e) {
if (this.exited) return;
// The IME owns a keystroke while a composition is running. Which that is
// has to come from our own compositionstart/end tracking rather than from
// e.isComposing alone: a browser can leave that flag set after an IME
// candidate is cancelled (Escape), and then every later key is dropped
// until the user clicks somewhere else. Same for a key the IME is
// consuming, which browsers report as "Process".
if (this.composing || e.key === "Process" || e.key === "Unidentified") return;
var m = this.modes;
var send = null;
if (e.shiftKey && (e.key === "PageUp" || e.key === "PageDown")) {
this._scrollBy(e.key === "PageUp" ? this.rows : -this.rows);
e.preventDefault();
return;
}
// Shortcuts the platform uses for copy and paste are left alone, so the
// browser fires the paste event we actually want. Ctrl+V and Shift+Insert
// paste on Windows and X11, Cmd+V on macOS, Ctrl+Shift+Insert on X11.
if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === "v" || e.key === "V")) return;
if (e.shiftKey && e.key === "Insert") return;
if (e.ctrlKey && e.shiftKey && (e.key === "C" || e.key === "V")) return;
// Ctrl+C interrupts, unless the user is copying a selection.
if (e.ctrlKey && !e.altKey && e.key.toLowerCase() === "c" && String(window.getSelection())) return;
send = this.keySequence(e.key);
if (send === null) {
if (e.ctrlKey && !e.altKey && e.key.length === 1) {
// The physical key is what a terminal wants: on a layout where Ctrl+A
// composes into something else, e.key is that something else.
var ck = /^Key([A-Z])$/.exec(e.code || "");
if (ck) send = String.fromCharCode(ck[1].charCodeAt(0) - 64);
else if (e.key === " ") send = "\x00";
else {
// Non-letters keep the plain ASCII mapping, which is what makes
// Ctrl+[ an Escape and Ctrl+6 Ctrl+^.
var cc = e.key.toUpperCase().charCodeAt(0);
if (cc >= 64 && cc < 128) send = String.fromCharCode(cc & 0x1f);
}
} else if (e.altKey && !e.metaKey) {
// Again the physical key: on macOS Option+a arrives as "å", so sending
// e.key would put that in the shell instead of Meta-a.
var ak = /^Key([A-Z])$/.exec(e.code || "");
if (ak) send = "\x1b" + ak[1].toLowerCase();
else if (e.key.length === 1) send = "\x1b" + e.key;
} else if (!e.ctrlKey && !e.metaKey && e.key.length === 1) {
send = e.key;
}
}
if (send === null) {
// Swallow named keys we do not map so they cannot scroll the page.
if (e.key.length > 1 && !/^(Shift|Control|Alt|Meta|CapsLock|NumLock|ScrollLock|Dead|Fn|ContextMenu|OS)$/.test(e.key)) {
e.preventDefault();
}
return;
}
e.preventDefault();
if (this.vy !== 0) this._scrollBy(-this.vy);
this._send(send);
};
window.VpsmgrTerm = Term;
// ---- session ----------------------------------------------------------
// The terminal is a connection to a shell on the far side of the internet, so
// the session is built to survive the link, not the other way round:
//
// - the shell outlives the socket (the server keeps it for a grace period
// and buffers what it prints), so a reconnect resumes the same shell
// rather than starting a new one. The token that claims it lives in
// sessionStorage, so reloading the page comes back to it and closing the
// window lets it go.
// - a browser cannot see protocol-level pings, so the server also sends a
// small JSON heartbeat; hearing nothing for a while means the socket is
// dead even though it has not said so.
// - a dropped connection is retried with exponential backoff and jitter,
// and giving up is a state the page can offer a manual reconnect from.
var TOKEN_KEY = "vpsmgr_ssh_token";
var HEARTBEAT_MS = 5000; // how often we check for silence
var SILENCE_MS = 45000; // no message for this long means it is gone
var MAX_ATTEMPTS = 8;
// A handshake that neither completes nor fails would otherwise stall the
// retry loop for good — exactly the shape a flaky link produces.
var CONNECT_TIMEOUT_MS = 15000;
window.vpsmgrOpenTerminal = function (mount, url, hooks) {
hooks = hooks || {};
var msg = hooks.msg || {};
var status = function (t) { if (hooks.onStatus) hooks.onStatus(t); };
var setOthers = function (n) { if (hooks.onOthers) hooks.onOthers(n); };
var askBusy = function (n, decide) { if (hooks.onBusy) hooks.onBusy(n, decide); };
var Term = window.VpsmgrTerm;
var term = null, ws = null, hb = null, retryT = null, retryAt = 0, connectT = null;
var attempt = 0, lastSeen = 0, finished = false;
function token() {
try { return sessionStorage.getItem(TOKEN_KEY) || ""; } catch (e) { return ""; }
}
function setToken(v) {
try { if (v) sessionStorage.setItem(TOKEN_KEY, v); else sessionStorage.removeItem(TOKEN_KEY); } catch (e) {}
}
function send(obj) {
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(obj));
}
function input(data) { send({ t: "input", data: data }); }
function build() {
mount.innerHTML = "";
term = new Term(mount, {
onTitle: function (t) { if (hooks.onTitle) hooks.onTitle(t); },
onReply: input,
onInput: input,
onResize: function (c, r) { send({ t: "resize", cols: c, rows: r }); }
});
}
function stopHeartbeat() { if (hb) { clearInterval(hb); hb = null; } }
function stopConnectTimer() { if (connectT) { clearTimeout(connectT); connectT = null; } }
function startHeartbeat() {
stopHeartbeat();
hb = setInterval(function () {
if (finished) return;
if (Date.now() - lastSeen > SILENCE_MS) {
// Silent for too long: treat it as gone and let the retry logic run.
try { ws.close(); } catch (e) { /* already gone */ }
lost();
}
}, HEARTBEAT_MS);
}
function connect() {
if (finished) return;
retryT = null;
stopHeartbeat();
// A resumed terminal starts blank and is rebuilt from what the server
// replays, so the emulator is recreated rather than left showing stale
// output from before the drop.
build();
status(attempt === 0 ? (msg.connecting || "connecting…")
: (msg.reconnecting || "reconnecting…") + " (" + attempt + "/" + MAX_ATTEMPTS + ")");
var proto = location.protocol === "https:" ? "wss:" : "ws:";
ws = new WebSocket(proto + "//" + location.host + url);
ws.binaryType = "arraybuffer";
var opened = false;
ws.onopen = function () {
opened = true;
stopConnectTimer();
lastSeen = Date.now();
send({ t: "hello", token: token(), cols: term.cols, rows: term.rows });
startHeartbeat();
};
connectT = setTimeout(function () {
if (opened) return;
try { ws.close(); } catch (e) { /* not open anyway */ }
lost();
}, CONNECT_TIMEOUT_MS);
ws.onmessage = onMessage;
ws.onclose = lost;
ws.onerror = function () { /* onclose follows */ };
}
function onMessage(e) {
lastSeen = Date.now();
if (typeof e.data !== "string") {
if (term) term.write(new Uint8Array(e.data));
return;
}
var m;
try { m = JSON.parse(e.data); } catch (err) { return; }
switch (m.t) {
case "ping":
return;
case "ready":
attempt = 0;
if (hooks.onReady) hooks.onReady();
setToken(m.token);
setOthers(m.others || 0);
status(msg.connected || "");
if (term) term.focus();
return;
case "others":
setOthers(m.others || 0);
return;
case "busy":
status(msg.busy || "another terminal is open");
askBusy(m.others || 0, function (take) {
if (!take) { close(); return; }
send({ t: "takeover" });
status(msg.connecting || "connecting…");
});
return;
case "exit":
finish((msg.ended || "session ended") + (m.code ? " · exit " + m.code : ""));
if (hooks.onExit) hooks.onExit(m.code);
return;
case "error":
if (term) term.write(new TextEncoder().encode("\r\n[ " + m.error + " ]\r\n"));
finish(m.error);
if (hooks.onError) hooks.onError(m.error);
return;
}
}
// finish stops for good: the shell is over, so retrying would only start a
// different one behind the user's back.
function finish(text) {
finished = true;
stopHeartbeat();
stopConnectTimer();
setToken("");
status(text);
}
// lost handles a connection that went away on its own.
function lost() {
stopHeartbeat();
stopConnectTimer();
if (finished || retryT) return;
attempt++;
if (attempt > MAX_ATTEMPTS) {
finished = true;
status(msg.disconnected || "disconnected");
if (hooks.onClose) hooks.onClose();
return;
}
// Exponential backoff with jitter, so a rack of tabs does not come back
// in lockstep.
var delay = Math.min(400 * Math.pow(2, attempt - 1), 8000) + Math.random() * 400;
retryAt = Date.now() + delay;
status((msg.reconnecting || "reconnecting…") + " (" + attempt + "/" + MAX_ATTEMPTS + ")");
retryT = setTimeout(connect, delay);
}
function close() {
finished = true;
stopHeartbeat();
stopConnectTimer();
if (retryT) { clearTimeout(retryT); retryT = null; }
setToken("");
try { ws.close(); } catch (e) { /* already gone */ }
}
connect();
return {
focus: function () { if (term) term.focus(); },
close: close,
// closeOthers ends the user's other shells and keeps this one.
closeOthers: function () { send({ t: "takeover" }); },
// key and raw are what the on-screen key bar uses: the bar is a fallback
// for keys a browser will not hand over, not the primary way to type.
key: function (name) {
if (!term) return;
var seq = term.keySequence(name);
if (seq !== null) { input(seq); term.focus(); }
},
raw: function (text) { input(text); if (term) term.focus(); },
// reconnect is the manual path offered once the retries are exhausted.
reconnect: function () {
finished = false;
attempt = 0;
if (retryT) { clearTimeout(retryT); retryT = null; }
connect();
},
retryInMs: function () { return retryT ? Math.max(0, retryAt - Date.now()) : 0; }
};
};
})();
+17 -2
View File
@@ -91,10 +91,16 @@ type Server struct {
mgr *mgr.Manager
limiter *loginLimiter
flash *flashStore
terms *termRegistry
}
func New(c *cfg.Config, d *db.DB, m *mgr.Manager) *Server {
return &Server{cfg: c, db: d, mgr: m, limiter: newLoginLimiter(), flash: newFlashStore()}
return &Server{
cfg: c, db: d, mgr: m,
limiter: newLoginLimiter(),
flash: newFlashStore(),
terms: newTermRegistry(),
}
}
func (s *Server) templates() (*template.Template, error) {
@@ -219,7 +225,10 @@ type pageData struct {
// impersonating admin can tell users apart. Users cannot set it themselves.
ThemeColor string
ShowFooter bool
Version string
// WebSSH mirrors cfg.Panel.WebSSH: the overview renders the Web SSH button
// only when the terminal is enabled.
WebSSH bool
Version string
}
func (s *Server) Handler() http.Handler {
@@ -239,6 +248,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/domain-del", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainDel))))
mux.HandleFunc("/domain-update", s.requireAuth(s.requireActive(s.requirePost(s.handleDomainUpdate))))
mux.HandleFunc("/init-script", s.requireAuth(s.requireActive(s.requirePost(s.handleInitScript))))
// The terminal is a WebSocket upgrade (a GET), so it checks expiry itself
// rather than going through requireActive's redirect.
mux.HandleFunc("/webssh", s.requireAuth(s.handleWebSSH))
mux.HandleFunc("/terminal", s.requireAuth(s.handleTerminal))
mux.HandleFunc(termAssetPath, s.requireAuth(s.handleTermAsset))
mux.HandleFunc("/stats", s.requireAuth(s.handleStats))
mux.HandleFunc("/images", s.requireAuth(s.requirePost(s.handleImages)))
mux.HandleFunc("/snapshot", s.requireAuth(s.requireActive(s.requirePost(s.handleSnapshot))))
@@ -397,6 +411,7 @@ func (s *Server) buildData(u *db.User, msg, errMsg string) pageData {
V4Forward: s.mgr.V4ForwardLive(),
ProxyEnabled: s.mgr.HaproxyLive(),
ShowFooter: s.cfg.Panel.ShowFooter,
WebSSH: s.cfg.Panel.WebSSH,
Version: ver.Version,
InitScript: u.InitScript,
MaxNotesPlaintext: cfg.MaxNotesPlaintextBytes,
+6 -1
View File
@@ -38,6 +38,11 @@ func useTempProxyLayout(t *testing.T) {
func newTestServer(t *testing.T) (*Server, *db.DB) {
t.Helper()
c := cfg.Default()
// Point the Incus client at a socket that cannot exist, so a test never
// reaches a real container. Several tests drive handlers that would
// otherwise power-cycle or delete whatever instance happens to share a
// fixture's name on the machine running the suite.
c.Incus.Socket = "/nonexistent/vpsmgr-test.sock"
c.Panel.URLPath = testSecret
c.Panel.PublicIP = "127.0.0.1"
c.Panel.SessionDays = 3
@@ -2062,7 +2067,7 @@ func TestOverviewKnowledgeModal(t *testing.T) {
Prefix: "/" + testSecret,
Knowledge: []knowledgeArticle{{
ID: 7, Title: "Getting started", UpdatedAt: "2026-08-01T10:00:00Z",
HTML: template.HTML("<h1>Getting started</h1><pre><code>ls -la</code></pre>"),
HTML: template.HTML("<h1>Getting started</h1><pre><code>ls -la</code></pre>"),
Source: "REGINNAME=*#*#region#*#*",
}},
})
@@ -60,6 +60,11 @@ code.copyable.copied{outline:1px solid var(--ok);outline-offset:2px;color:var(--
.warn{background:var(--warn);color:#fff;border-color:var(--warn)}
.danger{background:var(--danger);color:#fff;border-color:var(--danger)}
.violet{background:var(--violet-bg);color:var(--violet-fg);border-color:var(--violet-border)}
/* The Web SSH button deliberately breaks from the panel's palette: a black key
with green type is what a remote shell looks like everywhere else. */
.ssh{background:#04120a;color:#22c55e;border-color:#15803d;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.02em}
.ssh:hover{filter:none;background:#062012;box-shadow:0 0 0 3px rgba(34,197,94,.2)}
.btnrow{display:flex;flex-wrap:wrap;gap:.45rem;margin-bottom:.55rem}
.btnrow:last-child{margin-bottom:0}
form.inline{display:inline}
@@ -327,6 +332,7 @@ footer a{color:var(--muted);text-decoration:underline}
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="start"><button class="btn ok">{{if eq .Lang "zh"}}开机{{else}}Start{{end}}</button></form>
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="stop"><button class="btn warn">{{if eq .Lang "zh"}}关机{{else}}Stop{{end}}</button></form>
<form action="{{.Prefix}}/power" method="post" class="inline"><input type="hidden" name="action" value="restart"><button class="btn">{{if eq .Lang "zh"}}重启{{else}}Restart{{end}}</button></form>
{{if .WebSSH}}<button class="btn ssh" type="button" id="sshBtn" title="{{if eq .Lang "zh"}}在独立窗口里打开一个 shell{{else}}Open a shell in its own window{{end}}">Web SSH</button>{{end}}
</div>
<div class="btnrow">
<form action="{{.Prefix}}/root-reset" method="post" class="inline"><button class="btn accent" id="resetBtn">{{if eq .Lang "zh"}}重置 root 密码{{else}}Reset root password{{end}}</button></form>
@@ -2112,6 +2118,27 @@ apt-get update && apt-get install -y nginx{{end}}">{{.InitScript}}</textarea>
});
}).catch(function(){showEnable();});
})();
// Web SSH: opens the shell in its own browser window. A window can be dragged
// to any size, moved between screens and maximised; a modal cannot, and the
// terminal is exactly the thing that wants more room.
(function(){
var btn=document.getElementById('sshBtn');
if(!btn)return;
var LANG='{{.Lang}}';
var P='{{.Prefix}}';
btn.addEventListener('click',function(){
var size=[1040,660];
try{
var v=JSON.parse(localStorage.getItem('vpsmgr_ssh_size'));
if(v&&v[0]>=420&&v[1]>=320)size=v; // the size the user last dragged it to
}catch(e){}
var feat='width='+size[0]+',height='+size[1]+
',menubar=no,toolbar=no,location=no,status=no,scrollbars=no,resizable=yes';
var w=window.open(P+'/webssh','vpsmgr_ssh',feat);
if(w){ w.focus(); return; }
toast(LANG==='zh'?'弹窗被浏览器拦截,请允许本站点弹出窗口':'popup blocked — allow popups for this site',true);
});
})();
</script>
</body>
</html>
+199
View File
@@ -0,0 +1,199 @@
<!DOCTYPE html>
<html lang="{{if eq .Lang "zh"}}zh{{else}}en{{end}}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{{.Title}}</title>
<style>
/* A standalone window, not a modal: the whole point is that the user can drag
it to any size (or maximise it) and the terminal follows. */
:root{color-scheme:dark}
*{box-sizing:border-box}
html,body{height:100%;margin:0}
body{background:#0b1220;color:#e2e8f0;display:flex;flex-direction:column;
font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Noto Sans SC",sans-serif}
header{display:flex;align-items:center;gap:.6rem;padding:.4rem .7rem;background:#0f172a;
border-bottom:1px solid #1e293b;font-size:.78rem;flex:none}
header .host{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;color:#22c55e}
header .host::before{content:">_ ";opacity:.7}
header .status{color:#7c8aa0}
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}
/* The keys a browser keeps for itself, as buttons. Deliberately small: the
point is that typing works, this is only for the handful that cannot. */
.keys{display:flex;align-items:center;gap:.25rem;padding:.25rem .6rem;background:#0d1626;
border-bottom:1px solid #1e293b;flex:none}
.keys .sep{width:1px;height:1.1rem;background:#1e3a5f;margin:0 .25rem}
.keys button{background:#12203a;color:#9fb3c8;border:1px solid #1e3a5f;border-radius:5px;
padding:.1rem .45rem;font-size:.74rem;cursor:pointer;font-family:ui-monospace,
SFMono-Regular,Menlo,Consolas,monospace;min-width:2rem;line-height:1.5}
.keys button:hover{background:#18294a;color:#e2e8f0}
.keys button:active{background:#15803d;border-color:#15803d;color:#fff}
main>div{height:100%}
.term{height:100%;outline:none;padding:.5rem .6rem;background:#0f172a;overflow:hidden;cursor:text;
font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"DejaVu Sans Mono",monospace;
font-size:13px;line-height:1.35;color:#e2e8f0}
.term-rows{white-space:pre;user-select:text}
.term-row{white-space:pre;height:1.35em;overflow:hidden}
.term-probe{position:absolute;visibility:hidden;white-space:pre;line-height:1.35}
.term-input{position:absolute;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;
resize:none;overflow:hidden}
.ask{position:fixed;inset:0;background:rgba(2,6,16,.72);display:flex;align-items:center;
justify-content:center;z-index:10}
/* display:flex above would otherwise beat the hidden attribute, leaving an
invisible full-screen sheet swallowing every click. */
.ask[hidden]{display:none}
.ask-box{background:#0f172a;border:1px solid #1e3a5f;border-radius:10px;padding:1.1rem 1.2rem;
max-width:26rem;font-size:.85rem;line-height:1.5}
.ask-actions{display:flex;gap:.5rem;justify-content:flex-end;margin-top:.9rem}
.ask-actions button{background:#12203a;color:#cbd5e1;border:1px solid #1e3a5f;border-radius:6px;
padding:.35rem .8rem;font-size:.8rem;cursor:pointer;font-family:inherit}
.ask-actions button.go{background:#15803d;border-color:#15803d;color:#fff}
.ask-actions button:hover{filter:brightness(1.15)}
</style>
</head>
<body>
<header>
<span class="host">{{.User.Name}}</span>
<span class="status" id="status">{{if eq .Lang "zh"}}连接中…{{else}}connecting…{{end}}</span>
<span class="others" id="others" hidden></span>
<span class="spacer"></span>
<button type="button" id="fsBtn">{{if eq .Lang "zh"}}全屏{{else}}Fullscreen{{end}}</button>
<button type="button" id="reBtn">{{if eq .Lang "zh"}}重连{{else}}Reconnect{{end}}</button>
</header>
<div class="keys" id="keys">
<button type="button" data-key="Escape">Esc</button>
<button type="button" data-key="Tab">Tab</button>
<span class="sep"></span>
<button type="button" data-key="ArrowLeft">←</button>
<button type="button" data-key="ArrowUp">↑</button>
<button type="button" data-key="ArrowDown">↓</button>
<button type="button" data-key="ArrowRight">→</button>
<span class="sep"></span>
<button type="button" data-ctl="c">^C</button>
<button type="button" data-ctl="z">^Z</button>
<button type="button" data-ctl="d">^D</button>
</div>
<main><div id="termMount" tabindex="0"></div></main>
<div class="ask" id="ask" hidden>
<div class="ask-box">
<p id="askText"></p>
<div class="ask-actions">
<button type="button" id="askCancel">{{if eq .Lang "zh"}}取消{{else}}Cancel{{end}}</button>
<button type="button" id="askTake" class="go">{{if eq .Lang "zh"}}关闭其他终端并继续{{else}}Close the others and continue{{end}}</button>
</div>
</div>
</div>
<script src="{{.Prefix}}/assets/term.js"></script>
<script>
(function(){
var LANG='{{.Lang}}';
var P='{{.Prefix}}';
var mount=document.getElementById('termMount');
var status=document.getElementById('status');
var othersEl=document.getElementById('others');
var ask=document.getElementById('ask');
var askText=document.getElementById('askText');
var session=null;
var pendingDecide=null;
var T=function(zh,en){ return LANG==='zh'?zh:en; };
function setStatus(t){ status.textContent=t; }
// The notice stays visible while other windows hold shells, and offers the
// one action that matters: take their slots back.
function setOthers(n){
if(!othersEl)return;
if(n<=0){ othersEl.hidden=true; othersEl.textContent=''; return; }
othersEl.hidden=false;
othersEl.textContent=T('另有 '+n+' 个终端在运行',' +'+n+' other terminal(s)');
var b=document.createElement('button');
b.type='button';
b.textContent=T('全部关闭','Close them');
b.addEventListener('click',function(){ if(session)session.closeOthers(); });
othersEl.appendChild(b);
}
function start(){
if(session){ try{session.close();}catch(e){} session=null; }
ask.hidden=true;
setStatus(T('连接中…','connecting…'));
session=window.vpsmgrOpenTerminal(mount, P+'/terminal', {
msg:{
connecting:T('连接中…','connecting…'),
connected:T('已连接','connected'),
reconnecting:T('正在重新连接','reconnecting'),
ended:T('会话已结束','session ended'),
disconnected:T('已断开,点「重连」再试','disconnected — press Reconnect'),
busy:T('已有其他终端在运行','another terminal is already open')
},
onStatus:setStatus,
onOthers:setOthers,
onReady:function(){ ask.hidden=true; pendingDecide=null; },
onBusy:function(n,decide){
askText.textContent=T(
'你的账号下还有 '+n+' 个终端在运行(可能是关掉的窗口,或刚断线还在等待恢复的会话)。要关闭它们并在这里继续吗?',
'Your account still has '+n+' other terminal(s) open — a closed window, or a session waiting to be resumed. Close them and continue here?');
ask.hidden=false;
pendingDecide=decide;
},
onExit:function(code){ setStatus(T('会话已结束','session ended')+(code?(' · exit '+code):'')); },
onClose:function(){ setStatus(T('已断开,点「重连」再试','disconnected — press Reconnect')); }
});
}
function answerAsk(take){
var d=pendingDecide;
pendingDecide=null;
ask.hidden=true;
if(d)d(take);
}
document.getElementById('askTake').addEventListener('click',function(){ answerAsk(true); });
document.getElementById('askCancel').addEventListener('click',function(){ answerAsk(false); });
// Reconnect does double duty: a fresh start, and the manual path once the
// automatic retries have given up.
// The bar must not take focus: it is a convenience for keys the browser will
// not deliver, and stealing keyboard focus would defeat the point.
var keys=document.getElementById('keys');
// A control character cannot be written into an HTML attribute, so the
// buttons carry a name and the byte is looked up here.
var CTL={c:"\u0003",z:"\u001a",d:"\u0004"};
keys.addEventListener('mousedown',function(e){ e.preventDefault(); });
keys.addEventListener('click',function(e){
var b=e.target.closest('button');
if(!b||!session)return;
if(b.dataset.key)session.key(b.dataset.key);
else if(b.dataset.ctl&&CTL[b.dataset.ctl])session.raw(CTL[b.dataset.ctl]);
});
document.getElementById('reBtn').addEventListener('click',function(){
if(session&&session.reconnect){ session.reconnect(); return; }
start();
});
document.getElementById('fsBtn').addEventListener('click',function(){
if(document.fullscreenElement){ document.exitFullscreen(); return; }
if(document.documentElement.requestFullscreen) document.documentElement.requestFullscreen();
});
// Remember the size the user dragged the window to, so the panel reopens it
// the same way. The opener reads this key.
var saveT=null;
window.addEventListener('resize',function(){
if(saveT)clearTimeout(saveT);
saveT=setTimeout(function(){
try{ localStorage.setItem('vpsmgr_ssh_size',JSON.stringify([window.outerWidth,window.outerHeight])); }catch(e){}
},400);
});
start();
mount.focus();
})();
</script>
</body>
</html>
+623
View File
@@ -0,0 +1,623 @@
package panel
import (
"crypto/rand"
_ "embed"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"strconv"
"sync"
"time"
"github.com/gorilla/websocket"
"vpsmgr/internal/csrf"
"vpsmgr/internal/lx"
)
const (
// termMaxPerUser caps how many shells one account may hold, counting the
// ones sitting in their reconnect grace period. A terminal is a live
// process per connection, so this is a resource guard as much as a UX one.
termMaxPerUser = 2
// Liveness. A browser answers a WebSocket ping from its network stack,
// without JavaScript, so this works even in a throttled background tab.
// The interval has to sit well under the deadline: the deadline is what
// notices a peer that vanished (a dropped NAT mapping can otherwise stay
// silent for hours), and every message and every pong refreshes it.
termPingEvery = 20 * time.Second
termReadWait = 60 * time.Second
termWriteWait = 10 * time.Second
// termGrace is how long a shell outlives the client that opened it. A
// connection from far away drops for all sorts of reasons — a tunnel, a
// cell handover, a sleeping laptop — and losing a running build or an open
// editor to a three-second blip is the worst part of a web terminal. Two
// minutes covers those without leaving an unattended root shell much longer.
termGrace = 120 * time.Second
// termReplayMax bounds what a returning client is caught up with; anything
// older is dropped and the reader is told.
termReplayMax = 256 << 10
// termHelloWait bounds the first frame, before any shell exists.
termHelloWait = 10 * time.Second
// termMaxFrame caps a single message from the browser (a paste).
termMaxFrame = 1 << 20
)
// termJS is the browser terminal engine, embedded like the templates so the
// panel keeps shipping as a single self-contained binary.
//
//go:embed assets/term.js
var termJS []byte
// termIn is a message from the browser. Terminal output travels the other way
// as raw binary frames; only control messages are JSON.
type termIn struct {
T string `json:"t"` // hello | input | resize | takeover
Token string `json:"token,omitempty"`
Data string `json:"data,omitempty"`
Cols int `json:"cols,omitempty"`
Rows int `json:"rows,omitempty"`
}
func (t termIn) size() lx.TermSize { return lx.TermSize{Cols: t.Cols, Rows: t.Rows} }
// termOut is a control message to the browser.
type termOut struct {
T string `json:"t"` // ready | others | busy | ping | exit | error
Token string `json:"token,omitempty"`
Code int `json:"code,omitempty"`
Others int `json:"others,omitempty"`
Error string `json:"error,omitempty"`
}
// termPTY is the part of lx.Pty a session uses. It is an interface so the
// session's own logic — the grace period, the replay buffer, resuming — can be
// exercised without a container behind it.
type termPTY interface {
Output() <-chan []byte
Write([]byte) error
Resize(lx.TermSize) error
ExitCode() (int, error)
Close()
}
// termSink is where a session's output goes while a client is attached.
type termSink interface {
write(messageType int, data []byte) error
}
// termSocket serialises writes: gorilla allows one writer at a time, and the
// output pump, the keepalive pinger and the control replies all write.
type termSocket struct {
mu sync.Mutex
conn *websocket.Conn
}
func (t *termSocket) write(messageType int, data []byte) error {
t.mu.Lock()
defer t.mu.Unlock()
_ = t.conn.SetWriteDeadline(time.Now().Add(termWriteWait))
return t.conn.WriteMessage(messageType, data)
}
// termSession is one shell. It outlives the WebSocket that opened it: when the
// client goes away the PTY keeps running for termGrace with its output landing
// in a bounded buffer, so a client that comes back resumes the same shell
// rather than starting a new one.
type termSession struct {
token string
user string
pty termPTY
reg *termRegistry
mu sync.Mutex
sink termSink // nil while detached
ring []byte
ringOver bool
expire *time.Timer
closed bool
}
// attach hands the session to a client and catches it up on what the shell
// produced while nobody was listening.
func (s *termSession) attach(sink termSink, size lx.TermSize) {
s.mu.Lock()
if s.expire != nil {
s.expire.Stop()
s.expire = nil
}
s.sink = sink
replay, dropped := s.ring, s.ringOver
s.ring, s.ringOver = nil, false
s.mu.Unlock()
if dropped {
_ = sink.write(websocket.BinaryMessage,
[]byte("\r\n\x1b[33m[ output was dropped while you were disconnected ]\x1b[0m\r\n"))
}
if len(replay) > 0 {
_ = sink.write(websocket.BinaryMessage, replay)
}
// A returning terminal is blank, so anything painting a full screen has to
// be told to paint again. Nudging the size by one row is what makes the
// kernel raise SIGWINCH; the second message restores it.
_ = s.pty.Resize(lx.TermSize{Cols: size.Cols, Rows: size.Rows - 1})
_ = s.pty.Resize(size)
}
// detach takes the client away and starts the clock on the shell's grace
// period. It is idempotent: the reader loop and a failed write both land here.
func (s *termSession) detach() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed || s.sink == nil {
return
}
s.sink = nil
if s.expire == nil {
s.expire = time.AfterFunc(termGrace, s.close)
}
}
// run starts the output pump. It belongs to the session, not to a connection,
// which is the whole point: it is still there once the client is gone.
func (s *termSession) run() {
go func() {
for chunk := range s.pty.Output() {
s.mu.Lock()
sink := s.sink
if sink == nil {
s.bufferLocked(chunk)
s.mu.Unlock()
continue
}
s.mu.Unlock()
// Written outside the lock so a slow client cannot block a reattach.
if err := sink.write(websocket.BinaryMessage, chunk); err != nil {
// The client never received this chunk, so keep it before
// falling back to buffering: otherwise a client that drops
// mid-write loses the last thing the shell printed.
s.mu.Lock()
s.bufferLocked(chunk)
s.mu.Unlock()
s.detach()
}
}
s.finish()
}()
}
func (s *termSession) bufferLocked(chunk []byte) {
s.ring = append(s.ring, chunk...)
if len(s.ring) > termReplayMax {
s.ring = s.ring[len(s.ring)-termReplayMax:]
s.ringOver = true
}
}
// close ends the shell now, whatever the grace timer was waiting for.
func (s *termSession) close() {
s.mu.Lock()
if s.closed {
s.mu.Unlock()
return
}
s.closed = true
if s.expire != nil {
s.expire.Stop()
s.expire = nil
}
s.mu.Unlock()
// Unregister immediately: until the pump drains, a resume must not be able
// to attach to a shell that is already on its way out.
s.reg.remove(s)
s.pty.Close() // the pump drains and then calls finish
}
// finish runs once the shell is gone: tell whoever is attached, then let go.
func (s *termSession) finish() {
s.mu.Lock()
s.closed = true
sink := s.sink
s.sink = nil
if s.expire != nil {
s.expire.Stop()
s.expire = nil
}
s.mu.Unlock()
if sink != nil {
code, _ := s.pty.ExitCode()
_ = sink.write(websocket.TextMessage, mustJSON(termOut{T: "exit", Code: code}))
}
s.reg.remove(s)
}
// termRegistry owns every live shell. Sessions are keyed by a token handed to
// the client, which is what lets a reconnect claim back its own shell.
type termRegistry struct {
mu sync.Mutex
sessions map[string]*termSession
byUser map[string]map[string]bool
}
func newTermRegistry() *termRegistry {
return &termRegistry{sessions: map[string]*termSession{}, byUser: map[string]map[string]bool{}}
}
var errTermLimit = errors.New("too many open terminals")
// create registers a new shell. It refuses once the user is at the cap; the
// caller decides whether to offer a takeover.
func (r *termRegistry) create(user string, pty termPTY) (*termSession, error) {
r.mu.Lock()
defer r.mu.Unlock()
if len(r.byUser[user]) >= termMaxPerUser {
return nil, errTermLimit
}
token, err := newTermToken()
if err != nil {
return nil, err
}
s := &termSession{token: token, user: user, pty: pty, reg: r}
r.sessions[token] = s
if r.byUser[user] == nil {
r.byUser[user] = map[string]bool{}
}
r.byUser[user][token] = true
return s, nil
}
// lookup finds a session by token. The owner check is not decoration: a token
// is only ever handed to one user, and someone else's token must never resume
// their shell.
func (r *termRegistry) lookup(token, user string) (*termSession, bool) {
r.mu.Lock()
defer r.mu.Unlock()
s, ok := r.sessions[token]
if !ok || s.user != user {
return nil, false
}
s.mu.Lock()
closing := s.closed
s.mu.Unlock()
if closing {
return nil, false
}
return s, true
}
// others counts the user's sessions other than except.
func (r *termRegistry) others(user, except string) int {
r.mu.Lock()
defer r.mu.Unlock()
n := 0
for token := range r.byUser[user] {
if token != except {
n++
}
}
return n
}
func (r *termRegistry) atCap(user string) bool {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.byUser[user]) >= termMaxPerUser
}
// killOthers ends the user's other shells — how a window takes back the slots
// held by windows the user can no longer see.
func (r *termRegistry) killOthers(user, except string) int {
r.mu.Lock()
var doomed []*termSession
for token := range r.byUser[user] {
if token != except {
if s, ok := r.sessions[token]; ok {
doomed = append(doomed, s)
}
}
}
r.mu.Unlock()
for _, s := range doomed {
s.close()
}
return len(doomed)
}
func (r *termRegistry) remove(s *termSession) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.sessions, s.token)
if set := r.byUser[s.user]; set != nil {
delete(set, s.token)
if len(set) == 0 {
delete(r.byUser, s.user)
}
}
}
// attached returns the user's live sessions, used to push a fresh "others"
// count to each of them.
func (r *termRegistry) attached(user string) []*termSession {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*termSession, 0, len(r.byUser[user]))
for token := range r.byUser[user] {
if s, ok := r.sessions[token]; ok {
out = append(out, s)
}
}
return out
}
func newTermToken() (string, error) {
buf := make([]byte, 18)
if _, err := rand.Read(buf); err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
var termUpgrader = websocket.Upgrader{
ReadBufferSize: 4096,
WriteBufferSize: 4096,
// Belt and braces: the handler checks this too, but a refused handshake
// here never reaches the handler at all.
CheckOrigin: func(r *http.Request) bool { return csrf.SameOrigin(r) },
}
// handleWebSSH serves the standalone terminal window.
//
// It is a page rather than a modal on purpose: the terminal is the one thing in
// the panel that wants more room, and only a real browser window can be dragged
// to any size, moved to another screen or maximised. The page remembers the
// size it was last resized to, so the next open comes back the same way.
func (s *Server) handleWebSSH(w http.ResponseWriter, r *http.Request) {
if !s.cfg.Panel.WebSSH {
featureless404(w)
return
}
u := s.currentUser(r)
if u == nil {
http.Redirect(w, r, s.p("/login"), http.StatusFound)
return
}
if s.isExpired(r) {
s.redirect(w, r, s.p(""), "error: "+s.t(r, "err_account_expired"))
return
}
s.render(w, r, "webssh.html", pageData{
User: u,
Prefix: s.p(""),
Title: u.Name + " — Web SSH",
})
}
// handleTerminal serves an interactive shell for the session's own container.
//
// The container is always taken from the session, never from the request: the
// client cannot name a container it does not own. The handshake is a GET, so it
// is the one place in the panel where the POST CSRF check does not apply — the
// same-origin check below is what stands in for it.
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
// The operator switch is a real gate, not just a hidden button: with the
// terminal disabled nothing here may start or interact with a shell, even
// for someone replaying the endpoint by hand.
if !s.cfg.Panel.WebSSH {
featureless404(w)
return
}
u := s.currentUser(r)
if u == nil {
w.WriteHeader(http.StatusUnauthorized)
return
}
if !csrf.SameOrigin(r) {
w.WriteHeader(http.StatusForbidden)
return
}
// Expired accounts are locked out of their container; a shell is exactly
// what that lock is meant to prevent. Answered as a plain 403 rather than
// the usual redirect, which a WebSocket client cannot follow.
if s.isExpired(r) {
w.WriteHeader(http.StatusForbidden)
return
}
conn, err := termUpgrader.Upgrade(w, r, nil)
if err != nil {
return // Upgrade has already written a response
}
defer conn.Close()
sock := &termSocket{conn: conn}
conn.SetReadLimit(termMaxFrame)
// The size arrives in the first frame, before anything is created, so a
// stray connection cannot spawn a shell by accident.
_ = conn.SetReadDeadline(time.Now().Add(termHelloWait))
hello, err := readTermMessage(conn)
if err != nil {
return
}
size := hello.size()
if size.Cols == 0 || size.Rows == 0 {
size = lx.TermSize{Cols: 80, Rows: 24}
}
// A returning client owns its shell: resuming is not a new session, so the
// per-user cap must not turn it away.
if hello.Token != "" {
if sess, ok := s.terms.lookup(hello.Token, u.Name); ok {
sess.attach(sock, size)
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.resume")
// The resuming client still has to be told it is connected: without
// this it never learns the attempt succeeded, keeps the backoff
// counter where it was, and shows "reconnecting" over a live shell.
_ = sock.write(websocket.TextMessage, mustJSON(termOut{
T: "ready", Token: sess.token, Others: s.terms.others(u.Name, sess.token),
}))
s.notifyOthers(u.Name)
s.serveTermClient(conn, sock, sess, u.Name)
return
}
}
// At the cap the client is offered its slots back rather than being left
// with a ghost it cannot see: a closed tab, or one still in its grace
// period.
if s.terms.atCap(u.Name) {
_ = sock.write(websocket.TextMessage, mustJSON(termOut{
T: "busy", Others: s.terms.others(u.Name, ""),
}))
reply, err := readTermMessage(conn)
if err != nil || reply.T != "takeover" {
return
}
if n := s.terms.killOthers(u.Name, ""); n > 0 {
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.takeover")
}
}
pty, err := s.mgr.AttachTerminal(u.Name, size)
if err != nil {
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", Error: err.Error()}))
return
}
sess, err := s.terms.create(u.Name, pty)
if err != nil {
pty.Close()
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "error", Error: err.Error()}))
return
}
_ = s.db.AddAuditLog(s.auditActor(r, u.Name), "terminal.open")
sess.run()
sess.attach(sock, size)
_ = sock.write(websocket.TextMessage, mustJSON(termOut{
T: "ready", Token: sess.token, Others: s.terms.others(u.Name, sess.token),
}))
s.notifyOthers(u.Name)
s.serveTermClient(conn, sock, sess, u.Name)
}
// serveTermClient runs the connection until the client goes away, then leaves
// the shell in its grace period instead of killing it.
func (s *Server) serveTermClient(conn *websocket.Conn, sock *termSocket, sess *termSession, user string) {
defer sess.detach()
defer s.notifyOthers(user)
_ = conn.SetReadDeadline(time.Now().Add(termReadWait))
conn.SetPongHandler(func(string) error {
return conn.SetReadDeadline(time.Now().Add(termReadWait))
})
stopPing := make(chan struct{})
defer close(stopPing)
go func() {
t := time.NewTicker(termPingEvery)
defer t.Stop()
for {
select {
case <-stopPing:
return
case <-t.C:
if err := sock.write(websocket.PingMessage, nil); err != nil {
return
}
// A browser cannot see protocol-level pings, so the page needs
// its own heartbeat to notice a server that went quiet.
_ = sock.write(websocket.TextMessage, mustJSON(termOut{T: "ping"}))
}
}
}()
for {
_ = conn.SetReadDeadline(time.Now().Add(termReadWait))
in, err := readTermMessage(conn)
if err != nil {
return
}
switch in.T {
case "input":
if err := sess.pty.Write([]byte(in.Data)); err != nil {
return
}
case "resize":
_ = sess.pty.Resize(in.size())
case "takeover":
// Asked for at any time, not only when the cap is hit: it is how a
// window takes back the slots held by sessions the user cannot see.
if n := s.terms.killOthers(user, sess.token); n > 0 {
_ = s.db.AddAuditLog("000+"+user, "terminal.takeover")
}
s.notifyOthers(user)
}
}
}
// notifyOthers pushes the current count of *other* shells to each of the user's
// attached windows, so the notice line stays true as windows come and go.
func (s *Server) notifyOthers(user string) {
for _, sess := range s.terms.attached(user) {
sess.mu.Lock()
sink := sess.sink
sess.mu.Unlock()
if sink == nil {
continue
}
_ = sink.write(websocket.TextMessage, mustJSON(termOut{
T: "others", Others: s.terms.others(user, sess.token),
}))
}
}
// readTermMessage reads one control frame.
func readTermMessage(conn *websocket.Conn) (termIn, error) {
_, msg, err := conn.ReadMessage()
if err != nil {
return termIn{}, err
}
var in termIn
if err := json.Unmarshal(msg, &in); err != nil {
return termIn{}, err
}
return in, nil
}
// termSizeFrom reads the size the browser measured. Values are clamped by the
// lx layer, so anything nonsensical degrades to 80x24.
func termSizeFrom(r *http.Request) lx.TermSize {
q := r.URL.Query()
cols, _ := strconv.Atoi(q.Get("cols"))
rows, _ := strconv.Atoi(q.Get("rows"))
return lx.TermSize{Cols: cols, Rows: rows}
}
func mustJSON(v any) []byte {
b, err := json.Marshal(v)
if err != nil {
return []byte(`{"t":"error","error":"internal error"}`)
}
return b
}
// termAssetPath is the URL the terminal engine is served from.
const termAssetPath = "/assets/term.js"
// handleTermAsset serves the browser terminal engine. It is embedded in the
// binary like the templates, and it is served from the panel prefix so it is
// same-origin (the panel's CSP allows no third-party scripts).
func (s *Server) handleTermAsset(w http.ResponseWriter, r *http.Request) {
if !s.cfg.Panel.WebSSH {
featureless404(w)
return
}
w.Header().Set("Content-Type", "text/javascript; charset=utf-8")
_, _ = w.Write(termJS)
}
+310
View File
@@ -0,0 +1,310 @@
package panel
import (
"bytes"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"vpsmgr/internal/lx"
)
// fakePTY stands in for a container's shell so the session logic — the grace
// period, the replay buffer, resuming — is testable without an Incus daemon.
type fakePTY struct {
out chan []byte
mu sync.Mutex
closed bool
writes bytes.Buffer
resize []lx.TermSize
}
func newFakePTY() *fakePTY { return &fakePTY{out: make(chan []byte, 64)} }
func (f *fakePTY) Output() <-chan []byte { return f.out }
func (f *fakePTY) Write(b []byte) error {
f.mu.Lock()
defer f.mu.Unlock()
f.writes.Write(b)
return nil
}
func (f *fakePTY) Resize(s lx.TermSize) error {
f.mu.Lock()
defer f.mu.Unlock()
f.resize = append(f.resize, s)
return nil
}
func (f *fakePTY) ExitCode() (int, error) { return 0, nil }
func (f *fakePTY) Close() {
f.mu.Lock()
defer f.mu.Unlock()
if f.closed {
return
}
f.closed = true
close(f.out)
}
func (f *fakePTY) sizes() []lx.TermSize {
f.mu.Lock()
defer f.mu.Unlock()
return append([]lx.TermSize(nil), f.resize...)
}
// fakeSink records what a client was sent.
type fakeSink struct {
mu sync.Mutex
binary bytes.Buffer
ctl []termOut
fail bool
}
var errSinkWedged = errors.New("sink is wedged")
func (s *fakeSink) write(messageType int, data []byte) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.fail {
return errSinkWedged
}
if messageType == websocket.BinaryMessage {
s.binary.Write(data)
return nil
}
var out termOut
if err := json.Unmarshal(data, &out); err == nil {
s.ctl = append(s.ctl, out)
}
return nil
}
func (s *fakeSink) text() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.binary.String()
}
// waitFor polls until cond is true or the budget runs out.
func waitFor(t *testing.T, cond func() bool) bool {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return true
}
time.Sleep(2 * time.Millisecond)
}
return false
}
func TestTermRegistryCapAndOwnership(t *testing.T) {
reg := newTermRegistry()
a, err := reg.create("alice", newFakePTY())
if err != nil {
t.Fatal(err)
}
if reg.atCap("alice") {
t.Error("one session must not be the cap")
}
b, err := reg.create("alice", newFakePTY())
if err != nil {
t.Fatal(err)
}
if !reg.atCap("alice") {
t.Error("two sessions should be the cap")
}
if _, err := reg.create("alice", newFakePTY()); err != errTermLimit {
t.Errorf("third create = %v, want errTermLimit", err)
}
if _, err := reg.create("bob", newFakePTY()); err != nil {
t.Errorf("another account must be unaffected: %v", err)
}
if a.token == b.token {
t.Error("tokens must be unique")
}
// A token is good for its own user and its own shell only.
if got, ok := reg.lookup(a.token, "alice"); !ok || got != a {
t.Error("lookup of one's own token failed")
}
if _, ok := reg.lookup(a.token, "bob"); ok {
t.Error("another user's token must not resume a shell")
}
if _, ok := reg.lookup("nope", "alice"); ok {
t.Error("an unknown token must not resolve")
}
}
func TestTermRegistryTakeoverAndRemove(t *testing.T) {
reg := newTermRegistry()
a, _ := reg.create("alice", newFakePTY())
b, _ := reg.create("alice", newFakePTY())
if n := reg.others("alice", a.token); n != 1 {
t.Errorf("others = %d, want 1", n)
}
if n := reg.killOthers("alice", a.token); n != 1 {
t.Errorf("killOthers = %d, want 1", n)
}
// A killed session stops being resumable straight away, even though its
// pump may still be draining.
if _, ok := reg.lookup(b.token, "alice"); ok && b.closed {
t.Error("a closed session must not be resumable")
}
reg.remove(b)
if n := reg.others("alice", a.token); n != 0 {
t.Errorf("others after removal = %d, want 0", n)
}
if _, ok := reg.lookup(b.token, "alice"); ok {
t.Error("a removed session must not be resumable")
}
}
// While nobody is attached the shell keeps producing output; a client that
// comes back has to receive it, or a resumed session would look frozen.
func TestTermSessionBuffersWhileDetachedAndReplays(t *testing.T) {
reg := newTermRegistry()
pty := newFakePTY()
sess, err := reg.create("alice", pty)
if err != nil {
t.Fatal(err)
}
sess.run()
// Detached from the start, so everything lands in the replay buffer.
pty.out <- []byte("before-1 ")
pty.out <- []byte("before-2 ")
sink := &fakeSink{}
sess.attach(sink, lx.TermSize{Cols: 80, Rows: 24})
if !waitFor(t, func() bool { return strings.Contains(sink.text(), "before-2 ") }) {
t.Fatalf("buffered output was not replayed: %q", sink.text())
}
// Live output now goes straight through.
pty.out <- []byte("live")
if !waitFor(t, func() bool { return strings.Contains(sink.text(), "live") }) {
t.Fatalf("live output never arrived: %q", sink.text())
}
if got := sink.text(); !strings.Contains(got, "before-1 before-2 live") {
t.Errorf("resumed stream = %q, want the buffered output then the live one", got)
}
// The redraw nudge: one row short, then the real size.
sizes := pty.sizes()
if len(sizes) < 2 || sizes[len(sizes)-1] != (lx.TermSize{Cols: 80, Rows: 24}) {
t.Errorf("expected a SIGWINCH nudge ending at the real size, got %v", sizes)
}
}
func TestTermSessionDetachStartsGrace(t *testing.T) {
reg := newTermRegistry()
sess, err := reg.create("alice", newFakePTY())
if err != nil {
t.Fatal(err)
}
sess.attach(&fakeSink{}, lx.TermSize{Cols: 80, Rows: 24})
sess.detach()
sess.mu.Lock()
armed, attached := sess.expire != nil, sess.sink != nil
sess.mu.Unlock()
if !armed {
t.Error("detaching should arm the grace period")
}
if attached {
t.Error("detaching should clear the sink")
}
sess.detach() // idempotent
// A reattach inside the window keeps the shell and disarms the timer.
sess.attach(&fakeSink{}, lx.TermSize{Cols: 100, Rows: 30})
sess.mu.Lock()
armedAfter := sess.expire != nil
sess.mu.Unlock()
if armedAfter {
t.Error("reattaching should disarm the grace period")
}
if _, ok := reg.lookup(sess.token, "alice"); !ok {
t.Error("the resumed session must still be registered")
}
}
func TestTermSessionReplayIsBounded(t *testing.T) {
chunk := bytes.Repeat([]byte("x"), 64<<10)
sess := &termSession{pty: newFakePTY()}
for i := 0; i < 8; i++ {
sess.mu.Lock()
sess.bufferLocked(chunk)
sess.mu.Unlock()
}
sess.mu.Lock()
size, over := len(sess.ring), sess.ringOver
sess.mu.Unlock()
if size > termReplayMax {
t.Errorf("replay buffer = %d bytes, want <= %d", size, termReplayMax)
}
if !over {
t.Error("dropping output must set the truncation notice")
}
sink := &fakeSink{}
sess.attach(sink, lx.TermSize{Cols: 80, Rows: 24})
if !strings.Contains(sink.text(), "dropped") {
t.Error("the client must be told that output was dropped")
}
}
// A write that fails means the client is wedged; the session has to fall back
// to buffering rather than losing the shell.
func TestTermSessionFallsBackToBufferingOnWriteFailure(t *testing.T) {
pty := newFakePTY()
sess := &termSession{pty: pty}
sess.run()
sess.attach(&fakeSink{fail: true}, lx.TermSize{Cols: 80, Rows: 24})
pty.out <- []byte("while-wedged")
if !waitFor(t, func() bool {
sess.mu.Lock()
defer sess.mu.Unlock()
return sess.sink == nil
}) {
t.Fatal("a failing sink should have detached the session")
}
sess.mu.Lock()
buffered := string(sess.ring)
sess.mu.Unlock()
if !strings.Contains(buffered, "while-wedged") {
t.Errorf("output was lost when the client stalled: %q", buffered)
}
}
// Closing the shell must remove it, so a slot is never leaked.
func TestTermSessionCloseReleasesTheSlot(t *testing.T) {
reg := newTermRegistry()
pty := newFakePTY()
sess, err := reg.create("alice", pty)
if err != nil {
t.Fatal(err)
}
sess.run()
sess.attach(&fakeSink{}, lx.TermSize{Cols: 80, Rows: 24})
sess.close()
if !waitFor(t, func() bool { return !reg.atCap("alice") && reg.others("alice", "") == 0 }) {
t.Error("closing a session must release its slot")
}
if _, ok := reg.lookup(sess.token, "alice"); ok {
t.Error("a closed session must not be resumable")
}
}
+111
View File
@@ -0,0 +1,111 @@
package panel
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gorilla/websocket"
"vpsmgr/internal/db"
)
// The WebSocket handshake is a GET, so the panel's POST CSRF check never sees
// it and SameSite does not apply the way it does to a form post. These are the
// assertions that matter: without them any page could open a shell on the
// user's container with the user's cookie attached.
func TestTerminalRejectsForeignOrigin(t *testing.T) {
srv, d := newTestServer(t)
cookie := sessionCookie(t, d, "alice")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
url := "ws" + strings.TrimPrefix(ts.URL, "http") + srv.p("/terminal")
cases := []struct {
name string
origin string
}{
{"foreign origin", "http://evil.test"},
{"sibling subdomain", "http://sub.example.test"},
{"no origin at all", ""},
}
for _, tc := range cases {
h := http.Header{}
h.Set("Cookie", cookie.String())
if tc.origin != "" {
h.Set("Origin", tc.origin)
}
conn, resp, err := websocket.DefaultDialer.Dial(url, h)
if err == nil {
conn.Close()
t.Errorf("%s: handshake succeeded, want it refused", tc.name)
continue
}
if resp == nil || resp.StatusCode != http.StatusForbidden {
code := 0
if resp != nil {
code = resp.StatusCode
}
t.Errorf("%s: status = %d, want 403", tc.name, code)
}
}
}
// sessionCookie creates a user and a live panel session for it.
func sessionCookie(t *testing.T, d *db.DB, name string) *http.Cookie {
t.Helper()
u, err := d.CreateUser(name, "hash", "10.115.0.9", 9, 30009, 10900, 10, 1024, 10)
if err != nil {
t.Fatal(err)
}
sess, err := d.CreateSession(u.ID, 3)
if err != nil {
t.Fatal(err)
}
return &http.Cookie{Name: "vpsmgr_session", Value: sess.Token}
}
// A same-origin handshake still needs a session; without one the request goes
// through the ordinary auth redirect, which is not a websocket upgrade.
func TestTerminalRequiresSession(t *testing.T) {
srv, _ := newTestServer(t)
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
url := "ws" + strings.TrimPrefix(ts.URL, "http") + srv.p("/terminal")
h := http.Header{}
h.Set("Origin", ts.URL)
conn, resp, err := websocket.DefaultDialer.Dial(url, h)
if err == nil {
conn.Close()
t.Fatal("handshake succeeded without a session")
}
if resp == nil || resp.StatusCode == http.StatusSwitchingProtocols {
t.Fatalf("expected no upgrade, got %+v", resp)
}
}
// The engine is embedded and served from the panel prefix, because the panel's
// CSP allows no third-party scripts.
func TestTerminalAssetServed(t *testing.T) {
srv, _ := newTestServer(t)
h := srv.Handler()
rr := doReq(t, h, http.MethodGet, srv.p(termAssetPath), nil, nil)
if rr.Code != http.StatusFound { // no session yet: redirected to login
t.Fatalf("asset without a session = %d, want the login redirect", rr.Code)
}
}
func TestTerminalSizeFromQuery(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/terminal?cols=132&rows=43", nil)
got := termSizeFrom(r)
if got.Cols != 132 || got.Rows != 43 {
t.Errorf("size = %+v, want 132x43", got)
}
r = httptest.NewRequest(http.MethodGet, "/terminal", nil)
got = termSizeFrom(r)
if got.Cols != 0 || got.Rows != 0 {
t.Errorf("size = %+v, want zeroes for the lx layer to normalise", got)
}
}