feat(firmware): in-app MikroTik release notes + posture findings for pending updates (v0.16.6-beta)

- New GET /api/firmware/changelog/:version proxies MikroTik's official
  per-version release notes (download.mikrotik.com/routeros/<ver>/CHANGELOG)
  with strict version validation and process-lifetime caching
- ChangelogModal: "What's new in X.Y.Z" on the device Config tab's
  update box and on the Firmware page's latest-version cells opens the
  changelog in-app, with an external mikrotik.com link and graceful
  fallback when the CDN is unreachable
- Security posture: a pending RouterOS update is now a medium-severity
  finding and a pending RouterBOOT upgrade a low-severity one; titles
  are version-free so Common Findings aggregates the fleet into one row
- chore(deps): npm audit fix bumps morgan (GHSA-4vj7-5mj6-jm8m); both
  workspaces audit clean

Validation: backend tsc/lint/92 tests, frontend tsc/lint/build/11 tests,
endpoint tested (valid/invalid/unknown versions), browser-verified on
Firmware page, device Config tab, Security Center, and device Security
tab; security review of the diff found no findings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-07-11 15:45:53 -07:00
co-authored by Claude Fable 5
parent 8307bcd291
commit 27f155f778
11 changed files with 217 additions and 49 deletions
+33 -41
View File
@@ -1,12 +1,12 @@
{
"name": "mikrotik-manager-backend",
"version": "0.16.4-beta",
"version": "0.16.6-beta",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mikrotik-manager-backend",
"version": "0.16.4-beta",
"version": "0.16.6-beta",
"dependencies": {
"@influxdata/influxdb-client": "^1.33.2",
"@influxdata/influxdb-client-apis": "^1.35.0",
@@ -993,16 +993,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -1017,20 +1007,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2877,6 +2853,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -5886,6 +5872,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.0.tgz",
"integrity": "sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -6282,31 +6282,23 @@
}
},
"node_modules/morgan": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz",
"integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==",
"version": "1.11.0",
"resolved": "https://registry.npmjs.org/morgan/-/morgan-1.11.0.tgz",
"integrity": "sha512-zSkVu3t18r39pw4ixfBKvfZi3y2UOqr7d4WYwcj3m8nXpEQK4rPO6GLzs/CExoRgmX3y9EjmmcXqv6jq0SK46g==",
"license": "MIT",
"dependencies": {
"basic-auth": "~2.0.1",
"debug": "2.6.9",
"depd": "~2.0.0",
"on-finished": "~2.3.0",
"on-finished": "~2.4.1",
"on-headers": "~1.1.0"
},
"engines": {
"node": ">= 0.8.0"
}
},
"node_modules/morgan/node_modules/on-finished": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz",
"integrity": "sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mikrotik-manager-backend",
"version": "0.16.5-beta",
"version": "0.16.6-beta",
"description": "Mikrotik Network Management Platform - Backend API",
"main": "dist/index.js",
"scripts": {
+26 -1
View File
@@ -1012,7 +1012,13 @@ router.put('/:id/services/:serviceId', requireWrite, async (req, res) => {
// de-duplicated services, and it never proposes disabling the management
// service MikroTik Manager itself connects through (that would self-lockout).
router.get('/:id/security-posture', async (req, res) => {
const device = await queryOne<DeviceRow>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
const device = await queryOne<DeviceRow & {
firmware_update_available?: boolean;
latest_ros_version?: string | null;
routerboard_upgrade_available?: boolean;
firmware_version?: string | null;
upgrade_firmware_version?: string | null;
}>(`SELECT * FROM devices WHERE id = $1`, [req.params.id]);
if (!device) return res.status(404).json({ error: 'Device not found' });
// Which RouterOS service the platform uses to reach this device. Default api
// (8728); api-ssl is 8729. We must never offer to disable this one.
@@ -1079,6 +1085,25 @@ router.get('/:id/security-posture', async (req, res) => {
detail: 'Ensure SNMP uses a non-default community string and is restricted to trusted addresses.' });
}
// Outdated firmware is a security exposure: RouterOS releases routinely
// ship security fixes, so a pending update counts against the posture.
// Titles stay version-free so the Security Center's "Common Findings"
// rollup aggregates devices on different versions into one row.
if (device.firmware_update_available && device.latest_ros_version) {
checks.push({
id: 'firmware-outdated', severity: 'medium',
title: 'RouterOS update available',
detail: `This device runs RouterOS ${device.ros_version || '(unknown)'} but ${device.latest_ros_version} is available. RouterOS releases regularly include security fixes — review the changelog and schedule the upgrade from the Firmware section.`,
});
}
if (device.routerboard_upgrade_available) {
checks.push({
id: 'routerboot-outdated', severity: 'low',
title: 'RouterBOOT upgrade pending',
detail: `The bootloader (${device.firmware_version || 'current'}) has an available upgrade${device.upgrade_firmware_version ? ` to ${device.upgrade_firmware_version}` : ''}. Apply it after the next RouterOS upgrade from the device's Config tab.`,
});
}
// Graduated score: lighter weights + a floor of 5 so it never reads a hard
// 0 (which falsely implies "maximally insecure"). It's a relative hardening
// indicator, not an absolute grade.
+32
View File
@@ -117,6 +117,38 @@ router.post('/rollouts/:id/start', requireWrite, async (req: Request, res: Respo
}
});
// GET /api/firmware/changelog/:version — proxy MikroTik's per-version release
// notes (download.mikrotik.com/routeros/<ver>/CHANGELOG) so the UI can show
// "what's new" without a CORS-blocked cross-origin fetch. Released changelogs
// are immutable, so successful fetches are cached for the process lifetime.
const changelogCache = new Map<string, string>();
const CHANGELOG_CACHE_MAX = 100;
router.get('/changelog/:version', async (req: Request, res: Response) => {
const version = String(req.params.version).trim();
// RouterOS versions: 7.23.1, 7.16, 6.49.10, plus rc/beta suffixes (7.20rc3)
if (!/^\d+\.\d+(\.\d+)?(rc\d+|beta\d+)?$/i.test(version)) {
return res.status(400).json({ error: 'Invalid RouterOS version' });
}
const url = `https://download.mikrotik.com/routeros/${version}/CHANGELOG`;
const cached = changelogCache.get(version);
if (cached) return res.json({ version, url, text: cached });
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!resp.ok) {
return res.status(404).json({ error: `No changelog found for ${version}`, url });
}
const text = await resp.text();
if (changelogCache.size >= CHANGELOG_CACHE_MAX) changelogCache.clear();
changelogCache.set(version, text);
res.json({ version, url, text });
} catch {
res.status(502).json({ error: 'Could not reach the MikroTik changelog server', url });
}
});
// POST /api/firmware/rollouts/:id/cancel — stops before the next device (an
// in-flight upgrade is never interrupted mid-write)
router.post('/rollouts/:id/cancel', requireWrite, async (req: Request, res: Response) => {