Skip to content

Latest commit

 

History

History
275 lines (220 loc) · 12.6 KB

File metadata and controls

275 lines (220 loc) · 12.6 KB

tenax

Persistent remote shell for Linux. Like ssh, but the server side owns the pty and the session state, so the session survives network drops: the client reconnects and resumes with no lost or duplicated output or input. The transport is QUIC over UDP with a self-signed certificate pinned by fingerprint; a per-server bearer secret gates every attach. ssh carries both once at startup, so the everyday command is just tenax user@host.

Quickstart

./install.sh              # build and install tenax + tenax-server into PATH
./deploy.sh user@host     # copy tenax-server to the host over ssh
tenax user@host           # connect

install.sh is just cargo install --path crates/tenax (and the same for crates/tenax-server) with a PATH check afterwards, if you prefer the bare commands. The remote host needs nothing besides the deployed tenax-server binary. deploy.sh is a convenience wrapper for a fresh clone: it builds the release binaries and hands them to tenax update <target>.

Run

tenax user@host

That is the whole thing. tenax runs tenax-server on the host over ssh, which forks into the background and hands the certificate fingerprint and the session secret back through the ssh channel; tenax then connects directly over QUIC and attaches. ssh runs once: after the first attach a resume bundle is stored locally and every later tenax user@host resumes over QUIC without ssh, surviving disconnects and reboots of your client.

The target is an ssh target, so ports, identities and jump hosts come from your ssh_config, not from tenax flags. Set TENAX_SSH to use something other than ssh. If you authenticate with a password, run ssh-copy-id user@host once: a burst of password connections can trip server-side rate limiting, which has bitten this in the field. Exiting the remote shell ends the session; tenax exits with the shell's exit code and the remote server process terminates. On a lost connection tenax reconnects with backoff indefinitely, reprinting how long it has been trying every 30 seconds; Ctrl-C while disconnected gives up and prints how to reconnect or start fresh (--new). A reboot of the server machine ends the session for good because the shell lives inside the daemon, but the client cannot tell that from a network outage — the closed UDP port is invisible to it, so it keeps retrying until you stop it. A second client attaching to the same session detaches the first (newest wins).

Two flags matter for less friendly networks:

  • --remote-port <n> pins the daemon's UDP port (it is ephemeral by default); a firewall between you and the host must let that UDP port through.
  • --remote-server-path <path> locates tenax-server on the host. Without it the remote command looks on PATH first, then at $HOME/.cargo/bin/tenax-server and $HOME/.local/bin/tenax-server, which covers both cargo install and deploy.sh on a host whose non-interactive PATH is minimal (ssh does not source shell rc files). Pass the flag when the binary lives somewhere else. The path must then be absolute: tenax single-quotes it, so a leading ~ or a $HOME would reach the remote shell literally and never expand (/home/you/.local/bin/tenax-server, not ~/.local/bin/tenax-server).

State bundle

After the first attach the client writes a resume bundle to $XDG_STATE_HOME/tenax/<target> (falling back to ~/.local/state/tenax/): key=value lines holding fingerprint, session, secret and addr, file mode 0600 in a 0700 directory. Later runs resume from it with no ssh and no environment; it is removed on clean exit. --new ignores any bundle and bootstraps a fresh session.

Manual mode (no ssh)

You can run the server yourself and connect without ssh. The server prints one line to stdout with three tokens — the bound UDP address, the SHA-256 fingerprint of the certificate DER, and the secret in hex:

tenax-server
127.0.0.1:40123 9f2c...64 hex... 71ab...64 hex...

Connect with the fingerprint as a flag and the secret in the environment (not argv, which shows in ps):

TENAX_SECRET=71ab... tenax 127.0.0.1:40123 --fingerprint 9f2c...

The inline form lands in shell history; to keep the secret out of it, read it in first:

read -rs TENAX_SECRET && export TENAX_SECRET
tenax 127.0.0.1:40123 --fingerprint 9f2c...

A host:port argument (a colon, no @) selects this direct mode; a resume bundle from a manual-mode session reconnects with just the address. The client trusts exactly the certificate hashing to the pinned fingerprint: no CA chain, no hostname, no expiry. A mismatch fails immediately with "server certificate does not match the pinned fingerprint" and is never retried; a wrong secret is rejected before it can touch the session. Server options: --shell, --replay-buffer (output kept for replay, default 8 MiB, oldest evicted), --detach-ttl (default 48 h), --daemon (fork into the background and print the pid as a fourth token), and --log-file <path> (where a daemon writes logs; without it a daemon logs nothing).

Updating

This machine first, then each server:

tenax update                # client and local server
tenax update user@host      # tenax-server on that host

tenax update with no target rebuilds from source: install.sh records the clone it installed from, and the update pulls it with git pull --ff-only and reruns cargo install for both binaries. Nothing is merged or rebased on your behalf — a diverged or dirty clone stops the update and leaves the decision to you — and the version reported at the end is read back from the binary that landed on disk. Relaunch afterwards to be running it. The equivalent by hand is still git pull && ./install.sh in the clone.

tenax update user@host sends the local tenax-server over one ssh connection and installs it where bootstrap discovery looks — over the binary already there, or into $HOME/.local/bin when there is none — then prints the version it replaced and the version now in place. A daemon that is already running keeps the old binary until it exits, so an existing session is never disturbed; tenax --new user@host starts a fresh one at the new version.

Updating from a signed binary release, rather than from source, waits for the release pipeline planned after v0.1.0.

How it works

First connection: ssh starts the remote server and carries the two tokens back.

