| … | … | @@ -2,26 +2,35 @@ |
| 2 | 2 | // hypermosh — resilient P2P mosh sessions to peers reached by public key. |
| 3 | 3 | // |
| 4 | 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 <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 | |
| 8 | 8 | // hypermosh add-name <name> <pubkey> remember a name -> pubkey (optional) |
| 9 | 9 | // hypermosh names list known names |
| 10 | 10 | // |
| 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. | |
| 14 | 20 | import process from '#node:process' |
| 15 | 21 | import cp from '#node:child_process' |
| 16 | 22 | import { serveSession, connectSession } from '../lib/session.mjs' |
| 23 | import { serveSSH, connectSSH } from '../lib/ssh-mode.mjs' | |
| 17 | 24 | import { loadOrCreateKey, loadDeviceKeyPair, readAllowed, HypercoreId } from '../lib/identity.mjs' |
| 18 | import { resolve as resolveName, addName, loadNames } from '../lib/names.mjs' | |
| 25 | import { resolve as resolveName } from '../lib/names.mjs' | |
| 26 | import { addName, loadNames } from '../lib/names.mjs' | |
| 19 | 27 | |
| 20 | 28 | function flags (argv) { |
| 21 | 29 | const pos = []; const f = {} |
| 22 | 30 | for (let i = 0; i < argv.length; i++) { |
| 23 | 31 | const a = argv[i] |
| 24 | 32 | if (a === '--') { f._rest = argv.slice(i + 1); break } |
| 33 | if (a === '-v' || a === '--verbose') { f.verbose = true; continue } | |
| 25 | 34 | if (a.startsWith('--')) { const k = a.slice(2); const v = (argv[i + 1] && !argv[i + 1].startsWith('--')) ? argv[++i] : true; f[k] = v } |
| 26 | 35 | else pos.push(a) |
| 27 | 36 | } |
| … | … | @@ -29,13 +38,27 @@ function flags (argv) { |
| 29 | 38 | } |
| 30 | 39 | |
| 31 | 40 | function 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') | |
| 33 | 48 | process.exit(1) |
| 34 | 49 | } |
| 35 | 50 | |
| 51 | // Split an optional "user@" prefix off the name/pubkey target. | |
| 52 | function 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 | ||
| 36 | 58 | async function main () { |
| 37 | 59 | const [cmd, ...rest] = process.argv.slice(2) |
| 38 | 60 | const { pos, f } = flags(rest) |
| 61 | if (f.verbose) process.env.HYPERMOSH_DEBUG = '1' | |
| 39 | 62 | |
| 40 | 63 | if (cmd === 'id') { console.log(HypercoreId.encode(loadOrCreateKey().publicKey)); return } |
| 41 | 64 | |
| … | … | @@ -55,29 +78,67 @@ async function main () { |
| 55 | 78 | |
| 56 | 79 | if (cmd === 'serve') { |
| 57 | 80 | 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 }) | |
| 60 | 100 | 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)) | |
| 62 | 103 | process.on('SIGINT', async () => { await s.close(); process.exit(0) }) |
| 63 | 104 | return |
| 64 | 105 | } |
| 65 | 106 | |
| 66 | 107 | if (cmd && !cmd.startsWith('-')) { |
| 67 | // one-command client: hypermosh <name-or-pubkey> [-- cmd] | |
| 108 | // one-command client | |
| 109 | const { user, host } = splitTarget(cmd) | |
| 68 | 110 | 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 | ||
| 70 | 113 | 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 || [], | |
| 77 | 139 | onerror: (e) => { console.error('hypermosh: ' + (e.code || e.message)); process.exit(1) }, |
| 78 | 140 | 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) | |
| 81 | 142 | child.on('exit', (code) => { session.close().then(() => process.exit(code || 0)) }) |
| 82 | 143 | } |
| 83 | 144 | }) |
| … | … | @@ -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. | |
| 13 | import b4a from 'b4a' | |
| 14 | ||
| 15 | export const T_CTRL = 0 | |
| 16 | export const T_DATA = 1 | |
| 17 | export const T_SSH = 2 | |
| 18 | export const T_SSH_END = 3 | |
| 19 | ||
| 20 | export 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 | ||
| 27 | export const ctrlFrame = (obj) => frame(T_CTRL, b4a.from(JSON.stringify(obj))) | |
| 28 | export const dataFrame = (dg) => frame(T_DATA, dg) | |
| 29 | export const sshFrame = (chunk) => frame(T_SSH, chunk) | |
| 30 | export 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. | |
| 34 | export 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 | } |
| … | … | @@ -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`). | |
| 4 | import process from '#node:process' | |
| 5 | ||
| 6 | let seq = 0 | |
| 7 | ||
| 8 | export 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) | |
| 13 | export 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 | ||
| 21 | function safe (v) { | |
| 22 | try { return JSON.stringify(v) } catch { return String(v) } | |
| 23 | } |
| … | … | @@ -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). | |
| 4 | 6 | // |
| 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} | |
| 8 | 9 | // type 1 (data): raw — one frame == one UDP datagram (both directions) |
| 9 | 10 | import DHT from 'hyperdht' |
| 10 | 11 | import dgram from '#node:dgram' |
| 11 | 12 | import cp from '#node:child_process' |
| 12 | 13 | import process from '#node:process' |
| 13 | 14 | import b4a from 'b4a' |
| 15 | import { ctrlFrame, dataFrame, makeParser } from './frames.mjs' | |
| 16 | import { mkLog } from './log.mjs' | |
| 14 | 17 | |
| 15 | const T_CTRL = 0 | |
| 16 | const T_DATA = 1 | |
| 17 | ||
| 18 | function 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 | } | |
| 24 | const ctrlFrame = (obj) => frame(T_CTRL, b4a.from(JSON.stringify(obj))) | |
| 25 | const dataFrame = (dg) => frame(T_DATA, dg) | |
| 26 | ||
| 27 | function 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 | ||
| 43 | export async function serveSession ({ keyPair, allow = true, node = null } = {}) { | |
| 18 | export async function serveSession ({ keyPair, allow = true, node = null, moshTimeoutMs = 15000 } = {}) { | |
| 19 | const log = mkLog('serve-hi') | |
| 44 | 20 | const dht = node || new DHT() |
| 45 | 21 | const firewall = (rpk) => !(allow === true || allow.some((k) => b4a.equals(rpk, k))) |
| 46 | 22 | const server = dht.createServer({ firewall }, (socket) => { |
| 47 | socket.on('error', () => {}) | |
| 23 | socket.on('error', (e) => log('socket error', e.code || e.message)) | |
| 48 | 24 | let udp = null |
| 49 | 25 | let moshPort = null |
| 50 | 26 | let daemonPid = null |
| 27 | let replied = false | |
| 28 | const reply = (obj) => { if (!replied) { replied = true; try { socket.write(ctrlFrame(obj)) } catch {} } } | |
| 29 | ||
| 51 | 30 | const onCtrl = (start) => { |
| 52 | 31 | const args = ['new', '-i', '127.0.0.1'] |
| 53 | 32 | 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 } = {}) |
| 55 | 34 | try { |
| 56 | 35 | proc = cp.spawn('mosh-server', args, { env: { ...process.env, LC_ALL: 'C.UTF-8', LANG: 'C.UTF-8' } }) |
| 57 | 36 | } 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 }) | |
| 59 | 38 | return |
| 60 | 39 | } |
| 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) | |
| 61 | 44 | let out = '' |
| 62 | 45 | const scan = (d) => { |
| 63 | 46 | out += d |
| … | … | @@ -66,35 +49,40 @@ export async function serveSession ({ keyPair, allow = true, node = null } = {}) |
| 66 | 49 | moshPort = Number(m[1]) |
| 67 | 50 | udp = dgram.createSocket('udp4') |
| 68 | 51 | 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] }) | |
| 70 | 56 | } |
| 71 | 57 | const pm = out.match(/pid\s*=\s*(\d+)/) |
| 72 | 58 | if (pm) daemonPid = Number(pm[1]) |
| 73 | 59 | } |
| 74 | 60 | proc.stdout.on('data', scan) |
| 75 | 61 | 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' }) } }) | |
| 76 | 63 | // Do NOT socket.end() on proc 'exit': mosh-server DAEMONIZES — the spawned |
| 77 | 64 | // 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. | |
| 80 | 66 | } |
| 81 | 67 | 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 })) | |
| 83 | 69 | socket.on('close', () => { |
| 84 | 70 | try { if (udp) udp.close() } catch {} |
| 85 | 71 | try { if (daemonPid) process.kill(daemonPid) } catch {} // reap the detached mosh-server |
| 86 | 72 | }) |
| 87 | 73 | }) |
| 88 | 74 | await server.listen(keyPair) |
| 75 | log('listening as', b4a.toString(keyPair.publicKey, 'hex').slice(0, 8)) | |
| 89 | 76 | return { dht, server, publicKey: keyPair.publicKey, close: async () => { await server.close(); if (!node) await dht.destroy() } } |
| 90 | 77 | } |
| 91 | 78 | |
| 92 | const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET']) | |
| 79 | const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET', 'PEER_CONNECTION_FAILED']) | |
| 93 | 80 | |
| 94 | export function connectSession ({ remoteKey, keyPair = null, start = {}, onstarted, onerror = null, retries = 5 } = {}) { | |
| 81 | export function connectSession ({ remoteKey, keyPair = null, start = {}, onstarted, onerror = null, retries = 5, handshakeTimeoutMs = 20000 } = {}) { | |
| 82 | const log = mkLog('client-hi') | |
| 95 | 83 | const state = { dht: null, socket: null, udp: null, closed: false } |
| 96 | 84 | 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))) } | |
| 98 | 86 | const go = () => { |
| 99 | 87 | if (state.closed) return |
| 100 | 88 | attempt++ |
| … | … | @@ -104,18 +92,30 @@ export function connectSession ({ remoteKey, keyPair = null, start = {}, onstart |
| 104 | 92 | state.dht = dht; state.socket = socket; state.udp = udp |
| 105 | 93 | let established = false |
| 106 | 94 | let peer = null |
| 95 | let done = false | |
| 107 | 96 | 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 | } | |
| 109 | 105 | 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)) | |
| 111 | 108 | udp.on('message', (msg, rinfo) => { peer = rinfo; try { socket.write(dataFrame(msg)) } catch {} }) |
| 112 | 109 | socket.once('open', () => { |
| 113 | 110 | established = true |
| 111 | log('stream open (attempt ' + attempt + ')') | |
| 114 | 112 | 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() }) | |
| 116 | 114 | }) |
| 117 | 115 | socket.on('error', (err) => { |
| 118 | 116 | if (established) { fail(err); return } |
| 117 | clearTimeout(hsTimer) | |
| 118 | log('connect error', err.code || err.message) | |
| 119 | 119 | try { socket.destroy() } catch {} |
| 120 | 120 | dht.destroy().finally(() => { |
| 121 | 121 | if (!state.closed && attempt <= retries && (TRANSIENT.has(err.code) || !err.code)) setTimeout(go, 400 * attempt) |
| … | … | @@ -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. | |
| 15 | import DHT from 'hyperdht' | |
| 16 | import dgram from '#node:dgram' | |
| 17 | import net from '#node:net' | |
| 18 | import cp from '#node:child_process' | |
| 19 | import process from '#node:process' | |
| 20 | import b4a from 'b4a' | |
| 21 | import { ctrlFrame, dataFrame, sshFrame, sshEndFrame, makeParser } from './frames.mjs' | |
| 22 | import { 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. | |
| 27 | const MOSH_PORT_LO = 60000 | |
| 28 | const MOSH_PORT_HI = 61000 | |
| 29 | const 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. | |
| 34 | export 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 ──────────────────────────────────────────────────────────────── | |
| 98 | const 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. | |
| 102 | export 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 | ||
| 258 | function shq (s) { return "'" + String(s).replace(/'/g, "'\\''") + "'" } |
| … | … | @@ -7,13 +7,17 @@ |
| 7 | 7 | "exports": { ".": "./lib/relay.mjs", "./identity": "./lib/identity.mjs" }, |
| 8 | 8 | "imports": { |
| 9 | 9 | "#node:dgram": { "bare": "bare-dgram", "default": "dgram" }, |
| 10 | "#node:net": { "bare": "bare-net", "default": "net" }, | |
| 10 | 11 | "#node:fs": { "bare": "bare-fs", "default": "fs" }, |
| 11 | 12 | "#node:os": { "bare": "bare-os", "default": "os" }, |
| 12 | 13 | "#node:path": { "bare": "bare-path", "default": "path" }, |
| 13 | 14 | "#node:process": { "bare": "bare-process", "default": "process" }, |
| 14 | 15 | "#node:child_process": { "bare": "bare-subprocess", "default": "child_process" } |
| 15 | 16 | }, |
| 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 | }, | |
| 17 | 21 | "dependencies": { |
| 18 | 22 | "b4a": "^1.6.0", |
| 19 | 23 | "compact-encoding": "^2.11.0", |
| … | … | @@ -23,6 +27,7 @@ |
| 23 | 27 | }, |
| 24 | 28 | "optionalDependencies": { |
| 25 | 29 | "bare-dgram": "^1.0.0", |
| 30 | "bare-net": "^4.0.0", | |
| 26 | 31 | "bare-fs": "^4.0.0", |
| 27 | 32 | "bare-os": "^3.0.0", |
| 28 | 33 | "bare-path": "^3.0.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) | |
| 8 | import DHT from 'hyperdht' | |
| 9 | import { serveSession, connectSession } from '../lib/session.mjs' | |
| 10 | import { execSync } from 'child_process' | |
| 11 | ||
| 12 | let hasMosh = true | |
| 13 | try { execSync('command -v mosh-server', { stdio: 'ignore' }) } catch { hasMosh = false } | |
| 14 | ||
| 15 | let failures = 0 | |
| 16 | const 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). | |
| 20 | async 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 | ||
| 40 | console.log('[firewall] empty allow-list denies; --disable-firewall connects') | |
| 41 | ||
| 42 | // DENY: empty allow-list (what a plain `serve --hyperident` produces). | |
| 43 | ok((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. | |
| 46 | if (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 | ||
| 52 | console.log(failures === 0 ? '\n✅ firewall test passed' : '\n❌ firewall test failed') | |
| 53 | process.exit(failures === 0 ? 0 : 1) |
| … | … | @@ -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. | |
| 3 | import b4a from 'b4a' | |
| 4 | import { frame, ctrlFrame, dataFrame, sshFrame, sshEndFrame, makeParser, T_DATA, T_SSH } from '../lib/frames.mjs' | |
| 5 | ||
| 6 | let failures = 0 | |
| 7 | const ok = (cond, msg) => { if (cond) console.log(' ✅ ' + msg); else { console.log(' ❌ ' + msg); failures++ } } | |
| 8 | ||
| 9 | console.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 | ||
| 61 | console.log(failures === 0 ? '\n✅ frames test passed' : '\n❌ frames test failed') | |
| 62 | process.exit(failures === 0 ? 0 : 1) |
| … | … | @@ -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. | |
| 8 | import DHT from 'hyperdht' | |
| 9 | import { spawn, execSync } from 'child_process' | |
| 10 | import fs from 'fs' | |
| 11 | import os from 'os' | |
| 12 | import path from 'path' | |
| 13 | import { fileURLToPath } from 'url' | |
| 14 | import { serveSSH, connectSSH } from '../lib/ssh-mode.mjs' | |
| 15 | ||
| 16 | const __dirname = path.dirname(fileURLToPath(import.meta.url)) | |
| 17 | ||
| 18 | function have (bin) { try { execSync('command -v ' + bin, { stdio: 'ignore' }); return true } catch { return false } } | |
| 19 | const sshdBin = ['/usr/sbin/sshd', '/usr/bin/sshd', '/sbin/sshd'].find((p) => { try { fs.accessSync(p); return true } catch { return false } }) | |
| 20 | if (!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 | ||
| 25 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hm-sshmode-')) | |
| 26 | const port = 49000 + Math.floor((Date.now() % 1000)) // low-collision high port (Date.now ok in a node test) | |
| 27 | const F = (n) => path.join(dir, n) | |
| 28 | let sshd = null | |
| 29 | let failed = false | |
| 30 | ||
| 31 | function cleanup () { | |
| 32 | try { if (sshd) sshd.kill('SIGKILL') } catch {} | |
| 33 | try { fs.rmSync(dir, { recursive: true, force: true }) } catch {} | |
| 34 | } | |
| 35 | ||
| 36 | try { | |
| 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 | ||
| 86 | cleanup() | |
| 87 | console.log(failed ? '\n❌ ssh-mode test failed' : '\n✅ ssh-mode test passed') | |
| 88 | process.exit(failed ? 1 : 0) |
| … | … | @@ -24,7 +24,7 @@ async function testLoopback () { |
| 24 | 24 | |
| 25 | 25 | const probe = dgram.createSocket('udp4') |
| 26 | 26 | 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 | |
| 28 | 28 | probe.on('message', (m) => { clearTimeout(t); resolve(b4a.toString(m)) }) |
| 29 | 29 | }) |
| 30 | 30 | await new Promise((res) => probe.bind(0, '127.0.0.1', res)) |