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 { var asCoreError: CoreError {
switch self { switch self {
case .connectFailed(let host, let port, let underlying): 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): case .authenticationFailed(let host, let username):
return .sshFailed("authentication failed for \(username)@\(host)") 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. /// 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 } 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)") throw CoreError.timeout("ssh on \(host):\(port)\(detail)")
} }
+68 -8
View File
@@ -525,19 +525,39 @@ public enum Doctor {
/// The macOS 15+ Local Network permission note. /// The macOS 15+ Local Network permission note.
/// ///
/// Reports `.pass` when the host carries a subnet allowlist, because that /// Reports `.pass` when the host carries a subnet allowlist that actually
/// bypasses the prompt entirely. Otherwise it stays informational: we /// covers where guests turn up, because that bypasses the prompt entirely.
/// cannot see the grant itself, since Local Network privacy is a Network /// An allowlist that names some *other* subnet is worse than none, since it
/// Extension packet filter rather than a TCC entry, so there is no /// looks configured while blocking every guest, so it warns rather than
/// database to query and `tccutil` does not apply (Apple, TN3179). /// 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 { public static func localNetworkNote() -> DoctorCheck {
let name = "local network access" let name = "local network access"
let allowed = localNetworkAllowlist() let allowed = localNetworkAllowlist()
if !allowed.isEmpty { if !allowed.isEmpty {
if allowed.contains(where: coversVMNetRange) {
return DoctorCheck(
name: name,
result: .pass,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
)
}
return DoctorCheck( return DoctorCheck(
name: name, name: name,
result: .pass, result: .warn,
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))" 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 \ Terminal in the GUI session. On an unattended CI host prefer the subnet \
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \ allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \ 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. /// Subnets pre-authorized for local network access on this host, if any.
/// ///
/// Best effort and never fatal: an unreadable or absent preferences file /// 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 ```sh
sudo defaults write com.apple.network.local-network \ 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 \ 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 Then **reboot** — these are read at boot, so restarting the service alone is not enough.
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 **Do not pin this to a single /24.** Virtualization.framework's NAT starts at `192.168.64.0/24` but
`"10.0.0.0/8" "172.16.0.0/12" "192.168.0.0/16"` also works. `doctor` reports `local network access` picks the subnet at runtime and steps to the next free one when that range is already in use, so the
as a **pass** once it can see an allowlist. Both keys are documented by Apple in 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) [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. 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 | | `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 | | `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 | | `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 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 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 | | 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` | | 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 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 | | `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 | | 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>` | | `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 ```sh
sudo defaults write com.apple.network.local-network \ 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 \ 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 The `/18` is deliberate: the NAT subnet is chosen at runtime and slides to the next free /24
access` as a pass once it can see this. This is the deterministic fix for an unattended host — (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) 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. 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 ```sh
sudo defaults write com.apple.network.local-network \ 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 \ 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 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 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 `image '<name>' already exists`. To re-run provisioning on a finished image, use
`gitea-macos-runner image provision <name>` instead. `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.