Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

A terminal of the sandbox’s own

A sandboxed command can be given a pseudoterminal allocated for it: isatty is true on all three of its standard streams, a full-screen program behaves, /dev/tty resolves, and a shell inside the sandbox runs jobs of its own — against a terminal the caller does not share.

That last part is the point. Streaming output describes the two postures available without it, and both are compromises. Inheriting standard input makes an interactive command work, but everything that works there works by reaching into the caller’s session: the sandbox can read what is typed at the caller’s terminal, change its settings, and take its foreground process group. Not inheriting it closes that, and nothing that wants a terminal works. A terminal of the sandbox’s own gives both.

Starting one

A terminal is a launch mode rather than builder state, so it is passed to the launch and hands back a live resource:

use ferroday_cage::{Cage, Terminal};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cage = Cage::builder()
        .rootfs("/srv/rootfs/alpine")
        .command("/bin/sh")
        .env("TERM", "xterm-256color")
        .build()?;

    let (mut running, mut pty) = cage.spawn_terminal(&Terminal::new())?;
    std::io::copy(&mut pty, &mut std::io::stdout())?;
    let status = running.wait()?;
    assert!(status.success());
    Ok(())
}

Terminal states the window size and nothing else: Terminal::new() is 80 columns by 24 rows, a stated constant rather than anything read from the host, and Terminal::new().size(rows, cols) says otherwise. A zero dimension is left at its default: zero is what a terminal reports when its size is unknown, not a terminal with no rows, and a caller passing along what its own terminal reported should not have to filter that out. The replica is wired onto the command’s file descriptors 0, 1 and 2 — a terminal is one stream — and the launch owns its session, so the pseudoterminal is the sandbox’s controlling terminal whatever the standard-input disposition would otherwise have decided.

Pty is the caller’s end. It reads what the sandbox wrote to its terminal and writes what the sandbox reads as typed input.

Three things are the caller’s to supply, because the library reads nothing from the host:

  • TERM is an environment variable rather than terminal configuration, so set it with .env("TERM", ...). Without one, vim fails on first use.
  • The size, if the caller has a better answer than 80x24 — and afterwards, because a terminal’s size changes over the life of a session and no launch-time constant can track it. See Resizing.
  • The caller’s own terminal, which an interactive session must put into raw mode and restore afterwards. ferroday_cage::relay does both; what stays the caller’s is saying so. See The relay.

Draining, and the one order that deadlocks

The primary’s buffer is a few kilobytes. A command that writes more than that blocks, so a caller that waits without reading deadlocks — which is why run and output are not terminal entry points at all. When an unbounded wait() starts, the Pty is in one of three states:

  • Drained to end-of-file — safe. End-of-file means every replica is closed and the command is already gone.
  • Dropped — safe. After the hangup the command’s writes fail with EIO rather than block, so a caller that has dropped the primary owes the wait no drain.
  • Held and unread — the deadlocking case.

This is the same obligation Stdio::from_fd already carries for a pipe, with the same answer: where the caller owns both ends, the feed and the drain are the caller’s, and a wait that must be bounded is bounded.

A caller that sends no input drains to end-of-file and then waits. That terminates on its own — the example above is the whole pattern, and its second line is the part worth understanding.

An interactive caller needs both directions at once, and the relay is the answer: one poll loop that cannot be stalled by either direction, with the signal handling and the raw-mode discipline an interactive session also owes. Everything below is for a caller composing something else out of a Pty — a recorder, a test harness, a driver that is not a terminal session at all.

Both directions at once needs concurrency: writes to the primary block too once its input buffer fills, so a large paste into a command that is not reading stalls a loop that writes from the thread it also reads on. Two threads is the right minimum — a reader beside the main thread in the wait:

use std::io::Read as _;
use std::sync::Arc;

use ferroday_cage::{Cage, Terminal};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cage = Cage::builder()
        .rootfs("/srv/rootfs/alpine")
        .command("/bin/sh")
        .build()?;
    let (mut running, pty) = cage.spawn_terminal(&Terminal::new())?;
    let pty = Arc::new(pty);

    let reader = {
        let pty = Arc::clone(&pty);
        std::thread::spawn(move || {
            // `Read` is implemented for `&Pty`, so the thread borrows through
            // the `Arc` rather than taking the terminal away from the main
            // thread — which is what lets the size still be set from here.
            let mut out = std::io::stdout();
            std::io::copy(&mut &*pty, &mut out)
        })
    };

    // ... write to `&*pty`, resize it on SIGWINCH ...

    let status = running.wait()?;
    reader.join().expect("the reader thread completes")?;
    println!("exited with {status:?}");
    Ok(())
}

