diff --git a/src/gateway/net.test.ts b/src/gateway/net.test.ts new file mode 100644 index 000000000..24fdceed9 --- /dev/null +++ b/src/gateway/net.test.ts @@ -0,0 +1,41 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +const testTailnetIPv4 = { value: undefined as string | undefined }; +const testTailnetIPv6 = { value: undefined as string | undefined }; + +vi.mock("../infra/tailnet.js", () => ({ + pickPrimaryTailnetIPv4: () => testTailnetIPv4.value, + pickPrimaryTailnetIPv6: () => testTailnetIPv6.value, +})); + +import { isLocalGatewayAddress } from "./net.js"; + +describe("gateway net", () => { + beforeEach(() => { + testTailnetIPv4.value = undefined; + testTailnetIPv6.value = undefined; + }); + + test("treats loopback as local", () => { + expect(isLocalGatewayAddress("127.0.0.1")).toBe(true); + expect(isLocalGatewayAddress("127.0.1.1")).toBe(true); + expect(isLocalGatewayAddress("::1")).toBe(true); + expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true); + }); + + test("treats local tailnet IPv4 as local", () => { + testTailnetIPv4.value = "100.64.0.1"; + expect(isLocalGatewayAddress("100.64.0.1")).toBe(true); + expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true); + }); + + test("ignores non-matching tailnet IPv4", () => { + testTailnetIPv4.value = "100.64.0.1"; + expect(isLocalGatewayAddress("100.64.0.2")).toBe(false); + }); + + test("treats local tailnet IPv6 as local", () => { + testTailnetIPv6.value = "fd7a:115c:a1e0::123"; + expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true); + }); +}); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index f46c1592d..890a5eee9 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -1,6 +1,6 @@ import net from "node:net"; -import { pickPrimaryTailnetIPv4 } from "../infra/tailnet.js"; +import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js"; export function isLoopbackAddress(ip: string | undefined): boolean { if (!ip) return false; @@ -11,6 +11,22 @@ export function isLoopbackAddress(ip: string | undefined): boolean { return false; } +function normalizeIPv4MappedAddress(ip: string): string { + if (ip.startsWith("::ffff:")) return ip.slice("::ffff:".length); + return ip; +} + +export function isLocalGatewayAddress(ip: string | undefined): boolean { + if (isLoopbackAddress(ip)) return true; + if (!ip) return false; + const normalized = normalizeIPv4MappedAddress(ip.trim().toLowerCase()); + const tailnetIPv4 = pickPrimaryTailnetIPv4(); + if (tailnetIPv4 && normalized === tailnetIPv4.toLowerCase()) return true; + const tailnetIPv6 = pickPrimaryTailnetIPv6(); + if (tailnetIPv6 && ip.trim().toLowerCase() === tailnetIPv6.toLowerCase()) return true; + return false; +} + /** * Resolves gateway bind host with fallback strategy. * diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 3bb4a0cef..d21d19d67 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -25,7 +25,7 @@ import type { ResolvedGatewayAuth } from "../../auth.js"; import { authorizeGatewayConnect } from "../../auth.js"; import { loadConfig } from "../../../config/config.js"; import { buildDeviceAuthPayload } from "../../device-auth.js"; -import { isLoopbackAddress } from "../../net.js"; +import { isLocalGatewayAddress } from "../../net.js"; import { resolveNodeCommandAllowlist } from "../../node-command-policy.js"; import { type ConnectParams, @@ -347,7 +347,7 @@ export function attachGatewayWsMessageHandler(params: { close(1008, "device signature expired"); return; } - const nonceRequired = !isLoopbackAddress(remoteAddr); + const nonceRequired = !isLocalGatewayAddress(remoteAddr); const providedNonce = typeof device.nonce === "string" ? device.nonce.trim() : ""; if (nonceRequired && !providedNonce) { setHandshakeState("failed"); @@ -524,7 +524,7 @@ export function attachGatewayWsMessageHandler(params: { role, scopes, remoteIp: remoteAddr, - silent: isLoopbackAddress(remoteAddr), + silent: isLocalGatewayAddress(remoteAddr), }); const context = buildRequestContext(); if (pairing.request.silent === true) { @@ -656,7 +656,7 @@ export function attachGatewayWsMessageHandler(params: { if (presenceKey) { upsertPresence(presenceKey, { host: connectParams.client.displayName ?? connectParams.client.id ?? os.hostname(), - ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, + ip: isLocalGatewayAddress(remoteAddr) ? undefined : remoteAddr, version: connectParams.client.version, platform: connectParams.client.platform, deviceFamily: connectParams.client.deviceFamily,