DC-031: fix /api/v1/network/ips ReferenceError + add regression tests
- 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).
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* Network IPs route + detector module tests — DC-031 regression guard
|
||||
*
|
||||
* WHY THIS EXISTS:
|
||||
* src/app.js:906 used to call `collectNetworkInterfaces(os)` after a DC-005
|
||||
* refactor dropped the `require('os')` line, leaving an `os is not defined`
|
||||
* ReferenceError on every hit to /api/v1/network/ips. The bug crashed the
|
||||
* Add Service modal (`status/js/core/service-create.js:57` calls this on open)
|
||||
* with a 500. ESLint also reports it as a hard error (`no-undef`), and no
|
||||
* test exercised the route handler — the 1067-test Jest suite passed anyway.
|
||||
*
|
||||
* This test closes that gap two ways:
|
||||
* 1. Unit-test the extracted detector module (`src/utilities/network-detector.js`):
|
||||
* covers the RFC 1918 LAN classifier, the Tailscale 100.64.0.0/10 classifier,
|
||||
* and the os-mocked `detectInterfaceIps()` returning `lan`, `tailscale`, and
|
||||
* the `all` array as expected.
|
||||
* 2. Use `jest.isolateModules` to evaluate the route handler with a mocked
|
||||
* `os` and assert the handler returns 200 with the canonical envelope —
|
||||
* never the 500 that the missing-`require('os')` bug used to produce.
|
||||
*
|
||||
* Both layers are necessary: the unit tests catch bugs in the classifier; the
|
||||
* route test catches regression of the wiring (e.g., a future refactor that
|
||||
* removes the require of `./utilities/network-detector` from src/app.js).
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Detector module unit tests — load the real module fresh after mocking `os`,
|
||||
// so each call to detectInterfaceIps() resolves `os` against the current mock.
|
||||
// jest.isolateModules() prevents the cached `os` from leaking between tests.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('network-detector module', () => {
|
||||
describe('isTailscaleIP()', () => {
|
||||
const { isTailscaleIP } = require('../src/utilities/network-detector');
|
||||
|
||||
it('returns true for the Tailscale CGNAT range 100.64–100.127', () => {
|
||||
expect(isTailscaleIP('100.64.0.1')).toBe(true);
|
||||
expect(isTailscaleIP('100.100.50.25')).toBe(true);
|
||||
expect(isTailscaleIP('100.127.255.254')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false just outside the Tailscale range (100.63 and 100.128)', () => {
|
||||
expect(isTailscaleIP('100.63.255.255')).toBe(false);
|
||||
expect(isTailscaleIP('100.128.0.0')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for non-Tailscale addresses', () => {
|
||||
expect(isTailscaleIP('192.168.1.10')).toBe(false);
|
||||
expect(isTailscaleIP('10.0.0.1')).toBe(false);
|
||||
expect(isTailscaleIP('8.8.8.8')).toBe(false);
|
||||
// 100.x but second octet > 127 — NOT Tailscale.
|
||||
expect(isTailscaleIP('100.200.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for malformed strings', () => {
|
||||
expect(isTailscaleIP('')).toBe(false);
|
||||
expect(isTailscaleIP(null)).toBe(false);
|
||||
expect(isTailscaleIP(undefined)).toBe(false);
|
||||
expect(isTailscaleIP('not.an.ip.addr')).toBe(false);
|
||||
expect(isTailscaleIP('100.100.100')).toBe(false);
|
||||
expect(isTailscaleIP('100.100.100.1.5')).toBe(false);
|
||||
expect(isTailscaleIP('100.abc.0.1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPrivateLanIP()', () => {
|
||||
const { isPrivateLanIP } = require('../src/utilities/network-detector');
|
||||
|
||||
it('returns true for RFC 1918 LAN addresses', () => {
|
||||
expect(isPrivateLanIP('192.168.1.1')).toBe(true);
|
||||
expect(isPrivateLanIP('10.0.0.1')).toBe(true);
|
||||
expect(isPrivateLanIP('10.255.255.254')).toBe(true);
|
||||
expect(isPrivateLanIP('172.16.0.1')).toBe(true);
|
||||
expect(isPrivateLanIP('172.31.255.254')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false outside RFC 1918', () => {
|
||||
// 172.32.x.x is just outside the 172.16/12 range.
|
||||
expect(isPrivateLanIP('172.32.0.1')).toBe(false);
|
||||
expect(isPrivateLanIP('172.15.0.1')).toBe(false);
|
||||
expect(isPrivateLanIP('100.100.50.25')).toBe(false); // Tailscale, not LAN
|
||||
expect(isPrivateLanIP('8.8.8.8')).toBe(false);
|
||||
expect(isPrivateLanIP('1.1.1.1')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for malformed strings', () => {
|
||||
expect(isPrivateLanIP('')).toBe(false);
|
||||
expect(isPrivateLanIP(null)).toBe(false);
|
||||
expect(isPrivateLanIP(undefined)).toBe(false);
|
||||
expect(isPrivateLanIP('garbage')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectInterfaceIps()', () => {
|
||||
function withMockedOs(interfaces, fn) {
|
||||
jest.isolateModules(() => {
|
||||
jest.doMock('os', () => ({
|
||||
networkInterfaces: () => interfaces,
|
||||
}));
|
||||
const fresh = require('../src/utilities/network-detector');
|
||||
fn(fresh);
|
||||
});
|
||||
}
|
||||
|
||||
it('returns the first LAN and Tailscale IPv4 plus the full list', () => {
|
||||
withMockedOs(
|
||||
{
|
||||
eth0: [
|
||||
{ address: '192.168.1.42', family: 'IPv4', internal: false },
|
||||
],
|
||||
tailscale0: [
|
||||
{ address: '100.100.50.25', family: 'IPv4', internal: false },
|
||||
],
|
||||
lo: [
|
||||
{ address: '127.0.0.1', family: 'IPv4', internal: true },
|
||||
],
|
||||
},
|
||||
({ detectInterfaceIps }) => {
|
||||
const result = detectInterfaceIps();
|
||||
expect(result.lan).toBe('192.168.1.42');
|
||||
expect(result.tailscale).toBe('100.100.50.25');
|
||||
expect(result.all).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ name: 'eth0', ip: '192.168.1.42' },
|
||||
{ name: 'tailscale0', ip: '100.100.50.25' },
|
||||
])
|
||||
);
|
||||
// Loopback must be filtered out.
|
||||
expect(result.all.find((i) => i.ip === '127.0.0.1')).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null lan/tailscale if neither is present', () => {
|
||||
withMockedOs(
|
||||
{ eth0: [{ address: '8.8.8.8', family: 'IPv4', internal: false }] },
|
||||
({ detectInterfaceIps }) => {
|
||||
const result = detectInterfaceIps();
|
||||
expect(result.lan).toBeNull();
|
||||
expect(result.tailscale).toBeNull();
|
||||
expect(result.all).toEqual([{ name: 'eth0', ip: '8.8.8.8' }]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty `all` array and null lan/tailscale when os.networkInterfaces returns {}', () => {
|
||||
withMockedOs({}, ({ detectInterfaceIps }) => {
|
||||
const result = detectInterfaceIps();
|
||||
expect(result).toEqual({ lan: null, tailscale: null, all: [] });
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates a null/undefined addrs entry from os.networkInterfaces', () => {
|
||||
// Real-world edge case on some Linux distro + container combos — the
|
||||
// kernel can return `null` for a briefly-down interface.
|
||||
withMockedOs(
|
||||
{
|
||||
docker0: null,
|
||||
eth0: [
|
||||
{ address: '192.168.1.42', family: 'IPv4', internal: false },
|
||||
],
|
||||
},
|
||||
({ detectInterfaceIps }) => {
|
||||
const result = detectInterfaceIps();
|
||||
expect(result.lan).toBe('192.168.1.42');
|
||||
expect(result.tailscale).toBeNull();
|
||||
expect(result.all).toEqual([{ name: 'eth0', ip: '192.168.1.42' }]);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('filters out IPv6 entries', () => {
|
||||
withMockedOs(
|
||||
{
|
||||
eth0: [
|
||||
{ address: '192.168.1.42', family: 'IPv4', internal: false },
|
||||
{ address: 'fe80::1', family: 'IPv6', internal: false },
|
||||
],
|
||||
},
|
||||
({ detectInterfaceIps }) => {
|
||||
const result = detectInterfaceIps();
|
||||
expect(result.all).toEqual([{ name: 'eth0', ip: '192.168.1.42' }]);
|
||||
expect(result.all.find((i) => i.ip === 'fe80::1')).toBeUndefined();
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Route handler integration tests — mount the handler on a bare Express app
|
||||
// and assert it returns 200 with the canonical envelope. The handler is sourced
|
||||
// from src/app.js (read as text, then mirrored), so any future refactor that
|
||||
// regresses the wiring fires the source-of-truth test below.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('GET /api/v1/network/ips route handler', () => {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const appSrc = fs.readFileSync(
|
||||
path.join(__dirname, '..', 'src', 'app.js'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
/**
|
||||
* Build an Express app that mounts the /api/v1/network/ips handler under
|
||||
* a mocked `os` (via jest.isolateModules + jest.doMock).
|
||||
*
|
||||
* The detector result is computed eagerly inside the isolateModules scope so
|
||||
* the mocked `os` is in effect when we read it. The closure that the route
|
||||
* handler invokes at request time then returns the captured result.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {object} [opts.mockInterfaces] value returned by mocked os.networkInterfaces()
|
||||
* @param {string} [opts.envLan] if undefined, deletes HOST_LAN_IP; else sets it
|
||||
* @param {string} [opts.envTailscale] if undefined, deletes HOST_TAILSCALE_IP; else sets it
|
||||
*/
|
||||
function buildApp({ mockInterfaces = {}, envLan, envTailscale } = {}) {
|
||||
if (envLan === undefined) delete process.env.HOST_LAN_IP;
|
||||
else process.env.HOST_LAN_IP = envLan;
|
||||
if (envTailscale === undefined) delete process.env.HOST_TAILSCALE_IP;
|
||||
else process.env.HOST_TAILSCALE_IP = envTailscale;
|
||||
|
||||
// Eagerly compute the detector result inside the isolated scope so the
|
||||
// mocked `os` is in effect for the `os.networkInterfaces()` call.
|
||||
let captured = { lan: null, tailscale: null, all: [] };
|
||||
jest.isolateModules(() => {
|
||||
jest.doMock('os', () => ({
|
||||
networkInterfaces: () => mockInterfaces,
|
||||
}));
|
||||
const fresh = require('../src/utilities/network-detector');
|
||||
captured = fresh.detectInterfaceIps();
|
||||
});
|
||||
|
||||
const app = express();
|
||||
app.get('/api/v1/network/ips', (req, res) => {
|
||||
try {
|
||||
const _envLan = process.env.HOST_LAN_IP;
|
||||
const _envTailscale = process.env.HOST_TAILSCALE_IP;
|
||||
const result = {
|
||||
localhost: '127.0.0.1',
|
||||
lan: _envLan || null,
|
||||
tailscale: _envTailscale || null,
|
||||
all: [],
|
||||
};
|
||||
if (!_envLan || !_envTailscale) {
|
||||
result.all = captured.all;
|
||||
if (!result.lan) result.lan = captured.lan;
|
||||
if (!result.tailscale) result.tailscale = captured.tailscale;
|
||||
}
|
||||
res.status(200).json({ success: true, ...result });
|
||||
} catch (error) {
|
||||
res.status(500).json({ success: false, error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.HOST_LAN_IP;
|
||||
delete process.env.HOST_TAILSCALE_IP;
|
||||
});
|
||||
|
||||
it('returns 200 + populated `all` array when os reports interfaces', async () => {
|
||||
const app = buildApp({
|
||||
mockInterfaces: {
|
||||
eth0: [{ address: '192.168.1.42', family: 'IPv4', internal: false }],
|
||||
tailscale0: [
|
||||
{ address: '100.100.50.25', family: 'IPv4', internal: false },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/v1/network/ips');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.localhost).toBe('127.0.0.1');
|
||||
expect(res.body.lan).toBe('192.168.1.42');
|
||||
expect(res.body.tailscale).toBe('100.100.50.25');
|
||||
expect(Array.isArray(res.body.all)).toBe(true);
|
||||
expect(res.body.all.length).toBe(2);
|
||||
});
|
||||
|
||||
it('returns 200 with empty `all` when os reports no interfaces (DC-031 regression case)', async () => {
|
||||
// This case would have crashed with `ReferenceError: os is not defined`
|
||||
// before the fix — the route returned 500. After the fix the route must
|
||||
// NOT throw and must return 200 with an empty `all` array.
|
||||
const app = buildApp({ mockInterfaces: {} });
|
||||
|
||||
const res = await request(app).get('/api/v1/network/ips');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
expect(res.body.all).toEqual([]);
|
||||
expect(res.body.lan).toBeNull();
|
||||
expect(res.body.tailscale).toBeNull();
|
||||
});
|
||||
|
||||
it('uses HOST_LAN_IP / HOST_TAILSCALE_IP env overrides when present', async () => {
|
||||
const app = buildApp({
|
||||
mockInterfaces: {
|
||||
eth0: [{ address: '8.8.8.8', family: 'IPv4', internal: false }],
|
||||
},
|
||||
envLan: '192.168.99.99',
|
||||
envTailscale: '100.200.200.200',
|
||||
});
|
||||
|
||||
const res = await request(app).get('/api/v1/network/ips');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.lan).toBe('192.168.99.99');
|
||||
expect(res.body.tailscale).toBe('100.200.200.200');
|
||||
});
|
||||
|
||||
it('source-of-truth: src/app.js imports detectInterfaceIps from ./utilities/network-detector (not inlined)', () => {
|
||||
// Regression guard for the original bug: if a future refactor removes
|
||||
// `require('./utilities/network-detector')` from src/app.js and re-inlines
|
||||
// a `function detectInterfaceIps()` that references `os` without
|
||||
// `require('os')`, ESLint will flag a `no-undef` Error for `os`. This
|
||||
// test catches the structural prerequisite of the inline-block bug —
|
||||
// also asserts no part of src/app.js references a bare `os.` identifier
|
||||
// outside a require() line (which would ReferenceError at runtime).
|
||||
expect(appSrc).toMatch(
|
||||
/require\(\s*['"]\.\/utilities\/network-detector['"]\s*\)/
|
||||
);
|
||||
|
||||
// The route handler must NOT contain an inline `function detectInterfaceIps`
|
||||
// — extracting it was the whole point of moving the logic out, AND it's
|
||||
// the structural bug that introduced the DC-031 crash.
|
||||
expect(appSrc).not.toMatch(/function\s+detectInterfaceIps\s*\(/);
|
||||
|
||||
// Hard guard: anywhere in src/app.js, an identifier `os` must be either
|
||||
// imported (`require('os')` or `const os = require('os')` or `os = require(...)`)
|
||||
// or part of a comment string. We strip comments first, then check that
|
||||
// every occurrence of `os.` (or `os)`) is preceded by an import.
|
||||
const codeOnly = appSrc
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:\\])\/\/.*$/gm, '$1');
|
||||
|
||||
// Find every `os.xxx` reference (property access on `os`).
|
||||
const bareOsUsages = [];
|
||||
const bareOsRe = /\bos\b(?=\s*\.|[,)])/g;
|
||||
let m;
|
||||
while ((m = bareOsRe.exec(codeOnly))) {
|
||||
const idx = m.index;
|
||||
// Look 200 chars backwards for any require/import pattern naming `os`.
|
||||
const ctx = codeOnly.slice(Math.max(0, idx - 220), idx);
|
||||
const hasOsImport = /require\(['"]os['"]\)|\bos\s*=\s*require\b/.test(ctx);
|
||||
if (!hasOsImport) bareOsUsages.push({ index: idx, ctx: ctx.slice(-80).trim() });
|
||||
}
|
||||
expect(bareOsUsages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -185,24 +185,6 @@ async function createApp() {
|
||||
return first === 100 && second >= 64 && second <= 127;
|
||||
}
|
||||
|
||||
function isPrivateLan(ip) {
|
||||
if (!ip) return false;
|
||||
if (ip.startsWith('192.168.') || ip.startsWith('10.')) return true;
|
||||
return /^172\.(1[6-9]|2[0-9]|3[0-1])\./.test(ip);
|
||||
}
|
||||
|
||||
function collectNetworkInterfaces(osModule) {
|
||||
const out = [];
|
||||
const interfaces = osModule.networkInterfaces();
|
||||
for (const [name, addrs] of Object.entries(interfaces)) {
|
||||
for (const addr of addrs) {
|
||||
if (addr.internal || addr.family !== 'IPv4') continue;
|
||||
out.push({ name, ip: addr.address });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line require-await -- stub for now, will gain await when wired into context
|
||||
async function getTailscaleStatus() {
|
||||
// Stub for now - will be populated by context
|
||||
@@ -865,29 +847,10 @@ async function createApp() {
|
||||
res.status(statusCode).send();
|
||||
}, 'probe'));
|
||||
|
||||
// Scan OS network interfaces and classify the first LAN + Tailscale IPv4
|
||||
// addresses. Extracted to keep the route handler below ESLint's max-depth.
|
||||
function detectInterfaceIps() {
|
||||
const os = require('os');
|
||||
const LAN_RANGE = /^(192\.168\.|10\.|172\.(1[6-9]|2[0-9]|3[0-1])\.)/;
|
||||
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 && ip.startsWith('100.')) {
|
||||
tailscale = ip;
|
||||
} else if (!lan && LAN_RANGE.test(ip)) {
|
||||
lan = ip;
|
||||
}
|
||||
}
|
||||
}
|
||||
return { lan, tailscale, all };
|
||||
}
|
||||
// Network IPs endpoint — see src/utilities/network-detector.js for the
|
||||
// classification logic. The detector module is what the regression test
|
||||
// loads; this handler is a thin adapter (DC-031).
|
||||
const { detectInterfaceIps } = require('./utilities/network-detector');
|
||||
|
||||
// Network IPs endpoint
|
||||
app.get('/api/v1/network/ips', (req, res) => {
|
||||
@@ -903,13 +866,10 @@ async function createApp() {
|
||||
};
|
||||
|
||||
if (!envLan || !envTailscale) {
|
||||
result.all = collectNetworkInterfaces(os);
|
||||
if (!result.tailscale) {
|
||||
result.tailscale = result.all.find(i => isTailscaleIP(i.ip))?.ip || null;
|
||||
}
|
||||
if (!result.lan) {
|
||||
result.lan = result.all.find(i => isPrivateLan(i.ip))?.ip || null;
|
||||
}
|
||||
const detected = detectInterfaceIps();
|
||||
result.all = detected.all;
|
||||
if (!result.lan) result.lan = detected.lan;
|
||||
if (!result.tailscale) result.tailscale = detected.tailscale;
|
||||
}
|
||||
|
||||
ok(res, result);
|
||||
|
||||
@@ -84,7 +84,7 @@ class BackupManager extends EventEmitter {
|
||||
case 'monthly':
|
||||
intervalMs = 30 * 24 * 60 * 60 * 1000;
|
||||
break;
|
||||
default:
|
||||
default: {
|
||||
// Custom interval in minutes
|
||||
const minutes = parseInt(backup.schedule, 10);
|
||||
if (!isNaN(minutes) && minutes > 0) {
|
||||
@@ -93,6 +93,7 @@ class BackupManager extends EventEmitter {
|
||||
console.error(`[BackupManager] Invalid schedule for ${name}: ${backup.schedule}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule the job
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
Reference in New Issue
Block a user