Read and Write are implemented for &Pty as well as for Pty, and resize takes &self, which is what makes that shape possible at all. Wrapping the Pty in a mutex instead is worse: the reader holds the lock while blocked in read, so a resize lands only when the sandbox next produces output.

Resizing

Pty::resize sets the window size and delivers SIGWINCH to the sandbox’s foreground process group, which is how a full-screen program learns to redraw. An interactive caller calls it on every SIGWINCH of its own, with the size its own terminal reports.

Pty::size reads the size back.

Closing input

There is no way to close the write side of a pseudoterminal. End-of-file is sent as the VEOF character — ^D in the baseline below — and that is a line discipline behavior rather than a property of the descriptor: it holds only while the replica is in canonical mode. A program that has put its terminal in raw mode, an editor or a shell reading a line itself, receives VEOF as the literal byte 0x04 and does whatever it does with it, and even in canonical mode VEOF on a partial line flushes that line rather than signalling end-of-file, so a strict end-of-input takes it twice.

A caller that needs end-of-input to be unambiguous drops the Pty, which hangs the terminal up and cannot be mistaken for data, or does not use a terminal at all — Stdio::from_fd on a pipe closes for real.

Dropping the primary is also how a session is ended deliberately: the kernel sends SIGHUP to the sandbox’s foreground process group. Dropping it early ends the session early, which is why it normally outlives the wait.

The termios baseline

The line discipline is stated by the library rather than inherited from anything, so it is a promise rather than whatever the kernel happened to default to. It is field for field the kernel’s own tty_std_termios:

GroupFlags
InputICRNL, IXON
OutputOPOST, ONLCR
ControlB38400, CS8, CREAD, HUPCL
LocalISIG, ICANON, ECHO, ECHOE, ECHOK, ECHOCTL, ECHOKE, IEXTEN
CharactersVINTR ^C, VQUIT ^\, VERASE DEL, VKILL ^U, VEOF ^D, VSTART ^Q, VSTOP ^S, VSUSP ^Z, VREPRINT ^R, VDISCARD ^O, VWERASE ^W, VLNEXT ^V
ReadsVMIN 1, VTIME 0; VEOL and VEOL2 unset

It governs line-mode behavior only. A full-screen application calls tcsetattr on the replica itself and takes the terminal from there.

One consequence is visible immediately: OPOST | ONLCR means a \n the command writes arrives at the primary as \r\n. A terminal rewrites what the sandbox wrote, so no reproducibility-sensitive path should ever use one. None can: a terminal arrives only from spawn_terminal, which captures nothing and takes no observer, so no capturing pipeline — output, an export, provisioning — can compose one.

What does not work

Path resolution for the terminal fails; descriptor operations work. The pseudoterminal is allocated on the host rather than in the sandbox’s own devpts instance, which is what keeps the feature small. The cost is that the replica’s device node lives in the host’s devpts while the sandbox’s /dev/pts is a private instance, so ttyname fails inside the sandbox. Affected: tty(1), who, and GPG_TTY, which gpg populates from tty and pinentry then opens.

Not affected: isatty, tcgetattr, tcsetattr, the line discipline, the window size, job control, and /dev/tty — which is the character device 5:0, a per-process redirect to the opener’s own controlling terminal, so what it yields inside a terminal launch is the sandbox’s own terminal.

The private devpts stays mounted, so nested allocation — tmux, script, sshd, Python’s pty — works normally and still sees no host pseudoterminal but its own. The sandbox holds exactly one descriptor to one host pseudoterminal: its own, whose only reader is the caller. TIOCSTI on it injects into its own input queue.

A terminal launch reports no launch milestones. The observer is the progress sink as well as the capture sink, and the terminal entry points take no observer, so Progress::Launching, Supervised, and Executing go unwitnessed. For an interactive session the loss is small: a human is watching the terminal itself.

Refusals

A terminal is all three standard streams, so a sandbox that directs one of them somewhere else has asked for two different things. The launch refuses it:

use ferroday_cage::{Cage, ConfigError, Error, Stdio, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder()
    .rootfs("/srv/rootfs/alpine")
    .command("/bin/sh")
    .stdout(Stdio::Null)
    .build()?;

let refused = cage.spawn_terminal(&Terminal::new());
assert!(matches!(
    refused.err(),
    Some(Error::Config(ConfigError::StreamAttachmentConflict { .. })),
));
Ok(())
}

