mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
The #142 burst throttle (5/10s) misses a device flapping every 3-5s (~2-3/10s) — yet each cycle is an expensive register+build+acks and one status_log row (the spiral trigger). Now that Item A ends the restart loop that used to wipe in-memory throttle state every ~40s, an in-memory sustained limiter can finally bite. - lib/device-identity.js: SNAT-safe identity resolution — device_id -> fingerprint (map via device_fingerprints -> device_id, else raw fp) -> device_token -> ONE bounded global anon bucket. NEVER IP (the fleet SNATs to 10.10.10.1). An unidentifiable client is still bucketed (collectively) so an anon flood is capped, never unthrottled. - lib/flap-limiter.js: per-identity connect-frequency over a long window (CONNECT_RATE_WINDOW_MS=5min, CONNECT_RATE_MAX=20; anon bucket cap 60). Over the rate -> refuse + disconnect (cheap). Bounded by an idle sweep (anon bucket never swept). Optional auto-quarantine: a device_id-resolved hard flapper -> blocked=1. - Wired at the device:register gate BEFORE fingerprint tracking/throttle/DB/build, skipping same-socket playlist refreshes. Sweep started in server.js. Tests: 4s-flapper refused after the window max; 60s-normal never; two device_ids independent (never IP); device_id-less bucketed by fingerprint; neither id nor fingerprint capped via global anon; idle sweep preserves the anon bucket. Suite 254/254. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
46 lines
2 KiB
JavaScript
46 lines
2 KiB
JavaScript
'use strict';
|
|
// #146 hardening — SNAT-safe identity resolution for any per-device keying (flap
|
|
// limiter, operator block, throttles). The whole fleet egresses as ONE IP
|
|
// (10.10.10.1, Sophos SNAT), so keying on IP collapses the fleet into one bucket —
|
|
// FORBIDDEN. Resolve identity from the device:register payload via the fallback chain:
|
|
//
|
|
// device_id
|
|
// -> fingerprint (map via device_fingerprints -> device_id when it resolves,
|
|
// else the raw fingerprint string)
|
|
// -> device_token
|
|
// -> a single BOUNDED GLOBAL anon bucket (final backstop)
|
|
//
|
|
// An unidentifiable client is STILL bucketed (collectively, via the anon bucket) so an
|
|
// anonymous flood is capped, never unthrottled. Mirrors lib/ota-breaker's
|
|
// device_id-or-version fallback. `fingerprint` is present in the register payload;
|
|
// device_fingerprints maps fingerprint -> device_id. NEVER keys on IP.
|
|
|
|
const { db } = require('../db/database');
|
|
|
|
const ANON_KEY = 'anon:global';
|
|
|
|
// Returns { key, kind, deviceId } — `key` is stable for keying; `deviceId` is the
|
|
// resolved device id when the chain produced one (device_id directly or via
|
|
// fingerprint), else null.
|
|
function resolveIdentity(payload = {}) {
|
|
const { device_id, fingerprint, device_token } = payload;
|
|
|
|
if (device_id) return { key: 'd:' + device_id, kind: 'device_id', deviceId: device_id };
|
|
|
|
if (fingerprint) {
|
|
let mapped = null;
|
|
try {
|
|
const row = db.prepare('SELECT device_id FROM device_fingerprints WHERE fingerprint = ?').get(fingerprint);
|
|
mapped = row && row.device_id ? row.device_id : null;
|
|
} catch (_) { /* table may not exist on a partially-migrated DB */ }
|
|
if (mapped) return { key: 'd:' + mapped, kind: 'fingerprint->device_id', deviceId: mapped };
|
|
return { key: 'f:' + fingerprint, kind: 'fingerprint', deviceId: null };
|
|
}
|
|
|
|
if (device_token) return { key: 't:' + device_token, kind: 'device_token', deviceId: null };
|
|
|
|
return { key: ANON_KEY, kind: 'anon', deviceId: null };
|
|
}
|
|
|
|
module.exports = { resolveIdentity, ANON_KEY };
|