refactor(gateway): share interface discovery helpers

This commit is contained in:
Peter Steinberger
2026-03-22 15:01:27 -07:00
parent c0d4abc59e
commit 31ee442d3f
9 changed files with 251 additions and 127 deletions

View File

@@ -1,5 +1,6 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import {
isLocalishHost,
isPrivateOrLoopbackAddress,
@@ -321,41 +322,39 @@ describe("pickPrimaryLanIPv4", () => {
const cases = [
{
name: "prefers en0",
interfaces: {
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
en0: [{ address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }],
},
interfaces: makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [{ address: "192.168.1.42", family: "IPv4" }],
}),
expected: "192.168.1.42",
},
{
name: "falls back to eth0",
interfaces: {
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
eth0: [{ address: "10.0.0.5", family: "IPv4", internal: false, netmask: "" }],
},
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
eth0: [{ address: "10.0.0.5", family: "IPv4" }],
}),
expected: "10.0.0.5",
},
{
name: "falls back to any non-internal interface",
interfaces: {
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
wlan0: [{ address: "172.16.0.99", family: "IPv4", internal: false, netmask: "" }],
},
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
}),
expected: "172.16.0.99",
},
{
name: "no non-internal interface",
interfaces: {
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
},
interfaces: makeNetworkInterfacesSnapshot({
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
}),
expected: undefined,
},
] as const;
for (const testCase of cases) {
vi.spyOn(os, "networkInterfaces").mockReturnValue(
testCase.interfaces as unknown as ReturnType<typeof os.networkInterfaces>,
);
vi.spyOn(os, "networkInterfaces").mockReturnValue(testCase.interfaces);
expect(pickPrimaryLanIPv4(), testCase.name).toBe(testCase.expected);
vi.restoreAllMocks();
}

View File