Stdio::Inherit — the default on all three — states no destination, so it is not a contradiction: the launch is free to supply one. Only Stdio::Null and Stdio::from_fd name a destination of their own.

A profile cannot describe a terminal, and resolved_inputs() does not report one. Both follow from the same boundary: the record reports the plan — what every launch of the cage shares — and a launch-time attachment supersedes it at the one call site that made it. A terminal describes how the caller attaches rather than what the sandbox is.

With the network seam

Cage::spawn_pending_terminal is the composition an interactive session usually wants — a shell with outbound networking:

use ferroday_cage::{Cage, Terminal};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let cage = Cage::builder().rootfs("/r").command("/bin/sh").build()?;
let (pending, mut pty) = cage.spawn_pending_terminal(&Terminal::new())?;
// ... attach a stack to /proc/<pending.netns_pid()>/ns/net ...
let mut running = pending.proceed()?;
let _ = (&mut pty, &mut running);
Ok(())
}

The primary comes back beside the Pending rather than out of proceed, because the pseudoterminal is allocated before the fork and the gate sits after its controlling terminal is established. Nothing writes to the terminal while the launch is held, so the caller attaches its stack and proceeds with the primary already in hand.

The restriction fallback

Restriction::spawn_terminal gives the restriction fallback the same interactive story: a host-allocated pseudoterminal needs no devpts instance, so nothing about it depends on the namespaces a restriction does not have.

At a shell prompt

fcage --terminal is the whole of this from the command line:

fcage --rootfs /srv/rootfs/alpine --terminal -- /bin/sh

It allocates the terminal, copies this terminal’s current size into it, and relays the two. TERM passes through as it does for every fcage run, so --setenv TERM ... overrides it. It composes with --restrict and with --netstack; --terminal with --stdin null is refused at parse time, naming both flags. From a script, where fcage’s own input is not a terminal, it degrades to a plain byte relay around a sandbox that still has a real terminal of its own, at 80x24.

The relay

ferroday_cage::relay is the caller’s side of an interactive session: one poll loop over the sandbox’s terminal, the caller’s own terminal, and the signals a session has to handle, with raw mode and its restoration on every exit the process can observe.

use std::os::fd::AsFd as _;
use std::time::Duration;

use ferroday_cage::relay::{Escalation, Relay, Signals, install_panic_restore};
use ferroday_cage::{Cage, Terminal};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    // First of all: blocking the relayed signals is process-wide, and a thread
    // inherits the signal mask of the thread that created it. A failed install
    // leaves the mask as it found it, so a program that reports this and carries
    // on still answers a SIGINT.
    let signals = Signals::install()?;
    // Restores the terminal before a backtrace prints, which a guard cannot.
    install_panic_restore();

    let cage = Cage::builder()
        .rootfs("/srv/rootfs/alpine")
        .command("/bin/sh")
        .env("TERM", "xterm-256color")
        .build()?;
    let (mut running, pty) = cage.spawn_terminal(&Terminal::new().size(30, 100))?;

    let stdin = std::io::stdin();
    let stdout = std::io::stdout();
    let outcome = Relay::new(stdin.as_fd(), stdout.as_fd())
        .raw(true)
        .escalation(Escalation::new(
            Some(Duration::from_secs(3600)),
            Duration::from_secs(5),
        ))
        .run(&mut running, &pty, &signals)?;

    match outcome {
        Some(status) => println!("the session ended with {status:?}"),
        None => println!("the session ran out of time"),
    }
    Ok(())
}

What stays yours

