SSH from machines whose only way out is HTTPS.
podssh is one static binary. It is an SSH client, and it can carry any TCP
stream, through a WebSocket relay on port 443. It is for closed sandboxes:
CI runners, containers of AI agents, hosted notebooks. Such hosts have no
inbound ports and no direct outbound TCP. Often they have no working DNS, and
an HTTP proxy is their only way out. podssh needs no root, no LD_PRELOAD,
no installed ssh, and no TLS or crypto library of the system.
Warning
Status: beta. podssh ssh, podssh each, podssh run, podssh job, podssh session,
podssh containers,
podssh proxy, podssh pipe, podssh cp, podssh mv, podssh scp,
podssh sftp and podssh edit (files), podssh doctor, podssh keygen,
podssh agent, podssh man, podssh status, podssh ping,
podssh health, the reverse road (podssh node, podssh operator,
podssh relay), podssh chat, podssh play (recordings),
podssh detached (the runs of --detach, by their names),
podssh config (settings), podssh mcp and podssh update (this
binary, from a signed release) work.
Tests run them against OpenSSH and Dropbear servers, through the live
relay, and in a box like the target sandbox. The measured state of main
is in docs/STATUS.md, where a row of work after v1.0.0
names the first release that holds it, or says unreleased.
The plan is in docs/ROADMAP.md.
podssh --TLS 1.3 + WebSocket, port 443--> relay --TCP--> sshd (or another TCP service)
`-- through HTTPS_PROXY (HTTP CONNECT) when the host has one
- The relay (
tcp.ssh.relay.ajam.dev, a Cloudflare Worker) copies bytes between a WebSocket and a TCP socket. Its contract is at/llms-full.txt. docs/relay.md gives the parts that podssh uses. - Encryption. SSH encrypts the session between podssh and the server. The relay can see the target, the time and volume of the traffic, and the start of the SSH handshake. The host-key check makes a relay in the middle safe. See SECURITY.md.
- Outbound only, unless you ask. podssh opens outbound connections only
(to the relay, or to the proxy that
HTTPS_PROXYnames). It listens only on an address that you name, ofpodssh pipe(unix-listen:,tcp-listen:,http-listen:, an HTTP CONNECT proxy, andws-listen:, a WebSocket server) or ofpodssh ssh -Land-D, and on the socket ofpodssh session start, your own, after the bind shows that the host allows it. The iroh road, in each release binary and in a build with the featureiroh, binds UDP for direct paths only after a probe allows it. A node that you give a UDP target (podssh node NAME udp:HOST:PORT) sends UDP from its host, after a probe. - Fallbacks. If one relay host fails, podssh tries the next one. If DNS fails, podssh uses pinned addresses or DNS over HTTPS. If the relay goes silent, podssh finds it in 30 to 40 s.
- No local pty needed. A remote pty (
-tt) works when stdin and stdout are pipes. Full-screen programs and Ctrl-C work on a host that has no/dev/ptmx.
-
Get a static binary for the host: a beta from the releases page (each
v1.0.0-beta.Nis a prerelease), or build one (see "Build"). Check it againstSHA256SUMS, and make it executable:T=v1.0.1; B=podssh-x86_64-unknown-linux-musl # or podssh-aarch64-unknown-linux-musl curl -fsSLO "https://github.com/PodUtils/podssh/releases/download/$T/$B" curl -fsSLO "https://github.com/PodUtils/podssh/releases/download/$T/SHA256SUMS" grep " $B\$" SHA256SUMS | sha256sum -c - && chmod +x "$B"
From
v1.0.0-beta.2on, a release also haspodssh-armv7-unknown-linux-musleabihf(a Raspberry Pi with 32 bits) andpodssh-riscv64gc-unknown-linux-musl, both static;podssh-aarch64-apple-darwinandpodssh-x86_64-apple-darwin(macOS, where the check isshasum -a 256 -c); andpodssh-aarch64-pc-windows-msvc.exe(Windows on ARM). The Mac binaries are not signed with a Developer ID: fetch them withcurl, as above, which marks no quarantine; after a browser's download, runxattr -d com.apple.quarantine FILEfirst. The Windows binaries are not signed either, and SmartScreen may warn about them.From
v1.0.0-beta.2on, the release workflow also proves where each binary comes from.podssh --versionnames the commit of the tag. The provenance of each binary names the workflow and the commit (with the GitHub CLI):gh attestation verify "$B" --repo PodUtils/podssh \ --signer-workflow PodUtils/podssh/.github/workflows/release.yml --source-ref "refs/tags/$T"
And
SHA256SUMSis signed by the workflow at the tag (with cosign 3.x):curl -fsSLO "https://github.com/PodUtils/podssh/releases/download/$T/SHA256SUMS.sigstore.json" cosign verify-blob --bundle SHA256SUMS.sigstore.json \ --certificate-identity "https://github.com/PodUtils/podssh/.github/workflows/release.yml@refs/tags/$T" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com SHA256SUMS
-
Run
podssh doctor. It tells what the host allows and whether the full path works: oneok,FAILor????line for each check (????is a check that could not run). It exits 1 if a check failed. -
Run
podssh man. It is the whole manual: each command, flag,-okeyword, variable, file and exit code, with examples. The binary makes it from its own tables and needs nothing else.podssh man sshgives one section;podssh man --no-pagerwrites it all to stdout. -
If the host has no working
ssh-keygen, make a key with podssh:podssh keygen -t ed25519 -N '' -f ~/.ssh/id_ed25519 # -N '' for no passphrase podssh keygen -l -f ~/.ssh/id_ed25519.pub # its fingerprint; the private file works too podssh keygen -t ed25519-sk -f ~/.ssh/id_ed25519_sk # in a FIDO2 security key (Linux, Windows)
-
On the far hosts,
podssh jobandpodssh ssh --persistneed tmux. Without it they exit 255 and name the command that installs it from the host's package manager; podssh runs none:sudo apt-get install -y tmux # Debian, Ubuntu sudo dnf install -y tmux # Fedora, RHEL apk add tmux # Alpine, as root
Where tmux cannot be installed,
podssh job start --no-tmux HOST -- COMMANDruns the job under nohup instead (podssh man job).
podssh is the SSH client, with the options of OpenSSH:
podssh ssh user@example.org # an interactive shell
podssh ssh user@example.org 'uname -a' # a command; its exit status is podssh's
podssh ssh -i key -o StrictHostKeyChecking=accept-new user@example.org true
podssh ssh -J user@bastion user@inner # through a jump host
podssh ssh -A user@build 'git pull' # the server may use your agent while the session lasts
podssh ssh -W db.internal:5432 user@bastion # stdin and stdout to a TCP port; nothing listens
podssh ssh -N -L 5432:db.internal:5432 user@bastion # a local port to the database, if this host may listen
podssh ssh -N -D 1080 user@bastion # a SOCKS proxy on 127.0.0.1:1080
podssh ssh -M -N -S /tmp/cm-%C user@host & # Unix: later runs with -S /tmp/cm-%C share this connection
podssh ssh -tt user@example.org < script.txt # a remote pty; local stdin is a pipe
podssh ssh -tt --expect 'Choice: ' --send '2\r' user@menu # keys for a prompt after the login, then the session
podssh containers user@example.org # the containers of docker, podman and kubectl, each with the command that enters itFiles go over SFTP, or by exec where the server has no SFTP, verified by
SHA-256 before they take the destination's name; mv then removes the
source:
podssh cp report.pdf user@example.org:docs/ # up, into a directory
podssh cp user@example.org:logs/app.log . # down
podssh mv user@example.org:inbox/a.csv . # down, then the far file goes
podssh cp -r site user@example.org:www/ # a tree, several files at onceThrough the relay each small file waits for its own round trips, so
several files go at once over the one connection: 4 by default,
--parallel K for K, --parallel 1 for one at a time. A tester measured
100 files of 2 bytes in 210 s one at a time and 32 s with 8 (GitHub #100).
- On a host with no terminal (the sandbox of an agent), podssh never waits
for input that cannot come. It refuses an unknown host key, gives its
fingerprint, and gives
-o StrictHostKeyChecking=accept-newas the remedy. Passwords and passphrases come fromSSH_ASKPASS(withSSH_ASKPASS_REQUIRE=force), or podssh skips them with a note. - An agent that runs one command at a time keeps a shell between its
calls with
podssh session:start NAME HOST, thensend NAME TEXT,wait NAME(until the output stops),capture NAME(the screen) andstop NAME. Its socket is your own, and carries no credential. - Keepalives are on (every 60 s), because the relay closes a connection after 180 s with no traffic.
--directconnects without the relay.- The exit codes are those of OpenSSH: the remote status, 128 plus a signal
number, or 255 for a failure of podssh. See
podssh man exit-status. PODSSH_ERROR_PROGRAMnames a program that gets each failure of podssh as one JSON object on its stdin: the command, the exit code and podssh's last messages, with no credential. A remote status starts no program. podssh calls no AI service itself; the program decides.
If the host has an ssh client, podssh can be its ProxyCommand:
ssh -o ProxyCommand='podssh proxy %h %p' -o ServerAliveInterval=60 user@example.orgOr put it in ~/.ssh/config:
Host example
HostName example.org
User me
ProxyCommand podssh proxy %h %p
ServerAliveInterval 60
podssh proxy HOST PORT is a byte pipe, so it also works for other TCP
protocols:
printf 'HEAD / HTTP/1.0\r\nHost: example.com\r\n\r\n' | podssh proxy example.com 80podssh chat carries messages and files between two podssh ends, end to
end encrypted: one side waits as the node of a pair, the other reaches it.
Each line of stdin is a message; /file PATH offers a file, which the peer
writes only once it accepts it (/accept ID).
podssh relay pair NAME --operator-file op.json # once, on the side that waits
podssh chat --listen NAME # the side that waits
podssh chat NAME --pair-file op.json # the other side, with op.json sent over a trusted channelA standard sshd gets a session that survives a lost link when a podssh next to it owns the TCP connection to it: a roost. The roost checks that sshd answers with SSH's version line, before it goes online and for each session:
podssh relay pair lab --operator-file op.json # on the server, once
podssh node lab --sshd # the roost, in front of 127.0.0.1:22
podssh ssh --pair-file op.json node://me@lab # from outside; a lost link resumes the sessionWhen the server has a podssh on its PATH, podssh ssh --upgrade does
these steps itself: it logs in, starts a temporary roost there in that SSH
session, and moves the session to it. With no podssh there, the session
stays as it is, with one note; podssh never copies itself to a server.
podssh ssh --upgrade me@example.orgpodssh man environment lists each variable that podssh reads. The usual
one is HTTPS_PROXY: podssh sends the relay's name in CONNECT, so it
needs no DNS through a proxy. A SOCKS5 proxy works too
(ALL_PROXY=socks5h://HOST:PORT, with a user and a password if it needs
them).
podssh mints a relay token when it first needs one (POST /v1/mint, no
account), and caches it for its lifetime in the user's cache directory. Only
the owner can read the cache.
| Limit | Value | What to do |
|---|---|---|
| Idle cut | 180 s with no payload (the relay's keepalives do not count) | Keep ServerAliveInterval below 180; to a node, the resumable layer keeps the link busy |
| Session length | 12 h | Connect again |
| Session volume | 64 MiB, both directions together | Use a new session for large transfers |
| Token lifetime | 72 h or less, minted with POST /v1/mint |
podssh mints and caches tokens |
| Targets | Public hosts only; the relay refuses private, link-local and internal addresses | None |
| IPv6 targets | The default relay takes an IPv6 address, but reached no IPv6 host when measured (2026-10-08); podssh ssh then tries the direct road at once, also under -6 |
--direct, where the host has IPv6; podssh relay serve on a host with IPv6, then --relay-host |
podssh proxy and podssh pipe carry any TCP stream. A client that runs a
command as its transport needs no listener. A client that calls connect()
itself needs a local port or socket: podssh pipe listens only where you
ask for it and the host allows the bind (podssh doctor tries an AF_INET
and an AF_UNIX bind), and OpenSSH's ssh -L, with podssh as its
ProxyCommand, listens where a bind works and the user has an entry in the
user database. Through the relay, a session ends at 64 MiB, both directions
together ("Relay limits" above); --direct avoids the relay where the host
has a road of its own. These protocols are often not encrypted: reach them
with ssh:, so that the relay sees SSH only.
| Protocol | With no listener | With a local listener | Limits |
|---|---|---|---|
| VNC (RFB, port 5900 + display) | A viewer that runs a command as its transport: SSVNC's ssvncviewer exec=COMMAND (its manual; not run here) |
pipe unix-listen: and TigerVNC's vncviewer, which takes a socket's path, where only an AF_UNIX bind works; else pipe tcp-listen: or ssh -L |
64 MiB through the relay, 21 full screens of 1024x768 at 32 bits with no compression; docs/STATUS.md has a viewer's rate |
| RDP (port 3389) | No client that runs a command was found; FreeRDP's /proxy: takes an HTTP or SOCKS5 proxy, a listener (podssh ssh -D) |
pipe tcp-listen: or ssh -L (not measured) |
64 MiB through the relay |
| Telnet (port 23) | podssh proxy HOST 23 carries the bytes, but the server's first bytes are options (IAC) that a client must answer |
pipe tcp-listen:, then telnet 127.0.0.1 PORT |
Plain text, the password too: use ssh: to a host near the server |
| USB/IP (port 3240) | None: usbip dials a host and port itself |
pipe --keep-listening tcp-listen:127.0.0.1:3240 to ssh:, then usbip attach -r 127.0.0.1 -b BUSID: usbip needs root and the kernel modules (usbip-host on the server, vhci-hcd here), podssh neither |
64 MiB through the relay, about 2 minutes of a disk read at the 0.5 to 0.7 MB/s measured through a sandbox's proxy: the session then ends (1009), and the device goes away; not encrypted: use ssh:, never relay: to a public usbipd |
# VNC where only an AF_UNIX bind works: TigerVNC's viewer at the socket
podssh pipe unix-listen:/tmp/vnc.sock ssh:me@desktop.example.org,127.0.0.1:5901 &
vncviewer /tmp/vnc.sock
# VNC where a TCP bind works: podssh listens ...
podssh pipe tcp-listen:5901 ssh:me@desktop.example.org,127.0.0.1:5901 &
# ... or OpenSSH listens, and podssh carries its session
ssh -N -L 5901:127.0.0.1:5901 -o ProxyCommand='podssh proxy %h %p' -o ServerAliveInterval=60 me@desktop.example.org
# no listener: the viewer runs podssh
ssvncviewer 'exec=podssh pipe stdio ssh:me@desktop.example.org,127.0.0.1:5901'
# Telnet to a device behind an SSH host
podssh pipe tcp-listen:2323 ssh:me@gateway.example.org,router.lan:23 & telnet 127.0.0.1 2323
# USB/IP: list the server's devices, then attach one (usbipd runs there; each usbip command connects once)
podssh pipe --keep-listening tcp-listen:127.0.0.1:3240 ssh:me@usbhost.example.org,127.0.0.1:3240 &
usbip list -r 127.0.0.1
sudo usbip attach -r 127.0.0.1 -b 1-1A listener of podssh pipe takes one client, then closes; a viewer that
connects again, and usbip, which connects once to list and once to
attach, need --keep-listening. After usbip attach, the kernel speaks
USB/IP on the connection to podssh's listener, and the device stays while
that session lasts; usbip detach ends it.
podssh is a Rust workspace (crates/). It needs Rust 1.99 or later and a C
compiler: the SSH client uses aws-lc (through russh). Each released
binary is statically compiled, and the gate checks it. Some crates (the terminal's, the record's
checker, the identity) come from PodUtils/podcrates by a
tag of that repository, so the first build needs github.com.
export CARGO_BUILD_JOBS=4
cargo build --release -p podssh-cli # target/release/podssh
cargo test --no-fail-fast # the default membersThe Tailscale mode (podssh ts) links a fork of
tailscale-rs that needs a C
compiler and cmake. It is a feature: cargo build -p podssh-cli --features ts.
CAUTION: Builds use much memory. On a small machine or a VM, set
CARGO_BUILD_JOBS=4. The static Linux binary is built in a rust:1-alpine
container. See docs/development.md.
| Document | Contents |
|---|---|
| AGENTS.md | The start procedure and the rules for work on the repository |
| docs/STATUS.md | What works and what does not, as measured |
| docs/ROADMAP.md | The milestones, in order |
| docs/design.md | What a finished podssh is, and why |
| docs/architecture.md | The crates, the data flow, the design rules |
| docs/relay.md | The relay protocol and its limits |
| docs/cli.md | The rules behind the command line and the manual: parity with OpenSSH, exit codes, prompts |
| docs/terminal.md | Ptys, raw mode, and the line discipline |
| docs/target-environment.md | What a constrained host allows, and the rules that follow |
| docs/decisions.md | The decisions of the operator |
| TODO/PROGRESS.md | The work order, and the questions for the operator |
| TODO/INDEX.md | Each open and done item of work (defects, features, measurements), with its entry |
| docs/development.md | Build, test, checks, the sandbox box, releases |
| SECURITY.md | How to report a vulnerability; what podssh guarantees |
0BSD. The tailscale-rs fork in vendor/ keeps its BSD-3-Clause
license.