screentinker/server/lib/ota-download-guard.js
ScreenTinker f037dd476a fix(#146) C: OTA hardening under SNAT — no per-request fs, global download caps
The fleet SNATs to one IP, so nothing on the OTA path may key on IP.

- /api/update/check: EARLY-RETURN before any filesystem call when the breaker won't
  offer (rate-backoff / up-to-date / phantom / client-newer). A looping client that
  gets rate-backoff now does ZERO fs — the flood can't become a statSync flood.
- lib/apk-cache.js: resolve APK path/size/mtime once at boot + refresh on an interval;
  the check/download endpoints read cached metadata (get() does no fs, proven by test).
- lib/ota-download-guard.js + /download/apk: GLOBAL concurrency + rate caps + critical-
  band shed (503 Retry-After), NEVER per-IP. Replaces the per-IP-per-10min log throttle
  (which hid the flood under SNAT) with a per-window served/shed aggregate so a download
  flood is VISIBLE. Bounded single rolling-state object; in-flight released on finish/close.
- Breaker unchanged; no IP limiting or device_id requirement added (legacy field clients
  send no device_id on OTA checks — must keep working).

Tests: apk-cache get() = 0 statSync over 1000 reads; download guard sheds past global
concurrency + per-window rate + critical band; admit() has no IP parameter. Suite 259/259.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:05:32 -05:00

34 lines
1.7 KiB
JavaScript

'use strict';
// #146 Item C — GLOBAL admission control for /download/apk. NOT per-IP: the fleet SNATs
// to one IP, so per-IP would collapse the fleet into one bucket. Concurrency + rate caps
// + critical-band shed protect the loop and IO from a download flood; a per-window
// aggregate makes the flood visible. Pure + testable; mutates the passed rolling state.
const config = require('../config');
// newState() — the single bounded rolling counter the endpoint keeps.
function newState() { return { inFlight: 0, windowStart: 0, windowCount: 0, served: 0, shed: 0 }; }
// admit(state, band, now) -> { allow, status?, retryAfter?, summary? }
// summary (when a window just rolled) = { served, shed } to log, else null.
// NEVER takes an IP — admission is global by construction.
function admit(state, band, now = Date.now()) {
let summary = null;
if (now - state.windowStart >= config.otaDownloadWindowMs) {
if (state.served || state.shed) summary = { served: state.served, shed: state.shed, inFlight: state.inFlight };
state.windowStart = now; state.windowCount = 0; state.served = 0; state.shed = 0;
}
const overGlobal = state.inFlight >= config.otaDownloadMaxConcurrent || state.windowCount >= config.otaDownloadMaxPerWindow;
if (band === 'critical' || overGlobal) {
state.shed++;
return { allow: false, status: 503, retryAfter: band === 'critical' ? 30 : 10, summary };
}
state.inFlight++; state.windowCount++; state.served++;
return { allow: true, summary };
}
// release() — call when a served response finishes/closes (once).
function release(state) { state.inFlight = Math.max(0, state.inFlight - 1); }
module.exports = { newState, admit, release };