OverviewFilesCommitsIssuesDocsReleasesPeers

hypermosh-shell / lib / ssh-mode.mjs

259 lines · javascript ref:
// 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, "'\\''") + "'" }