Terminal and one-off commands
The web terminal and one-off commands in an application's context.
Noust opens three things you used to need SSH for, and keeps a record of every one of them:
- A shell as an application, in the console: the account the application runs as, in its sandbox, in its directory, with its environment. For a Compose or container application, a shell in one of its containers.
- A command run once in an application, from the console or
noust app exec: the same account, sandbox and environment, no shell, its output kept in a job's log. - A root shell on the server itself, in the console, for administrators only, off until you turn it on.
A shell in the browser is an open door to an application's secrets. This page says what stands in front of the door, what is kept of everyone who goes through it, and how to change either.
- Using it
- The security model
- Recordings
- A command run once
- The server's own shell
- When the connection drops
- What the console's terminal does and refuses
- Configuration
- The ENS profile
- The API
- What it is not
Using it
In the console, an application's page has More actions > Terminal
(/apps/<domain>/terminal), and Server > More actions > Open a terminal
(/server/terminal) opens the server's. The page says what you are about to open and what is
kept of it. Open terminal asks you to confirm it is you (sudo mode), then connects. The strip
above the screen says where you are, as whom, when the session ends however busy it is, and how
long it may sit idle.
Keys go to the program, as in any terminal, with three that are the console's:
| Keys | What they do |
|---|---|
| Ctrl+C | Interrupts the program, as always. It is never a copy. |
| Ctrl+Shift+C, Ctrl+Shift+V | Copy the selection (or the whole screen when nothing is selected) and paste. |
| Shift+Page Up, Shift+Page Down, Shift+Home, Shift+End | Scroll back through what was printed, and return to the newest. Typing returns too. |
| Escape, then Tab | Leave the terminal with the keyboard. Tab belongs to the shell, so this is the way out for someone who cannot use a pointer. The Escape still reaches the program. |
The mouse wheel scrolls back as well, and double and triple clicks select a word and a line. On a full-screen program (an editor, a pager) the wheel sends arrow keys instead, because there is nothing to scroll back to.
Pasting into a program that asked for it is bracketed, so a pasted newline is text and not Enter. The text the program printed stays text: a pasted or printed escape sequence cannot make the console copy to your clipboard, open a link, or type for you (see What the console's terminal does and refuses).
Reading it without sight. The screen is drawn on a canvas, which a screen reader cannot read. Text view shows the same screen as text in a region the keyboard can scroll, and Read new output aloud announces new lines as they appear (off by default: a program that redraws the screen, such as an editor, would be read again each time).
Opening again. When a shell ends, the page says how ("The shell exited with status 3", "Closed:
nothing was typed for 10 min") and Open a new terminal starts another with the same choices.
Options chooses the shell (bash, sh, ash, zsh, dash; the server's default otherwise)
and, for a stack that runs several services, the service.
From the command line, a command run once is noust app exec:
noust app exec shop.example.com -- rails runner 'puts User.count'
noust app exec --service worker shop.example.com -- ./bin/statusThe program and its arguments follow --. They are given to the program as they are: -- echo 'a; b' prints a; b.
The security model
Every layer below is enforced on the server. The console only reflects them, which is why a request that skips the console meets all of them anyway.
Who may. apps.terminal opens a terminal or runs a command in an application: administrators
and, inside their team, tenants. It is not an operator's permission, because a shell as the
application reads the secrets the application has and changes what runs. A principal limited to
some teams reaches only the applications of those teams. The server's shell needs server.terminal
(administrators) and a principal that sees every team. On a fleet, each node also sets a ceiling
for its central: an application's terminal needs noust fleet authorize --access admin, and the
server's shell needs --allow-host-access as well.
Sudo mode. Asking for a terminal, and running a command, need sudo mode, like every destructive action: the console asks "Confirm it's you" and retries.
Approval. Opening a root shell on the server is one of the changes the four-eyes approvals
cover (terminal.server). With approvals on, a second person approves the request before the
ticket is issued.
A ticket, once. The call that asks for a terminal answers with a ticket: random, stored only as
a hash, valid for 20 seconds, bound to the credential that asked (and to its address, as sessions are
unless bind_session_to_ip is turned off), and spent the first time it is presented, even when that attempt is
refused. A ticket read out of a proxy's log is worth nothing. The WebSocket then passes the same
checks as every other: address, host, origin, the credential's own permission, the second-factor
and usage-notice gates.
Whose shell it is. For an application on this server, the shell is a process of the
application's own account (noust-app-<name>, or the account an older application already runs
as), started in a transient systemd unit with the same confinement as its builds and hooks:
the application's directory and .env, nothing of Noust's configuration, the store or root's
home. The environment is the application's, plus TERM and the locale. For a Compose or container
application, the shell is docker exec -it into one of its running containers, as the container's
user; Noust adds no privilege the stack does not already give its service.
Bounded in time and number. A session ends after terminal.idle_minutes with nothing typed and
after terminal.max_minutes in all; both are enforced by the process runner, not the browser, and
the console warns a minute before. terminal.max_sessions and terminal.max_sessions_per_user
bound how many are open. Whatever the console says, a session that no connection has attached to
for a minute is ended.
Recorded, or not opened. A session cannot start without a recording. See below.
Everything is audited (the audit log's closed catalog):
| Event | When |
|---|---|
terminal.ticket |
A ticket was issued. |
terminal.start |
A shell opened: who, where, as whom, from which address. |
terminal.end |
It ended: why, how long, the exit status, and the recording's digest. |
terminal.kill |
An administrator ended somebody else's terminal. |
terminal.exec |
A one-off command was queued and finished: the command, how it ended. |
terminal.denied |
A terminal was refused: a limit, a ticket that did not match, a switch that is off. |
terminal.recording.read |
Somebody read a recording. |
Administrators can see the open sessions (GET /api/terminal/sessions) and end one
(DELETE /api/terminal/sessions/{id}).
Recordings
Each session is recorded, input and output, in an
asciicast v2 file, so any asciinema player
plays it and nothing from Noust is needed to read it. Beside it, a small .json says how the
session ended and holds the SHA-256 of the cast, which the terminal.end audit event repeats. The
audit log is HMAC-chained, so a recording that no longer matches its digest was changed after the
fact, and noust audit recording says so first.
They live in terminal-recordings/ beside the store, 0600, and are kept for
retention.terminal_days (90 by default; under the ENS profile at least as long as the audit log).
- Bounded fairly. A recording takes at most
terminal.recording.max_mb. Output has 80% of it and what was typed the rest, so a flood of output (yes,catof a large file) fills its share and is replaced by one marker while what was typed keeps being recorded. - Scrubbed. Every secret value Noust knows of the application (its marked variables, URLs with
passwords) and Noust's own credentials are replaced by
***before anything is written, including a value split across two reads. - Passwords are not recorded. What is typed while the program has echo off in line mode (a
password prompt) is replaced by
<hidden: the program asked for a secret>. - What Noust does not know to be a secret is recorded as it was. A recording is an audit
trail, not a vault: reading one needs
audit.read, and is itself audited.
noust audit recordings # newest first; --app, --actor, --kind app|server
noust audit recording 20261006T091203Z-1f2e3d4c5b6a7988 # what was printed and typed
noust audit recording ID --no-input # only what was printed
noust audit recording ID --format cast # the asciicast, for a playerThe text form removes the program's escape sequences, because printed as they are they would be obeyed by your own terminal.
A command run once
POST /api/apps/{domain}/exec, the console's Run a command once and noust app exec are one
implementation:
- The command is an argument vector. A shell never sees it: a pipe, a redirection or
$VARreaches the program as text. The console splits what you type on spaces and quotes, shows you the list it will run, and says when an argument holds something a shell would have interpreted. - It runs as the application's account in its sandbox, in its directory, with its environment. For
a stack, in a container of the chosen service (
docker exec, no TTY). - From the console it is a job: its log is the output, scrubbed of the application's secrets, read while it runs and once more when it ends. At most 2 MiB are kept, with a marker where the rest was dropped. It never changes the application's state in the header, because a command is work done in an application, not something that happens to it.
- It stops after
--timeoutseconds (300 by default, at mostterminal.exec.max_seconds). A command that exits with anything but 0, or that did not finish, is a failed job whose log says how, andnoust app execexits with the command's own status. - In a container, stopping a command leaves the process Docker started running there: Docker has
no way to signal it from outside. Use a timeout you can live with, and
docker compose execto look for what is left.
The server's own shell
It is root, with nothing in between: the shell an administrator gets over SSH. That is why it is a switch, off until you turn it on, and why the console says so on the page before opening it.
noust config set terminal.server.enabled trueThe compliance control server_terminal can keep it off even then. A server where nobody chose a
framework can have it refused on its own (noust compliance set server_terminal refused), and
noust compliance set server_terminal allowed undoes that. A framework that holds the control
(ENS, ISO 27001) keeps it off unless you also force it, which is put on record, or
turn the framework off:
noust config set terminal.server.force_ens trueWhen it is off the page says exactly what keeps it off (the switch, the control, or the framework
by name) and prints the command that changes it, and GET /api/terminal/status carries the same
in server.reason and server.how, and as a sentence with a code
in server.coded: terminal.server_off, terminal.server_control or terminal.server_framework,
with the control, the frameworks and the command as its values. Opening a terminal that is off
is refused with the same sentence. On a server that asks for a reason with every change the
command carries --reason "<why>". Everything else in
The security model applies, with the four-eyes approval on top.
When the connection drops
A WebSocket that drops does not end the shell. The server keeps it for 60 seconds, holding up to 256 KiB of what it printed meanwhile, and the console asks for a new ticket that resumes the same session, with the backoff every stream of the console uses. The page says "Reconnecting", and on return "Reconnected to the same shell" and, if the output outgrew the backlog, that some of it was lost. A reload within the minute finds the shell. After the minute the session is ended and recorded as such.
Closing the tab does not end the shell either: End session does. A session ends at once when the credential it was opened with stops being valid (the session was signed out, the account disabled).
What the console's terminal does and refuses
The console cannot use xterm.js: its content security policy forbids the <style> element the
library injects. The terminal is its own, in panel/src/lib/terminal/ (the emulator) and
panel/src/components/ui/terminal/ (the drawing on a <canvas>, set through the CSSOM).
It understands the subset of xterm's sequences real programs use: cursor movement and saving, erase, insert and delete of lines and characters, scroll regions, the alternate screen, tab stops, the 16, 256 and true colours of SGR with bold, dim, italic, underline, inverse, hidden and strike, the DEC line-drawing set, and the modes that change what is drawn or what keys send (application cursor keys, origin, auto-wrap, bracketed paste). Wide and combining characters take the cells they should.
It refuses, because each is a way for output to act on the person reading it:
- It never writes your clipboard (OSC 52) and never opens a link (OSC 8).
- It never reports the window's title or size in pixels back as input (
CSI 21 t,CSI 14 t), the replies a hostile file once used to type into a shell. - It answers only queries whose answer is fixed or a position: device status, cursor position and the two device attribute reports.
- It never sends mouse reports. A program that asks for the mouse gets none, and the console's own selection keeps working.
- A paste can never close its own bracket: an end-of-paste sequence inside the pasted text is removed.
- A sequence it does not know is ignored in bounded memory: an unterminated string is dropped after 4,096 characters, and a run of ignored bytes after 65,536.
The colours are the console's own tokens, read where the screen is drawn and read again when the theme changes, so a program's red is the console's failure red in both themes.
Configuration
| Key | Default | Meaning |
|---|---|---|
terminal.enabled |
true |
Whether the console may open a terminal into an application. Off, one-off commands remain. |
terminal.idle_minutes |
10 |
Nothing typed for this long ends a terminal. 1 to 120. |
terminal.max_minutes |
120 |
The longest a terminal lasts, however busy. 1 to 720. |
terminal.max_sessions |
8 |
Terminals open at once. 1 to 64. |
terminal.max_sessions_per_user |
3 |
Terminals one person may have open. 1 to 16. |
terminal.recording.max_mb |
8 |
The most one session's recording may take. 1 to 1024. |
terminal.server.enabled |
false |
A root shell on the server in the browser. |
terminal.server.force_ens |
false |
Allows it under the ENS profile, on record. |
terminal.exec.max_seconds |
1800 |
The longest a one-off command may run. 1 to 86400. |
retention.terminal_days |
90 |
Days recordings are kept. |
A value that cannot be read falls back to its default: a limit is never the thing that fails closed into "no limit".
The ENS profile
Under security.profile: ens-medium the limits are capped and the server's shell is closed:
| Standard | ENS | |
|---|---|---|
| Idle limit | as configured | 10 minutes at most |
| Total limit | as configured | 60 minutes at most |
| The server's root shell | when terminal.server.enabled |
refused unless terminal.server.force_ens as well, which is audited; with approval |
| Recordings kept | retention.terminal_days |
at least as long as the audit log |
Every session is recorded in either profile, so the controls op.acc.6.4 (session limits) and op.exp.8 (records of activity) are met by the terminal as they are by the console.
The API
| Route | Permission | |
|---|---|---|
POST /api/apps/{domain}/terminal |
apps.terminal, sudo mode |
A ticket for a terminal in an application. Body: rows, cols, shell, service, resume. |
POST /api/server/terminal |
server.terminal, sudo mode, approval |
The same for the server. |
POST /api/apps/{domain}/exec |
apps.terminal, sudo mode |
Queues a one-off command (argv, service, timeout) and answers 202 with the job. |
GET /api/terminal/status |
apps.read |
The switches and limits, which the console reads to offer a terminal or say why there is none. |
GET /api/terminal/sessions, DELETE /api/terminal/sessions/{id} |
apps.terminal |
Open sessions in the caller's teams; end one. |
GET /api/terminal/recordings, GET /api/terminal/recordings/{id} |
audit.read |
Recordings and one with its events and verdict. |
WS /ws/terminal?terminal_ticket=... |
apps.terminal |
The session. Authenticated like every WebSocket of the console. |
The socket speaks JSON text frames. The client sends input (data), resize (rows, cols),
ping and close. The server sends ready (who, where, ends_in, whether this is a resumed
session, whether output was lost), output (data), notice (a limit kind and the seconds
left), ended (reason, exit_code), error (code, message) and pong. A console that cannot
keep up makes the program wait instead of making the server hold the output in memory.
On a central, /ws/nodes/{node}/terminal and the node's routes are relayed over the node's tunnel:
a central re-implements nothing a node does, and the node's own permissions and ceilings apply.
What it is not
- Not a replacement for SSH. It is a shell for the work an operator does through a console. A session is bounded in time, recorded, and ends when the console does.
- Not a boundary between applications that share an account, any more than the rest of Noust is: a shell as an application can do what the application can.
- Not tamper-proof against root on the machine. A recording's digest is in a chained log, which
makes changing it detectable, not impossible; ship the audit log off the machine for that
(
audit.syslog,audit.journald). - No file transfer, no port forwarding, no multiplexing. One session is one shell.