OverviewFilesCommitsIssuesDocsReleasesPeers

hypermosh-shell commits / c382c34

SSH-style login by default + durability fixes + observability
Root cause of "just does not connect" (confirmed by repro): a plain `hypermosh serve` built an empty firewall allow-list, which reads as DENY-ALL. Client saw a silent hang (good NAT) or PEER_CONNECTION_FAILED / HOLEPUNCH_ABORTED (termux) with no explanation. Auth redesign: - DEFAULT is now SSH-style login: the hyperdht stream tunnels a real SSH connection to the server's system sshd (127.0.0.1:22), so password / ssh-key / authorized_keys all work exactly like `mosh user@host`. sshd is the auth gate; the hyperdht firewall is open. (lib/ssh-mode.mjs) - --hyperident opts into the pubkey-firewall model (former default). Empty allow-list => deny-all, now LOUDLY proclaimed at startup. Durability (matches how mosh actually works — audited /usr/bin/mosh): - mosh-server is launched inside the authenticated SSH session, so it runs as the logged-in user AND daemonizes independent of the hyperdht stream. A wifi/Doze flap now just redials and re-bridges UDP to the SAME mosh-server => real mosh roaming (fixes the reconnect gaps). Observability (answers "can I enable more logging?"): - HYPERMOSH_DEBUG=1 / -v connection logging (lib/log.mjs). - Client handshake timeout: no more silent hang when the server is reachable but never completes a session. - Server now reports mosh-server spawn/exit failures instead of going silent. - mosh-client spawn-error handler; PEER_CONNECTION_FAILED added to retry set. Also: shared typed-frame codec with an SSH channel (lib/frames.mjs); clamp the server UDP bridge to 127.0.0.1 + mosh's port range so an authenticated tunnel can't be an arbitrary loopback proxy. Tests: frames (unit), firewall (root-cause regression), ssh-mode (full SSH-login e2e over hyperdht with an isolated sshd). P2P self-hairpin loopback tests moved to `npm run test:p2p` (flaky on a single machine; real proof is the cross-machine e2e). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
c382c34e423d1a8d61b8427747008ca5a1788242
by plankatron <fleeky@gmail.com> · 2026-07-19T21:25:08.000Z

Changed files (10) +670 -70

