/** * 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, };