| … | … | @@ -1,14 +1,14 @@ |
| 1 | 1 | # hypermosh-shell |
| 2 | 2 | |
| 3 | | **Resilient, roaming terminal sessions to identity-keyed P2P nodes** — real |
| 3 | **Resilient, roaming terminal sessions to peers you reach by public key** — real |
| 4 | 4 | [mosh](https://mosh.org) tunneled over a hole-punched |
| 5 | 5 | [hyperdht](https://github.com/holepunchto/hyperdht) connection, in one command. |
| 6 | 6 | |
| 7 | | Built for **celltron-class nodes**: NAT'd, Doze-prone phones with no reachable |
| 8 | | UDP port, where a plain reliable-stream shell (like `hyperident-shell`) **dies on |
| 9 | | every network flap** — Doze, wifi handoff, cellular↔wifi roaming, a DHT hiccup. |
| 10 | | mosh was designed for exactly that world; hypermosh gives it a P2P datagram path |
| 11 | | to a peer you reach *by public key*, not by `host:port`. |
| 7 | `mosh user@host`, but the "host" is a public key and the transport is P2P. For |
| 8 | nodes with **no reachable UDP port** (behind NAT/CGNAT, or mobile/roaming), where |
| 9 | a plain reliable-stream shell **dies on every network change** — mosh was designed |
| 10 | for exactly that (intermittent links, latency, IP roaming); hypermosh gives it a |
| 11 | P2P datagram path. |
| 12 | 12 | |
| 13 | 13 | ## One command |
| 14 | 14 | |
| … | … | @@ -17,13 +17,10 @@ hypermosh <server-pubkey> # interactive mosh session over the DHT |
| 17 | 17 | hypermosh <server-pubkey> -- htop # run a specific command |
| 18 | 18 | ``` |
| 19 | 19 | |
| 20 | | Like `mosh user@host`, but the "host" is a hyperident public key and the |
| 21 | | transport is P2P. |
| 22 | | |
| 23 | 20 | ## How it works |
| 24 | 21 | |
| 25 | 22 | ``` |
| 26 | | tabertron (client) celltron (daemon) |
| 23 | client server |
| 27 | 24 | hypermosh <key> ─┐ hypermosh serve |
| 28 | 25 | spawns │ hyperdht stream (identity-keyed, on connection: |
| 29 | 26 | mosh-client ─UDP┤ firewalled, auto-reconnect) spawns mosh-server |
| … | … | @@ -31,82 +28,71 @@ tabertron (client) celltron (daemon) |
| 31 | 28 | data: UDP datagrams ⇄ UDP ───────┘ |
| 32 | 29 | ``` |
| 33 | 30 | |
| 34 | | - `hypermosh serve` runs a small daemon on its **own key**. Per connection it |
| 35 | | **spawns a fresh `mosh-server`**, sends the client the one-time key over a |
| 36 | | control frame, and bridges the mosh UDP datagrams over the hyperdht stream. |
| 37 | | - `hypermosh <key>` connects, receives the key, spawns `mosh-client` pointed at a |
| 38 | | local UDP port, and bridges. mosh's **SSP** does the rest: it survives packet |
| 39 | | loss, latency, and roaming, so when the phone's network flaps the stream |
| 40 | | reconnects and the session **resyncs** — nothing lost. |
| 41 | | - Runs on its **own key** (not the device shell key) so it never conflicts with |
| 42 | | `hyperident-shell` holding the device identity. The client presents its |
| 43 | | hyperident identity to the daemon's firewall. |
| 44 | | |
| 45 | | Wire format is typed frames over the reliable stream: |
| 46 | | `[uint8 type][uint32 len][payload]` — type 0 control (JSON), type 1 datagram. |
| 47 | | |
| 48 | | ## Status |
| 49 | | |
| 50 | | | Piece | State | |
| 51 | | |---|---| |
| 52 | | | UDP↔hyperdht relay core (`lib/relay.mjs`) | ✅ proven (loopback) | |
| 53 | | | Real mosh session over the tunnel | ✅ proven (loopback, all hops + output) | |
| 54 | | | **One-command session** (`lib/session.mjs`, `hypermosh <key>`) | ✅ **proven** (loopback, end-to-end) | |
| 55 | | | celltron deploy (mosh-server in proot via extract-from-deb) | 🔜 next | |
| 56 | | | Flap/roaming resilience test (kill+restore mid-session) | 🔜 next | |
| 31 | - `hypermosh serve` runs a small daemon on its own key. Per connection it **spawns |
| 32 | a fresh `mosh-server`**, sends the client the one-time key over a control frame, |
| 33 | and bridges the mosh UDP datagrams over the hyperdht stream. |
| 34 | - `hypermosh <key>` connects, receives the key, spawns `mosh-client` at a local UDP |
| 35 | port, and bridges. mosh's **SSP** does the rest — it survives packet loss, |
| 36 | latency, and roaming, so when the network flaps the stream reconnects and the |
| 37 | session **resyncs**. Nothing lost. |
| 38 | - Addressed by **public key**, firewalled by an allow-list. The relay is a dumb |
| 39 | UDP↔stream bridge; mosh provides all the resilience. |
| 40 | |
| 41 | Wire format: typed frames over the reliable stream — |
| 42 | `[uint8 type][uint32 len][payload]` (type 0 control/JSON, type 1 datagram). |
| 57 | 43 | |
| 58 | 44 | ## Install |
| 59 | 45 | |
| 60 | 46 | ```sh |
| 61 | | npm install # hyperdht, b4a, hypercore-id-encoding |
| 62 | | # requires `mosh` on both ends; on celltron, mosh-server goes in the proot rootfs |
| 47 | npm install # hyperdht, b4a, hypercore-id-encoding, protomux, compact-encoding |
| 48 | # requires `mosh` (mosh-server + mosh-client) installed on both ends |
| 63 | 49 | ``` |
| 64 | 50 | |
| 65 | | Dual-runtime: Node (client/testing) and Bare (celltron) via the `#node:*` import |
| 66 | | map (`dgram`→`bare-dgram`, `child_process`→`bare-subprocess`, …). |
| 51 | Dual-runtime: Node and [Bare](https://github.com/holepunchto/bare) via the |
| 52 | `#node:*` import map (`dgram`→`bare-dgram`, `child_process`→`bare-subprocess`, …). |
| 67 | 53 | |
| 68 | 54 | ## CLI |
| 69 | 55 | |
| 70 | 56 | ``` |
| 71 | | hypermosh id # this daemon's server pubkey (~/.hypermosh) |
| 72 | | hypermosh serve [--firewall <f> | --disable-firewall] |
| 73 | | hypermosh <server-pubkey> [-- cmd...] |
| 57 | hypermosh id # this daemon's server pubkey |
| 58 | hypermosh serve [--firewall <file> | --disable-firewall] |
| 59 | hypermosh <name-or-pubkey> [-- cmd...] |
| 60 | hypermosh add-name <name> <pubkey> # optional: remember a name |
| 61 | hypermosh names # list known names |
| 74 | 62 | ``` |
| 75 | 63 | |
| 76 | | - The **daemon key** lives in `~/.hypermosh/session.json` (created on first |
| 77 | | `serve`), separate from the device identity. |
| 78 | | - The **client** presents the hyperident device identity (`~/.hyperident`) to the |
| 79 | | daemon's firewall. |
| 64 | - `serve` runs on its own key (persisted under `~/.hypermosh/`). `--firewall` is an |
| 65 | allow-list file of client public keys (one z-base32/hex key per line, `#` |
| 66 | comments); `--disable-firewall` accepts any peer. |
| 67 | - The client presents an identity keypair (the hyperident vault at `~/.hyperident`) |
| 68 | to the daemon's firewall. |
| 69 | - **Names are optional.** A raw public key always works; `add-name` records a |
| 70 | `name -> pubkey` mapping in `~/.hypermosh/names` (SSH-known_hosts style) so you can |
| 71 | say `hypermosh myserver`. A future P2P name registry slots in behind the same |
| 72 | resolver. |
| 80 | 73 | |
| 81 | 74 | ## Design notes |
| 82 | 75 | |
| 83 | | - **mosh-server daemonizes.** `mosh-server new` prints `MOSH CONNECT …` then forks |
| 84 | | a *detached* daemon and the spawned parent exits immediately. Do **not** tie the |
| 85 | | tunnel socket to that parent's exit (an early `socket.end()` closes the write |
| 86 | | side before any session data flows — this was the one-command bug). We track the |
| 87 | | detached pid for cleanup instead. |
| 88 | | - **Attach the data handler immediately** (before the hyperdht `open` event), |
| 89 | | matching `lib/relay.mjs` — attaching it inside `open` stalled the read side. |
| 90 | | - **No `writable` guard on writes** — UDX buffers pre-connection writes, so mosh's |
| 91 | | earliest packets survive the DHT connect instead of being dropped. |
| 92 | | - **Auto-reconnect = roaming.** On stream close the client redials the pubkey; |
| 93 | | mosh resyncs the session across it. |
| 76 | - **mosh-server daemonizes.** `mosh-server new` prints `MOSH CONNECT …` then forks a |
| 77 | *detached* daemon and the spawned parent exits immediately. Do **not** tie the |
| 78 | tunnel socket to that parent's exit — an early `socket.end()` closes the write |
| 79 | side before any session data flows. Track the detached pid for cleanup instead. |
| 80 | - **Attach the data handler immediately** (before the hyperdht `open` event) — |
| 81 | attaching it inside `open` stalled the read side after the first frame. |
| 82 | - **No `writable` guard on writes** — the UDX stream buffers pre-connection writes, |
| 83 | so mosh's earliest packets survive the DHT connect instead of being dropped. |
| 84 | - **Auto-reconnect = roaming.** On stream close the client redials the pubkey; mosh |
| 85 | resyncs the session across it. |
| 94 | 86 | |
| 95 | 87 | ## Tests |
| 96 | 88 | |
| 97 | 89 | ```sh |
| 98 | 90 | npm test |
| 99 | | # [1] loopback: UDP echo through hyperdht tunnel ✅ |
| 100 | | # [2] real mosh: session through the hyperdht tunnel ✅ |
| 101 | | # [session] one-command mosh session end-to-end ✅ (both skip if mosh absent) |
| 91 | # [1] loopback: UDP echo through the hyperdht tunnel ✅ |
| 92 | # [2] real mosh: session through the tunnel ✅ |
| 93 | # [session] one-command mosh session end-to-end ✅ (mosh-dependent tests skip if mosh absent) |
| 102 | 94 | ``` |
| 103 | 95 | |
| 104 | | ## Related |
| 105 | | |
| 106 | | - `hyperident-shell` — the identity-keyed P2P shell this complements. Stream-based, |
| 107 | | so fragile on flaky links — the gap hypermosh fills. |
| 108 | | - `celltron` — the retired-phone P2P node this is built for. |
| 109 | | |
| 110 | 96 | ## License |
| 111 | 97 | |
| 112 | 98 | Apache-2.0 |