Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -313,11 +313,48 @@ enum SSHTransportError: Error {
|
||||
var asCoreError: CoreError {
|
||||
switch self {
|
||||
case .connectFailed(let host, let port, let underlying):
|
||||
return .sshFailed("cannot connect to \(host):\(port): \(underlying)")
|
||||
return .sshFailed(
|
||||
"cannot connect to \(host):\(port): \(underlying)"
|
||||
+ Self.localNetworkHint(for: underlying)
|
||||
)
|
||||
case .authenticationFailed(let host, let username):
|
||||
return .sshFailed("authentication failed for \(username)@\(host)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Extra guidance for the one connect failure that is usually not a network
|
||||
/// problem at all.
|
||||
///
|
||||
/// macOS 15 and newer filter local-network traffic per app, and a blocked
|
||||
/// flow is not reported as "denied": the filter answers `EHOSTUNREACH`
|
||||
/// (errno 65, "No route to host"), which is exactly what a guest that is
|
||||
/// genuinely off the network looks like. Guests here sit on a host-private
|
||||
/// NAT link that is reachable whenever the VM is up, so on this code path
|
||||
/// that errno is more often the privacy filter than a routing failure —
|
||||
/// worth naming rather than leaving the operator to guess.
|
||||
///
|
||||
/// It matters most right after a rebuild. Per
|
||||
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
||||
/// the grant "uses your main executable UUID", and the linker mints a fresh
|
||||
/// `LC_UUID` on essentially every build — so `make install` can present a
|
||||
/// program macOS has never seen, whose permission is undetermined again,
|
||||
/// even though the previous binary worked minutes earlier.
|
||||
static func localNetworkHint(for underlying: any Error) -> String {
|
||||
let text = "\(underlying)".lowercased()
|
||||
guard text.contains("errno: 65") || text.contains("no route to host")
|
||||
|| text.contains("host is unreachable")
|
||||
else { return "" }
|
||||
|
||||
return """
|
||||
(on macOS 15+ this is also what Local Network privacy returns when \
|
||||
it blocks an app — and the grant is keyed on the executable's UUID, \
|
||||
so every rebuild withdraws it. Pre-authorize the guest subnet \
|
||||
instead: sudo defaults write com.apple.network.local-network \
|
||||
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" — same \
|
||||
for AllowedWiFiLocalNetworkAddresses — then reboot. \
|
||||
See docs/troubleshooting.md)
|
||||
"""
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared, thread-safe record of whether the server rejected our password.
|
||||
@@ -584,6 +621,15 @@ public func waitForSSH(
|
||||
guard ContinuousClock.now - started < timeout else { break }
|
||||
}
|
||||
|
||||
let detail = lastError.map { "; last error: \($0)" } ?? ""
|
||||
// Rendered through `asCoreError` rather than interpolated raw: a connect
|
||||
// failure is where the Local Network privacy hint lives, and the timeout
|
||||
// message is the *only* place most operators will ever see the last error.
|
||||
let detail: String
|
||||
if let lastError {
|
||||
let rendered = (lastError as? SSHTransportError).map { "\($0.asCoreError)" } ?? "\(lastError)"
|
||||
detail = "; last error: \(rendered)"
|
||||
} else {
|
||||
detail = ""
|
||||
}
|
||||
throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
|
||||
}
|
||||
|
||||
@@ -525,19 +525,39 @@ public enum Doctor {
|
||||
|
||||
/// The macOS 15+ Local Network permission note.
|
||||
///
|
||||
/// Reports `.pass` when the host carries a subnet allowlist, because that
|
||||
/// bypasses the prompt entirely. Otherwise it stays informational: we
|
||||
/// cannot see the grant itself, since Local Network privacy is a Network
|
||||
/// Extension packet filter rather than a TCC entry, so there is no
|
||||
/// database to query and `tccutil` does not apply (Apple, TN3179).
|
||||
/// Reports `.pass` when the host carries a subnet allowlist that actually
|
||||
/// covers where guests turn up, because that bypasses the prompt entirely.
|
||||
/// An allowlist that names some *other* subnet is worse than none, since it
|
||||
/// looks configured while blocking every guest, so it warns rather than
|
||||
/// passing. Without one this stays informational: we cannot see the grant
|
||||
/// itself, since Local Network privacy is a Network Extension packet filter
|
||||
/// rather than a TCC entry, so there is no database to query and `tccutil`
|
||||
/// does not apply (Apple, TN3179).
|
||||
public static func localNetworkNote() -> DoctorCheck {
|
||||
let name = "local network access"
|
||||
let allowed = localNetworkAllowlist()
|
||||
if !allowed.isEmpty {
|
||||
if allowed.contains(where: coversVMNetRange) {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .pass,
|
||||
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .pass,
|
||||
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
|
||||
result: .warn,
|
||||
detail: "subnet allowlist set but does not cover the guest range: "
|
||||
+ allowed.joined(separator: ", "),
|
||||
remediation: """
|
||||
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \
|
||||
to the next free /24 (192.168.65.x, .66.x, …) when one is taken, so \
|
||||
an allowlist pinned to a single /24 stops working the day the subnet shifts \
|
||||
and every guest connection then fails with "No route to host". Widen it to \
|
||||
cover the whole span: sudo defaults write com.apple.network.local-network \
|
||||
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
|
||||
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -554,11 +574,51 @@ public enum Doctor {
|
||||
Terminal in the GUI session. On an unattended CI host prefer the subnet \
|
||||
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
|
||||
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
|
||||
"192.168.64.0/24" (then reboot). See docs/setup.md §2.6.
|
||||
"192.168.64.0/18" (then reboot). See docs/setup.md §2.6.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
/// The span of addresses a vmnet NAT link can plausibly use.
|
||||
///
|
||||
/// `192.168.64.0/24` is only the *first* choice: the subnet is picked at
|
||||
/// runtime and steps to the next free /24 when that one is already in use,
|
||||
/// which is why a host that worked yesterday can hand out `192.168.65.x`
|
||||
/// today. Everything from 192.168.64.0 to 192.168.127.255 — a /18 — is
|
||||
/// treated as guest territory so the allowlist survives that drift.
|
||||
static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
|
||||
static let vmNetLastAddress: UInt32 = 0xC0A8_7FFF // 192.168.127.255
|
||||
|
||||
/// Whether one allowlist entry covers the whole guest range.
|
||||
///
|
||||
/// Deliberately all-or-nothing: partial cover is the failure mode being
|
||||
/// warned about, so an entry that contains today's subnet but not
|
||||
/// tomorrow's is not treated as good enough.
|
||||
static func coversVMNetRange(_ entry: String) -> Bool {
|
||||
let parts = entry.split(separator: "/", maxSplits: 1)
|
||||
guard let base = ipv4Value(String(parts[0])) else { return false }
|
||||
let prefix = parts.count == 2 ? Int(parts[1]) : 32
|
||||
guard let prefix, (0...32).contains(prefix) else { return false }
|
||||
|
||||
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
|
||||
let network = base & mask
|
||||
let broadcast = network | ~mask
|
||||
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
|
||||
}
|
||||
|
||||
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
|
||||
/// (an IPv6 entry, a hostname, a typo).
|
||||
static func ipv4Value(_ text: String) -> UInt32? {
|
||||
let octets = text.split(separator: ".", omittingEmptySubsequences: false)
|
||||
guard octets.count == 4 else { return nil }
|
||||
var value: UInt32 = 0
|
||||
for octet in octets {
|
||||
guard let number = UInt32(octet), number <= 255 else { return nil }
|
||||
value = value << 8 | number
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Subnets pre-authorized for local network access on this host, if any.
|
||||
///
|
||||
/// Best effort and never fatal: an unreadable or absent preferences file
|
||||
|
||||
Reference in New Issue
Block a user