// ssh-mode — the DEFAULT auth model: normal mosh-style login.
//
// mosh itself does no authentication: its wrapper execs `ssh user@host --
// mosh-server new …` and lets OpenSSH do password/ssh-key/PAM. We keep that
// exactly, but tunnel the SSH TCP over a hole-punched hyperdht stream to the
// server's real sshd (127.0.0.1:22). The client runs a normal `ssh` against a
// local port that bridges to the tunnel, authenticates, launches `mosh-server
// new`, and we parse `MOSH CONNECT <port> <key>` from ssh's stdout — the same
// bytes the mosh wrapper parses. mosh-server then DAEMONIZES as the logged-in
// user (correct PAM/login context) and outlives our stream, so a network flap
// just redials and re-bridges the UDP to the SAME server → real mosh roaming.
//
// Auth is 100% sshd's (password, keys, authorized_keys). The hyperdht firewall
// is open by default here — sshd is the gate, exactly like a public SSH host.
import DHT from 'hyperdht'
import dgram from '#node:dgram'
import net from '#node:net'
import cp from '#node:child_process'
import process from '#node:process'
import b4a from 'b4a'
import { ctrlFrame, dataFrame, sshFrame, sshEndFrame, makeParser } from './frames.mjs'
import { mkLog } from './log.mjs'
// mosh-server picks a UDP port from this range. The server only ever bridges the
// tunnel to 127.0.0.1 inside it, so an authenticated tunnel can't be turned into
// an arbitrary loopback UDP proxy.
const MOSH_PORT_LO = 60000
const MOSH_PORT_HI = 61000
const validMoshPort = (p) => Number.isInteger(p) && p >= MOSH_PORT_LO && p <= MOSH_PORT_HI
// ── SERVER ────────────────────────────────────────────────────────────────
// Per connection: forward the T_SSH channel to local sshd, and once the client
// asks (T_CTRL {bridge}), forward the T_DATA channel to the mosh-server UDP port.
export async function serveSSH ({ keyPair, allow = true, sshdHost = '127.0.0.1', sshdPort = 22, node = null } = {}) {
const log = mkLog('serve-ssh')
const dht = node || new DHT()
const firewall = (rpk) => !(allow === true || allow.some((k) => b4a.equals(rpk, k))) // true = DENY
const server = dht.createServer({ firewall }, (socket) => {
log('connection from', b4a.toString(socket.remotePublicKey || b4a.alloc(0), 'hex').slice(0, 8))
socket.on('error', (e) => log('socket error', e.code || e.message))
let sshd = null // TCP socket to local sshd
let udp = null // UDP socket to mosh-server
let moshPort = null
const openSshd = () => {
if (sshd) return sshd
log('opening sshd tunnel ->', sshdHost + ':' + sshdPort)
sshd = net.connect(sshdPort, sshdHost)
sshd.on('data', (d) => { try { socket.write(sshFrame(d)) } catch {} })
sshd.on('error', (e) => { log('sshd error', e.code || e.message); try { socket.write(sshEndFrame()) } catch {} })
sshd.on('close', () => { try { socket.write(sshEndFrame()) } catch {} })
return sshd
}
const onCtrl = (m) => {
if (m && m.bridge !== undefined) {
const port = Number(m.bridge)
if (!validMoshPort(port)) {
log('reject bridge to out-of-range port', port)
try { socket.write(ctrlFrame({ bridged: false, error: 'port ' + port + ' outside mosh range' })) } catch {}
return
}
moshPort = port
if (!udp) {
udp = dgram.createSocket('udp4')
udp.on('message', (msg) => { try { socket.write(dataFrame(msg)) } catch {} })
udp.on('error', (e) => log('udp error', e.code || e.message))
}
log('bridging mosh UDP -> 127.0.0.1:' + moshPort)
try { socket.write(ctrlFrame({ bridged: true, port: moshPort })) } catch {}
}
}
socket.on('data', makeParser({
onCtrl,
onData: (dg) => { if (udp && moshPort) { try { udp.send(dg, moshPort, '127.0.0.1') } catch {} } },
onSsh: (chunk) => { try { openSshd().write(chunk) } catch {} },
onSshEnd: () => { try { if (sshd) sshd.end() } catch {} }
}))
socket.on('close', () => {
log('connection closed')
try { if (sshd) sshd.destroy() } catch {}
try { if (udp) udp.close() } catch {}
// NB: mosh-server is NOT killed here — it daemonized under the SSH login and
// must survive tunnel reconnects (that persistence IS mosh's roaming).
})
})
await server.listen(keyPair)
log('listening as', b4a.toString(keyPair.publicKey, 'hex').slice(0, 8))
return { dht, server, publicKey: keyPair.publicKey, close: async () => { await server.close(); if (!node) await dht.destroy() } }
}
// ── CLIENT ────────────────────────────────────────────────────────────────
const TRANSIENT = new Set(['HOLEPUNCH_ABORTED', 'ETIMEDOUT', 'PEER_NOT_FOUND', 'ECONNRESET', 'PEER_CONNECTION_FAILED'])
// connectSSH bootstraps once, then keeps the mosh datagram path alive across
// stream flaps. onstarted(localUdpPort, moshKey) fires when mosh-server is up.
export function connectSSH ({
remoteKey, keyPair = null, user = null, cmd = [],
sshBin = 'ssh', sshOpts = [], onstarted, onerror = null,
connectRetries = 5, handshakeTimeoutMs = 20000, reconnectMs = 500
} = {}) {
const log = mkLog('client-ssh')
const state = { dht: null, socket: null, udp: null, sshListener: null, sshConn: null, closed: false, moshPort: null, moshKey: null, peer: null }
const fail = (err) => { if (onerror) onerror(err instanceof Error ? err : new Error(String(err))) }
// The local UDP port mosh-client targets; created once, survives reconnects.
const ensureUdp = (cb) => {
if (state.udp) return cb(state.udp.address().port)
const udp = dgram.createSocket('udp4')
udp.on('error', (e) => log('local udp error', e.code || e.message))
udp.on('message', (msg, rinfo) => { state.peer = rinfo; if (state.socket) { try { state.socket.write(dataFrame(msg)) } catch {} } })
udp.bind(0, '127.0.0.1', () => { state.udp = udp; cb(udp.address().port) })
}
// Bridge the (persistent) local mosh UDP over the current stream + ask the
// server to bind the far side. Called on first bootstrap AND every reconnect.
const wireUdp = (socket) => {
ensureUdp(() => {
log('requesting UDP bridge to mosh port', state.moshPort)
try { socket.write(ctrlFrame({ bridge: state.moshPort })) } catch {}
})
}
// Phase 1: run ssh through the tunnel, authenticate, launch mosh-server, parse key.
const bootstrap = (socket) => {
// Local TCP listener that ssh dials; forward its bytes over the stream as T_SSH.
const listener = net.createServer((ssh) => {
state.sshConn = ssh
ssh.on('data', (d) => { try { socket.write(sshFrame(d)) } catch {} })
ssh.on('error', (e) => log('ssh conn error', e.code || e.message))
ssh.on('close', () => { try { socket.write(sshEndFrame()) } catch {} })
})
state.sshListener = listener
listener.listen(0, '127.0.0.1', () => {
const lport = listener.address().port
const target = (user ? user + '@' : '') + '127.0.0.1'
// mosh-server args mirror what the mosh wrapper builds (minus IP-proxy dance).
const remoteCmd = 'mosh-server new -i 127.0.0.1 -l LANG=C.UTF-8 -l LC_ALL=C.UTF-8' +
(cmd && cmd.length ? ' -- ' + cmd.map(shq).join(' ') : '')
const args = [
'-p', String(lport),
// The hyperdht layer already cryptographically pins the server identity
// (Noise over the pubkey), so sshd host-key TOFU here is redundant churn
// against a rotating 127.0.0.1 — disable it.
'-o', 'StrictHostKeyChecking=no',
'-o', 'UserKnownHostsFile=/dev/null',
'-o', 'LogLevel=ERROR',
...sshOpts,
target, '--', remoteCmd
]
log('spawn ssh', sshBin, args.join(' '))
let child
try {
// stdout piped (we parse MOSH CONNECT); stderr + tty inherited so
// password prompts and ssh errors reach the user.
child = cp.spawn(sshBin, args, { stdio: ['inherit', 'pipe', 'inherit'] })
} catch (e) { fail(new Error('spawn ssh: ' + e.message)); return }
child.on('error', (e) => fail(new Error('ssh not runnable: ' + (e.code || e.message))))
let out = ''
let got = false
child.stdout.on('data', (d) => {
out += d
const m = out.match(/MOSH CONNECT (\d+) (\S+)/)
if (m && !got) {
got = true
state.moshPort = Number(m[1])
state.moshKey = m[2]
log('MOSH CONNECT parsed: port', state.moshPort)
try { listener.close() } catch {}
wireUdp(socket)
ensureUdp((lp) => onstarted(state.moshKey, lp))
}
})
child.on('exit', (code) => {
try { listener.close() } catch {}
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.'))
})
})
}
let attempt = 0
const go = () => {
if (state.closed) return
attempt++
const dht = new DHT()
const socket = dht.connect(remoteKey, keyPair ? { keyPair } : undefined)
state.dht = dht; state.socket = socket
let opened = false
socket.setKeepAlive(5000)
// Guard: connected at the transport layer but no MOSH CONNECT within the
// window → surface it instead of hanging forever (the old silent-hang bug).
const hsTimer = setTimeout(() => {
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)')) }
}, handshakeTimeoutMs)
socket.on('data', makeParser({
onCtrl: (m) => {
if (m && m.bridged === false) fail(new Error('server refused UDP bridge: ' + (m.error || 'unknown')))
else if (m && m.bridged === true) log('server bridged UDP to', m.port)
},
onData: (dg) => { if (state.udp && state.peer) { try { state.udp.send(dg, state.peer.port, state.peer.address) } catch {} } },
// sshd's replies come back as T_SSH frames — feed them to the local ssh conn.
onSsh: (chunk) => { if (state.sshConn) { try { state.sshConn.write(chunk) } catch {} } },
onSshEnd: () => { if (state.sshConn) { try { state.sshConn.end() } catch {} } }
}))
socket.once('open', () => {
opened = true
log('stream open (attempt ' + attempt + ')')
if (state.moshKey) {
// Reconnect after a flap: mosh-server still alive, just re-bridge the UDP.
clearTimeout(hsTimer)
wireUdp(socket)
} else {
bootstrap(socket)
}
socket.once('close', () => {
clearTimeout(hsTimer)
log('stream closed')
try { state.dht.destroy() } catch {}
// Roaming: if we already have a session, redial to re-bridge. Keep udp +
// mosh-client alive; only the tunnel behind them reconnects.
if (!state.closed && state.moshKey) setTimeout(go, reconnectMs)
})
})
socket.on('error', (err) => {
if (opened) { log('post-open socket error', err.code || err.message); return } // handled by close/redial
clearTimeout(hsTimer)
log('connect error', err.code || err.message)
try { socket.destroy() } catch {}
dht.destroy().finally(() => {
if (!state.closed && attempt <= connectRetries && (TRANSIENT.has(err.code) || !err.code)) setTimeout(go, 400 * attempt)
else fail(err)
})
})
}
go()
return {
close: async () => {
state.closed = true
try { if (state.sshConn) state.sshConn.destroy() } catch {}
try { if (state.sshListener) state.sshListener.close() } catch {}
try { if (state.udp) state.udp.close() } catch {}
try { if (state.socket) state.socket.destroy() } catch {}
try { if (state.dht) await state.dht.destroy() } catch {}
}
}
}
function shq (s) { return "'" + String(s).replace(/'/g, "'\\''") + "'" }