tickcli-js 0.1.1 → 0.1.3

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.3
4
+
5
+ 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).
6
+
7
+ ## 0.1.2
8
+
9
+ 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.
10
+
3
11
  ## 0.1.1
4
12
 
5
13
  A "Leave" button in the top bar of multiplayer games: out of the room at any time (mid-match with others, a second tap within 3 s confirms) into a new private room of your own, and the seat is freed at once. With `Game({ exit: "/" })` or `<meta name="tickcli-exit" content="/">` it reads "Exit" and goes to that URL. Fix: after leaving, a page opened from an invite link (`#room=CODE`) no longer goes back to that room.
package/README.md CHANGED
@@ -112,6 +112,7 @@ Game({
112
112
  | Server | membership, host, order, match number, clock | - |
113
113
 
114
114
  - Writes merge deeply: `{hp: {a: 80}}` changes one entry, `null` removes a key, lists are replaced whole. Values that did not change are not sent (room.js and the hub both drop them), so writing a whole object every step is fine; a write that changes nothing sends nothing.
115
+ - Top speed: a third number in a player field's range caps how fast it moves, `player: { x: [0, 800, 300] }` = at most 300 a second. The hub cuts a bigger jump to that distance (10% headroom, measured on the senders' sample times), so a speed hack or teleport shows as normal running to everyone else and to the host's rules; the writer is not refused. A new match lets each player jump once (to a start spot); a respawn mid-match shows as a quick run. Leave it out for fields that may jump.
115
116
  - Things the host moves (monsters, minions, shots) ease smoothly on every other screen when their position fields are named `x`, `y` (also `z`, `angle`, `rot`, and a quaternion `qx, qy, qz, qw`). Players ease the same way. Others are drawn in the past by a delay measured per room (mean delay of the last 40 updates + 2 send intervals + its spread, 100 to 500 ms; usually 200 to 400), at their senders' sample times; the drawing clock follows it by running 2% fast or 4% slow; a late update: the last speed for 100 ms, then hold. Measured through the real code: 0% jump frames, speed within 1%, for 4 and 16 players with 30 ms jitter.
116
117
  - Timers are end times on the shared clock: `g.shared.set({endsAt: g.now + 60})`. The clock: 4 pings 250 ms apart after connecting, then one every 5 s; the offset of the fastest of the last 8 (none older than 60 s) is used, reached over a second, never in one step.
117
118
  - `g.shared` is read-only all the way down (frozen): a write into it throws in strict code and does nothing otherwise, and `list.sort()` on a list in it throws, so copy first (`g.shared.list.slice().sort()`). The page never copies the whole state: each write makes new objects only along the paths it changes, so a frame costs the same at 3 KB or 60 KB (0.07 ms at 60 KB with 32 moving things, was 1.4 ms). `structuredClone(g.shared)` works (plain frozen objects).
@@ -261,6 +262,14 @@ Or in your own server: `createRemote({ hub, deployment })` gives `upgrade(req, s
261
262
  - A refused page stops and shows why ("This site is not allowed for deployment dep_ab12.").
262
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.
263
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
+
264
273
  ## Protocol (version 1)
265
274
 
266
275
  Every message is JSON with `v: 1`.
@@ -308,8 +317,10 @@ Saving needs a host app that gives the hub a storage port (`new RoomHub({ saves
308
317
  |---|---|
309
318
  | Players per room | 16 |
310
319
  | Rooms on the server / per namespace | 500 / 50 |
311
- | Sockets on the server / unverified / per address | 2000 / 200 / 20 |
312
- | 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 |
313
324
  | Joins per address | 30 a minute |
314
325
  | Message / player state / shared state | 16 KB / 2 KB / 64 KB (the client splits bigger host updates) |
315
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/game.js CHANGED
@@ -36,7 +36,7 @@
36
36
  if (g.state === "playing") g.me.set({x: hero.x, y: hero.y});
37
37
  g.sync(function (p) { return addSprite(p); }, function (s, st) { s.x = st.x; s.y = st.y; }, function (s) { s.destroy(); });
38
38
 
39
- Playing together (also load room.js before this file): pass multiplayer: {max: 4, player: {x: [0, 800], y: [0, 600]}}.
39
+ Playing together (also load room.js before this file): pass multiplayer: {max: 4, player: {x: [0, 800], y: [0, 600]}}. A third number is a top speed per second (x: [0, 800, 300]): the server cuts faster moves to it.
40
40
  The shell then shows the lobby (room code, invite link, players, Start for the host), reconnects, and shows notices. Your code:
41
41
  update(g, dt) every player: move yourself, g.me.set({x, y}) (sent 12 times a second at most, clamped to the declared ranges)
42
42
  host(g, dt) runs on ONE player only (the host): rounds, timers, health, score, who won: g.shared.set({...}); g.gameOver() ends the match
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;
@@ -88,14 +92,17 @@ export interface Who {
88
92
  name: string;
89
93
  guest: boolean;
90
94
  }
91
- /** The page's declaration: room size and the number range of each player field. */
95
+ /** The page's declaration: room size and the number range of each player field, optionally with its top speed: [lo, hi] or [lo, hi, maxPerSecond]. */
92
96
  /** saveVersion: the game's save format (whole number, default 1); a page edited to read another shape raises it (README: saved games). */
97
+ export type Range = [number, number] | [number, number, number];
93
98
  export interface Spec {
94
99
  max: number;
95
- player: Record<string, [number, number]>;
100
+ player: Record<string, Range>;
96
101
  save: boolean;
97
102
  saveVersion: number;
98
103
  }
104
+ /** Headroom on a declared top speed, so an honest player moving at full speed whose update was clamped once catches up again. */
105
+ export declare const SPEED_SLACK = 1.1;
99
106
  export interface PlayerView {
100
107
  id: string;
101
108
  name: string;
@@ -161,6 +168,11 @@ interface Seat {
161
168
  profAt: number;
162
169
  /** JSON size of state, kept up to date by each write (an exact count near the limit and every 256 writes) */
163
170
  stateBytes: number;
171
+ /** fields with a top speed: the last value let through and its sample time (kept when the field is removed, so null then a far value is no teleport) */
172
+ moved?: Map<string, {
173
+ v: number;
174
+ t: number;
175
+ }>;
164
176
  }
165
177
  interface Room {
166
178
  key: string;
@@ -427,6 +439,7 @@ export declare class RoomHub {
427
439
  private sessions;
428
440
  pending: number;
429
441
  private perAddr;
442
+ private addrHits;
430
443
  private joins;
431
444
  private refused;
432
445
  private timer;
@@ -489,10 +502,16 @@ export declare class RoomHub {
489
502
  private refuse;
490
503
  private count;
491
504
  /** A new socket. Null = refused (the caller closes it with the code given). */
492
- 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 | {
493
509
  refused: number;
494
510
  reason: string;
495
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;
496
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). */
497
516
  sent(len: number, n: number, room?: Room | null): void;
498
517
  receive(s: Session, text: string): void;
@@ -515,6 +534,10 @@ export declare class RoomHub {
515
534
  private alive;
516
535
  private vis;
517
536
  private me;
537
+ /** Top speeds (a third number in a field's range): a declared field moves at most speed x the time since its last update (sample times, see stamp),
538
+ * with SPEED_SLACK headroom. A bigger jump is cut to that distance in the same direction: the others see the player run, not teleport, and the
539
+ * writer is not refused (a game that moves a player to a far spot, a respawn, shows a quick run there). No top speed declared: no change. */
540
+ private speedCap;
518
541
  private sharedWrite;
519
542
  /** The sender's sample time of a write (its shared clock, whole ms), trusted within 500 ms back and 50 ms ahead; else now. Pages draw others
520
543
  * at these times, so updates sent 80 ms apart are drawn 80 ms apart whatever the fan-out tick did to them. */
package/dist/hub.js CHANGED
@@ -19,11 +19,24 @@ 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
+ }
38
+ /** Headroom on a declared top speed, so an honest player moving at full speed whose update was clamped once catches up again. */
39
+ export const SPEED_SLACK = 1.1;
27
40
  const CODE_ABC = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"; // no 0/O, 1/I/L
28
41
  const CODE_RE = /^[A-HJ-NP-Z2-9]{4,6}$/;
29
42
  const NAME_RE = /^[a-z_][a-z0-9_-]{0,23}$/i; // a leading underscore is the game shell's own (_team, _seat, _shot)
@@ -87,12 +100,14 @@ export function parseSpec(raw, maxPlayers = DEFAULT_LIMITS.maxPlayers) {
87
100
  return null;
88
101
  for (const k of keys) {
89
102
  const range = p[k];
90
- if (!NAME_RE.test(k) || BAD_KEYS.has(k) || !Array.isArray(range) || range.length !== 2)
103
+ if (!NAME_RE.test(k) || BAD_KEYS.has(k) || !Array.isArray(range) || (range.length !== 2 && range.length !== 3))
91
104
  return null;
92
- const [lo, hi] = range.map(Number);
105
+ const [lo, hi, speed] = range.map(Number);
93
106
  if (!Number.isFinite(lo) || !Number.isFinite(hi) || lo >= hi || Math.abs(lo) > 1e9 || Math.abs(hi) > 1e9)
94
107
  return null;
95
- player[k] = [lo, hi];
108
+ if (range.length === 3 && (!Number.isFinite(speed) || speed <= 0 || speed > 1e9))
109
+ return null;
110
+ player[k] = range.length === 3 ? [lo, hi, speed] : [lo, hi];
96
111
  }
97
112
  return { max, player, save: r.save === true, saveVersion: sv };
98
113
  }
@@ -333,6 +348,7 @@ export class RoomHub {
333
348
  sessions = new Set();
334
349
  pending = 0;
335
350
  perAddr = new Map();
351
+ addrHits = new Map();
336
352
  joins = new Map();
337
353
  refused = {};
338
354
  timer = null;
@@ -391,14 +407,25 @@ export class RoomHub {
391
407
  refuse(why) { this.refused[why] = (this.refused[why] || 0) + 1; }
392
408
  count(what, n = 1) { this.counts[what] = (this.counts[what] || 0) + n; }
393
409
  /** A new socket. Null = refused (the caller closes it with the code given). */
394
- 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 = {}) {
395
412
  const L = this.limits;
413
+ const addr = addressKey(address);
396
414
  if (this.closing)
397
415
  return { refused: CLOSE.restart, reason: "server restarting" };
398
416
  if (this.sessions.size >= L.maxConnections) {
399
417
  this.refuse("connections");
400
418
  return { refused: CLOSE.full, reason: "server is full" };
401
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
+ }
402
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
403
430
  if (this.pending >= L.maxPending) {
404
431
  this.refuse("pending");
@@ -413,16 +440,29 @@ export class RoomHub {
413
440
  this.refuse("accept_rate");
414
441
  return { refused: CLOSE.busy, reason: "server is busy, try again" };
415
442
  }
416
- if ((this.perAddr.get(addr) || 0) >= L.maxPerAddress) {
417
- this.refuse("address");
418
- return { refused: CLOSE.tooFast, reason: "too many connections" };
419
- }
420
443
  const s = new Session(this, conn, addr);
421
444
  this.sessions.add(s);
422
445
  this.pending++;
423
446
  this.perAddr.set(addr, (this.perAddr.get(addr) || 0) + 1);
424
447
  return s;
425
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
+ }
426
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). */
427
467
  sent(len, n, room) {
428
468
  this.meter.framesOut += n;
@@ -817,7 +857,8 @@ export class RoomHub {
817
857
  if (range && v !== null && (typeof v !== "number" || v < range[0] || v > range[1]))
818
858
  return this.err(s, "out_of_range", k);
819
859
  }
820
- const ch = changedOnly(seat.state, p);
860
+ const at = this.stamp(m.c);
861
+ const ch = changedOnly(seat.state, this.speedCap(room, seat, p, at));
821
862
  if (!ch)
822
863
  return; // nothing changed: nothing applied, counted or sent
823
864
  const size = this.sized(seat.stateBytes + sizeDelta(seat.state, ch), this.limits.playerStateBytes, () => applyPatch(seat.state, ch), room);
@@ -829,7 +870,33 @@ export class RoomHub {
829
870
  const pend = room.pendPlayers.get(seat.id) ?? {};
830
871
  joinPatch(pend, ch);
831
872
  room.pendPlayers.set(seat.id, pend);
832
- room.pendPT.set(seat.id, this.stamp(m.c));
873
+ room.pendPT.set(seat.id, at);
874
+ }
875
+ /** Top speeds (a third number in a field's range): a declared field moves at most speed x the time since its last update (sample times, see stamp),
876
+ * with SPEED_SLACK headroom. A bigger jump is cut to that distance in the same direction: the others see the player run, not teleport, and the
877
+ * writer is not refused (a game that moves a player to a far spot, a respawn, shows a quick run there). No top speed declared: no change. */
878
+ speedCap(room, seat, p, at) {
879
+ let out = p;
880
+ for (const [k, v] of Object.entries(p)) {
881
+ const speed = room.spec.player[k]?.[2];
882
+ if (speed === undefined || typeof v !== "number")
883
+ continue;
884
+ const moved = (seat.moved ??= new Map());
885
+ const last = moved.get(k);
886
+ let w = v;
887
+ if (last) {
888
+ const reach = speed * SPEED_SLACK * Math.max(0, at - last.t) / 1000;
889
+ if (Math.abs(v - last.v) > reach) {
890
+ w = last.v + Math.sign(v - last.v) * reach;
891
+ if (out === p)
892
+ out = { ...p };
893
+ out[k] = w;
894
+ this.refuse("speed");
895
+ }
896
+ }
897
+ moved.set(k, { v: w, t: Math.max(at, last?.t ?? at) });
898
+ }
899
+ return out;
833
900
  }
834
901
  sharedWrite(s, room, seat, m) {
835
902
  if (room.host !== seat)
@@ -942,6 +1009,8 @@ export class RoomHub {
942
1009
  beginMatch(room, resume) {
943
1010
  room.startWait = null;
944
1011
  room.noSave = false;
1012
+ for (const seat of room.seats.values())
1013
+ seat.moved = undefined; // a new match: players may jump to their start spots once
945
1014
  if (resume && room.saved) {
946
1015
  // Continue: the saved world becomes the shared state (the shell's clock key excepted); the game's setup must not overwrite it
947
1016
  this.flush(room);