From 369827c43fb0586f18d7c35e89024c4513ca69a3 Mon Sep 17 00:00:00 2001 From: Krystie Date: Mon, 6 Jul 2026 04:43:55 -0700 Subject: [PATCH] DC-031: fix /api/v1/network/ips ReferenceError + add regression tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- BACKLOG.md | 3 +- .../__tests__/network-ips-route.test.js | 360 ++++++++++++++++++ dashcaddy-api/src/app.js | 56 +-- dashcaddy-api/src/utilities/backup-manager.js | 3 +- .../src/utilities/network-detector.js | 99 +++++ 5 files changed, 471 insertions(+), 50 deletions(-) create mode 100644 dashcaddy-api/__tests__/network-ips-route.test.js create mode 100644 dashcaddy-api/src/utilities/network-detector.js diff --git a/BACKLOG.md b/BACKLOG.md index 425e117..a691b65 100644 --- a/BACKLOG.md +++ b/BACKLOG.md @@ -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 diff --git a/dashcaddy-api/__tests__/network-ips-route.test.js b/dashcaddy-api/__tests__/network-ips-route.test.js new file mode 100644 index 0000000..b3157cc --- /dev/null +++ b/dashcaddy-api/__tests__/network-ips-route.test.js @@ -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([]); + }); +}); diff --git a/dashcaddy-api/src/app.js b/dashcaddy-api/src/app.js index c00893a..a4c5c90 100644 --- a/dashcaddy-api/src/app.js +++ b/dashcaddy-api/src/app.js @@ -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); diff --git a/dashcaddy-api/src/utilities/backup-manager.js b/dashcaddy-api/src/utilities/backup-manager.js index df24224..2da5fd0 100644 --- a/dashcaddy-api/src/utilities/backup-manager.js +++ b/dashcaddy-api/src/utilities/backup-manager.js @@ -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 diff --git a/dashcaddy-api/src/utilities/network-detector.js b/dashcaddy-api/src/utilities/network-detector.js new file mode 100644 index 0000000..43230fe --- /dev/null +++ b/dashcaddy-api/src/utilities/network-detector.js @@ -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, +};