How terminal-sharing tools put your shell in a browser A developer explored how terminal-sharing tools like ttyd, TermPair, and sshx put a user's shell in a browser by leveraging pseudoterminals (PTYs). The tools use a master-slave file descriptor pair to forward terminal I/O over WebSockets, with ttyd implementing a simple single-byte opcode protocol and bundling its frontend as a C byte array. Hello, I'm Maneshwar. I'm building git-lrc, a Micro AI code reviewer that runs on every commit. It is free and source-available on Github. Star git-lrc to help devs discover the project. Do give it a try and share your feedback. You have probably done this dance. You are debugging something on a box, your teammate says "can you just show me", and you end up screen sharing a 4K monitor over a video call so they can squint at 11px monospace text that has been compressed into oatmeal. There is a better way, and it has existed for years. Tools like ttyd https://github.com/tsl0922/ttyd , TermPair https://github.com/cs01/termpair , and sshx https://github.com/ekzhang/sshx put your actual terminal in someone else's browser. Real text. Real selection. Real copy paste. I got curious about how they pull this off, so I cloned all three and read them. Turns out they are solving the same problem in three very different ways, and the differences are genuinely interesting. Let's get shell shocked together. Before any of the web stuff makes sense, you need one concept: the pseudoterminal , or PTY. When you run bash in your terminal app, bash is not talking to your keyboard. It is talking to a file. Specifically, a pair of linked file descriptors that the kernel sets up to impersonate an actual physical teletype from 1965. One end is the master , one end is the slave . Your terminal emulator holds the master. Bash holds the slave, and bash has no idea it is being catfished. Anything you write to the master shows up as keyboard input to bash. Anything bash prints comes back out of the master. That is the entire trick. Terminal sharing tools are just programs that hold the master end and forward those bytes somewhere more interesting than a window on your laptop. Here is sshx doing exactly that, in crates/sshx/src/terminal/unix.rs : use nix::pty::{self, Winsize}; use nix::unistd::{execvp, fork, ForkResult, Pid}; use nix::libc::{login tty, TIOCGWINSZ, TIOCSWINSZ}; pub struct Terminal { child: Pid, master read: File, master write: File, } Fork, call login tty in the child so the slave fd becomes its controlling terminal, execvp the shell, and keep the master in the parent. ttyd does the same thing in C. TermPair does it in Rust. Three codebases, one ancient POSIX handshake. Everyone takes the same fork in the road. Oh, and TIOCSWINSZ is how the terminal size gets set. When you resize your browser window, that ioctl is what eventually fires so that vim redraws correctly instead of smearing itself across the screen. Resize handling is not a nice-to-have here, it is load bearing. ttyd is the "no, seriously, that's it" implementation. One C process, built on libwebsockets and libuv. It is the web server and it is the terminal host. There is no relay, no separate client, no key exchange. The protocol is beautifully dumb. From src/server.h : php // client - server define INPUT '0' define RESIZE TERMINAL '1' define PAUSE '2' define RESUME '3' define JSON DATA '{' // server - client define OUTPUT '0' define SET WINDOW TITLE '1' define SET PREFERENCES '2' Single byte opcode, then payload. That is the whole wire format. '0' plus your keystrokes goes up, '0' plus terminal output comes down. PAUSE and RESUME exist because a fast-scrolling cat of a huge file can outrun the browser, so the client can apply backpressure instead of dying. Here is the full round trip: Two details worth stealing from ttyd: The frontend ships inside the binary. The Preact and xterm.js app in html/ gets bundled, gzipped, and inlined into src/html.h as a C byte array. You get a single executable with no static file directory to misplace. The tradeoff is that touching a .tsx file means running yarn run inline and then rebuilding the C binary, which surprises people exactly once. It is read only by default. You have to pass -W to let viewers actually type. ttyd -p 7681 -c user:pass -O -W bash That said, ttyd's threat model is "you trust the server, because the server is your machine." The bytes are plaintext over the wire unless you turn on TLS. Which brings us to the interesting question. The moment you want to share a terminal with someone across the internet, you need a middleman with a public IP. And now that middleman can read everything you type. TermPair and sshx both took that guy's advice. They make the server a blind relay : it routes ciphertext between the terminal host and the browsers, and it structurally cannot decrypt any of it. Green means "can read your terminal." The whole game is shrinking the green. So the client encrypts and the browser decrypts. Both need the key. The server must not have it. But the browser gets its entire existence from the server. How do you hand a secret to a page that the server itself sent you? The answer is a URL fragment. https://sharemyclau.de/s/abc123 key-goes-right-here Everything after is never sent in the HTTP request . It is a client side construct. Browsers use it for anchor scrolling and hash routing, and it stays in the address bar and in JavaScript's location.hash while the server sees only /s/abc123 . So you share one link, the recipient's browser reads the key out of its own address bar, and the relay in the middle sees an opaque session ID and a stream of noise. Here is the flow end to end: The security of this rests entirely on the link. Anyone who gets the full URL gets the session. Don't paste it in a public Slack channel and then act surprised. TermPair and sshx both do AES, but they made different calls, and the differences are instructive. From crates/termpair-common/src/encryption.rs : php pub fn iv from count count: u64 - u8; IV LENGTH { let mut iv = 0u8; IV LENGTH ; let bytes = count.to le bytes ; iv ..bytes.len .copy from slice &bytes ; iv } GCM gives you authentication for free, so a tampered message fails to decrypt rather than quietly turning into garbage that your terminal then interprets as escape sequences. The IV is a plain message counter, which is fine and correct as long as you never reuse one with the same key . Nonce reuse in GCM is not a "slightly weaker" situation, it is a "here is your key, thanks for playing" situation. Which is why constants.rs has this: js pub const ROTATION THRESHOLD: u64 = 1 << 20; pub const MAX MESSAGES PER KEY: u64 = ROTATION THRESHOLD 2; After about a million messages, the key rotates. There is a whole aes key rotation event in the protocol for it. Separate keys for terminal output and browser input, plus a bootstrap key for the handshake, so the counter spaces cannot collide. It is careful work, and the kind of thing that is invisible when it is done right. sshx made a different bet. From crates/sshx/src/encrypt.rs , with a comment I really enjoyed: js const SALT: &str = "This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys "; let hasher = Argon2::new Algorithm::Argon2id, Version::V0x13, Params::new 19 1024, 2, 1, Some 16 .unwrap , ; The key in your URL is only 83 bits of entropy, because sshx wants the shareable link to stay short enough to actually paste in chat. 83 bits is not a lot by modern standards, so it runs Argon2id over it with real memory cost parameters. Brute forcing now costs 19MB and real CPU time per guess instead of a nanosecond. It is a deliberate trade of raw entropy for ergonomics, paid back with a KDF. Then it uses CTR mode with a clever addressing scheme: php pub fn segment &self, stream num: u64, offset: u64, data: & u8 - Vec