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 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 it: 1013 busy) |
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
- var Q = { rate: 1, every: 0, ask: 1, net: 1, dev: 1, lateMs: 0, fps: 0, rtt: 0 };
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
- connect(conn: Conn, addr: string): Session | {
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
- connect(conn, addr) {
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;