mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a615432264 | ||
|
|
03402a43ff | ||
|
|
5d313ebbae | ||
|
|
ffc133ad96 | ||
|
|
861a3c60dc | ||
|
|
df2a280f04 | ||
|
|
f5104a00f0 | ||
|
|
c60b75b3d8 | ||
|
|
eef8f285d0 | ||
|
|
90d9f9dd26 | ||
|
|
74a64d7469 | ||
|
|
584eecbeb6 | ||
|
|
ebb68538fa | ||
|
|
c599aeed03 | ||
|
|
1388aaccc7 | ||
|
|
288431a285 | ||
|
|
3e03fd4c0e | ||
|
|
81c0061394 |
No files matched your search
@@ -1,7 +1,7 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* macOS Local Network privacy gate (Apple TN3179 / issue #2663).
|
||||
* macOS Local Network privacy gate (Apple TN3179 / issues #1040, #2663, #2673).
|
||||
*
|
||||
* Since macOS 15, outbound TCP/UDP to LAN addresses requires the user's
|
||||
* Local Network privilege. Netcatty already declares
|
||||
@@ -9,20 +9,32 @@
|
||||
* (#1040), but SSH sessions normally run inside Electron's utilityProcess
|
||||
* (terminal worker). Connections from that helper often fail with
|
||||
* EHOSTUNREACH without ever registering "Netcatty" under
|
||||
* System Settings → Privacy & Security → Local Network — so the user never
|
||||
* System Settings -> Privacy & Security -> Local Network - so the user never
|
||||
* gets a prompt and has nothing to toggle.
|
||||
*
|
||||
* Before the worker opens a LAN socket, the main process performs a short
|
||||
* TCP connect to the first hop so TCC attributes the attempt to the app
|
||||
* bundle and can present the system alert.
|
||||
* Before the worker opens a LAN socket, the main process performs Apple's
|
||||
* recommended trigger: connect a UDP socket to a local-network address on
|
||||
* the discard port (9). That attributes the attempt to the app bundle and
|
||||
* can present the system alert without sending traffic (TN3179).
|
||||
*
|
||||
* Hostnames are resolved first so vault entries like "dev-viet" that map to
|
||||
* 192.168.x still probe; `.local` mDNS names are probed directly.
|
||||
*/
|
||||
|
||||
const net = require("node:net");
|
||||
const dgram = require("node:dgram");
|
||||
const dns = require("node:dns");
|
||||
|
||||
/** Keep the pre-SSH LAN probe short so dead hosts do not stall the dial. */
|
||||
const DEFAULT_PROBE_TIMEOUT_MS = 3_000;
|
||||
/** Hold the connected UDP socket briefly so TCC can present the alert (FB16131937). */
|
||||
const DEFAULT_PROBE_HOLD_MS = 500;
|
||||
/** IANA discard service - Apple's TN3179 sample uses this port for the trigger. */
|
||||
const DISCARD_PORT = 9;
|
||||
const LOCAL_NETWORK_HINT =
|
||||
"macOS may be blocking Local Network access. Open System Settings → Privacy & Security → Local Network, enable Netcatty, then reconnect.";
|
||||
"macOS may be blocking Local Network access. Open System Settings -> Privacy & Security -> Local Network, enable Netcatty, then reconnect.";
|
||||
|
||||
const defaultLookup = dns.promises.lookup.bind(dns.promises);
|
||||
|
||||
function stripIpBrackets(value) {
|
||||
return String(value || "").replace(/^\[|\]$/g, "").trim();
|
||||
@@ -60,7 +72,7 @@ function isIpv6LocalNetworkAddress(hostname) {
|
||||
if (net.isIP(hostname) !== 6) return false;
|
||||
const lower = hostname.toLowerCase();
|
||||
if (lower.startsWith("::ffff:")) {
|
||||
// IPv4-mapped IPv6 — classify the embedded v4 address.
|
||||
// IPv4-mapped IPv6 - classify the embedded v4 address.
|
||||
return isLocalNetworkHostname(lower.slice("::ffff:".length));
|
||||
}
|
||||
const hextet = ipv6FirstHextet(lower);
|
||||
@@ -87,30 +99,92 @@ function isLocalNetworkHostname(hostname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* RFC 6762 mDNS names (*.local). Resolving or connecting to them requires
|
||||
* Local Network access on macOS 15+ (TN3179 DNS / Bonjour sections).
|
||||
*/
|
||||
function isLocalMdnsName(hostname) {
|
||||
if (hostname == null) return false;
|
||||
const cleaned = stripIpBrackets(hostname).toLowerCase().replace(/\.$/, "");
|
||||
if (!cleaned || cleaned === "localhost") return false;
|
||||
if (net.isIP(cleaned)) return false;
|
||||
return cleaned.endsWith(".local");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hostnames that commonly denote a LAN-side hop even when they are not
|
||||
* literal RFC1918 addresses or mDNS `.local` names (e.g. bastion.lan).
|
||||
*/
|
||||
function isPrivateDnsLanName(hostname) {
|
||||
if (hostname == null) return false;
|
||||
const cleaned = stripIpBrackets(hostname).toLowerCase().replace(/\.$/, "");
|
||||
if (!cleaned || cleaned === "localhost") return false;
|
||||
if (net.isIP(cleaned)) return false;
|
||||
return /\.(lan|internal|intranet|localdomain|home|corp|private)$/.test(cleaned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-label vault/jump names (e.g. "dev-viet") are usually LAN DNS.
|
||||
* Public first hops are typically FQDNs, so keep this narrow.
|
||||
*/
|
||||
function isUnqualifiedHostname(hostname) {
|
||||
if (hostname == null) return false;
|
||||
const cleaned = stripIpBrackets(hostname).toLowerCase().replace(/\.$/, "");
|
||||
if (!cleaned || cleaned === "localhost") return false;
|
||||
if (net.isIP(cleaned)) return false;
|
||||
return !cleaned.includes(".");
|
||||
}
|
||||
|
||||
function looksLikeLocalNetworkName(hostname) {
|
||||
return isLocalNetworkHostname(hostname)
|
||||
|| isLocalMdnsName(hostname)
|
||||
|| isPrivateDnsLanName(hostname)
|
||||
|| isUnqualifiedHostname(hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* First TCP hop the local process will open for this SSH dial.
|
||||
* Prefer HTTP/SOCKS proxy host when configured, else first jump host, else target.
|
||||
* Prefer the first jump's HTTP/SOCKS proxy when set (sshBridge hop wiring),
|
||||
* else the session-level proxy, else the first jump host, else the target.
|
||||
* An active ProxyCommand is terminal: Electron never dials the TCP host
|
||||
* itself, so callers should skip the Local Network probe entirely.
|
||||
*/
|
||||
function resolveFirstTcpEndpoint(options = {}) {
|
||||
const proxy = options.proxy && typeof options.proxy === "object" ? options.proxy : null;
|
||||
if (
|
||||
proxy
|
||||
&& proxy.type !== "command"
|
||||
&& String(proxy.host || proxy.hostname || "").trim()
|
||||
) {
|
||||
const commandProxySkip = (proxy) => {
|
||||
if (!proxy || typeof proxy !== "object") return null;
|
||||
if (proxy.type !== "command") return null;
|
||||
if (!String(proxy.command || "").trim()) return null;
|
||||
return { hostname: "", port: 0, skipProbe: true, reason: "command-proxy" };
|
||||
};
|
||||
|
||||
const endpointFromProxy = (proxy, fallbackPort = 1080) => {
|
||||
if (!proxy || typeof proxy !== "object") return null;
|
||||
if (proxy.type === "command") return null;
|
||||
const hostname = String(proxy.host || proxy.hostname || "").trim();
|
||||
if (!hostname) return null;
|
||||
const port = Number(proxy.port);
|
||||
return {
|
||||
hostname,
|
||||
port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 1080,
|
||||
port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : fallbackPort,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const jumpHosts = Array.isArray(options.jumpHosts) ? options.jumpHosts : [];
|
||||
if (jumpHosts.length > 0) {
|
||||
const first = jumpHosts[0] || {};
|
||||
const hostname = String(first.hostname || first.host || "").trim();
|
||||
const port = Number(first.port);
|
||||
const firstJump = jumpHosts.length > 0 ? (jumpHosts[0] || {}) : null;
|
||||
const jumpProxy = firstJump ? (firstJump.proxy || firstJump.proxyConfig || null) : null;
|
||||
const jumpCommandSkip = commandProxySkip(jumpProxy);
|
||||
if (jumpCommandSkip) return jumpCommandSkip;
|
||||
const jumpProxyEndpoint = endpointFromProxy(jumpProxy);
|
||||
if (jumpProxyEndpoint) return jumpProxyEndpoint;
|
||||
|
||||
const sessionCommandSkip = commandProxySkip(options.proxy || null);
|
||||
if (sessionCommandSkip) return sessionCommandSkip;
|
||||
const sessionProxyEndpoint = endpointFromProxy(options.proxy || null);
|
||||
if (sessionProxyEndpoint) return sessionProxyEndpoint;
|
||||
|
||||
if (firstJump) {
|
||||
const hostname = String(firstJump.hostname || firstJump.host || "").trim();
|
||||
const port = Number(firstJump.port);
|
||||
return {
|
||||
hostname,
|
||||
port: Number.isInteger(port) && port >= 1 && port <= 65535 ? port : 22,
|
||||
@@ -125,11 +199,105 @@ function resolveFirstTcpEndpoint(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide which hostname/IP to UDP-probe for Local Network TCC attribution.
|
||||
* @returns {Promise<{ hostname: string, reason: "literal"|"mdns"|"resolved" }|null>}
|
||||
*/
|
||||
async function resolveLanProbeTarget(hostname, options = {}) {
|
||||
const cleaned = stripIpBrackets(hostname);
|
||||
if (!cleaned) return null;
|
||||
if (isLocalNetworkHostname(cleaned)) {
|
||||
return { hostname: cleaned, reason: "literal" };
|
||||
}
|
||||
if (isLocalMdnsName(cleaned)) {
|
||||
return { hostname: cleaned, reason: "mdns" };
|
||||
}
|
||||
if (net.isIP(cleaned)) return null;
|
||||
|
||||
const lookup = options.lookup || defaultLookup;
|
||||
const timeoutMs = Number.isFinite(options.timeoutMs)
|
||||
? Math.max(0, Math.round(options.timeoutMs))
|
||||
: DEFAULT_PROBE_TIMEOUT_MS;
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
|
||||
try {
|
||||
const results = await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let timer = null;
|
||||
const finish = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimer(timer);
|
||||
fn(value);
|
||||
};
|
||||
if (timeoutMs > 0) {
|
||||
timer = setTimer(() => finish(reject, Object.assign(new Error("LAN probe DNS timeout"), {
|
||||
code: "ETIMEDOUT",
|
||||
})), timeoutMs);
|
||||
}
|
||||
Promise.resolve()
|
||||
.then(() => lookup(cleaned, { all: true, verbatim: true }))
|
||||
.then((value) => finish(resolve, value))
|
||||
.catch((err) => finish(reject, err));
|
||||
});
|
||||
const list = Array.isArray(results) ? results : results ? [results] : [];
|
||||
for (const entry of list) {
|
||||
const address = typeof entry === "string" ? entry : entry?.address;
|
||||
if (address && isLocalNetworkHostname(address)) {
|
||||
return { hostname: stripIpBrackets(address), reason: "resolved" };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// DNS failure / timeout is not fatal; skip the probe and let SSH dial.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function looksLikeHostUnreachableMessage(message) {
|
||||
const text = String(message || "");
|
||||
return /EHOSTUNREACH|ENETUNREACH|host is unreachable|network is unreachable/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull unreachable *remote* addresses out of Node connect errors.
|
||||
* Strips the trailing `- Local (bind:port)` clause so the local bind IP
|
||||
* cannot be mistaken for a LAN destination (Codex #2673 follow-up).
|
||||
*/
|
||||
function extractRemoteUnreachableAddresses(text) {
|
||||
const withoutLocalBind = String(text || "").replace(/\s*-\s*Local\s*\([^)]*\)\s*$/i, "");
|
||||
const found = [];
|
||||
|
||||
const pushIfLan = (value) => {
|
||||
const cleaned = stripIpBrackets(value);
|
||||
if (!cleaned) return;
|
||||
if (isLocalNetworkHostname(cleaned)) found.push(cleaned);
|
||||
};
|
||||
|
||||
for (const ip of withoutLocalBind.match(/\b(?:\d{1,3}\.){3}\d{1,3}\b/g) || []) {
|
||||
pushIfLan(ip);
|
||||
}
|
||||
for (const matched of withoutLocalBind.match(/\[([0-9a-fA-F:]+)\]/g) || []) {
|
||||
pushIfLan(matched.slice(1, -1));
|
||||
}
|
||||
|
||||
const afterErr = withoutLocalBind.match(/(?:EHOSTUNREACH|ENETUNREACH)\s+(\S+)/i);
|
||||
if (afterErr) {
|
||||
const token = afterErr[1];
|
||||
const bracketed = token.match(/^\[([0-9a-fA-F:]+)\](?::\d+)?$/);
|
||||
if (bracketed) {
|
||||
pushIfLan(bracketed[1]);
|
||||
} else {
|
||||
pushIfLan(token);
|
||||
// Node often formats IPv6 as addr:port without brackets.
|
||||
const withoutPort = String(token).replace(/:(\d+)$/, "");
|
||||
if (withoutPort !== token) pushIfLan(withoutPort);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(found)];
|
||||
}
|
||||
|
||||
function annotateMacLocalNetworkErrorMessage(message, options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const text = String(message || "");
|
||||
@@ -137,102 +305,243 @@ function annotateMacLocalNetworkErrorMessage(message, options = {}) {
|
||||
if (!looksLikeHostUnreachableMessage(text)) return text;
|
||||
if (text.includes("Local Network")) return text;
|
||||
|
||||
// ProxyCommand owns the dial outside Electron. Never append a Netcatty
|
||||
// Local Network hint - even when the child error embeds a LAN address -
|
||||
// because the user would be sent to enable Netcatty for a connection the
|
||||
// external command made.
|
||||
if (options.skipProbe === true) return text;
|
||||
|
||||
const firstHop = String(options.firstHopHostname || "").trim();
|
||||
const firstHopResolved = String(options.firstHopResolvedAddress || "").trim();
|
||||
const targetHost = String(options.hostname || options.host || "").trim();
|
||||
const normalizeHost = (value) => stripIpBrackets(value).toLowerCase();
|
||||
// Only treat the vault target as LAN evidence when it is also the first TCP
|
||||
// hop. A .local final host reached via a public proxy/jump must not force
|
||||
// the Local Network hint when the failing dial never touched the LAN.
|
||||
const targetIsFirstHop = !firstHop
|
||||
|| !targetHost
|
||||
|| normalizeHost(targetHost) === normalizeHost(firstHop);
|
||||
|
||||
// Embedded unreachable addresses often name a downstream hop that a public
|
||||
// proxy/jump dialed. Only treat them as LAN evidence when they identify the
|
||||
// local first-hop dial (hostname or the address ensureAccess resolved).
|
||||
const remotes = extractRemoteUnreachableAddresses(text);
|
||||
const remotesForEvidence = targetIsFirstHop
|
||||
? remotes
|
||||
: remotes.filter((value) => {
|
||||
const normalized = normalizeHost(value);
|
||||
if (normalized === normalizeHost(firstHop)) return true;
|
||||
if (firstHopResolved && normalized === normalizeHost(firstHopResolved)) return true;
|
||||
// Unqualified / private-DNS first hops may lack a carried resolution.
|
||||
return looksLikeLocalNetworkName(firstHop) && isLocalNetworkHostname(value);
|
||||
});
|
||||
|
||||
const candidates = [
|
||||
options.hostname,
|
||||
options.host,
|
||||
...(targetIsFirstHop ? [options.hostname, options.host] : []),
|
||||
options.firstHopHostname,
|
||||
firstHopResolved || null,
|
||||
...remotesForEvidence,
|
||||
].filter((value) => value != null && String(value).trim() !== "");
|
||||
const touchesLan = candidates.some((value) => isLocalNetworkHostname(value));
|
||||
const touchesLan = candidates.some((value) => looksLikeLocalNetworkName(value));
|
||||
if (!touchesLan) return text;
|
||||
return `${text}\n\n${LOCAL_NETWORK_HINT}`;
|
||||
}
|
||||
|
||||
function pickUdpTypes(hostname) {
|
||||
const cleaned = stripIpBrackets(hostname);
|
||||
if (net.isIP(cleaned) === 6) return ["udp6"];
|
||||
if (net.isIP(cleaned) === 4) return ["udp4"];
|
||||
// Unresolved hostnames (especially .local) may be IPv6-only on the LAN.
|
||||
// Node's udp4 sockets will not attempt AAAA, so try both families.
|
||||
return ["udp4", "udp6"];
|
||||
}
|
||||
|
||||
function createMacLocalNetworkAccessGate(options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
const versions = options.versions || process.versions;
|
||||
const netModule = options.net || net;
|
||||
const dgramModule = options.dgram || dgram;
|
||||
const lookup = options.lookup || defaultLookup;
|
||||
const probedKeys = options.probedKeys || new Set();
|
||||
const inFlight = options.inFlight || new Map();
|
||||
const probeTimeoutMs = Number.isFinite(options.probeTimeoutMs)
|
||||
? Math.max(500, Math.round(options.probeTimeoutMs))
|
||||
: DEFAULT_PROBE_TIMEOUT_MS;
|
||||
const probeHoldMs = Number.isFinite(options.probeHoldMs)
|
||||
? Math.max(0, Math.round(options.probeHoldMs))
|
||||
: DEFAULT_PROBE_HOLD_MS;
|
||||
const setTimer = options.setTimer || setTimeout;
|
||||
const clearTimer = options.clearTimer || clearTimeout;
|
||||
const resolvedFirstHopByName = options.resolvedFirstHopByName || new Map();
|
||||
|
||||
function rememberResolvedFirstHop(hostname, resolvedHostname) {
|
||||
const name = stripIpBrackets(hostname).toLowerCase();
|
||||
const resolved = stripIpBrackets(resolvedHostname);
|
||||
if (!name || !resolved) return;
|
||||
if (!isLocalNetworkHostname(resolved) && !isLocalMdnsName(resolved)) return;
|
||||
resolvedFirstHopByName.set(name, resolved);
|
||||
}
|
||||
|
||||
function getResolvedFirstHop(hostname) {
|
||||
const name = stripIpBrackets(hostname).toLowerCase();
|
||||
if (!name) return "";
|
||||
return resolvedFirstHopByName.get(name) || "";
|
||||
}
|
||||
// Bare Node unit tests (and non-Electron CLIs) must never open LAN sockets.
|
||||
// Only the real Electron main process should trigger the TCC prompt.
|
||||
const electronRuntime = options.forceElectron === true
|
||||
|| (options.forceElectron !== false && Boolean(versions?.electron));
|
||||
|
||||
function probeKey(hostname, port) {
|
||||
return `${String(hostname).toLowerCase()}:${port}`;
|
||||
function probeKey(hostname) {
|
||||
return String(hostname).toLowerCase();
|
||||
}
|
||||
|
||||
function runProbe(hostname, port) {
|
||||
function runUdpProbeOnce(hostname, udpType, attemptTimeoutMs) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
let socket = null;
|
||||
let timer = null;
|
||||
let safetyTimer = null;
|
||||
let holdTimer = null;
|
||||
const attemptMs = Math.max(1, Math.round(attemptTimeoutMs));
|
||||
|
||||
const finish = () => {
|
||||
const finish = (connected) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (timer) clearTimer(timer);
|
||||
try { socket?.destroy(); } catch { /* ignore */ }
|
||||
resolve();
|
||||
if (safetyTimer) clearTimer(safetyTimer);
|
||||
if (holdTimer) clearTimer(holdTimer);
|
||||
safetyTimer = null;
|
||||
holdTimer = null;
|
||||
try { socket?.close(); } catch { /* ignore */ }
|
||||
resolve(connected === true);
|
||||
};
|
||||
|
||||
try {
|
||||
socket = netModule.connect({ host: hostname, port }, finish);
|
||||
socket.once?.("error", finish);
|
||||
if (typeof socket.setTimeout === "function") {
|
||||
socket.setTimeout(probeTimeoutMs, finish);
|
||||
} else {
|
||||
timer = setTimer(finish, probeTimeoutMs);
|
||||
}
|
||||
socket = dgramModule.createSocket(udpType);
|
||||
socket.once?.("error", () => finish(false));
|
||||
safetyTimer = setTimer(() => finish(false), attemptMs);
|
||||
socket.connect(DISCARD_PORT, hostname, () => {
|
||||
if (settled) return;
|
||||
// Clear the connect safety window once connected so a slow
|
||||
// .local/mDNS resolve cannot truncate the intentional hold
|
||||
// that gives TCC time to present the Local Network alert.
|
||||
if (safetyTimer) {
|
||||
clearTimer(safetyTimer);
|
||||
safetyTimer = null;
|
||||
}
|
||||
if (probeHoldMs <= 0) {
|
||||
finish(true);
|
||||
return;
|
||||
}
|
||||
holdTimer = setTimer(() => finish(true), probeHoldMs);
|
||||
});
|
||||
} catch {
|
||||
finish();
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function runUdpProbe(hostname, budgetMs = probeTimeoutMs) {
|
||||
const types = pickUdpTypes(hostname);
|
||||
const totalMs = Number.isFinite(budgetMs) ? Math.max(0, Math.round(budgetMs)) : probeTimeoutMs;
|
||||
if (totalMs <= 0) return;
|
||||
const deadlineAt = Date.now() + totalMs;
|
||||
for (let index = 0; index < types.length; index += 1) {
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
if (remainingMs <= 0) return;
|
||||
const familiesLeft = types.length - index;
|
||||
// Split the residual budget so a stalled udp4 resolve cannot consume
|
||||
// the entire window before the udp6 fallback for IPv6-only .local hosts.
|
||||
const sliceMs = familiesLeft > 1
|
||||
? Math.max(1, Math.floor(remainingMs / familiesLeft))
|
||||
: remainingMs;
|
||||
const connected = await runUdpProbeOnce(hostname, types[index], sliceMs);
|
||||
if (connected) return;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAccess(connectOptions = {}) {
|
||||
if (platform !== "darwin") return { skipped: true, reason: "platform" };
|
||||
if (!electronRuntime) return { skipped: true, reason: "not-electron" };
|
||||
// Main process already probed before forwarding into the terminal
|
||||
// worker; skip the second hold in utilityProcess (#2673 Codex P2).
|
||||
if (connectOptions._macLocalNetworkMainProbed === true) {
|
||||
return { skipped: true, reason: "main-probed" };
|
||||
}
|
||||
|
||||
const endpoint = resolveFirstTcpEndpoint(connectOptions);
|
||||
if (!endpoint.hostname || !isLocalNetworkHostname(endpoint.hostname)) {
|
||||
if (endpoint.skipProbe === true) {
|
||||
return { skipped: true, reason: endpoint.reason || "command-proxy" };
|
||||
}
|
||||
if (!endpoint.hostname) {
|
||||
return { skipped: true, reason: "not-local-network" };
|
||||
}
|
||||
|
||||
const key = probeKey(endpoint.hostname, endpoint.port);
|
||||
if (probedKeys.has(key)) return { skipped: true, reason: "cached" };
|
||||
// One wall-clock budget covers DNS resolution + UDP family attempts.
|
||||
const deadlineAt = Date.now() + probeTimeoutMs;
|
||||
const target = await resolveLanProbeTarget(endpoint.hostname, {
|
||||
lookup,
|
||||
timeoutMs: Math.max(0, deadlineAt - Date.now()),
|
||||
setTimer,
|
||||
clearTimer,
|
||||
});
|
||||
if (!target) {
|
||||
return { skipped: true, reason: "not-local-network" };
|
||||
}
|
||||
rememberResolvedFirstHop(endpoint.hostname, target.hostname);
|
||||
|
||||
const key = probeKey(target.hostname);
|
||||
if (probedKeys.has(key)) {
|
||||
return { skipped: true, reason: "cached", hostname: target.hostname };
|
||||
}
|
||||
|
||||
const remainingMs = deadlineAt - Date.now();
|
||||
if (remainingMs <= 0) {
|
||||
return { skipped: true, reason: "timeout", hostname: target.hostname };
|
||||
}
|
||||
|
||||
const pending = inFlight.get(key);
|
||||
if (pending) {
|
||||
await pending;
|
||||
return { skipped: true, reason: "in-flight" };
|
||||
return { skipped: true, reason: "in-flight", hostname: target.hostname };
|
||||
}
|
||||
|
||||
const probe = runProbe(endpoint.hostname, endpoint.port).finally(() => {
|
||||
const probe = runUdpProbe(target.hostname, remainingMs).finally(() => {
|
||||
inFlight.delete(key);
|
||||
probedKeys.add(key);
|
||||
});
|
||||
inFlight.set(key, probe);
|
||||
await probe;
|
||||
return { probed: true, hostname: endpoint.hostname, port: endpoint.port };
|
||||
return {
|
||||
probed: true,
|
||||
hostname: target.hostname,
|
||||
port: DISCARD_PORT,
|
||||
reason: target.reason,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ensureAccess,
|
||||
isLocalNetworkHostname,
|
||||
isLocalMdnsName,
|
||||
resolveFirstTcpEndpoint,
|
||||
getProbeTimeoutMs: () => probeTimeoutMs,
|
||||
getProbeHoldMs: () => probeHoldMs,
|
||||
getResolvedFirstHop,
|
||||
annotateErrorMessage(message, connectOptions = {}) {
|
||||
const endpoint = resolveFirstTcpEndpoint(connectOptions);
|
||||
const firstHopHostname = endpoint.skipProbe ? "" : endpoint.hostname;
|
||||
const firstHopResolvedAddress = endpoint.skipProbe
|
||||
? ""
|
||||
: (
|
||||
connectOptions._macLocalNetworkResolvedFirstHop
|
||||
|| connectOptions.firstHopResolvedAddress
|
||||
|| getResolvedFirstHop(firstHopHostname)
|
||||
);
|
||||
return annotateMacLocalNetworkErrorMessage(message, {
|
||||
platform,
|
||||
hostname: connectOptions.hostname || connectOptions.host,
|
||||
firstHopHostname: endpoint.hostname,
|
||||
firstHopHostname,
|
||||
firstHopResolvedAddress,
|
||||
skipProbe: endpoint.skipProbe === true,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -243,8 +552,12 @@ const defaultGate = createMacLocalNetworkAccessGate();
|
||||
module.exports = {
|
||||
LOCAL_NETWORK_HINT,
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
DEFAULT_PROBE_HOLD_MS,
|
||||
DISCARD_PORT,
|
||||
isLocalNetworkHostname,
|
||||
isLocalMdnsName,
|
||||
resolveFirstTcpEndpoint,
|
||||
resolveLanProbeTarget,
|
||||
annotateMacLocalNetworkErrorMessage,
|
||||
createMacLocalNetworkAccessGate,
|
||||
ensureMacLocalNetworkAccess: (options) => defaultGate.ensureAccess(options),
|
||||
|
||||
@@ -6,8 +6,12 @@ const { EventEmitter } = require("node:events");
|
||||
|
||||
const {
|
||||
DEFAULT_PROBE_TIMEOUT_MS,
|
||||
DEFAULT_PROBE_HOLD_MS,
|
||||
DISCARD_PORT,
|
||||
isLocalNetworkHostname,
|
||||
isLocalMdnsName,
|
||||
resolveFirstTcpEndpoint,
|
||||
resolveLanProbeTarget,
|
||||
annotateMacLocalNetworkErrorMessage,
|
||||
createMacLocalNetworkAccessGate,
|
||||
} = require("./macLocalNetworkAccess.cjs");
|
||||
@@ -22,6 +26,15 @@ test("DEFAULT_PROBE_TIMEOUT_MS is a few seconds, not tens of seconds", () => {
|
||||
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);
|
||||
@@ -51,6 +64,16 @@ test("isLocalNetworkHostname rejects public, loopback, empty, and non-IP hostnam
|
||||
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({
|
||||
@@ -79,6 +102,135 @@ test("resolveFirstTcpEndpoint prefers proxy, then jump host, then target", () =>
|
||||
);
|
||||
});
|
||||
|
||||
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, {
|
||||
@@ -94,12 +246,20 @@ test("annotateMacLocalNetworkErrorMessage only rewrites LAN unreachability on da
|
||||
}),
|
||||
base,
|
||||
);
|
||||
assert.equal(
|
||||
// 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",
|
||||
}),
|
||||
base,
|
||||
/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", {
|
||||
@@ -117,25 +277,191 @@ test("annotateMacLocalNetworkErrorMessage only rewrites LAN unreachability on da
|
||||
assert.match(viaJump, /Local Network/i);
|
||||
});
|
||||
|
||||
test("createMacLocalNetworkAccessGate probes from the main process once per LAN endpoint", async () => {
|
||||
const connects = [];
|
||||
class FakeSocket extends EventEmitter {
|
||||
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("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.destroyed = false;
|
||||
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;
|
||||
}
|
||||
setTimeout() { return this; }
|
||||
destroy() { this.destroyed = true; }
|
||||
}
|
||||
return new FakeUdpSocket();
|
||||
}
|
||||
|
||||
test("createMacLocalNetworkAccessGate probes with UDP discard once per LAN endpoint", async () => {
|
||||
const sockets = [];
|
||||
const gate = createMacLocalNetworkAccessGate({
|
||||
platform: "darwin",
|
||||
forceElectron: true,
|
||||
net: {
|
||||
connect(options, onConnect) {
|
||||
connects.push(options);
|
||||
const socket = new FakeSocket();
|
||||
queueMicrotask(() => onConnect && onConnect());
|
||||
probeHoldMs: 5,
|
||||
dgram: {
|
||||
createSocket(type) {
|
||||
const socket = createFakeUdpSocket();
|
||||
socket.type = type;
|
||||
sockets.push(socket);
|
||||
return socket;
|
||||
},
|
||||
},
|
||||
@@ -143,19 +469,138 @@ test("createMacLocalNetworkAccessGate probes from the main process once per LAN
|
||||
|
||||
await gate.ensureAccess({ hostname: "192.168.5.22", port: 22 });
|
||||
await gate.ensureAccess({ hostname: "192.168.5.22", port: 22 });
|
||||
assert.equal(connects.length, 1);
|
||||
assert.deepEqual(connects[0], { host: "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 connects = 0;
|
||||
let creates = 0;
|
||||
const linuxGate = createMacLocalNetworkAccessGate({
|
||||
platform: "linux",
|
||||
forceElectron: true,
|
||||
net: {
|
||||
connect() {
|
||||
connects += 1;
|
||||
throw new Error("should not connect");
|
||||
dgram: {
|
||||
createSocket() {
|
||||
creates += 1;
|
||||
throw new Error("should not create socket");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -164,10 +609,11 @@ test("createMacLocalNetworkAccessGate skips non-darwin and non-LAN hosts", async
|
||||
const darwinGate = createMacLocalNetworkAccessGate({
|
||||
platform: "darwin",
|
||||
forceElectron: true,
|
||||
net: {
|
||||
connect() {
|
||||
connects += 1;
|
||||
throw new Error("should not connect");
|
||||
lookup: async () => [{ address: "93.184.216.34", family: 4 }],
|
||||
dgram: {
|
||||
createSocket() {
|
||||
creates += 1;
|
||||
throw new Error("should not create socket");
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -177,30 +623,27 @@ test("createMacLocalNetworkAccessGate skips non-darwin and non-LAN hosts", async
|
||||
const bareNodeGate = createMacLocalNetworkAccessGate({
|
||||
platform: "darwin",
|
||||
versions: {},
|
||||
net: {
|
||||
connect() {
|
||||
connects += 1;
|
||||
throw new Error("should not connect outside Electron");
|
||||
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(connects, 0);
|
||||
assert.equal(creates, 0);
|
||||
});
|
||||
|
||||
test("createMacLocalNetworkAccessGate treats connect errors as a completed probe", async () => {
|
||||
class FakeSocket extends EventEmitter {
|
||||
setTimeout() { return this; }
|
||||
destroy() {}
|
||||
}
|
||||
test("createMacLocalNetworkAccessGate treats UDP errors as a completed probe", async () => {
|
||||
const gate = createMacLocalNetworkAccessGate({
|
||||
platform: "darwin",
|
||||
forceElectron: true,
|
||||
net: {
|
||||
connect() {
|
||||
const socket = new FakeSocket();
|
||||
queueMicrotask(() => socket.emit("error", Object.assign(new Error("connect EHOSTUNREACH"), { code: "EHOSTUNREACH" })));
|
||||
return socket;
|
||||
probeHoldMs: 5,
|
||||
dgram: {
|
||||
createSocket() {
|
||||
return createFakeUdpSocket({
|
||||
onError: Object.assign(new Error("connect EHOSTUNREACH"), { code: "EHOSTUNREACH" }),
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -210,14 +653,14 @@ test("createMacLocalNetworkAccessGate treats connect errors as a completed probe
|
||||
jumpHosts: [{ hostname: "192.168.1.50", port: 2200 }],
|
||||
});
|
||||
// Second call must be cached even though the first connect failed.
|
||||
let secondConnects = 0;
|
||||
let secondCreates = 0;
|
||||
const cachedGate = createMacLocalNetworkAccessGate({
|
||||
platform: "darwin",
|
||||
forceElectron: true,
|
||||
probedKeys: new Set(["192.168.1.50:2200"]),
|
||||
net: {
|
||||
connect() {
|
||||
secondConnects += 1;
|
||||
probedKeys: new Set(["192.168.1.50"]),
|
||||
dgram: {
|
||||
createSocket() {
|
||||
secondCreates += 1;
|
||||
throw new Error("should use cache");
|
||||
},
|
||||
},
|
||||
@@ -225,30 +668,220 @@ test("createMacLocalNetworkAccessGate treats connect errors as a completed probe
|
||||
await cachedGate.ensureAccess({
|
||||
jumpHosts: [{ hostname: "192.168.1.50", port: 2200 }],
|
||||
});
|
||||
assert.equal(secondConnects, 0);
|
||||
assert.equal(secondCreates, 0);
|
||||
});
|
||||
|
||||
test("createMacLocalNetworkAccessGate applies the configured probe timeout", async () => {
|
||||
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 = [];
|
||||
class FakeSocket extends EventEmitter {
|
||||
setTimeout(ms, onTimeout) {
|
||||
timeouts.push(ms);
|
||||
queueMicrotask(() => onTimeout && onTimeout());
|
||||
return this;
|
||||
}
|
||||
destroy() {}
|
||||
}
|
||||
const holdMs = 5;
|
||||
const gate = createMacLocalNetworkAccessGate({
|
||||
platform: "darwin",
|
||||
forceElectron: true,
|
||||
probeTimeoutMs: DEFAULT_PROBE_TIMEOUT_MS,
|
||||
net: {
|
||||
connect() {
|
||||
return new FakeSocket();
|
||||
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 });
|
||||
assert.deepEqual(timeouts, [DEFAULT_PROBE_TIMEOUT_MS]);
|
||||
assert.ok(DEFAULT_PROBE_TIMEOUT_MS <= 5_000);
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -1318,8 +1318,9 @@ async function startSSHSessionWrapper(event, options) {
|
||||
}
|
||||
}
|
||||
try {
|
||||
// Main-process LAN probe so macOS Local Network TCC attributes to Netcatty
|
||||
// before the (possibly worker-hosted) SSH dial. See #2663 / TN3179.
|
||||
// Main-process UDP Local Network probe (TN3179 discard-port connect) so
|
||||
// TCC attributes to Netcatty before the (possibly worker-hosted) SSH dial.
|
||||
// See #2663 / #2673.
|
||||
await ensureMacLocalNetworkAccess(options);
|
||||
return await startSSHSessionWithRetries(event, {
|
||||
...options,
|
||||
@@ -1399,13 +1400,26 @@ const {
|
||||
*/
|
||||
function registerWorkerHandle(ipcMain, terminalWorkerManager, channel) {
|
||||
ipcMain.handle(channel, async (event, payload) => {
|
||||
// SSH sessions run in utilityProcess; probe LAN access from the main
|
||||
// SSH sessions run in utilityProcess; UDP-probe LAN access from the main
|
||||
// process first so macOS can show the Local Network privacy alert for
|
||||
// the Netcatty app bundle instead of silently denying the helper (#2663).
|
||||
// the Netcatty app bundle instead of silently denying the helper
|
||||
// (#2663 / #2673 / TN3179). Mark the payload so the worker skips a
|
||||
// second hold / probe in its own process.
|
||||
let workerPayload = payload;
|
||||
if (channel === "netcatty:start") {
|
||||
await ensureMacLocalNetworkAccess(payload);
|
||||
const probeResult = await ensureMacLocalNetworkAccess(payload);
|
||||
const resolvedFirstHop = probeResult && typeof probeResult.hostname === "string"
|
||||
? String(probeResult.hostname).trim()
|
||||
: "";
|
||||
workerPayload = {
|
||||
...(payload && typeof payload === "object" ? payload : {}),
|
||||
_macLocalNetworkMainProbed: true,
|
||||
...(resolvedFirstHop
|
||||
? { _macLocalNetworkResolvedFirstHop: resolvedFirstHop }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return terminalWorkerManager.request(channel, payload, {
|
||||
return terminalWorkerManager.request(channel, workerPayload, {
|
||||
webContentsId: event?.sender?.id,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,11 @@ function userVisibleSshErrorMessage(err, options = {}) {
|
||||
const firstHop = resolveFirstTcpEndpoint(options);
|
||||
return annotateMacLocalNetworkErrorMessage(err?.message || String(err || ""), {
|
||||
hostname: options.hostname || options.host,
|
||||
firstHopHostname: firstHop.hostname,
|
||||
firstHopHostname: firstHop.skipProbe ? "" : firstHop.hostname,
|
||||
firstHopResolvedAddress: firstHop.skipProbe
|
||||
? ""
|
||||
: (options._macLocalNetworkResolvedFirstHop || options.firstHopResolvedAddress || ""),
|
||||
skipProbe: firstHop.skipProbe === true,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -134,6 +134,11 @@ test("terminal worker mode proxies SSH session and remote helper requests", asyn
|
||||
"netcatty:ssh:setEncoding",
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
terminalWorkerManager.requests[0].payload._macLocalNetworkMainProbed,
|
||||
true,
|
||||
"worker start payload should skip a second Local Network probe",
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal worker mode keeps main-process keyboard-interactive responses in the main process", async () => {
|
||||
|
||||
Reference in new issue
Block a user