web: shared MCMS cache + systemd/clone-to-run deploy + NPM docs

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 <noreply@anthropic.com>
This commit is contained in:
Jon Vanvik 2026-06-23 14:21:04 +02:00
parent 96b0264179
commit 6ee1d37a79
7 changed files with 309 additions and 35 deletions

View file

@ -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
`<input type=file>`. 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.
---

View file

@ -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.
**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
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
}
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

79
web/cache.js Normal file
View file

@ -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 "<mcms-base-url>|<dataset>": 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 };

View file

@ -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

View file

@ -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

22
web/deploy/update.sh Executable file
View file

@ -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."

View file

@ -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.');
});