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.
./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>.
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>locatestenax-serveron the host. Without it the remote command looks onPATHfirst, then at$HOME/.cargo/bin/tenax-serverand$HOME/.local/bin/tenax-server, which covers bothcargo installanddeploy.shon a host whose non-interactivePATHis 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$HOMEwould reach the remote shell literally and never expand (/home/you/.local/bin/tenax-server, not~/.local/bin/tenax-server).
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.
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).
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.
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
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
No ssh and no credentials are involved on that path: the address, the fingerprint and the secret all come from the bundle.
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.
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.
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.
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.
MIT or Apache-2.0, at your option.