DocsSign-in and safety
Web terminal
On this page
GET /ws/term?host=<id>&cols=&rows=&mode=default|shell upgrades to a WebSocket that runs the system OpenSSH client in a Bun.Terminal PTY. Binary frames are raw PTY bytes in both directions. Text frames are JSON: the client sends {type:"resize",cols,rows} and {type:"ping"}; the server sends {type:"hello",host,target,route,recording,startup} and {type:"exit",code}.
Enabling it, step by step#
A terminal is a real shell on a host, opened from the browser, so set it up on purpose. It is off until you do all of this.
-
Make a key just for terminals, different from the collector key, and an account on the host to match. The terminal logs in as a person would; the collector only needs to read metrics. See security.md.
ssh-keygen -t ed25519 -f /etc/yarrix/terminal_key -N "" -C yarrix-terminal # on the hub's machinePut the public key in
~/.ssh/authorized_keysof the account you want people to land in (not the collector account), and keep the private key readable only by the account the hub runs as. -
Switch the feature on in
yarrix.yaml:terminal: enabled: true record: true # keep an asciicast of each session's output (never input) -
Give the host a terminal target in
hosts/<id>.yaml:terminal: target: ops@atlas.example.internal # user@host[:port] identity_file: /etc/yarrix/terminal_key # jump: [bastion] # optional hops: a host id that has an ssh.target, or user@hostUse a different key per host when you can (security.md).
If you leave
identity_fileout, the terminal uses the host'sssh.identity_fileonly whenterminal.targetis exactly the same asssh.target(same user, host and port). Any other target is offered no key unless you set one here, so the collector key never opens a different account. -
Say who may open it: admins can open any host; an operator needs the host id (or
*) in theirauth.usersrule (terminal: [atlas]); a viewer never can. -
Serve the hub over HTTPS (or open it on
localhost). The terminal refuses plain HTTP, as explained in auth.md.Under Docker the
localhostshortcut does not apply: published ports reach the hub from the bridge gateway, not from loopback, so the terminal is refused over plain http even athttp://127.0.0.1:7790. Put the hub behind TLS or setYARRIX_PUBLIC_URLto anhttps://URL. The check is deliberately not loosened.
A host's first terminal connection trusts its SSH host key on first use unless you pin it (security.md).
Startup command examples#
The startup command runs when the terminal connects; {user} and {host} are filled in. A multiplexer keeps the session alive on the host, so a dropped browser reattaches to the same screen (see "What happens when the socket closes" below):
| Command | Result |
|---|---|
tmux new -A -s yarrix-{user} | One tmux session per person, created or reattached |
zellij attach -c yarrix-{user} | The same with zellij |
herdr | Opens the herdr multiplexer, if it is installed on the host |
"" (empty) | A plain login shell, no multiplexer |
The command must exist on the host. If it does not, the shell exits with 127 within a few seconds and the client shows "Didn't start" with an "Open plain shell" button.
Switches#
All three must allow it, and the terminal is off by default:
terminal.enabled: trueinyarrix.yaml.- No file at
$YARRIX_DATA_DIR/terminal.disabled. It is read on every upgrade, and a 2 s sweep closes live sessions with4004.touchit to stop every terminal at once. - A
terminal.targeton the host.
Only a browser session opens a terminal, never an API token. The session must be younger than 12 hours (older ones are asked to sign in again), a viewer never gets one, and an operator needs the host, or *, in their auth.users rule; an admin may open any host. The hub must be served over HTTPS (YARRIX_PUBLIC_URL starting https://, or an HTTPS request), or reached on loopback with no proxy in front. The Origin must match YARRIX_PUBLIC_URL, or the request's own Host when no public URL is set, and it is checked before anything else.
Startup command#
terminal.startup_command in yarrix.yaml is the default, and terminal.startup_command in a host file overrides it. Absent means inherit; "" means the plain login shell. {user} and {host} are replaced with the username and host id. Each is reduced to [a-z0-9_-] (19 characters at most) with a 12-character hash of the original appended, so two users whose names reduce alike never share a session (ann becomes ann-49915e0d7d4b). The command runs as exec "$SHELL" -lc '<command>' so the login profile is read; set login_shell: false on a host to send it as-is. A client can pick mode=shell for a plain shell, but cannot send a command of its own.
Example: tmux new -A -s yarrix-{user} reattaches to the same session after a dropped socket.
Close codes#
1000 shell exited (an exit frame carries the code, e.g. 127 for a missing command, and the close reason is exit; any other reason at 1000 means the browser ended the session), 1011 spawn or ssh failed (the reason is ssh's last line), 1013 capacity or a stalled client, 4004 disabled at runtime, 4403 not permitted for this host, 4408 idle, 4409 host asleep or unreachable.
Limits and records#
terminal.max_sessions (4), 2 sessions per user, terminal.idle_timeout (30m), 10 opens per minute per user, cols 1 to 1000, rows 1 to 500. A client whose socket buffer stays above 1 MB for 2 s, or passes 8 MB, is closed with 1013.
Each session writes a term_sessions row and term.open / term.close audit rows. With terminal.record: true, output (never input) is written to $YARRIX_DATA_DIR/recordings/<session>.cast as asciicast v2 and removed after 30 days.
HTTP routes#
GET /api/v1/hosts/{id}/terminal: the preflight. It runs the upgrade's checks in the same order (minus the rate limit) and answers{host, available, reason, user, target, route, startup, recording, playback}.reasonis one ofoff,killed,unknown_host,no_target,not_permitted,viewer,session_too_old,https,origin(the page is not atYARRIX_PUBLIC_URL's host, so the socket would be refused),asleep, or null. The preflight answersoriginonly for a page whose host differs from the public URL's; it does not compare schemes, which the upgrade does.startupis the command this caller's default session runs with placeholders expanded, null for the login shell. It exists because a browser reports every refused upgrade as close1006, so the client asks first, and asks again after a socket that never opened.GET /api/v1/hosts/{id}/terminal/sessions: the newest sessions on the host, with arecordingflag. Recording paths never leave the hub.GET /api/v1/terminal/recordings/{session}: one asciicast v2 file. Each playback writes aterm.playbackaudit row.
Both session routes need an admin browser session. An API token is refused whatever its scope, because a recording holds whatever the remote printed.
Browser client#
The terminal lives in the host drawer (Systems, then a host). The header shows user@host, the route (direct or via …), the startup command, "session recorded" when recording is on, Pop out and End session. Pop out opens /term/<host> in a window of its own; the hub serves the app there with a <base href="../">, so the page's assets resolve from the root.
xterm.js 5.5 renders it, with the fit and clipboard addons. The WebGL addon is loaded lazily and attached after open(); if WebGL is missing or its context is lost, the DOM renderer takes over (3 recoveries at most) and the terminal keeps working. OSC 52 is write-only: a program on the host can copy into the browser clipboard but can never read it back. minimumContrastRatio is 4.5, so a pair a program paints itself (black on a green bar, say) is lifted to AA in both themes.
What happens when the socket closes:
| Close | With a startup command | Login shell |
|---|---|---|
Transport drop (1006, 1001, anything unlisted) | Reattach by itself after 0.5, 1, 2, 4, 8 s, then every 8 s; the screen is reset for the multiplexer to repaint | Stop at "Disconnected" with a Reconnect button |
1000 with a non-zero exit within 3 s of opening | "Didn't start": the command, the exit code in words, and Open plain shell | Ended |
1000 otherwise | Ended | Ended |
4004 | Disabled, never retried, even on a socket that had not opened | same |
4403, 1013, 4408, 4409, 1011 | Not allowed, no session free, idle, asleep or unreachable, ssh failed: each says why and offers the fix | same |
| Never opened | Ask the preflight why, then show its answer; retry only when it has none | same |
Admins also get a Terminal sessions list in the drawer: when, who, what ran, how long and how it ended, with Play for the recorded ones. Playback is read-only, at 1x, 2x or 4x, with seeking; pauses longer than 2 s are shortened, and the font shrinks (to 8.5 px at the smallest) so a session recorded in a wide window fits the drawer.
Two switches in localStorage help with testing: yarrix.term.dev = "1" adds a Drop network button to the header (the client closes its socket with 3001 and treats it as a transport drop), and yarrix.term.webgl = "fail" makes the WebGL step throw, as a broken GPU would.
Config, Terminal defaults sets the default startup command, with presets for tmux, zellij, screen and herdr. On a host page, the Terminal section picks Use the default, Login shell or Run a command; those write no key, "", or the command to the host file, and the preview shows the YAML before Save. The startup command decides what runs on a host, so the hub accepts a change to it only from an admin signed in to the web app, never from an API token (403 exec_field_requires_admin_session). The same holds for the terminal target, jump hosts, identity file and the login-shell switch; for anyone else those fields are locked.