cautpartener / 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
130
131
#!/usr/bin/env node
/**
 * Minimal libp2p relay for Caut Partener — the DEV/TEST relay and the
 * self-host example (ported from the upstream sueta v2 deploy, adapted
 * only in naming; production runs the managed node behind
 * signal.cautpartener.ro).
 *
 * 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 — OrbitDB sync gossip rides
 * direct member-to-member WebRTC connections; the relay only carries opaque
 * noise-encrypted circuit frames while an upgrade is pending, bounded by the
 * circuit-relay defaults (2 min / 128 KiB per relayed connection).
 *
 * Usage:
 *   node relay.mjs --dev                 # loopback, deterministic PeerId
 *   node relay.mjs --port 9090 --key k   # production-ish: persistent key file
 *
 * Self-hosting: run behind any TLS reverse proxy that forwards wss://host/ to
 * this port, and point the client at /dns4/<host>/tcp/443/tls/ws/p2p/<peerid>
 * via VITE_RELAY_MULTIADDR.
 */

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 — e2e suites parse the printed PeerId anyway,
    // but a stable id keeps restart scenarios (same relay identity) honest
    const seed = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode("cautpartener-dev-relay-v1")));
    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.)
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(`cautpartener 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://cod-sursa.cautpartener.ro/cautpartener.git