{"slug": "how-terminal-sharing-tools-put-your-shell-in-a-browser", "title": "How terminal-sharing tools put your shell in a browser", "summary": "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.", "body_md": "*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.*\n\nYou have probably done this dance.\n\nYou 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.\n\nThere is a better way, and it has existed for years.\n\nTools 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.\n\nReal copy paste.\n\nI got curious about how they pull this off, so I cloned all three and read them.\n\nTurns out they are solving the same problem in three very different ways, and the differences are genuinely interesting.\n\nLet's get shell shocked together.\n\nBefore any of the web stuff makes sense, you need one concept: the **pseudoterminal**, or PTY.\n\nWhen you run `bash`\n\nin your terminal app, bash is not talking to your keyboard.\n\nIt is talking to a file.\n\nSpecifically, a pair of linked file descriptors that the kernel sets up to impersonate an actual physical teletype from 1965.\n\nOne end is the **master**, one end is the **slave**.\n\nYour terminal emulator holds the master. Bash holds the slave, and bash has no idea it is being catfished.\n\nAnything you write to the master shows up as keyboard input to bash. Anything bash prints comes back out of the master.\n\nThat is the entire trick.\n\nTerminal sharing tools are just programs that hold the master end and forward those bytes somewhere more interesting than a window on your laptop.\n\nHere is sshx doing exactly that, in `crates/sshx/src/terminal/unix.rs`\n\n:\n\n```\nuse nix::pty::{self, Winsize};\nuse nix::unistd::{execvp, fork, ForkResult, Pid};\nuse nix::libc::{login_tty, TIOCGWINSZ, TIOCSWINSZ};\n\npub struct Terminal {\n    child: Pid,\n    master_read: File,\n    master_write: File,\n}\n```\n\nFork, call `login_tty`\n\nin the child so the slave fd becomes its controlling terminal, `execvp`\n\nthe shell, and keep the master in the parent.\n\nttyd does the same thing in C.\n\nTermPair does it in Rust.\n\nThree codebases, one ancient POSIX handshake.\n\nEveryone takes the same fork in the road.\n\nOh, and `TIOCSWINSZ`\n\nis how the terminal size gets set.\n\nWhen you resize your browser window, that ioctl is what eventually fires so that `vim`\n\nredraws correctly instead of smearing itself across the screen.\n\nResize handling is not a nice-to-have here, it is load bearing.\n\nttyd is the \"no, seriously, that's it\" implementation.\n\nOne C process, built on libwebsockets and libuv.\n\nIt **is** the web server and it **is** the terminal host.\n\nThere is no relay, no separate client, no key exchange.\n\nThe protocol is beautifully dumb. From `src/server.h`\n\n:\n\n``` php\n// client -> server\n#define INPUT           '0'\n#define RESIZE_TERMINAL '1'\n#define PAUSE           '2'\n#define RESUME          '3'\n#define JSON_DATA       '{'\n\n// server -> client\n#define OUTPUT            '0'\n#define SET_WINDOW_TITLE  '1'\n#define SET_PREFERENCES   '2'\n```\n\nSingle byte opcode, then payload. That is the whole wire format.\n\n`'0'`\n\nplus your keystrokes goes up, `'0'`\n\nplus terminal output comes down.\n\n`PAUSE`\n\nand `RESUME`\n\nexist because a fast-scrolling `cat`\n\nof a huge file can outrun the browser, so the client can apply backpressure instead of dying.\n\nHere is the full round trip:\n\nTwo details worth stealing from ttyd:\n\n**The frontend ships inside the binary.**\n\nThe Preact and xterm.js app in `html/`\n\ngets bundled, gzipped, and inlined into `src/html.h`\n\nas a C byte array.\n\nYou get a single executable with no static file directory to misplace.\n\nThe tradeoff is that touching a `.tsx`\n\nfile means running `yarn run inline`\n\nand then rebuilding the C binary, which surprises people exactly once.\n\n**It is read only by default.** You have to pass `-W`\n\nto let viewers actually type.\n\n```\nttyd -p 7681 -c user:pass -O -W bash\n```\n\nThat said, ttyd's threat model is \"you trust the server, because the server is your machine.\"\n\nThe bytes are plaintext over the wire unless you turn on TLS.\n\nWhich brings us to the interesting question.\n\nThe 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.\n\nTermPair and sshx both took that guy's advice.\n\nThey make the server a **blind relay**: it routes ciphertext between the terminal host and the browsers, and it structurally cannot decrypt any of it.\n\nGreen means \"can read your terminal.\"\n\nThe whole game is shrinking the green.\n\nSo the client encrypts and the browser decrypts. Both need the key.\n\nThe server must not have it. But the browser gets its entire existence from the server.\n\nHow do you hand a secret to a page that the server itself sent you?\n\nThe answer is a URL fragment.\n\n```\nhttps://sharemyclau.de/s/abc123#key-goes-right-here\n```\n\nEverything after `#`\n\nis **never sent in the HTTP request**.\n\nIt is a client side construct.\n\nBrowsers use it for anchor scrolling and hash routing, and it stays in the address bar and in JavaScript's `location.hash`\n\nwhile the server sees only `/s/abc123`\n\n.\n\nSo 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.\n\nHere is the flow end to end:\n\nThe security of this rests entirely on the link. Anyone who gets the full URL gets the session.\n\nDon't paste it in a public Slack channel and then act surprised.\n\nTermPair and sshx both do AES, but they made different calls, and the differences are instructive.\n\nFrom `crates/termpair-common/src/encryption.rs`\n\n:\n\n``` php\npub fn iv_from_count(count: u64) -> [u8; IV_LENGTH] {\n    let mut iv = [0u8; IV_LENGTH];\n    let bytes = count.to_le_bytes();\n    iv[..bytes.len()].copy_from_slice(&bytes);\n    iv\n}\n```\n\nGCM 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.\n\nThe IV is a plain message counter, which is fine and correct **as long as you never reuse one with the same key**.\n\nNonce reuse in GCM is not a \"slightly weaker\" situation, it is a \"here is your key, thanks for playing\" situation.\n\nWhich is why `constants.rs`\n\nhas this:\n\n``` js\npub const ROTATION_THRESHOLD: u64 = 1 << 20;\npub const MAX_MESSAGES_PER_KEY: u64 = ROTATION_THRESHOLD * 2;\n```\n\nAfter about a million messages, the key rotates.\n\nThere is a whole `aes_key_rotation`\n\nevent in the protocol for it.\n\nSeparate keys for terminal output and browser input, plus a bootstrap key for the handshake, so the counter spaces cannot collide.\n\nIt is careful work, and the kind of thing that is invisible when it is done right.\n\nsshx made a different bet. From `crates/sshx/src/encrypt.rs`\n\n, with a comment I really enjoyed:\n\n``` js\nconst SALT: &str =\n    \"This is a non-random salt for sshx.io, since we want to stretch the security of 83-bit keys!\";\n\nlet hasher = Argon2::new(\n    Algorithm::Argon2id,\n    Version::V0x13,\n    Params::new(19 * 1024, 2, 1, Some(16)).unwrap(),\n);\n```\n\nThe 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.\n\n83 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.\n\nIt is a deliberate trade of raw entropy for ergonomics, paid back with a KDF.\n\nThen it uses CTR mode with a clever addressing scheme:\n\n``` php\npub fn segment(&self, stream_num: u64, offset: u64, data: &[u8]) -> Vec<u8> {\n    assert_ne!(stream_num, 0, \"stream number must be nonzero\"); // security check\n    let mut iv = [0; 16];\n    iv[0..8].copy_from_slice(&stream_num.to_be_bytes());\n    let mut cipher = Aes128Ctr64BE::new(&self.aes_key.into(), &iv.into());\n    cipher.seek(offset);\n    cipher.apply_keystream(&mut buf);\n    buf\n}\n```\n\nBecause CTR mode is seekable, sshx can encrypt \"the bytes at offset N of stream M\" without touching anything before them.\n\nThat is not a crypto flex, it is an architecture decision: it means a browser that reconnects can say \"I have everything up to byte 4096, send me the rest\" and the server can serve that from its buffer.\n\nEncryption and resumability designed together.\n\nThe `assert_ne!(stream_num, 0)`\n\nguard is there because stream 0 is used for the encrypted zeros block that proves you have the right key.\n\nReusing it would mean reusing a keystream.\n\nNice to see the security check written down instead of assumed.\n\nHere is my favorite detail in any of these codebases.\n\nThere is a fundamental problem with remote terminals: your keystroke has to go to the server, into the PTY, back out, and back to you before you see the character.\n\nOn a 200ms link, typing feels like moving through syrup.\n\nsshx solves this the way [Mosh](https://mosh.org/) does, with **predictive echo**. From `src/lib/typeahead.ts`\n\n:\n\n```\n// A terminal \"local echo\" or typeahead addon for xterm.js.\n//\n// This is forked from VSCode's typeahead implementation at\n// https://github.com/microsoft/vscode/blob/1.80.1/...\n```\n\nThe client guesses. You press `k`\n\n, it draws a `k`\n\nimmediately (slightly dimmed) while the real round trip happens in the background.\n\nWhen the server's actual output arrives, it reconciles.\n\nIf the guess was right, nothing visibly changes.\n\nIf it was wrong, because you were in `vim`\n\nor hitting a password prompt, it rolls back.\n\nThe tricky part is knowing when **not** to predict.\n\nPredicting into a password prompt would echo your password onto the screen, which is a memorable way to lose a friend.\n\nSo the addon tracks terminal modes and bails out when it is not confident.\n\nGetting a-head of the output, but politely.\n\nThe last layer is lifecycle. Sessions are long lived, connections are not. Wifi drops. Laptops sleep.\n\nsshx handles this with sequence numbers in the protocol (`crates/sshx-core/proto/sshx.proto`\n\n):\n\n```\nmessage TerminalData {\n  uint32 id = 1;\n  bytes data = 2;\n  uint64 seq = 3;  // Sequence number of the first byte\n}\n\nmessage SequenceNumbers {\n  map<uint32, uint64> map = 1;  // Active shells and their sequence numbers\n}\n```\n\nEvery shell is a stream with a byte offset. On reconnect, the client sends where it left off and the server replays the difference.\n\nCombined with seekable CTR encryption, resume is nearly free.\n\nIt also has to decide when to give up on a session:\n\n``` js\n/// Timeout for a disconnected session to be evicted and closed.\nconst DISCONNECTED_SESSION_EXPIRY: Duration = Duration::from_secs(300);\n```\n\nFive minutes without a backend client and the session is closed and dropped from the `DashMap`\n\n. Otherwise every crashed laptop leaks memory forever.\n\nAnd because sshx runs a globally distributed mesh, session state also goes into Redis via `crates/sshx-server/src/state/mesh.rs`\n\n, so you can connect to the nearest server rather than always crossing an ocean.\n\nTermPair, meanwhile, caps things with hard limits (`MAX_TERMINALS: 200`\n\n, `MAX_BROWSERS_PER_TERMINAL: 50`\n\n, `MAX_WS_MSGS_PER_SEC: 500`\n\n) which is the sensible answer when you are running one modest box instead of a mesh.\n\nGenuinely depends on what you are doing.\n\n**ttyd** if the network is already trusted. Homelab, LAN, behind a VPN, embedded devices, an OpenWrt router. It is one small C binary with no runtime dependencies and it will outlive us all. `ttyd -W bash`\n\nand you are done.\n\n**TermPair** if you want end to end encryption with a simple mental model and the option to self host the relay. The three-key design with rotation is easy to reason about and the code is small enough to read in an afternoon.\n\n**sshx** if you want the polished product experience. Multiple terminals on an infinite canvas, other people's cursors moving in real time, predictive echo, global mesh, automatic reconnect. Worth noting the README says self hosting is explicitly not supported, so this is \"use sshx.io\" rather than \"run your own.\"\n\nEvery one of these tools is, at its core, `fork()`\n\n, a PTY master fd, and a loop that shovels bytes into a WebSocket.\n\nThat part is 1970s technology wearing a 2020s hoodie.\n\nEverything else, the encryption, the sequence numbers, the key rotation, the predictive echo, the session eviction, exists to answer one question: **how much do you trust the machine in the middle?** ttyd says \"it's mine, so completely.\"\n\nTermPair and sshx say \"not at all, and here is the math.\"\n\nReading three implementations of the same idea side by side is the fastest architecture lesson I have had in a while.\n\nIf a concept keeps showing up in every version, it is essential.\n\nIf it only shows up in one, it is a product decision.\n\nThat distinction is hard to see from a single codebase and obvious from three.\n\nGo clone them.\n\nIt is more fun than another framework tutorial, and you will never look at `location.hash`\n\nthe same way again.\n\nAI agents write code fast. They also silently remove logic, change behavior, and introduce bugs — without telling you. You often find out in production.\n\ngit-lrc fixes this. It hooks into git commit and reviews every diff before it lands. 60-second setup. Completely free.\n\nAny feedback or contributors are welcome! It's online, source-available, and ready for anyone to use.\n\n⭐ Star it on GitHub:\n\n| [🇩🇰 Dansk](https://github.com/HexmosTech/git-lrc/readme/README.da.md) | [🇪🇸 Español](https://github.com/HexmosTech/git-lrc/readme/README.es.md) | [🇮🇷 Farsi](https://github.com/HexmosTech/git-lrc/readme/README.fa.md) | [🇫🇮 Suomi](https://github.com/HexmosTech/git-lrc/readme/README.fi.md) | [🇯🇵 日本語](https://github.com/HexmosTech/git-lrc/readme/README.ja.md) | [🇳🇴 Norsk](https://github.com/HexmosTech/git-lrc/readme/README.nn.md) | [🇵🇹 Português](https://github.com/HexmosTech/git-lrc/readme/README.pt.md) | [🇷🇺 Русский](https://github.com/HexmosTech/git-lrc/readme/README.ru.md) | [🇦🇱 Shqip](https://github.com/HexmosTech/git-lrc/readme/README.sq.md) | [🇨🇳 中文](https://github.com/HexmosTech/git-lrc/readme/README.zh.md) | [🇮🇳 हिन्दी](https://github.com/HexmosTech/git-lrc/readme/README.hi.md) |\n\nGenAI today is a **race car without brakes**. It accelerates fast -- you describe something, and large blocks of code appear instantly. But AI agents *silently break things*: they remove logic, relax constraints, introduce expensive cloud calls, leak credentials, and change behavior -- without telling you. You often find out in production.\n\n** git-lrc is your braking system.** It hooks into\n\n`git commit`\n\nand runs an AI review on every diff In short, git-lrc helps **Prevent Outages, Breaches, and Technical Debt Before They Happen**\n\n**At a glance:** [10 risk categories](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · [100+ failure patterns tracked](https://github.com/HexmosTech/git-lrc#what-git-lrc-checks-for) · every commit…", "url": "https://wpnews.pro/news/how-terminal-sharing-tools-put-your-shell-in-a-browser", "canonical_source": "https://dev.to/lovestaco/how-terminal-sharing-tools-put-your-shell-in-a-browser-328", "published_at": "2026-07-26 16:43:17+00:00", "updated_at": "2026-07-26 17:01:02.454811+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["ttyd", "TermPair", "sshx", "Maneshwar", "git-lrc"], "alternates": {"html": "https://wpnews.pro/news/how-terminal-sharing-tools-put-your-shell-in-a-browser", "markdown": "https://wpnews.pro/news/how-terminal-sharing-tools-put-your-shell-in-a-browser.md", "text": "https://wpnews.pro/news/how-terminal-sharing-tools-put-your-shell-in-a-browser.txt", "jsonld": "https://wpnews.pro/news/how-terminal-sharing-tools-put-your-shell-in-a-browser.jsonld"}}