From 6ee1d37a79c7ae1f81970ac6bc28986e01899980 Mon Sep 17 00:00:00 2001 From: Jon Vanvik Date: Tue, 23 Jun 2026 14:21:04 +0200 Subject: [PATCH] web: shared MCMS cache + systemd/clone-to-run deploy + NPM docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Caching (reduce MCMS API load with multiple operators): - web/cache.js: in-memory TTL cache with single-flight, keyed by MCMS host. Concurrent identical bulk reads collapse into ONE upstream fetch; everyone on the same MCMS shares the snapshot. - Wired into the bulk reads only (ONU/OLT config+state lists, controllers, firmware). Per-ONU read-modify-write and the FEC pre-flight stay live. - Writes (delete / per-ONU + bulk upgrade / flood change / firmware upload) invalidate the affected datasets for that host, so changes show on the next load instead of waiting out the TTL. - PFW_CACHE_TTL_SECONDS (default 60, 0 disables). Authenticated _cacheStats / _cacheClear endpoints for ops. Deploy (Debian 13 LXC, clone-to-run) in web/deploy/: - pon-fleet-web.service (runs as unprivileged ponfw, hardened, repo read-only, no disk writes), pon-fleet-web.env.example, update.sh (git pull + npm install --omit=dev + restart; uses install not ci since package-lock.json is gitignored). - README: full LXC setup, Nginx Proxy Manager proxy-host + access-list notes. Streams already send X-Accel-Buffering: no so NPM/nginx don't buffer the NDJSON progress. Verified: node --check; cache unit-tested (single-flight, TTL hit/expiry, fresh, invalidate, key isolation, disable — 9/9); server boots and reports the cache TTL; _cacheStats gated to logged-in sessions. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 15 ++++- web/README.md | 98 ++++++++++++++++++++++------ web/cache.js | 79 ++++++++++++++++++++++ web/deploy/pon-fleet-web.env.example | 30 +++++++++ web/deploy/pon-fleet-web.service | 35 ++++++++++ web/deploy/update.sh | 22 +++++++ web/server.js | 65 ++++++++++++++---- 7 files changed, 309 insertions(+), 35 deletions(-) create mode 100644 web/cache.js create mode 100644 web/deploy/pon-fleet-web.env.example create mode 100644 web/deploy/pon-fleet-web.service create mode 100755 web/deploy/update.sh diff --git a/CLAUDE.md b/CLAUDE.md index 5695be6..bb4250f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -796,9 +796,22 @@ Consequences for editing: browser session (cookie `pfw_sid`), vs. the Electron app's single global `client`. CSV writes become browser downloads; file dialogs become ``. Dashboard aggregation is shared via `src/dashboard.js`. +- **Shared upstream cache** (`web/cache.js`). Heavy bulk *reads* (ONU/OLT + config+state lists, controllers, firmware) go through a TTL cache with + single-flight, keyed by MCMS host, so concurrent operators collapse into + one upstream fetch (`PFW_CACHE_TTL_SECONDS`, default 60). Per-ONU + read-modify-write fetches and the FEC pre-flight stay **uncached** (must be + live). Writes call `invalidate(session, [...])` for the affected datasets. + This cache is web-only; the single-user Electron app doesn't need it. +- **Deploy** lives in `web/deploy/` (systemd unit + env template + + `update.sh`); Debian-13-LXC clone-to-run steps and the Nginx Proxy Manager + setup are in `web/README.md`. Note streams send `X-Accel-Buffering: no` so + nginx/NPM don't buffer the NDJSON progress. The web app must stay a subdirectory of this repo — it `require`s `../src/*` -and borrows `../node_modules` (`tough-cookie`); no separate install. +and borrows `../node_modules` (`tough-cookie`); no separate install. Deploy +uses `npm install --omit=dev` (not `npm ci` — `package-lock.json` is +gitignored) which pulls `tough-cookie` and skips the Electron devDep. --- diff --git a/web/README.md b/web/README.md index 183b8ef..b4e205b 100644 --- a/web/README.md +++ b/web/README.md @@ -42,38 +42,96 @@ cookie jar) per session. | Var | Default | Meaning | |---|---|---| | `PORT` | `8080` | listen port | -| `HOST` | `127.0.0.1` | bind address — keep on localhost behind a proxy | +| `HOST` | `127.0.0.1` | bind address — `0.0.0.0` if the proxy is on another host | +| `PFW_CACHE_TTL_SECONDS` | `60` | shared upstream cache TTL; `0` disables (see Caching) | | `PFW_IDLE_MINUTES` | `30` | idle-session expiry (logs the MCMS session out) | | `PFW_SECURE_COOKIE` | off | force the `Secure` cookie flag (else inferred from `X-Forwarded-Proto: https`) | | `PFW_VERBOSE` | off | verbose MCMS request logging (very noisy with many users) | -## Reverse proxy +## Caching (fewer MCMS requests) -Terminate TLS at the proxy and forward to `127.0.0.1:8080`. **Disable -response buffering** so the NDJSON progress streams (bulk delete, FEC -pre-flight, flood change, …) arrive live. +The server keeps a small in-memory cache so multiple operators don't each +hammer MCMS. The heavy bulk **reads** — ONU configs, ONU states, OLT states, +OLT configs, controller configs, firmware inventory — are cached with a TTL +(`PFW_CACHE_TTL_SECONDS`, default 60s) and **keyed by MCMS host**, so: -Caddy: +- Everyone pointed at the same MCMS shares one snapshot. Ten operators + loading the fleet within a minute trigger **one** upstream fetch, not ten. +- A **single-flight** guard means even simultaneous cold loads collapse into + one upstream request (the rest await it). +- Different MCMS hosts never share, and the dashboard's light path reuses the + same OLT-STATE snapshot the fleet view already fetched. -``` -mcms-tools.example.net { - reverse_proxy 127.0.0.1:8080 { - flush_interval -1 # don't buffer streamed responses - } -} +What is **not** cached (always live, by design): + +- Per-ONU `getOnuConfig` used right before a firmware PUT (read-modify-write + must see the current bank state). +- The FEC pre-flight and verify per-ONU `getOnuState` (operators want the + reading at decision time). + +**Writes invalidate** the affected datasets for that MCMS host, so a delete / +firmware push / flood-mode change is reflected on the next load instead of +waiting out the TTL. Tune the TTL up to cut MCMS load further, or set +`PFW_CACHE_TTL_SECONDS=0` to disable caching entirely. `POST /api/_cacheStats` +(authenticated) shows hits/misses/coalesced; `POST /api/_cacheClear` drops the +caller's MCMS-host snapshots. + +## Deploy on a Debian 13 LXC (clone-to-run) + +Files in `web/deploy/`: a systemd unit, an env template, and `update.sh`. + +```bash +# --- as root on the LXC, once --- +apt update && apt install -y nodejs npm git # Node 18+; or use NodeSource +adduser --system --group --no-create-home ponfw + +git clone ssh://forgejo@int.git.vanvikinternet.no/Svorka/ponfw.git /opt/ponfw +cd /opt/ponfw +npm install --omit=dev --no-audit --no-fund # installs tough-cookie, skips Electron + +install -m 0644 web/deploy/pon-fleet-web.service /etc/systemd/system/ +install -m 0640 web/deploy/pon-fleet-web.env.example /etc/pon-fleet-web.env +$EDITOR /etc/pon-fleet-web.env # set HOST / PFW_SECURE_COOKIE etc. + +systemctl daemon-reload +systemctl enable --now pon-fleet-web +systemctl status pon-fleet-web +journalctl -u pon-fleet-web -f # logs ``` -nginx: +The repo lives at `/opt/ponfw` (root-owned, read-only to the service); the +service runs as the unprivileged `ponfw` user and writes nothing to disk. -```nginx -location / { - proxy_pass http://127.0.0.1:8080; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_buffering off; # stream NDJSON progress live - proxy_read_timeout 300s; # bulk ops can run a while -} +**To update:** just re-pull and restart — + +```bash +sudo /opt/ponfw/web/deploy/update.sh # git pull + npm install + restart ``` +## Behind Nginx Proxy Manager + +Point an NPM **Proxy Host** at the server: + +- Forward Hostname/IP: the LXC's address · Forward Port: `8080` +- Scheme: `http` (NPM terminates TLS; request an SSL cert in NPM) +- Enable **Block Common Exploits**; add your **Access List** for auth/IP + allow-listing (this is your access control — the app itself has no user + directory beyond MCMS login). +- **Websockets Support** on is harmless. The progress streams (bulk delete, + FEC pre-flight, flood change) are plain NDJSON over HTTP and already send + `X-Accel-Buffering: no`, which nginx/NPM honour, so they stream live with + no extra config. If you ever see progress arrive only at the end, add to + the host's **Advanced** tab: + + ```nginx + proxy_buffering off; + proxy_read_timeout 300s; + ``` + +If NPM runs on a different host than this LXC, set `HOST=0.0.0.0` in +`/etc/pon-fleet-web.env` and restrict reachability to the NPM host via the +LXC firewall + the NPM access list. + ## Security notes - The server holds each logged-in user's **live MCMS session cookie in diff --git a/web/cache.js b/web/cache.js new file mode 100644 index 0000000..2e94073 --- /dev/null +++ b/web/cache.js @@ -0,0 +1,79 @@ +// In-memory TTL cache with single-flight, shared across all browser sessions, +// to collapse repeated MCMS bulk fetches into one upstream request. +// +// Keyed by "|": operators pointed at the SAME MCMS +// share a snapshot (the inventory is identical regardless of which +// authenticated user fetched it), while different MCMS hosts never cross. +// +// Only slow-changing bulk *reads* are cached (ONU/OLT config + state lists, +// firmware inventory). Per-ONU reads used for read-modify-write (getOnuConfig +// before a PUT) and the FEC pre-flight are deliberately NOT cached — they must +// be live. Writes invalidate the affected datasets so the next read refetches. + +class TtlCache { + constructor(ttlMs) { + this.ttl = ttlMs; + this.entries = new Map(); // key -> { value, at } + this.inflight = new Map(); // key -> Promise (single-flight) + this.hits = 0; + this.misses = 0; + this.coalesced = 0; + } + + enabled() { return this.ttl > 0; } + + /** + * Return the cached value for `key` if fresh; otherwise call `producer()` + * once (coalescing concurrent callers) and cache the result. + * @param {object} [opts] + * @param {boolean} [opts.fresh] bypass a still-fresh cached entry + */ + async get(key, producer, { fresh = false } = {}) { + if (!this.enabled()) return producer(); + + const now = Date.now(); + const e = this.entries.get(key); + if (!fresh && e && (now - e.at) < this.ttl) { this.hits++; return e.value; } + + // Single-flight: if a fetch for this key is already running, join it + // (even a `fresh` request — it's fetching right now anyway). + const existing = this.inflight.get(key); + if (existing) { this.coalesced++; return existing; } + + this.misses++; + const p = Promise.resolve() + .then(producer) + .then( + (value) => { this.entries.set(key, { value, at: Date.now() }); this.inflight.delete(key); return value; }, + (err) => { this.inflight.delete(key); throw err; }, + ); + this.inflight.set(key, p); + return p; + } + + /** Drop every entry whose key starts with `prefix` (e.g. one MCMS host's + * dataset). Returns how many were removed. */ + invalidate(prefix) { + let n = 0; + for (const k of [...this.entries.keys()]) { + if (!prefix || k.startsWith(prefix)) { this.entries.delete(k); n++; } + } + return n; + } + + /** Free memory: drop entries far older than the TTL (idle datasets). */ + sweep(maxAgeMs) { + const cutoff = Date.now() - maxAgeMs; + for (const [k, e] of [...this.entries]) if (e.at < cutoff) this.entries.delete(k); + } + + stats() { + return { + ttlMs: this.ttl, enabled: this.enabled(), size: this.entries.size, + hits: this.hits, misses: this.misses, coalesced: this.coalesced, + keys: [...this.entries.keys()], + }; + } +} + +module.exports = { TtlCache }; diff --git a/web/deploy/pon-fleet-web.env.example b/web/deploy/pon-fleet-web.env.example new file mode 100644 index 0000000..18e7e0b --- /dev/null +++ b/web/deploy/pon-fleet-web.env.example @@ -0,0 +1,30 @@ +# PON Fleet web server config. +# Copy to /etc/pon-fleet-web.env and edit. All values are optional; the +# defaults shown are what the server uses if a line is omitted. + +# Listen port. +#PORT=8080 + +# Bind address. Keep 127.0.0.1 if Nginx Proxy Manager runs on THIS host. +# If NPM is a separate box/container, set 0.0.0.0 and restrict access at the +# proxy (access lists) and/or the LXC firewall. +#HOST=127.0.0.1 + +# Shared upstream cache TTL (seconds). Repeated bulk reads (fleet load, +# dashboard, OLT list) within this window are served from one cached MCMS +# fetch, shared across all operators on the same MCMS host. 0 disables it. +# Raise to cut MCMS load further; lower for fresher data. +#PFW_CACHE_TTL_SECONDS=60 + +# Idle session timeout (minutes). After this with no requests the server +# logs that operator's MCMS session out and drops their client. +#PFW_IDLE_MINUTES=30 + +# Force the Secure flag on the session cookie. Recommended when always +# behind HTTPS (NPM terminates TLS). If unset, Secure is inferred from the +# X-Forwarded-Proto: https header NPM/nginx sends. +#PFW_SECURE_COOKIE=1 + +# Verbose MCMS request logging to the journal. Off by default — very noisy +# with multiple users and dumps request metadata. +#PFW_VERBOSE=0 diff --git a/web/deploy/pon-fleet-web.service b/web/deploy/pon-fleet-web.service new file mode 100644 index 0000000..417b45a --- /dev/null +++ b/web/deploy/pon-fleet-web.service @@ -0,0 +1,35 @@ +[Unit] +Description=PON Fleet web server (MCMS tools, multi-user) +Documentation=https://git.vanvikinternet.no/Svorka/ponfw +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ponfw +Group=ponfw +# The repo is cloned to /opt/ponfw; the server lives in its web/ subdir. +WorkingDirectory=/opt/ponfw/web +ExecStart=/usr/bin/env node server.js +# Defaults; override anything in /etc/pon-fleet-web.env (optional). +Environment=NODE_ENV=production +EnvironmentFile=-/etc/pon-fleet-web.env +Restart=on-failure +RestartSec=3 + +# --- Hardening (the app reads its repo, writes nothing to disk) --- +NoNewPrivileges=true +PrivateTmp=true +ProtectSystem=strict +ProtectHome=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +RestrictNamespaces=true +LockPersonality=true +# V8 needs writable+executable memory for its JIT, so do NOT deny W^X. +MemoryDenyWriteExecute=false + +[Install] +WantedBy=multi-user.target diff --git a/web/deploy/update.sh b/web/deploy/update.sh new file mode 100755 index 0000000..2ff63d4 --- /dev/null +++ b/web/deploy/update.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Update the PON Fleet web server in place: pull the repo, reinstall runtime +# deps (tough-cookie only — Electron is a devDep and is skipped), restart. +# Run as root (or via sudo) on the LXC. Override the path with PONFW_DIR. +set -euo pipefail + +REPO="${PONFW_DIR:-/opt/ponfw}" +SERVICE="pon-fleet-web" + +echo "==> Updating $REPO" +cd "$REPO" +git pull --ff-only + +echo "==> Installing runtime dependencies (omit dev / Electron)" +# `npm install` (not `npm ci`) because this repo gitignores package-lock.json. +npm install --omit=dev --no-audit --no-fund + +echo "==> Restarting $SERVICE" +systemctl restart "$SERVICE" +sleep 1 +systemctl --no-pager --lines=8 status "$SERVICE" || true +echo "==> Done." diff --git a/web/server.js b/web/server.js index bbb30db..d76b943 100644 --- a/web/server.js +++ b/web/server.js @@ -28,11 +28,14 @@ const { } = require('../src/mcms-api'); const { planUpgrade } = require('../src/bank-strategy'); const { summarizeDashboard } = require('../src/dashboard'); +const { TtlCache } = require('./cache'); const PORT = Number(process.env.PORT) || 8080; const HOST = process.env.HOST || '127.0.0.1'; const IDLE_MS = (Number(process.env.PFW_IDLE_MINUTES) || 30) * 60 * 1000; const VERBOSE = process.env.PFW_VERBOSE === '1'; +const CACHE_TTL_MS = (process.env.PFW_CACHE_TTL_SECONDS !== undefined + ? Number(process.env.PFW_CACHE_TTL_SECONDS) : 60) * 1000; const ROOT = __dirname; const RENDERER = path.join(ROOT, '..', 'renderer'); const PUBLIC = path.join(ROOT, 'public'); @@ -40,6 +43,21 @@ const PUBLIC = path.join(ROOT, 'public'); const SMALL_BODY = 8 * 1024 * 1024; // 8 MB for normal JSON requests const UPLOAD_BODY = 256 * 1024 * 1024; // 256 MB for base64 firmware uploads +// ---- Shared upstream cache ------------------------------------------ +// Collapses the heavy MCMS bulk reads across all sessions on the same MCMS +// host. Per-ONU read-modify-write fetches and the FEC pre-flight stay live +// (uncached). Writes invalidate the affected datasets. +const cache = new TtlCache(CACHE_TTL_MS); +setInterval(() => cache.sweep(Math.max(CACHE_TTL_MS * 5, 10 * 60 * 1000)), 5 * 60 * 1000).unref(); + +const ckey = (session, name) => `${session.baseUrl}|${name}`; +function cachedBulk(session, name, producer, fresh) { + return cache.get(ckey(session, name), producer, { fresh }); +} +function invalidate(session, names) { + for (const n of names) cache.invalidate(ckey(session, n)); +} + // ---- Sessions -------------------------------------------------------- // sid -> { client, baseUrl, user, lastSeen, sid } const sessions = new Map(); @@ -210,13 +228,14 @@ const handlers = { return { ok: true, data }; }, - async listFleet(s) { + async listFleet(s, body) { const c = s.client; - const configs = await c.listAllOnuConfigs(); + const fresh = !!(body && body.fresh); + const configs = await cachedBulk(s, 'onuConfigs', () => c.listAllOnuConfigs(), fresh); let states = [], statesError = null; - try { states = await c.listAllOnuStates(); } catch (e) { statesError = toWireError(e); } + try { states = await cachedBulk(s, 'onuStates', () => c.listAllOnuStates(), fresh); } catch (e) { statesError = toWireError(e); } let oltStates = [], oltStatesError = null; - try { oltStates = await c.listAllOltStates(); } catch (e) { oltStatesError = toWireError(e); } + try { oltStates = await cachedBulk(s, 'oltStates', () => c.listAllOltStates(), fresh); } catch (e) { oltStatesError = toWireError(e); } const statusByOnu = new Map(); for (const olt of oltStates) { @@ -249,8 +268,9 @@ const handlers = { return { ok: true, data: await s.client.getOnuConfig(body.onuId) }; }, - async listOnuFirmware(s) { - return { ok: true, data: await s.client.listOnuFirmware() }; + async listOnuFirmware(s, body) { + const fresh = !!(body && body.fresh); + return { ok: true, data: await cachedBulk(s, 'firmware', () => s.client.listOnuFirmware(), fresh) }; }, // Browser sent the .bin as base64 (no server-side file dialog). @@ -264,6 +284,7 @@ const handlers = { Version: versionMatch ? versionMatch[1] : '', }; const bodyResp = await s.client.uploadOnuFirmware(filename, base64, metadata); + invalidate(s, ['firmware']); return { ok: true, data: { filename, metadata, body: bodyResp } }; }, @@ -283,9 +304,15 @@ const handlers = { }, async executeBulkTask(s, body) { - return { ok: true, data: await s.client.createFirmwareTask(body) }; + const data = await s.client.createFirmwareTask(body); + invalidate(s, ['onuConfigs', 'onuStates']); + return { ok: true, data }; }, + // Ops/debug: cache visibility + manual clear for the caller's MCMS host. + async _cacheStats() { return { ok: true, data: cache.stats() }; }, + async _cacheClear(s) { return { ok: true, data: { cleared: cache.invalidate(`${s.baseUrl}|`) } }; }, + async getTaskConfig(s, body) { return { ok: true, data: await s.client.getTaskConfig(body.taskId) }; }, @@ -297,17 +324,21 @@ const handlers = { async dashboardStats(s, body) { const c = s.client; const extraStats = !!(body && body.extraStats); - const olts = await c.listAllOltStates(); - const onuStates = extraStats ? await c.listAllOnuStates() : null; + const fresh = !!(body && body.fresh); + const olts = await cachedBulk(s, 'oltStates', () => c.listAllOltStates(), fresh); + const onuStates = extraStats + ? await cachedBulk(s, 'onuStates', () => c.listAllOnuStates(), fresh) + : null; let controllerCount = null; - try { controllerCount = (await c.listAllControllerConfigs()).length; } catch (_) { /* best-effort */ } + try { controllerCount = (await cachedBulk(s, 'controllerConfigs', () => c.listAllControllerConfigs(), fresh)).length; } catch (_) { /* best-effort */ } return { ok: true, data: summarizeDashboard({ olts, onuStates, controllerCount }) }; }, async listOlts(s, body) { const c = s.client; const tagPattern = (body && body.tagPattern) || ''; - const cfgs = await c.listAllOltConfigs(); + const fresh = !!(body && body.fresh); + const cfgs = await cachedBulk(s, 'oltConfigs', () => c.listAllOltConfigs(), fresh); const rows = cfgs.map((cfg) => { const summary = summarizeOltNniNetworks(cfg, tagPattern); const counts = { private: 0, auto: 0, unknown: 0 }; @@ -355,6 +386,7 @@ const streamHandlers = { results.push({ onuId, ok: false, error: toWireError(err) }); } } + invalidate(s, ['onuConfigs', 'onuStates']); return results; }, @@ -396,7 +428,7 @@ const streamHandlers = { async deleteOnus(s, body, write) { const { onuIds, concurrency = 5 } = body; - return runPool(onuIds, concurrency, async (id) => { + const results = await runPool(onuIds, concurrency, async (id) => { let ok = false, error = null; try { await s.client.deleteOnuConfig(id); @@ -405,6 +437,8 @@ const streamHandlers = { } catch (e) { error = toWireError(e); } return { onuId: id, ok, error }; }, (entry, done, total) => write({ onuId: entry.onuId, ok: entry.ok, error: entry.error, done, total })); + invalidate(s, ['onuConfigs', 'onuStates', 'oltStates']); + return results; }, async executeFloodChange(s, body, write) { @@ -412,7 +446,7 @@ const streamHandlers = { oltIds, tagPattern, fromMode = 'private', targetMode = 'auto', ponFloodIdValue = 0, concurrency = 3, } = body; - return runPool(oltIds, concurrency, async (id) => { + const results = await runPool(oltIds, concurrency, async (id) => { try { const cfg = await s.client.getOltConfig(id); if (!cfg) throw new Error('OLT not found'); @@ -424,6 +458,8 @@ const streamHandlers = { return { oltId: id, ok: false, error: toWireError(e) }; } }, (entry, done, total) => write({ ...entry, done, total })); + invalidate(s, ['oltConfigs']); + return results; }, }; @@ -503,5 +539,6 @@ const server = http.createServer(async (req, res) => { server.listen(PORT, HOST, () => { console.log(`[pon-fleet-web] listening on http://${HOST}:${PORT}`); console.log(`[pon-fleet-web] idle session timeout: ${IDLE_MS / 60000} min · verbose MCMS: ${VERBOSE}`); - console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx) in front for remote use.'); + console.log(`[pon-fleet-web] upstream cache: ${cache.enabled() ? (CACHE_TTL_MS / 1000) + 's TTL (shared per MCMS host)' : 'disabled'}`); + console.log('[pon-fleet-web] put a TLS-terminating reverse proxy (Caddy/nginx/NPM) in front for remote use.'); });