From 042bd813a839265017fc6e78eaa0d5ca48288287 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 03:46:54 -0700 Subject: [PATCH] Merge nucleic/mellow-dewy-falcon-rjhr into main --- Sources/RunnerCore/SSHExec.swift | 50 ++++++++++++++++++++- Sources/RunnerHost/Doctor.swift | 76 ++++++++++++++++++++++++++++---- docs/setup.md | 22 +++++---- docs/troubleshooting.md | 75 ++++++++++++++++++++++++++++--- 4 files changed, 199 insertions(+), 24 deletions(-) diff --git a/Sources/RunnerCore/SSHExec.swift b/Sources/RunnerCore/SSHExec.swift index 1232c02..0ddde45 100644 --- a/Sources/RunnerCore/SSHExec.swift +++ b/Sources/RunnerCore/SSHExec.swift @@ -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)") } diff --git a/Sources/RunnerHost/Doctor.swift b/Sources/RunnerHost/Doctor.swift index bd73a82..ae2137c 100644 --- a/Sources/RunnerHost/Doctor.swift +++ b/Sources/RunnerHost/Doctor.swift @@ -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 diff --git a/docs/setup.md b/docs/setup.md index 5bf573f..ef240ca 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 3a97675..10d9504 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 ` | @@ -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 '' already exists`. To re-run provisioning on a finished image, use `gitea-macos-runner image provision ` 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.