@@ -1,6 +1,9 @@
import type { IncomingMessage } from "node:http";
import net from "node:net";
import os from "node:os";
import {
pickMatchingExternalInterfaceAddress,
safeNetworkInterfaces,
} from "../infra/network-interfaces.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import {
isCanonicalDottedDecimalIPv4,
@@ -15,27 +18,10 @@ import {
* Prefers common interface names (en0, eth0) then falls back to any external IPv4.
*/
export function pickPrimaryLanIPv4(): string | undefined {
let nets: ReturnType<typeof os.networkInterfaces>;
try {
nets = os.networkInterfaces();
} catch {
return undefined;
}
const preferredNames = ["en0", "eth0"];
for (const name of preferredNames) {
const list = nets[name];
const entry = list?.find((n) => n.family === "IPv4" && !n.internal);
if (entry?.address) {
return entry.address;
}
}
for (const list of Object.values(nets)) {
const entry = list?.find((n) => n.family === "IPv4" && !n.internal);
if (entry?.address) {
return entry.address;
}
}
return undefined;
return pickMatchingExternalInterfaceAddress(safeNetworkInterfaces(), {
family: "IPv4",
preferredNames: ["en0", "eth0"],
});
}
export function normalizeHostHeader(hostHeader?: string): string {

View File

@@ -1,5 +1,6 @@
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import {
__testing,
consumeGatewaySigusr1RestartAuthorization,
@@ -217,24 +218,15 @@ describe("infra runtime", () => {
describe("tailnet address detection", () => {
it("detects tailscale IPv4 and IPv6 addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
utun9: [
{
address: "100.123.224.76",
family: "IPv4",
internal: false,
netmask: "",
},
{
address: "fd7a:115c:a1e0::8801:e04c",
family: "IPv6",
internal: false,
netmask: "",
},
],
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
vi.spyOn(os, "networkInterfaces").mockReturnValue(
makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
utun9: [
{ address: "100.123.224.76", family: "IPv4" },
{ address: "fd7a:115c:a1e0::8801:e04c", family: "IPv6" },
],
}),
);
const out = listTailnetAddresses();
expect(out.ipv4).toEqual(["100.123.224.76"]);

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import {
listExternalInterfaceAddresses,
pickMatchingExternalInterfaceAddress,
safeNetworkInterfaces,
} from "./network-interfaces.js";
describe("network-interfaces", () => {
it("returns undefined when interface discovery throws", () => {
expect(
safeNetworkInterfaces(() => {
throw new Error("uv_interface_addresses failed");
}),
).toBeUndefined();
});
it("lists trimmed non-internal external addresses only", () => {
const snapshot = makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [
{ address: " 192.168.1.42 ", family: "IPv4" },
{ address: "fd7a:115c:a1e0::1", family: "IPv6" },
{ address: " ", family: "IPv6" },
],
});
expect(listExternalInterfaceAddresses(snapshot)).toEqual([
{ name: "en0", address: "192.168.1.42", family: "IPv4" },
{ name: "en0", address: "fd7a:115c:a1e0::1", family: "IPv6" },
]);
});
it("prefers configured interface names before falling back", () => {
const snapshot = makeNetworkInterfacesSnapshot({
wlan0: [{ address: "172.16.0.99", family: "IPv4" }],
en0: [{ address: "192.168.1.42", family: "IPv4" }],
});
expect(
pickMatchingExternalInterfaceAddress(snapshot, {
family: "IPv4",
preferredNames: ["en0", "eth0"],
}),
).toBe("192.168.1.42");
});
});

View File

@@ -0,0 +1,84 @@
import os from "node:os";
export type NetworkInterfacesSnapshot = ReturnType<typeof os.networkInterfaces>;
export type NetworkInterfaceFamily = "IPv4" | "IPv6";
export type ExternalNetworkInterfaceAddress = {
name: string;
address: string;
family: NetworkInterfaceFamily;
};
function normalizeNetworkInterfaceFamily(
family: string | number | undefined,
): NetworkInterfaceFamily | undefined {
if (family === "IPv4" || family === 4) {
return "IPv4";
}
if (family === "IPv6" || family === 6) {
return "IPv6";
}
return undefined;
}
export function safeNetworkInterfaces(
networkInterfaces: () => NetworkInterfacesSnapshot = os.networkInterfaces,
): NetworkInterfacesSnapshot | undefined {
try {
return networkInterfaces();
} catch {
return undefined;
}
}
export function listExternalInterfaceAddresses(
snapshot: NetworkInterfacesSnapshot | undefined,
family?: NetworkInterfaceFamily,
): ExternalNetworkInterfaceAddress[] {
const addresses: ExternalNetworkInterfaceAddress[] = [];
if (!snapshot) {
return addresses;
}
for (const [name, entries] of Object.entries(snapshot)) {
if (!entries) {
continue;
}
for (const entry of entries) {
if (!entry || entry.internal) {
continue;
}
const address = entry.address?.trim();
if (!address) {
continue;
}
const entryFamily = normalizeNetworkInterfaceFamily(entry.family);
if (!entryFamily || (family && entryFamily !== family)) {
continue;
}
addresses.push({ name, address, family: entryFamily });
}
}
return addresses;
}
export function pickMatchingExternalInterfaceAddress(
snapshot: NetworkInterfacesSnapshot | undefined,
params: {
family: NetworkInterfaceFamily;
preferredNames?: string[];
matches?: (address: string) => boolean;
},
): string | undefined {
const { family, preferredNames = [], matches = () => true } = params;
const addresses = listExternalInterfaceAddresses(snapshot, family);
for (const name of preferredNames) {
const preferred = addresses.find((entry) => entry.name === name && matches(entry.address));
if (preferred) {
return preferred.address;
}
}
return addresses.find((entry) => matches(entry.address))?.address;
}

View File

@@ -1,5 +1,6 @@
import os from "node:os";
import { afterEach, describe, expect, it, vi } from "vitest";
import { makeNetworkInterfacesSnapshot } from "../test-helpers/network-interfaces.js";
import {
isTailnetIPv4,
listTailnetAddresses,
@@ -20,17 +21,18 @@ describe("tailnet helpers", () => {
});
it("lists unique non-internal tailnet addresses only", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
en0: [
{ address: " 100.88.1.5 ", family: "IPv4", internal: false, netmask: "" },
{ address: "100.88.1.5", family: "IPv4", internal: false, netmask: "" },
{ address: "fd7a:115c:a1e0::1", family: "IPv6", internal: false, netmask: "" },
{ address: " ", family: "IPv6", internal: false, netmask: "" },
{ address: "fe80::1", family: "IPv6", internal: false, netmask: "" },
],
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
vi.spyOn(os, "networkInterfaces").mockReturnValue(
makeNetworkInterfacesSnapshot({
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true }],
en0: [
{ address: " 100.88.1.5 ", family: "IPv4" },
{ address: "100.88.1.5", family: "IPv4" },
{ address: "fd7a:115c:a1e0::1", family: "IPv6" },
{ address: " ", family: "IPv6" },
{ address: "fe80::1", family: "IPv6" },
],
}),
);
expect(listTailnetAddresses()).toEqual({
ipv4: ["100.88.1.5"],
@@ -39,14 +41,15 @@ describe("tailnet helpers", () => {
});
it("picks the first available tailnet addresses", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
utun1: [
{ address: "100.99.1.1", family: "IPv4", internal: false, netmask: "" },
{ address: "100.99.1.2", family: "IPv4", internal: false, netmask: "" },
{ address: "fd7a:115c:a1e0::9", family: "IPv6", internal: false, netmask: "" },
],
// oxlint-disable-next-line typescript/no-explicit-any
} as any);
vi.spyOn(os, "networkInterfaces").mockReturnValue(
makeNetworkInterfacesSnapshot({
utun1: [
{ address: "100.99.1.1", family: "IPv4" },
{ address: "100.99.1.2", family: "IPv4" },
{ address: "fd7a:115c:a1e0::9", family: "IPv6" },
],
}),
);
expect(pickPrimaryTailnetIPv4()).toBe("100.99.1.1");
expect(pickPrimaryTailnetIPv6()).toBe("fd7a:115c:a1e0::9");

View File

@@ -1,5 +1,5 @@
import os from "node:os";
import { isIpInCidr } from "../shared/net/ip.js";
import { listExternalInterfaceAddresses, safeNetworkInterfaces } from "./network-interfaces.js";
export type TailnetAddresses = {
ipv4: string[];
@@ -25,30 +25,12 @@ export function listTailnetAddresses(): TailnetAddresses {
const ipv4: string[] = [];
const ipv6: string[] = [];
let ifaces: ReturnType<typeof os.networkInterfaces>;
try {
ifaces = os.networkInterfaces();
} catch {
return { ipv4, ipv6 };
}
for (const entries of Object.values(ifaces)) {
if (!entries) {
continue;
for (const { address, family } of listExternalInterfaceAddresses(safeNetworkInterfaces())) {
if (family === "IPv4" && isTailnetIPv4(address)) {
ipv4.push(address);
}
for (const e of entries) {
if (!e || e.internal) {
continue;
}
const address = e.address?.trim();
if (!address) {
continue;
}
if (isTailnetIPv4(address)) {
ipv4.push(address);
}
if (isTailnetIPv6(address)) {
ipv6.push(address);
}
if (family === "IPv6" && isTailnetIPv6(address)) {
ipv6.push(address);
}
}

View File

@@ -9,6 +9,10 @@ import {
import { assertExplicitGatewayAuthModeWhenBothConfigured } from "../gateway/auth-mode-policy.js";
import { resolveRequiredConfiguredSecretRefInputString } from "../gateway/resolve-configured-secret-input-string.js";
import { issueDeviceBootstrapToken } from "../infra/device-bootstrap.js";
import {
pickMatchingExternalInterfaceAddress,
safeNetworkInterfaces,
} from "../infra/network-interfaces.js";
import { resolveGatewayBindUrl } from "../shared/gateway-bind-url.js";
import { isCarrierGradeNatIpv4Address, isRfc1918Ipv4Address } from "../shared/net/ip.js";
import { resolveTailnetHostWithRunner } from "../shared/tailscale-status.js";
@@ -118,32 +122,12 @@ function pickIPv4Matching(
networkInterfaces: () => ReturnType<typeof os.networkInterfaces>,
matches: (address: string) => boolean,
): string | null {
let nets: ReturnType<typeof os.networkInterfaces>;
try {
nets = networkInterfaces();
} catch {
return null;
}
for (const entries of Object.values(nets)) {
if (!entries) {
continue;
}
for (const entry of entries) {
const family = entry?.family;
const isIpv4 = family === "IPv4";
if (!entry || entry.internal || !isIpv4) {
continue;
}
const address = entry.address?.trim() ?? "";
if (!address) {
continue;
}
if (matches(address)) {
return address;
}
}
}
return null;
return (
pickMatchingExternalInterfaceAddress(safeNetworkInterfaces(networkInterfaces), {
family: "IPv4",
matches,
}) ?? null
);
}
function pickLanIPv4(

View File

@@ -0,0 +1,47 @@
import os from "node:os";
import type { NetworkInterfacesSnapshot } from "../infra/network-interfaces.js";
type NetworkInterfaceEntry = NonNullable<ReturnType<typeof os.networkInterfaces>[string]>[number];
export type NetworkInterfaceEntryInput = {
address: string;
family: "IPv4" | "IPv6";
internal?: boolean;
netmask?: string;
};
export function makeNetworkInterfaceEntry(
input: NetworkInterfaceEntryInput,
): NetworkInterfaceEntry {
if (input.family === "IPv6") {
return {
address: input.address,
family: "IPv6",
internal: input.internal ?? false,
netmask: input.netmask ?? "",
cidr: null,
mac: "",
scopeid: 0,
};
}
return {
address: input.address,
family: "IPv4",
internal: input.internal ?? false,
netmask: input.netmask ?? "",
cidr: null,
mac: "",
};
}
export function makeNetworkInterfacesSnapshot(
snapshot: Record<string, NetworkInterfaceEntryInput[]>,
): NetworkInterfacesSnapshot {
return Object.fromEntries(
Object.entries(snapshot).map(([name, entries]) => [
name,
entries.map((entry) => makeNetworkInterfaceEntry(entry)),
]),
);
}