Merge nucleic/mellow-dewy-falcon-rjhr into main
This commit is contained in:
@@ -38,6 +38,15 @@
|
|||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>26.0</string>
|
<string>26.0</string>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Shown in the macOS 15+ Local Network permission prompt. The runner reaches
|
||||||
|
each guest VM over the host-private NAT link (SSH on 192.168.64.0/24) to
|
||||||
|
install and start the Gitea runner agent; without this access every VM
|
||||||
|
boots but no job ever starts.
|
||||||
|
-->
|
||||||
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
|
<string>Gitea macOS Runner connects to the virtual machines it starts on this Mac, over the host-private network link, to run your CI jobs.</string>
|
||||||
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string></string>
|
<string></string>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -92,10 +92,11 @@ public enum Doctor {
|
|||||||
/// same verb the real download uses, because the presigned redirect
|
/// same verb the real download uses, because the presigned redirect
|
||||||
/// target is signed per method. Catches a version bump that no longer
|
/// target is signed per method. Catches a version bump that no longer
|
||||||
/// has a darwin-arm64 asset.
|
/// has a darwin-arm64 asset.
|
||||||
/// 10. **Local Network privacy note** (informational). On macOS 15+ the
|
/// 10. **Local Network privacy**. Passes when a subnet allowlist is set in
|
||||||
/// first attempt to reach a guest over the NAT link can be blocked by
|
/// `com.apple.network.local-network`; otherwise informational. On
|
||||||
/// the Local Network permission prompt, which a background agent cannot
|
/// macOS 15+ the first attempt to reach a guest over the NAT link can
|
||||||
/// answer; the operator must approve the app once.
|
/// be blocked by the Local Network permission prompt, which a
|
||||||
|
/// background agent cannot answer.
|
||||||
///
|
///
|
||||||
/// - Parameter config: Validated configuration. Gitea-dependent checks are
|
/// - Parameter config: Validated configuration. Gitea-dependent checks are
|
||||||
/// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set.
|
/// 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.
|
/// 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 {
|
public static func localNetworkNote() -> DoctorCheck {
|
||||||
DoctorCheck(
|
let name = "local network access"
|
||||||
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,
|
result: .info,
|
||||||
detail: "guests are reached over the host-private NAT link",
|
detail: "guests are reached over the host-private NAT link",
|
||||||
remediation: """
|
remediation: """
|
||||||
on macOS 15+ the first connection to a guest can be blocked by the Local Network \
|
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 \
|
privacy prompt, which a background LaunchAgent cannot answer. The app cannot be \
|
||||||
under System Settings → Privacy & Security → Local Network.
|
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.
|
/// Renders checks as aligned, human-readable lines for the CLI.
|
||||||
public static func format(_ checks: [DoctorCheck]) -> String {
|
public static func format(_ checks: [DoctorCheck]) -> String {
|
||||||
let width = checks.map(\.name.count).max() ?? 0
|
let width = checks.map(\.name.count).max() ?? 0
|
||||||
|
|||||||
+33
-8
@@ -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
|
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.
|
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
|
**On a CI host, use the subnet allowlist.** It is the only deterministic option — no prompt, no GUI
|
||||||
Terminal in the GUI session and click **Allow** — or pre-authorize the VM subnet:
|
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.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
|
Then **reboot** — these are read at boot, so restarting the service alone is not enough. Adjust the
|
||||||
`/var/db/dhcpd_leases` after a VM boots). Reboot, or restart the service, for the change to take
|
range to match the subnet Virtualization.framework's NAT hands out on your host (check
|
||||||
effect. Also confirm the runner is enabled under **System Settings → Privacy & Security → Local
|
`/var/db/dhcpd_leases` after a VM boots); if you would rather not pin it, the RFC 1918 set
|
||||||
Network**.
|
`"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 |
|
| `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` | 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
|
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
|
||||||
|
|||||||
+61
-12
@@ -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/…` |
|
| 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 |
|
| `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 |
|
| 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 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 |
|
| `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 |
|
||||||
@@ -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
|
An entry with a recent `lease` timestamp and the guest's MAC means networking is fine and the
|
||||||
problem is timing — raise `scheduler.bootTimeoutSeconds`.
|
problem is timing — raise `scheduler.bootTimeoutSeconds`.
|
||||||
|
|
||||||
2. No entry at all: check **System Settings → Privacy & Security → Local Network** and enable the
|
2. No entry at all: pre-authorize the VM subnet, then **reboot** (these are read at boot):
|
||||||
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:
|
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
sudo defaults write com.apple.network.local-network \
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user