sequenceDiagram
    participant C as tenax
    participant S as ssh
    participant D as sshd
    participant T as tenax-server
    C->>S: spawn ssh target, remote command is the discovery script
    S->>D: connect and authenticate, any password prompt is ssh's own
    D->>T: remote shell runs the script, execs tenax-server --daemon --listen
    T->>T: fork, child keeps the pty session, parent prints the token line
    T-->>C: addr, fingerprint, secret, pid over the ssh channel
    S-->>C: ssh exits
    C->>T: QUIC connect, certificate checked against the pinned fingerprint
    C->>T: prelude, Hello New carrying the secret
    T-->>C: prelude, ServerHello
    C->>C: write the resume bundle
    T-->>C: Data, pty output
Loading

Disconnect and resume: the bundle is enough, so ssh is out of the picture.

sequenceDiagram
    participant C as tenax
    participant T as tenax-server
    C-xT: link drops, packets stop
    T->>T: QUIC idle timeout, session detaches, output keeps buffering
    C->>C: QUIC idle timeout, reconnect loop with backoff and jitter
    C->>T: new QUIC connection, prelude
    C->>T: Hello Resume with last_server_seq
    T-->>C: prelude, ServerHello with last_client_seq
    T-->>C: OutputGap if the replay buffer had evicted those bytes
    T-->>C: Data, buffered output past last_server_seq
    C->>T: Data, input the server never acknowledged
    T-->>C: Data, live output
Loading

No ssh and no credentials are involved on that path: the address, the fingerprint and the secret all come from the bundle.

Threat model

The fingerprint authenticates the server to the client; the secret authenticates the client to the server. Both travel over the ssh channel during bootstrap, so trusting tenax user@host is exactly trusting ssh to that host — tenax adds no trust of its own there. In manual mode the two tokens travel by hand from the server's stdout. Auth failures are not rate limited yet; that is future work.

Credential prompts belong to ssh by design: tenax gives the ssh child its own stdin and stderr and never reads, wraps or re-renders a password or 2FA prompt, so no credential ever passes through tenax.

Known limitation: after a resume the terminal is not redrawn; fullscreen programs like vim or htop need a manual redraw (Ctrl-L). TERM is captured when the session is created, so resuming from a different kind of terminal keeps the original value, the same as tmux and screen.

Manual test

tenax user@host

Without a remote host, ./try-local.sh runs the whole thing on localhost: it builds, starts a server, and attaches a client to it in one step.

In the attached session: run vim and htop, resize the terminal window (both must redraw at the new size), quit them, then exit 7. The client must exit with code 7 (check with echo $?) and the remote server process must have terminated. For resume: kill the client (or drop the network), rerun tenax user@host (or tenax <addr> after try-local.sh), and the session must come back over QUIC with no ssh and buffered output replayed.

Fuzzing

Five libFuzzer targets under fuzz/, driven by cargo-fuzz. They build with the nightly named in fuzz/nightly-version, which both fuzz.sh and the ci job read: it is a pin rather than plain nightly because later nightlies hit an internal compiler error building tokio, which every target links.

pin=$(cat fuzz/nightly-version)
rustup toolchain install "$pin"
cargo +"$pin" install cargo-fuzz --locked

./fuzz.sh frame_decode 300

./fuzz.sh with no arguments lists the targets: frame_decode (arbitrary bytes into the frame codec, fed in chunks so its buffering across partial reads is exercised), frame_roundtrip (every frame survives encode and decode unchanged), replay_buffer (push and replay sequences against a small buffer), bundle_roundtrip (the state bundle survives a write and a read back) and ssh_hostname. ./fuzz.sh all 300 runs each in turn; with no seconds argument a run continues until interrupted.

Every run starts from the committed seeds in fuzz/seeds/<target>/ and accumulates its own corpus in fuzz/corpus/<target>/. A crash writes the input that caused it to fuzz/artifacts/<target>/, and that file is the bug report; replay it with

cargo +"$(cat fuzz/nightly-version)" fuzz run <target> fuzz/artifacts/<target>/<file>

Once the bug is fixed, the same file moves into fuzz/seeds/<target>/, where every later run and the ci job replay it as a regression test. The other seeds are not written by hand: cargo run --bin gen-seeds inside fuzz/ rewrites them.

Wire format

QUIC with ALPN "tenax", one bidirectional stream per connection. Protocol version 5.

Immediately after the stream opens, each side sends a 6-byte transport prelude: the magic TNAX followed by a big-endian u16 version. The server always answers with its own prelude before any frame, and both sides proceed only on an exact version match; on mismatch each names both versions and closes, without touching any session. This prelude replaces in-band version negotiation — postcard is not self-describing, so a cross-version frame would otherwise die as an opaque decode error — and its 6-byte layout is frozen forever. (The one exception it cannot cover is v3-to-v4 itself: v3 servers predate the prelude, so an upgraded client sees a decode error rather than the clean message against them.)

After the prelude the stream carries frames: each is a postcard-serialized Frame value prefixed with a u32 big-endian length; frames over 1 MiB are rejected. Frames: Hello (the bearer secret plus New with the client's TERM or Resume with the last seen output seq), ServerHello (session id and last processed input seq), Reject (refused handshake or superseded connection), Data (terminal bytes with a per-direction sequence number), Ack (server to client, acknowledges input), OutputGap (bytes lost to replay-buffer eviction), Resize, Ping/Pong, Exit (shell exit code). The client confirms a received Exit by closing the connection with application code 1.

After a client upgrade, a still-running old daemon answers with the older version and the client reports the mismatch and exits; tenax --new <target> bootstraps a fresh daemon at the new version, and the old one tears itself down once its detach TTL elapses.

License

MIT or Apache-2.0, at your option.