bin/hypermosh.mjs +82 -21
……@@ -2,26 +2,35 @@
22// hypermosh — resilient P2P mosh sessions to peers reached by public key.
33//
44// 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 <name-or-pubkey> [-- cmd] one command: open a mosh session
5// hypermosh serve [--hyperident [--firewall <f> | --disable-firewall]]
6// run the session daemon
7// hypermosh [user@]<name-or-pubkey> [-- cmd] open a mosh session
88// hypermosh add-name <name> <pubkey> remember a name -> pubkey (optional)
99// hypermosh names list known names
1010//
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.
11// AUTH — two models:
12// • DEFAULT (SSH-style login): the tunnel carries a normal SSH connection to
13// the server's system sshd, so password / ssh-key / authorized_keys all work
14// exactly like `mosh user@host`. sshd is the auth gate; the hyperdht firewall
15// is open. This is what most people want.
16// • --hyperident: no SSH — auth is the hyperdht pubkey firewall (allow-list of
17// client identity keys). The client presents its ~/.hyperident device key.
18//
19// A raw public key ALWAYS works; names are an optional convenience.
1420import process from '#node:process'
1521import cp from '#node:child_process'
1622import { serveSession, connectSession } from '../lib/session.mjs'
23import { serveSSH, connectSSH } from '../lib/ssh-mode.mjs'
1724import { loadOrCreateKey, loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs'
18import { resolve as resolveName, addName, loadNames } from '../lib/names.mjs'
25import { resolve as resolveName } from '../lib/names.mjs'
26import { addName, loadNames } from '../lib/names.mjs'
1927
2028function flags (argv) {
2129 const pos = []; const f = {}
2230 for (let i = 0; i < argv.length; i++) {
2331 const a = argv[i]
2432 if (a === '--') { f._rest = argv.slice(i + 1); break }
33 if (a === '-v' || a === '--verbose') { f.verbose = true; continue }
2534 if (a.startsWith('--')) { const k = a.slice(2); const v = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; f[k] = v }
2635 else pos.push(a)
2736 }
……@@ -29,13 +38,27 @@ function flags (argv) {
2938}
3039
3140function usage () {
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')
41 console.error('usage:\n' +
42 ' hypermosh id\n' +
43 ' hypermosh serve [--hyperident [--firewall <f> | --disable-firewall]]\n' +
44 ' hypermosh [user@]<name-or-pubkey> [-- cmd...] (SSH-style login by default)\n' +
45 ' hypermosh add-name <name> <pubkey>\n' +
46 ' hypermosh names\n' +
47 '\noptions: -v/--verbose (or HYPERMOSH_DEBUG=1) for connection logging')
3348 process.exit(1)
3449}
3550
51// Split an optional "user@" prefix off the name/pubkey target.
52function splitTarget (s) {
53 const at = s.indexOf('@')
54 if (at === -1) return { user: null, host: s }
55 return { user: s.slice(0, at), host: s.slice(at + 1) }
56}
57
3658async function main () {
3759 const [cmd, ...rest] = process.argv.slice(2)
3860 const { pos, f } = flags(rest)
61 if (f.verbose) process.env.HYPERMOSH_DEBUG = '1'
3962
4063 if (cmd === 'id') { console.log(HypercoreId.encode(loadOrCreateKey().publicKey)); return }
4164
……@@ -55,29 +78,67 @@ async function main () {
5578
5679 if (cmd === 'serve') {
5780 const keyPair = loadOrCreateKey()
58 const allow = f['disable-firewall'] ? true : readAllowed(f.firewall)
59 const s = await serveSession({ keyPair, allow })
81 if (f.hyperident) {
82 // Pubkey-firewall mode. Empty allow-list == deny-all — proclaim it loudly.
83 const allow = f['disable-firewall'] ? true : readAllowed(f.firewall)
84 const s = await serveSession({ keyPair, allow })
85 console.log('hypermosh daemon serving as ' + HypercoreId.encode(s.publicKey) + ' [--hyperident]')
86 if (allow === true) {
87 console.log(' firewall: DISABLED — ANY peer with this key may connect')
88 } else if (allow.length === 0) {
89 console.error(' ⚠ HYPERIDENT FIREWALL ENABLED WITH EMPTY ALLOWLIST — DENYING ALL PEERS.')
90 console.error(' Nobody can connect. Add client keys with --firewall <file>, or open with --disable-firewall.')
91 } else {
92 console.log(' firewall: ' + allow.length + ' allowed key(s)')
93 }
94 process.on('SIGINT', async () => { await s.close(); process.exit(0) })
95 return
96 }
97 // DEFAULT: SSH-style. sshd is the auth gate; hyperdht transport is open.
98 if (f.firewall) console.error(' note: --firewall is ignored without --hyperident (SSH mode auth is your system sshd)')
99 const s = await serveSSH({ keyPair, allow: true })
60100 console.log('hypermosh daemon serving as ' + HypercoreId.encode(s.publicKey))
61 console.log(' spawns a fresh mosh-server per connection; firewall: ' + (allow === true ? 'DISABLED (any peer)' : allow.length + ' allowed key(s)'))
101 console.log(' SSH-style login: tunnels to your system sshd (127.0.0.1:22). Auth = password / ssh-key / authorized_keys.')
102 console.log(' connect with: hypermosh <user>@' + HypercoreId.encode(s.publicKey))
62103 process.on('SIGINT', async () => { await s.close(); process.exit(0) })
63104 return
64105 }
65106
66107 if (cmd && !cmd.startsWith('-')) {
67 // one-command client: hypermosh <name-or-pubkey> [-- cmd]
108 // one-command client
109 const { user, host } = splitTarget(cmd)
68110 let remoteKey
69 try { remoteKey = resolveName(cmd) } catch (e) { console.error('hypermosh: ' + e.message); process.exit(1) }
111 try { remoteKey = resolveName(host) } catch (e) { console.error('hypermosh: ' + e.message); process.exit(1) }
112
70113 let keyPair = null
71 try { keyPair = loadDeviceKeyPair() } catch { /* fall back to ephemeral identity */ }
72 const start = { term: process.env.TERM || 'xterm', cmd: f._rest || [] }
73 const session = connectSession({
74 remoteKey,
75 keyPair,
76 start,
114 try { keyPair = loadDeviceKeyPair() } catch { /* ephemeral identity is fine */ }
115
116 const spawnClient = (moshKey, localPort) => {
117 const env = { ...process.env, MOSH_KEY: moshKey, LC_ALL: process.env.LC_ALL || 'C.UTF-8' }
118 const child = cp.spawn('mosh-client', ['127.0.0.1', String(localPort)], { stdio: 'inherit', env })
119 child.on('error', (e) => { console.error('hypermosh: cannot run mosh-client (' + (e.code || e.message) + ') — is mosh installed locally?'); process.exit(1) })
120 return child
121 }
122
123 if (f.hyperident) {
124 const start = { term: process.env.TERM || 'xterm', cmd: f._rest || [] }
125 const session = connectSession({
126 remoteKey, keyPair, start,
127 onerror: (e) => { console.error('hypermosh: ' + (e.code || e.message)); process.exit(1) },
128 onstarted: (moshKey, localPort) => {
129 const child = spawnClient(moshKey, localPort)
130 child.on('exit', (code) => { session.close().then(() => process.exit(code || 0)) })
131 }
132 })
133 return
134 }
135
136 // DEFAULT: SSH-style login.
137 const session = connectSSH({
138 remoteKey, keyPair, user, cmd: f._rest || [],
77139 onerror: (e) => { console.error('hypermosh: ' + (e.code || e.message)); process.exit(1) },
78140 onstarted: (moshKey, localPort) => {
79 const env = { ...process.env, MOSH_KEY: moshKey, LC_ALL: process.env.LC_ALL || 'C.UTF-8' }
80 const child = cp.spawn('mosh-client', ['127.0.0.1', String(localPort)], { stdio: 'inherit', env })
141 const child = spawnClient(moshKey, localPort)
81142 child.on('exit', (code) => { session.close().then(() => process.exit(code || 0)) })
82143 }
83144 })
lib/frames.mjs +50 -0
……@@ -0,0 +1,50 @@
1// frames — the typed wire format shared by both modes, carried over one reliable
2// hyperdht stream:
3//
4// [uint8 type][uint32 BE len][payload]
5//
6// T_CTRL (0): JSON control message
7// T_DATA (1): one raw mosh UDP datagram (one frame == one datagram)
8// T_SSH (2): a chunk of the tunnelled SSH TCP byte stream (SSH-style mode)
9// T_SSH_END (3): the SSH TCP side closed (empty payload)
10//
11// A single stream multiplexes the SSH bootstrap (T_SSH) and, once mosh-server is
12// up, the mosh datagrams (T_DATA) — no protomux needed, the type byte demuxes.
13import b4a from 'b4a'
14
15export const T_CTRL = 0
16export const T_DATA = 1
17export const T_SSH = 2
18export const T_SSH_END = 3
19
20export function frame (type, payload) {
21 const h = b4a.alloc(5)
22 h.writeUInt8(type, 0)
23 h.writeUInt32BE(payload ? payload.length : 0, 1)
24 return payload && payload.length ? b4a.concat([h, payload]) : h
25}
26
27export const ctrlFrame = (obj) => frame(T_CTRL, b4a.from(JSON.stringify(obj)))
28export const dataFrame = (dg) => frame(T_DATA, dg)
29export const sshFrame = (chunk) => frame(T_SSH, chunk)
30export const sshEndFrame = () => frame(T_SSH_END, null)
31
32// makeParser(handlers) -> (chunk) => void. handlers: { onCtrl, onData, onSsh, onSshEnd }.
33// Unknown/absent handlers are ignored so old and new peers stay wire-tolerant.
34export function makeParser ({ onCtrl, onData, onSsh, onSshEnd } = {}) {
35 let buf = b4a.alloc(0)
36 return (chunk) => {
37 buf = b4a.concat([buf, chunk])
38 while (buf.length >= 5) {
39 const type = buf.readUInt8(0)
40 const len = buf.readUInt32BE(1)
41 if (buf.length < 5 + len) break
42 const payload = buf.subarray(5, 5 + len)
43 buf = buf.subarray(5 + len)
44 if (type === T_CTRL) { if (onCtrl) { try { onCtrl(JSON.parse(b4a.toString(payload))) } catch {} } }
45 else if (type === T_DATA) { if (onData) onData(payload) }
46 else if (type === T_SSH) { if (onSsh) onSsh(payload) }
47 else if (type === T_SSH_END) { if (onSshEnd) onSshEnd() }
48 }
49 }
50}
lib/log.mjs +23 -0
……@@ -0,0 +1,23 @@
1// Tiny debug logger. Off by default; on via HYPERMOSH_DEBUG=1 in the env or the
2// `-v`/`--verbose` CLI flag (which sets HYPERMOSH_DEBUG before anything logs).
3// Everything goes to stderr so it never pollutes captured stdout (e.g. `id`).
4import process from '#node:process'
5
6let seq = 0
7
8export function debugEnabled () {
9 return process.env.HYPERMOSH_DEBUG === '1' || process.env.HYPERMOSH_DEBUG === 'true'
10}
11
12// A tagged logger, e.g. log = mkLog('client'); log('connecting', key)
13export function mkLog (tag) {
14 return (...args) => {
15 if (!debugEnabled()) return
16 const line = args.map((a) => (typeof a === 'string' ? a : safe(a))).join(' ')
17 try { process.stderr.write('[hypermosh:' + tag + ' #' + (++seq) + '] ' + line + '\n') } catch {}
18 }
19}
20
21function safe (v) {
22 try { return JSON.stringify(v) } catch { return String(v) }
23}
lib/session.mjs +47 -47
……@@ -1,53 +1,32 @@
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).
1// session — the `--hyperident` mode. Auth is the hyperdht pubkey firewall (no
2// SSH): the daemon runs on its own key, and per connection spawns its OWN
3// mosh-server, sends the client the one-time key over a control frame, and
4// bridges the mosh UDP over the hyperdht stream. Simpler than SSH-mode but the
5// firewall allow-list IS the whole auth surface (see bin/hypermosh.mjs).
46//
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}
7// Wire format: shared typed frames (frames.mjs) —
8// type 0 (control): JSON — server sends {ok,key}|{ok:false,error}
89// type 1 (data): raw — one frame == one UDP datagram (both directions)
910import DHT from 'hyperdht'
1011import dgram from '#node:dgram'
1112import cp from '#node:child_process'
1213import process from '#node:process'
1314import b4a from 'b4a'
15import { ctrlFrame, dataFrame, makeParser } from './frames.mjs'
16import { mkLog } from './log.mjs'
1417
15const T_CTRL = 0
16const T_DATA = 1
17
18function frame (type, payload) {
19 const h = b4a.alloc(5)
20 h.writeUInt8(type, 0)
21 h.writeUInt32BE(payload.length, 1)
22 return b4a.concat([h, payload])
23}
24const ctrlFrame = (obj) => frame(T_CTRL, b4a.from(JSON.stringify(obj)))
25const dataFrame = (dg) => frame(T_DATA, dg)
26
27function makeParser (onCtrl, onData) {
28 let buf = b4a.alloc(0)
29 return (chunk) => {
30 buf = b4a.concat([buf, chunk])
31 while (buf.length >= 5) {
32 const type = buf.readUInt8(0)
33 const len = buf.readUInt32BE(1)
34 if (buf.length < 5 + len) break
35 const payload = buf.subarray(5, 5 + len)
36 buf = buf.subarray(5 + len)
37 if (type === T_CTRL) { try { onCtrl(JSON.parse(b4a.toString(payload))) } catch {} }
38 else onData(payload)
39 }
40 }
41}
42
43export async function serveSession ({ keyPair, allow = true, node = null } = {}) {
18export async function serveSession ({ keyPair, allow = true, node = null, moshTimeoutMs = 15000 } = {}) {
19 const log = mkLog('serve-hi')
4420 const dht = node || new DHT()
4521 const firewall = (rpk) => !(allow === true || allow.some((k) => b4a.equals(rpk, k)))
4622 const server = dht.createServer({ firewall }, (socket) => {
47 socket.on('error', () => {})
23 socket.on('error', (e) => log('socket error', e.code || e.message))
4824 let udp = null
4925 let moshPort = null
5026 let daemonPid = null
27 let replied = false
28 const reply = (obj) => { if (!replied) { replied = true; try { socket.write(ctrlFrame(obj)) } catch {} } }
29
5130 const onCtrl = (start) => {
5231 const args = ['new', '-i', '127.0.0.1']
5332 if (start && Array.isArray(start.cmd) && start.cmd.length) args.push('--', ...start.cmd)
……@@ -55,9 +34,13 @@ export async function serveSession ({ keyPair, allow = true, node = null } = {})
5534 try {
5635 proc = cp.spawn('mosh-server', args, { env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' } })
5736 } catch (e) {
58 try { socket.write(ctrlFrame({ ok: false, error: 'spawn mosh-server: ' + e.message })) } catch {}
37 reply({ ok: false, error: 'spawn mosh-server: ' + e.message })
5938 return
6039 }
40 // Async spawn failures (ENOENT) and early exits are NOT caught by the
41 // try/catch above — surface them to the client instead of hanging it.
42 proc.on('error', (e) => { log('mosh-server spawn error', e.code || e.message); reply({ ok: false, error: 'mosh-server not runnable: ' + (e.code || e.message) }) })
43 const failTimer = setTimeout(() => reply({ ok: false, error: 'mosh-server produced no MOSH CONNECT in ' + moshTimeoutMs + 'ms' }), moshTimeoutMs)
6144 let out = ''
6245 const scan = (d) => {
6346 out += d
……@@ -66,35 +49,40 @@ export async function serveSession ({ keyPair, allow = true, node = null } = {})
6649 moshPort = Number(m[1])
6750 udp = dgram.createSocket('udp4')
6851 udp.on('message', (msg) => { try { socket.write(dataFrame(msg)) } catch {} })
69 try { socket.write(ctrlFrame({ ok: true, key: m[2] })) } catch {}
52 udp.on('error', (e) => log('udp error', e.code || e.message))
53 clearTimeout(failTimer)
54 log('mosh-server up on', moshPort)
55 reply({ ok: true, key: m[2] })
7056 }
7157 const pm = out.match(/pid\s*=\s*(\d+)/)
7258 if (pm) daemonPid = Number(pm[1])
7359 }
7460 proc.stdout.on('data', scan)
7561 proc.stderr.on('data', scan)
62 proc.on('exit', (code) => { if (!moshPort) { clearTimeout(failTimer); reply({ ok: false, error: 'mosh-server exited (' + code + ') before starting a session' }) } })
7663 // Do NOT socket.end() on proc 'exit': mosh-server DAEMONIZES — the spawned
7764 // parent exits right after printing MOSH CONNECT while the real server runs
78 // detached. Ending the socket there closed the write side before any
79 // session data could flow (the bug). We track the detached pid for cleanup.
65 // detached. We track the detached pid for cleanup.
8066 }
8167 const onData = (dg) => { if (udp && moshPort) { try { udp.send(dg, moshPort, '127.0.0.1') } catch {} } }
82 socket.on('data', makeParser(onCtrl, onData))
68 socket.on('data', makeParser({ onCtrl, onData }))
8369 socket.on('close', () => {
8470 try { if (udp) udp.close() } catch {}
8571 try { if (daemonPid) process.kill(daemonPid) } catch {} // reap the detached mosh-server
8672 })
8773 })
8874 await server.listen(keyPair)
75 log('listening as', b4a.toString(keyPair.publicKey, 'hex').slice(0, 8))
8976 return { dht, server, publicKey: keyPair.publicKey, close: async () => { await server.close(); if (!node) await dht.destroy() } }
9077}
9178
92const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET'])
79const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET', 'PEER_CONNECTION_FAILED'])
9380
94export function connectSession ({ remoteKey, keyPair = null, start = {}, onstarted, onerror = null, retries = 5 } = {}) {
81export function connectSession ({ remoteKey, keyPair = null, start = {}, onstarted, onerror = null, retries = 5, handshakeTimeoutMs = 20000 } = {}) {
82 const log = mkLog('client-hi')
9583 const state = { dht: null, socket: null, udp: null, closed: false }
9684 let attempt = 0
97 const fail = (err) => { if (onerror) onerror(err) }
85 const fail = (err) => { if (onerror) onerror(err instanceof Error ? err : new Error(String(err))) }
9886 const go = () => {
9987 if (state.closed) return
10088 attempt++
……@@ -104,18 +92,30 @@ export function connectSession ({ remoteKey, keyPair = null, start = {}, onstart
10492 state.dht = dht; state.socket = socket; state.udp = udp
10593 let established = false
10694 let peer = null
95 let done = false
10796 socket.setKeepAlive(5000)
108 const onCtrl = (m) => { if (m && m.ok) onstarted(m.key, udp.address().port); else fail(new Error((m && m.error) || 'session failed')) }
97 const hsTimer = setTimeout(() => {
98 if (!done) { log('handshake timeout'); try { socket.destroy() } catch {}; fail(new Error('timed out waiting for session (server reachable but silent in ' + handshakeTimeoutMs + 'ms)')) }
99 }, handshakeTimeoutMs)
100 const onCtrl = (m) => {
101 done = true; clearTimeout(hsTimer)
102 if (m && m.ok) onstarted(m.key, udp.address().port)
103 else fail(new Error((m && m.error) || 'session failed'))
104 }
109105 const onData = (dg) => { if (peer) { try { udp.send(dg, peer.port, peer.address) } catch {} } }
110 socket.on('data', makeParser(onCtrl, onData))
106 socket.on('data', makeParser({ onCtrl, onData }))
107 udp.on('error', (e) => log('local udp error', e.code || e.message))
111108 udp.on('message', (msg, rinfo) => { peer = rinfo; try { socket.write(dataFrame(msg)) } catch {} })
112109 socket.once('open', () => {
113110 established = true
111 log('stream open (attempt ' + attempt + ')')
114112 udp.bind(0, '127.0.0.1', () => { try { socket.write(ctrlFrame(start)) } catch {} })
115 socket.once('close', () => { try { udp.close() } catch {}; dht.destroy() })
113 socket.once('close', () => { clearTimeout(hsTimer); try { udp.close() } catch {}; dht.destroy() })
116114 })
117115 socket.on('error', (err) => {
118116 if (established) { fail(err); return }
117 clearTimeout(hsTimer)
118 log('connect error', err.code || err.message)
119119 try { socket.destroy() } catch {}
120120 dht.destroy().finally(() => {
121121 if (!state.closed && attempt <= retries && (TRANSIENT.has(err.code) || !err.code)) setTimeout(go, 400 * attempt)
lib/ssh-mode.mjs +258 -0
……@@ -0,0 +1,258 @@
1// ssh-mode — the DEFAULT auth model: normal mosh-style login.
2//
3// mosh itself does no authentication: its wrapper execs `ssh user@host --
4// mosh-server new …` and lets OpenSSH do password/ssh-key/PAM. We keep that
5// exactly, but tunnel the SSH TCP over a hole-punched hyperdht stream to the
6// server's real sshd (127.0.0.1:22). The client runs a normal `ssh` against a
7// local port that bridges to the tunnel, authenticates, launches `mosh-server
8// new`, and we parse `MOSH CONNECT <port> <key>` from ssh's stdout — the same
9// bytes the mosh wrapper parses. mosh-server then DAEMONIZES as the logged-in
10// user (correct PAM/login context) and outlives our stream, so a network flap
11// just redials and re-bridges the UDP to the SAME server → real mosh roaming.
12//
13// Auth is 100% sshd's (password, keys, authorized_keys). The hyperdht firewall
14// is open by default here — sshd is the gate, exactly like a public SSH host.
15import DHT from 'hyperdht'
16import dgram from '#node:dgram'
17import net from '#node:net'
18import cp from '#node:child_process'
19import process from '#node:process'
20import b4a from 'b4a'
21import { ctrlFrame, dataFrame, sshFrame, sshEndFrame, makeParser } from './frames.mjs'
22import { mkLog } from './log.mjs'
23
24// mosh-server picks a UDP port from this range. The server only ever bridges the
25// tunnel to 127.0.0.1 inside it, so an authenticated tunnel can't be turned into
26// an arbitrary loopback UDP proxy.
27const MOSH_PORT_LO = 60000
28const MOSH_PORT_HI = 61000
29const validMoshPort = (p) => Number.isInteger(p) && p >= MOSH_PORT_LO && p <= MOSH_PORT_HI
30
31// ── SERVER ────────────────────────────────────────────────────────────────
32// Per connection: forward the T_SSH channel to local sshd, and once the client
33// asks (T_CTRL {bridge}), forward the T_DATA channel to the mosh-server UDP port.
34export async function serveSSH ({ keyPair, allow = true, sshdHost = '127.0.0.1', sshdPort = 22, node = null } = {}) {
35 const log = mkLog('serve-ssh')
36 const dht = node || new DHT()
37 const firewall = (rpk) => !(allow === true || allow.some((k) => b4a.equals(rpk, k))) // true = DENY
38
39 const server = dht.createServer({ firewall }, (socket) => {
40 log('connection from', b4a.toString(socket.remotePublicKey || b4a.alloc(0), 'hex').slice(0, 8))
41 socket.on('error', (e) => log('socket error', e.code || e.message))
42
43 let sshd = null // TCP socket to local sshd
44 let udp = null // UDP socket to mosh-server
45 let moshPort = null
46
47 const openSshd = () => {
48 if (sshd) return sshd
49 log('opening sshd tunnel ->', sshdHost + ':' + sshdPort)
50 sshd = net.connect(sshdPort, sshdHost)
51 sshd.on('data', (d) => { try { socket.write(sshFrame(d)) } catch {} })
52 sshd.on('error', (e) => { log('sshd error', e.code || e.message); try { socket.write(sshEndFrame()) } catch {} })
53 sshd.on('close', () => { try { socket.write(sshEndFrame()) } catch {} })
54 return sshd
55 }
56
57 const onCtrl = (m) => {
58 if (m && m.bridge !== undefined) {
59 const port = Number(m.bridge)
60 if (!validMoshPort(port)) {
61 log('reject bridge to out-of-range port', port)
62 try { socket.write(ctrlFrame({ bridged: false, error: 'port ' + port + ' outside mosh range' })) } catch {}
63 return
64 }
65 moshPort = port
66 if (!udp) {
67 udp = dgram.createSocket('udp4')
68 udp.on('message', (msg) => { try { socket.write(dataFrame(msg)) } catch {} })
69 udp.on('error', (e) => log('udp error', e.code || e.message))
70 }
71 log('bridging mosh UDP -> 127.0.0.1:' + moshPort)
72 try { socket.write(ctrlFrame({ bridged: true, port: moshPort })) } catch {}
73 }
74 }
75
76 socket.on('data', makeParser({
77 onCtrl,
78 onData: (dg) => { if (udp && moshPort) { try { udp.send(dg, moshPort, '127.0.0.1') } catch {} } },
79 onSsh: (chunk) => { try { openSshd().write(chunk) } catch {} },
80 onSshEnd: () => { try { if (sshd) sshd.end() } catch {} }
81 }))
82
83 socket.on('close', () => {
84 log('connection closed')
85 try { if (sshd) sshd.destroy() } catch {}
86 try { if (udp) udp.close() } catch {}
87 // NB: mosh-server is NOT killed here — it daemonized under the SSH login and
88 // must survive tunnel reconnects (that persistence IS mosh's roaming).
89 })
90 })
91
92 await server.listen(keyPair)
93 log('listening as', b4a.toString(keyPair.publicKey, 'hex').slice(0, 8))
94 return { dht, server, publicKey: keyPair.publicKey, close: async () => { await server.close(); if (!node) await dht.destroy() } }
95}
96
97// ── CLIENT ────────────────────────────────────────────────────────────────
98const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET', 'PEER_CONNECTION_FAILED'])
99
100// connectSSH bootstraps once, then keeps the mosh datagram path alive across
101// stream flaps. onstarted(localUdpPort, moshKey) fires when mosh-server is up.
102export function connectSSH ({
103 remoteKey, keyPair = null, user = null, cmd = [],
104 sshBin = 'ssh', sshOpts = [], onstarted, onerror = null,
105 connectRetries = 5, handshakeTimeoutMs = 20000, reconnectMs = 500
106} = {}) {
107 const log = mkLog('client-ssh')
108 const state = { dht: null, socket: null, udp: null, sshListener: null, sshConn: null, closed: false, moshPort: null, moshKey: null, peer: null }
109 const fail = (err) => { if (onerror) onerror(err instanceof Error ? err : new Error(String(err))) }
110
111 // The local UDP port mosh-client targets; created once, survives reconnects.
112 const ensureUdp = (cb) => {
113 if (state.udp) return cb(state.udp.address().port)
114 const udp = dgram.createSocket('udp4')
115 udp.on('error', (e) => log('local udp error', e.code || e.message))
116 udp.on('message', (msg, rinfo) => { state.peer = rinfo; if (state.socket) { try { state.socket.write(dataFrame(msg)) } catch {} } })
117 udp.bind(0, '127.0.0.1', () => { state.udp = udp; cb(udp.address().port) })
118 }
119
120 // Bridge the (persistent) local mosh UDP over the current stream + ask the
121 // server to bind the far side. Called on first bootstrap AND every reconnect.
122 const wireUdp = (socket) => {
123 ensureUdp(() => {
124 log('requesting UDP bridge to mosh port', state.moshPort)
125 try { socket.write(ctrlFrame({ bridge: state.moshPort })) } catch {}
126 })
127 }
128
129 // Phase 1: run ssh through the tunnel, authenticate, launch mosh-server, parse key.
130 const bootstrap = (socket) => {
131 // Local TCP listener that ssh dials; forward its bytes over the stream as T_SSH.
132 const listener = net.createServer((ssh) => {
133 state.sshConn = ssh
134 ssh.on('data', (d) => { try { socket.write(sshFrame(d)) } catch {} })
135 ssh.on('error', (e) => log('ssh conn error', e.code || e.message))
136 ssh.on('close', () => { try { socket.write(sshEndFrame()) } catch {} })
137 })
138 state.sshListener = listener
139 listener.listen(0, '127.0.0.1', () => {
140 const lport = listener.address().port
141 const target = (user ? user + '@' : '') + '127.0.0.1'
142 // mosh-server args mirror what the mosh wrapper builds (minus IP-proxy dance).
143 const remoteCmd = 'mosh-server new -i 127.0.0.1 -l LANG=C.UTF-8 -l LC_ALL=C.UTF-8' +
144 (cmd && cmd.length ? ' -- ' + cmd.map(shq).join(' ') : '')
145 const args = [
146 '-p', String(lport),
147 // The hyperdht layer already cryptographically pins the server identity
148 // (Noise over the pubkey), so sshd host-key TOFU here is redundant churn
149 // against a rotating 127.0.0.1 — disable it.
150 '-o', 'StrictHostKeyChecking=no',
151 '-o', 'UserKnownHostsFile=/dev/null',
152 '-o', 'LogLevel=ERROR',
153 ...sshOpts,
154 target, '--', remoteCmd
155 ]
156 log('spawn ssh', sshBin, args.join(' '))
157 let child
158 try {
159 // stdout piped (we parse MOSH CONNECT); stderr + tty inherited so
160 // password prompts and ssh errors reach the user.
161 child = cp.spawn(sshBin, args, { stdio: ['inherit', 'pipe', 'inherit'] })
162 } catch (e) { fail(new Error('spawn ssh: ' + e.message)); return }
163 child.on('error', (e) => fail(new Error('ssh not runnable: ' + (e.code || e.message))))
164 let out = ''
165 let got = false
166 child.stdout.on('data', (d) => {
167 out += d
168 const m = out.match(/MOSH CONNECT (\d+) (\S+)/)
169 if (m && !got) {
170 got = true
171 state.moshPort = Number(m[1])
172 state.moshKey = m[2]
173 log('MOSH CONNECT parsed: port', state.moshPort)
174 try { listener.close() } catch {}
175 wireUdp(socket)
176 ensureUdp((lp) => onstarted(state.moshKey, lp))
177 }
178 })
179 child.on('exit', (code) => {
180 try { listener.close() } catch {}
181 if (!got) fail(new Error('ssh/mosh-server bootstrap failed (exit ' + code + ') — no MOSH CONNECT. Check credentials and that mosh-server is installed on the server.'))
182 })
183 })
184 }
185
186 let attempt = 0
187 const go = () => {
188 if (state.closed) return
189 attempt++
190 const dht = new DHT()
191 const socket = dht.connect(remoteKey, keyPair ? { keyPair } : undefined)
192 state.dht = dht; state.socket = socket
193 let opened = false
194 socket.setKeepAlive(5000)
195
196 // Guard: connected at the transport layer but no MOSH CONNECT within the
197 // window → surface it instead of hanging forever (the old silent-hang bug).
198 const hsTimer = setTimeout(() => {
199 if (!state.moshKey) { log('handshake timeout'); try { socket.destroy() } catch {} ; fail(new Error('timed out waiting for mosh bootstrap (server reachable but no session in ' + handshakeTimeoutMs + 'ms)')) }
200 }, handshakeTimeoutMs)
201
202 socket.on('data', makeParser({
203 onCtrl: (m) => {
204 if (m && m.bridged === false) fail(new Error('server refused UDP bridge: ' + (m.error || 'unknown')))
205 else if (m && m.bridged === true) log('server bridged UDP to', m.port)
206 },
207 onData: (dg) => { if (state.udp && state.peer) { try { state.udp.send(dg, state.peer.port, state.peer.address) } catch {} } },
208 // sshd's replies come back as T_SSH frames — feed them to the local ssh conn.
209 onSsh: (chunk) => { if (state.sshConn) { try { state.sshConn.write(chunk) } catch {} } },
210 onSshEnd: () => { if (state.sshConn) { try { state.sshConn.end() } catch {} } }
211 }))
212
213 socket.once('open', () => {
214 opened = true
215 log('stream open (attempt ' + attempt + ')')
216 if (state.moshKey) {
217 // Reconnect after a flap: mosh-server still alive, just re-bridge the UDP.
218 clearTimeout(hsTimer)
219 wireUdp(socket)
220 } else {
221 bootstrap(socket)
222 }
223 socket.once('close', () => {
224 clearTimeout(hsTimer)
225 log('stream closed')
226 try { state.dht.destroy() } catch {}
227 // Roaming: if we already have a session, redial to re-bridge. Keep udp +
228 // mosh-client alive; only the tunnel behind them reconnects.
229 if (!state.closed && state.moshKey) setTimeout(go, reconnectMs)
230 })
231 })
232
233 socket.on('error', (err) => {
234 if (opened) { log('post-open socket error', err.code || err.message); return } // handled by close/redial
235 clearTimeout(hsTimer)
236 log('connect error', err.code || err.message)
237 try { socket.destroy() } catch {}
238 dht.destroy().finally(() => {
239 if (!state.closed && attempt <= connectRetries && (TRANSIENT.has(err.code) || !err.code)) setTimeout(go, 400 * attempt)
240 else fail(err)
241 })
242 })
243 }
244 go()
245
246 return {
247 close: async () => {
248 state.closed = true
249 try { if (state.sshConn) state.sshConn.destroy() } catch {}
250 try { if (state.sshListener) state.sshListener.close() } catch {}
251 try { if (state.udp) state.udp.close() } catch {}
252 try { if (state.socket) state.socket.destroy() } catch {}
253 try { if (state.dht) await state.dht.destroy() } catch {}
254 }
255 }
256}
257
258function shq (s) { return "'" + String(s).replace(/'/g, "'\\''") + "'" }
package.json +6 -1
……@@ -7,13 +7,17 @@
77 "exports": { ".": "./lib/relay.mjs", "./identity": "./lib/identity.mjs" },
88 "imports": {
99 "#node:dgram": { "bare": "bare-dgram", "default": "dgram" },
10 "#node:net": { "bare": "bare-net", "default": "net" },
1011 "#node:fs": { "bare": "bare-fs", "default": "fs" },
1112 "#node:os": { "bare": "bare-os", "default": "os" },
1213 "#node:path": { "bare": "bare-path", "default": "path" },
1314 "#node:process": { "bare": "bare-process", "default": "process" },
1415 "#node:child_process": { "bare": "bare-subprocess", "default": "child_process" }
1516 },
16 "scripts": { "test": "node test/tunnel.test.mjs && node test/session.test.mjs" },
17 "scripts": {
18 "test": "node test/frames.test.mjs && node test/firewall.test.mjs && node test/ssh-mode.test.mjs",
19 "test:p2p": "node test/tunnel.test.mjs && node test/session.test.mjs"
20 },
1721 "dependencies": {
1822 "b4a": "^1.6.0",
1923 "compact-encoding": "^2.11.0",
……@@ -23,6 +27,7 @@
2327 },
2428 "optionalDependencies": {
2529 "bare-dgram": "^1.0.0",
30 "bare-net": "^4.0.0",
2631 "bare-fs": "^4.0.0",
2732 "bare-os": "^3.0.0",
2833 "bare-path": "^3.0.0",
test/firewall.test.mjs +53 -0
……@@ -0,0 +1,53 @@
1// firewall regression test — the root cause of "hypermosh just does not connect".
2//
3// A bare `hypermosh serve --hyperident` builds an EMPTY allow-list, which the
4// firewall reads as DENY-ALL. Before the fix that produced a silent hang / a
5// bare PEER_CONNECTION_FAILED with no explanation. This pins the behaviour:
6// • empty allow-list -> client is rejected (connection never establishes)
7// • --disable-firewall -> client connects (control frame received)
8import DHT from 'hyperdht'
9import { serveSession, connectSession } from '../lib/session.mjs'
10import { execSync } from 'child_process'
11
12let hasMosh = true
13try { execSync('command -v mosh-server', { stdio: 'ignore' }) } catch { hasMosh = false }
14
15let failures = 0
16const ok = (cond, msg) => { if (cond) console.log(' ✅ ' + msg); else { console.log(' ❌ ' + msg); failures++ } }
17
18// Single-machine self-hairpin DHT connects can be slow cold, so give the
19// expected-connect case generous headroom (matches session.test's 40s tolerance).
20async function trial (allow, { timeoutMs, retries }) {
21 const serverKp = DHT.keyPair()
22 const s = await serveSession({ keyPair: serverKp, allow })
23 const clientKp = DHT.keyPair()
24 const outcome = await new Promise((resolve) => {
25 let done = false
26 const finish = (r) => { if (!done) { done = true; resolve(r) } }
27 const t = setTimeout(() => finish('deny'), timeoutMs) // never established => treated as deny/hang
28 const session = connectSession({
29 remoteKey: serverKp.publicKey, keyPair: clientKp, retries,
30 start: { term: 'xterm', cmd: ['true'] },
31 onstarted: () => { clearTimeout(t); finish('connect') },
32 onerror: () => { clearTimeout(t); finish('deny') }
33 })
34 setTimeout(() => { try { session.close() } catch {} }, timeoutMs + 500)
35 })
36 await s.close()
37 return outcome
38}
39
40console.log('[firewall] empty allow-list denies; --disable-firewall connects')
41
42// DENY: empty allow-list (what a plain `serve --hyperident` produces).
43ok((await trial([], { timeoutMs: 20000, retries: 1 })) === 'deny', 'empty allow-list rejects the client (root-cause behaviour pinned)')
44
45// ALLOW: firewall disabled. Requires mosh-server to reach the onstarted control frame.
46if (hasMosh) {
47 ok((await trial(true, { timeoutMs: 45000, retries: 3 })) === 'connect', '--disable-firewall lets the client through to a session')
48} else {
49 console.log(' ⏭ disable-firewall connect check SKIPPED (mosh-server not installed)')
50}
51
52console.log(failures === 0 ? '\n✅ firewall test passed' : '\n❌ firewall test failed')
53process.exit(failures === 0 ? 0 : 1)
test/frames.test.mjs +62 -0
……@@ -0,0 +1,62 @@
1// frames unit test — the typed wire format must demux CTRL/DATA/SSH/SSH_END
2// correctly, including when frames are split across arbitrary chunk boundaries.
3import b4a from 'b4a'
4import { frame, ctrlFrame, dataFrame, sshFrame, sshEndFrame, makeParser, T_DATA, T_SSH } from '../lib/frames.mjs'
5
6let failures = 0
7const ok = (cond, msg) => { if (cond) console.log(' ✅ ' + msg); else { console.log(' ❌ ' + msg); failures++ } }
8
9console.log('[frames] typed frame demux')
10
11// 1. Round-trip every type in one buffer.
12{
13 const events = []
14 const parse = makeParser({
15 onCtrl: (o) => events.push(['ctrl', o]),
16 onData: (d) => events.push(['data', b4a.toString(d)]),
17 onSsh: (d) => events.push(['ssh', b4a.toString(d)]),
18 onSshEnd: () => events.push(['sshend'])
19 })
20 const buf = b4a.concat([
21 ctrlFrame({ bridge: 60007 }),
22 dataFrame(b4a.from('DGRAM1')),
23 sshFrame(b4a.from('ssh-bytes')),
24 sshEndFrame(),
25 dataFrame(b4a.from('DGRAM2'))
26 ])
27 parse(buf)
28 ok(events.length === 5, 'all 5 frames parsed')
29 ok(events[0][0] === 'ctrl' && events[0][1].bridge === 60007, 'ctrl json decoded')
30 ok(events[1][1] === 'DGRAM1' && events[4][1] === 'DGRAM2', 'data frames in order')
31 ok(events[2][1] === 'ssh-bytes', 'ssh frame decoded')
32 ok(events[3][0] === 'sshend', 'ssh-end frame decoded')
33}
34
35// 2. Byte-at-a-time delivery must reassemble identically (partial-frame safety).
36{
37 const events = []
38 const parse = makeParser({ onData: (d) => events.push(b4a.toString(d)), onSsh: (d) => events.push('S:' + b4a.toString(d)) })
39 const buf = b4a.concat([dataFrame(b4a.from('hello')), sshFrame(b4a.from('world'))])
40 for (let i = 0; i < buf.length; i++) parse(buf.subarray(i, i + 1))
41 ok(events.length === 2 && events[0] === 'hello' && events[1] === 'S:world', 'reassembles across 1-byte chunks')
42}
43
44// 3. Empty-payload frame (sshEnd) has a correct 5-byte header, len 0.
45{
46 const f = sshEndFrame()
47 ok(f.length === 5 && f.readUInt32BE(1) === 0, 'empty frame header well-formed')
48 const d = dataFrame(b4a.alloc(0))
49 ok(d.length === 5 && d.readUInt8(0) === T_DATA, 'zero-length data frame well-formed')
50 ok(sshFrame(b4a.from('x')).readUInt8(0) === T_SSH, 'ssh type byte correct')
51}
52
53// 4. Unknown handlers are ignored (wire tolerance) — no throw when onSsh absent.
54{
55 let dataSeen = 0
56 const parse = makeParser({ onData: () => dataSeen++ })
57 parse(b4a.concat([sshFrame(b4a.from('ignored')), dataFrame(b4a.from('x'))]))
58 ok(dataSeen === 1, 'missing handler ignored, other frames still delivered')
59}
60
61console.log(failures === 0 ? '\n✅ frames test passed' : '\n❌ frames test failed')
62process.exit(failures === 0 ? 0 : 1)
test/ssh-mode.test.mjs +88 -0
……@@ -0,0 +1,88 @@
1// ssh-mode e2e — the DEFAULT (SSH-style login) path, end to end over hyperdht:
2// connectSSH -> hyperdht -> serveSSH -> (tunnel) -> throwaway sshd -> mosh-server
3// -> MOSH CONNECT parsed -> mosh-client (in a PTY) runs a command through the tunnel.
4//
5// Uses an ISOLATED sshd on a high port with its own host/client keys, so it never
6// touches the system sshd or authorized_keys. Skips cleanly if sshd/mosh/python3
7// are unavailable or the sandbox can't run a non-root sshd.
8import DHT from 'hyperdht'
9import { spawn, execSync } from 'child_process'
10import fs from 'fs'
11import os from 'os'
12import path from 'path'
13import { fileURLToPath } from 'url'
14import { serveSSH, connectSSH } from '../lib/ssh-mode.mjs'
15
16const __dirname = path.dirname(fileURLToPath(import.meta.url))
17
18function have (bin) { try { execSync('command -v ' + bin, { stdio: 'ignore' }); return true } catch { return false } }
19const sshdBin = ['/usr/sbin/sshd', '/usr/bin/sshd', '/sbin/sshd'].find((p) => { try { fs.accessSync(p); return true } catch { return false } })
20if (!sshdBin || !have('mosh-server') || !have('mosh-client') || !have('python3') || !have('ssh')) {
21 console.log('[ssh-mode] SKIPPED (need sshd + mosh + python3 + ssh)')
22 process.exit(0)
23}
24
25const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-sshmode-'))
26const port = 49000 + Math.floor((Date.now() % 1000)) // low-collision high port (Date.now ok in a node test)
27const F = (n) => path.join(dir, n)
28let sshd = null
29let failed = false
30
31function cleanup () {
32 try { if (sshd) sshd.kill('SIGKILL') } catch {}
33 try { fs.rmSync(dir, { recursive: true, force: true }) } catch {}
34}
35
36try {
37 execSync(`ssh-keygen -q -t ed25519 -f ${F('hostkey')} -N ''`)
38 execSync(`ssh-keygen -q -t ed25519 -f ${F('clientkey')} -N ''`)
39 fs.copyFileSync(F('clientkey.pub'), F('authorized_keys'))
40 fs.chmodSync(F('authorized_keys'), 0o600)
41 fs.writeFileSync(F('sshd_config'),
42 `Port ${port}\nListenAddress 127.0.0.1\nHostKey ${F('hostkey')}\n` +
43 `AuthorizedKeysFile ${F('authorized_keys')}\nPidFile ${F('sshd.pid')}\n` +
44 'UsePAM no\nPermitRootLogin no\nPasswordAuthentication no\nPubkeyAuthentication yes\nStrictModes no\n')
45
46 console.log('[ssh-mode] connectSSH -> hyperdht -> serveSSH -> sshd -> mosh-server -> mosh-client')
47 sshd = spawn(sshdBin, ['-f', F('sshd_config'), '-D', '-E', F('sshd.log')])
48 await new Promise((r) => setTimeout(r, 1500))
49
50 const keyPair = DHT.keyPair()
51 const s = await serveSSH({ keyPair, sshdPort: port })
52
53 const user = os.userInfo().username
54 const marker = 'HYPERMOSH_SSH_OK_' + port
55 const done = new Promise((resolve) => {
56 const timer = setTimeout(() => { console.log(' ❌ FAIL — no session in 45s'); failed = true; resolve() }, 45000)
57 const session = connectSSH({
58 remoteKey: keyPair.publicKey, user,
59 cmd: ['bash', '-lc', `echo ${marker}; sleep 8`],
60 sshOpts: ['-i', F('clientkey'), '-o', 'IdentitiesOnly=yes'],
61 onerror: (e) => { console.log(' ❌ FAIL — ' + e.message); failed = true; clearTimeout(timer); resolve() },
62 onstarted: (moshKey, localPort) => {
63 console.log(' logged in via sshd, mosh-server up; driving mosh-client on 127.0.0.1:' + localPort)
64 const drv = spawn('python3', [path.join(__dirname, 'pty-mosh.py'), moshKey, '127.0.0.1', String(localPort), '12'])
65 let cap = ''
66 drv.stdout.on('data', (d) => { cap += d })
67 drv.on('exit', async () => {
68 const clean = cap.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')
69 if (clean.includes(marker)) console.log(' ✅ PASS — SSH-style login + mosh session ran end-to-end over hyperdht')
70 else { console.log(' ❌ FAIL — marker not seen'); failed = true }
71 clearTimeout(timer)
72 await session.close()
73 resolve()
74 })
75 }
76 })
77 })
78 await done
79 await s.close()
80} catch (e) {
81 console.log(' ⏭ SKIPPED — environment could not run the isolated sshd: ' + e.message)
82 cleanup()
83 process.exit(0)
84}
85
86cleanup()
87console.log(failed ? '\n❌ ssh-mode test failed' : '\n✅ ssh-mode test passed')
88process.exit(failed ? 1 : 0)
test/tunnel.test.mjs +1 -1
……@@ -24,7 +24,7 @@ async function testLoopback () {
2424
2525 const probe = dgram.createSocket('udp4')
2626 const got = new Promise((resolve, reject) => {
27 const t = setTimeout(() => reject(new Error('timeout')), 20000)
27 const t = setTimeout(() => reject(new Error('timeout')), 45000) // cold self-hairpin DHT connect can be slow on one machine
2828 probe.on('message', (m) => { clearTimeout(t); resolve(b4a.toString(m)) })
2929 })
3030 await new Promise((res) => probe.bind(0, '127.0.0.1', res))