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
|
||||
|
||||
+14
-8
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user