board / deploy / relay / relay.mjs
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#!/usr/bin/env node
/**
 * Minimal libp2p relay for sueta v2 — the dev server AND the self-host example.
 *
 * What browsers need from it (and all it provides):
 *   - a websocket listener they can dial
 *   - circuit relay v2 reservations (for the SDP handshake of direct WebRTC)
 *   - gossipsub subscribed to the peer-discovery topic, so members find each other
 *
 * Room traffic normally bypasses this process — presence/messages are sealed
 * end-to-end and ride direct member-to-member WebRTC connections. While a
 * pair's WebRTC upgrade is pending or impossible ("relayed" state), their
 * traffic transits here as opaque noise-encrypted circuit frames, bounded by
 * the circuit-relay defaults (2 min / 128 KiB per relayed connection).
 *
 * Usage:
 *   node relay.mjs --dev                 # loopback :9090, deterministic PeerId
 *   node relay.mjs --port 9090 --key k   # production-ish: persistent key file
 *
 * Self-hosting for real deployments: run behind any TLS reverse proxy
 * (caddy/nginx) that forwards wss://host/ to this port, and point the client at
 * /dns4/<host>/tcp/443/tls/ws/p2p/<peerid> via VITE_RELAY_MULTIADDR.
 * TURN stays coturn — see deploy/turnserver.conf and docs/RELAY.md.
 */

import { readFileSync, writeFileSync, existsSync } from "node:fs";
import { createLibp2p } from "libp2p";
import { webSockets } from "@libp2p/websockets";
import { noise } from "@chainsafe/libp2p-noise";
import { yamux } from "@chainsafe/libp2p-yamux";
import { identify } from "@libp2p/identify";
import { ping } from "@libp2p/ping";
import { gossipsub } from "@chainsafe/libp2p-gossipsub";
import { circuitRelayServer } from "@libp2p/circuit-relay-v2";
import { pubsubPeerDiscovery } from "@libp2p/pubsub-peer-discovery";
import { generateKeyPair, generateKeyPairFromSeed, privateKeyToProtobuf, privateKeyFromProtobuf } from "@libp2p/crypto/keys";

const args = process.argv.slice(2);
const opt = (name, dflt) => {
  const i = args.indexOf(`--${name}`);
  if (i < 0) return dflt;
  const v = args[i + 1];
  if (v === undefined || v.startsWith("--")) {
    console.error(`--${name} needs a value`);
    process.exit(1);
  }
  return v;
};
const dev = args.includes("--dev");
const port = Number(opt("port", "9090"));
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
  console.error(`invalid --port`);
  process.exit(1);
}
const keyFile = opt("key", null);
const DISCOVERY_TOPIC = opt("topic", "_peer-discovery._p2p._pubsub");
const MAX_CONNS_PER_IP = Number(opt("max-per-ip", "16"));

async function loadKey() {
  if (dev) {
    // deterministic dev identity — the client's DEV default multiaddr bakes this PeerId
    const seed = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode("sueta-dev-relay-v2")));
    return generateKeyPairFromSeed("Ed25519", seed);
  }
  if (keyFile && existsSync(keyFile)) {
    return privateKeyFromProtobuf(readFileSync(keyFile));
  }
  const key = await generateKeyPair("Ed25519");
  if (keyFile) {
    writeFileSync(keyFile, privateKeyToProtobuf(key), { mode: 0o600 });
    console.log(`new relay key written to ${keyFile}`);
  }
  return key;
}

// Per-IP inbound cap: the global maxConnections/maxReservations pools are
// otherwise exhaustible by ONE host minting free PeerIds, locking every
// legitimate browser out of joins. (Run a reverse proxy with its own per-IP
// limits in front for defense-in-depth — see docs/RELAY.md.)
let node;
const ipOf = (ma) => {
  try {
    return ma.nodeAddress().address;
  } catch {
    return null;
  }
};
const denyInboundConnection = (maConn) => {
  const ip = ipOf(maConn.remoteAddr);
  if (!ip || !node) return false;
  let count = 0;
  for (const c of node.getConnections()) {
    if (c.status === "open" && ipOf(c.remoteAddr) === ip) count++;
  }
  return count >= MAX_CONNS_PER_IP;
};

node = await createLibp2p({
  privateKey: await loadKey(),
  addresses: { listen: [`/ip4/${dev ? "127.0.0.1" : "0.0.0.0"}/tcp/${port}/ws`] },
  transports: [webSockets()],
  connectionEncrypters: [noise()],
  streamMuxers: [yamux()],
  connectionGater: { denyInboundConnection },
  peerDiscovery: [pubsubPeerDiscovery({ interval: 5000, topics: [DISCOVERY_TOPIC] })],
  services: {
    identify: identify(),
    ping: ping(),
    pubsub: gossipsub({ allowPublishToZeroTopicPeers: true }),
    relay: circuitRelayServer({
      reservations: { maxReservations: 128 },
    }),
  },
  connectionManager: { maxConnections: 256 },
});

node.services.pubsub.subscribe(DISCOVERY_TOPIC);

console.log(`sueta relay up`);
console.log(`  peerId     ${node.peerId.toString()}`);
for (const a of node.getMultiaddrs()) console.log(`  listening  ${a.toString()}`);
console.log(`  discovery  ${DISCOVERY_TOPIC}`);

const shutdown = async () => {
  await node.stop().catch(() => {});
  process.exit(0);
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);

static mirror of HEAD · about · clone: git clone https://git.ardegazu.ro/board.git