OverviewFilesCommitsIssuesDocsReleasesPeers

hypermosh-shell commits / 01496a4

one-command wrapper: hypermosh <key> — self-contained mosh-over-hyperdht
hypermosh serve runs a daemon on its own key that spawns a fresh mosh-server per connection and bridges its UDP over the hyperdht stream (typed control+data frames). hypermosh <key> connects, gets the one-time key, and launches mosh-client. Session runs end-to-end over the DHT; loopback test green. Root-cause fix: mosh-server DAEMONIZES (spawned parent exits right after MOSH CONNECT), so tying the socket to proc 'exit' closed the write side before session data flowed — track the detached pid for cleanup instead. Also: attach the stream data handler immediately (not in 'open'), which was stalling reads. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
01496a44776a307288b02f15f80055b76087f0c6
by plankatron <plankatron@users.noreply.github.com> · 2026-07-14T20:45:26.000Z

Changed files (7) +420 -126

README.md +62 -82
……@@ -2,129 +2,109 @@
22
33**Resilient, roaming terminal sessions to identity-keyed P2P nodes** — real
44[mosh](https://mosh.org) tunneled over a hole-punched
5[hyperdht](https://github.com/holepunchto/hyperdht) connection.
5[hyperdht](https://github.com/holepunchto/hyperdht) connection, in one command.
66
77Built for **celltron-class nodes**: NAT'd, Doze-prone phones with no reachable
88UDP port, where a plain reliable-stream shell (like `hyperident-shell`) **dies on
99every network flap** — Doze, wifi handoff, cellular↔wifi roaming, a DHT hiccup.
10mosh was designed for exactly that world. hypermosh-shell gives mosh a P2P
11datagram path so its resilience works against a peer you reach *by public key*,
12not by `host:port`.
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`.
1312
14## The idea
13## One command
14
15```sh
16hypermosh <server-pubkey> # interactive mosh session over the DHT
17hypermosh <server-pubkey> -- htop # run a specific command
18```
19
20Like `mosh user@host`, but the "host" is a hyperident public key and the
21transport is P2P.
22
23## How it works
1524
1625```
17tabertron (client) celltron (server, in proot)
18 mosh-client ─UDP→ [hypermosh connect] ═hyperdht═ [hypermosh serve] ─UDP→ mosh-server
19 ←UDP─ (local 127.0.0.1 identity-keyed, (bridge to ←UDP─
20 udp port) firewall'd, 127.0.0.1:port)
21 auto-reconnect)
26tabertron (client) celltron (daemon)
27 hypermosh <key> ─┐ hypermosh serve
28 spawns │ hyperdht stream (identity-keyed, on connection:
29 mosh-client ─UDP┤ firewalled, auto-reconnect) spawns mosh-server
30 └── control: {term,cmd} → {ok,key} ──────┐ bridges its UDP
31 data: UDP datagrams ⇄ UDP ───────┘
2232```
2333
24- mosh's **SSP** (State Synchronization Protocol) already survives packet loss,
25 latency, and IP roaming — it just needs *a* datagram path to the server.
26- celltron has no public UDP port, so we carry mosh's UDP datagrams over a
27 **hyperdht connection**: hole-punched, encrypted, addressed by the device's
28 hyperident **public key**, and firewalled by an allow-list — the same identity
29 model as `hyperident-shell`.
30- When the phone's network flaps, the hyperdht stream drops and the relay
31 **auto-reconnects** (re-looking-up the pubkey — which *is* roaming, P2P-style).
32 mosh sees a brief gap in datagrams and **resyncs the session**. Nothing lost.
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.
3344
34The relay itself is deliberately dumb: a UDP↔stream bridge with framing and
35reconnect. **mosh provides all the resilience**; hypermosh only provides the
36pipe.
45Wire format is typed frames over the reliable stream:
46`[uint8 type][uint32 len][payload]` — type 0 control (JSON), type 1 datagram.
3747
3848## Status
3949
4050| Piece | State |
4151|---|---|
42| UDP↔hyperdht relay core (`lib/relay.mjs`) | ✅ **proven** (loopback round-trip) |
43| Real mosh session end-to-end over the tunnel | ✅ **proven** (loopback, all hops + session output) |
44| CLI (`hypermosh id/serve/connect`) | ✅ usable (manual bootstrap) |
45| One-command wrapper (`hypermosh <key>` auto-bootstraps mosh-server) | 🔜 roadmap |
46| celltron deploy (mosh-server in proot via extract-from-deb) | 🔜 roadmap |
47| Flap/roaming resilience test (kill+restore tunnel mid-session) | 🔜 roadmap |
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 |
4857
4958## Install
5059
5160```sh
52npm install # hyperdht, b4a, hypercore-id-encoding
61npm install # hyperdht, b4a, hypercore-id-encoding
5362# requires `mosh` on both ends; on celltron, mosh-server goes in the proot rootfs
5463```
5564
56Dual-runtime: runs on **Node** (client side / testing) and **Bare** (celltron)
57via the `#node:dgram` import map (`dgram` on node, `bare-dgram` on bare).
58
59## Usage (manual bootstrap — mosh-style)
60
61mosh normally uses `ssh` to start `mosh-server` and hand back a one-time key.
62hypermosh uses **`hyperident-shell`** as that bootstrap channel.
63
64**1. Start `mosh-server` on the remote (via hyperident-shell), grab its port+key:**
65```sh
66hyperident-shell <celltron-key> -- mosh-server new -i 127.0.0.1 -- bash -l
67# -> MOSH CONNECT 60001 <base64-key>
68```
69
70**2. On the remote (celltron), bridge the tunnel to that port:**
71```sh
72hypermosh serve --udp 60001 --firewall ~/.hyperident-shell/firewall.txt
73# -> hypermosh serving as <celltron-key>
74```
75
76**3. On the client, open the tunnel and point mosh-client at it:**
77```sh
78hypermosh connect <celltron-key> --udp-listen 6000
79# -> point mosh-client at: 127.0.0.1 6000
80MOSH_KEY=<base64-key> mosh-client 127.0.0.1 6000
81```
82
83You now have a mosh session on celltron that **survives Doze, roaming, and DHT
84flaps** — the failure mode that makes a plain stream shell unusable on a phone.
85
86> The one-command `hypermosh <celltron-key>` (which does steps 1–3 for you and
87> launches mosh-client) is the next milestone — it just wires these primitives
88> together over a small control channel.
65Dual-runtime: Node (client/testing) and Bare (celltron) via the `#node:*` import
66map (`dgram`→`bare-dgram`, `child_process`→`bare-subprocess`, …).
8967
9068## CLI
9169
9270```
93hypermosh id # print this device's pubkey (hyperident vault)
94hypermosh serve --udp <port> [--firewall <f> | --disable-firewall]
95hypermosh connect <server-pubkey> [--udp-listen <port>]
71hypermosh id # this daemon's server pubkey (~/.hypermosh)
72hypermosh serve [--firewall <f> | --disable-firewall]
73hypermosh <server-pubkey> [-- cmd...]
9674```
9775
98Identity comes from the hyperident vault (`~/.hyperident/device.key` + `.pub`) —
99the same identity as `hyperident-shell`, so the same firewall allow-list governs
100both.
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.
10180
10281## Design notes
10382
104- **Framing.** UDP datagrams are `[uint16 BE len][payload]` over the reliable
105 UDX byte stream, so they reassemble cleanly.
106- **No `writable` guard.** The client writes datagrams to the hyperdht stream
107 *without* checking `socket.writable` — UDX buffers pre-connection writes, so
108 mosh's earliest packets survive the ~seconds of DHT connect instead of being
109 dropped. (Dropping them was a real bug: mosh reported "Nothing received from
110 server" while the connection was still establishing.)
111- **Auto-reconnect = roaming.** On stream `close`, the client redials the
112 server pubkey. That reconnect is what turns a phone's network change into
113 mosh's roaming.
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.
11494
11595## Tests
11696
11797```sh
11898npm test
11999# [1] loopback: UDP echo through hyperdht tunnel ✅
120# [2] real mosh: session through the hyperdht tunnel ✅ (skipped if mosh absent)
100# [2] real mosh: session through the hyperdht tunnel ✅
101# [session] one-command mosh session end-to-end ✅ (both skip if mosh absent)
121102```
122103
123104## Related
124105
125- `hyperident-shell` — the identity-keyed P2P shell this complements (and uses to
126 bootstrap `mosh-server`). Stream-based, so fragile on flaky links — which is
127 the gap hypermosh fills.
106- `hyperident-shell` — the identity-keyed P2P shell this complements. Stream-based,
107 so fragile on flaky links — the gap hypermosh fills.
128108- `celltron` — the retired-phone P2P node this is built for.
129109
130110## License
bin/hypermosh.mjs +35 -37
……@@ -1,27 +1,23 @@
11#!/usr/bin/env node
2// hypermosh — tunnel mosh's UDP over an identity-keyed hyperdht connection.
2// hypermosh — resilient P2P mosh sessions to identity-keyed nodes.
33//
4// hypermosh id
5// hypermosh serve --udp <mosh-server-port> [--firewall <file> | --disable-firewall]
6// hypermosh connect <server-pubkey> [--udp-listen <port>]
4// hypermosh id print this daemon's server pubkey
5// 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>
78//
8// Bootstrap flow (mosh-style, using hyperident-shell as the "ssh"):
9// 1) on the client, over hyperident-shell: mosh-server new -i 127.0.0.1
10// -> prints MOSH CONNECT <port> <key>
11// 2) on the server (celltron): hypermosh serve --udp <port>
12// 3) on the client: hypermosh connect <server-pubkey> --udp-listen 6000
13// 4) on the client: MOSH_KEY=<key> mosh-client 127.0.0.1 6000
14// See README for the details and the roadmap toward a one-command wrapper.
15import DHT from 'hyperdht'
9// The daemon runs on its own key (from ~/.hypermosh); the client presents the
10// hyperident device identity (~/.hyperident) to the daemon's firewall.
1611import process from '#node:process'
17import { serve, connect } from '../lib/relay.mjs'
18import { loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs'
12import cp from '#node:child_process'
13import { serveSession, connectSession } from '../lib/session.mjs'
14import { loadOrCreateKey, loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs'
1915
2016function flags (argv) {
21 const pos = []
22 const f = {}
17 const pos = []; const f = {}
2318 for (let i = 0; i < argv.length; i++) {
2419 const a = argv[i]
20 if (a === '--') { f._rest = argv.slice(i + 1); break }
2521 if (a.startsWith('--')) { const k = a.slice(2); const v = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; f[k] = v }
2622 else pos.push(a)
2723 }
……@@ -29,7 +25,7 @@ function flags (argv) {
2925}
3026
3127function usage () {
32 console.error('usage:\n hypermosh id\n hypermosh serve --udp <port> [--firewall <file> | --disable-firewall]\n hypermosh connect <server-pubkey> [--udp-listen <port>]')
28 console.error('usage:\n hypermosh id\n hypermosh serve [--firewall <f> | --disable-firewall]\n hypermosh <server-pubkey> [-- cmd...]')
3329 process.exit(1)
3430}
3531
……@@ -37,33 +33,35 @@ async function main () {
3733 const [cmd, ...rest] = process.argv.slice(2)
3834 const { pos, f } = flags(rest)
3935
40 if (cmd === 'id') {
41 console.log(loadDeviceKeyPair().pub)
42 return
43 }
36 if (cmd === 'id') { console.log(HypercoreId.encode(loadOrCreateKey().publicKey)); return }
4437
4538 if (cmd === 'serve') {
46 if (!f.udp) usage()
47 const keyPair = loadDeviceKeyPair()
39 const keyPair = loadOrCreateKey()
4840 const allow = f['disable-firewall'] ? true : readAllowed(f.firewall)
49 const s = await serve({ keyPair, udpPort: Number(f.udp), allow })
50 console.log('hypermosh serving as ' + HypercoreId.encode(s.publicKey))
51 console.log(' bridging hyperdht <-> 127.0.0.1:' + f.udp + ' (mosh-server)')
52 console.log(' firewall: ' + (allow === true ? 'DISABLED (any peer)' : (allow.length + ' allowed key(s)')))
41 const s = await serveSession({ keyPair, allow })
42 console.log('hypermosh daemon serving as ' + HypercoreId.encode(s.publicKey))
43 console.log(' spawns a fresh mosh-server per connection; firewall: ' + (allow === true ? 'DISABLED (any peer)' : allow.length + ' allowed key(s)'))
5344 process.on('SIGINT', async () => { await s.close(); process.exit(0) })
5445 return
5546 }
5647
57 if (cmd === 'connect') {
58 if (!pos[0]) usage()
59 const remoteKey = HypercoreId.decode(pos[0])
60 const keyPair = loadDeviceKeyPair() // present our device identity to the server firewall
61 const localPort = f['udp-listen'] ? Number(f['udp-listen']) : 0
62 const c = await connect({ remoteKey, keyPair, localPort })
63 console.log('hypermosh tunnel up.')
64 console.log(' point mosh-client at: 127.0.0.1 ' + c.localPort)
65 console.log(' e.g.: MOSH_KEY=<key> mosh-client 127.0.0.1 ' + c.localPort)
66 process.on('SIGINT', async () => { await c.close(); process.exit(0) })
48 if (cmd && !cmd.startsWith('-')) {
49 // one-command client: hypermosh <server-pubkey> [-- cmd]
50 const remoteKey = HypercoreId.decode(cmd)
51 let keyPair = null
52 try { keyPair = loadDeviceKeyPair() } catch { /* fall back to ephemeral identity */ }
53 const start = { term: process.env.TERM || 'xterm', cmd: f._rest || [] }
54 const session = connectSession({
55 remoteKey,
56 keyPair,
57 start,
58 onerror: (e) => { console.error('hypermosh: ' + (e.code || e.message)); process.exit(1) },
59 onstarted: (moshKey, localPort) => {
60 const env = { ...process.env, MOSH_KEY: moshKey, LC_ALL: process.env.LC_ALL || 'C.UTF-8' }
61 const child = cp.spawn('mosh-client', ['127.0.0.1', String(localPort)], { stdio: 'inherit', env })
62 child.on('exit', (code) => { session.close().then(() => process.exit(code || 0)) })
63 }
64 })
6765 return
6866 }
6967
lib/identity.mjs +19 -0
……@@ -5,6 +5,7 @@ import fs from '#node:fs'
55import os from '#node:os'
66import path from '#node:path'
77import b4a from 'b4a'
8import DHT from 'hyperdht'
89import HypercoreId from 'hypercore-id-encoding'
910
1011function homedir () { return os.homedir ? os.homedir() : (process.env.HOME || '/root') }
……@@ -30,6 +31,24 @@ export function loadDeviceKeyPair (vault = DEFAULT_VAULT()) {
3031 return { publicKey, secretKey, pub: HypercoreId.encode(publicKey) }
3132}
3233
34// The hypermosh session daemon runs on its OWN key (separate from the device
35// shell key — hyperident-shell already holds that, and one keyPair = one
36// hyperdht server). Persisted in ~/.hypermosh/session.json, created on first use.
37export function loadOrCreateKey (vault) {
38 const dir = vault || path.join(homedir(), '.hypermosh')
39 const kf = path.join(dir, 'session.json')
40 try {
41 const j = JSON.parse(fs.readFileSync(kf, 'utf8'))
42 return { publicKey: b4a.from(j.publicKey, 'hex'), secretKey: b4a.from(j.secretKey, 'hex') }
43 } catch (e) {
44 if (e.code !== 'ENOENT') throw e
45 const kp = DHT.keyPair()
46 fs.mkdirSync(dir, { recursive: true })
47 fs.writeFileSync(kf, JSON.stringify({ publicKey: b4a.toString(kp.publicKey, 'hex'), secretKey: b4a.toString(kp.secretKey, 'hex') }))
48 return kp
49 }
50}
51
3352// Read an allow-list firewall file (one z-base32/hex key per line, '#' comments).
3453export function readAllowed (firewallPath) {
3554 if (!firewallPath) return []
lib/session.mjs +127 -0
……@@ -0,0 +1,127 @@
1// session — the one-command layer. A self-contained hypermosh daemon that, per
2// connection, spawns its OWN mosh-server and bridges its UDP over the hyperdht
3// stream. Runs on its own key (separate from the device shell key).
4//
5// Wire format: typed frames over the reliable stream —
6// [uint8 type][uint32 BE len][payload]
7// type 0 (control): JSON — client sends {term,cmd}; server sends {ok,key}|{ok:false,error}
8// type 1 (data): raw — one frame == one UDP datagram (both directions)
9import DHT from 'hyperdht'
10import dgram from '#node:dgram'
11import cp from '#node:child_process'
12import b4a from 'b4a'
13
14const T_CTRL = 0
15const T_DATA = 1
16
17function frame (type, payload) {
18 const h = b4a.alloc(5)
19 h.writeUInt8(type, 0)
20 h.writeUInt32BE(payload.length, 1)
21 return b4a.concat([h, payload])
22}
23const ctrlFrame = (obj) => frame(T_CTRL, b4a.from(JSON.stringify(obj)))
24const dataFrame = (dg) => frame(T_DATA, dg)
25
26function makeParser (onCtrl, onData) {
27 let buf = b4a.alloc(0)
28 return (chunk) => {
29 buf = b4a.concat([buf, chunk])
30 while (buf.length >= 5) {
31 const type = buf.readUInt8(0)
32 const len = buf.readUInt32BE(1)
33 if (buf.length < 5 + len) break
34 const payload = buf.subarray(5, 5 + len)
35 buf = buf.subarray(5 + len)
36 if (type === T_CTRL) { try { onCtrl(JSON.parse(b4a.toString(payload))) } catch {} }
37 else onData(payload)
38 }
39 }
40}
41
42export async function serveSession ({ keyPair, allow = true, node = null } = {}) {
43 const dht = node || new DHT()
44 const firewall = (rpk) => !(allow === true || allow.some((k) => b4a.equals(rpk, k)))
45 const server = dht.createServer({ firewall }, (socket) => {
46 socket.on('error', () => {})
47 let udp = null
48 let moshPort = null
49 let daemonPid = null
50 const onCtrl = (start) => {
51 const args = ['new', '-i', '127.0.0.1']
52 if (start && Array.isArray(start.cmd) && start.cmd.length) args.push('--', ...start.cmd)
53 let proc
54 try {
55 proc = cp.spawn('mosh-server', args, { env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' } })
56 } catch (e) {
57 try { socket.write(ctrlFrame({ ok: false, error: 'spawn mosh-server: ' + e.message })) } catch {}
58 return
59 }
60 let out = ''
61 const scan = (d) => {
62 out += d
63 const m = out.match(/MOSH CONNECT (\d+) (\S+)/)
64 if (m && !moshPort) {
65 moshPort = Number(m[1])
66 udp = dgram.createSocket('udp4')
67 udp.on('message', (msg) => { try { socket.write(dataFrame(msg)) } catch {} })
68 try { socket.write(ctrlFrame({ ok: true, key: m[2] })) } catch {}
69 }
70 const pm = out.match(/pid\s*=\s*(\d+)/)
71 if (pm) daemonPid = Number(pm[1])
72 }
73 proc.stdout.on('data', scan)
74 proc.stderr.on('data', scan)
75 // Do NOT socket.end() on proc 'exit': mosh-server DAEMONIZES — the spawned
76 // parent exits right after printing MOSH CONNECT while the real server runs
77 // detached. Ending the socket there closed the write side before any
78 // session data could flow (the bug). We track the detached pid for cleanup.
79 }
80 const onData = (dg) => { if (udp && moshPort) { try { udp.send(dg, moshPort, '127.0.0.1') } catch {} } }
81 socket.on('data', makeParser(onCtrl, onData))
82 socket.on('close', () => {
83 try { if (udp) udp.close() } catch {}
84 try { if (daemonPid) process.kill(daemonPid) } catch {} // reap the detached mosh-server
85 })
86 })
87 await server.listen(keyPair)
88 return { dht, server, publicKey: keyPair.publicKey, close: async () => { await server.close(); if (!node) await dht.destroy() } }
89}
90
91const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET'])
92
93export function connectSession ({ remoteKey, keyPair = null, start = {}, onstarted, onerror = null, retries = 5 } = {}) {
94 const state = { dht: null, socket: null, udp: null, closed: false }
95 let attempt = 0
96 const fail = (err) => { if (onerror) onerror(err) }
97 const go = () => {
98 if (state.closed) return
99 attempt++
100 const dht = new DHT()
101 const socket = dht.connect(remoteKey, keyPair ? { keyPair } : undefined)
102 const udp = dgram.createSocket('udp4')
103 state.dht = dht; state.socket = socket; state.udp = udp
104 let established = false
105 let peer = null
106 socket.setKeepAlive(5000)
107 const onCtrl = (m) => { if (m && m.ok) onstarted(m.key, udp.address().port); else fail(new Error((m && m.error) || 'session failed')) }
108 const onData = (dg) => { if (peer) { try { udp.send(dg, peer.port, peer.address) } catch {} } }
109 socket.on('data', makeParser(onCtrl, onData))
110 udp.on('message', (msg, rinfo) => { peer = rinfo; try { socket.write(dataFrame(msg)) } catch {} })
111 socket.once('open', () => {
112 established = true
113 udp.bind(0, '127.0.0.1', () => { try { socket.write(ctrlFrame(start)) } catch {} })
114 socket.once('close', () => { try { udp.close() } catch {}; dht.destroy() })
115 })
116 socket.on('error', (err) => {
117 if (established) { fail(err); return }
118 try { socket.destroy() } catch {}
119 dht.destroy().finally(() => {
120 if (!state.closed && attempt <= retries && (TRANSIENT.has(err.code) || !err.code)) setTimeout(go, 400 * attempt)
121 else fail(err)
122 })
123 })
124 }
125 go()
126 return { close: async () => { state.closed = true; try { if (state.udp) state.udp.close() } catch {}; try { if (state.socket) state.socket.destroy() } catch {}; try { if (state.dht) await state.dht.destroy() } catch {} } }
127}
package-lock.json +121 -3
……@@ -10,8 +10,10 @@
1010 "license": "Apache-2.0",
1111 "dependencies": {
1212 "b4a": "^1.6.0",
13 "compact-encoding": "^2.11.0",
1314 "hypercore-id-encoding": "^1.2.0",
14 "hyperdht": "^6.6.0"
15 "hyperdht": "^6.6.0",
16 "protomux": "^3.4.0"
1517 },
1618 "bin": {
1719 "hypermosh": "bin/hypermosh.mjs"
……@@ -21,7 +23,8 @@
2123 "bare-fs": "^4.0.0",
2224 "bare-os": "^3.0.0",
2325 "bare-path": "^3.0.0",
24 "bare-process": "^4.0.0"
26 "bare-process": "^4.0.0",
27 "bare-subprocess": "^5.0.0"
2528 }
2629 },
2730 "node_modules/@hyperswarm/secret-stream": {
……@@ -323,6 +326,31 @@
323326 }
324327 }
325328 },
329 "node_modules/bare-subprocess": {
330 "version": "5.2.3",
331 "resolved": "https://registry.npmjs.org/bare-subprocess/-/bare-subprocess-5.2.3.tgz",
332 "integrity": "sha512-07wwswlV7M3sC9IykbZRZ/jHAkrXFWVLqdBWGv1y0ojCimtRD9hGwxdHmR5FUFmDUZLNsBmTYJNQqgio5+A85Q==",
333 "license": "Apache-2.0",
334 "optional": true,
335 "dependencies": {
336 "bare-env": "^3.0.0",
337 "bare-events": "^2.5.4",
338 "bare-os": "^3.0.1",
339 "bare-pipe": "^4.0.0",
340 "bare-url": "^2.2.2"
341 },
342 "engines": {
343 "bare": ">=1.7.0"
344 },
345 "peerDependencies": {
346 "bare-buffer": "*"
347 },
348 "peerDependenciesMeta": {
349 "bare-buffer": {
350 "optional": true
351 }
352 }
353 },
326354 "node_modules/bare-tty": {
327355 "version": "5.1.2",
328356 "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.1.2.tgz",
……@@ -382,6 +410,15 @@
382410 "streamx": "^2.15.1"
383411 }
384412 },
413 "node_modules/blind-relay/node_modules/compact-encoding": {
414 "version": "3.3.0",
415 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
416 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
417 "license": "Apache-2.0",
418 "dependencies": {
419 "b4a": "^1.3.0"
420 }
421 },
385422 "node_modules/bogon": {
386423 "version": "1.3.0",
387424 "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.3.0.tgz",
……@@ -392,7 +429,7 @@
392429 "compact-encoding-net": "^1.2.0"
393430 }
394431 },
395 "node_modules/compact-encoding": {
432 "node_modules/bogon/node_modules/compact-encoding": {
396433 "version": "3.3.0",
397434 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
398435 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
……@@ -401,6 +438,15 @@
401438 "b4a": "^1.3.0"
402439 }
403440 },
441 "node_modules/compact-encoding": {
442 "version": "2.19.2",
443 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-2.19.2.tgz",
444 "integrity": "sha512-/YjhHQE/5L4F7l5Bht69dRbP9RV6zoJPeowi8bMKQxNKe3Nh6hOY8pBGoVE9fz5GaWfEd8fWJ2aU9sB4KZuMYg==",
445 "license": "Apache-2.0",
446 "dependencies": {
447 "b4a": "^1.3.0"
448 }
449 },
404450 "node_modules/compact-encoding-bitfield": {
405451 "version": "1.1.0",
406452 "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.1.0.tgz",
……@@ -410,6 +456,15 @@
410456 "compact-encoding": "^3.0.0"
411457 }
412458 },
459 "node_modules/compact-encoding-bitfield/node_modules/compact-encoding": {
460 "version": "3.3.0",
461 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
462 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
463 "license": "Apache-2.0",
464 "dependencies": {
465 "b4a": "^1.3.0"
466 }
467 },
413468 "node_modules/compact-encoding-net": {
414469 "version": "1.3.0",
415470 "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.3.0.tgz",
……@@ -419,6 +474,15 @@
419474 "compact-encoding": "^3.0.0"
420475 }
421476 },
477 "node_modules/compact-encoding-net/node_modules/compact-encoding": {
478 "version": "3.3.0",
479 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
480 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
481 "license": "Apache-2.0",
482 "dependencies": {
483 "b4a": "^1.3.0"
484 }
485 },
422486 "node_modules/dht-rpc": {
423487 "version": "6.27.0",
424488 "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz",
……@@ -439,6 +503,15 @@
439503 "udx-native": "^1.5.3"
440504 }
441505 },
506 "node_modules/dht-rpc/node_modules/compact-encoding": {
507 "version": "3.3.0",
508 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
509 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
510 "license": "Apache-2.0",
511 "dependencies": {
512 "b4a": "^1.3.0"
513 }
514 },
442515 "node_modules/events-universal": {
443516 "version": "1.0.1",
444517 "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz",
……@@ -480,6 +553,15 @@
480553 "sodium-universal": "^5.0.0"
481554 }
482555 },
556 "node_modules/hypercore-crypto/node_modules/compact-encoding": {
557 "version": "3.3.0",
558 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
559 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
560 "license": "Apache-2.0",
561 "dependencies": {
562 "b4a": "^1.3.0"
563 }
564 },
483565 "node_modules/hypercore-id-encoding": {
484566 "version": "1.3.0",
485567 "resolved": "https://registry.npmjs.org/hypercore-id-encoding/-/hypercore-id-encoding-1.3.0.tgz",
……@@ -530,6 +612,24 @@
530612 "hyperschema": "^1.20.1"
531613 }
532614 },
615 "node_modules/hyperdht-address/node_modules/compact-encoding": {
616 "version": "3.3.0",
617 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
618 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
619 "license": "Apache-2.0",
620 "dependencies": {
621 "b4a": "^1.3.0"
622 }
623 },
624 "node_modules/hyperdht/node_modules/compact-encoding": {
625 "version": "3.3.0",
626 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
627 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
628 "license": "Apache-2.0",
629 "dependencies": {
630 "b4a": "^1.3.0"
631 }
632 },
533633 "node_modules/hyperschema": {
534634 "version": "1.21.0",
535635 "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.21.0.tgz",
……@@ -542,6 +642,15 @@
542642 "generate-string": "^1.0.1"
543643 }
544644 },
645 "node_modules/hyperschema/node_modules/compact-encoding": {
646 "version": "3.3.0",
647 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
648 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
649 "license": "Apache-2.0",
650 "dependencies": {
651 "b4a": "^1.3.0"
652 }
653 },
545654 "node_modules/is-property": {
546655 "version": "1.0.2",
547656 "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
……@@ -604,6 +713,15 @@
604713 "unslab": "^1.3.0"
605714 }
606715 },
716 "node_modules/protomux/node_modules/compact-encoding": {
717 "version": "3.3.0",
718 "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz",
719 "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==",
720 "license": "Apache-2.0",
721 "dependencies": {
722 "b4a": "^1.3.0"
723 }
724 },
607725 "node_modules/queue-tick": {
608726 "version": "1.0.1",
609727 "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz",
package.json +8 -4
……@@ -10,20 +10,24 @@
1010 "#node:fs": { "bare": "bare-fs", "default": "fs" },
1111 "#node:os": { "bare": "bare-os", "default": "os" },
1212 "#node:path": { "bare": "bare-path", "default": "path" },
13 "#node:process": { "bare": "bare-process", "default": "process" }
13 "#node:process": { "bare": "bare-process", "default": "process" },
14 "#node:child_process": { "bare": "bare-subprocess", "default": "child_process" }
1415 },
15 "scripts": { "test": "node test/tunnel.test.mjs" },
16 "scripts": { "test": "node test/tunnel.test.mjs && node test/session.test.mjs" },
1617 "dependencies": {
1718 "b4a": "^1.6.0",
19 "compact-encoding": "^2.11.0",
1820 "hypercore-id-encoding": "^1.2.0",
19 "hyperdht": "^6.6.0"
21 "hyperdht": "^6.6.0",
22 "protomux": "^3.4.0"
2023 },
2124 "optionalDependencies": {
2225 "bare-dgram": "^1.0.0",
2326 "bare-fs": "^4.0.0",
2427 "bare-os": "^3.0.0",
2528 "bare-path": "^3.0.0",
26 "bare-process": "^4.0.0"
29 "bare-process": "^4.0.0",
30 "bare-subprocess": "^5.0.0"
2731 },
2832 "license": "Apache-2.0",
2933 "author": "hyper-fleeky"
test/session.test.mjs +48 -0
……@@ -0,0 +1,48 @@
1// session test — the one-command flow over loopback: serveSession spawns
2// mosh-server itself, connectSession requests a session and gets the key back,
3// then mosh-client (driven in a PTY) runs the session through the tunnel.
4import DHT from 'hyperdht'
5import { spawn, execSync } from 'child_process'
6import { fileURLToPath } from 'url'
7import path from 'path'
8import { serveSession, connectSession } from '../lib/session.mjs'
9
10const __dirname = path.dirname(fileURLToPath(import.meta.url))
11
12let hasMosh = true
13try { execSync('command -v mosh-server && command -v mosh-client && command -v python3', { stdio: 'ignore' }) } catch { hasMosh = false }
14if (!hasMosh) { console.log('session test: SKIPPED (mosh/python3 not installed)'); process.exit(0) }
15
16console.log('[session] serveSession (spawns mosh-server) <-hyperdht-> connectSession -> mosh-client')
17
18const keyPair = DHT.keyPair()
19const s = await serveSession({ keyPair, allow: true })
20
21let failed = false
22const done = new Promise((resolve) => {
23 const timer = setTimeout(() => { console.log(' ❌ FAIL — no session/marker in 40s'); failed = true; resolve() }, 40000)
24 const session = connectSession({
25 remoteKey: keyPair.publicKey,
26 start: { term: 'xterm', cmd: ['bash', '-lc', 'echo HYPERMOSH_SESSION_OK; sleep 8'] },
27 onerror: (e) => { console.log(' ❌ FAIL — connect error:', e.message); failed = true; clearTimeout(timer); resolve() },
28 onstarted: (moshKey, localPort) => {
29 console.log(' session started, mosh key received; driving mosh-client on 127.0.0.1:' + localPort)
30 const drv = spawn('python3', [path.join(__dirname, 'pty-mosh.py'), moshKey, '127.0.0.1', String(localPort), '12'], { env: process.env })
31 let cap = ''
32 drv.stdout.on('data', (d) => { cap += d })
33 drv.on('exit', async () => {
34 const clean = cap.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
35 if (clean.includes('HYPERMOSH_SESSION_OK')) console.log(' ✅ PASS — one-command mosh session ran end-to-end over hyperdht')
36 else { console.log(' ❌ FAIL — marker not seen'); failed = true }
37 clearTimeout(timer)
38 await session.close()
39 resolve()
40 })
41 }
42 })
43})
44
45await done
46await s.close()
47console.log(failed ? '\n❌ session test failed' : '\n✅ session test passed')
48process.exit(failed ? 1 : 0)