915 lines
28 KiB
JavaScript
915 lines
28 KiB
JavaScript
|
|
"use strict";
|
||
|
|
|
||
|
|
const test = require("node:test");
|
||
|
|
const assert = require("node:assert/strict");
|
||
|
|
const { EventEmitter } = require("node:events");
|
||
|
|
|
||
|
|
const {
|
||
|
|
DEFAULT_PROBE_TIMEOUT_MS,
|
||
|
|
DEFAULT_PROBE_HOLD_MS,
|
||
|
|
DISCARD_PORT,
|
||
|
|
isLocalNetworkHostname,
|
||
|
|
isLocalMdnsName,
|
||
|
|
resolveFirstTcpEndpoint,
|
||
|
|
resolveLanProbeTarget,
|
||
|
|
annotateMacLocalNetworkErrorMessage,
|
||
|
|
attachMacLocalNetworkProbeResult,
|
||
|
|
createMacLocalNetworkAccessGate,
|
||
|
|
} = require("./macLocalNetworkAccess.cjs");
|
||
|
|
|
||
|
|
test("DEFAULT_PROBE_TIMEOUT_MS is a few seconds, not tens of seconds", () => {
|
||
|
|
assert.ok(DEFAULT_PROBE_TIMEOUT_MS >= 500);
|
||
|
|
assert.ok(DEFAULT_PROBE_TIMEOUT_MS <= 5_000);
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
});
|
||
|
|
assert.equal(gate.getProbeTimeoutMs(), DEFAULT_PROBE_TIMEOUT_MS);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("DEFAULT_PROBE_HOLD_MS keeps the UDP socket open briefly for TCC", () => {
|
||
|
|
assert.ok(DEFAULT_PROBE_HOLD_MS >= 200);
|
||
|
|
assert.ok(DEFAULT_PROBE_HOLD_MS <= 2_000);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("DISCARD_PORT is the IANA discard service used by Apple TN3179", () => {
|
||
|
|
assert.equal(DISCARD_PORT, 9);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLocalNetworkHostname recognizes RFC1918, link-local, and CGNAT", () => {
|
||
|
|
assert.equal(isLocalNetworkHostname("192.168.5.22"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("10.0.0.1"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("172.16.9.1"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("172.31.255.1"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("169.254.1.1"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("100.64.0.1"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("[fd12::1]"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("fe80::1"), true);
|
||
|
|
assert.equal(isLocalNetworkHostname("::ffff:192.168.1.9"), true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLocalNetworkHostname rejects public, loopback, empty, and non-IP hostnames", () => {
|
||
|
|
assert.equal(isLocalNetworkHostname("8.8.8.8"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("1.1.1.1"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("172.15.0.1"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("172.32.0.1"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("127.0.0.1"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("localhost"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("example.com"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("nas.local"), false);
|
||
|
|
// Must not treat public names that merely start with fc/fd as ULA.
|
||
|
|
assert.equal(isLocalNetworkHostname("fda.gov"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("fdcorp.example"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname("fc-host.example"), false);
|
||
|
|
assert.equal(isLocalNetworkHostname(""), false);
|
||
|
|
assert.equal(isLocalNetworkHostname(null), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("isLocalMdnsName recognizes .local hostnames that require Local Network access", () => {
|
||
|
|
assert.equal(isLocalMdnsName("nas.local"), true);
|
||
|
|
assert.equal(isLocalMdnsName("NAS.LOCAL."), true);
|
||
|
|
assert.equal(isLocalMdnsName("printer.local"), true);
|
||
|
|
assert.equal(isLocalMdnsName("example.com"), false);
|
||
|
|
assert.equal(isLocalMdnsName("192.168.1.1"), false);
|
||
|
|
assert.equal(isLocalMdnsName("localhost"), false);
|
||
|
|
assert.equal(isLocalMdnsName(""), false);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveFirstTcpEndpoint prefers proxy, then jump host, then target", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({
|
||
|
|
hostname: "10.0.0.9",
|
||
|
|
port: 2222,
|
||
|
|
proxy: { type: "socks5", host: "192.168.0.2", port: 1080 },
|
||
|
|
jumpHosts: [{ hostname: "192.168.1.1", port: 2200 }],
|
||
|
|
}),
|
||
|
|
{ hostname: "192.168.0.2", port: 1080 },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({
|
||
|
|
hostname: "10.0.0.9",
|
||
|
|
port: 2222,
|
||
|
|
jumpHosts: [{ hostname: "192.168.1.1", port: 2200 }],
|
||
|
|
}),
|
||
|
|
{ hostname: "192.168.1.1", port: 2200 },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({ hostname: "192.168.5.22", port: 22 }),
|
||
|
|
{ hostname: "192.168.5.22", port: 22 },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({ host: "10.1.2.3" }),
|
||
|
|
{ hostname: "10.1.2.3", port: 22 },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveFirstTcpEndpoint prefers the first jump proxy over session proxy and jump host", () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({
|
||
|
|
hostname: "10.0.0.9",
|
||
|
|
port: 22,
|
||
|
|
proxy: { type: "socks5", host: "203.0.113.10", port: 1080 },
|
||
|
|
jumpHosts: [{
|
||
|
|
hostname: "nas.local",
|
||
|
|
port: 22,
|
||
|
|
proxy: { type: "http", host: "192.168.0.50", port: 8080 },
|
||
|
|
}],
|
||
|
|
}),
|
||
|
|
{ hostname: "192.168.0.50", port: 8080 },
|
||
|
|
);
|
||
|
|
// Active ProxyCommand is terminal: do not fall through to LAN hostnames.
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({
|
||
|
|
hostname: "nas.local",
|
||
|
|
proxy: { type: "command", command: "nc -X connect" },
|
||
|
|
}),
|
||
|
|
{ hostname: "", port: 0, skipProbe: true, reason: "command-proxy" },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
resolveFirstTcpEndpoint({
|
||
|
|
hostname: "10.0.0.9",
|
||
|
|
proxy: { type: "socks5", host: "192.168.0.2", port: 1080 },
|
||
|
|
jumpHosts: [{
|
||
|
|
hostname: "nas.local",
|
||
|
|
proxy: { type: "command", command: "nc -X connect" },
|
||
|
|
}],
|
||
|
|
}),
|
||
|
|
{ hostname: "", port: 0, skipProbe: true, reason: "command-proxy" },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate skips probing when ProxyCommand owns the first hop", async () => {
|
||
|
|
let creates = 0;
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
creates += 1;
|
||
|
|
throw new Error("must not probe past ProxyCommand");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const result = await gate.ensureAccess({
|
||
|
|
hostname: "nas.local",
|
||
|
|
port: 22,
|
||
|
|
proxy: { type: "command", command: "cloudflared access ssh" },
|
||
|
|
});
|
||
|
|
assert.deepEqual(result, { skipped: true, reason: "command-proxy" });
|
||
|
|
assert.equal(creates, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate shares one deadline across DNS and UDP probe", async () => {
|
||
|
|
const safetyTimeouts = [];
|
||
|
|
let now = 5_000;
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeTimeoutMs: 500,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
setTimer: (fn, ms) => {
|
||
|
|
safetyTimeouts.push(ms);
|
||
|
|
// DNS success clears its timer; only fire later UDP hang timers.
|
||
|
|
if (safetyTimeouts.length > 1) queueMicrotask(fn);
|
||
|
|
return { ms };
|
||
|
|
},
|
||
|
|
clearTimer() {},
|
||
|
|
lookup: async () => {
|
||
|
|
now += 200;
|
||
|
|
return [{ address: "192.168.7.37", family: 4 }];
|
||
|
|
},
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
class HangSocket extends EventEmitter {
|
||
|
|
connect() {}
|
||
|
|
close() {}
|
||
|
|
}
|
||
|
|
return new HangSocket();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const originalNow = Date.now;
|
||
|
|
Date.now = () => now;
|
||
|
|
try {
|
||
|
|
await gate.ensureAccess({ hostname: "dev-viet", port: 22 });
|
||
|
|
} finally {
|
||
|
|
Date.now = originalNow;
|
||
|
|
}
|
||
|
|
|
||
|
|
// DNS armed with the full budget; UDP families inherit only the remainder.
|
||
|
|
assert.ok(safetyTimeouts.includes(500), `expected DNS budget arm, got ${JSON.stringify(safetyTimeouts)}`);
|
||
|
|
const udpBudgets = safetyTimeouts.filter((ms) => ms !== 500);
|
||
|
|
assert.ok(udpBudgets.length >= 1, `expected UDP budget arm(s), got ${JSON.stringify(safetyTimeouts)}`);
|
||
|
|
assert.ok(udpBudgets.every((ms) => ms <= 300), `UDP budgets should be residual, got ${JSON.stringify(udpBudgets)}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveLanProbeTarget keeps LAN literals and .local names", async () => {
|
||
|
|
assert.deepEqual(
|
||
|
|
await resolveLanProbeTarget("192.168.7.37"),
|
||
|
|
{ hostname: "192.168.7.37", reason: "literal" },
|
||
|
|
);
|
||
|
|
assert.deepEqual(
|
||
|
|
await resolveLanProbeTarget("dev-viet.local"),
|
||
|
|
{ hostname: "dev-viet.local", reason: "mdns" },
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveLanProbeTarget DNS-resolves hostnames and picks a LAN address", async () => {
|
||
|
|
const target = await resolveLanProbeTarget("dev-viet", {
|
||
|
|
lookup: async () => [
|
||
|
|
{ address: "8.8.8.8", family: 4 },
|
||
|
|
{ address: "192.168.7.37", family: 4 },
|
||
|
|
],
|
||
|
|
});
|
||
|
|
assert.deepEqual(target, { hostname: "192.168.7.37", reason: "resolved" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("resolveLanProbeTarget returns null for public-only hostnames", async () => {
|
||
|
|
const target = await resolveLanProbeTarget("example.com", {
|
||
|
|
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||
|
|
});
|
||
|
|
assert.equal(target, null);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage only rewrites LAN unreachability on darwin", () => {
|
||
|
|
const base = "connect EHOSTUNREACH 192.168.5.22:22";
|
||
|
|
const annotated = annotateMacLocalNetworkErrorMessage(base, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "192.168.5.22",
|
||
|
|
});
|
||
|
|
assert.match(annotated, /Local Network/i);
|
||
|
|
assert.match(annotated, /System Settings/i);
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(base, {
|
||
|
|
platform: "linux",
|
||
|
|
hostname: "192.168.5.22",
|
||
|
|
}),
|
||
|
|
base,
|
||
|
|
);
|
||
|
|
// Message embeds a LAN IP - annotate even when the vault hostname option is public.
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(base, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "8.8.8.8",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(
|
||
|
|
"connect EHOSTUNREACH 8.8.8.8:22",
|
||
|
|
{ platform: "darwin", hostname: "8.8.8.8" },
|
||
|
|
),
|
||
|
|
"connect EHOSTUNREACH 8.8.8.8:22",
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage("All configured authentication methods failed", {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "192.168.5.22",
|
||
|
|
}),
|
||
|
|
"All configured authentication methods failed",
|
||
|
|
);
|
||
|
|
// Annotate when first hop is LAN even if final target is public.
|
||
|
|
const viaJump = annotateMacLocalNetworkErrorMessage(base, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "8.8.8.8",
|
||
|
|
firstHopHostname: "192.168.1.1",
|
||
|
|
});
|
||
|
|
assert.match(viaJump, /Local Network/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage detects LAN IPs embedded in the error text", () => {
|
||
|
|
const message = "Error: connect EHOSTUNREACH 192.168.7.37:22 - Local (192.168.6.131:58210)";
|
||
|
|
const annotated = annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
// Vault host stored as a DNS name, not a literal IP.
|
||
|
|
hostname: "dev-viet",
|
||
|
|
});
|
||
|
|
assert.match(annotated, /Local Network/i);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage ignores LAN bind addresses on public failures", () => {
|
||
|
|
const message = "connect EHOSTUNREACH 93.184.216.34:22 - Local (192.168.1.5:58210)";
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "example.com",
|
||
|
|
}),
|
||
|
|
message,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage detects embedded IPv6 LAN destinations", () => {
|
||
|
|
const bracketed = "connect EHOSTUNREACH [fd12::1]:22";
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(bracketed, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "ula-host",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
const bare = "connect EHOSTUNREACH fe80::1:22 - Local ([fe80::abcd]:5555)";
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(bare, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "link-local-host",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage ignores .local targets behind a public first hop", () => {
|
||
|
|
const message = "connect EHOSTUNREACH 93.184.216.34:1080";
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "nas.local",
|
||
|
|
firstHopHostname: "93.184.216.34",
|
||
|
|
}),
|
||
|
|
message,
|
||
|
|
);
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "nas.local",
|
||
|
|
firstHopHostname: "nas.local",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage ignores downstream LAN addresses behind a public first hop", () => {
|
||
|
|
const message = "connect EHOSTUNREACH 192.168.1.10:22";
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "nas.local",
|
||
|
|
firstHopHostname: "proxy.example.com",
|
||
|
|
}),
|
||
|
|
message,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "192.168.1.10",
|
||
|
|
firstHopHostname: "93.184.216.34",
|
||
|
|
}),
|
||
|
|
message,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage keeps LAN guidance for hostname-based LAN first hops", () => {
|
||
|
|
const message = "connect EHOSTUNREACH 192.168.1.20:22";
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "app.internal",
|
||
|
|
firstHopHostname: "bastion.lan",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage keeps LAN guidance for unqualified first hops", () => {
|
||
|
|
const message = "connect EHOSTUNREACH 192.168.7.37:22";
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "app.example.com",
|
||
|
|
firstHopHostname: "dev-viet",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage uses carried resolved first-hop addresses", () => {
|
||
|
|
const message = "connect EHOSTUNREACH 192.168.1.20:22";
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "app.example.com",
|
||
|
|
firstHopHostname: "bastion.example.com",
|
||
|
|
firstHopResolvedAddress: "192.168.1.20",
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(message, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "app.example.com",
|
||
|
|
firstHopHostname: "bastion.example.com",
|
||
|
|
}),
|
||
|
|
message,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("attachMacLocalNetworkProbeResult forwards resolved first-hop for direct starts", () => {
|
||
|
|
const options = {
|
||
|
|
hostname: "app.example.com",
|
||
|
|
jumpHosts: [{ hostname: "bastion.example.com", port: 22 }],
|
||
|
|
};
|
||
|
|
const attached = attachMacLocalNetworkProbeResult(options, {
|
||
|
|
probed: true,
|
||
|
|
hostname: "192.168.1.20",
|
||
|
|
});
|
||
|
|
assert.equal(attached._macLocalNetworkResolvedFirstHop, "192.168.1.20");
|
||
|
|
assert.match(
|
||
|
|
annotateMacLocalNetworkErrorMessage("connect EHOSTUNREACH 192.168.1.20:22", {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: attached.hostname,
|
||
|
|
firstHopHostname: "bastion.example.com",
|
||
|
|
firstHopResolvedAddress: attached._macLocalNetworkResolvedFirstHop,
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
assert.equal(
|
||
|
|
attachMacLocalNetworkProbeResult(options, { skipped: true, reason: "not-local-network" })
|
||
|
|
._macLocalNetworkResolvedFirstHop,
|
||
|
|
undefined,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("annotateMacLocalNetworkErrorMessage ignores ProxyCommand errors even with LAN evidence", () => {
|
||
|
|
const localName = "Network is unreachable";
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(localName, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "nas.local",
|
||
|
|
firstHopHostname: "",
|
||
|
|
skipProbe: true,
|
||
|
|
}),
|
||
|
|
localName,
|
||
|
|
);
|
||
|
|
|
||
|
|
const childLan = "connect EHOSTUNREACH 192.168.1.10:22";
|
||
|
|
assert.equal(
|
||
|
|
annotateMacLocalNetworkErrorMessage(childLan, {
|
||
|
|
platform: "darwin",
|
||
|
|
hostname: "nas.local",
|
||
|
|
firstHopHostname: "",
|
||
|
|
skipProbe: true,
|
||
|
|
}),
|
||
|
|
childLan,
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
function createFakeUdpSocket({ onConnect, onError } = {}) {
|
||
|
|
class FakeUdpSocket extends EventEmitter {
|
||
|
|
constructor() {
|
||
|
|
super();
|
||
|
|
this.closed = false;
|
||
|
|
this.connectCalls = [];
|
||
|
|
}
|
||
|
|
connect(port, host, callback) {
|
||
|
|
this.connectCalls.push({ port, host });
|
||
|
|
queueMicrotask(() => {
|
||
|
|
if (onError) {
|
||
|
|
this.emit("error", onError);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (typeof callback === "function") callback();
|
||
|
|
if (typeof onConnect === "function") onConnect(this);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
close() {
|
||
|
|
this.closed = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return new FakeUdpSocket();
|
||
|
|
}
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate probes with UDP discard once per LAN endpoint", async () => {
|
||
|
|
const sockets = [];
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
dgram: {
|
||
|
|
createSocket(type) {
|
||
|
|
const socket = createFakeUdpSocket();
|
||
|
|
socket.type = type;
|
||
|
|
sockets.push(socket);
|
||
|
|
return socket;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
await gate.ensureAccess({ hostname: "192.168.5.22", port: 22 });
|
||
|
|
await gate.ensureAccess({ hostname: "192.168.5.22", port: 22 });
|
||
|
|
assert.equal(sockets.length, 1);
|
||
|
|
assert.equal(sockets[0].type, "udp4");
|
||
|
|
assert.deepEqual(sockets[0].connectCalls[0], { port: DISCARD_PORT, host: "192.168.5.22" });
|
||
|
|
assert.equal(sockets[0].closed, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate resolves hostnames before probing", async () => {
|
||
|
|
const sockets = [];
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
lookup: async () => [{ address: "192.168.7.37", family: 4 }],
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
const socket = createFakeUdpSocket();
|
||
|
|
sockets.push(socket);
|
||
|
|
return socket;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await gate.ensureAccess({ hostname: "dev-viet", port: 22 });
|
||
|
|
assert.equal(result.probed, true);
|
||
|
|
assert.equal(result.hostname, "192.168.7.37");
|
||
|
|
assert.deepEqual(sockets[0].connectCalls[0], { port: DISCARD_PORT, host: "192.168.7.37" });
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate probes .local names without requiring DNS", async () => {
|
||
|
|
const sockets = [];
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
lookup: async () => {
|
||
|
|
throw new Error("DNS must not be required for .local");
|
||
|
|
},
|
||
|
|
dgram: {
|
||
|
|
createSocket(type) {
|
||
|
|
const socket = createFakeUdpSocket();
|
||
|
|
socket.type = type;
|
||
|
|
sockets.push(socket);
|
||
|
|
return socket;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await gate.ensureAccess({ hostname: "nas.local", port: 22 });
|
||
|
|
assert.equal(result.probed, true);
|
||
|
|
assert.deepEqual(sockets[0].connectCalls[0], { port: DISCARD_PORT, host: "nas.local" });
|
||
|
|
assert.equal(sockets[0].type, "udp4");
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate retries .local probes over udp6 when udp4 fails", async () => {
|
||
|
|
const sockets = [];
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
dgram: {
|
||
|
|
createSocket(type) {
|
||
|
|
const socket = createFakeUdpSocket(
|
||
|
|
type === "udp4"
|
||
|
|
? { onError: Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" }) }
|
||
|
|
: undefined,
|
||
|
|
);
|
||
|
|
socket.type = type;
|
||
|
|
sockets.push(socket);
|
||
|
|
return socket;
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await gate.ensureAccess({ hostname: "ipv6-only.local", port: 22 });
|
||
|
|
assert.equal(result.probed, true);
|
||
|
|
assert.deepEqual(sockets.map((socket) => socket.type), ["udp4", "udp6"]);
|
||
|
|
assert.deepEqual(sockets[1].connectCalls[0], { port: DISCARD_PORT, host: "ipv6-only.local" });
|
||
|
|
assert.equal(sockets[1].closed, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate shares one timeout across udp4 and udp6 fallbacks", async () => {
|
||
|
|
const safetyTimeouts = [];
|
||
|
|
let now = 1_000;
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeTimeoutMs: 500,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
setTimer: (fn, ms) => {
|
||
|
|
safetyTimeouts.push(ms);
|
||
|
|
// First family burns its reserved slice before failing.
|
||
|
|
if (safetyTimeouts.length === 1) now += ms;
|
||
|
|
queueMicrotask(fn);
|
||
|
|
return { ms };
|
||
|
|
},
|
||
|
|
clearTimer() {},
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
class HangSocket extends EventEmitter {
|
||
|
|
connect() {
|
||
|
|
// Never connects; each family burns part of the shared budget.
|
||
|
|
}
|
||
|
|
close() {}
|
||
|
|
}
|
||
|
|
return new HangSocket();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const originalNow = Date.now;
|
||
|
|
Date.now = () => now;
|
||
|
|
try {
|
||
|
|
await gate.ensureAccess({ hostname: "hang.local", port: 22 });
|
||
|
|
} finally {
|
||
|
|
Date.now = originalNow;
|
||
|
|
}
|
||
|
|
|
||
|
|
assert.equal(safetyTimeouts.length, 2);
|
||
|
|
// Budget is split so udp6 always gets an attempt.
|
||
|
|
assert.equal(safetyTimeouts[0], 250);
|
||
|
|
assert.equal(safetyTimeouts[1], 250);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate skips non-darwin and non-LAN hosts", async () => {
|
||
|
|
let creates = 0;
|
||
|
|
const linuxGate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "linux",
|
||
|
|
forceElectron: true,
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
creates += 1;
|
||
|
|
throw new Error("should not create socket");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await linuxGate.ensureAccess({ hostname: "192.168.5.22", port: 22 });
|
||
|
|
|
||
|
|
const darwinGate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
creates += 1;
|
||
|
|
throw new Error("should not create socket");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await darwinGate.ensureAccess({ hostname: "example.com", port: 22 });
|
||
|
|
await darwinGate.ensureAccess({ hostname: "fda.gov", port: 22 });
|
||
|
|
|
||
|
|
const bareNodeGate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
versions: {},
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
creates += 1;
|
||
|
|
throw new Error("should not create socket outside Electron");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await bareNodeGate.ensureAccess({ hostname: "192.168.5.22", port: 22 });
|
||
|
|
assert.equal(creates, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate treats UDP errors as a completed probe", async () => {
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
return createFakeUdpSocket({
|
||
|
|
onError: Object.assign(new Error("connect EHOSTUNREACH"), { code: "EHOSTUNREACH" }),
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await gate.ensureAccess({
|
||
|
|
hostname: "10.0.0.8",
|
||
|
|
port: 22,
|
||
|
|
jumpHosts: [{ hostname: "192.168.1.50", port: 2200 }],
|
||
|
|
});
|
||
|
|
// Second call must be cached even though the first connect failed.
|
||
|
|
let secondCreates = 0;
|
||
|
|
const cachedGate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probedKeys: new Set(["192.168.1.50"]),
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
secondCreates += 1;
|
||
|
|
throw new Error("should use cache");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await cachedGate.ensureAccess({
|
||
|
|
jumpHosts: [{ hostname: "192.168.1.50", port: 2200 }],
|
||
|
|
});
|
||
|
|
assert.equal(secondCreates, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate holds the UDP socket before closing", async () => {
|
||
|
|
const holds = [];
|
||
|
|
let resolveConnect;
|
||
|
|
const connectSeen = new Promise((resolve) => {
|
||
|
|
resolveConnect = resolve;
|
||
|
|
});
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 25,
|
||
|
|
setTimer: (fn, ms) => {
|
||
|
|
holds.push(ms);
|
||
|
|
return setTimeout(fn, ms);
|
||
|
|
},
|
||
|
|
clearTimer: clearTimeout,
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
return createFakeUdpSocket({
|
||
|
|
onConnect(socket) {
|
||
|
|
resolveConnect(socket);
|
||
|
|
},
|
||
|
|
});
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const pending = gate.ensureAccess({ hostname: "10.0.0.1", port: 22 });
|
||
|
|
const socket = await connectSeen;
|
||
|
|
assert.equal(socket.closed, false);
|
||
|
|
await pending;
|
||
|
|
assert.ok(holds.includes(25), `expected hold timer 25ms, got ${JSON.stringify(holds)}`);
|
||
|
|
assert.equal(socket.closed, true);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate applies the configured probe timeout as a safety net", async () => {
|
||
|
|
const timeouts = [];
|
||
|
|
const holdMs = 5;
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeTimeoutMs: DEFAULT_PROBE_TIMEOUT_MS,
|
||
|
|
probeHoldMs: holdMs,
|
||
|
|
setTimer: (fn, ms) => {
|
||
|
|
timeouts.push(ms);
|
||
|
|
// Shared-deadline math often arms a residual (e.g. 2999) rather than the
|
||
|
|
// exact constant. Fire any safety-budget timer; the hang socket never connects.
|
||
|
|
if (ms > holdMs) queueMicrotask(fn);
|
||
|
|
return { ms };
|
||
|
|
},
|
||
|
|
clearTimer() {},
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
class HangSocket extends EventEmitter {
|
||
|
|
connect() {
|
||
|
|
// Never connects; safety timeout must finish the probe.
|
||
|
|
}
|
||
|
|
close() {}
|
||
|
|
}
|
||
|
|
return new HangSocket();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
await gate.ensureAccess({ hostname: "10.0.0.1", port: 22 });
|
||
|
|
const safetyTimeouts = timeouts.filter((ms) => ms > holdMs);
|
||
|
|
assert.equal(safetyTimeouts.length, 1);
|
||
|
|
assert.ok(
|
||
|
|
safetyTimeouts[0] >= DEFAULT_PROBE_TIMEOUT_MS - 50
|
||
|
|
&& safetyTimeouts[0] <= DEFAULT_PROBE_TIMEOUT_MS,
|
||
|
|
`expected residual safety budget near ${DEFAULT_PROBE_TIMEOUT_MS}, got ${safetyTimeouts[0]}`
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate clears the safety timer so the full hold runs after a slow connect", async () => {
|
||
|
|
const cleared = [];
|
||
|
|
const holds = [];
|
||
|
|
const safetyHandles = [];
|
||
|
|
const safetyMs = 500;
|
||
|
|
const holdMs = 40;
|
||
|
|
let resolveConnectReady;
|
||
|
|
const connectReady = new Promise((resolve) => {
|
||
|
|
resolveConnectReady = resolve;
|
||
|
|
});
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeTimeoutMs: safetyMs,
|
||
|
|
probeHoldMs: holdMs,
|
||
|
|
setTimer: (fn, ms) => {
|
||
|
|
const handle = { ms, fn, cleared: false };
|
||
|
|
if (ms === holdMs) {
|
||
|
|
holds.push(ms);
|
||
|
|
queueMicrotask(fn);
|
||
|
|
} else if (ms > holdMs) {
|
||
|
|
// Connect budget may be a residual (e.g. 499) after shared-deadline math.
|
||
|
|
safetyHandles.push(handle);
|
||
|
|
}
|
||
|
|
return handle;
|
||
|
|
},
|
||
|
|
clearTimer(handle) {
|
||
|
|
if (handle && typeof handle === "object") {
|
||
|
|
handle.cleared = true;
|
||
|
|
cleared.push(handle.ms);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
class SlowSocket extends EventEmitter {
|
||
|
|
connect(_port, _host, callback) {
|
||
|
|
resolveConnectReady(callback);
|
||
|
|
}
|
||
|
|
close() {}
|
||
|
|
}
|
||
|
|
return new SlowSocket();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const pending = gate.ensureAccess({ hostname: "192.168.1.10", port: 22 });
|
||
|
|
const connectCallback = await connectReady;
|
||
|
|
// Simulate connect completing late in the safety window.
|
||
|
|
assert.equal(typeof connectCallback, "function");
|
||
|
|
assert.equal(safetyHandles.length, 1);
|
||
|
|
assert.ok(
|
||
|
|
safetyHandles[0].ms <= safetyMs && safetyHandles[0].ms >= safetyMs - 50,
|
||
|
|
`unexpected safety budget ${safetyHandles[0].ms}`,
|
||
|
|
);
|
||
|
|
connectCallback();
|
||
|
|
await pending;
|
||
|
|
|
||
|
|
assert.equal(safetyHandles[0].cleared, true);
|
||
|
|
assert.ok(
|
||
|
|
cleared.includes(safetyHandles[0].ms),
|
||
|
|
`safety timer should be cleared on connect, cleared=${JSON.stringify(cleared)}`,
|
||
|
|
);
|
||
|
|
assert.deepEqual(holds, [holdMs]);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate skips when main process already probed", async () => {
|
||
|
|
let creates = 0;
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
creates += 1;
|
||
|
|
throw new Error("worker must not probe after main");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const result = await gate.ensureAccess({
|
||
|
|
hostname: "192.168.5.22",
|
||
|
|
port: 22,
|
||
|
|
_macLocalNetworkMainProbed: true,
|
||
|
|
});
|
||
|
|
assert.deepEqual(result, { skipped: true, reason: "main-probed" });
|
||
|
|
assert.equal(creates, 0);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate bounds hostname DNS lookup by the probe timeout", async () => {
|
||
|
|
let creates = 0;
|
||
|
|
const timeouts = [];
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeTimeoutMs: 500,
|
||
|
|
probeHoldMs: 5,
|
||
|
|
setTimer: (fn, ms) => {
|
||
|
|
timeouts.push(ms);
|
||
|
|
queueMicrotask(fn);
|
||
|
|
return { ms };
|
||
|
|
},
|
||
|
|
clearTimer() {},
|
||
|
|
lookup: () => new Promise(() => {
|
||
|
|
// Never resolves - must be aborted by the probe timeout budget.
|
||
|
|
}),
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
creates += 1;
|
||
|
|
throw new Error("must not probe after DNS timeout");
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
|
||
|
|
const result = await gate.ensureAccess({ hostname: "slow-dns.example", port: 22 });
|
||
|
|
|
||
|
|
assert.deepEqual(result, { skipped: true, reason: "not-local-network" });
|
||
|
|
assert.equal(creates, 0);
|
||
|
|
assert.ok(timeouts.includes(500), `expected DNS timeout arm, got ${JSON.stringify(timeouts)}`);
|
||
|
|
});
|
||
|
|
|
||
|
|
test("createMacLocalNetworkAccessGate carries resolved first-hop addresses into annotation", async () => {
|
||
|
|
const gate = createMacLocalNetworkAccessGate({
|
||
|
|
platform: "darwin",
|
||
|
|
forceElectron: true,
|
||
|
|
probeHoldMs: 0,
|
||
|
|
lookup: async () => ({ address: "192.168.1.20", family: 4 }),
|
||
|
|
dgram: {
|
||
|
|
createSocket() {
|
||
|
|
return createFakeUdpSocket();
|
||
|
|
},
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const probe = await gate.ensureAccess({
|
||
|
|
jumpHosts: [{ hostname: "bastion.example.com", port: 22 }],
|
||
|
|
});
|
||
|
|
assert.equal(probe.hostname, "192.168.1.20");
|
||
|
|
assert.equal(gate.getResolvedFirstHop("bastion.example.com"), "192.168.1.20");
|
||
|
|
assert.match(
|
||
|
|
gate.annotateErrorMessage("connect EHOSTUNREACH 192.168.1.20:22", {
|
||
|
|
hostname: "app.example.com",
|
||
|
|
jumpHosts: [{ hostname: "bastion.example.com", port: 22 }],
|
||
|
|
}),
|
||
|
|
/Local Network/i,
|
||
|
|
);
|
||
|
|
});
|