screentinker/server/lib/apk-cache.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

40 lines
1.4 KiB
JavaScript

'use strict';
// #146 hardening (Item C) — cache the OTA APK resolution so no /api/update/check or
// /download/apk does a per-request synchronous filesystem call. The path/size/mtime are
// resolved once at boot and refreshed on an interval (like the frontend-hash refresh),
// so a poll/download flood can't turn into an existsSync/statSync flood on the loop.
const fs = require('fs');
const path = require('path');
const config = require('../config');
// A copy under DATA_DIR wins (container operators mount /data/ScreenTinker.apk),
// else the legacy in-repo root path — same order as the old resolveApkPath().
function candidates() {
return [path.join(config.dataDir, 'ScreenTinker.apk'), path.join(__dirname, '..', '..', 'ScreenTinker.apk')];
}
let cache = { path: null, exists: false, size: 0, mtime: 0 };
function refresh() {
for (const p of candidates()) {
try { const st = fs.statSync(p); cache = { path: p, exists: true, size: st.size, mtime: st.mtimeMs }; return cache; } catch (_) { /* next */ }
}
cache = { path: null, exists: false, size: 0, mtime: 0 };
return cache;
}
function get() { return cache; }
let timer = null;
function start() {
refresh(); // resolve once at boot
if (!timer) {
timer = setInterval(refresh, config.otaApkRefreshMs);
if (timer.unref) timer.unref();
}
return cache;
}
module.exports = { start, refresh, get };