OverviewFilesCommitsIssuesDocsReleasesPeers

hypermosh-shell / lib / relay.mjs

104 lines · javascript ref:
// relay — tunnel UDP datagrams over a hyperdht connection.
//
// The whole point: mosh's SSP already survives packet loss, latency, and
// roaming — it just needs *a* datagram path to the server. celltron (a NAT'd,
// Doze-prone phone) has no reachable UDP port, so we carry mosh's UDP over a
// hole-punched, identity-keyed hyperdht connection. mosh provides the
// resilience; this relay is a dumb UDP<->stream bridge with auto-reconnect
// (which becomes mosh's roaming when the phone's network flaps).
//
// Dual-runtime (node + bare) via the `#node:dgram` import map.

import DHT from 'hyperdht'
import dgram from '#node:dgram'
import b4a from 'b4a'

// Datagrams are length-prefixed ([uint16 BE len][payload]) so they reassemble
// cleanly out of the reliable UDX byte stream.
export function makeFramer (onDatagram) {
  let buf = b4a.alloc(0)
  return (chunk) => {
    buf = b4a.concat([buf, chunk])
    while (buf.length >= 2) {
      const len = buf.readUInt16BE(0)
      if (buf.length < 2 + len) break
      onDatagram(buf.subarray(2, 2 + len))
      buf = buf.subarray(2 + len)
    }
  }
}

export function frame (dg) {
  const h = b4a.alloc(2)
  h.writeUInt16BE(dg.length, 0)
  return b4a.concat([h, dg])
}

// SERVER side (runs next to mosh-server, e.g. on celltron): accept identity-
// firewalled hyperdht connections; bridge each to the local mosh-server UDP
// port. `allow` is `true` (any) or an array of allowed public keys.
export async function serve ({ keyPair, udpHost = '127.0.0.1', udpPort, allow = true, node = null } = {}) {
  if (!udpPort) throw new Error('serve requires udpPort (the local mosh-server UDP port)')
  const dht = node || new DHT()

  const firewall = (remotePublicKey) =>
    !(allow === true || allow.some((k) => b4a.equals(remotePublicKey, k))) // true = DENY

  const server = dht.createServer({ firewall }, (socket) => {
    const udp = dgram.createSocket('udp4')
    // mosh-server -> tunnel
    udp.on('message', (msg) => { try { socket.write(frame(msg)) } catch {} })
    // tunnel -> mosh-server
    socket.on('data', makeFramer((dg) => { try { udp.send(dg, udpPort, udpHost) } catch {} }))
    socket.on('error', () => {})
    socket.on('close', () => { try { udp.close() } catch {} })
  })

  await server.listen(keyPair)
  return {
    dht,
    server,
    publicKey: keyPair.publicKey,
    async close () { await server.close(); if (!node) await dht.destroy() }
  }
}

// CLIENT side (runs next to mosh-client): expose a local UDP port that
// mosh-client targets; bridge it to the remote server over hyperdht. The peer
// (mosh-client) UDP address is learned from its first packet. On stream close
// we redial — that reconnect IS mosh's roaming across network changes.
//
// NOTE: writes are NOT guarded on socket.writable — UDX buffers pre-connection
// writes, so mosh's earliest packets survive the ~seconds of DHT connect
// instead of being dropped (the bug that made an early prototype time out).
export async function connect ({ remoteKey, keyPair = null, localHost = '127.0.0.1', localPort = 0, node = null, reconnectMs = 500 } = {}) {
  const dht = node || new DHT()
  const udp = dgram.createSocket('udp4')
  let peer = null
  let socket = null
  let closed = false

  const dial = () => {
    if (closed) return
    socket = dht.connect(remoteKey, keyPair ? { keyPair } : undefined)
    socket.on('data', makeFramer((dg) => { if (peer) { try { udp.send(dg, peer.port, peer.address) } catch {} } }))
    socket.on('error', () => {})
    socket.on('close', () => { socket = null; if (!closed) setTimeout(dial, reconnectMs) })
  }

  udp.on('message', (msg, rinfo) => {
    peer = rinfo
    if (socket) { try { socket.write(frame(msg)) } catch {} } // buffers until connected
  })

  await new Promise((resolve) => udp.bind(localPort, localHost, resolve))
  dial()

  return {
    dht,
    udp,
    localPort: udp.address().port,
    async close () { closed = true; try { udp.close() } catch {}; if (socket) socket.destroy(); if (!node) await dht.destroy() }
  }
}