From 8c410cf8415734ab25c8c2142f7a6fd2d5985dec Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 01:14:28 -0700 Subject: [PATCH] Merge nucleic/mellow-dewy-falcon-rjhr into main --- Resources/Info.plist | 9 ++++ Sources/RunnerHost/Doctor.swift | 68 ++++++++++++++++++++++++++---- docs/setup.md | 41 ++++++++++++++---- docs/troubleshooting.md | 73 +++++++++++++++++++++++++++------ 4 files changed, 163 insertions(+), 28 deletions(-) diff --git a/Resources/Info.plist b/Resources/Info.plist index c2a7a3a..3be3d87 100644 --- a/Resources/Info.plist +++ b/Resources/Info.plist @@ -38,6 +38,15 @@ LSMinimumSystemVersion 26.0 + + NSLocalNetworkUsageDescription + Gitea macOS Runner connects to the virtual machines it starts on this Mac, over the host-private network link, to run your CI jobs. + NSHumanReadableCopyright diff --git a/Sources/RunnerHost/Doctor.swift b/Sources/RunnerHost/Doctor.swift index 08cb35f..bd73a82 100644 --- a/Sources/RunnerHost/Doctor.swift +++ b/Sources/RunnerHost/Doctor.swift @@ -92,10 +92,11 @@ public enum Doctor { /// same verb the real download uses, because the presigned redirect /// target is signed per method. Catches a version bump that no longer /// has a darwin-arm64 asset. - /// 10. **Local Network privacy note** (informational). On macOS 15+ the - /// first attempt to reach a guest over the NAT link can be blocked by - /// the Local Network permission prompt, which a background agent cannot - /// answer; the operator must approve the app once. + /// 10. **Local Network privacy**. Passes when a subnet allowlist is set in + /// `com.apple.network.local-network`; otherwise informational. On + /// macOS 15+ the first attempt to reach a guest over the NAT link can + /// be blocked by the Local Network permission prompt, which a + /// background agent cannot answer. /// /// - Parameter config: Validated configuration. Gitea-dependent checks are /// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set. @@ -523,19 +524,70 @@ 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). public static func localNetworkNote() -> DoctorCheck { - DoctorCheck( - name: "local network access", + let name = "local network access" + let allowed = localNetworkAllowlist() + if !allowed.isEmpty { + return DoctorCheck( + name: name, + result: .pass, + detail: "subnet allowlist set: \(allowed.joined(separator: ", "))" + ) + } + + return DoctorCheck( + name: name, result: .info, detail: "guests are reached over the host-private NAT link", remediation: """ on macOS 15+ the first connection to a guest can be blocked by the Local Network \ - privacy prompt, which a background LaunchAgent cannot answer. Approve the app once \ - under System Settings → Privacy & Security → Local Network. + privacy prompt, which a background LaunchAgent cannot answer. The app cannot be \ + pre-approved: it only appears under System Settings → Privacy & Security → Local \ + Network once it has actually attempted a guest connection. To trigger and answer \ + the prompt by hand, run `gitea-macos-runner vm boot --image default` once from a \ + 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. """ ) } + /// Subnets pre-authorized for local network access on this host, if any. + /// + /// Best effort and never fatal: an unreadable or absent preferences file + /// simply reads as "no allowlist". The domain is written with `sudo`, so + /// which preferences directory it lands in depends on whether that `sudo` + /// preserved `HOME` — check each candidate rather than guess. + static func localNetworkAllowlist() -> [String] { + let keys = ["AllowedEthernetLocalNetworkAddresses", "AllowedWiFiLocalNetworkAddresses"] + let candidates = [ + "/var/root/Library/Preferences/com.apple.network.local-network.plist", + "/Library/Preferences/com.apple.network.local-network.plist", + NSHomeDirectory() + "/Library/Preferences/com.apple.network.local-network.plist", + ] + + var found: [String] = [] + for path in candidates { + guard let data = FileManager.default.contents(atPath: path), + let plist = try? PropertyListSerialization.propertyList( + from: data, options: [], format: nil) as? [String: Any] + else { continue } + for key in keys { + for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) { + found.append(entry) + } + } + } + return found + } + /// Renders checks as aligned, human-readable lines for the CLI. public static func format(_ checks: [DoctorCheck]) -> String { let width = checks.map(\.name.count).max() ?? 0 diff --git a/docs/setup.md b/docs/setup.md index f678462..747e345 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -385,18 +385,43 @@ Starting with macOS 15, a process that contacts other hosts on the local network one-time Local Network permission prompt. A LaunchAgent that is denied (or that never gets a human to click Allow) cannot reach the guest's NAT address, so VMs boot but SSH never connects. -Grant it interactively the first time — run `gitea-macos-runner vm boot` from a -Terminal in the GUI session and click **Allow** — or pre-authorize the VM subnet: +**On a CI host, use the subnet allowlist.** It is the only deterministic option — no prompt, no GUI +session, and nothing to redo after a rebuild: ```sh sudo defaults write com.apple.network.local-network \ - AllowedEthernetLocalNetworkAddresses -array "192.168.0.0/16" + AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24" +sudo defaults write com.apple.network.local-network \ + AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24" ``` -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). Reboot, or restart the service, for the change to take -effect. Also confirm the runner is enabled under **System Settings → Privacy & Security → Local -Network**. +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 +[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. + +**Approving interactively instead.** The app cannot be pre-approved: it appears under **System +Settings → Privacy & Security → Local Network** only *after* it has actually attempted a connection +to a guest. An empty list is expected on a fresh install and does not mean anything is broken. To +create the entry and answer the prompt, run one boot by hand from a Terminal in the GUI session: + +```sh +gitea-macos-runner vm boot --image default +``` + +and click **Allow**. Do not wait for the LaunchAgent to hit it — a background agent has no way to +answer the prompt. + +> **Caveat with ad-hoc signing.** An interactive grant is not durable for this project's ad-hoc +> signed bundle. Local Network privacy does not use TCC; per TN3179 it "uses your main executable +> UUID as part of its implementation", and the linker mints a fresh `LC_UUID` on essentially every +> rebuild. So `make install` after a code change is liable to present as a new app that must be +> approved again — and macOS offers no way to reset a Local Network decision back to undetermined, +> so the stale entries accumulate. This is why the allowlist above, which is keyed on the subnet +> rather than on the app, is the recommendation for an unattended machine. --- @@ -428,7 +453,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` | An informational note about the macOS 15+ Local Network prompt | +| `local network access` | Passes when a subnet allowlist is set; 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 f2e5dba..1ee04d6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -24,6 +24,7 @@ gitea-macos-runner service status | VM won't start; entitlement / `com.apple.security.virtualization` error | Running an unsigned binary, or one outside the signed `.app` bundle | `make sign` (or re-run `make install`); invoke the installed bundle, never `.build/release/…` | | `virtualMachineLimitExceeded` at boot | macOS allows at most **2** concurrent macOS VMs | Set `scheduler.maxConcurrentVMs` ≤ 2; kill stray VMs from earlier runs | | 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 | | `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 | @@ -96,23 +97,71 @@ prompt**: a LaunchAgent that was never granted permission (or was denied) cannot An entry with a recent `lease` timestamp and the guest's MAC means networking is fine and the problem is timing — raise `scheduler.bootTimeoutSeconds`. -2. No entry at all: check **System Settings → Privacy & Security → Local Network** and enable the - runner. If it isn't listed, trigger the prompt interactively from a Terminal in the GUI session: - - ```sh - gitea-macos-runner vm boot - ``` - - and click **Allow**. - -3. Or pre-authorize the VM subnet, then reboot: +2. No entry at all: pre-authorize the VM subnet, then **reboot** (these are read at boot): ```sh sudo defaults write com.apple.network.local-network \ - AllowedEthernetLocalNetworkAddresses -array "192.168.0.0/16" + AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24" + sudo defaults write com.apple.network.local-network \ + AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24" ``` - Match the range to what your host's NAT actually hands out. + 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 — + 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. + +3. Or grant it interactively: run `gitea-macos-runner vm boot --image default` from a Terminal in + the GUI session and click **Allow**. The app is not listed under **System Settings → Privacy & + Security → Local Network** until it has made that first attempt. + +--- + +## Local Network: the app is not listed in System Settings + +**Symptom.** `doctor` prints the `local network access` note telling you to approve the app under +**System Settings → Privacy & Security → Local Network**, but the runner is nowhere in that list — +so there is nothing to switch on. + +**Cause.** This is expected, not a bug. The Local Network list is populated lazily: an app appears +there only *after* it has actually attempted a local-network connection and been evaluated. It +cannot be pre-approved. A freshly installed runner that has not yet reached a guest has no entry. + +There is also nothing to query, so `doctor` cannot tell you the grant's state. Unlike most macOS +privacy controls, Local Network privacy is not stored in TCC — per +[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy) +the checks live "deep in the networking stack" as a Network Extension packet filter, so the +permission is absent from `TCC.db` and `tccutil reset` does not apply to it. + +**Fix — interactive.** Make the app connect once, from a GUI session where a human can answer: + +```sh +gitea-macos-runner vm boot --image default +``` + +Click **Allow**. The entry now exists and can be toggled later. Do not wait for the LaunchAgent to +trigger it; a background agent cannot answer the prompt, so it simply fails to reach the guest. + +**Fix — deterministic, and what to use on a CI box.** Allowlist the VM subnet instead. It is keyed +on the network rather than on the app, so no prompt is involved and nothing needs redoing: + +```sh +sudo defaults write com.apple.network.local-network \ + AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/24" +sudo defaults write com.apple.network.local-network \ + AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/24" +``` + +Reboot afterwards. `doctor` then reports `local network access` as a **pass**. Both keys are +Apple's, documented in TN3179; the same pair is what [Tart's FAQ](https://tart.run/faq/) recommends +for this exact problem on CI hosts. + +> **Why the interactive grant does not stick here.** This project ships an **ad-hoc signed** bundle +> (`codesign --sign -`), and TN3179 notes that "local network privacy uses your main executable UUID +> as part of its implementation". The linker writes a new `LC_UUID` on essentially every rebuild, so +> a rebuilt-and-reinstalled runner can read as a *different* program and prompt again — while the +> old row lingers, since macOS provides no way to reset a Local Network decision to undetermined. +> Expect duplicate entries after a few upgrades. The subnet allowlist avoids all of this. ---