mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-20 21:23:23 +00:00
refactor(gateway): share interface discovery helpers
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
47
src/infra/network-interfaces.test.ts
Normal file
47
src/infra/network-interfaces.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
84
src/infra/network-interfaces.ts
Normal file
84
src/infra/network-interfaces.ts
Normal 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;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
47
src/test-helpers/network-interfaces.ts
Normal file
47
src/test-helpers/network-interfaces.ts
Normal 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)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user