DC-031: fix /api/v1/network/ips ReferenceError + add regression tests
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

- 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:
Krystie
2026-07-06 15:06:28 -07:00
committed by Hermes
parent 71fd7cd58f
commit 369827c43f
5 changed files with 471 additions and 50 deletions
+2 -1
View File
@@ -104,9 +104,10 @@
## P0 — Must Fix (blocks public release)
### DC-031: /api/v1/network/ips crashes with ReferenceError — Add Service modal silently broken
- **status:** in-progress
- **status:** done
- **owner:** hermes
- **details:** Audited via `npx eslint src/`. `src/app.js:906` calls `collectNetworkInterfaces(os)` but `os` was removed from scope by the DC-004 refactor (commit `a37e79a` replaced the inline `const os = require('os')` block with a `detectInterfaceIps()` helper that requires `os` internally). The merge into main (`283121e`) brought back the old `collectNetworkInterfaces(os)` reference but lost the `require('os')` line. Result: every hit to `/api/v1/network/ips` (called from `status/js/core/service-create.js:57` on Add Service modal open) throws `ReferenceError: os is not defined` → 500. ESLint also catches it as `Error - 'os' is not defined. (no-undef)`. The endpoint is auth-protected (not in `PUBLIC_ROUTES`), so logged-out users get a clean 401 — the crash is masked until a logged-in admin clicks Add Service and the LAN/Tailscale auto-detect silently fails. Fix: route handler must call `detectInterfaceIps()` (which manages its own `require('os')`), drop the dead `detectInterfaceIps()` helper if unused, or wire it back into the handler properly. Add a regression test that hits the route through the app and asserts 200 + a populated `all` array.
- **result:** Extracted LAN/Tailscale classification into a dedicated module `src/utilities/network-detector.js` exporting `detectInterfaceIps()`, `isTailscaleIP()`, `isPrivateLanIP()`. The route handler in `src/app.js` is now a thin adapter that requires the module — no inline `os` reference, no inline classification logic. Added `__tests__/network-ips-route.test.js` (16 tests) covering: 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 via `jest.isolateModules` + `jest.doMock('os')` 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; plus a source-of-truth test that fails if a future refactor reintroduces `function detectInterfaceIps(...)` inline in `src/app.js` or references `os.` without a prior `require('os')` line. Pre-fix baseline had no test exercising this route, so the 1071-test suite passed despite the 500. Post-fix: 1087/1087 tests pass (+16 new), zero new ESLint warnings. Also fixed a latent bug in `src/utilities/backup-manager.js` that was sitting unstaged — `default:` case had a `const minutes` declaration without a surrounding block, triggering ESLint `no-case-declarations` Error. Added the block braces.
## P2 — Polish & DX
@@ -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.64100.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([]);
});
});
+8 -48
View File
@@ -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.0100.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,
};