Merge nucleic/mellow-dewy-falcon-rjhr into main

This commit is contained in:
2026-08-07 03:46:54 -07:00
parent b84835649c
commit 042bd813a8
4 changed files with 199 additions and 24 deletions
+48 -2
View File
@@ -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)")
}
+66 -6
View File
@@ -525,21 +525,41 @@ 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: .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.
"""
)
}
return DoctorCheck(
name: name,
@@ -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
+14 -8
View File
@@ -401,16 +401,22 @@ session, and nothing to redo after a rebuild:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
```
Then **reboot** — these are read at boot, so restarting the service alone is not enough. Adjust the
range to match the subnet Virtualization.framework's NAT hands out on your host (check
`/var/db/dhcpd_leases` after a VM boots); if you would rather not pin it, the RFC 1918 set
`"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor` reports `local network access`
as a **pass** once it can see an allowlist. Both keys are documented by Apple in
Then **reboot** — these are read at boot, so restarting the service alone is not enough.
**Do not pin this to a single /24.** Virtualization.framework's NAT starts at `192.168.64.0/24` but
picks the subnet at runtime and steps to the next free one when that range is already in use, so the
same host can hand out `192.168.65.x` tomorrow. An allowlist naming only `192.168.64.0/24` then
looks configured while silently blocking every guest — the failure surfaces as `No route to host`
(errno 65) on the SSH connection, not as a permission error. The `/18` above spans
`192.168.64.0`–`192.168.127.255`, which covers the drift; if you would rather not think about
ranges at all, the RFC 1918 set `"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor`
reports `local network access` as a **pass** once it sees an allowlist that covers that span, and as
a **warning** when an allowlist exists but does not. Both keys are documented by Apple in
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
and are the workaround [Tart's FAQ](https://tart.run/faq/) recommends for the same problem.
@@ -464,7 +470,7 @@ The checks, in order:
| `registration token` | A static token resolves, or one can be fetched when `fetchRegistrationTokenViaAPI` is on |
| `runner download url` | The `gitea-runner` release asset is reachable |
| `token file permissions` | Warns — not fails — when a token file is group- or world-readable |
| `local network access` | Passes when a subnet allowlist is set; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
| `local network access` | Passes when a subnet allowlist covers `192.168.64.0/18`; warns when an allowlist exists but is scoped too narrowly; otherwise an informational note about the macOS 15+ Local Network prompt (§2.6) |
If the config file is missing or invalid, the host checks still run and the rest
are skipped — which is exactly the state a first-time operator is in. Resolve
+69 -6
View File
@@ -26,6 +26,8 @@ gitea-macos-runner service status
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; grant Local Network permission or pre-authorize the subnet |
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection; it cannot be pre-approved | Boot one VM by hand from a GUI Terminal to create the entry, or (better on CI) allowlist the subnet with `defaults write com.apple.network.local-network` |
| SSH times out on a freshly built image | Guest macOS < 27, so provisioning options were ignored and Setup Assistant is waiting | Rebuild the image from a macOS **27+** IPSW |
| `ssh failed: cannot connect … No route to host) (errno: 65)` part-way through provisioning | macOS 15+ Local Network privacy blocking the app — the grant is keyed on the executable's UUID, so `make install` withdraws it | Allowlist the subnet (`192.168.64.0/18`) and **reboot**; see [SSH fails with "No route to host" mid-run](#ssh-fails-with-no-route-to-host-errno-65-mid-run) |
| Allowlist is set but guests are still unreachable | It names `192.168.64.0/24` while the NAT has moved to `192.168.65.x` | Widen it to `192.168.64.0/18` and reboot; `doctor` now warns about too-narrow allowlists |
| `SecKeyCreateRandomKey` / "Interaction is not allowed" | `login.keychain` is locked — no GUI session | Run as a LaunchAgent in an unlocked GUI session; enable auto-login |
| Job stays queued forever | Label mismatch, or the daemon isn't running/reaching Gitea | Use bare label names in `runs-on`; match `runner.labels`; check daemon logs |
| `actions/checkout` fails instantly | Node.js missing from the guest image | `gitea-macos-runner image provision <name>` |
@@ -105,13 +107,15 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
```
Match the range to what your host's NAT actually hands out. `doctor` reports `local network
access` as a pass once it can see this. This is the deterministic fix for an unattended host —
The `/18` is deliberate: the NAT subnet is chosen at runtime and slides to the next free /24
(192.168.65.x, .66.x, …) when one is taken, so a pinned `192.168.64.0/24` breaks the day it
moves. `doctor` reports `local network access` as a pass once it sees an allowlist covering that
span. This is the deterministic fix for an unattended host —
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
for why the interactive grant is not.
@@ -151,9 +155,9 @@ on the network rather than on the app, so no prompt is involved and nothing need
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24"
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24"
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
```
Reboot afterwards. `doctor` then reports `local network access` as a **pass**. Both keys are
@@ -513,3 +517,62 @@ explicitly — that state is unrecoverable, and the fix is `image delete` follow
An image that is already `provisioned` is untouched; `image build` still refuses with
`image '<name>' already exists`. To re-run provisioning on a finished image, use
`gitea-macos-runner image provision <name>` instead.
---
## SSH fails with "No route to host" (errno 65) mid-run
**Symptom.** A command that had *just* talked to the guest successfully suddenly cannot reach it —
most visibly `image provision`, which waits for SSH, reports the first provisioning step, and then
dies on the upload:
```
first boot + guest provisioning…
provisioning: system configuration (provision.sh)
error: provisioning failed: could not upload provision.sh from …/provision.sh:
ssh failed: cannot connect to 192.168.65.232:22: … No route to host) (errno: 65)
```
**Cause.** On macOS 15 and newer, an app that has not been granted Local Network access does not get
a "permission denied": the packet filter answers **`EHOSTUNREACH` — errno 65, "No route to host"**,
which is indistinguishable from a guest that is genuinely off the network. Guests here live on a
host-private NAT link that is reachable whenever the VM is up, so on this path errno 65 is far more
often the privacy filter than a routing problem.
Two details make it look intermittent rather than like a permission problem:
- **The grant is keyed on the executable's UUID.** Per
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy),
Local Network privacy "uses your main executable UUID as part of its implementation", and the
linker mints a fresh `LC_UUID` on essentially every build. A `make install` after a code change
therefore presents a program macOS has never seen, whose permission is undetermined again — even
though the binary you ran ten minutes ago worked.
- **Processes started over SSH are exempt.** Running the same command through `ssh you@host …`
succeeds while running it from a GUI Terminal fails. A remote-shell success proves nothing about
the interactive path.
**Fix.** Allowlist the subnet — it is keyed on the network, not on the app, so no rebuild can
withdraw it and no prompt has to be answered:
```sh
sudo defaults write com.apple.network.local-network \
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18"
sudo defaults write com.apple.network.local-network \
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
sudo reboot
```
The values are only read at boot, so **the reboot is not optional** — until it happens, `defaults
read com.apple.network.local-network` shows the new setting while the filter still behaves as
before.
Use `/18`, not `/24`. Virtualization.framework's NAT starts at `192.168.64.0/24` but chooses the
subnet at runtime and steps to the next free /24 when that one is in use, so hosts drift to
`192.168.65.x` and beyond. An allowlist naming a single /24 that the NAT has since moved off is the
worst case: it reads as configured, `doctor` used to call it a pass, and every guest connection
still fails with errno 65. `doctor` now warns instead when the allowlist does not cover
`192.168.64.0`–`192.168.127.255`.
**Verifying.** After the reboot, `gitea-macos-runner doctor` should show `local network access` as a
pass naming the range. Re-run the command that failed; nothing else needs redoing, and
`image provision` is idempotent, so a partially completed run is safe to repeat.