Files
dashcaddy/dashcaddy-api/src/utilities/network-detector.js
T
Krystie 369827c43f
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
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).
2026-07-06 15:06:28 -07:00

100 lines
3.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
};