tickcli-js 0.1.2 → 0.1.4
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/CHANGELOG.md +8 -0
- package/README.md +12 -2
- package/client/room.js +26 -2
- package/dist/hub.d.ts +12 -1
- package/dist/hub.js +42 -6
- package/dist/hub.js.map +1 -1
- package/dist/node.d.ts +11 -0
- package/dist/node.js +10 -1
- package/dist/node.js.map +1 -1
- package/dist/remote.d.ts +6 -0
- package/dist/remote.js +47 -9
- package/dist/remote.js.map +1 -1
- package/dist/serve.js +7 -4
- package/dist/serve.js.map +1 -1
- package/package.json +3 -3
- package/src/hub.ts +35 -4
- package/src/node.ts +14 -1
- package/src/remote.ts +38 -9
- package/src/serve.ts +6 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.4
|
|
4
|
+
|
|
5
|
+
Idle pages send less. A visible page with no key, pointer, touch or gamepad input for 1 min asks the hub for a quarter of the update frames (nothing is lost, changes are merged into each frame); the first input brings the full rate back. In a 4-player test room the idle reader's download fell from 4.4 to 1.2 KB/s. Hidden pages already got no frames.
|
|
6
|
+
|
|
7
|
+
## 0.1.3
|
|
8
|
+
|
|
9
|
+
Flood limits. New sockets per client address (`connectsPerAddress`, 40 per 10 s) are checked before the server-wide accept budget, so one client can no longer spend it for everyone; IPv6 addresses count by their /64 (`addressKey`) for every per-address limit. `hardenServer(server)` in `tickcli-js/node` (used by `createGameServer` and `createRemoteServer`): request headers within 10 s, whole request 15 s, idle keep-alive 5 s, 100 headers. Remote: a deployment is looked up at most once per 15 s per id, found or not (`remote.forget(id)` drops one after an edit), fresh lookups are capped per address, and an upgrade over the address budget is refused before any lookup. `ws` 8.23.0 or later (GHSA-37g9-w4hg-h9qr, ping flood).
|
|
10
|
+
|
|
3
11
|
## 0.1.2
|
|
4
12
|
|
|
5
13
|
Top speed for player fields: a third number in a range (`player: { x: [0, 800, 300] }`) caps how far the field moves per second. The hub cuts a faster move to that distance (10% headroom, senders' sample times) instead of refusing it, so speed hacks and teleports look like normal running to the others and to the host; removing a field first does not reset it; a new match allows one free jump. Ranges without the third number work as before.
|
package/README.md
CHANGED
|
@@ -262,6 +262,14 @@ Or in your own server: `createRemote({ hub, deployment })` gives `upgrade(req, s
|
|
|
262
262
|
- A refused page stops and shows why ("This site is not allowed for deployment dep_ab12.").
|
|
263
263
|
- Not a guard: a script outside a browser can send any `Origin`. The caps, the per-address limits and revoking the id are what stop a misused deployment.
|
|
264
264
|
|
|
265
|
+
### Floods and DDoS
|
|
266
|
+
|
|
267
|
+
The hub and adapters cap what one client can cost (table below: sockets and new sockets per address, messages, bytes, lookups, slow headers). Keep `ws` at 8.23.0 or later (a peer that floods pings while not reading exhausted memory in earlier versions, GHSA-37g9-w4hg-h9qr); `WS_OPTIONS` keeps compression off and messages at 16 KB (ws's own default is 100 MiB). What the server cannot do alone:
|
|
268
|
+
|
|
269
|
+
- Volume (a full uplink, SYN floods): the host's network filter. Most VPS hosts include one; turn on the game or always-on profile if offered.
|
|
270
|
+
- Before Node: a per-address limit on new TCP connections in the firewall, generous enough for a school or office behind one address, e.g. nftables `tcp dport 443 ct state new meter tcli4 { ip saddr limit rate over 30/second burst 60 packets } drop` (and the same with `ip6 saddr`).
|
|
271
|
+
- In the proxy: short header timeouts (Caddy `servers { timeouts { read_header 5s } }`). Behind a proxy set `trustProxy` so limits count players, not the proxy.
|
|
272
|
+
|
|
265
273
|
## Protocol (version 1)
|
|
266
274
|
|
|
267
275
|
Every message is JSON with `v: 1`.
|
|
@@ -309,8 +317,10 @@ Saving needs a host app that gives the hub a storage port (`new RoomHub({ saves
|
|
|
309
317
|
|---|---|
|
|
310
318
|
| Players per room | 16 |
|
|
311
319
|
| Rooms on the server / per namespace | 500 / 50 |
|
|
312
|
-
| Sockets on the server / unverified / per address | 2000 / 200 / 20 |
|
|
313
|
-
| New sockets | 200 a second (over
|
|
320
|
+
| Sockets on the server / unverified / per address | 2000 / 200 / 20 (an IPv6 address counts by its /64, `addressKey`) |
|
|
321
|
+
| New sockets | 40 per address per 10 s (`connectsPerAddress`, checked first, so one client never spends the shared budget), then 200 a second for the whole server (over either: 1013 busy) |
|
|
322
|
+
| HTTP (serve, remote) | request headers within 10 s, whole request 15 s, idle keep-alive 5 s, 100 headers (`hardenServer`; Node's defaults are 60 s, 300 s, 5 s, 2000) |
|
|
323
|
+
| Deployment lookups (remote) | each id at most once per 15 s, found or not (`LOOKUP_MS`; `remote.forget(id)` after an edit); fresh lookups 20 per address per 10 s |
|
|
314
324
|
| Joins per address | 30 a minute |
|
|
315
325
|
| Message / player state / shared state | 16 KB / 2 KB / 64 KB (the client splits bigger host updates) |
|
|
316
326
|
| Shape | plain JSON, depth 4, 64 keys per object, 64 items per list, text 200 characters, keys 40 characters (`SHAPE`); a write that breaks one is refused whole |
|
package/client/room.js
CHANGED
|
@@ -216,15 +216,39 @@
|
|
|
216
216
|
// and how fast the game draws (game.js reports frames a second). When either is bad for 2 s the page asks for half the rate (then a quarter);
|
|
217
217
|
// after 15 s of good readings it asks one step back. The hub uses the slower of its reading and the page's ask and says which (rate {div}).
|
|
218
218
|
// navigator.connection (Chrome, Edge, Android) only sets the starting point: saveData or 3g = half, 2g = a quarter.
|
|
219
|
-
|
|
219
|
+
// Idle: a visible page with no key, pointer, touch or gamepad input for IDLE_MS = 1 min (someone who walked away; a short pause or a turn-based wait keeps the full rate) asks a quarter of
|
|
220
|
+
// the frames; the first input brings the full rate back. A hidden page already gets none (see the hub's vis).
|
|
221
|
+
var Q = { rate: 1, every: 0, ask: 1, net: 1, dev: 1, idle: 1, lateMs: 0, fps: 0, rtt: 0 };
|
|
220
222
|
var late = [], lateBase = [], badSince = 0, goodSince = 0, devBad = 0, devGood = 0, askedAt = 0;
|
|
223
|
+
var IDLE_MS = 60000, lastInput = Date.now();
|
|
224
|
+
function touched() {
|
|
225
|
+
lastInput = Date.now();
|
|
226
|
+
if (Q.idle > 1) { Q.idle = 1; askRate(); }
|
|
227
|
+
}
|
|
228
|
+
function idleCheck() {
|
|
229
|
+
if (typeof document !== "undefined" && document.hidden) return;
|
|
230
|
+
try {
|
|
231
|
+
var gp = navigator.getGamepads && navigator.getGamepads();
|
|
232
|
+
for (var g = 0; gp && g < gp.length; g++) {
|
|
233
|
+
var pad = gp[g]; if (!pad) continue;
|
|
234
|
+
var i; for (i = 0; i < pad.buttons.length; i++) if (pad.buttons[i].pressed) return touched();
|
|
235
|
+
for (i = 0; i < pad.axes.length; i++) if (Math.abs(pad.axes[i]) > 0.3) return touched();
|
|
236
|
+
}
|
|
237
|
+
} catch (e) { /* no gamepads */ }
|
|
238
|
+
if (Q.idle === 1 && Date.now() - lastInput >= IDLE_MS) { Q.idle = 4; askRate(); }
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
["pointerdown", "pointermove", "keydown", "touchstart", "wheel"].forEach(function (ev) { addEventListener(ev, touched, { capture: true, passive: true }); });
|
|
242
|
+
addEventListener("visibilitychange", function () { if (typeof document === "undefined" || !document.hidden) touched(); });
|
|
243
|
+
} catch (e) { /* no window events */ }
|
|
244
|
+
setInterval(idleCheck, 2000);
|
|
221
245
|
try {
|
|
222
246
|
var nc = navigator.connection;
|
|
223
247
|
if (nc) Q.net = nc.effectiveType === "slow-2g" || nc.effectiveType === "2g" ? 4 : nc.saveData || nc.effectiveType === "3g" ? 2 : 1;
|
|
224
248
|
} catch (e) { /* no Network Information API */ }
|
|
225
249
|
Q.ask = Q.net;
|
|
226
250
|
function askRate() {
|
|
227
|
-
var want = Math.max(Q.net, Q.dev);
|
|
251
|
+
var want = Math.max(Q.net, Q.dev, Q.idle);
|
|
228
252
|
if (want === Q.ask) return;
|
|
229
253
|
Q.ask = want; askedAt = Date.now();
|
|
230
254
|
send({ t: "rate", div: want });
|
package/dist/hub.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface Limits {
|
|
|
22
22
|
maxConnections: number;
|
|
23
23
|
maxPending: number;
|
|
24
24
|
maxPerAddress: number;
|
|
25
|
+
connectsPerAddress: number;
|
|
25
26
|
joinsPerMinute: number;
|
|
26
27
|
messageBytes: number;
|
|
27
28
|
playerStateBytes: number;
|
|
@@ -55,6 +56,9 @@ export interface Limits {
|
|
|
55
56
|
saveDays: number;
|
|
56
57
|
}
|
|
57
58
|
export declare const DEFAULT_LIMITS: Limits;
|
|
59
|
+
/** The key one client is counted by: an IPv4 address as is, an IPv6 address by its first 64 bits (a home, phone or server is handed a whole /64,
|
|
60
|
+
* so per-address limits on full IPv6 addresses would not hold anyone). IPv4-mapped IPv6 counts as the IPv4 address. */
|
|
61
|
+
export declare function addressKey(addr: string): string;
|
|
58
62
|
/** One open socket, as the hub sees it. */
|
|
59
63
|
export interface Conn {
|
|
60
64
|
send(data: string): void;
|
|
@@ -435,6 +439,7 @@ export declare class RoomHub {
|
|
|
435
439
|
private sessions;
|
|
436
440
|
pending: number;
|
|
437
441
|
private perAddr;
|
|
442
|
+
private addrHits;
|
|
438
443
|
private joins;
|
|
439
444
|
private refused;
|
|
440
445
|
private timer;
|
|
@@ -497,10 +502,16 @@ export declare class RoomHub {
|
|
|
497
502
|
private refuse;
|
|
498
503
|
private count;
|
|
499
504
|
/** A new socket. Null = refused (the caller closes it with the code given). */
|
|
500
|
-
|
|
505
|
+
/** `counted`: the adapter already spent this socket's allowAddress before its upgrade, so it is not counted twice. */
|
|
506
|
+
connect(conn: Conn, address: string, o?: {
|
|
507
|
+
counted?: boolean;
|
|
508
|
+
}): Session | {
|
|
501
509
|
refused: number;
|
|
502
510
|
reason: string;
|
|
503
511
|
};
|
|
512
|
+
/** New-socket budget of one address (addressKey), connectsPerAddress per 10 s. Adapters may call it before costly work on an upgrade (a lookup):
|
|
513
|
+
* false = refuse now. The window map is pruned when it grows, so random addresses cannot grow it without end. */
|
|
514
|
+
allowAddress(address: string): boolean;
|
|
504
515
|
/** Meter: `n` copies of a text of `len` bytes (UTF-8, as on the wire) went out (to one room's sockets when given). */
|
|
505
516
|
sent(len: number, n: number, room?: Room | null): void;
|
|
506
517
|
receive(s: Session, text: string): void;
|
package/dist/hub.js
CHANGED
|
@@ -19,11 +19,22 @@ export const CLOSE = {
|
|
|
19
19
|
restart: 1012, roomError: 1011, busy: 1013, silent: 4009, notAllowed: 4001, full: 4002, replaced: 4003, tooFast: 4004, badMessage: 4005, removed: 4006, gameUpdated: 4007, tooSlow: 4008, idle: 4010,
|
|
20
20
|
};
|
|
21
21
|
export const DEFAULT_LIMITS = {
|
|
22
|
-
maxPlayers: 16, maxRooms: 500, maxRoomsPerScope: 50, maxConnections: 2000, maxPending: 200, maxPerAddress: 20, joinsPerMinute: 30,
|
|
22
|
+
maxPlayers: 16, maxRooms: 500, maxRoomsPerScope: 50, maxConnections: 2000, maxPending: 200, maxPerAddress: 20, connectsPerAddress: 40, joinsPerMinute: 30,
|
|
23
23
|
messageBytes: 16 * 1024, playerStateBytes: 2048, sharedStateBytes: 64 * 1024, stateRate: 50, sharedRate: 20, actionRate: 5, timeRate: 2,
|
|
24
24
|
pendingMs: 5000, seatHoldMs: 30_000, evictAfterMs: 10_000, awayHoldMs: 300_000, hostSilenceMs: 5000, silentMs: 15_000, acceptRate: 200, roomOutBytes: 250 * 1024, maxOutMbit: 100, actionTimeoutMs: 5000, slowBytes: 64 * 1024, dropBytes: 256 * 1024, rateBytes: 16 * 1024, rateUpMs: 10_000, listRate: 1, idleMs: 15 * 60_000, idleWarnMs: 60_000, listIdleMs: 5 * 60_000, tickMs: 50,
|
|
25
25
|
profileBytes: 16 * 1024, profileEveryMs: 3000, worldEveryMs: 5000, saveDays: 90,
|
|
26
26
|
};
|
|
27
|
+
/** The key one client is counted by: an IPv4 address as is, an IPv6 address by its first 64 bits (a home, phone or server is handed a whole /64,
|
|
28
|
+
* so per-address limits on full IPv6 addresses would not hold anyone). IPv4-mapped IPv6 counts as the IPv4 address. */
|
|
29
|
+
export function addressKey(addr) {
|
|
30
|
+
const a = String(addr || "?").toLowerCase().replace(/^::ffff:(?=\d+\.)/, "").replace(/%.*$/, "");
|
|
31
|
+
if (!a.includes(":"))
|
|
32
|
+
return a;
|
|
33
|
+
const [head, tail = ""] = a.split("::");
|
|
34
|
+
const h = head ? head.split(":") : [], t = tail ? tail.split(":") : [];
|
|
35
|
+
const groups = a.includes("::") ? [...h, ...Array(Math.max(0, 8 - h.length - t.length)).fill("0"), ...t] : h;
|
|
36
|
+
return `${groups.slice(0, 4).map((g) => (g || "0").replace(/^0+(?=.)/, "")).join(":")}::/64`;
|
|
37
|
+
}
|
|
27
38
|
/** Headroom on a declared top speed, so an honest player moving at full speed whose update was clamped once catches up again. */
|
|
28
39
|
export const SPEED_SLACK = 1.1;
|
|
29
40
|
const CODE_ABC = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I/L
|
|
@@ -337,6 +348,7 @@ export class RoomHub {
|
|
|
337
348
|
sessions = new Set();
|
|
338
349
|
pending = 0;
|
|
339
350
|
perAddr = new Map();
|
|
351
|
+
addrHits = new Map();
|
|
340
352
|
joins = new Map();
|
|
341
353
|
refused = {};
|
|
342
354
|
timer = null;
|
|
@@ -395,14 +407,25 @@ export class RoomHub {
|
|
|
395
407
|
refuse(why) { this.refused[why] = (this.refused[why] || 0) + 1; }
|
|
396
408
|
count(what, n = 1) { this.counts[what] = (this.counts[what] || 0) + n; }
|
|
397
409
|
/** A new socket. Null = refused (the caller closes it with the code given). */
|
|
398
|
-
|
|
410
|
+
/** `counted`: the adapter already spent this socket's allowAddress before its upgrade, so it is not counted twice. */
|
|
411
|
+
connect(conn, address, o = {}) {
|
|
399
412
|
const L = this.limits;
|
|
413
|
+
const addr = addressKey(address);
|
|
400
414
|
if (this.closing)
|
|
401
415
|
return { refused: CLOSE.restart, reason: "server restarting" };
|
|
402
416
|
if (this.sessions.size >= L.maxConnections) {
|
|
403
417
|
this.refuse("connections");
|
|
404
418
|
return { refused: CLOSE.full, reason: "server is full" };
|
|
405
419
|
}
|
|
420
|
+
// one address first: a single client flooding new sockets is cut here and never spends the server-wide budgets below that everyone shares
|
|
421
|
+
if ((this.perAddr.get(addr) || 0) >= L.maxPerAddress) {
|
|
422
|
+
this.refuse("address");
|
|
423
|
+
return { refused: CLOSE.tooFast, reason: "too many connections" };
|
|
424
|
+
}
|
|
425
|
+
if (!o.counted && !this.allowAddress(addr)) {
|
|
426
|
+
this.refuse("address_rate");
|
|
427
|
+
return { refused: CLOSE.busy, reason: "too many new connections, try again" };
|
|
428
|
+
}
|
|
406
429
|
// busy (not full): the page tries again with its normal backoff, so a reconnect wave after a restart spreads out instead of ending on a "full" screen
|
|
407
430
|
if (this.pending >= L.maxPending) {
|
|
408
431
|
this.refuse("pending");
|
|
@@ -417,16 +440,29 @@ export class RoomHub {
|
|
|
417
440
|
this.refuse("accept_rate");
|
|
418
441
|
return { refused: CLOSE.busy, reason: "server is busy, try again" };
|
|
419
442
|
}
|
|
420
|
-
if ((this.perAddr.get(addr) || 0) >= L.maxPerAddress) {
|
|
421
|
-
this.refuse("address");
|
|
422
|
-
return { refused: CLOSE.tooFast, reason: "too many connections" };
|
|
423
|
-
}
|
|
424
443
|
const s = new Session(this, conn, addr);
|
|
425
444
|
this.sessions.add(s);
|
|
426
445
|
this.pending++;
|
|
427
446
|
this.perAddr.set(addr, (this.perAddr.get(addr) || 0) + 1);
|
|
428
447
|
return s;
|
|
429
448
|
}
|
|
449
|
+
/** New-socket budget of one address (addressKey), connectsPerAddress per 10 s. Adapters may call it before costly work on an upgrade (a lookup):
|
|
450
|
+
* false = refuse now. The window map is pruned when it grows, so random addresses cannot grow it without end. */
|
|
451
|
+
allowAddress(address) {
|
|
452
|
+
const key = addressKey(address), now = this.now();
|
|
453
|
+
let w = this.addrHits.get(key);
|
|
454
|
+
if (!w || now - w.at >= 10_000) {
|
|
455
|
+
if (this.addrHits.size >= 50_000)
|
|
456
|
+
for (const [k, v] of this.addrHits)
|
|
457
|
+
if (now - v.at >= 10_000)
|
|
458
|
+
this.addrHits.delete(k);
|
|
459
|
+
if (this.addrHits.size >= 50_000)
|
|
460
|
+
return false; // 50,000 addresses inside 10 s: refuse newcomers until the window turns
|
|
461
|
+
w = { at: now, n: 0 };
|
|
462
|
+
this.addrHits.set(key, w);
|
|
463
|
+
}
|
|
464
|
+
return ++w.n <= this.limits.connectsPerAddress;
|
|
465
|
+
}
|
|
430
466
|
/** Meter: `n` copies of a text of `len` bytes (UTF-8, as on the wire) went out (to one room's sockets when given). */
|
|
431
467
|
sent(len, n, room) {
|
|
432
468
|
this.meter.framesOut += n;
|