The relay does nothing process-global as a side effect and reads nothing from the environment, so four decisions belong to the program driving the session:

  • Installing Signals, early and on the main thread, and keeping the handle. Blocking the relayed set is process-wide, so it is an explicit call rather than something run does on the way past — and it must precede every thread the run may start, because a thread inherits the mask of the thread that created it. The library starts one thread of its own — the native network stack’s pump — so a program composing a relay with a stack installs the signals before it attaches one.

    Nothing restores the mask, deliberately: a relay that unblocked on the way out would leave a window in which the signals it exists to handle were fatal again, and a program that relays one session usually relays another. What follows from that is the handle’s lifetime. Dropping it closes the descriptor and unblocks nothing, so the eight signals stay blocked with nowhere to be read from and the process then ignores SIGINT, SIGTERM, SIGHUP and SIGQUIT entirely — a shell’s ^C and a supervisor’s SIGTERM both do nothing, and only SIGKILL is left. For fcage the handle lives to process exit; a longer-lived program holds it for the program’s life, which is also the one-per-process shape the type asks for.

    That permanence belongs to a successful install. A call that blocks the set and then cannot open the descriptor — descriptor exhaustion, a seccomp policy refusing signalfd4 — restores the exact mask it found before it reports the failure, so a program that logs it and carries on is not left unable to answer SIGINT by the call that told it the relay was unavailable. The prior mask is restored rather than the relayed set unblocked, so a signal the program had blocked for its own reasons stays blocked.

  • Raw mode. Relay::raw is off unless asked. Whether the caller’s own standard input is a terminal is the program’s reading to make; fcage asks tcgetattr and passes the answer.

  • The panic hook. install_panic_restore() is one call, or a program with a hook of its own calls restore_terminal() from it.

  • The sandbox’s terminal. Terminal states a size; a program that wants its own window’s size reads it and passes it, because the library reads nothing of the host. See Resizing.

What the loop does

One thread, one poll: the primary, the signal descriptor, and the caller’s input — in that order, because the input is the one channel that retires and a retired channel must leave the polled set rather than be given an empty events mask. poll reports POLLHUP, POLLERR, and POLLNVAL whatever the requested events are, so a muted slot on a hung-up pipe is ready on every pass and the loop stops blocking; dropping the slot is what keeps producer | fcage --terminal ... from burning a core after the producer exits.

The primary is set non-blocking so a large paste cannot stall the loop; bytes that do not fit yet wait in a pending buffer behind POLLOUT, and the input is left unwatched while that buffer is occupied, so it cannot grow without bound. End of input sends VEOF to the primary — read from the replica’s current settings rather than assumed, since a program inside is free to have changed it — and retires that channel. An invalid input (fcage --terminal ... 0<&-) takes the same path, since it says the same thing. End-of-file on the primary ends the loop. An Escalation is the poll deadline, consulted on every wakeup rather than only on the one poll timed out on, so a sandbox printing without pause cannot postpone its own expiry.

Signals are read as data rather than handled: no library code runs in signal context, so there are no handlers, no async-signal-safety reasoning, and no shared flags. SIGWINCH resizes the sandbox’s terminal; SIGINT/SIGTERM/SIGHUP/SIGQUIT from outside terminate the sandbox and a second one kills it, while a typed ^C is a byte the sandbox’s own line discipline acts on; SIGTSTP restores and stops; SIGCONT re-enters raw mode and re-reads the size. The module’s own documentation carries the whole table, including why SIGTTOU is blocked and SIGTTIN deliberately is not.

The two directions are not symmetric: the caller-bound one is a plain blocking write, outside the poll. Making it symmetric would need O_NONBLOCK on the output descriptor, and that flag lives on the open file description rather than on the descriptor — setting it would set it for the caller’s shell and for every other process in the pipeline the program was started from. What is given up is that a stopped downstream reader stalls the loop, including the signal channel; ssh has the same shape for the same reason.

What restoration promises

Raw mode is entered last — everything fallible happens first, so a failure before the loop leaves the caller’s terminal untouched — and undone by three things that between them cover every exit the process can observe: a guard in run’s own frame, dropped on normal exit, on error, and on a panic unwinding through it; the optional panic hook, which restores before the default hook prints, because with OPOST off a backtrace prints stair-stepped and a drop runs after the hook; and the stop path, which restores before the process stops and re-enters raw mode when it continues.

SIGKILL and SIGSTOP are uncatchable and out of scope, with the same honesty the leaked-handle case gets: the remedy is stty sane, and no process can promise otherwise. Restoration means termios. Screen contents — an alternate screen left active, a hidden cursor — were written by the sandboxed program through the relay verbatim and belong to it, exactly as ssh treats them.

restore_terminal() reaches the first terminal a raw session was entered on, which is what a panic hook with no argument can name. Sequential sessions on the same terminal restore correctly, and so does every session’s own exit, because run restores from its own guard. A program relaying raw sessions on different terminals over its lifetime is the case the hook does not cover.

The worked consumer

fcage --terminal is this, configured: it reads whether its standard input is a terminal and what size it reports, installs the hook when it is one, and hands the relay descriptors 0 and 1. Everything else it does with a session is a library call. crates/ferroday-cage-cli/src/terminal.rs is the whole of what is left.