freehop 0.1.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +204 -0
- package/LICENSE +202 -0
- package/LICENSE-docs +396 -0
- package/NOTICE +34 -0
- package/PROTOCOL.md +310 -0
- package/README.md +221 -0
- package/SDK.md +113 -0
- package/bin/freehop-gate.mjs +51 -0
- package/deploy/Caddyfile.snippet +6 -0
- package/deploy/freehop-gate.service +46 -0
- package/package.json +79 -0
- package/src/client/crypto.mjs +49 -0
- package/src/client/gate-client.mjs +95 -0
- package/src/client/ice-urls.mjs +14 -0
- package/src/client/peer.mjs +272 -0
- package/src/client/peerlane.mjs +6 -0
- package/src/client/room.mjs +1188 -0
- package/src/client/tracker-client.mjs +123 -0
- package/src/electron/main.mjs +70 -0
- package/src/electron/preload.cjs +17 -0
- package/src/gate/gate.mjs +357 -0
- package/src/gate/stun-responder.mjs +52 -0
- package/src/relay/agent.mjs +230 -0
- package/src/relay/member.mjs +169 -0
- package/src/relay/port-mapper.mjs +1058 -0
- package/src/relay/turn-server.mjs +790 -0
- package/src/sdk/authority.mjs +85 -0
- package/src/sdk/client.mjs +113 -0
- package/src/sdk/host.mjs +72 -0
- package/src/sdk/ticket.mjs +29 -0
- package/src/shared/stun.mjs +282 -0
- package/src/shared/tokens.mjs +35 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// A WebTorrent (BitTorrent-over-WebSocket) tracker used as a gate, so a room can run on
|
|
3
|
+
// public infrastructure that already exists. Trackers relay opaque WebRTC offers/answers
|
|
4
|
+
// between peers of a swarm; Freehop places its sealed envelopes in those fields:
|
|
5
|
+
// offer = "pl1:<peer id>:<sealed hello>" (announced, delivered to random swarm members)
|
|
6
|
+
// answer = "pl1:<peer id>:<sealed envelope>" (addressed to one peer via to_peer_id)
|
|
7
|
+
// The swarm's info_hash is derived from the room tag, so only room members can find it, and
|
|
8
|
+
// every envelope is authenticated by the room key before it is trusted.
|
|
9
|
+
import { Emitter } from './gate-client.mjs';
|
|
10
|
+
import { fromBase64Url } from './crypto.mjs';
|
|
11
|
+
|
|
12
|
+
const PREFIX = 'pl1:';
|
|
13
|
+
const PEER_ID = /^[A-Za-z0-9_-]{22}$/;
|
|
14
|
+
// Trackers treat info_hash/peer_id as 20-byte strings. ASCII-only values avoid tracker
|
|
15
|
+
// implementations that mishandle raw bytes in JSON text frames.
|
|
16
|
+
const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
|
17
|
+
const random20 = () => [...globalThis.crypto.getRandomValues(new Uint8Array(20))].map(b => ALPHABET[b % 62]).join('');
|
|
18
|
+
const hex = bytes => [...bytes].map(b => b.toString(16).padStart(2, '0')).join('');
|
|
19
|
+
|
|
20
|
+
export class TrackerClient extends Emitter {
|
|
21
|
+
constructor(url, { room, peer, hello, needsIntroduction = () => false, bootstrapMs = [8000, 24000],
|
|
22
|
+
WebSocketImpl = globalThis.WebSocket, announceMs = 30000, offers = 6,
|
|
23
|
+
backoffMs = [1000, 2000, 5000, 10000, 30000, 60000] } = {}) {
|
|
24
|
+
super();
|
|
25
|
+
Object.assign(this, { url, room, peer, hello, needsIntroduction, bootstrapMs, WebSocketImpl, announceMs, offers, backoffMs });
|
|
26
|
+
this.infoHash = hex(fromBase64Url(room)).slice(0, 20);
|
|
27
|
+
this.trackerPeerId = random20();
|
|
28
|
+
this.trackerIds = new Map(); // peerlane id -> tracker peer_id (binary string)
|
|
29
|
+
this.state = 'idle'; this.closed = false; this.ws = null; this.attempt = 0; this.stun = []; this.started = false;
|
|
30
|
+
this.counters = { sent: 0, received: 0, bytesOut: 0, bytesIn: 0, errors: 0, connects: 0, announces: 0 };
|
|
31
|
+
}
|
|
32
|
+
connect() {
|
|
33
|
+
if (this.closed || this.ws) return;
|
|
34
|
+
this.state = 'connecting';
|
|
35
|
+
let ws;
|
|
36
|
+
try { ws = new this.WebSocketImpl(this.url); } catch { this.#retry(); return; }
|
|
37
|
+
this.ws = ws;
|
|
38
|
+
ws.onopen = () => {
|
|
39
|
+
if (ws !== this.ws) return;
|
|
40
|
+
this.state = 'joined'; this.attempt = 0; this.counters.connects++; this.started = false;
|
|
41
|
+
this.emit('joined', { gate: this, peers: [], stun: [] });
|
|
42
|
+
this.announce();
|
|
43
|
+
clearInterval(this.timer);
|
|
44
|
+
this.timer = setInterval(() => this.announce(), this.announceMs);
|
|
45
|
+
// Trackers commonly request a 120 s interval. A missed first offer would leave a
|
|
46
|
+
// new meeting empty that long, so make two bounded introduction retries.
|
|
47
|
+
this.bootstrapTimers = this.bootstrapMs.map(ms => setTimeout(() => {
|
|
48
|
+
if (this.state === 'joined' && this.needsIntroduction()) this.announce();
|
|
49
|
+
}, ms));
|
|
50
|
+
};
|
|
51
|
+
ws.onmessage = event => {
|
|
52
|
+
if (ws !== this.ws || typeof event.data !== 'string' || event.data.length > 200000) return;
|
|
53
|
+
this.counters.bytesIn += event.data.length;
|
|
54
|
+
let m; try { m = JSON.parse(event.data); } catch { return; }
|
|
55
|
+
this.#handle(m);
|
|
56
|
+
};
|
|
57
|
+
ws.onclose = () => {
|
|
58
|
+
if (ws !== this.ws) return;
|
|
59
|
+
this.ws = null; clearInterval(this.timer);
|
|
60
|
+
for (const timer of this.bootstrapTimers ?? []) clearTimeout(timer);
|
|
61
|
+
this.bootstrapTimers = [];
|
|
62
|
+
const was = this.state; this.state = 'idle';
|
|
63
|
+
if (was === 'joined') this.emit('left', { gate: this });
|
|
64
|
+
this.#retry();
|
|
65
|
+
};
|
|
66
|
+
ws.onerror = () => { this.counters.errors++; };
|
|
67
|
+
}
|
|
68
|
+
#retry() {
|
|
69
|
+
if (this.closed) return;
|
|
70
|
+
const delay = this.backoffMs[Math.min(this.attempt++, this.backoffMs.length - 1)];
|
|
71
|
+
this.retryTimer = setTimeout(() => { this.retryTimer = null; this.connect(); }, delay * (0.75 + Math.random() / 2));
|
|
72
|
+
}
|
|
73
|
+
#raw(message) {
|
|
74
|
+
if (this.ws?.readyState !== 1) return false;
|
|
75
|
+
const text = JSON.stringify(message);
|
|
76
|
+
this.ws.send(text); this.counters.bytesOut += text.length;
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
async announce() {
|
|
80
|
+
if (this.state !== 'joined') return;
|
|
81
|
+
const offers = [];
|
|
82
|
+
for (let i = 0; i < this.offers; i++) offers.push({ offer_id: random20(), offer: { type: 'offer', sdp: PREFIX + this.peer + ':' + await this.hello() } });
|
|
83
|
+
this.#raw({ action: 'announce', info_hash: this.infoHash, peer_id: this.trackerPeerId, numwant: this.offers,
|
|
84
|
+
uploaded: 0, downloaded: 0, left: 1, ...(this.started ? {} : { event: 'started' }), offers });
|
|
85
|
+
this.started = true; this.counters.announces++;
|
|
86
|
+
}
|
|
87
|
+
#handle(m) {
|
|
88
|
+
if (m?.action !== 'announce' || m.info_hash !== this.infoHash) return;
|
|
89
|
+
if (Number.isFinite(m.interval) && m.interval * 1000 > this.announceMs && m.interval < 3600) {
|
|
90
|
+
this.announceMs = m.interval * 1000; clearInterval(this.timer); this.timer = setInterval(() => this.announce(), this.announceMs);
|
|
91
|
+
}
|
|
92
|
+
const payload = m.offer?.sdp ?? m.answer?.sdp;
|
|
93
|
+
if (typeof payload !== 'string' || !payload.startsWith(PREFIX) || typeof m.peer_id !== 'string' || m.peer_id === this.trackerPeerId) return;
|
|
94
|
+
const rest = payload.slice(PREFIX.length), split = rest.indexOf(':');
|
|
95
|
+
const from = rest.slice(0, split), box = rest.slice(split + 1);
|
|
96
|
+
if (box.length > 49152 || split !== 22 || !PEER_ID.test(from) || from === this.peer || !/^[A-Za-z0-9_-]+$/.test(box)) return;
|
|
97
|
+
// The tracker address is remembered only once the room authenticated this sender (bind()),
|
|
98
|
+
// so a swarm member cannot redirect envelopes by impersonating a peer id.
|
|
99
|
+
this.counters.received++;
|
|
100
|
+
if (m.offer) this.emit('hello', { gate: this, from, box, route: m.peer_id });
|
|
101
|
+
else this.emit('recv', { gate: this, from, box, route: m.peer_id });
|
|
102
|
+
}
|
|
103
|
+
bind(peer, route) {
|
|
104
|
+
if (typeof route !== 'string' || route.length !== 20) return;
|
|
105
|
+
if (!this.trackerIds.has(peer) && this.trackerIds.size >= 64) this.trackerIds.delete(this.trackerIds.keys().next().value);
|
|
106
|
+
this.trackerIds.set(peer, route);
|
|
107
|
+
}
|
|
108
|
+
send(to, box) {
|
|
109
|
+
const target = this.trackerIds.get(to);
|
|
110
|
+
if (this.state !== 'joined' || !target) return false;
|
|
111
|
+
const ok = this.#raw({ action: 'announce', info_hash: this.infoHash, peer_id: this.trackerPeerId, to_peer_id: target,
|
|
112
|
+
answer: { type: 'answer', sdp: PREFIX + this.peer + ':' + box }, offer_id: random20() });
|
|
113
|
+
if (ok) this.counters.sent++;
|
|
114
|
+
return ok;
|
|
115
|
+
}
|
|
116
|
+
close() {
|
|
117
|
+
this.closed = true; clearInterval(this.timer); clearTimeout(this.retryTimer);
|
|
118
|
+
for (const timer of this.bootstrapTimers ?? []) clearTimeout(timer);
|
|
119
|
+
this.bootstrapTimers = [];
|
|
120
|
+
const ws = this.ws; this.ws = null; this.state = 'closed';
|
|
121
|
+
if (ws) { try { if (ws.readyState === 1) ws.send(JSON.stringify({ action: 'announce', info_hash: this.infoHash, peer_id: this.trackerPeerId, event: 'stopped', numwant: 0, uploaded: 0, downloaded: 0, left: 1 })); ws.close(1000); } catch {} }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Electron main-process helper: runs the participant's gateway (TURN + router port mapping) and
|
|
3
|
+
// exposes it to the app's renderer through IPC. Pair it with preload.cjs. The gateway starts
|
|
4
|
+
// lazily, the first time a renderer asks for it, so participants who never use voice pay nothing.
|
|
5
|
+
//
|
|
6
|
+
// import { installFreehopGateway } from 'freehop/electron';
|
|
7
|
+
// const peerlane = installFreehopGateway({ ipcMain, allowedOrigins: ['https://play.example.com'] });
|
|
8
|
+
// app.on('will-quit', () => peerlane.close());
|
|
9
|
+
// new BrowserWindow({ webPreferences: { preload: preloadPath,
|
|
10
|
+
// additionalArguments: ['--freehop-origins=https://play.example.com'] } });
|
|
11
|
+
import { startGateway } from '../relay/agent.mjs';
|
|
12
|
+
|
|
13
|
+
const TAG = /^[A-Za-z0-9_-]{8,64}$/, PEER = /^[A-Za-z0-9_-]{22}$/;
|
|
14
|
+
|
|
15
|
+
export function installFreehopGateway({ ipcMain, allowedOrigins, options = {}, log = () => {} }) {
|
|
16
|
+
if (!Array.isArray(allowedOrigins) || !allowedOrigins.length || !allowedOrigins.every(origin => {
|
|
17
|
+
try { const url = new URL(origin); return (url.protocol === 'https:' || url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)) && url.origin === origin; }
|
|
18
|
+
catch { return false; }
|
|
19
|
+
})) throw new TypeError('Specify allowedOrigins as exact HTTPS origins (HTTP is allowed only on loopback) for the gateway renderer.');
|
|
20
|
+
const origins = new Set(allowedOrigins);
|
|
21
|
+
// Validate at the privileged boundary too: a preload check alone cannot authorize IPC.
|
|
22
|
+
const trusted = event => {
|
|
23
|
+
try { return !closed && event.senderFrame === event.sender.mainFrame && origins.has(new URL(event.senderFrame.url).origin); }
|
|
24
|
+
catch { return false; }
|
|
25
|
+
};
|
|
26
|
+
const grants = new WeakMap();
|
|
27
|
+
// A window's rooms are released on the gateway when its page navigates away, its origin
|
|
28
|
+
// changes or the window is destroyed: a page that never called leave() leaves nothing behind.
|
|
29
|
+
const releaseAll = grant => { if (!grant) return; for (const tag of grant.tags) gateway?.revokeRoom(tag); grant.tags.clear(); };
|
|
30
|
+
const watched = new WeakSet();
|
|
31
|
+
const watch = sender => {
|
|
32
|
+
if (watched.has(sender) || typeof sender?.on !== 'function') return;
|
|
33
|
+
watched.add(sender);
|
|
34
|
+
sender.on('did-navigate', () => releaseAll(grants.get(sender))); // main-frame navigations only (not in-page)
|
|
35
|
+
sender.once?.('destroyed', () => { releaseAll(grants.get(sender)); grants.delete(sender); });
|
|
36
|
+
};
|
|
37
|
+
const roomsFor = event => {
|
|
38
|
+
const origin = new URL(event.senderFrame.url).origin;
|
|
39
|
+
let grant = grants.get(event.sender);
|
|
40
|
+
if (!grant || grant.origin !== origin) { releaseAll(grant); grant = {origin, tags: new Set()}; grants.set(event.sender, grant); watch(event.sender); }
|
|
41
|
+
return grant.tags;
|
|
42
|
+
};
|
|
43
|
+
let gateway = null, starting = null, closed = false;
|
|
44
|
+
const ensure = () => {
|
|
45
|
+
if (closed) return Promise.reject(new Error('closed'));
|
|
46
|
+
if (gateway) return Promise.resolve(gateway);
|
|
47
|
+
starting ??= startGateway({ ...options, log }).then(g => (gateway = g)).catch(error => { starting = null; throw error; });
|
|
48
|
+
return starting;
|
|
49
|
+
};
|
|
50
|
+
ipcMain.handle('freehop:gateway-info', async event => { if (!trusted(event)) return null; try { const g = await ensure(); return trusted(event) ? g.info() : null; } catch (error) { log('gateway-unavailable', { message: error.message }); return null; } });
|
|
51
|
+
ipcMain.handle('freehop:allow-room', async (event, tag) => { if (trusted(event) && typeof tag === 'string' && TAG.test(tag)) { const origin = new URL(event.senderFrame.url).origin; const g = await ensure(); if (trusted(event) && new URL(event.senderFrame.url).origin === origin) { const rooms = roomsFor(event); if (rooms.size >= 64 && !rooms.has(tag)) return; g.allowRoom(tag); rooms.add(tag); } } });
|
|
52
|
+
ipcMain.handle('freehop:credentials', async (event, tag, peer) => {
|
|
53
|
+
if (!trusted(event) || typeof tag !== 'string' || !TAG.test(tag) || typeof peer !== 'string' || !(peer === 'self' || PEER.test(peer)) || !roomsFor(event).has(tag)) return null;
|
|
54
|
+
const g = await ensure();
|
|
55
|
+
return trusted(event) && roomsFor(event).has(tag) ? g.credentialsFor(tag, peer) : null;
|
|
56
|
+
});
|
|
57
|
+
ipcMain.handle('freehop:revoke-peer', async (event, tag, peer) => {
|
|
58
|
+
if (trusted(event) && gateway && typeof tag === 'string' && TAG.test(tag) && roomsFor(event).has(tag) && typeof peer === 'string' && PEER.test(peer)) gateway.revokePeer(tag, peer);
|
|
59
|
+
});
|
|
60
|
+
ipcMain.handle('freehop:revoke-room', async (event, tag) => { if (trusted(event) && gateway && typeof tag === 'string' && TAG.test(tag) && roomsFor(event).has(tag)) { gateway.revokeRoom(tag); roomsFor(event).delete(tag); } });
|
|
61
|
+
return {
|
|
62
|
+
get gateway() { return gateway; },
|
|
63
|
+
async close() {
|
|
64
|
+
closed = true;
|
|
65
|
+
for (const channel of ['freehop:gateway-info', 'freehop:credentials', 'freehop:allow-room', 'freehop:revoke-peer', 'freehop:revoke-room']) ipcMain.removeHandler(channel);
|
|
66
|
+
const g = gateway ?? await starting?.catch(() => null);
|
|
67
|
+
await g?.close();
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Electron preload: exposes window.freehopGateway to the app's own pages only. The main
|
|
3
|
+
// process passes the allowed origins as --freehop-origins=<origin>[,<origin>...]; any other
|
|
4
|
+
// page (e.g. a link opened inside the window) never sees the gateway. Its signing secret stays in the main process.
|
|
5
|
+
const { contextBridge, ipcRenderer } = require('electron');
|
|
6
|
+
|
|
7
|
+
const arg = process.argv.find(a => a.startsWith('--freehop-origins='));
|
|
8
|
+
const allowed = new Set((arg ? arg.slice('--freehop-origins='.length) : '').split(',').map(s => s.trim()).filter(Boolean));
|
|
9
|
+
if (allowed.has(globalThis.location?.origin)) {
|
|
10
|
+
contextBridge.exposeInMainWorld('freehopGateway', Object.freeze({
|
|
11
|
+
info: () => ipcRenderer.invoke('freehop:gateway-info'),
|
|
12
|
+
credentialsFor: (tag, peer) => ipcRenderer.invoke('freehop:credentials', tag, peer),
|
|
13
|
+
allowRoom: tag => ipcRenderer.invoke('freehop:allow-room', tag),
|
|
14
|
+
revokePeer: (tag, peer) => ipcRenderer.invoke('freehop:revoke-peer', tag, peer),
|
|
15
|
+
revokeRoom: tag => ipcRenderer.invoke('freehop:revoke-room', tag)
|
|
16
|
+
}));
|
|
17
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Freehop gate: a blind rendezvous mailbox. It routes sealed envelopes between peers that
|
|
3
|
+
// announce the same opaque room tag. It never sees SDP, candidates, credentials or media:
|
|
4
|
+
// clients seal every envelope with a room key the gate does not have. Any number of
|
|
5
|
+
// interchangeable gates may serve the same room; clients announce on several at once.
|
|
6
|
+
import http from 'node:http';
|
|
7
|
+
import { isIPv4, isIPv6 } from 'node:net';
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
9
|
+
import { WebSocketServer } from 'ws';
|
|
10
|
+
import { verifyGateToken } from '../shared/tokens.mjs';
|
|
11
|
+
|
|
12
|
+
export const GATE_PROTOCOL = 1;
|
|
13
|
+
// Worst-case memory at these defaults (measured on Node 24): (1024 admitted + 256 pending or closing
|
|
14
|
+
// sockets) x (64 KiB partial frame + ~28 KiB state) = 115 MiB, + 256 x 64 KiB queued behind admission
|
|
15
|
+
// = 16 MiB, + 32 MiB unflushed output, + ~60 MiB runtime: ~225 MiB. Forwarding bursts add short-lived
|
|
16
|
+
// garbage on top (up to ~250 MiB before GC), so deploy/freehop-gate.service sets MemoryMax=512M.
|
|
17
|
+
export const GATE_LIMITS = Object.freeze({
|
|
18
|
+
frame: 65536, // bytes per WebSocket frame
|
|
19
|
+
box: 49152, // sealed envelope (base64url) characters
|
|
20
|
+
roomsPerSocket: 4,
|
|
21
|
+
peersPerRoom: 16,
|
|
22
|
+
socketsPerAddress: 32, // every per-address limit counts an IPv4 address or an IPv6 /64
|
|
23
|
+
maxSockets: 1024, // admitted sockets
|
|
24
|
+
maxPendingSockets: 256, // sockets not admitted yet, plus sockets closing
|
|
25
|
+
pendingPerAddress: 8,
|
|
26
|
+
helloMs: 5000,
|
|
27
|
+
closeMs: 1000, // a close handshake gets this long, then the socket is destroyed
|
|
28
|
+
idleMs: 90000,
|
|
29
|
+
pingMs: 30000,
|
|
30
|
+
// Token bucket over every byte a socket makes the gate forward (fan-out included).
|
|
31
|
+
// Signalling for a full five-peer join is ~100 KB; media needs megabytes per minute.
|
|
32
|
+
burstBytes: 524288,
|
|
33
|
+
refillBytesPerSec: 2048,
|
|
34
|
+
messagesPerSec: 40, // pings and unsolicited pongs count too
|
|
35
|
+
messageBurst: 400,
|
|
36
|
+
maxBufferedBytes: 1048576, // output handed to ws and not yet flushed, per socket
|
|
37
|
+
maxBufferedTotal: 33554432, // the same, for the whole gate
|
|
38
|
+
maxPendingFrames: 64, // frames queued behind an admission in progress
|
|
39
|
+
maxPendingBytes: 65536
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const TAG = /^[A-Za-z0-9_-]{22,43}$/; // base64url of 16..32 bytes
|
|
43
|
+
const BOX = /^[A-Za-z0-9_-]+$/;
|
|
44
|
+
const first = set => set.values().next().value; // sets below iterate oldest first
|
|
45
|
+
|
|
46
|
+
export { mintGateToken, verifyGateToken } from '../shared/tokens.mjs';
|
|
47
|
+
|
|
48
|
+
class Bucket {
|
|
49
|
+
constructor(capacity, refillPerSec) { this.capacity = capacity; this.refill = refillPerSec; this.level = capacity; this.at = Date.now(); }
|
|
50
|
+
take(amount) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
this.level = Math.min(this.capacity, this.level + (now - this.at) / 1000 * this.refill); this.at = now;
|
|
53
|
+
if (amount > this.level) return false;
|
|
54
|
+
this.level -= amount; return true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Per-address budgets key on an IPv4 address or an IPv6 /64: one host can use a whole /64.
|
|
59
|
+
// Some proxies append a port ("ip:port", "[ip]:port"); it must not split one client into many.
|
|
60
|
+
function addressKey(address) {
|
|
61
|
+
const ip = String(address).replace(/^\[([^\]]*)\](:\d+)?$/, '$1').replace(/^([\d.]+):\d+$/, '$1').replace(/%.*$/, '');
|
|
62
|
+
if (isIPv4(ip)) return ip;
|
|
63
|
+
if (!isIPv6(ip)) return String(address).slice(0, 64);
|
|
64
|
+
const v4 = /(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(ip);
|
|
65
|
+
const text = v4 ? `${ip.slice(0, v4.index)}${(v4[1] << 8 | v4[2]).toString(16)}:${(v4[3] << 8 | v4[4]).toString(16)}` : ip;
|
|
66
|
+
const [head, tail] = text.split('::');
|
|
67
|
+
const left = head ? head.split(':') : [], right = tail ? tail.split(':') : [];
|
|
68
|
+
const g = (tail === undefined ? left : [...left, ...Array(8 - left.length - right.length).fill('0'), ...right]).map(x => parseInt(x, 16));
|
|
69
|
+
// IPv4-mapped (::ffff:0:0/96) and well-known NAT64 (64:ff9b::/96) addresses stand for one IPv4 client.
|
|
70
|
+
if (!(g[0] | g[1] | g[2] | g[3] | g[4]) && g[5] === 0xffff || g[0] === 0x64 && g[1] === 0xff9b && !(g[2] | g[3] | g[4] | g[5]))
|
|
71
|
+
return `${g[6] >> 8}.${g[6] & 255}.${g[7] >> 8}.${g[7] & 255}`;
|
|
72
|
+
return `${g.slice(0, 4).map(x => x.toString(16)).join(':')}::/64`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* createGate({ server?, host, port, path, stun, stunUrls, tokenSecret, tokenAudience, trustProxy, authorize, limits, log })
|
|
77
|
+
* - server: an existing http(s).Server to attach to (otherwise one is created on host:port)
|
|
78
|
+
* - stun: [{ host, port }] UDP STUN Binding responders started alongside (optional)
|
|
79
|
+
* - stunUrls: STUN URLs advertised to clients (defaults derived from `stun` + publicHost)
|
|
80
|
+
* - tokenSecret: when set, `hello.auth` must be a valid gate token (room-bound tokens restrict joins)
|
|
81
|
+
* - authorize(hello, request): custom admission hook, overrides tokenSecret when given
|
|
82
|
+
* Sockets that have not been admitted have their own budget (limits.maxPendingSockets), so
|
|
83
|
+
* sockets that never send a valid hello cannot lock admitted clients out.
|
|
84
|
+
*/
|
|
85
|
+
export async function createGate(options = {}) {
|
|
86
|
+
const limits = { ...GATE_LIMITS, ...options.limits };
|
|
87
|
+
const log = options.log ?? (() => {});
|
|
88
|
+
const path = options.path ?? '/freehop';
|
|
89
|
+
const stats = { sockets: 0, socketsTotal: 0, pending: 0, rooms: 0, joins: 0, framesIn: 0, bytesIn: 0, framesOut: 0, bytesOut: 0,
|
|
90
|
+
buffered: 0, envelopes: 0, refused: 0, rateLimited: 0, closedForAbuse: 0, evicted: 0 };
|
|
91
|
+
const rooms = new Map(); // roomTag -> Map(peerTag -> socketState)
|
|
92
|
+
const perAddress = new Map(); // address key -> { all, waiting } socket counts
|
|
93
|
+
const sockets = new Set(); // every open socket
|
|
94
|
+
const waiting = new Set(); // not admitted yet
|
|
95
|
+
const silent = new Set(); // waiting, and no frame received yet
|
|
96
|
+
const closing = new Set(); // close handshake in progress
|
|
97
|
+
const backlog = new Set(); // sockets with unflushed output, longest behind first
|
|
98
|
+
let admitted = 0, buffered = 0;
|
|
99
|
+
|
|
100
|
+
const ownServer = !options.server;
|
|
101
|
+
const server = options.server ?? http.createServer((req, res) => {
|
|
102
|
+
if (req.url === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok'); return; }
|
|
103
|
+
res.writeHead(404); res.end();
|
|
104
|
+
});
|
|
105
|
+
// autoPong is off so pong replies go through the output budget; closeTimeout bounds every close handshake.
|
|
106
|
+
const wss = new WebSocketServer({ noServer: true, maxPayload: limits.frame, perMessageDeflate: false, clientTracking: false,
|
|
107
|
+
autoPong: false, closeTimeout: limits.closeMs });
|
|
108
|
+
const onUpgrade = (request, socket, head) => {
|
|
109
|
+
let url;
|
|
110
|
+
try { url = new URL(request.url ?? '/', 'http://gate'); }
|
|
111
|
+
catch { socket.destroy(); stats.refused++; return; }
|
|
112
|
+
if (url.pathname !== path) { socket.destroy(); return; }
|
|
113
|
+
// Behind a local reverse proxy every socket comes from loopback: with trustProxy, the
|
|
114
|
+
// client address is the hop the proxy appended to X-Forwarded-For.
|
|
115
|
+
const direct = request.socket.remoteAddress ?? '?';
|
|
116
|
+
const forwarded = options.trustProxy && /^(127\.|::1$|::ffff:127\.)/.test(direct)
|
|
117
|
+
? String(request.headers['x-forwarded-for'] ?? '').split(',').map(v => v.trim()).filter(Boolean).pop() : null;
|
|
118
|
+
const address = addressKey(forwarded || direct);
|
|
119
|
+
const count = perAddress.get(address);
|
|
120
|
+
// A full pending budget is refused only when no closing or silent socket can make room (see accept).
|
|
121
|
+
if (count && (count.all >= limits.socketsPerAddress || count.waiting >= limits.pendingPerAddress)
|
|
122
|
+
|| waiting.size + closing.size >= limits.maxPendingSockets && !closing.size && !silent.size) { socket.destroy(); stats.refused++; return; }
|
|
123
|
+
wss.handleUpgrade(request, socket, head, ws => accept(ws, request, address));
|
|
124
|
+
};
|
|
125
|
+
server.on('upgrade', onUpgrade);
|
|
126
|
+
|
|
127
|
+
const stunResponders = [];
|
|
128
|
+
if (options.stun?.length) {
|
|
129
|
+
const { createStunResponder } = await import('./stun-responder.mjs');
|
|
130
|
+
for (const spec of options.stun) stunResponders.push(await createStunResponder({ ...spec, log }));
|
|
131
|
+
}
|
|
132
|
+
// A wildcard bind cannot be advertised: clients need the public host name or address.
|
|
133
|
+
const stunUrls = options.stunUrls ?? stunResponders.map(r => {
|
|
134
|
+
const a = r.address();
|
|
135
|
+
const host = options.publicHost ?? (a.address === '0.0.0.0' || a.address === '::' ? null : a.address);
|
|
136
|
+
if (!host) { log('stun-not-advertised', { reason: 'wildcard bind without publicHost' }); return null; }
|
|
137
|
+
return `stun:${host.includes(':') ? `[${host}]` : host}:${a.port}`;
|
|
138
|
+
}).filter(Boolean);
|
|
139
|
+
|
|
140
|
+
// Output accounting: bytes handed to ws and not yet flushed (its write callback has not run).
|
|
141
|
+
function queue(state, size, write) {
|
|
142
|
+
if (state.closed || state.ws.readyState !== 1) return false;
|
|
143
|
+
// A receiver that stops reading must not make the gate buffer without bound.
|
|
144
|
+
if (state.buffered + size > limits.maxBufferedBytes) { stats.closedForAbuse++; kill(state); return false; }
|
|
145
|
+
// Over the gate-wide budget, the receiver that has been behind the longest goes first,
|
|
146
|
+
// so stalled sockets cannot spend the budget of receivers that keep up.
|
|
147
|
+
while (buffered + size > limits.maxBufferedTotal && backlog.size) {
|
|
148
|
+
const slowest = first(backlog); stats.closedForAbuse++; kill(slowest);
|
|
149
|
+
if (slowest === state) return false;
|
|
150
|
+
}
|
|
151
|
+
if (!state.buffered) backlog.add(state);
|
|
152
|
+
state.buffered += size; buffered += size;
|
|
153
|
+
write(() => {
|
|
154
|
+
if (state.phase === 'gone') return; // released in full on close
|
|
155
|
+
state.buffered -= size; buffered -= size;
|
|
156
|
+
if (!state.buffered) backlog.delete(state);
|
|
157
|
+
});
|
|
158
|
+
return true;
|
|
159
|
+
}
|
|
160
|
+
function send(state, message) {
|
|
161
|
+
const text = JSON.stringify(message);
|
|
162
|
+
if (!queue(state, Buffer.byteLength(text), done => state.ws.send(text, done))) return false;
|
|
163
|
+
stats.framesOut++; stats.bytesOut += text.length;
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
function emit(state, message) {
|
|
167
|
+
if (state.closed) return false;
|
|
168
|
+
// Expiry is checked on delivery too, so an expired socket stops receiving at once, not at the next heartbeat.
|
|
169
|
+
if (Date.now() >= state.expiresAt) { refuse(state, 'auth-expired', true); return false; }
|
|
170
|
+
return send(state, message);
|
|
171
|
+
}
|
|
172
|
+
function refuse(state, code, close = false) {
|
|
173
|
+
stats.refused++; send(state, { t: 'error', code });
|
|
174
|
+
if (close) { stats.closedForAbuse++; shut(state, 1008, code); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Socket phases: waiting (not admitted) -> admitted -> closing -> gone.
|
|
178
|
+
function leavePhase(state) {
|
|
179
|
+
if (state.phase === 'waiting') { waiting.delete(state); silent.delete(state); perAddress.get(state.address).waiting--; }
|
|
180
|
+
else if (state.phase === 'admitted') admitted--;
|
|
181
|
+
else if (state.phase === 'closing') closing.delete(state);
|
|
182
|
+
}
|
|
183
|
+
// Rooms are left in a microtask: an eviction inside a fan-out loop then cannot reorder its notifications.
|
|
184
|
+
function leaveRooms(state) {
|
|
185
|
+
if (state.leaving) return; state.leaving = true;
|
|
186
|
+
queueMicrotask(() => { for (const room of [...state.rooms.keys()]) leaveRoom(state, room); });
|
|
187
|
+
}
|
|
188
|
+
// Close frame now, destroyed after closeMs at most (ws closeTimeout). Closing sockets share the
|
|
189
|
+
// pending budget; over it, the oldest closing socket skips the rest of its grace period.
|
|
190
|
+
function shut(state, code, reason) {
|
|
191
|
+
if (state.closed) return;
|
|
192
|
+
leavePhase(state); state.phase = 'closing'; state.closed = true; closing.add(state);
|
|
193
|
+
if (waiting.size + closing.size > limits.maxPendingSockets) kill(first(closing));
|
|
194
|
+
if (state.phase !== 'closing') return;
|
|
195
|
+
state.ws.close(code, reason); leaveRooms(state);
|
|
196
|
+
}
|
|
197
|
+
function kill(state) { release(state); state.ws.terminate(); }
|
|
198
|
+
function release(state) {
|
|
199
|
+
if (state.phase === 'gone') return;
|
|
200
|
+
leavePhase(state); state.phase = 'gone'; state.closed = true;
|
|
201
|
+
clearTimeout(state.helloTimer); sockets.delete(state); backlog.delete(state);
|
|
202
|
+
buffered -= state.buffered; state.buffered = 0;
|
|
203
|
+
const count = perAddress.get(state.address); if (--count.all === 0) perAddress.delete(state.address);
|
|
204
|
+
leaveRooms(state);
|
|
205
|
+
}
|
|
206
|
+
function leaveRoom(state, room) {
|
|
207
|
+
const members = rooms.get(room); const peer = state.rooms.get(room);
|
|
208
|
+
state.rooms.delete(room);
|
|
209
|
+
if (!members || members.get(peer) !== state) return;
|
|
210
|
+
members.delete(peer);
|
|
211
|
+
for (const other of members.values()) emit(other, { t: 'peer', room, peer, on: false });
|
|
212
|
+
if (members.size === 0) { rooms.delete(room); stats.rooms = rooms.size; }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function admit(state, hello, request) {
|
|
216
|
+
if (options.authorize) return !!(await options.authorize(hello, request));
|
|
217
|
+
if (!options.tokenSecret) return true;
|
|
218
|
+
const a = server.address();
|
|
219
|
+
const audience = options.tokenAudience ?? `${server.setSecureContext ? 'wss' : 'ws'}://${a.address.includes(':') ? `[${a.address}]` : a.address}:${a.port}${path}`;
|
|
220
|
+
const claims = verifyGateToken(options.tokenSecret, hello.auth, { audience });
|
|
221
|
+
if (!claims) return false;
|
|
222
|
+
state.boundRoom = claims.room ?? null;
|
|
223
|
+
state.expiresAt = claims.exp * 1000;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function accept(ws, request, address) {
|
|
228
|
+
// A full pending budget sheds a closing socket, else the oldest silent one. A real client
|
|
229
|
+
// sends hello within a round trip of the upgrade, so silent sockets cannot keep it out.
|
|
230
|
+
while (waiting.size + closing.size >= limits.maxPendingSockets) {
|
|
231
|
+
const victim = first(closing) ?? first(silent); if (!victim) break;
|
|
232
|
+
stats.evicted++; kill(victim);
|
|
233
|
+
}
|
|
234
|
+
const state = { ws, address, phase: 'waiting', rooms: new Map(), boundRoom: null, expiresAt: Infinity, closed: false,
|
|
235
|
+
bytes: new Bucket(limits.burstBytes, limits.refillBytesPerSec), messages: new Bucket(limits.messageBurst, limits.messagesPerSec),
|
|
236
|
+
lastSeen: Date.now(), nonce: null, buffered: 0, helloTimer: null, leaving: false };
|
|
237
|
+
const count = perAddress.get(address) ?? perAddress.set(address, { all: 0, waiting: 0 }).get(address);
|
|
238
|
+
count.all++; count.waiting++;
|
|
239
|
+
sockets.add(state); waiting.add(state); silent.add(state); stats.socketsTotal++;
|
|
240
|
+
state.helloTimer = setTimeout(() => { if (state.phase === 'waiting') shut(state, 1008, 'hello-timeout'); }, limits.helloMs);
|
|
241
|
+
ws.on('close', () => release(state));
|
|
242
|
+
ws.on('error', () => {});
|
|
243
|
+
ws.on('ping', data => {
|
|
244
|
+
if (state.closed) return;
|
|
245
|
+
if (!state.messages.take(1)) { stats.rateLimited++; return refuse(state, 'rate', true); }
|
|
246
|
+
queue(state, data.length + 2, done => ws.pong(data, false, done));
|
|
247
|
+
});
|
|
248
|
+
ws.on('pong', data => {
|
|
249
|
+
if (state.closed) return;
|
|
250
|
+
// Only an echo of the latest heartbeat nonce shows that the socket reads what it is sent.
|
|
251
|
+
if (state.nonce?.equals(data)) { state.nonce = null; state.lastSeen = Date.now(); }
|
|
252
|
+
else if (!state.messages.take(1)) { stats.rateLimited++; refuse(state, 'rate', true); }
|
|
253
|
+
});
|
|
254
|
+
let chain = Promise.resolve(), pendingFrames = 0, pendingBytes = 0;
|
|
255
|
+
ws.on('message', (data, isBinary) => {
|
|
256
|
+
if (state.closed) return;
|
|
257
|
+
silent.delete(state);
|
|
258
|
+
// Frames queued behind an admission in progress are bounded here. Admitted sockets answer to
|
|
259
|
+
// the message and byte budgets only, so a burst of frames in one TCP read is not a flood.
|
|
260
|
+
const queued = state.phase === 'waiting';
|
|
261
|
+
if (queued && ((pendingBytes += data.length) > limits.maxPendingBytes || ++pendingFrames > limits.maxPendingFrames) || !state.messages.take(1)) {
|
|
262
|
+
stats.rateLimited++; return refuse(state, 'rate', true);
|
|
263
|
+
}
|
|
264
|
+
chain = chain.then(() => handle(state, data, isBinary, request)).catch(error => {
|
|
265
|
+
log('gate-error', { message: error?.message }); refuse(state, 'internal', true);
|
|
266
|
+
}).finally(() => { if (queued) { pendingFrames--; pendingBytes -= data.length; } });
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function handle(state, data, isBinary, request) {
|
|
271
|
+
if (state.closed) return;
|
|
272
|
+
if (Date.now() >= state.expiresAt) return refuse(state, 'auth-expired', true);
|
|
273
|
+
stats.framesIn++; stats.bytesIn += data.length; state.lastSeen = Date.now();
|
|
274
|
+
if (isBinary) return refuse(state, 'binary', true);
|
|
275
|
+
let m; try { m = JSON.parse(data.toString('utf8')); } catch { return refuse(state, 'json', true); }
|
|
276
|
+
if (!m || typeof m !== 'object' || Array.isArray(m) || typeof m.t !== 'string') return refuse(state, 'schema', true);
|
|
277
|
+
if (state.phase === 'waiting') {
|
|
278
|
+
if (m.t !== 'hello' || m.v !== GATE_PROTOCOL) return refuse(state, 'hello', true);
|
|
279
|
+
if (!(await admit(state, m, request))) return refuse(state, 'auth', true);
|
|
280
|
+
if (state.closed) return;
|
|
281
|
+
if (admitted >= limits.maxSockets) { refuse(state, 'busy'); return shut(state, 1013, 'busy'); }
|
|
282
|
+
clearTimeout(state.helloTimer); leavePhase(state); state.phase = 'admitted'; admitted++;
|
|
283
|
+
return emit(state, { t: 'welcome', v: GATE_PROTOCOL, stun: stunUrls, limits: { box: limits.box, peersPerRoom: limits.peersPerRoom } });
|
|
284
|
+
}
|
|
285
|
+
switch (m.t) {
|
|
286
|
+
case 'join': {
|
|
287
|
+
if (Object.keys(m).length !== 3 || typeof m.room !== 'string' || typeof m.peer !== 'string' || !TAG.test(m.room) || !TAG.test(m.peer)) return refuse(state, 'schema', true);
|
|
288
|
+
if (state.boundRoom && state.boundRoom !== m.room) return refuse(state, 'room-not-allowed');
|
|
289
|
+
if (state.rooms.has(m.room)) return refuse(state, 'already-joined');
|
|
290
|
+
if (state.rooms.size >= limits.roomsPerSocket) return refuse(state, 'too-many-rooms');
|
|
291
|
+
let members = rooms.get(m.room);
|
|
292
|
+
if (members?.has(m.peer)) return refuse(state, 'peer-taken');
|
|
293
|
+
if ((members?.size ?? 0) >= limits.peersPerRoom) return refuse(state, 'room-full');
|
|
294
|
+
if (!members) { members = new Map(); rooms.set(m.room, members); stats.rooms = rooms.size; }
|
|
295
|
+
const peers = [...members.keys()];
|
|
296
|
+
members.set(m.peer, state); state.rooms.set(m.room, m.peer); stats.joins++;
|
|
297
|
+
if (!emit(state, { t: 'peers', room: m.room, peers })) return;
|
|
298
|
+
for (const other of members.values()) if (other !== state && !state.closed) emit(other, { t: 'peer', room: m.room, peer: m.peer, on: true });
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
case 'leave':
|
|
302
|
+
if (Object.keys(m).length !== 2 || !state.rooms.has(m.room)) return refuse(state, 'schema');
|
|
303
|
+
return leaveRoom(state, m.room);
|
|
304
|
+
case 'send': {
|
|
305
|
+
if (Object.keys(m).length !== 4 || typeof m.box !== 'string' || m.box.length > limits.box || !BOX.test(m.box)) return refuse(state, 'schema', true);
|
|
306
|
+
const from = state.rooms.get(m.room);
|
|
307
|
+
if (!from) return refuse(state, 'not-joined');
|
|
308
|
+
const members = rooms.get(m.room);
|
|
309
|
+
const targets = m.to === '*' ? [...members.entries()].filter(([peer]) => peer !== from).map(([, s]) => s)
|
|
310
|
+
: TAG.test(m.to ?? '') && members.has(m.to) && m.to !== from ? [members.get(m.to)] : null;
|
|
311
|
+
if (!targets) return refuse(state, 'no-such-peer');
|
|
312
|
+
// Charge the sender for the full fan-out: the gate's cost is what it forwards.
|
|
313
|
+
if (!state.bytes.take(m.box.length * Math.max(1, targets.length))) { stats.rateLimited++; return refuse(state, 'rate', true); }
|
|
314
|
+
for (const target of targets) emit(target, { t: 'recv', room: m.room, from, box: m.box });
|
|
315
|
+
stats.envelopes += targets.length;
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
case 'ping': return emit(state, { t: 'pong' });
|
|
319
|
+
default: return refuse(state, 'type', true);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const heartbeat = setInterval(() => {
|
|
324
|
+
const now = Date.now();
|
|
325
|
+
for (const state of sockets) {
|
|
326
|
+
if (state.closed) continue; // closing sockets are destroyed within closeMs
|
|
327
|
+
if (now >= state.expiresAt) { refuse(state, 'auth-expired', true); continue; }
|
|
328
|
+
// An unanswered nonce means the socket did not read its last ping: unsolicited pongs do not count.
|
|
329
|
+
if (state.nonce || now - state.lastSeen > limits.idleMs) { kill(state); continue; }
|
|
330
|
+
const nonce = state.nonce = randomBytes(8);
|
|
331
|
+
queue(state, nonce.length + 2, done => state.ws.ping(nonce, false, done));
|
|
332
|
+
}
|
|
333
|
+
}, limits.pingMs);
|
|
334
|
+
heartbeat.unref?.();
|
|
335
|
+
|
|
336
|
+
if (ownServer) await new Promise((resolve, reject) => {
|
|
337
|
+
server.once('error', reject);
|
|
338
|
+
server.listen(options.port ?? 0, options.host ?? '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
server, path, stunUrls,
|
|
343
|
+
url(host) { const a = server.address(); return `ws://${host ?? (a.address.includes(':') ? `[${a.address}]` : a.address)}:${a.port}${path}`; },
|
|
344
|
+
stats() { return { ...stats, sockets: sockets.size, pending: waiting.size, buffered, stun: stunResponders.map(r => r.stats()) }; },
|
|
345
|
+
rooms() { return rooms.size; },
|
|
346
|
+
async close() {
|
|
347
|
+
clearInterval(heartbeat);
|
|
348
|
+
server.off('upgrade', onUpgrade);
|
|
349
|
+
for (const state of sockets) state.ws.terminate();
|
|
350
|
+
wss.close();
|
|
351
|
+
await Promise.all(stunResponders.map(r => r.close()));
|
|
352
|
+
if (ownServer) await new Promise(resolve => server.close(() => resolve()));
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export const randomTag = (bytes = 16) => randomBytes(bytes).toString('base64url');
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// RFC 8489 Binding responder: tells a peer which public address its UDP socket maps to.
|
|
3
|
+
// It answers Binding requests only, never relays anything, and rate-limits per source.
|
|
4
|
+
import dgram from 'node:dgram';
|
|
5
|
+
import { isStunMessage, decode, encode, encodeXorAddress, METHOD, CLASS, ATTR } from '../shared/stun.mjs';
|
|
6
|
+
|
|
7
|
+
export async function createStunResponder({ host = '0.0.0.0', port = 3478, ratePerSec = 10, burst = 30, totalRatePerSec = 200, totalBurst = 400, software = 'peerlane-gate', log = () => {} } = {}) {
|
|
8
|
+
const type = host.includes(':') ? 'udp6' : 'udp4';
|
|
9
|
+
const socket = dgram.createSocket({ type, ipv6Only: type === 'udp6' });
|
|
10
|
+
const stats = { requests: 0, responses: 0, bytesIn: 0, bytesOut: 0, dropped: 0, rateLimited: 0 };
|
|
11
|
+
const buckets = new Map();
|
|
12
|
+
const total = { level: totalBurst, at: Date.now() };
|
|
13
|
+
const allow = address => {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
total.level = Math.min(totalBurst, total.level + (now - total.at) / 1000 * totalRatePerSec); total.at = now;
|
|
16
|
+
if (total.level < 1) return false;
|
|
17
|
+
let b = buckets.get(address);
|
|
18
|
+
if (!b) {
|
|
19
|
+
if (buckets.size > 50000) buckets.delete(buckets.keys().next().value);
|
|
20
|
+
b = { level: burst, at: now }; buckets.set(address, b);
|
|
21
|
+
}
|
|
22
|
+
b.level = Math.min(burst, b.level + (now - b.at) / 1000 * ratePerSec); b.at = now;
|
|
23
|
+
if (b.level < 1) return false;
|
|
24
|
+
b.level -= 1; total.level -= 1; return true;
|
|
25
|
+
};
|
|
26
|
+
socket.on('message', (buf, rinfo) => {
|
|
27
|
+
try {
|
|
28
|
+
stats.bytesIn += buf.length;
|
|
29
|
+
if (buf.length > 548 || !isStunMessage(buf)) { stats.dropped++; return; }
|
|
30
|
+
const msg = decode(buf);
|
|
31
|
+
if (!msg || msg.method !== METHOD.BINDING || msg.cls !== CLASS.REQUEST) { stats.dropped++; return; }
|
|
32
|
+
if (!allow(rinfo.address)) { stats.rateLimited++; return; }
|
|
33
|
+
stats.requests++;
|
|
34
|
+
const mapped = rinfo.address.startsWith('::ffff:') && rinfo.address.includes('.')
|
|
35
|
+
? { family: 4, address: rinfo.address.slice(7), port: rinfo.port }
|
|
36
|
+
: { family: rinfo.family === 'IPv6' ? 6 : 4, address: rinfo.address, port: rinfo.port };
|
|
37
|
+
const out = encode({ method: METHOD.BINDING, cls: CLASS.SUCCESS, transactionId: msg.transactionId, attributes: [
|
|
38
|
+
{ type: ATTR.XOR_MAPPED_ADDRESS, value: encodeXorAddress(mapped, msg.transactionId) },
|
|
39
|
+
{ type: ATTR.SOFTWARE, value: Buffer.from(software) }
|
|
40
|
+
] }, { fingerprint: true });
|
|
41
|
+
socket.send(out, rinfo.port, rinfo.address);
|
|
42
|
+
stats.responses++; stats.bytesOut += out.length;
|
|
43
|
+
} catch (error) { stats.dropped++; log('stun-error', { message: error?.message }); }
|
|
44
|
+
});
|
|
45
|
+
socket.on('error', error => log('stun-socket-error', { message: error?.message }));
|
|
46
|
+
await new Promise((resolve, reject) => { socket.once('error', reject); socket.bind(port, host, () => { socket.off('error', reject); resolve(); }); });
|
|
47
|
+
return {
|
|
48
|
+
address: () => socket.address(),
|
|
49
|
+
stats: () => ({ ...stats }),
|
|
50
|
+
close: () => new Promise(resolve => { try { socket.close(resolve); } catch { resolve(); } })
|
|
51
|
+
};
|
|
52
|
+
}
|