DocsSign-in and safety

Web terminal

View source on GitHubdocs/terminal.md

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.

  1. 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 machine
    

    Put the public key in ~/.ssh/authorized_keys of 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.

  2. Switch the feature on in yarrix.yaml:

    terminal:
      enabled: true
      record: true            # keep an asciicast of each session's output (never input)
    
  3. 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@host
    

    Use a different key per host when you can (security.md).

    If you leave identity_file out, the terminal uses the host's ssh.identity_file only when terminal.target is exactly the same as ssh.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.

  4. Say who may open it: admins can open any host; an operator needs the host id (or *) in their auth.users rule (terminal: [atlas]); a viewer never can.

  5. Serve the hub over HTTPS (or open it on localhost). The terminal refuses plain HTTP, as explained in auth.md.

    Under Docker the localhost shortcut does not apply: published ports reach the hub from the bridge gateway, not from loopback, so the terminal is refused over plain http even at http://127.0.0.1:7790. Put the hub behind TLS or set YARRIX_PUBLIC_URL to an https:// 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):

CommandResult
tmux new -A -s yarrix-{user}One tmux session per person, created or reattached
zellij attach -c yarrix-{user}The same with zellij
herdrOpens 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:

  1. terminal.enabled: true in yarrix.yaml.
  2. No file at $YARRIX_DATA_DIR/terminal.disabled. It is read on every upgrade, and a 2 s sweep closes live sessions with 4004. touch it to stop every terminal at once.
  3. A terminal.target on 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}. reason is one of off, killed, unknown_host, no_target, not_permitted, viewer, session_too_old, https, origin (the page is not at YARRIX_PUBLIC_URL's host, so the socket would be refused), asleep, or null. The preflight answers origin only for a page whose host differs from the public URL's; it does not compare schemes, which the upgrade does. startup is 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 close 1006, 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 a recording flag. Recording paths never leave the hub.
  • GET /api/v1/terminal/recordings/{session}: one asciicast v2 file. Each playback writes a term.playback audit 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:

CloseWith a startup commandLogin 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 repaintStop 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 shellEnded
1000 otherwiseEndedEnded
4004Disabled, never retried, even on a socket that had not openedsame
4403, 1013, 4408, 4409, 1011Not allowed, no session free, idle, asleep or unreachable, ssh failed: each says why and offers the fixsame
Never openedAsk the preflight why, then show its answer; retry only when it has nonesame

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.