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);
|