- Extract LAN/Tailscale classification into src/utilities/network-detector.js
(detectInterfaceIps, isTailscaleIP, isPrivateLanIP). The route handler in
src/app.js is now a thin adapter — no inline 'os' reference, no inline
classification logic.
- Drop the dead 'collectNetworkInterfaces' / inline 'detectInterfaceIps'
helpers from app.js (the original ReferenceError shape).
- Add __tests__/network-ips-route.test.js (16 tests):
- Detector unit tests for Tailscale CGNAT (100.64/10) and RFC 1918 LAN
ranges with malformed-input guards.
- detectInterfaceIps() behavior under os-mocked interfaces with IPv4
filtering, IPv6 exclusion, null addrs tolerance.
- Route handler integration tests asserting 200 + canonical envelope on
the populated path, the empty-path (regression case for the original
bug shape), and HOST_LAN_IP/HOST_TAILSCALE_IP env override branches.
- Source-of-truth test that fails if a future refactor reintroduces an
inline detectInterfaceIps() in src/app.js or references 'os' without
a prior require('os') line.
- Fix latent ESLint Error in backup-manager.js: the 'default:' case had a
'const minutes' declaration without a surrounding block, triggering
no-case-declarations. Added the block braces.
Pre-fix baseline: no test exercised this route, so the 1071-test suite
passed despite the 500. Post-fix: 1087/1087 tests pass (+16 new), zero new
ESLint warnings (10 pre-existing warnings in backup-manager.js unrelated
to this commit).
100 lines
3.7 KiB
JavaScript
100 lines
3.7 KiB
JavaScript
/**
|
||
* Network interface detection — extracts the LAN + Tailscale IP discovery logic
|
||
* out of src/app.js so it can be unit-tested in isolation (DC-031 regression guard).
|
||
*
|
||
* Why this lives in its own module instead of inside createApp():
|
||
* The route handler at /api/v1/network/ips previously had this logic inlined.
|
||
* A bad refactor dropped the `require('os')` line and left a `collectNetworkInterfaces(os)`
|
||
* reference that crashed with ReferenceError on every request — and no test caught it
|
||
* because no test exercised the route handler. By extracting the detector here,
|
||
* (a) the module can be unit-tested without booting the entire Express app and
|
||
* its middleware/auth/CSRF stack, and
|
||
* (b) the route handler in src/app.js becomes a thin adapter that calls
|
||
* `detectInterfaceIps()` — if a future refactor reintroduces an inline `os`
|
||
* reference, the inline-block of test will still pass, but the regression
|
||
* suite around this module will catch any divergence in the detector contract.
|
||
*
|
||
* Public API:
|
||
* detectInterfaceIps() -> { lan: string|null, tailscale: string|null, all: Array<{name, ip}> }
|
||
* Scans the host's network interfaces and returns the first matching LAN and
|
||
* Tailscale IPv4 address (if any), plus the full list of non-internal IPv4
|
||
* interfaces. Used by /api/v1/network/ips to pre-fill the "Add Service" form
|
||
* auto-detect UX.
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
// Host LAN IPv4 ranges per RFC 1918:
|
||
// 10.0.0.0/8
|
||
// 172.16.0.0/12 -> 172.16.* through 172.31.*
|
||
// 192.168.0.0/16
|
||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||
|
||
/**
|
||
* Scan the host's network interfaces and return the first LAN and Tailscale IPv4
|
||
* addresses (if found), plus the full list of non-internal IPv4 interfaces.
|
||
*
|
||
* Tailscale IP range: 100.64.0.0/10 (Tailscale assigns addresses from this CGNAT
|
||
* range — the entire 100.64.0.0–100.127.255.255 block).
|
||
*
|
||
* @returns {{lan: string|null, tailscale: string|null, all: Array<{name: string, ip: string}>}}
|
||
*/
|
||
function detectInterfaceIps() {
|
||
// Use a lazy require so test code that mocks `os` can swap it before this
|
||
// function executes. Production callers hit the real `os` module the first
|
||
// time the route handler runs.
|
||
const os = require('os');
|
||
const all = [];
|
||
let lan = null;
|
||
let tailscale = null;
|
||
|
||
const interfaces = os.networkInterfaces();
|
||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||
for (const addr of addrs || []) {
|
||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||
const { address: ip } = addr;
|
||
all.push({ name, ip });
|
||
if (!tailscale && isTailscaleIP(ip)) {
|
||
tailscale = ip;
|
||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||
lan = ip;
|
||
}
|
||
}
|
||
}
|
||
return { lan, tailscale, all };
|
||
}
|
||
|
||
/**
|
||
* Tailscale assigns IPv4 addresses from 100.64.0.0/10 (the CGNAT space carved out
|
||
* for it). The first octet must be 100, the second octet must be in [64, 127].
|
||
*
|
||
* @param {string} ip an IPv4 dotted-quad address (e.g. "100.100.50.25")
|
||
* @returns {boolean} true if `ip` falls in the Tailscale CGNAT range
|
||
*/
|
||
function isTailscaleIP(ip) {
|
||
if (!ip) return false;
|
||
const parts = ip.split('.');
|
||
if (parts.length !== 4) return false;
|
||
const first = parseInt(parts[0], 10);
|
||
const second = parseInt(parts[1], 10);
|
||
if (Number.isNaN(first) || Number.isNaN(second)) return false;
|
||
return first === 100 && second >= 64 && second <= 127;
|
||
}
|
||
|
||
/**
|
||
* Determine whether an IPv4 address is in one of the RFC 1918 private LAN ranges.
|
||
* @param {string} ip an IPv4 dotted-quad address
|
||
* @returns {boolean}
|
||
*/
|
||
function isPrivateLanIP(ip) {
|
||
if (!ip) return false;
|
||
return LAN_RANGE.test(ip);
|
||
}
|
||
|
||
module.exports = {
|
||
detectInterfaceIps,
|
||
isTailscaleIP,
|
||
isPrivateLanIP,
|
||
LAN_RANGE,
|
||
};
|