Merge dev-webssh: a browser terminal for the container
This commit is contained in:
commit
18a680b924
21 files changed
+2917
-61
No files matched your search
@@ -8,6 +8,7 @@ Technical documentation for vpsmgr. All docs are in English.
|
||||
| [configuration.md](configuration.md) | Full reference for `/etc/vpsmgr/config.yaml` |
|
||||
| [ipv6.md](ipv6.md) | IPv6 pass-through design: deterministic addresses, prefix support, routing, installer flow |
|
||||
| [ipv6-ndp-responder.md](ipv6-ndp-responder.md) | Why prefix mode replaced ndppd with an in-tree NDP responder, design hardening, and what to test at scale |
|
||||
| [web-ssh.md](web-ssh.md) | The browser terminal: how it reaches a container, how a session survives a dropped connection, and how to disable it |
|
||||
| [development.md](development.md) | Building, testing, releasing, conventions |
|
||||
|
||||
See also [`AGENTS.md`](../AGENTS.md) at the repo root — short version aimed at AI coding agents.
|
||||
@@ -35,6 +35,7 @@ same table; `vps config list` shows the live values with this annotation.
|
||||
| `panel.url_path` | **fixed at install** | — | secret prefix of the user panel; settable only while empty (re-enable) |
|
||||
| `panel.admin_url_path` | operator | restart panel | secret prefix of the admin panel; an **empty value disables the admin panel** (shown as `disabled`) |
|
||||
| `panel.show_footer` | operator | restart panel | user panel footer with project link and installed/build version; `false` hides the entire footer |
|
||||
| `panel.web_ssh` | operator | restart panel | browser terminal in the user panel; default `true`. When `false` the Web SSH button is not rendered and the panel refuses the terminal page and connection outright |
|
||||
| `panel.bandwidth_reset_day` | operator | restart panel — **discards stored state** | day of month the monthly bandwidth quota resets, `1`-`28` (28 keeps February safe); default `1`. **Changing it wipes every user's accumulated traffic totals** — see below |
|
||||
| `panel.admin_pass_hash` | managed elsewhere | — | bcrypt hash of the admin password; stored in the **DB**, set via `vps admin-passwd` / web UI |
|
||||
| `net.subnet` | **fixed at install** | — | container subnet `10.<n>.0.0/24`; changing breaks existing containers |
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
@@ -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"
|
||||
|
||||
@@ -153,6 +153,12 @@ type PanelCfg struct {
|
||||
// URLPath nor AdminPath get a bare, headerless 404.
|
||||
AdminPath string `yaml:"admin_url_path,omitempty"`
|
||||
ShowFooter bool `yaml:"show_footer"`
|
||||
// WebSSH enables the browser terminal in the user panel. On by default,
|
||||
// including for config files written before the feature existed (a missing
|
||||
// key keeps the default). An operator who does not want a shell reachable
|
||||
// from the panel turns it off: the button disappears and the panel refuses
|
||||
// both the terminal page and the terminal connection.
|
||||
WebSSH bool `yaml:"web_ssh"`
|
||||
// BandwidthResetDay is the day of the month the monthly bandwidth period
|
||||
// rolls over. 1-28 (capped at 28 so February always has the day).
|
||||
BandwidthResetDay int `yaml:"bandwidth_reset_day"`
|
||||
@@ -289,7 +295,7 @@ type SnapshotsCfg struct {
|
||||
|
||||
func Default() *Config {
|
||||
c := &Config{}
|
||||
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, BandwidthResetDay: 1}
|
||||
c.Panel = PanelCfg{Listen: DefaultListen, Cert: DefaultDataDir + "/panel.crt", Key: DefaultDataDir + "/panel.key", DB: DefaultDB, SessionDays: 3, ShowFooter: true, WebSSH: true, BandwidthResetDay: 1}
|
||||
c.Net = NetCfg{Subnet: DefaultSubnet, Gateway: DefaultGateway, V4Forward: true, Haproxy: true, UserPorts: DefaultUserPorts}
|
||||
c.Incus = IncusCfg{Image: DefaultImage, ImageFallback: DefaultImageFB, Pool: DefaultPool, Bridge: DefaultBridge, Socket: DefaultSocket, SwapRatio: DefaultSwapRatio}
|
||||
c.Snapshots = SnapshotsCfg{Limit: 1, Share: false}
|
||||
|
||||
@@ -230,6 +230,18 @@ var Fields = []Field{
|
||||
c.Panel.ShowFooter = b
|
||||
return nil
|
||||
}},
|
||||
{"panel.web_ssh", KindOperator, ApplyRestart,
|
||||
"browser terminal (Web SSH) in the user panel; false hides the button and refuses terminal connections entirely",
|
||||
"true or false",
|
||||
getStr(func(c *Config) string { return strconv.FormatBool(c.Panel.WebSSH) }),
|
||||
func(c *Config, v string) error {
|
||||
b, ok := parseBool(v)
|
||||
if !ok {
|
||||
return fmt.Errorf("panel.web_ssh must be true/false or 1/0")
|
||||
}
|
||||
c.Panel.WebSSH = b
|
||||
return nil
|
||||
}},
|
||||
{"panel.bandwidth_reset_day", KindOperator, ApplyDestructive,
|
||||
"day of month the monthly bandwidth quota resets (1-28; 28 keeps February safe). WARNING: changing it discards EVERY user's accumulated bandwidth totals — the accounting period key moves, so the next sampler pass starts a fresh period",
|
||||
"1",
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package cfg
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
@@ -42,6 +44,11 @@ func TestFieldValueReadsConfig(t *testing.T) {
|
||||
if v := FieldValue(c, "net.haproxy"); v != "true" {
|
||||
t.Errorf("net.haproxy default = %q", v)
|
||||
}
|
||||
// The upgrade case matters: a config written before Web SSH existed has no
|
||||
// key for it, and the feature must still come out enabled.
|
||||
if v := FieldValue(c, "panel.web_ssh"); v != "true" {
|
||||
t.Errorf("panel.web_ssh default = %q, want true (upgrades must get it enabled)", v)
|
||||
}
|
||||
if v := FieldValue(c, "panel.show_footer"); v != "true" {
|
||||
t.Errorf("panel.show_footer default = %q", v)
|
||||
}
|
||||
@@ -405,3 +412,35 @@ func TestBandwidthResetDayIsDestructive(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A config file written before the feature existed decodes with the key absent,
|
||||
// and the feature must come out enabled — that is what "upgrades get it on"
|
||||
// means in practice.
|
||||
func TestWebSSHDefaultsOnForAnOlderConfig(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
t.Setenv("VPSMGR_CONFIG", filepath.Join(base, "config.yaml"))
|
||||
if err := Save(Default()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(Path())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var kept []string
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.Contains(line, "web_ssh") {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, line)
|
||||
}
|
||||
if err := os.WriteFile(Path(), []byte(strings.Join(kept, "\n")), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := Load()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !c.Panel.WebSSH {
|
||||
t.Error("an old config without the key must still enable Web SSH")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
/* vpsmgr browser terminal.
|
||||
*
|
||||
* A small VT100/xterm emulator plus the WebSocket session for /terminal. It is
|
||||
* hand-written and dependency-free on purpose: the panel ships as one binary,
|
||||
* has no build step, and its CSP allows no third-party scripts.
|
||||
*
|
||||
* Terminal output is arbitrary bytes from a shell, so nothing from the stream
|
||||
* is ever interpreted as markup — every character goes through escapeText or
|
||||
* textContent.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
var PALETTE = [
|
||||
"#1c1c1c", "#d75f5f", "#5faf5f", "#d7af5f", "#5f87d7", "#af5faf", "#5fafaf", "#c6c6c6",
|
||||
"#6c6c6c", "#ff8787", "#87d787", "#ffd787", "#87afff", "#ff87ff", "#87ffff", "#ffffff"
|
||||
];
|
||||
|
||||
var F_BOLD = 1, F_DIM = 2, F_ITALIC = 4, F_UNDER = 8, F_BLINK = 16,
|
||||
F_REV = 32, F_HIDDEN = 64, F_STRIKE = 128;
|
||||
|
||||
function escapeText(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function color256(n) {
|
||||
if (n < 16) return PALETTE[n];
|
||||
if (n < 232) {
|
||||
n -= 16;
|
||||
var f = function (v) { return v === 0 ? 0 : 55 + v * 40; };
|
||||
return "rgb(" + f(Math.floor(n / 36)) + "," + f(Math.floor((n % 36) / 6)) + "," + f(n % 6) + ")";
|
||||
}
|
||||
var v = 8 + (n - 232) * 10;
|
||||
return "rgb(" + v + "," + v + "," + v + ")";
|
||||
}
|
||||
|
||||
function isWide(cp) {
|
||||
return (cp >= 0x1100 && (
|
||||
cp <= 0x115f || cp === 0x2329 || cp === 0x232a ||
|
||||
(cp >= 0x2e80 && cp <= 0xa4cf && cp !== 0x303f) ||
|
||||
(cp >= 0xac00 && cp <= 0xd7a3) || (cp >= 0xf900 && cp <= 0xfaff) ||
|
||||
(cp >= 0xfe30 && cp <= 0xfe6f) || (cp >= 0xff00 && cp <= 0xff60) ||
|
||||
(cp >= 0xffe0 && cp <= 0xffe6) || (cp >= 0x20000 && cp <= 0x3fffd)));
|
||||
}
|
||||
|
||||
function blankCell() { return { ch: " ", fg: null, bg: null, fl: 0, cont: false }; }
|
||||
|
||||
function blankLine(cols) {
|
||||
var cells = new Array(cols);
|
||||
for (var i = 0; i < cols; i++) cells[i] = blankCell();
|
||||
return { cells: cells };
|
||||
}
|
||||
|
||||
function styleOf(c) {
|
||||
var fg = c.fg, bg = c.bg, f = c.fl;
|
||||
if (f & F_REV) { var t = fg; fg = bg === null ? "#0f172a" : bg; bg = t === null ? "#c6c6c6" : t; }
|
||||
if (f & F_HIDDEN) fg = bg === null ? "#0f172a" : bg;
|
||||
if (f & F_DIM) fg = fg === null ? "#6c6c6c" : fg;
|
||||
var s = "";
|
||||
if (fg) s += "color:" + fg + ";";
|
||||
if (bg) s += "background:" + bg + ";";
|
||||
if (f & F_BOLD) s += "font-weight:700;";
|
||||
if (f & F_ITALIC) s += "font-style:italic;";
|
||||
if (f & F_UNDER) s += "text-decoration:underline;";
|
||||
if (f & F_STRIKE) s += "text-decoration:line-through;";
|
||||
return s;
|
||||
}
|
||||
|
||||
// ---- emulator ---------------------------------------------------------
|
||||
|
||||
function Term(mount, opts) {
|
||||
this.mount = mount;
|
||||
this.opts = opts || {};
|
||||
this.cols = 80;
|
||||
this.rows = 24;
|
||||
this.sb = [];
|
||||
this.maxSb = 2000;
|
||||
this.vy = 0;
|
||||
this.cx = 0;
|
||||
this.cy = 0;
|
||||
this.attr = { fg: null, bg: null, fl: 0 };
|
||||
this.saved = null;
|
||||
this.scrollTop = 0;
|
||||
this.scrollBot = 23;
|
||||
this.modes = { wrap: true, cursor: true, appCursor: false, bracketed: false, insert: false, alt: false };
|
||||
this.altSaved = null;
|
||||
this.state = "ground";
|
||||
this.buf = "";
|
||||
this.params = [];
|
||||
this.priv = false;
|
||||
this.osc = "";
|
||||
this.dirty = new Set();
|
||||
this.needFull = true;
|
||||
this.lastCursorRow = -1;
|
||||
this.decoder = new TextDecoder("utf-8");
|
||||
this.disposed = false;
|
||||
this.exited = false;
|
||||
this.screen = [];
|
||||
for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols));
|
||||
this._buildDom();
|
||||
this._measure();
|
||||
this._onResize();
|
||||
}
|
||||
|
||||
Term.prototype._buildDom = function () {
|
||||
var self = this;
|
||||
this.mount.classList.add("term");
|
||||
this.rowsEl = document.createElement("div");
|
||||
this.rowsEl.className = "term-rows";
|
||||
this.mount.appendChild(this.rowsEl);
|
||||
this.rowEls = [];
|
||||
for (var i = 0; i < 24; i++) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "term-row";
|
||||
this.rowsEl.appendChild(d);
|
||||
this.rowEls.push(d);
|
||||
}
|
||||
// A hidden textarea is what receives IME composition and any text the
|
||||
// browser inserts without key events (mobile keyboards, autocomplete).
|
||||
// Key events we handle ourselves are preventDefault'ed, so nothing lands
|
||||
// here for ordinary typing and the two paths never double up.
|
||||
this.input = document.createElement("textarea");
|
||||
this.input.className = "term-input";
|
||||
this.input.setAttribute("autocapitalize", "off");
|
||||
this.input.setAttribute("autocorrect", "off");
|
||||
this.input.setAttribute("autocomplete", "off");
|
||||
this.input.setAttribute("spellcheck", "false");
|
||||
this.input.setAttribute("aria-label", "terminal input");
|
||||
this.mount.appendChild(this.input);
|
||||
this.composing = false;
|
||||
this.input.addEventListener("input", function () {
|
||||
if (self.composing) return;
|
||||
var v = self.input.value;
|
||||
if (v) { self.input.value = ""; self._send(v); }
|
||||
});
|
||||
this.input.addEventListener("compositionstart", function () { self.composing = true; });
|
||||
this.input.addEventListener("compositionend", function () {
|
||||
self.composing = false;
|
||||
var v = self.input.value;
|
||||
if (v) { self.input.value = ""; self._send(v); }
|
||||
});
|
||||
this.mount.addEventListener("keydown", function (e) { self._onKey(e); });
|
||||
// A browser can move focus off the page's own element (Escape is one way),
|
||||
// and then every keystroke goes nowhere until the user clicks back in. This
|
||||
// window sits on a page that IS the terminal, so take the key instead of
|
||||
// making the user find the focus again.
|
||||
window.addEventListener("keydown", function (e) {
|
||||
if (self.disposed || self.exited) return;
|
||||
if (self.mount.contains(document.activeElement)) return; // already ours
|
||||
self.input.focus();
|
||||
self._onKey(e);
|
||||
});
|
||||
this.mount.addEventListener("paste", function (e) { self._onPaste(e); });
|
||||
this.mount.addEventListener("wheel", function (e) { self._onWheel(e); }, { passive: false });
|
||||
// A click must leave focus on the hidden input, never on the mount itself:
|
||||
// the mount is focusable so it can host the cursor, and the browser's own
|
||||
// focus handling runs after mousedown, hence the deferred call.
|
||||
this.mount.addEventListener("mousedown", function () {
|
||||
setTimeout(function () { self.input.focus(); }, 0);
|
||||
});
|
||||
this.mount.addEventListener("focus", function () { self.input.focus(); });
|
||||
this.mount.addEventListener("focus", function () { self.needFull = true; self._scheduleRender(); });
|
||||
this.mount.addEventListener("blur", function () { self.needFull = true; self._scheduleRender(); });
|
||||
this._ro = new ResizeObserver(function () { self._onResize(); });
|
||||
this._ro.observe(this.mount);
|
||||
};
|
||||
|
||||
Term.prototype._measure = function () {
|
||||
var probe = document.createElement("span");
|
||||
probe.className = "term-probe";
|
||||
probe.textContent = new Array(101).join("W");
|
||||
this.mount.appendChild(probe);
|
||||
var r = probe.getBoundingClientRect();
|
||||
this.cellW = r.width > 0 ? r.width / 100 : 8;
|
||||
this.cellH = r.height > 0 ? r.height : 17;
|
||||
this.mount.removeChild(probe);
|
||||
};
|
||||
|
||||
Term.prototype._onResize = function () {
|
||||
var st = getComputedStyle(this.mount);
|
||||
var padX = parseFloat(st.paddingLeft) + parseFloat(st.paddingRight);
|
||||
var padY = parseFloat(st.paddingTop) + parseFloat(st.paddingBottom);
|
||||
var cols = Math.max(2, Math.floor((this.mount.clientWidth - padX) / this.cellW));
|
||||
var rows = Math.max(2, Math.floor((this.mount.clientHeight - padY) / this.cellH));
|
||||
if (cols === this.cols && rows === this.rows) return;
|
||||
this.resize(cols, rows);
|
||||
if (this.opts.onResize) this.opts.onResize(cols, rows);
|
||||
};
|
||||
|
||||
Term.prototype.resize = function (cols, rows) {
|
||||
var all = this.sb.concat(this.screen);
|
||||
this.cols = cols;
|
||||
this.rows = rows;
|
||||
// Existing lines are truncated or padded rather than re-wrapped: good
|
||||
// enough for a resize, and it never loses the line being worked on.
|
||||
for (var i = 0; i < all.length; i++) {
|
||||
var line = all[i];
|
||||
if (line.cells.length > cols) line.cells.length = cols;
|
||||
while (line.cells.length < cols) line.cells.push(blankCell());
|
||||
}
|
||||
while (all.length < rows) all.push(blankLine(cols));
|
||||
this.screen = all.slice(all.length - rows);
|
||||
this.sb = all.slice(0, all.length - rows);
|
||||
if (this.sb.length > this.maxSb) this.sb = this.sb.slice(this.sb.length - this.maxSb);
|
||||
|
||||
while (this.rowEls.length < rows) {
|
||||
var d = document.createElement("div");
|
||||
d.className = "term-row";
|
||||
this.rowsEl.appendChild(d);
|
||||
this.rowEls.push(d);
|
||||
}
|
||||
for (var j = 0; j < this.rowEls.length; j++) {
|
||||
this.rowEls[j].style.display = j < rows ? "" : "none";
|
||||
}
|
||||
this.scrollTop = 0;
|
||||
this.scrollBot = rows - 1;
|
||||
if (this.cy >= rows) this.cy = rows - 1;
|
||||
if (this.cx >= cols) this.cx = cols - 1;
|
||||
this.needFull = true;
|
||||
this._scheduleRender();
|
||||
};
|
||||
|
||||
Term.prototype.line = function (y) { return this.screen[y]; };
|
||||
|
||||
Term.prototype._scrollUp = function () {
|
||||
this.sb.push(this.screen[this.scrollTop]);
|
||||
if (this.sb.length > this.maxSb) this.sb.shift();
|
||||
this.screen.splice(this.scrollTop, 1);
|
||||
this.screen.push(blankLine(this.cols));
|
||||
this.needFull = true;
|
||||
};
|
||||
|
||||
Term.prototype._scrollDown = function () {
|
||||
this.screen.splice(this.scrollBot, 1);
|
||||
this.screen.splice(this.scrollTop, 0, blankLine(this.cols));
|
||||
this.needFull = true;
|
||||
};
|
||||
|
||||
Term.prototype._lf = function () {
|
||||
if (this.cy === this.scrollBot) this._scrollUp();
|
||||
else if (this.cy < this.rows - 1) this.cy++;
|
||||
};
|
||||
|
||||
// ---- output -----------------------------------------------------------
|
||||
|
||||
Term.prototype.write = function (bytes) {
|
||||
var s = this.decoder.decode(bytes, { stream: true });
|
||||
if (!s) return;
|
||||
this._parse(s);
|
||||
this._scheduleRender();
|
||||
};
|
||||
|
||||
Term.prototype._parse = function (s) {
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
var ch = s[i], code = s.charCodeAt(i);
|
||||
switch (this.state) {
|
||||
case "ground":
|
||||
if (code === 0x1b) { this.state = "esc"; break; }
|
||||
if (code === 0x0d) { this.cx = 0; break; }
|
||||
if (code === 0x0a || code === 0x0b || code === 0x0c) { this._lf(); break; }
|
||||
if (code === 0x08) { if (this.cx > 0) this.cx--; break; }
|
||||
if (code === 0x09) { this.cx = Math.min(this.cols - 1, (this.cx + 8) & ~7); break; }
|
||||
if (code < 0x20) break; // BEL and the rest have no visual effect
|
||||
if (code >= 0xd800 && code <= 0xdbff && i + 1 < s.length) { ch = s.substr(i, 2); i++; }
|
||||
this._put(ch, ch.codePointAt(0));
|
||||
break;
|
||||
case "esc":
|
||||
if (ch === "[") { this.state = "csi"; this.params = []; this.priv = false; this.buf = ""; }
|
||||
else if (ch === "]") { this.state = "osc"; this.osc = ""; }
|
||||
else if (ch === "7") { this.saved = { x: this.cx, y: this.cy, a: Object.assign({}, this.attr) }; this.state = "ground"; }
|
||||
else if (ch === "8") {
|
||||
if (this.saved) { this.cx = this.saved.x; this.cy = this.saved.y; this.attr = Object.assign({}, this.saved.a); }
|
||||
this.state = "ground";
|
||||
}
|
||||
else if (ch === "D") { this._lf(); this.state = "ground"; }
|
||||
else if (ch === "M") { if (this.cy === this.scrollTop) this._scrollDown(); else if (this.cy > 0) this.cy--; this.state = "ground"; }
|
||||
else if (ch === "E") { this.cx = 0; this._lf(); this.state = "ground"; }
|
||||
else if (ch === "c") { this._reset(); this.state = "ground"; }
|
||||
else if (ch === "(" || ch === ")" || ch === "*" || ch === "+") { this.state = "charset"; }
|
||||
else if (ch === "P" || ch === "_" || ch === "^" || ch === "X") { this.state = "dcs"; }
|
||||
else this.state = "ground";
|
||||
break;
|
||||
case "charset":
|
||||
this.state = "ground";
|
||||
break;
|
||||
case "dcs":
|
||||
if (code === 0x07) this.state = "ground";
|
||||
else if (code === 0x1b) this.state = "dcsEsc";
|
||||
break;
|
||||
case "dcsEsc":
|
||||
this.state = ch === "\\" ? "ground" : "dcs";
|
||||
break;
|
||||
case "osc":
|
||||
if (code === 0x07) { this._osc(); this.state = "ground"; }
|
||||
else if (code === 0x1b) { this._osc(); this.state = "oscEsc"; }
|
||||
else this.osc += ch;
|
||||
break;
|
||||
case "oscEsc":
|
||||
this.state = ch === "\\" ? "ground" : "osc";
|
||||
break;
|
||||
case "csi":
|
||||
if (code >= 0x40 && code <= 0x7e) { this._csi(ch); this.state = "ground"; }
|
||||
else if (code === 0x3f || code === 0x3c || code === 0x3d || code === 0x3e) {
|
||||
// The private/extension markers are ? < = >. All four have to be
|
||||
// recognised: ignoring one lets its parameters leak into the final
|
||||
// byte, which is how xterm's modifyOtherKeys (CSI > 4 ; 2 m) used to
|
||||
// arrive as "underline on" and stay on for the rest of the session.
|
||||
this.priv = true;
|
||||
}
|
||||
else if (code >= 0x30 && code <= 0x39) this.buf += ch;
|
||||
else if (code === 0x3b) { this.params.push(parseInt(this.buf, 10)); this.buf = ""; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Term.prototype._osc = function () {
|
||||
var m = /^(\d+);?(.*)$/.exec(this.osc);
|
||||
if (!m) return;
|
||||
var n = parseInt(m[1], 10);
|
||||
// 777 is the session's own end-of-session marker; only the title is used.
|
||||
if ((n === 0 || n === 2) && this.opts.onTitle) this.opts.onTitle(m[2]);
|
||||
};
|
||||
|
||||
Term.prototype._params = function () {
|
||||
var p = this.params.slice();
|
||||
if (this.buf !== "") p.push(parseInt(this.buf, 10));
|
||||
if (p.length === 0) p.push(0);
|
||||
for (var i = 0; i < p.length; i++) if (isNaN(p[i])) p[i] = 0;
|
||||
return p;
|
||||
};
|
||||
|
||||
Term.prototype._csi = function (final) {
|
||||
var p = this._params();
|
||||
var n = function (i, d) { return p[i] === 0 ? d : p[i]; };
|
||||
var i, c, k;
|
||||
switch (final) {
|
||||
case "A": this.cy = Math.max(this.scrollTop, this.cy - n(0, 1)); break;
|
||||
case "B": this.cy = Math.min(this.scrollBot, this.cy + n(0, 1)); break;
|
||||
case "C": this.cx = Math.min(this.cols - 1, this.cx + n(0, 1)); break;
|
||||
case "D": this.cx = Math.max(0, this.cx - n(0, 1)); break;
|
||||
case "E": this.cy = Math.min(this.rows - 1, this.cy + n(0, 1)); this.cx = 0; break;
|
||||
case "F": this.cy = Math.max(0, this.cy - n(0, 1)); this.cx = 0; break;
|
||||
case "G": this.cx = Math.min(this.cols - 1, n(0, 1) - 1); break;
|
||||
case "d": this.cy = Math.min(this.rows - 1, n(0, 1) - 1); break;
|
||||
case "H": case "f":
|
||||
this.cy = Math.min(this.rows - 1, n(0, 1) - 1);
|
||||
this.cx = Math.min(this.cols - 1, n(1, 1) - 1);
|
||||
break;
|
||||
case "J":
|
||||
if (p[0] === 0) { this._eraseLine(this.cy, this.cx, this.cols - 1); for (i = this.cy + 1; i < this.rows; i++) this._eraseLine(i, 0, this.cols - 1); }
|
||||
else if (p[0] === 1) { this._eraseLine(this.cy, 0, this.cx); for (i = 0; i < this.cy; i++) this._eraseLine(i, 0, this.cols - 1); }
|
||||
else if (p[0] === 2 || p[0] === 3) { for (i = 0; i < this.rows; i++) this._eraseLine(i, 0, this.cols - 1); }
|
||||
break;
|
||||
case "K":
|
||||
if (p[0] === 0) this._eraseLine(this.cy, this.cx, this.cols - 1);
|
||||
else if (p[0] === 1) this._eraseLine(this.cy, 0, this.cx);
|
||||
else if (p[0] === 2) this._eraseLine(this.cy, 0, this.cols - 1);
|
||||
break;
|
||||
case "L":
|
||||
for (i = 0; i < n(0, 1); i++) this.screen.splice(this.scrollBot, 0, blankLine(this.cols));
|
||||
this.screen.length = this.rows;
|
||||
this.needFull = true;
|
||||
break;
|
||||
case "M":
|
||||
for (i = 0; i < n(0, 1); i++) { this.screen.splice(this.cy, 1); this.screen.splice(this.scrollBot, 0, blankLine(this.cols)); }
|
||||
this.needFull = true;
|
||||
break;
|
||||
case "P":
|
||||
c = this.line(this.cy);
|
||||
for (i = 0; i < n(0, 1); i++) { c.cells.splice(this.cx, 1); c.cells.push(blankCell()); }
|
||||
this.dirty.add(this.cy);
|
||||
break;
|
||||
case "@":
|
||||
c = this.line(this.cy);
|
||||
for (i = 0; i < n(0, 1); i++) { c.cells.splice(this.cx, 0, blankCell()); c.cells.length = this.cols; }
|
||||
this.dirty.add(this.cy);
|
||||
break;
|
||||
case "X":
|
||||
this._eraseLine(this.cy, this.cx, Math.min(this.cols - 1, this.cx + n(0, 1) - 1));
|
||||
break;
|
||||
case "S": for (i = 0; i < n(0, 1); i++) this._scrollUp(); break;
|
||||
case "T": for (i = 0; i < n(0, 1); i++) this._scrollDown(); break;
|
||||
case "r":
|
||||
this.scrollTop = Math.max(0, n(0, 1) - 1);
|
||||
this.scrollBot = Math.min(this.rows - 1, n(1, this.rows) - 1);
|
||||
if (this.scrollBot <= this.scrollTop) { this.scrollTop = 0; this.scrollBot = this.rows - 1; }
|
||||
this.cx = 0; this.cy = this.scrollTop;
|
||||
break;
|
||||
case "m": if (!this.priv) this._sgr(p); break;
|
||||
case "h": this._mode(p, true); break;
|
||||
case "l": this._mode(p, false); break;
|
||||
case "n":
|
||||
if (p[0] === 6 && this.opts.onReply) this.opts.onReply("\x1b[" + (this.cy + 1) + ";" + (this.cx + 1) + "R");
|
||||
break;
|
||||
case "c":
|
||||
// A private ">" query asks for the secondary attributes; answering with
|
||||
// the primary form (or not at all) makes a program wait for its timeout.
|
||||
if (this.opts.onReply) this.opts.onReply(this.priv ? "\x1b[>0;0;0c" : "\x1b[?1;2c");
|
||||
break;
|
||||
}
|
||||
this._scheduleRender();
|
||||
};
|
||||
|
||||
Term.prototype._eraseLine = function (y, from, to) {
|
||||
var line = this.line(y);
|
||||
if (!line) return;
|
||||
for (var i = Math.max(0, from); i <= to && i < this.cols; i++) {
|
||||
line.cells[i] = blankCell();
|
||||
// Erasing must not leave the trailing half of a wide character behind.
|
||||
if (i > 0 && line.cells[i - 1].ch) {
|
||||
var prev = line.cells[i - 1];
|
||||
if (!prev.cont && isWide(prev.ch.codePointAt(0))) line.cells[i - 1] = blankCell();
|
||||
}
|
||||
}
|
||||
this.dirty.add(y);
|
||||
};
|
||||
|
||||
Term.prototype._sgr = function (p) {
|
||||
if (p.length === 0) p = [0];
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
var v = p[i];
|
||||
if (v === 0) this.attr = { fg: null, bg: null, fl: 0 };
|
||||
else if (v === 1) this.attr.fl |= F_BOLD;
|
||||
else if (v === 2) this.attr.fl |= F_DIM;
|
||||
else if (v === 3) this.attr.fl |= F_ITALIC;
|
||||
else if (v === 4) this.attr.fl |= F_UNDER;
|
||||
else if (v === 5) this.attr.fl |= F_BLINK;
|
||||
else if (v === 7) this.attr.fl |= F_REV;
|
||||
else if (v === 8) this.attr.fl |= F_HIDDEN;
|
||||
else if (v === 9) this.attr.fl |= F_STRIKE;
|
||||
else if (v === 22) this.attr.fl &= ~(F_BOLD | F_DIM);
|
||||
else if (v === 23) this.attr.fl &= ~F_ITALIC;
|
||||
else if (v === 24) this.attr.fl &= ~F_UNDER;
|
||||
else if (v === 25) this.attr.fl &= ~F_BLINK;
|
||||
else if (v === 27) this.attr.fl &= ~F_REV;
|
||||
else if (v === 28) this.attr.fl &= ~F_HIDDEN;
|
||||
else if (v === 29) this.attr.fl &= ~F_STRIKE;
|
||||
else if (v >= 30 && v <= 37) this.attr.fg = PALETTE[v - 30];
|
||||
else if (v === 39) this.attr.fg = null;
|
||||
else if (v >= 40 && v <= 47) this.attr.bg = PALETTE[v - 40];
|
||||
else if (v === 49) this.attr.bg = null;
|
||||
else if (v >= 90 && v <= 97) this.attr.fg = PALETTE[v - 82];
|
||||
else if (v >= 100 && v <= 107) this.attr.bg = PALETTE[v - 92];
|
||||
else if (v === 38 || v === 48) {
|
||||
var t = v === 38 ? "fg" : "bg";
|
||||
if (p[i + 1] === 5) { this.attr[t] = color256(p[i + 2]); i += 2; }
|
||||
else if (p[i + 1] === 2) {
|
||||
this.attr[t] = "rgb(" + p[i + 2] + "," + p[i + 3] + "," + p[i + 4] + ")";
|
||||
i += 4;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Term.prototype._mode = function (p, on) {
|
||||
for (var i = 0; i < p.length; i++) {
|
||||
var v = p[i];
|
||||
if (!this.priv) { if (v === 4) this.modes.insert = on; continue; }
|
||||
if (v === 25) this.modes.cursor = on;
|
||||
else if (v === 7) this.modes.wrap = on;
|
||||
else if (v === 1) this.modes.appCursor = on;
|
||||
else if (v === 2004) this.modes.bracketed = on;
|
||||
else if (v === 47 || v === 1047 || v === 1049) {
|
||||
if (on && !this.modes.alt) {
|
||||
this.altSaved = { screen: this.screen, cx: this.cx, cy: this.cy };
|
||||
this.screen = [];
|
||||
for (var k = 0; k < this.rows; k++) this.screen.push(blankLine(this.cols));
|
||||
this.cx = 0; this.cy = 0; this.vy = 0;
|
||||
this.modes.alt = true;
|
||||
this.needFull = true;
|
||||
} else if (!on && this.modes.alt && this.altSaved) {
|
||||
this.screen = this.altSaved.screen;
|
||||
this.cx = this.altSaved.cx;
|
||||
this.cy = this.altSaved.cy;
|
||||
this.modes.alt = false;
|
||||
this.needFull = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Term.prototype._put = function (ch, cp) {
|
||||
var wide = isWide(cp);
|
||||
if (this.cx >= this.cols) {
|
||||
if (!this.modes.wrap) this.cx = this.cols - 1;
|
||||
else { this.cx = 0; this._lf(); }
|
||||
}
|
||||
if (wide && this.cx === this.cols - 1) { this.cx = 0; this._lf(); }
|
||||
var line = this.line(this.cy);
|
||||
if (this.modes.insert) { line.cells.splice(this.cx, 0, blankCell()); line.cells.length = this.cols; }
|
||||
line.cells[this.cx] = { ch: ch, fg: this.attr.fg, bg: this.attr.bg, fl: this.attr.fl, cont: false };
|
||||
this.cx++;
|
||||
if (wide && this.cx < this.cols) {
|
||||
line.cells[this.cx] = { ch: "", fg: this.attr.fg, bg: this.attr.bg, fl: this.attr.fl, cont: true };
|
||||
this.cx++;
|
||||
}
|
||||
this.dirty.add(this.cy);
|
||||
};
|
||||
|
||||
Term.prototype._reset = function () {
|
||||
this.attr = { fg: null, bg: null, fl: 0 };
|
||||
this.modes.alt = false;
|
||||
this.screen = [];
|
||||
for (var i = 0; i < this.rows; i++) this.screen.push(blankLine(this.cols));
|
||||
this.cx = 0; this.cy = 0; this.vy = 0;
|
||||
this.scrollTop = 0; this.scrollBot = this.rows - 1;
|
||||
this.needFull = true;
|
||||
};
|
||||
|
||||
// ---- rendering --------------------------------------------------------
|
||||
|
||||
Term.prototype._scheduleRender = function () {
|
||||
if (this._raf || this.disposed) return;
|
||||
var self = this;
|
||||
this._raf = requestAnimationFrame(function () { self._raf = 0; self._render(); });
|
||||
};
|
||||
|
||||
Term.prototype._render = function () {
|
||||
if (this.disposed) return;
|
||||
var total = this.sb.length + this.screen.length;
|
||||
var maxVy = Math.max(0, total - this.rows);
|
||||
if (this.vy > maxVy) this.vy = maxVy;
|
||||
var start = total - this.rows - this.vy;
|
||||
var focused = this.mount.contains(document.activeElement);
|
||||
// The alternate screen is where the cursor matters most: vi, less and htop
|
||||
// all run there, and an editor with no cursor tells you nothing about where
|
||||
// you are typing.
|
||||
var cursorRow = this.vy === 0 ? this.cy : -1;
|
||||
|
||||
for (var y = 0; y < this.rows; y++) {
|
||||
var idx = start + y;
|
||||
var line = idx < this.sb.length ? this.sb[idx] : this.screen[idx - this.sb.length];
|
||||
var live = (y === cursorRow);
|
||||
// Repaint only what changed, plus the two rows the cursor can have moved
|
||||
// between.
|
||||
if (!this.needFull && !this.dirty.has(y) && !live && y !== this.lastCursorRow) continue;
|
||||
this.rowEls[y].innerHTML = this._rowHtml(line, live, focused);
|
||||
}
|
||||
this.dirty.clear();
|
||||
this.needFull = false;
|
||||
this.lastCursorRow = cursorRow;
|
||||
};
|
||||
|
||||
Term.prototype._rowHtml = function (line, live, focused) {
|
||||
if (!line) return "";
|
||||
var html = "", style = null, buf = "";
|
||||
var flush = function () {
|
||||
if (!buf) return;
|
||||
html += style ? '<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; }
|
||||
};
|
||||
};
|
||||
})();
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user