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/src/remote.ts CHANGED
@@ -19,8 +19,8 @@ import crypto from "node:crypto";
19
19
  import type { Duplex } from "node:stream";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { WebSocketServer, type WebSocket } from "ws";
22
- import { CLOSE, RoomHub, type Limits, type SavePort } from "./hub.ts";
23
- import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash } from "./node.ts";
22
+ import { CLOSE, RoomHub, addressKey, type Limits, type SavePort } from "./hub.ts";
23
+ import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, hardenServer } from "./node.ts";
24
24
 
25
25
  export interface Deployment {
26
26
  /** public id, as in the page's script tag: letters, digits, "_" and "-", 3 to 64 */
@@ -59,6 +59,8 @@ export interface Remote {
59
59
  request(req: http.IncomingMessage, res: http.ServerResponse): boolean;
60
60
  /** Turn a deployment off now: its open rooms close (saves are written first) and its cached settings are dropped. */
61
61
  revoke(id: string): void;
62
+ /** Drop the cached settings of one deployment (after its sites or caps changed) without closing its rooms. Unchanged ones refresh every LOOKUP_MS. */
63
+ forget(id: string): void;
62
64
  /** The hub namespace of a deployment (for hub.scopeStats, hub.closeScope). */
63
65
  scope(id: string): string;
64
66
  /** The rooms cap the hub should use for a namespace (pass to new RoomHub({ roomsPerScope })). */
@@ -68,6 +70,10 @@ export interface Remote {
68
70
  }
69
71
 
70
72
  const ID_RE = /^[A-Za-z0-9_-]{3,64}$/;
73
+ /** A deployment (found or not) is asked for again after this long; call forget(id) to make an edit count at once. */
74
+ export const LOOKUP_MS = 15_000;
75
+ /** Fresh lookups one client address (IPv6: /64) may cause per 10 s. */
76
+ export const LOOKUPS_PER_ADDRESS = 20;
71
77
  const GAME_RE = /^[A-Za-z0-9_-]{1,60}$/;
72
78
  const VER_RE = /^[A-Za-z0-9._-]{1,40}$/;
73
79
  const KEY_RE = /^[A-Za-z0-9_-]{22,64}$/;
@@ -134,13 +140,33 @@ export function createRemote(o: RemoteOptions): Remote {
134
140
  const sources = { relay: src("relay"), room: src("room"), game: src("game") };
135
141
  const files: Record<string, string> = { relay: sources.relay, room: sources.room, game: sources.game, game3d: src("game3d") };
136
142
  const scope = (id: string) => `dep:${id}`;
137
- const lookup = async (id: string) => {
143
+ // Deployments are looked up at most once per LOOKUP_MS per id (found or not), and an address may cause at most LOOKUPS_PER_ADDRESS fresh lookups
144
+ // per 10 s: a flood of sockets or boot scripts with random ids costs a map hit, not a query on your table per request.
145
+ const seen = new Map<string, number>(); // id -> when it was last looked up
146
+ const misses = new Map<string, { at: number; n: number }>();
147
+ const lookupAllowed = (addr: string) => {
148
+ const k = addressKey(addr), now = Date.now();
149
+ let w = misses.get(k);
150
+ if (!w || now - w.at >= 10_000) {
151
+ if (misses.size >= 50_000) for (const [x, v] of misses) if (now - v.at >= 10_000) misses.delete(x);
152
+ if (misses.size >= 50_000) return false;
153
+ w = { at: now, n: 0 }; misses.set(k, w);
154
+ }
155
+ return ++w.n <= LOOKUPS_PER_ADDRESS;
156
+ };
157
+ const lookup = async (id: string, addr: string) => {
138
158
  if (!ID_RE.test(id)) return null;
159
+ const at = seen.get(id);
160
+ if (at !== undefined && Date.now() - at < LOOKUP_MS) return cache.get(id) ?? null;
161
+ if (!lookupAllowed(addr)) return cache.get(id) ?? null;
139
162
  let dep: Deployment | null | undefined = null;
140
163
  try { dep = await o.deployment(id); } catch { dep = null; }
164
+ if (seen.size >= 100_000) seen.clear();
165
+ seen.set(id, Date.now());
141
166
  if (dep && dep.id === id) cache.set(id, dep); else cache.delete(id);
142
167
  return dep && dep.id === id ? dep : null;
143
168
  };
169
+ const addrOf = (req: http.IncomingMessage) => (o.address ? o.address(req) : req.socket.remoteAddress || "?");
144
170
  // refused sockets are still accepted, then closed with a code the page shows (4001 is final: the page stops trying; 1013 busy: it retries)
145
171
  const closeWith = (req: http.IncomingMessage, socket: Duplex, head: Buffer, code: number, reason: string) =>
146
172
  wss.handleUpgrade(req, socket, head, (ws: WebSocket) => ws.close(code, reason));
@@ -149,7 +175,8 @@ export function createRemote(o: RemoteOptions): Remote {
149
175
  scope,
150
176
  sockets: (id) => live.get(id) || 0,
151
177
  roomsPerScope: (sc) => (sc.startsWith("dep:") ? cache.get(sc.slice(4))?.caps?.rooms : undefined),
152
- revoke(id) { cache.delete(id); o.hub.closeScope(scope(id), "this game was turned off by its owner"); },
178
+ revoke(id) { cache.delete(id); seen.delete(id); o.hub.closeScope(scope(id), "this game was turned off by its owner"); },
179
+ forget(id) { cache.delete(id); seen.delete(id); },
153
180
 
154
181
  upgrade(req, socket, head) {
155
182
  const url = new URL(req.url || "/", "http://localhost");
@@ -159,7 +186,9 @@ export function createRemote(o: RemoteOptions): Remote {
159
186
  const id = url.searchParams.get("d") || "", game = url.searchParams.get("game") || "", ver = url.searchParams.get("v") || "1";
160
187
  if (!GAME_RE.test(game) || !VER_RE.test(ver)) { closeWith(req, socket, head, CLOSE.badMessage, "bad game name or version"); return true; }
161
188
  socket.on("error", () => { /* closed early */ });
162
- void lookup(id).then((dep) => {
189
+ const addr = addrOf(req);
190
+ if (!o.hub.allowAddress(addr)) { closeWith(req, socket, head, CLOSE.busy, "too many new connections, try again"); return true; }
191
+ void lookup(id, addr).then((dep) => {
163
192
  if (socket.destroyed) return;
164
193
  if (!dep || dep.revoked) return closeWith(req, socket, head, CLOSE.notAllowed, "this game's deployment is unknown or turned off");
165
194
  if (!originAllowed(dep, origin)) return closeWith(req, socket, head, CLOSE.notAllowed, `this site is not allowed for deployment ${id}`);
@@ -170,7 +199,7 @@ export function createRemote(o: RemoteOptions): Remote {
170
199
  live.set(id, (live.get(id) || 0) + 1);
171
200
  ws.on("close", () => { const left = (live.get(id) || 1) - 1; if (left > 0) live.set(id, left); else live.delete(id); });
172
201
  attach(ws, {
173
- hub: o.hub, heartbeat: o.heartbeat, address: o.address ? o.address(req) : req.socket.remoteAddress || "?",
202
+ hub: o.hub, heartbeat: o.heartbeat, address: addr, counted: true,
174
203
  // the guest key is the page's own random 128-bit value: unguessable, so nobody can take another player's seat; it names a seat in
175
204
  // this deployment only (the hub shows others a short HMAC of it, never the key)
176
205
  authenticate: (ticket) => typeof ticket === "string" && KEY_RE.test(ticket)
@@ -193,7 +222,7 @@ export function createRemote(o: RemoteOptions): Remote {
193
222
  if (c) { js(200, files[c[1]], "public, max-age=300"); return true; }
194
223
  const b = /^\/c\/([^/]+)\.js$/.exec(url.pathname);
195
224
  if (!b) return false;
196
- void lookup(b[1]).then((dep) => {
225
+ void lookup(b[1], addrOf(req)).then((dep) => {
197
226
  if (!dep || dep.revoked) return js(404, `console.error(${JSON.stringify(`tickcli: no deployment ${b[1].slice(0, 64)} (unknown or turned off)`)});`);
198
227
  js(200, bootScript(dep.id, sources), "public, max-age=300");
199
228
  });
@@ -222,10 +251,10 @@ export function createRemoteServer(o: RemoteServerOptions) {
222
251
  hub.start();
223
252
  const heartbeat = new Heartbeat();
224
253
  remote = createRemote({ hub, deployment: o.deployment, heartbeat, address: o.address ?? (o.trustProxy ? (req) => clientAddress(req, true) : undefined) });
225
- const server = http.createServer((req, res) => {
254
+ const server = hardenServer(http.createServer((req, res) => {
226
255
  if (remote!.request(req, res)) return;
227
256
  res.writeHead(404, { "Content-Type": "text/plain" }); res.end("not found");
228
- });
257
+ }));
229
258
  server.on("upgrade", (req, socket, head) => { if (!remote!.upgrade(req, socket, head)) socket.destroy(); });
230
259
  const listen = (port = 8080, host?: string) => new Promise<string>((resolve, reject) => {
231
260
  server.once("error", reject);
package/src/serve.ts CHANGED
@@ -18,7 +18,7 @@ import { createRequire } from "node:module";
18
18
  import { fileURLToPath } from "node:url";
19
19
  import { WebSocketServer } from "ws";
20
20
  import { RoomHub, type Limits, type SavePort } from "./hub.ts";
21
- import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, sameSite } from "./node.ts";
21
+ import { GuestPasses, Heartbeat, WS_OPTIONS, attach, clientAddress, fileSaves, gameHash, hardenServer, sameSite } from "./node.ts";
22
22
 
23
23
  export interface GameServerOptions {
24
24
  /** Folder of games, one .html file each (names: letters, digits, "-" and "_"). Default: the current folder. */
@@ -172,7 +172,7 @@ export function createGameServer(o: GameServerOptions = {}): GameServer {
172
172
  };
173
173
  const title = o.title ?? "tickcli games";
174
174
 
175
- const server = http.createServer((req, res) => {
175
+ const server = hardenServer(http.createServer((req, res) => {
176
176
  const url = new URL(req.url || "/", "http://localhost");
177
177
  const send = (code: number, type: string, body: string, headers: Record<string, string> = {}) => { res.writeHead(code, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...headers }); res.end(body); };
178
178
  if (req.method !== "GET" && req.method !== "HEAD") return send(405, "text/plain", "method not allowed");
@@ -195,12 +195,14 @@ export function createGameServer(o: GameServerOptions = {}): GameServer {
195
195
  const headers: Record<string, string> = { "Content-Security-Policy": `default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: blob:; connect-src 'self'${wsOrigins(req)}; frame-ancestors 'none'` };
196
196
  if (!passes.check(SCOPE, cookie(req, PASS))) headers["Set-Cookie"] = GuestPasses.cookie(PASS, passes.issue(SCOPE), "/", !!o.secure);
197
197
  return send(200, "text/html; charset=utf-8", playPage(html, g![1]), headers);
198
- });
198
+ }));
199
199
 
200
200
  server.on("upgrade", (req, socket, head) => {
201
201
  const url = new URL(req.url || "/", "http://localhost");
202
202
  const refuse = (code: number) => { socket.end(`HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\nConnection: close\r\nContent-Length: 0\r\n\r\n`); };
203
203
  if (url.pathname !== "/room") return refuse(404);
204
+ const address = clientAddress(req, !!o.trustProxy);
205
+ if (!hub.allowAddress(address)) return refuse(429);
204
206
  if (!sameSite((n) => req.headers[n] as string | undefined)) return refuse(403);
205
207
  const pass = passes.check(SCOPE, cookie(req, PASS));
206
208
  if (!pass) return refuse(403);
@@ -208,7 +210,7 @@ export function createGameServer(o: GameServerOptions = {}): GameServer {
208
210
  const game = read(name);
209
211
  if (!game) return refuse(404);
210
212
  const identity = { scope: `${SCOPE}:${name}`, game: game.hash, slot: name, who: { seat: `g:${pass}`, name: GuestPasses.defaultName(pass), guest: true } };
211
- wss.handleUpgrade(req, socket, head, (ws) => attach(ws, { hub, heartbeat, address: clientAddress(req, !!o.trustProxy), identity }));
213
+ wss.handleUpgrade(req, socket, head, (ws) => attach(ws, { hub, heartbeat, address, counted: true, identity }));
212
214
  });
213
215
 
214
216
  const listen = (port = 8080, host?: string) => new Promise<string>((resolve, reject) => {