OverviewFilesCommitsIssuesDocsReleasesPeers

hypermosh-shell commits / 0b0c509

optional name resolution + generic README
hypermosh <name-or-pubkey> resolves names from ~/.hypermosh/names (SSH known_hosts style, same format/hook as hyperident-shell) — a raw pubkey always works, names are optional convenience. Adds `add-name` / `names` commands. README genericized (no project-specific infra). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
0b0c5097c136abbdc09f905e290329fa432dc106
by plankatron <plankatron@users.noreply.github.com> · 2026-07-14T21:04:47.000Z

Changed files (3) +130 -73

README.md +50 -64
……@@ -1,14 +1,14 @@
11# hypermosh-shell
22
3**Resilient, roaming terminal sessions to identity-keyed P2P nodes** — real
3**Resilient, roaming terminal sessions to peers you reach by public key** — real
44[mosh](https://mosh.org) tunneled over a hole-punched
55[hyperdht](https://github.com/holepunchto/hyperdht) connection, in one command.
66
7Built for **celltron-class nodes**: NAT'd, Doze-prone phones with no reachable
8UDP port, where a plain reliable-stream shell (like `hyperident-shell`) **dies on
9every network flap** — Doze, wifi handoff, cellular↔wifi roaming, a DHT hiccup.
10mosh was designed for exactly that world; hypermosh gives it a P2P datagram path
11to 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
8nodes with **no reachable UDP port** (behind NAT/CGNAT, or mobile/roaming), where
9a plain reliable-stream shell **dies on every network change** — mosh was designed
10for exactly that (intermittent links, latency, IP roaming); hypermosh gives it a
11P2P datagram path.
1212
1313## One command
1414
……@@ -17,13 +17,10 @@ hypermosh <server-pubkey> # interactive mosh session over the DHT
1717hypermosh <server-pubkey> -- htop # run a specific command
1818```
1919
20Like `mosh user@host`, but the "host" is a hyperident public key and the
21transport is P2P.
22
2320## How it works
2421
2522```
26tabertron (client) celltron (daemon)
23client server
2724 hypermosh <key> ─┐ hypermosh serve
2825 spawns │ hyperdht stream (identity-keyed, on connection:
2926 mosh-client ─UDP┤ firewalled, auto-reconnect) spawns mosh-server
……@@ -31,82 +28,71 @@ tabertron (client) celltron (daemon)
3128 data: UDP datagrams ⇄ UDP ───────┘
3229```
3330
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
45Wire 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
41Wire format: typed frames over the reliable stream —
42`[uint8 type][uint32 len][payload]` (type 0 control/JSON, type 1 datagram).
5743
5844## Install
5945
6046```sh
61npm install # hyperdht, b4a, hypercore-id-encoding
62# requires `mosh` on both ends; on celltron, mosh-server goes in the proot rootfs
47npm install # hyperdht, b4a, hypercore-id-encoding, protomux, compact-encoding
48# requires `mosh` (mosh-server + mosh-client) installed on both ends
6349```
6450
65Dual-runtime: Node (client/testing) and Bare (celltron) via the `#node:*` import
66map (`dgram`→`bare-dgram`, `child_process`→`bare-subprocess`, …).
51Dual-runtime: Node and [Bare](https://github.com/holepunchto/bare) via the
52`#node:*` import map (`dgram`→`bare-dgram`, `child_process`→`bare-subprocess`, …).
6753
6854## CLI
6955
7056```
71hypermosh id # this daemon's server pubkey (~/.hypermosh)
72hypermosh serve [--firewall <f> | --disable-firewall]
73hypermosh <server-pubkey> [-- cmd...]
57hypermosh id # this daemon's server pubkey
58hypermosh serve [--firewall <file> | --disable-firewall]
59hypermosh <name-or-pubkey> [-- cmd...]
60hypermosh add-name <name> <pubkey> # optional: remember a name
61hypermosh names # list known names
7462```
7563
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.
8073
8174## Design notes
8275
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.
9486
9587## Tests
9688
9789```sh
9890npm 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)
10294```
10395
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
11096## License
11197
11298Apache-2.0
bin/hypermosh.mjs +28 -9
……@@ -1,17 +1,21 @@
11#!/usr/bin/env node
2// hypermosh — resilient P2P mosh sessions to identity-keyed nodes.
2// hypermosh — resilient P2P mosh sessions to peers reached by public key.
33//
4// hypermosh id print this daemon's server pubkey
4// hypermosh id print this daemon's server pubkey
55// hypermosh serve [--firewall <f> | --disable-firewall]
6// run the session daemon (spawns mosh-server per conn)
7// hypermosh <server-pubkey> [-- cmd] one command: open a mosh session on <server-pubkey>
6// run the session daemon (spawns mosh-server per conn)
7// hypermosh <name-or-pubkey> [-- cmd] one command: open a mosh session
8// hypermosh add-name <name> <pubkey> remember a name -> pubkey (optional)
9// hypermosh names list known names
810//
9// The daemon runs on its own key (from ~/.hypermosh); the client presents the
10// hyperident device identity (~/.hyperident) to the daemon's firewall.
11// A raw public key ALWAYS works; names are an optional convenience resolved from
12// ~/.hypermosh/names (with a hook for the P2P HandleRegistry). The client presents
13// the hyperident identity (~/.hyperident) to the daemon's firewall.
1114import process from '#node:process'
1215import cp from '#node:child_process'
1316import { serveSession, connectSession } from '../lib/session.mjs'
1417import { loadOrCreateKey, loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs'
18import { resolve as resolveName, addName, loadNames } from '../lib/names.mjs'
1519
1620function flags (argv) {
1721 const pos = []; const f = {}
……@@ -25,7 +29,7 @@ function flags (argv) {
2529}
2630
2731function usage () {
28 console.error('usage:\n hypermosh id\n hypermosh serve [--firewall <f> | --disable-firewall]\n hypermosh <server-pubkey> [-- cmd...]')
32 console.error('usage:\n hypermosh id\n hypermosh serve [--firewall <f> | --disable-firewall]\n hypermosh <name-or-pubkey> [-- cmd...]\n hypermosh add-name <name> <pubkey>\n hypermosh names')
2933 process.exit(1)
3034}
3135
……@@ -35,6 +39,20 @@ async function main () {
3539
3640 if (cmd === 'id') { console.log(HypercoreId.encode(loadOrCreateKey().publicKey)); return }
3741
42 if (cmd === 'add-name') {
43 if (!pos[0] || !pos[1]) usage()
44 addName(pos[0], pos[1])
45 console.log('added: ' + pos[0] + ' -> ' + pos[1])
46 return
47 }
48
49 if (cmd === 'names') {
50 const map = loadNames()
51 if (map.size === 0) console.log('(no names — add one with: hypermosh add-name <name> <pubkey>)')
52 else for (const [n, k] of map) console.log(n + ' ' + k)
53 return
54 }
55
3856 if (cmd === 'serve') {
3957 const keyPair = loadOrCreateKey()
4058 const allow = f['disable-firewall'] ? true : readAllowed(f.firewall)
……@@ -46,8 +64,9 @@ async function main () {
4664 }
4765
4866 if (cmd && !cmd.startsWith('-')) {
49 // one-command client: hypermosh <server-pubkey> [-- cmd]
50 const remoteKey = HypercoreId.decode(cmd)
67 // one-command client: hypermosh <name-or-pubkey> [-- cmd]
68 let remoteKey
69 try { remoteKey = resolveName(cmd) } catch (e) { console.error('hypermosh: ' + e.message); process.exit(1) }
5170 let keyPair = null
5271 try { keyPair = loadDeviceKeyPair() } catch { /* fall back to ephemeral identity */ }
5372 const start = { term: process.env.TERM || 'xterm', cmd: f._rest || [] }
lib/names.mjs +52 -0
……@@ -0,0 +1,52 @@
1// Optional name resolution. A raw public key ALWAYS works; a name is looked up
2// in a local store first. Same known_hosts-style format as hyperident-shell, so
3// a `~/.hypermosh/names` line is `<name> <z-base32-pubkey>`.
4//
5// Two layers (mirrors hyperident-shell):
6// 1. local names store (~/.hypermosh/names) — immediate, offline, per-machine.
7// 2. (future) the hyperident HandleRegistry — a P2P name -> identity registry;
8// resolve() has the hook where that lookup slots in, with the local store
9// acting as cache/override. Call sites are unchanged when it lands.
10import fs from '#node:fs'
11import os from '#node:os'
12import path from '#node:path'
13import process from '#node:process'
14import HypercoreId from 'hypercore-id-encoding'
15
16function homedir () { return (os.homedir && os.homedir()) || process.env.HOME || process.env.USERPROFILE || '.' }
17function namesFile () { return path.join(homedir(), '.hypermosh', 'names') }
18
19export function loadNames (file = namesFile()) {
20 const map = new Map()
21 let txt
22 try { txt = fs.readFileSync(file, 'utf8') } catch (e) { if (e.code === 'ENOENT') return map; throw e }
23 for (const line of txt.split('\n')) {
24 const s = line.trim()
25 if (!s || s.startsWith('#')) continue
26 const sp = s.indexOf(' ')
27 const name = sp === -1 ? s : s.slice(0, sp).trim()
28 const key = sp === -1 ? '' : s.slice(sp + 1).trim()
29 if (name && key) map.set(name, key)
30 }
31 return map
32}
33
34export function addName (name, pubkey, file = namesFile()) {
35 HypercoreId.decode(pubkey) // validate (throws if not a valid key)
36 fs.mkdirSync(path.dirname(file), { recursive: true })
37 const map = loadNames(file)
38 map.set(name, pubkey)
39 fs.writeFileSync(file, [...map.entries()].map(([n, k]) => n + ' ' + k).join('\n') + '\n', { mode: 0o600 })
40}
41
42// Resolve a name-or-pubkey to a 32-byte public key Buffer.
43// 1. local names store (name -> pubkey)
44// 2. treat the input as a raw z-base32/hex pubkey (← pubkeys always work)
45// 3. TODO: query the hyperident HandleRegistry and cache into the local store.
46export function resolve (nameOrKey, file = namesFile()) {
47 const map = loadNames(file)
48 if (map.has(nameOrKey)) return HypercoreId.decode(map.get(nameOrKey))
49 try { return HypercoreId.decode(nameOrKey) } catch {}
50 throw new Error('cannot resolve "' + nameOrKey + '" — not a known name or a valid pubkey.\n' +
51 ' add it with: hypermosh add-name ' + nameOrKey + ' <pubkey>')
52}