| … | … | @@ -2,129 +2,109 @@ |
| 2 | 2 | |
| 3 | 3 | **Resilient, roaming terminal sessions to identity-keyed P2P nodes** — real |
| 4 | 4 | [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. | |
| 6 | 6 | |
| 7 | 7 | Built for **celltron-class nodes**: NAT'd, Doze-prone phones with no reachable |
| 8 | 8 | UDP port, where a plain reliable-stream shell (like `hyperident-shell`) **dies on |
| 9 | 9 | every network flap** — Doze, wifi handoff, cellular↔wifi roaming, a DHT hiccup. |
| 10 | mosh was designed for exactly that world. hypermosh-shell gives mosh a P2P | |
| 11 | datagram path so its resilience works against a peer you reach *by public key*, | |
| 12 | not by `host:port`. | |
| 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`. | |
| 13 | 12 | |
| 14 | ## The idea | |
| 13 | ## One command | |
| 14 | ||
| 15 | ```sh | |
| 16 | hypermosh <server-pubkey> # interactive mosh session over the DHT | |
| 17 | hypermosh <server-pubkey> -- htop # run a specific command | |
| 18 | ``` | |
| 19 | ||
| 20 | Like `mosh user@host`, but the "host" is a hyperident public key and the | |
| 21 | transport is P2P. | |
| 22 | ||
| 23 | ## How it works | |
| 15 | 24 | |
| 16 | 25 | ``` |
| 17 | tabertron (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) | |
| 26 | tabertron (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 ───────┘ | |
| 22 | 32 | ``` |
| 23 | 33 | |
| 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. | |
| 33 | 44 | |
| 34 | The relay itself is deliberately dumb: a UDP↔stream bridge with framing and | |
| 35 | reconnect. **mosh provides all the resilience**; hypermosh only provides the | |
| 36 | pipe. | |
| 45 | Wire format is typed frames over the reliable stream: | |
| 46 | `[uint8 type][uint32 len][payload]` — type 0 control (JSON), type 1 datagram. | |
| 37 | 47 | |
| 38 | 48 | ## Status |
| 39 | 49 | |
| 40 | 50 | | Piece | State | |
| 41 | 51 | |---|---| |
| 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 | | |
| 48 | 57 | |
| 49 | 58 | ## Install |
| 50 | 59 | |
| 51 | 60 | ```sh |
| 52 | npm install # hyperdht, b4a, hypercore-id-encoding | |
| 61 | npm install # hyperdht, b4a, hypercore-id-encoding | |
| 53 | 62 | # requires `mosh` on both ends; on celltron, mosh-server goes in the proot rootfs |
| 54 | 63 | ``` |
| 55 | 64 | |
| 56 | Dual-runtime: runs on **Node** (client side / testing) and **Bare** (celltron) | |
| 57 | via the `#node:dgram` import map (`dgram` on node, `bare-dgram` on bare). | |
| 58 | ||
| 59 | ## Usage (manual bootstrap — mosh-style) | |
| 60 | ||
| 61 | mosh normally uses `ssh` to start `mosh-server` and hand back a one-time key. | |
| 62 | hypermosh 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 | |
| 66 | hyperident-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 | |
| 72 | hypermosh 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 | |
| 78 | hypermosh connect <celltron-key> --udp-listen 6000 | |
| 79 | # -> point mosh-client at: 127.0.0.1 6000 | |
| 80 | MOSH_KEY=<base64-key> mosh-client 127.0.0.1 6000 | |
| 81 | ``` | |
| 82 | ||
| 83 | You now have a mosh session on celltron that **survives Doze, roaming, and DHT | |
| 84 | flaps** — 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. | |
| 65 | Dual-runtime: Node (client/testing) and Bare (celltron) via the `#node:*` import | |
| 66 | map (`dgram`→`bare-dgram`, `child_process`→`bare-subprocess`, …). | |
| 89 | 67 | |
| 90 | 68 | ## CLI |
| 91 | 69 | |
| 92 | 70 | ``` |
| 93 | hypermosh id # print this device's pubkey (hyperident vault) | |
| 94 | hypermosh serve --udp <port> [--firewall <f> | --disable-firewall] | |
| 95 | hypermosh connect <server-pubkey> [--udp-listen <port>] | |
| 71 | hypermosh id # this daemon's server pubkey (~/.hypermosh) | |
| 72 | hypermosh serve [--firewall <f> | --disable-firewall] | |
| 73 | hypermosh <server-pubkey> [-- cmd...] | |
| 96 | 74 | ``` |
| 97 | 75 | |
| 98 | Identity comes from the hyperident vault (`~/.hyperident/device.key` + `.pub`) — | |
| 99 | the same identity as `hyperident-shell`, so the same firewall allow-list governs | |
| 100 | both. | |
| 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. | |
| 101 | 80 | |
| 102 | 81 | ## Design notes |
| 103 | 82 | |
| 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. | |
| 114 | 94 | |
| 115 | 95 | ## Tests |
| 116 | 96 | |
| 117 | 97 | ```sh |
| 118 | 98 | npm test |
| 119 | 99 | # [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) | |
| 121 | 102 | ``` |
| 122 | 103 | |
| 123 | 104 | ## Related |
| 124 | 105 | |
| 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. | |
| 128 | 108 | - `celltron` — the retired-phone P2P node this is built for. |
| 129 | 109 | |
| 130 | 110 | ## License |
| … | … | @@ -1,27 +1,23 @@ |
| 1 | 1 | #!/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. | |
| 3 | 3 | // |
| 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> | |
| 7 | 8 | // |
| 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. | |
| 15 | import 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. | |
| 16 | 11 | import process from '#node:process' |
| 17 | import { serve, connect } from '../lib/relay.mjs' | |
| 18 | import { loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs' | |
| 12 | import cp from '#node:child_process' | |
| 13 | import { serveSession, connectSession } from '../lib/session.mjs' | |
| 14 | import { loadOrCreateKey, loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs' | |
| 19 | 15 | |
| 20 | 16 | function flags (argv) { |
| 21 | const pos = [] | |
| 22 | const f = {} | |
| 17 | const pos = []; const f = {} | |
| 23 | 18 | for (let i = 0; i < argv.length; i++) { |
| 24 | 19 | const a = argv[i] |
| 20 | if (a === '--') { f._rest = argv.slice(i + 1); break } | |
| 25 | 21 | if (a.startsWith('--')) { const k = a.slice(2); const v = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; f[k] = v } |
| 26 | 22 | else pos.push(a) |
| 27 | 23 | } |
| … | … | @@ -29,7 +25,7 @@ function flags (argv) { |
| 29 | 25 | } |
| 30 | 26 | |
| 31 | 27 | function 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...]') | |
| 33 | 29 | process.exit(1) |
| 34 | 30 | } |
| 35 | 31 | |
| … | … | @@ -37,33 +33,35 @@ async function main () { |
| 37 | 33 | const [cmd, ...rest] = process.argv.slice(2) |
| 38 | 34 | const { pos, f } = flags(rest) |
| 39 | 35 | |
| 40 | if (cmd === 'id') { | |
| 41 | console.log(loadDeviceKeyPair().pub) | |
| 42 | return | |
| 43 | } | |
| 36 | if (cmd === 'id') { console.log(HypercoreId.encode(loadOrCreateKey().publicKey)); return } | |
| 44 | 37 | |
| 45 | 38 | if (cmd === 'serve') { |
| 46 | if (!f.udp) usage() | |
| 47 | const keyPair = loadDeviceKeyPair() | |
| 39 | const keyPair = loadOrCreateKey() | |
| 48 | 40 | 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)')) | |
| 53 | 44 | process.on('SIGINT', async () => { await s.close(); process.exit(0) }) |
| 54 | 45 | return |
| 55 | 46 | } |
| 56 | 47 | |
| 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 | }) | |
| 67 | 65 | return |
| 68 | 66 | } |
| 69 | 67 |
| … | … | @@ -5,6 +5,7 @@ import fs from '#node:fs' |
| 5 | 5 | import os from '#node:os' |
| 6 | 6 | import path from '#node:path' |
| 7 | 7 | import b4a from 'b4a' |
| 8 | import DHT from 'hyperdht' | |
| 8 | 9 | import HypercoreId from 'hypercore-id-encoding' |
| 9 | 10 | |
| 10 | 11 | function homedir () { return os.homedir ? os.homedir() : (process.env.HOME || '/root') } |
| … | … | @@ -30,6 +31,24 @@ export function loadDeviceKeyPair (vault = DEFAULT_VAULT()) { |
| 30 | 31 | return { publicKey, secretKey, pub: HypercoreId.encode(publicKey) } |
| 31 | 32 | } |
| 32 | 33 | |
| 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. | |
| 37 | export 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 | ||
| 33 | 52 | // Read an allow-list firewall file (one z-base32/hex key per line, '#' comments). |
| 34 | 53 | export function readAllowed (firewallPath) { |
| 35 | 54 | if (!firewallPath) return [] |
| … | … | @@ -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) | |
| 9 | import DHT from 'hyperdht' | |
| 10 | import dgram from '#node:dgram' | |
| 11 | import cp from '#node:child_process' | |
| 12 | import b4a from 'b4a' | |
| 13 | ||
| 14 | const T_CTRL = 0 | |
| 15 | const T_DATA = 1 | |
| 16 | ||
| 17 | function 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 | } | |
| 23 | const ctrlFrame = (obj) => frame(T_CTRL, b4a.from(JSON.stringify(obj))) | |
| 24 | const dataFrame = (dg) => frame(T_DATA, dg) | |
| 25 | ||
| 26 | function 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 | ||
| 42 | export 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 | ||
| 91 | const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET']) | |
| 92 | ||
| 93 | export 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 | } |
| … | … | @@ -10,8 +10,10 @@ |
| 10 | 10 | "license": "Apache-2.0", |
| 11 | 11 | "dependencies": { |
| 12 | 12 | "b4a": "^1.6.0", |
| 13 | "compact-encoding": "^2.11.0", | |
| 13 | 14 | "hypercore-id-encoding": "^1.2.0", |
| 14 | "hyperdht": "^6.6.0" | |
| 15 | "hyperdht": "^6.6.0", | |
| 16 | "protomux": "^3.4.0" | |
| 15 | 17 | }, |
| 16 | 18 | "bin": { |
| 17 | 19 | "hypermosh": "bin/hypermosh.mjs" |
| … | … | @@ -21,7 +23,8 @@ |
| 21 | 23 | "bare-fs": "^4.0.0", |
| 22 | 24 | "bare-os": "^3.0.0", |
| 23 | 25 | "bare-path": "^3.0.0", |
| 24 | "bare-process": "^4.0.0" | |
| 26 | "bare-process": "^4.0.0", | |
| 27 | "bare-subprocess": "^5.0.0" | |
| 25 | 28 | } |
| 26 | 29 | }, |
| 27 | 30 | "node_modules/@hyperswarm/secret-stream": { |
| … | … | @@ -323,6 +326,31 @@ |
| 323 | 326 | } |
| 324 | 327 | } |
| 325 | 328 | }, |
| 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 | }, | |
| 326 | 354 | "node_modules/bare-tty": { |
| 327 | 355 | "version": "5.1.2", |
| 328 | 356 | "resolved": "https://registry.npmjs.org/bare-tty/-/bare-tty-5.1.2.tgz", |
| … | … | @@ -382,6 +410,15 @@ |
| 382 | 410 | "streamx": "^2.15.1" |
| 383 | 411 | } |
| 384 | 412 | }, |
| 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 | }, | |
| 385 | 422 | "node_modules/bogon": { |
| 386 | 423 | "version": "1.3.0", |
| 387 | 424 | "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.3.0.tgz", |
| … | … | @@ -392,7 +429,7 @@ |
| 392 | 429 | "compact-encoding-net": "^1.2.0" |
| 393 | 430 | } |
| 394 | 431 | }, |
| 395 | "node_modules/compact-encoding": { | |
| 432 | "node_modules/bogon/node_modules/compact-encoding": { | |
| 396 | 433 | "version": "3.3.0", |
| 397 | 434 | "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.3.0.tgz", |
| 398 | 435 | "integrity": "sha512-e64XyzlBvTRJ3iScuU/U2w25Dglkm7fhrRbrGaHIwuTlNnzjRKMaQBCVl7mJRvZg7wI5a9wX1IVTXCuaAANNkw==", |
| … | … | @@ -401,6 +438,15 @@ |
| 401 | 438 | "b4a": "^1.3.0" |
| 402 | 439 | } |
| 403 | 440 | }, |
| 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 | }, | |
| 404 | 450 | "node_modules/compact-encoding-bitfield": { |
| 405 | 451 | "version": "1.1.0", |
| 406 | 452 | "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.1.0.tgz", |
| … | … | @@ -410,6 +456,15 @@ |
| 410 | 456 | "compact-encoding": "^3.0.0" |
| 411 | 457 | } |
| 412 | 458 | }, |
| 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 | }, | |
| 413 | 468 | "node_modules/compact-encoding-net": { |
| 414 | 469 | "version": "1.3.0", |
| 415 | 470 | "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.3.0.tgz", |
| … | … | @@ -419,6 +474,15 @@ |
| 419 | 474 | "compact-encoding": "^3.0.0" |
| 420 | 475 | } |
| 421 | 476 | }, |
| 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 | }, | |
| 422 | 486 | "node_modules/dht-rpc": { |
| 423 | 487 | "version": "6.27.0", |
| 424 | 488 | "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz", |
| … | … | @@ -439,6 +503,15 @@ |
| 439 | 503 | "udx-native": "^1.5.3" |
| 440 | 504 | } |
| 441 | 505 | }, |
| 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 | }, | |
| 442 | 515 | "node_modules/events-universal": { |
| 443 | 516 | "version": "1.0.1", |
| 444 | 517 | "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", |
| … | … | @@ -480,6 +553,15 @@ |
| 480 | 553 | "sodium-universal": "^5.0.0" |
| 481 | 554 | } |
| 482 | 555 | }, |
| 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 | }, | |
| 483 | 565 | "node_modules/hypercore-id-encoding": { |
| 484 | 566 | "version": "1.3.0", |
| 485 | 567 | "resolved": "https://registry.npmjs.org/hypercore-id-encoding/-/hypercore-id-encoding-1.3.0.tgz", |
| … | … | @@ -530,6 +612,24 @@ |
| 530 | 612 | "hyperschema": "^1.20.1" |
| 531 | 613 | } |
| 532 | 614 | }, |
| 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 | }, | |
| 533 | 633 | "node_modules/hyperschema": { |
| 534 | 634 | "version": "1.21.0", |
| 535 | 635 | "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.21.0.tgz", |
| … | … | @@ -542,6 +642,15 @@ |
| 542 | 642 | "generate-string": "^1.0.1" |
| 543 | 643 | } |
| 544 | 644 | }, |
| 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 | }, | |
| 545 | 654 | "node_modules/is-property": { |
| 546 | 655 | "version": "1.0.2", |
| 547 | 656 | "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", |
| … | … | @@ -604,6 +713,15 @@ |
| 604 | 713 | "unslab": "^1.3.0" |
| 605 | 714 | } |
| 606 | 715 | }, |
| 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 | }, | |
| 607 | 725 | "node_modules/queue-tick": { |
| 608 | 726 | "version": "1.0.1", |
| 609 | 727 | "resolved": "https://registry.npmjs.org/queue-tick/-/queue-tick-1.0.1.tgz", |
| … | … | @@ -10,20 +10,24 @@ |
| 10 | 10 | "#node:fs": { "bare": "bare-fs", "default": "fs" }, |
| 11 | 11 | "#node:os": { "bare": "bare-os", "default": "os" }, |
| 12 | 12 | "#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" } | |
| 14 | 15 | }, |
| 15 | "scripts": { "test": "node test/tunnel.test.mjs" }, | |
| 16 | "scripts": { "test": "node test/tunnel.test.mjs && node test/session.test.mjs" }, | |
| 16 | 17 | "dependencies": { |
| 17 | 18 | "b4a": "^1.6.0", |
| 19 | "compact-encoding": "^2.11.0", | |
| 18 | 20 | "hypercore-id-encoding": "^1.2.0", |
| 19 | "hyperdht": "^6.6.0" | |
| 21 | "hyperdht": "^6.6.0", | |
| 22 | "protomux": "^3.4.0" | |
| 20 | 23 | }, |
| 21 | 24 | "optionalDependencies": { |
| 22 | 25 | "bare-dgram": "^1.0.0", |
| 23 | 26 | "bare-fs": "^4.0.0", |
| 24 | 27 | "bare-os": "^3.0.0", |
| 25 | 28 | "bare-path": "^3.0.0", |
| 26 | "bare-process": "^4.0.0" | |
| 29 | "bare-process": "^4.0.0", | |
| 30 | "bare-subprocess": "^5.0.0" | |
| 27 | 31 | }, |
| 28 | 32 | "license": "Apache-2.0", |
| 29 | 33 | "author": "hyper-fleeky" |
| … | … | @@ -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. | |
| 4 | import DHT from 'hyperdht' | |
| 5 | import { spawn, execSync } from 'child_process' | |
| 6 | import { fileURLToPath } from 'url' | |
| 7 | import path from 'path' | |
| 8 | import { serveSession, connectSession } from '../lib/session.mjs' | |
| 9 | ||
| 10 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) | |
| 11 | ||
| 12 | let hasMosh = true | |
| 13 | try { execSync('command -v mosh-server && command -v mosh-client && command -v python3', { stdio: 'ignore' }) } catch { hasMosh = false } | |
| 14 | if (!hasMosh) { console.log('session test: SKIPPED (mosh/python3 not installed)'); process.exit(0) } | |
| 15 | ||
| 16 | console.log('[session] serveSession (spawns mosh-server) <-hyperdht-> connectSession -> mosh-client') | |
| 17 | ||
| 18 | const keyPair = DHT.keyPair() | |
| 19 | const s = await serveSession({ keyPair, allow: true }) | |
| 20 | ||
| 21 | let failed = false | |
| 22 | const 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 | ||
| 45 | await done | |
| 46 | await s.close() | |
| 47 | console.log(failed ? '\n❌ session test failed' : '\n✅ session test passed') | |
| 48 | process.exit(failed ? 1 : 0) |