Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 a615432264 fix(macos): carry resolved first-hop LAN addresses into error annotation
ensureAccess remembers the LAN IP it probed for a hostname first hop and
forwards it on the worker start payload. Annotation uses that resolved
address so private split-DNS FQDNs like bastion.example.com still get
Local Network guidance without treating public proxies as LAN.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:51:43 +00:00
Cursor Agentand陈大猫 03402a43ff fix(macos): keep Local Network hints for unqualified first hops
Single-label jump/proxy names such as "dev-viet" resolve to LAN IPs that
appear in Node unreachable errors. Treat those hostnames as local-side
evidence so annotation still guides users to macOS Local Network settings.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:39:10 +00:00
Cursor Agentand陈大猫 5d313ebbae fix(macos): keep LAN hints for private DNS first hops and ASCII comments
Treat common private DNS suffixes (e.g. bastion.lan) as Local Network
evidence so hostname-based first hops still get guidance when Node reports
the resolved IP. Replace em dashes and arrows in this module with ASCII.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:28:06 +00:00
Cursor Agentand陈大猫 ffc133ad96 fix(macos): ignore downstream LAN addresses behind public first hops
When a public proxy/jump owns the first dial, unreachable addresses embedded
in the error often name a private host the remote side tried to reach. Only
use those addresses as Local Network evidence when they identify the local
first-hop dial.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:15:57 +00:00
Cursor Agentand陈大猫 861a3c60dc fix(macos): never hint Local Network for ProxyCommand errors
skipProbe means an external ProxyCommand owns the dial. Do not append the
Netcatty Local Network TCC hint from child-process LAN addresses in the
error text — that guidance would send users to enable Netcatty for a
connection Electron never made.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 06:03:55 +00:00
Cursor Agentand陈大猫 df2a280f04 test(ssh): fire residual Local Network safety timers in hang probe test
Shared-deadline math arms the UDP safety timer with a residual budget
(e.g. 2999ms) rather than the exact DEFAULT_PROBE_TIMEOUT_MS constant,
so matching only the constant left the hang-socket test unable to settle.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 05:17:17 +00:00
Cursor Agentand陈大猫 f5104a00f0 fix(macos): preserve ProxyCommand skip during Local Network error annotation
Keep ProxyCommand's skipProbe sentinel through annotateMacLocalNetworkErrorMessage
so .local hop names do not get a false Local Network hint when TCP probing was
intentionally skipped. Split residual UDP budget evenly across remaining address
families so dual-stack and .local IPv6 attempts still get time.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:49:13 +00:00
Cursor Agentand陈大猫 c60b75b3d8 test(ssh): tolerate residual Local Network probe budgets in hold test
Shared-deadline math can arm the safety timer at 499ms instead of exactly
500ms. Identify that timer by being larger than the hold, not by equality.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:43:16 +00:00
Cursor Agentand陈大猫 eef8f285d0 fix(ssh): skip Local Network probe for ProxyCommand hops
Treat an active jump/session ProxyCommand as a terminal first hop so we
do not fall through to a LAN hostname and spuriously prompt. Also share
one wall-clock deadline across DNS resolution and UDP family attempts.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:38:51 +00:00
Cursor Agentand陈大猫 90d9f9dd26 chore: re-request Codex review after unanswered pass
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:33:38 +00:00
Cursor Agentand陈大猫 74a64d7469 fix(ssh): share Local Network probe timeout across UDP families
udp4 then udp6 fallbacks for .local hosts now inherit one overall
deadline instead of restarting DEFAULT_PROBE_TIMEOUT_MS per family.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 04:01:49 +00:00
Cursor Agentand陈大猫 584eecbeb6 fix(ssh): retry .local Local Network probes over udp6
IPv6-only mDNS hosts never resolve on a udp4 socket, so the TCC trigger
could miss them. For unresolved hostnames try udp4 then udp6.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:57:30 +00:00
Cursor Agentand陈大猫 ebb68538fa fix(ssh): probe jump-level proxy as the first Local Network hop
Match sshBridge first-hop wiring: a usable jump.proxy overrides the
session proxy and the jump hostname. Otherwise a .local jump behind a
public proxy probes the wrong host, and a LAN proxy in front of a public
jump is missed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:50:05 +00:00
Cursor Agentand陈大猫 c599aeed03 fix(ssh): only hint Local Network when the first hop is LAN
A .local final host reached through a public proxy or jump must not force
the macOS Local Network hint when the failing dial never touched LAN.
Annotate from the first hop and remote error address instead.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:43:32 +00:00
Cursor Agentand陈大猫 1388aaccc7 fix(ssh): tighten Local Network error address parsing
Ignore LAN bind addresses in Node's "- Local (ip:port)" suffix so public
SSH failures are not mislabeled as Local Network denials. Also extract
embedded IPv6 ULA/link-local remotes from unreachable errors.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:37:31 +00:00
Cursor Agentand陈大猫 288431a285 fix(ssh): bound Local Network DNS lookup by probe timeout
Codex noted that hostname resolution before the UDP probe had no
deadline, so a hung DNS lookup could stall every non-literal SSH start.
Race the lookup against the existing probe timeout and skip probing when
it expires so the normal SSH dial can proceed.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:30:38 +00:00
Cursor Agentand陈大猫 3e03fd4c0e fix(ssh): address Codex Local Network review findings
Clear the UDP connect safety timer once connected so a slow .local
resolve cannot truncate the TCC hold. Mark worker-forwarded start
payloads after the main-process probe so utilityProcess skips a second
hold. Keep the new test comments ASCII-only.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:23:11 +00:00
Cursor Agentand陈大猫 81c0061394 fix(ssh): harden macOS Local Network TCC trigger for LAN SSH
Replace the main-process TCP probe with Apple's TN3179 UDP discard-port
connect, resolve vault hostnames/.local before probing, hold the socket
briefly so TCC can present the alert, and annotate EHOSTUNREACH errors
that embed LAN IPs even when the host was stored as a DNS name.

Addresses recurring #2673 / #2663 / #1040 Local Network silent failures.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:05:07 +00:00
5 changed files with 1082 additions and 113 deletions

No files matched your search

+360 -47
View File
@@ -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),
+692 -59
View File
@@ -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,
);
});
+20 -6
View File
@@ -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,
});
});
+5 -1
View File
@@ -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 () => {