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>
This commit is contained in:
ScreenTinker 2026-06-30 21:05:32 -05:00
parent 9e3222a503
commit f037dd476a
5 changed files with 207 additions and 47 deletions

View file

@ -176,6 +176,14 @@ module.exports = {
// batches, so no sweep can block the loop regardless of table size. Keep well under
// the ~50ms invariant per batch.
statusLogPruneBatch: parseInt(process.env.STATUS_LOG_PRUNE_BATCH) || 2000,
// #146 hardening (Item C) — /download/apk GLOBAL guards (NOT per-IP; SNAT collapses
// the fleet to one IP). Concurrency + rate caps + critical-band shed protect the loop
// and IO from a download flood; the aggregate counter makes a flood VISIBLE (the old
// per-IP-per-10min log throttle hid it under SNAT).
otaDownloadMaxConcurrent: parseInt(process.env.OTA_DOWNLOAD_MAX_CONCURRENT) || 10,
otaDownloadMaxPerWindow: parseInt(process.env.OTA_DOWNLOAD_MAX_PER_WINDOW) || 120,
otaDownloadWindowMs: parseInt(process.env.OTA_DOWNLOAD_WINDOW_MS) || 60000,
otaApkRefreshMs: parseInt(process.env.OTA_APK_REFRESH_MS) || 60000,
// #146 device_status_log write batching (lib/status-log-writer.js). Status
// transitions are buffered and coalesced to the NET state per device per flush,
// so a flapping device writes ~1 row/flush instead of a row per transition —

39
server/lib/apk-cache.js Normal file
View file

@ -0,0 +1,39 @@
'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 };

View file

@ -0,0 +1,33 @@
'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 };

View file

@ -583,34 +583,44 @@ const otaBreaker = require('./lib/ota-breaker');
otaBreaker.startSweep(); // #144: periodically evict idle breaker buckets so keyed state stays bounded
require('./lib/reconnect-throttle').startSweep(); // #146: same, for the reconnect throttle's per-device buckets
require('./lib/flap-limiter').startSweep(); // #146 Item B: evict idle flap-limiter buckets
const apkCache = require('./lib/apk-cache');
apkCache.start(); // #146 Item C: resolve APK path/size/mtime once + refresh on interval (no per-request fs)
const { getBand } = require('./services/loop-lag'); // #146 Item C: critical-band download shed
app.get('/api/update/check', (req, res) => {
const currentVersion = req.query.version;
const deviceId = req.query.device_id || null; // #144: optional; beta4+ clients send it for per-device keying
const latestVersion = VERSION;
// #144: circuit-breaker + phantom-version guard (replaces the old string-inequality
// offer). Keys per device_id when present, else per reported version. Rate-trips a
// looping client in seconds; never offers a downgrade or a superseded/garbage version.
// #144: circuit-breaker + phantom-version guard. Keys per device_id when present, else
// per reported version (NOT IP — SNAT). Rate-trips a looping client in seconds.
const verdict = otaBreaker.decide(currentVersion, latestVersion, deviceId);
const apkPath = resolveApkPath(); // existsSync x2 (cheap)
const apkExists = apkPath !== null;
const updateAvailable = !!verdict.update_available && apkExists; // never offer if APK missing
const apkSize = updateAvailable ? fs.statSync(apkPath).size : 0; // statSync only when actually offering (don't stat on every looped poll)
const apkModified = updateAvailable ? fs.statSync(apkPath).mtimeMs : 0;
if (verdict.log) console.log(verdict.log); // once-per-event (trip / unrecognized)
// #96: keep the per-check line observable; now also shows the breaker reason + device_id.
console.log(`[ota] update check from ${getClientIp(req)}: device=${deviceId || 'none'} client=${currentVersion || 'unknown'} latest=${latestVersion} update_available=${updateAvailable} reason=${verdict.reason} apk=${apkExists ? 'present' : 'MISSING'}`);
// #146 Item C: EARLY-RETURN before any filesystem work when we won't serve
// (rate-backoff, up-to-date, phantom, client-newer, …). A looping client that gets
// rate-backoff does ZERO fs calls — the flood can't turn into a statSync flood.
if (!verdict.update_available) {
if (verdict.log) console.log(verdict.log);
logOtaCheck(deviceId, currentVersion, latestVersion, false, verdict.reason);
return res.json({
latest_version: latestVersion, current_version: currentVersion || 'unknown',
update_available: false, reason: verdict.reason, download_url: '/download/apk',
apk_size: 0, apk_modified: 0,
...(verdict.retry_after_seconds ? { retry_after_seconds: verdict.retry_after_seconds } : {}),
});
}
// Offering — read the CACHED apk metadata (no per-request statSync; refreshed on an
// interval by apkCache). Never offer if the APK isn't actually present.
const apk = apkCache.get();
const updateAvailable = apk.exists;
if (verdict.log) console.log(verdict.log);
logOtaCheck(deviceId, currentVersion, latestVersion, updateAvailable, updateAvailable ? verdict.reason : 'apk-missing');
res.json({
latest_version: latestVersion,
current_version: currentVersion || 'unknown',
update_available: updateAvailable,
reason: verdict.reason, // #144: breaker decision, for observability (additive; old clients ignore)
latest_version: latestVersion, current_version: currentVersion || 'unknown',
update_available: updateAvailable, reason: updateAvailable ? verdict.reason : 'apk-missing',
download_url: '/download/apk',
apk_size: apkSize,
apk_modified: apkModified,
...(verdict.retry_after_seconds ? { retry_after_seconds: verdict.retry_after_seconds } : {}),
apk_size: updateAvailable ? apk.size : 0,
apk_modified: updateAvailable ? apk.mtime : 0,
});
});
@ -715,40 +725,40 @@ app.post('/api/provision/pair', requireAuth, resolveTenancy, checkDeviceLimit, (
res.json(updated);
});
// Resolve the OTA APK. A copy under the data dir (DATA_DIR) wins, so a container
// operator can mount one at /data/ScreenTinker.apk; otherwise the legacy in-repo
// root path (unchanged when DATA_DIR is unset). Returns null if neither exists.
function resolveApkPath() {
for (const p of [path.join(config.dataDir, 'ScreenTinker.apk'), path.join(__dirname, '..', 'ScreenTinker.apk')]) {
if (fs.existsSync(p)) return p;
}
return null;
// #146 Item C: OTA update-check log. One line per check today; Item E coalesces the
// high-frequency lines. Never keys on IP for any decision (SNAT). Kept as a helper so
// both the offer and no-offer paths log consistently.
function logOtaCheck(deviceId, client, latest, available, reason) {
console.log(`[ota] update check: device=${deviceId || 'none'} client=${client || 'unknown'} latest=${latest} update_available=${available} reason=${reason}`);
}
// #139: a device that can't silently install re-downloads the APK every check cycle. Don't
// word a download as "in progress" (it may be a stuck loop, not progress), and rate-limit the
// line to once per IP per window so a looping device can't flood the log.
const otaDownloadLoggedAt = new Map(); // ip -> last-logged ms
const OTA_DOWNLOAD_LOG_WINDOW_MS = 10 * 60 * 1000;
// #146 Item C: GLOBAL download admission (lib/ota-download-guard) — concurrency + rate
// caps + critical-band shed, NEVER per-IP (SNAT). Single bounded rolling state.
const otaDownloadGuard = require('./lib/ota-download-guard');
const otaDownloadState = otaDownloadGuard.newState();
// Serve APK download
app.get('/download/apk', (req, res) => {
const apkPath = resolveApkPath();
if (apkPath) {
const ip = getClientIp(req);
const now = Date.now();
if (now - (otaDownloadLoggedAt.get(ip) || 0) > OTA_DOWNLOAD_LOG_WINDOW_MS) {
otaDownloadLoggedAt.set(ip, now);
console.log(`[ota] APK served to ${ip} (${fs.statSync(apkPath).size} bytes)`);
}
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(apkPath);
} else {
console.warn(`[ota] APK download requested by ${getClientIp(req)} but no APK is available (404)`);
res.status(404).send(`<!DOCTYPE html><html><head><title>APK Not Found</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:500px;padding:24px}h1{color:#f87171;font-size:24px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:14px}p{line-height:1.6;color:#94a3b8}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet. To build it from source:</p><p><code>cd android</code><br><code>./gradlew assembleDebug</code><br><code>cp app/build/outputs/apk/debug/app-debug.apk ../ScreenTinker.apk</code></p><p>See the <a href="/" style="color:#3b82f6">README</a> for full build instructions.</p><p>In Docker, mount a built APK at <code>/data/ScreenTinker.apk</code> (the data dir).</p><p>Alternatively, use the <a href="/player" style="color:#3b82f6">web player</a> in any browser.</p></div></body></html>`);
const apk = apkCache.get();
if (!apk.exists) {
return res.status(404).send(`<!DOCTYPE html><html><head><title>APK Not Found</title><style>body{font-family:-apple-system,system-ui,sans-serif;display:flex;justify-content:center;align-items:center;min-height:100vh;margin:0;background:#0f172a;color:#e2e8f0}div{text-align:center;max-width:500px;padding:24px}h1{color:#f87171;font-size:24px}code{background:#1e293b;padding:2px 8px;border-radius:4px;font-size:14px}p{line-height:1.6;color:#94a3b8}</style></head><body><div><h1>APK Not Available</h1><p>The Android APK has not been compiled yet. In Docker, mount a built APK at <code>/data/ScreenTinker.apk</code>, or use the <a href="/player" style="color:#3b82f6">web player</a>.</p></div></body></html>`);
}
const verdict = otaDownloadGuard.admit(otaDownloadState, getBand());
if (verdict.summary) {
console.log(`[ota] downloads last ${Math.round(config.otaDownloadWindowMs / 1000)}s: ${verdict.summary.served} served, ${verdict.summary.shed} shed (in-flight ${verdict.summary.inFlight})`);
}
if (!verdict.allow) {
res.setHeader('Retry-After', String(verdict.retryAfter));
return res.status(verdict.status).json({ error: 'download capacity reached, retry shortly', retry_after: verdict.retryAfter });
}
let released = false;
const release = () => { if (released) return; released = true; otaDownloadGuard.release(otaDownloadState); };
res.on('finish', release); res.on('close', release);
res.setHeader('Content-Type', 'application/vnd.android.package-archive');
res.setHeader('Content-Disposition', 'attachment; filename="ScreenTinker.apk"');
res.setHeader('Cache-Control', 'no-cache');
res.sendFile(apk.path, (err) => { if (err) release(); });
});
// SPA fallback for app routes. Unmatched /api/ paths return 404 so misrouted

View file

@ -0,0 +1,70 @@
'use strict';
// #146 hardening (Item C) — OTA under SNAT. Cached APK resolution (no per-request fs)
// + GLOBAL download admission (concurrency + rate + critical-band shed, never per-IP).
const os = require('node:os');
const path = require('node:path');
const fs = require('node:fs');
const crypto = require('node:crypto');
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-otahard-' + crypto.randomBytes(4).toString('hex'));
process.env.OTA_DOWNLOAD_MAX_CONCURRENT = '3';
process.env.OTA_DOWNLOAD_MAX_PER_WINDOW = '5';
process.env.OTA_DOWNLOAD_WINDOW_MS = '100000';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const guard = require('../lib/ota-download-guard');
const apkCache = require('../lib/apk-cache');
test('apk-cache: get() never touches the filesystem (resolution cached at boot/refresh)', () => {
// seed a fake APK under DATA_DIR and refresh once
const apk = path.join(process.env.DATA_DIR, 'ScreenTinker.apk');
fs.mkdirSync(process.env.DATA_DIR, { recursive: true });
fs.writeFileSync(apk, 'FAKEAPKBYTES');
const c = apkCache.refresh();
assert.equal(c.exists, true);
assert.equal(c.size, 12);
// count fs.statSync calls across many get()s -> must be ZERO (a poll/download flood
// can't become a statSync flood).
const realStat = fs.statSync; let calls = 0;
fs.statSync = (...a) => { calls++; return realStat(...a); };
try { for (let i = 0; i < 1000; i++) apkCache.get(); } finally { fs.statSync = realStat; }
assert.equal(calls, 0, 'get() does no fs; 1000 reads = 0 statSync');
});
test('download guard: global concurrency cap -> 503 (not per-IP)', () => {
const s = guard.newState();
assert.equal(guard.admit(s, 'normal').allow, true);
assert.equal(guard.admit(s, 'normal').allow, true);
assert.equal(guard.admit(s, 'normal').allow, true); // 3 in-flight = cap
const over = guard.admit(s, 'normal');
assert.equal(over.allow, false);
assert.equal(over.status, 503);
assert.ok(over.retryAfter > 0);
guard.release(s); // free one slot
assert.equal(guard.admit(s, 'normal').allow, true, 'a freed slot admits again');
});
test('download guard: global per-window rate cap -> 503', () => {
const s = guard.newState();
for (let i = 0; i < 5; i++) { assert.equal(guard.admit(s, 'normal').allow, true); guard.release(s); } // 5 served this window
const over = guard.admit(s, 'normal');
assert.equal(over.allow, false, '6th in the window is shed');
assert.equal(over.status, 503);
});
test('download guard: critical band sheds regardless of caps', () => {
const s = guard.newState();
const v = guard.admit(s, 'critical');
assert.equal(v.allow, false);
assert.equal(v.retryAfter, 30, 'critical band asks for a longer backoff');
});
test('download guard admission takes NO ip argument (global by construction)', () => {
// admit(state, band, now) — there is no IP parameter, so it cannot key on IP.
assert.equal(guard.admit.length <= 3, true);
const s = guard.newState();
assert.equal(guard.admit(s, 'normal').allow, true); // works with zero IP context
});