Merge nucleic/vivid-glass-urchin-xoym into main
This commit is contained in:
@@ -87,6 +87,8 @@ gitea-macos-runner image build --ipsw ~/Downloads/UniversalMac_27.0_*.ipsw
|
||||
gitea-macos-runner image provision default --xcode-xip ~/Downloads/Xcode_XX.xip
|
||||
|
||||
# Install and start the LaunchAgent (runs in your GUI login session — not a LaunchDaemon).
|
||||
# On a terminal this also offers to grant macOS Local Network access, which the agent
|
||||
# needs to reach its guests; `permissions grant` does the same thing on its own.
|
||||
gitea-macos-runner service install
|
||||
gitea-macos-runner service status
|
||||
```
|
||||
@@ -148,9 +150,11 @@ Every subcommand accepts the global options `--config PATH` (`-c`, default
|
||||
| `image delete NAME [--force]` | Delete a base image and its disk. `--force` (`-f`) skips the confirmation prompt. |
|
||||
| `vm boot [--image NAME] [--slot N] [--keep]` | Clone an image, boot it, print its IP, and wait for Ctrl-C. `--slot` picks which persistent per-slot MAC to use (default `0`); `--keep` leaves the clone on disk. |
|
||||
| `vm list` | List ephemeral VM clones on disk. |
|
||||
| `service install [--executable PATH]` | Write and load `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`. Also evicts any agent left behind under a previous label. |
|
||||
| `service install [--executable PATH] [--grant-local-network allowlist\|prompt\|none]` | Write and load `~/Library/LaunchAgents/xyz.blakeslee.gitea-macos-vm-orchestrator.plist`. Also evicts any agent left behind under a previous label. On a terminal it offers to configure Local Network access when that is unconfigured, defaulting to no; `--grant-local-network` decides it up front. |
|
||||
| `service uninstall` | Unload the LaunchAgent and remove its plist. |
|
||||
| `service status` | Report LaunchAgent installation and run state. |
|
||||
| `permissions status` | Report whether macOS Local Network access is configured, and whether the code identity is stable enough to hold an interactive grant. |
|
||||
| `permissions grant [--method allowlist\|prompt] [--subnet CIDR ...] [--reboot\|--no-reboot]` | Grant Local Network access. `allowlist` (default) writes the subnet allowlist with sudo — all of RFC 1918 unless `--subnet` narrows it — and needs a reboot. `prompt` launches the installed `.app` so the system alert is attributed to it rather than to Terminal, and applies immediately. |
|
||||
| `doctor [--json] [--no-fail]` | Preflight checks. `--json` emits machine-readable results; `--no-fail` exits zero even when checks fail. |
|
||||
| `config init [--force] [--instance-url URL]` | Write the annotated example config. `--force` (`-f`) overwrites an existing file. |
|
||||
| `config show` | Print the effective configuration with secrets redacted. |
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import Foundation
|
||||
|
||||
/// The macOS 15+ Local Network subnet allowlist: where it lives, what counts as
|
||||
/// covering the guest range, and the commands that write it.
|
||||
///
|
||||
/// ## Why an allowlist at all
|
||||
///
|
||||
/// Local Network privacy is not TCC. It is a Network Extension packet filter,
|
||||
/// so there is no database to query, `tccutil` does not apply, and a blocked
|
||||
/// flow is not reported as "denied" — it comes back `EHOSTUNREACH` (errno 65,
|
||||
/// "No route to host"), indistinguishable from a guest that is genuinely off
|
||||
/// the network (Apple, TN3179).
|
||||
///
|
||||
/// Worse, nothing here is well placed to *answer* the prompt. A LaunchAgent has
|
||||
/// no UI to show it in, and a run started from a shell is attributed to the
|
||||
/// **responsible process** — Terminal — so both the prompt and the System
|
||||
/// Settings row belong to Terminal, and granting it there does not carry over
|
||||
/// to the agent.
|
||||
///
|
||||
/// The allowlist sidesteps all of that: it is consulted before the per-app
|
||||
/// check, so a flow to a listed subnet is never subject to a prompt, by any
|
||||
/// process. Its one cost is that the values are read at boot, so setting it
|
||||
/// requires a reboot to take effect. That is the trade this type exists to make
|
||||
/// explicit.
|
||||
///
|
||||
/// Everything here is pure — reading a plist and formatting argument vectors —
|
||||
/// so it lives in `RunnerCore` and is unit-tested. The effectful half (running
|
||||
/// `sudo`, launching the app to trigger a prompt) is `RunnerHost`'s
|
||||
/// `LocalNetworkPermission`.
|
||||
public enum LocalNetworkPolicy {
|
||||
|
||||
// MARK: - Where the setting lives
|
||||
|
||||
/// The preferences domain macOS reads the allowlist from.
|
||||
public static let domain = "com.apple.network.local-network"
|
||||
|
||||
/// The wired interfaces key.
|
||||
public static let ethernetKey = "AllowedEthernetLocalNetworkAddresses"
|
||||
|
||||
/// The Wi-Fi interfaces key.
|
||||
public static let wifiKey = "AllowedWiFiLocalNetworkAddresses"
|
||||
|
||||
/// Both keys. Guests are reached over a virtual interface, and which of the
|
||||
/// two the filter consults is not something we get to observe — so both are
|
||||
/// always written, and both are read back.
|
||||
public static let keys = [ethernetKey, wifiKey]
|
||||
|
||||
/// Every preferences file the allowlist could plausibly be written to.
|
||||
///
|
||||
/// The domain is written with `sudo`, so which preferences directory it
|
||||
/// lands in depends on whether that `sudo` preserved `HOME`. Rather than
|
||||
/// guess at the host's sudoers configuration, check each candidate.
|
||||
public static func preferenceCandidates() -> [String] {
|
||||
[
|
||||
"/var/root/Library/Preferences/\(domain).plist",
|
||||
"/Library/Preferences/\(domain).plist",
|
||||
NSHomeDirectory() + "/Library/Preferences/\(domain).plist",
|
||||
]
|
||||
}
|
||||
|
||||
// MARK: - What to write
|
||||
|
||||
/// The subnets granted by default: all of RFC 1918.
|
||||
///
|
||||
/// Deliberately wider than the `192.168.64.0/18` that vmnet actually uses.
|
||||
/// The allowlist is read at boot, so getting it wrong costs a reboot to fix,
|
||||
/// and the failure mode of "too narrow" is silent — guests simply stop being
|
||||
/// reachable the day the host joins a network that shifts things around.
|
||||
/// This is also the set Tart, orchard, and packer-plugin-tart ship, so a
|
||||
/// host already configured for one of those needs no second entry.
|
||||
///
|
||||
/// Narrow it with `permissions grant --subnet` on a host where that matters.
|
||||
public static let defaultSubnets = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"]
|
||||
|
||||
/// The argument vectors that write `subnets` to both keys.
|
||||
///
|
||||
/// Returned as arrays, not a shell string: these are handed straight to
|
||||
/// `/usr/bin/defaults` with no shell in between, so a subnet containing
|
||||
/// something shell-significant cannot become an injection.
|
||||
///
|
||||
/// - Parameter subnets: CIDR entries, e.g. `["192.168.0.0/16"]`.
|
||||
/// - Returns: One `defaults write …` argument vector per key.
|
||||
public static func writeArguments(subnets: [String]) -> [[String]] {
|
||||
keys.map { key in ["write", domain, key, "-array"] + subnets }
|
||||
}
|
||||
|
||||
/// The same commands as copy-pasteable shell, for the message shown when we
|
||||
/// cannot run them ourselves.
|
||||
public static func writeCommandLines(subnets: [String]) -> [String] {
|
||||
writeArguments(subnets: subnets).map { arguments in
|
||||
"sudo defaults " + arguments.map(quoteForShell).joined(separator: " ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Single-quotes an argument unless it is plainly inert.
|
||||
private static func quoteForShell(_ argument: String) -> String {
|
||||
let safe = argument.allSatisfy { $0.isLetter || $0.isNumber || "./_-".contains($0) }
|
||||
guard !safe || argument.isEmpty else { return argument }
|
||||
return "'" + argument.replacingOccurrences(of: "'", with: #"'\''"#) + "'"
|
||||
}
|
||||
|
||||
// MARK: - Reading it back
|
||||
|
||||
/// What the host's allowlist currently says.
|
||||
public struct Status: Sendable, Equatable {
|
||||
/// Every distinct entry found, across both keys and all candidate files.
|
||||
public let allowlist: [String]
|
||||
|
||||
/// The files the entries came from, in the order they were checked.
|
||||
public let sourcePaths: [String]
|
||||
|
||||
/// Whether at least one entry covers the whole vmnet range.
|
||||
public let coversGuestRange: Bool
|
||||
|
||||
/// Whether anything is configured at all.
|
||||
public var isConfigured: Bool { !allowlist.isEmpty }
|
||||
|
||||
public init(allowlist: [String], sourcePaths: [String], coversGuestRange: Bool) {
|
||||
self.allowlist = allowlist
|
||||
self.sourcePaths = sourcePaths
|
||||
self.coversGuestRange = coversGuestRange
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads the host's current allowlist.
|
||||
///
|
||||
/// Best effort and never fatal: an unreadable or absent preferences file
|
||||
/// simply reads as "no allowlist".
|
||||
public static func status() -> Status {
|
||||
var found: [String] = []
|
||||
var sources: [String] = []
|
||||
|
||||
for path in preferenceCandidates() {
|
||||
guard let data = FileManager.default.contents(atPath: path),
|
||||
let plist = try? PropertyListSerialization.propertyList(
|
||||
from: data, options: [], format: nil) as? [String: Any]
|
||||
else { continue }
|
||||
|
||||
var contributed = false
|
||||
for key in keys {
|
||||
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
|
||||
found.append(entry)
|
||||
contributed = true
|
||||
}
|
||||
}
|
||||
if contributed { sources.append(path) }
|
||||
}
|
||||
|
||||
return Status(
|
||||
allowlist: found,
|
||||
sourcePaths: sources,
|
||||
coversGuestRange: found.contains(where: coversVMNetRange)
|
||||
)
|
||||
}
|
||||
|
||||
/// Subnets pre-authorized for local network access on this host, if any.
|
||||
public static func allowlist() -> [String] { status().allowlist }
|
||||
|
||||
// MARK: - Coverage arithmetic
|
||||
|
||||
/// 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.
|
||||
public static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
|
||||
public 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.
|
||||
public static func coversVMNetRange(_ entry: String) -> Bool {
|
||||
guard let (network, broadcast) = range(of: entry) else { return false }
|
||||
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
|
||||
}
|
||||
|
||||
/// Whether `entry` is a well-formed IPv4 address or CIDR block.
|
||||
///
|
||||
/// Used to reject `--subnet` typos at parse time. An entry macOS cannot
|
||||
/// understand is silently ignored by the filter, which would leave the
|
||||
/// operator with a configured-looking allowlist that grants nothing.
|
||||
public static func isValidSubnet(_ entry: String) -> Bool { range(of: entry) != nil }
|
||||
|
||||
/// The first and last address of an IPv4 CIDR entry; nil for anything that
|
||||
/// is not one (an IPv6 entry, a hostname, a typo).
|
||||
///
|
||||
/// A bare address is treated as a /32, matching `defaults`' own reading.
|
||||
static func range(of entry: String) -> (network: UInt32, broadcast: UInt32)? {
|
||||
// Empty components are kept, so a trailing slash is a parse failure
|
||||
// rather than silently reading "192.168.64.0/" as a bare /32 host.
|
||||
let parts = entry.split(separator: "/", maxSplits: 1, omittingEmptySubsequences: false)
|
||||
guard let first = parts.first, let base = ipv4Value(String(first)) else { return nil }
|
||||
|
||||
let prefix = parts.count == 2 ? Int(parts[1]) : 32
|
||||
guard let prefix, (0...32).contains(prefix) else { return nil }
|
||||
|
||||
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
|
||||
let network = base & mask
|
||||
return (network, network | ~mask)
|
||||
}
|
||||
|
||||
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
|
||||
/// (an IPv6 entry, a hostname, a typo).
|
||||
public 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
|
||||
}
|
||||
}
|
||||
@@ -351,12 +351,16 @@ enum SSHTransportError: Error {
|
||||
/// 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
|
||||
/// On an **ad-hoc signed** build 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.
|
||||
/// the grant "uses your main executable UUID" when there is no stable
|
||||
/// designated requirement to key on, 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. A Developer ID
|
||||
/// signature is anchored to the team instead and does not have this
|
||||
/// problem; the hint is unconditional because this layer cannot see which
|
||||
/// kind of signature it is running under.
|
||||
static func localNetworkHint(for underlying: any Error) -> String {
|
||||
let text = "\(underlying)".lowercased()
|
||||
guard text.contains("errno: 65") || text.contains("no route to host")
|
||||
@@ -365,11 +369,8 @@ enum SSHTransportError: Error {
|
||||
|
||||
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. \
|
||||
it blocks an app. Check and fix it with `gitea-macos-runner \
|
||||
permissions status` / `permissions grant`. \
|
||||
See docs/troubleshooting.md)
|
||||
"""
|
||||
}
|
||||
|
||||
@@ -771,28 +771,27 @@ public enum Doctor {
|
||||
/// 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) {
|
||||
let status = LocalNetworkPolicy.status()
|
||||
|
||||
if status.isConfigured {
|
||||
if status.coversGuestRange {
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .pass,
|
||||
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
|
||||
detail: "subnet allowlist set: \(status.allowlist.joined(separator: ", "))"
|
||||
)
|
||||
}
|
||||
return DoctorCheck(
|
||||
name: name,
|
||||
result: .warn,
|
||||
detail: "subnet allowlist set but does not cover the guest range: "
|
||||
+ allowed.joined(separator: ", "),
|
||||
+ status.allowlist.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.
|
||||
and every guest connection then fails with "No route to host". Widen it: \
|
||||
`gitea-macos-runner permissions grant`, then reboot. See docs/setup.md §2.6.
|
||||
"""
|
||||
)
|
||||
}
|
||||
@@ -807,84 +806,14 @@ public enum Doctor {
|
||||
has no UI to show it in; a run started from a shell is attributed to the \
|
||||
*responsible* process, so both the prompt and the System Settings → Privacy & \
|
||||
Security → Local Network row belong to Terminal rather than to this app — and \
|
||||
granting it to Terminal does not carry over to the LaunchAgent. Prefer the subnet \
|
||||
allowlist: it needs no prompt, covers every process, and survives rebuilds. \
|
||||
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.
|
||||
granting it to Terminal does not carry over to the LaunchAgent. Grant it with \
|
||||
`gitea-macos-runner permissions grant`, which writes a subnet allowlist that \
|
||||
needs no prompt, covers every process, and survives rebuilds — then reboot. \
|
||||
`permissions status` explains both routes. 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
|
||||
/// 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
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
import AppKit
|
||||
import Darwin
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
|
||||
/// Grants the host's Local Network access, so the operator does not have to
|
||||
/// paste `sudo defaults write` incantations and work out for themselves that a
|
||||
/// reboot is required.
|
||||
///
|
||||
/// Two routes, with genuinely different trade-offs — see ``Method``:
|
||||
///
|
||||
/// - ``grantViaAllowlist(subnets:allowPasswordPrompt:)`` writes the subnet
|
||||
/// allowlist. Deterministic and process-independent, but inert until reboot.
|
||||
/// - ``triggerPrompt(timeout:)`` provokes the real system prompt, attributed to
|
||||
/// *this app* rather than to Terminal. Takes effect immediately, but depends
|
||||
/// on macOS actually presenting the alert.
|
||||
///
|
||||
/// The pure parts — where the setting lives, what covers the guest range, what
|
||||
/// to write — are `RunnerCore`'s ``LocalNetworkPolicy``. This type is the half
|
||||
/// that runs processes.
|
||||
public enum LocalNetworkPermission {
|
||||
|
||||
/// How to obtain the grant.
|
||||
public enum Method: String, CaseIterable, Sendable {
|
||||
/// Write the subnet allowlist. Needs `sudo` and a reboot.
|
||||
case allowlist
|
||||
/// Provoke the system prompt via LaunchServices. Needs a GUI session.
|
||||
case prompt
|
||||
}
|
||||
|
||||
/// The bundle identifier of the installed app.
|
||||
///
|
||||
/// Must match `Resources/Info.plist`. It is the same string as
|
||||
/// ``LaunchdService/label`` by convention — the agent is named after the
|
||||
/// bundle it launches — but they are read by different subsystems, so this
|
||||
/// spells it out rather than aliasing.
|
||||
public static let bundleIdentifier = "xyz.blakeslee.gitea-macos-vm-orchestrator"
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
public enum PermissionError: Error, CustomStringConvertible {
|
||||
/// `sudo` could not be run non-interactively and there is no terminal
|
||||
/// to prompt on. Carries the commands to run by hand.
|
||||
case needsPassword(commands: [String])
|
||||
/// A `defaults write` exited non-zero.
|
||||
case writeFailed(command: String, exitCode: Int32)
|
||||
/// `open -b` could not find the app.
|
||||
case bundleNotRegistered
|
||||
/// The probe process ran but left no report behind.
|
||||
case probeProducedNoReport
|
||||
/// A subnet argument is not an IPv4 address or CIDR block.
|
||||
case invalidSubnet(String)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .needsPassword(let commands):
|
||||
return """
|
||||
this needs administrator rights and stdin is not a terminal, so there is \
|
||||
nowhere to prompt for a password. Run these by hand, then reboot:
|
||||
""" + commands.map { "\n " + $0 }.joined()
|
||||
case .writeFailed(let command, let exitCode):
|
||||
return "`\(command)` exited \(exitCode)"
|
||||
case .bundleNotRegistered:
|
||||
return """
|
||||
the signed app bundle is not installed, so it cannot be launched as its own \
|
||||
responsible process — which is the entire point of this method. Install it \
|
||||
with `make install`, or use --method allowlist instead.
|
||||
"""
|
||||
case .probeProducedNoReport:
|
||||
return "the probe exited without writing a result"
|
||||
case .invalidSubnet(let entry):
|
||||
return """
|
||||
"\(entry)" is not an IPv4 address or CIDR block. macOS silently ignores \
|
||||
entries it cannot parse, which would leave the allowlist looking configured \
|
||||
while granting nothing.
|
||||
"""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Headless: the subnet allowlist
|
||||
|
||||
/// What ``grantViaAllowlist(subnets:allowPasswordPrompt:)`` actually achieved.
|
||||
public struct AllowlistResult: Sendable {
|
||||
/// The subnets we asked for.
|
||||
public let requested: [String]
|
||||
/// What reading the preferences back afterwards found.
|
||||
public let observed: LocalNetworkPolicy.Status
|
||||
/// Whether every requested subnet is now readable on disk.
|
||||
public var verified: Bool {
|
||||
requested.allSatisfy(observed.allowlist.contains)
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes the subnet allowlist, then reads it back to prove it landed.
|
||||
///
|
||||
/// The read-back is not ceremony. `sudo defaults write <domain>` resolves
|
||||
/// the domain relative to whichever `HOME` survived `sudo`'s `env_reset`,
|
||||
/// which differs between hosts — so the only way to know where the file
|
||||
/// went is to look. A write that succeeds but leaves nothing readable is
|
||||
/// reported as unverified rather than as success.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - subnets: CIDR entries to authorize.
|
||||
/// - allowPasswordPrompt: When true, `sudo` inherits this process's
|
||||
/// terminal and may ask for a password. When false it runs `-n` and
|
||||
/// fails rather than blocking — the right behaviour under `launchd` or
|
||||
/// in a pipeline.
|
||||
/// - Returns: The requested subnets and what is now on disk.
|
||||
public static func grantViaAllowlist(
|
||||
subnets: [String] = LocalNetworkPolicy.defaultSubnets,
|
||||
allowPasswordPrompt: Bool
|
||||
) throws -> AllowlistResult {
|
||||
for subnet in subnets where !LocalNetworkPolicy.isValidSubnet(subnet) {
|
||||
throw PermissionError.invalidSubnet(subnet)
|
||||
}
|
||||
|
||||
let commands = LocalNetworkPolicy.writeCommandLines(subnets: subnets)
|
||||
|
||||
for arguments in LocalNetworkPolicy.writeArguments(subnets: subnets) {
|
||||
let sudoArguments = (allowPasswordPrompt ? [] : ["-n"]) + ["/usr/bin/defaults"] + arguments
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
process.arguments = sudoArguments
|
||||
// Stdio is deliberately inherited rather than piped. sudo reads the
|
||||
// password from /dev/tty and would work either way, but its prompt
|
||||
// and any "not in the sudoers file" complaint belong in front of
|
||||
// the operator, not captured and paraphrased.
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let index = LocalNetworkPolicy.writeArguments(subnets: subnets)
|
||||
.firstIndex(of: arguments) ?? 0
|
||||
if !allowPasswordPrompt {
|
||||
throw PermissionError.needsPassword(commands: commands)
|
||||
}
|
||||
throw PermissionError.writeFailed(
|
||||
command: commands[index], exitCode: process.terminationStatus)
|
||||
}
|
||||
}
|
||||
|
||||
return AllowlistResult(requested: subnets, observed: LocalNetworkPolicy.status())
|
||||
}
|
||||
|
||||
/// Reboots the host. Only ever called from an explicit confirmation — the
|
||||
/// allowlist is read at boot, so nothing else makes it take effect.
|
||||
public static func reboot() throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
|
||||
process.arguments = ["/sbin/shutdown", "-r", "now"]
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
}
|
||||
|
||||
// MARK: - Interactive: the system prompt
|
||||
|
||||
/// What a probe observed.
|
||||
public enum ProbeOutcome: String, Codable, Sendable {
|
||||
/// Datagrams left the host. Either the app is allowed, or the system is
|
||||
/// still deciding — macOS drops packets silently while the prompt is up
|
||||
/// rather than failing the send, so this is "not blocked", not proof.
|
||||
case permitted
|
||||
/// Every send came back `EHOSTUNREACH`. That is what Local Network
|
||||
/// privacy returns when it blocks an app.
|
||||
case blocked
|
||||
/// The socket failed for some unrelated reason.
|
||||
case inconclusive
|
||||
}
|
||||
|
||||
/// A probe result, serialized through a temp file because the probe runs in
|
||||
/// a separate process launched by LaunchServices.
|
||||
public struct ProbeReport: Codable, Sendable {
|
||||
public let outcome: ProbeOutcome
|
||||
public let detail: String
|
||||
|
||||
public init(outcome: ProbeOutcome, detail: String) {
|
||||
self.outcome = outcome
|
||||
self.detail = detail
|
||||
}
|
||||
}
|
||||
|
||||
/// Launches the installed bundle so it provokes the Local Network prompt
|
||||
/// **as itself**, then reports what the launched process observed.
|
||||
///
|
||||
/// The launch is the whole trick. Running this binary from a shell makes
|
||||
/// Terminal the *responsible process*, so the prompt and the System
|
||||
/// Settings row name Terminal — and a grant to Terminal does nothing for
|
||||
/// the LaunchAgent. Going through LaunchServices (`open -b`) makes the app
|
||||
/// its own responsible process, so the grant attaches to the app's code
|
||||
/// identity and the agent inherits it.
|
||||
///
|
||||
/// That only holds because the bundle is Developer ID signed: a team
|
||||
/// anchored designated requirement is a stable identity across rebuilds.
|
||||
/// Under an ad-hoc signature macOS falls back to the Mach-O UUID, which the
|
||||
/// linker regenerates on every link, and the grant would not survive the
|
||||
/// next `make install`.
|
||||
///
|
||||
/// - Parameter timeout: How long to let the child wait for a verdict. It
|
||||
/// needs to outlast a human reading the alert.
|
||||
public static func triggerPrompt(timeout: TimeInterval = 90) throws -> ProbeReport {
|
||||
let reportURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("gmr-probe-\(UUID().uuidString).json")
|
||||
defer { try? FileManager.default.removeItem(at: reportURL) }
|
||||
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/open")
|
||||
process.arguments = [
|
||||
"-n", // a fresh instance; an already-running daemon must not be reused
|
||||
"-b", bundleIdentifier,
|
||||
"--wait-apps",
|
||||
"--args", "permissions", "probe",
|
||||
"--report", reportURL.path,
|
||||
"--timeout", String(Int(timeout)),
|
||||
]
|
||||
// `open` reports "Unable to find application" on stderr; let it through.
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else { throw PermissionError.bundleNotRegistered }
|
||||
|
||||
guard let data = FileManager.default.contents(atPath: reportURL.path),
|
||||
let report = try? JSONDecoder().decode(ProbeReport.self, from: data)
|
||||
else { throw PermissionError.probeProducedNoReport }
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/// The child side of ``triggerPrompt(timeout:)``: touch the local network
|
||||
/// and report whether the packets got out.
|
||||
///
|
||||
/// Sends to the broadcast address and to mDNS multicast, which is what
|
||||
/// makes macOS classify this as local-network traffic and raise the prompt.
|
||||
/// Deliberately does not boot a VM — no guest is needed to trigger the
|
||||
/// check, and this path therefore needs none of the `NSApplication`
|
||||
/// plumbing `VZAppRuntime` exists for.
|
||||
///
|
||||
/// Retries until `deadline` because the verdict is not synchronous: while
|
||||
/// the alert is on screen the system neither fails the send nor delivers
|
||||
/// the packet, so a single attempt cannot distinguish "allowed" from "still
|
||||
/// asking". Looping until the operator answers is what turns it into a
|
||||
/// usable signal.
|
||||
public static func probe(timeout: TimeInterval = 90) async -> ProbeReport {
|
||||
await activateForPrompt()
|
||||
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
var lastErrno: Int32 = 0
|
||||
var attempts = 0
|
||||
|
||||
repeat {
|
||||
attempts += 1
|
||||
guard let code = sendLocalNetworkDatagrams() else {
|
||||
return ProbeReport(
|
||||
outcome: .permitted,
|
||||
detail: attempts == 1
|
||||
? "local network traffic was not blocked"
|
||||
: "local network traffic was allowed after \(attempts) attempts"
|
||||
)
|
||||
}
|
||||
|
||||
lastErrno = code
|
||||
// Anything other than the privacy filter's answer is a real socket
|
||||
// problem; retrying will not change it.
|
||||
guard code == EHOSTUNREACH else {
|
||||
return ProbeReport(
|
||||
outcome: .inconclusive,
|
||||
detail: "socket error \(code): \(describeErrno(code))"
|
||||
)
|
||||
}
|
||||
// Deliberately not Thread.sleep: activateForPrompt just put this
|
||||
// process in the foreground, and a main thread wedged in a sleep is
|
||||
// a process macOS will show as unresponsive while the alert it is
|
||||
// waiting on is on screen.
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} while Date() < deadline
|
||||
|
||||
return ProbeReport(
|
||||
outcome: .blocked,
|
||||
detail: "every send over \(attempts) attempts returned EHOSTUNREACH (errno \(lastErrno))"
|
||||
)
|
||||
}
|
||||
|
||||
/// Sends one datagram to the broadcast address and one to mDNS multicast.
|
||||
///
|
||||
/// - Returns: `nil` if either got out, otherwise the last `errno`.
|
||||
private static func sendLocalNetworkDatagrams() -> Int32? {
|
||||
// Port 9 is discard; 5353 is mDNS. Nothing has to be listening — the
|
||||
// privacy filter makes its decision on the send, not on a reply.
|
||||
let targets: [(address: String, port: UInt16)] = [
|
||||
("255.255.255.255", 9),
|
||||
("224.0.0.251", 5353),
|
||||
]
|
||||
|
||||
var lastErrno: Int32 = EINVAL
|
||||
|
||||
for target in targets {
|
||||
let handle = socket(AF_INET, SOCK_DGRAM, 0)
|
||||
guard handle >= 0 else {
|
||||
lastErrno = errno
|
||||
continue
|
||||
}
|
||||
defer { close(handle) }
|
||||
|
||||
var enable: Int32 = 1
|
||||
setsockopt(handle, SOL_SOCKET, SO_BROADCAST, &enable, socklen_t(MemoryLayout<Int32>.size))
|
||||
|
||||
var destination = sockaddr_in()
|
||||
destination.sin_family = sa_family_t(AF_INET)
|
||||
destination.sin_port = target.port.bigEndian
|
||||
destination.sin_addr.s_addr = inet_addr(target.address)
|
||||
|
||||
let payload: [UInt8] = [0]
|
||||
let sent = withUnsafePointer(to: &destination) { pointer in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) { address in
|
||||
sendto(handle, payload, payload.count, 0, address, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
|
||||
if sent >= 0 { return nil }
|
||||
lastErrno = errno
|
||||
}
|
||||
|
||||
return lastErrno
|
||||
}
|
||||
|
||||
/// `strerror`, with the optionality unwrapped.
|
||||
private static func describeErrno(_ code: Int32) -> String {
|
||||
guard let text = strerror(code) else { return "unknown error" }
|
||||
return String(cString: text)
|
||||
}
|
||||
|
||||
/// Brings the probe process forward so the system alert has a frontmost app
|
||||
/// to attach to.
|
||||
///
|
||||
/// `LSUIElement` in `Info.plist` would otherwise leave this at `.accessory`.
|
||||
/// The daemon wants that — it goes further and sets `.prohibited` — but a
|
||||
/// prompt nobody can see is the exact failure this command exists to fix,
|
||||
/// so the probe opts back in. It starts no VM, so it is not bound by the
|
||||
/// activation policy `VZAppRuntime` needs.
|
||||
@MainActor
|
||||
private static func activateForPrompt() {
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.regular)
|
||||
app.activate(ignoringOtherApps: true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import RunnerCore
|
||||
import RunnerHost
|
||||
|
||||
/// `gitea-macos-runner permissions …` — inspect and grant the macOS 15+ Local
|
||||
/// Network access the runner needs to reach its guests.
|
||||
///
|
||||
/// This exists because the alternative was a paragraph of documentation asking
|
||||
/// the operator to paste two `sudo defaults write` lines and reboot. That is
|
||||
/// the single most common way a freshly installed runner fails — every guest
|
||||
/// boots, no job ever starts, and the only symptom is `No route to host`.
|
||||
struct PermissionsCommand: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "permissions",
|
||||
abstract: "Inspect and grant the macOS Local Network access guests are reached over.",
|
||||
discussion: """
|
||||
macOS 15 and newer filter local-network traffic per app. When the runner is \
|
||||
blocked the connection fails with "No route to host", which looks exactly \
|
||||
like a guest that is off the network — so this is worth checking before \
|
||||
debugging anything else.
|
||||
|
||||
`permissions grant` offers two routes. The default subnet allowlist is \
|
||||
deterministic and applies to every process, but is read at boot, so it \
|
||||
needs a reboot. `--method prompt` provokes the real system prompt and \
|
||||
applies immediately, but needs a GUI session and an installed app bundle.
|
||||
""",
|
||||
subcommands: [Status.self, Grant.self, Probe.self]
|
||||
)
|
||||
|
||||
/// `permissions status` — what is configured, and what to do about it.
|
||||
struct Status: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "status",
|
||||
abstract: "Report whether local network access is configured."
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
func run() async throws {
|
||||
// The same two checks `doctor` runs, rendered the same way. Code
|
||||
// identity belongs here because it decides whether an interactive
|
||||
// grant survives the next build — an ad-hoc signature makes
|
||||
// --method prompt a waste of the operator's time.
|
||||
print(Doctor.format([
|
||||
Doctor.localNetworkNote(),
|
||||
Doctor.checkCodeSignature(),
|
||||
]))
|
||||
|
||||
let status = LocalNetworkPolicy.status()
|
||||
if !status.sourcePaths.isEmpty {
|
||||
print("")
|
||||
for path in status.sourcePaths {
|
||||
print("allowlist read from: \(path)")
|
||||
}
|
||||
}
|
||||
|
||||
guard !status.coversGuestRange else { return }
|
||||
print("")
|
||||
print("to fix:")
|
||||
print(" gitea-macos-runner permissions grant # subnet allowlist, needs a reboot")
|
||||
print(" gitea-macos-runner permissions grant --method prompt # system prompt, takes effect at once")
|
||||
}
|
||||
}
|
||||
|
||||
/// `permissions grant` — actually configure it.
|
||||
struct Grant: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "grant",
|
||||
abstract: "Grant local network access to the guest subnets.",
|
||||
discussion: """
|
||||
The default `allowlist` method writes com.apple.network.local-network with \
|
||||
sudo, so it will ask for your password, and the values are only read at \
|
||||
boot — nothing changes until you reboot.
|
||||
|
||||
`--method prompt` instead launches the installed app bundle through \
|
||||
LaunchServices so it becomes its own responsible process, and provokes the \
|
||||
system prompt as *this app* rather than as Terminal. That distinction is \
|
||||
the whole point: a grant given to Terminal does not carry over to the \
|
||||
LaunchAgent. It takes effect immediately, but needs `make install` to have \
|
||||
run and a GUI session to show the alert in.
|
||||
"""
|
||||
)
|
||||
|
||||
@OptionGroup var options: GlobalOptions
|
||||
|
||||
@Option(name: .long, help: "How to grant it: allowlist (default) or prompt.")
|
||||
var method: LocalNetworkPermission.Method = .allowlist
|
||||
|
||||
@Option(
|
||||
name: .long,
|
||||
parsing: .singleValue,
|
||||
help: ArgumentHelp(
|
||||
"Subnet to authorize, repeatable. Defaults to all of RFC 1918.",
|
||||
valueName: "cidr"
|
||||
))
|
||||
var subnet: [String] = []
|
||||
|
||||
@Flag(
|
||||
inversion: .prefixedNo,
|
||||
help: "Reboot when the allowlist is written. Default: ask, when on a terminal.")
|
||||
var reboot: Bool?
|
||||
|
||||
func run() async throws {
|
||||
try LocalNetworkGrantFlow.run(method: method, subnets: subnet, reboot: reboot)
|
||||
}
|
||||
}
|
||||
|
||||
/// `permissions probe` — the child half of `grant --method prompt`.
|
||||
///
|
||||
/// Hidden because it is not something to run directly: invoked from a shell
|
||||
/// it is attributed to Terminal, which is precisely the attribution the
|
||||
/// prompt method exists to avoid. It is only meaningful when LaunchServices
|
||||
/// started it.
|
||||
struct Probe: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "probe",
|
||||
abstract: "Internal: touch the local network and report whether it was blocked.",
|
||||
shouldDisplay: false
|
||||
)
|
||||
|
||||
@Option(name: .long, help: "Where to write the JSON result.")
|
||||
var report: String?
|
||||
|
||||
@Option(name: .long, help: "Seconds to wait for a verdict.")
|
||||
var timeout: Int = 90
|
||||
|
||||
func run() async throws {
|
||||
let result = await LocalNetworkPermission.probe(timeout: TimeInterval(timeout))
|
||||
|
||||
guard let report else {
|
||||
print("\(result.outcome.rawValue): \(result.detail)")
|
||||
return
|
||||
}
|
||||
try JSONEncoder().encode(result).write(to: URL(fileURLWithPath: report))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LocalNetworkPermission.Method: ExpressibleByArgument {}
|
||||
|
||||
/// The operator-facing grant flow, shared by `permissions grant` and the
|
||||
/// `service install` hook.
|
||||
///
|
||||
/// It lives outside both so `service install` does not have to construct
|
||||
/// another command's `ParsableCommand` and mutate its parsed properties, which
|
||||
/// works only by accident of how ArgumentParser synthesizes initializers.
|
||||
enum LocalNetworkGrantFlow {
|
||||
|
||||
/// Runs one grant, end to end, printing what happened.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - method: Allowlist or system prompt.
|
||||
/// - subnets: Empty means the RFC 1918 default. Allowlist only.
|
||||
/// - reboot: `nil` asks, when there is a terminal to ask on.
|
||||
static func run(
|
||||
method: LocalNetworkPermission.Method,
|
||||
subnets: [String] = [],
|
||||
reboot: Bool? = nil
|
||||
) throws {
|
||||
switch method {
|
||||
case .allowlist: try grantAllowlist(subnets: subnets, reboot: reboot)
|
||||
case .prompt: try grantByPrompt(subnets: subnets)
|
||||
}
|
||||
}
|
||||
|
||||
private static func grantAllowlist(subnets requested: [String], reboot: Bool?) throws {
|
||||
let subnets = requested.isEmpty ? LocalNetworkPolicy.defaultSubnets : requested
|
||||
let interactive = isatty(fileno(stdin)) == 1
|
||||
|
||||
CLI.note("authorizing \(subnets.joined(separator: ", ")) for local network access")
|
||||
if interactive {
|
||||
CLI.note("this needs administrator rights; sudo may ask for your password")
|
||||
}
|
||||
|
||||
let result: LocalNetworkPermission.AllowlistResult
|
||||
do {
|
||||
result = try LocalNetworkPermission.grantViaAllowlist(
|
||||
subnets: subnets, allowPasswordPrompt: interactive)
|
||||
} catch let error as LocalNetworkPermission.PermissionError {
|
||||
CLI.error("\(error)")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
|
||||
// `defaults` reports success regardless of which preferences directory
|
||||
// the write landed in, so report what was read back rather than what
|
||||
// was asked for. See LocalNetworkPermission.grantViaAllowlist.
|
||||
guard result.verified else {
|
||||
CLI.error("""
|
||||
the write reported success but the values could not be read back. \
|
||||
Check by hand: sudo defaults read \(LocalNetworkPolicy.domain)
|
||||
""")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
|
||||
print("granted: \(result.observed.allowlist.joined(separator: ", "))")
|
||||
for path in result.observed.sourcePaths {
|
||||
print("written to: \(path)")
|
||||
}
|
||||
if !result.observed.coversGuestRange {
|
||||
CLI.note("""
|
||||
warning: none of these cover the whole guest range (192.168.64.0/18), \
|
||||
so guests will still be blocked once the NAT subnet shifts
|
||||
""")
|
||||
}
|
||||
|
||||
print("")
|
||||
print("This is read at boot, so it does nothing until the host reboots.")
|
||||
|
||||
guard reboot ?? CLI.confirm("reboot now?") else {
|
||||
CLI.note("not rebooting; run `sudo shutdown -r now` when convenient")
|
||||
return
|
||||
}
|
||||
try LocalNetworkPermission.reboot()
|
||||
}
|
||||
|
||||
private static func grantByPrompt(subnets: [String]) throws {
|
||||
guard subnets.isEmpty else {
|
||||
CLI.error("--subnet applies to --method allowlist only; the system prompt is not per-subnet")
|
||||
throw ExitCode(2)
|
||||
}
|
||||
|
||||
CLI.note("launching the app bundle so the prompt is attributed to it, not to Terminal")
|
||||
CLI.note("answer \"Allow\" in the alert that appears")
|
||||
|
||||
let report: LocalNetworkPermission.ProbeReport
|
||||
do {
|
||||
report = try LocalNetworkPermission.triggerPrompt()
|
||||
} catch let error as LocalNetworkPermission.PermissionError {
|
||||
CLI.error("\(error)")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
|
||||
switch report.outcome {
|
||||
case .permitted:
|
||||
print("local network access is not blocked (\(report.detail))")
|
||||
print("")
|
||||
print("""
|
||||
This applies immediately — no reboot. It is tied to the app's code \
|
||||
identity, so it survives rebuilds only while the bundle keeps a stable \
|
||||
Developer ID signature; `permissions status` reports that.
|
||||
""")
|
||||
case .blocked:
|
||||
CLI.error("still blocked after the prompt (\(report.detail))")
|
||||
CLI.note("""
|
||||
Either the alert was declined, or macOS already has a decision on file for \
|
||||
this app — it does not ask twice, and there is no way to reset one. Look in \
|
||||
System Settings > Privacy & Security > Local Network: if there is a row for \
|
||||
Gitea macOS Runner, switch it on.
|
||||
""")
|
||||
CLI.note("""
|
||||
Otherwise use the allowlist, which needs no prompt at all: \
|
||||
gitea-macos-runner permissions grant
|
||||
""")
|
||||
throw ExitCode(1)
|
||||
case .inconclusive:
|
||||
CLI.error("could not tell: \(report.detail)")
|
||||
throw ExitCode(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,16 @@ struct ServiceCommand: AsyncParsableCommand {
|
||||
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
|
||||
var executable: String?
|
||||
|
||||
/// How to configure Local Network access, if it is not already.
|
||||
///
|
||||
/// Unset means "decide at run time": ask on a terminal, skip with a
|
||||
/// pointer otherwise. `none` suppresses the question outright, for a
|
||||
/// scripted install that has its own arrangements.
|
||||
@Option(
|
||||
name: .customLong("grant-local-network"),
|
||||
help: "Configure macOS Local Network access during install: allowlist, prompt, or none.")
|
||||
var grantLocalNetwork: LocalNetworkGrantChoice?
|
||||
|
||||
func run() async throws {
|
||||
let executablePath = executable ?? LaunchdService.defaultExecutablePath
|
||||
|
||||
@@ -59,8 +69,81 @@ struct ServiceCommand: AsyncParsableCommand {
|
||||
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
|
||||
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
||||
print("")
|
||||
|
||||
offerLocalNetworkGrant()
|
||||
|
||||
print("check it with: gitea-macos-runner service status")
|
||||
}
|
||||
|
||||
/// Offers to configure Local Network access, if it is not already.
|
||||
///
|
||||
/// This is where the question belongs. The agent that was just
|
||||
/// installed is the process that will be blocked, it has no UI to ask
|
||||
/// with, and the symptom when it is blocked — every guest boots, no job
|
||||
/// starts, `No route to host` — points nowhere near the cause. Asking
|
||||
/// now costs one prompt; not asking costs a debugging session.
|
||||
///
|
||||
/// Never fatal: a failed or declined grant leaves a perfectly good
|
||||
/// installed agent, so this reports and returns rather than throwing.
|
||||
private func offerLocalNetworkGrant() {
|
||||
guard grantLocalNetwork != .skip else { return }
|
||||
guard !LocalNetworkPolicy.status().coversGuestRange else { return }
|
||||
|
||||
let method: LocalNetworkPermission.Method
|
||||
switch grantLocalNetwork {
|
||||
case .allowlist: method = .allowlist
|
||||
case .prompt: method = .prompt
|
||||
case .skip: return // handled above; here for exhaustiveness
|
||||
case nil:
|
||||
// Not asked for either way: decide from the terminal. A piped
|
||||
// or launchd-driven install must not stop on a question, so it
|
||||
// gets the pointer and carries on.
|
||||
guard isatty(fileno(stdin)) == 1 else {
|
||||
CLI.note("""
|
||||
note: macOS Local Network access is not configured. Until it is, guests \
|
||||
boot but SSH fails with "No route to host". Configure it with \
|
||||
`gitea-macos-runner permissions grant`.
|
||||
""")
|
||||
print("")
|
||||
return
|
||||
}
|
||||
CLI.note("""
|
||||
macOS Local Network access is not configured. Without it the agent starts \
|
||||
guests fine but cannot reach them, and every job fails with "No route to \
|
||||
host". Granting it writes a subnet allowlist with sudo and needs a reboot.
|
||||
""")
|
||||
guard CLI.confirm("configure it now?") else {
|
||||
CLI.note("skipped; run `gitea-macos-runner permissions grant` later")
|
||||
print("")
|
||||
return
|
||||
}
|
||||
method = .allowlist
|
||||
}
|
||||
|
||||
// Deliberately swallowed. The agent is installed and correct at
|
||||
// this point; a declined sudo password should not turn a successful
|
||||
// install into a failure.
|
||||
do {
|
||||
try LocalNetworkGrantFlow.run(method: method)
|
||||
} catch {
|
||||
CLI.note("could not configure it: \(error)")
|
||||
CLI.note("the agent is installed; run `gitea-macos-runner permissions grant` to retry")
|
||||
}
|
||||
print("")
|
||||
}
|
||||
}
|
||||
|
||||
/// `--grant-local-network`'s values: the two grant methods plus an explicit
|
||||
/// opt-out, which the method enum itself has no business carrying.
|
||||
///
|
||||
/// The opt-out case is spelled `skip` rather than `none` so that
|
||||
/// `choice == .skip` cannot be read as `Optional.none` — the option is
|
||||
/// itself optional, and "not passed" means something different from
|
||||
/// "passed `none`".
|
||||
enum LocalNetworkGrantChoice: String, ExpressibleByArgument, CaseIterable {
|
||||
case allowlist
|
||||
case prompt
|
||||
case skip = "none"
|
||||
}
|
||||
|
||||
/// `service uninstall` — unload and remove the plist.
|
||||
|
||||
@@ -45,6 +45,7 @@ struct GiteaMacOSRunner: AsyncParsableCommand {
|
||||
ServiceCommand.self,
|
||||
DoctorCommand.self,
|
||||
ConfigCommand.self,
|
||||
PermissionsCommand.self,
|
||||
],
|
||||
defaultSubcommand: nil
|
||||
)
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import Foundation
|
||||
import Testing
|
||||
|
||||
@testable import RunnerCore
|
||||
|
||||
/// Tests for ``LocalNetworkPolicy`` — the arithmetic behind the Local Network
|
||||
/// subnet allowlist.
|
||||
///
|
||||
/// The bug these exist for: an allowlist entry that covers *today's* guest
|
||||
/// subnet but not tomorrow's. vmnet picks its NAT subnet at runtime and steps
|
||||
/// to the next free /24 when one is taken, so `192.168.64.0/24` works right up
|
||||
/// until the day a second VM host appears on the machine — and then every guest
|
||||
/// connection fails with "No route to host" with the allowlist still looking
|
||||
/// perfectly configured.
|
||||
@Suite("LocalNetworkPolicy")
|
||||
struct LocalNetworkPolicyTests {
|
||||
|
||||
// MARK: - Coverage
|
||||
|
||||
@Test("an entry must cover the whole vmnet span, not just its first /24")
|
||||
func coverageIsAllOrNothing() {
|
||||
// The exact span, and anything wider.
|
||||
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.64.0/18"))
|
||||
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.0.0/16"))
|
||||
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.0.0/8"))
|
||||
#expect(LocalNetworkPolicy.coversVMNetRange("0.0.0.0/0"))
|
||||
|
||||
// The trap: contains 192.168.64.x, but not 192.168.65.x.
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.0/24"))
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.0/19"))
|
||||
|
||||
// Adjacent but disjoint.
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.128.0/18"))
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange("10.0.0.0/8"))
|
||||
}
|
||||
|
||||
@Test("the RFC 1918 default covers the guest range")
|
||||
func defaultSubnetsCoverGuests() {
|
||||
#expect(LocalNetworkPolicy.defaultSubnets.contains(where: LocalNetworkPolicy.coversVMNetRange))
|
||||
}
|
||||
|
||||
@Test("a prefix is required for coverage; a bare address is a /32")
|
||||
func bareAddressIsASingleHost() {
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.1"))
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange("192.168.64.1/32"))
|
||||
}
|
||||
|
||||
@Test("host bits below the prefix do not change the block")
|
||||
func hostBitsAreMaskedOff() {
|
||||
// 192.168.70.5/18 and 192.168.64.0/18 are the same block.
|
||||
#expect(LocalNetworkPolicy.coversVMNetRange("192.168.70.5/18"))
|
||||
}
|
||||
|
||||
// MARK: - Rejecting what macOS would silently ignore
|
||||
|
||||
@Test("malformed, IPv6 and hostname entries are rejected, not crashed on")
|
||||
func garbageIsRejected() {
|
||||
for entry in [
|
||||
"", "/", "/24", "192.168.64.0/", "192.168.64.0/33", "192.168.64.0/-1",
|
||||
"192.168.64", "192.168.64.0.1", "192.168.256.0/18", "192.168.64.x/18",
|
||||
"fd00::/8", "::/0", "localhost", "example.com/24", "192.168.64.0/18/24",
|
||||
] {
|
||||
#expect(!LocalNetworkPolicy.isValidSubnet(entry), "\(entry) should be rejected")
|
||||
#expect(!LocalNetworkPolicy.coversVMNetRange(entry), "\(entry) should not cover")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("well-formed entries validate")
|
||||
func goodEntriesValidate() {
|
||||
for entry in ["0.0.0.0/0", "10.0.0.0/8", "192.168.64.0/24", "192.168.64.1", "255.255.255.255/32"] {
|
||||
#expect(LocalNetworkPolicy.isValidSubnet(entry), "\(entry) should validate")
|
||||
}
|
||||
}
|
||||
|
||||
@Test("ipv4Value packs octets most-significant first")
|
||||
func addressPacking() {
|
||||
#expect(LocalNetworkPolicy.ipv4Value("0.0.0.0") == 0)
|
||||
#expect(LocalNetworkPolicy.ipv4Value("192.168.64.0") == 0xC0A8_4000)
|
||||
#expect(LocalNetworkPolicy.ipv4Value("192.168.127.255") == 0xC0A8_7FFF)
|
||||
#expect(LocalNetworkPolicy.ipv4Value("255.255.255.255") == 0xFFFF_FFFF)
|
||||
#expect(LocalNetworkPolicy.ipv4Value("192.168.64") == nil)
|
||||
#expect(LocalNetworkPolicy.ipv4Value("192.168.64.256") == nil)
|
||||
}
|
||||
|
||||
// MARK: - What gets written
|
||||
|
||||
@Test("both interface keys are written, each with the subnets as separate arguments")
|
||||
func writeArgumentsCoverBothKeys() {
|
||||
let subnets = ["10.0.0.0/8", "192.168.0.0/16"]
|
||||
let commands = LocalNetworkPolicy.writeArguments(subnets: subnets)
|
||||
|
||||
#expect(commands.count == 2)
|
||||
#expect(commands[0] == ["write", LocalNetworkPolicy.domain, LocalNetworkPolicy.ethernetKey,
|
||||
"-array", "10.0.0.0/8", "192.168.0.0/16"])
|
||||
#expect(commands[1] == ["write", LocalNetworkPolicy.domain, LocalNetworkPolicy.wifiKey,
|
||||
"-array", "10.0.0.0/8", "192.168.0.0/16"])
|
||||
|
||||
// Each subnet is its own argv element. Joined into one string, macOS
|
||||
// would read the whole thing as a single unparseable entry and grant
|
||||
// nothing — while `defaults read` still showed something plausible.
|
||||
for command in commands {
|
||||
#expect(!command.contains { $0.contains(" ") })
|
||||
}
|
||||
}
|
||||
|
||||
@Test("the shell rendering quotes anything a shell would reinterpret")
|
||||
func shellRenderingIsSafe() {
|
||||
let lines = LocalNetworkPolicy.writeCommandLines(subnets: ["10.0.0.0/8", "a b; rm -rf /"])
|
||||
#expect(lines.count == 2)
|
||||
for line in lines {
|
||||
#expect(line.hasPrefix("sudo defaults write \(LocalNetworkPolicy.domain) "))
|
||||
// Plain CIDR stays readable; the hostile entry gets quoted.
|
||||
#expect(line.contains(" 10.0.0.0/8 "))
|
||||
#expect(line.contains("'a b; rm -rf /'"))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Reading the host back
|
||||
|
||||
@Test("status reads the live host without throwing and stays self-consistent")
|
||||
func statusIsSelfConsistent() {
|
||||
// Cannot assert the host's actual configuration — this suite runs on
|
||||
// developer machines and in CI guests alike. What must hold either way
|
||||
// is that the derived flags agree with the entries.
|
||||
let status = LocalNetworkPolicy.status()
|
||||
#expect(status.isConfigured == !status.allowlist.isEmpty)
|
||||
#expect(status.coversGuestRange == status.allowlist.contains(where: LocalNetworkPolicy.coversVMNetRange))
|
||||
if status.allowlist.isEmpty { #expect(status.sourcePaths.isEmpty) }
|
||||
#expect(Set(status.allowlist).count == status.allowlist.count, "entries should be deduplicated")
|
||||
}
|
||||
|
||||
@Test("all three candidate preference paths are checked")
|
||||
func candidatePathsCoverBothSudoOutcomes() {
|
||||
let candidates = LocalNetworkPolicy.preferenceCandidates()
|
||||
// `sudo defaults write` lands in root's preferences or the invoking
|
||||
// user's depending on whether sudo preserved HOME, so both must be
|
||||
// checked — plus the system-wide location.
|
||||
#expect(candidates.contains("/var/root/Library/Preferences/\(LocalNetworkPolicy.domain).plist"))
|
||||
#expect(candidates.contains("/Library/Preferences/\(LocalNetworkPolicy.domain).plist"))
|
||||
#expect(candidates.contains(NSHomeDirectory() + "/Library/Preferences/\(LocalNetworkPolicy.domain).plist"))
|
||||
}
|
||||
}
|
||||
+27
-5
@@ -612,7 +612,7 @@ adopting it would require per-slot saved states and a careful look at DHCP lease
|
||||
reuse — not a drop-in optimization.
|
||||
|
||||
**16. macOS 15+ Local Network privacy can block host→guest connections, and
|
||||
there is no reliable way to grant it interactively here.** Per
|
||||
granting it interactively takes deliberate work.** Per
|
||||
[TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
||||
it is not TCC — the check is a Network Extension packet filter, so it is absent
|
||||
from `TCC.db`, cannot be queried, cannot be reset, and it "uses your main
|
||||
@@ -628,8 +628,30 @@ rebuild.
|
||||
(`com.apple.network.local-network`, keys `AllowedEthernetLocalNetworkAddresses`
|
||||
and `AllowedWiFiLocalNetworkAddresses`), which is keyed on the network rather
|
||||
than the app and is read at boot — so it needs a reboot, not a service restart.
|
||||
`Doctor.checkLocalNetwork` reads those keys and requires coverage of
|
||||
`LocalNetworkPolicy` owns the arithmetic and requires coverage of
|
||||
`192.168.64.0/18`, not a single /24, because the NAT subnet is chosen at runtime
|
||||
and slides to the next free /24. `SSHExec.localNetworkHint` appends the same
|
||||
guidance to connection failures, since errno 65 gives the operator nothing to go
|
||||
on by itself.
|
||||
and slides to the next free /24; `Doctor.localNetworkNote` and
|
||||
`SSHExec.localNetworkHint` both report against it, since errno 65 gives the
|
||||
operator nothing to go on by itself.
|
||||
|
||||
→ *Consequence:* both routes are commands rather than documentation.
|
||||
`permissions grant` writes the allowlist and verifies it read back (`sudo
|
||||
defaults write` lands in root's or the invoking user's preferences depending on
|
||||
whether sudo preserved `HOME`, so where it went is not assumable).
|
||||
`permissions grant --method prompt` addresses the attribution problem head-on:
|
||||
launching the installed bundle through LaunchServices (`open -n -b …`) makes the
|
||||
app its **own** responsible process, so the prompt and the Settings row belong to
|
||||
it rather than to Terminal — and because the LaunchAgent runs the same signed
|
||||
identity, the grant carries. That only became worth building once the bundle was
|
||||
Developer ID signed; under ad-hoc signing the UUID churn withdraws it on the next
|
||||
rebuild, which is why `permissions status` reports code identity alongside the
|
||||
allowlist.
|
||||
|
||||
→ *Consequence:* the prompt route is best-effort and says so. Observed on a host
|
||||
where the decision was already recorded: `UserEventAgent` resolves the flow to
|
||||
the bundle ID on every attempt — so the attribution works — but presents no
|
||||
alert, because macOS asks once per app identity and then answers from that
|
||||
record, silently, forever. There is no supported reset. So `--method prompt`
|
||||
verifies by *probing* rather than by trusting the launch, and on a denial says
|
||||
plainly that it did not take and points at the allowlist, which is not subject
|
||||
to the per-app check at all. The allowlist stays the recommendation.
|
||||
|
||||
+48
-29
@@ -441,6 +441,11 @@ disabling sleep (`sudo pmset -a sleep 0 disablesleep 1`) so the Mac comes back i
|
||||
after a power event without a human present. This does mean the disk is effectively unlocked at
|
||||
boot — appropriate for a dedicated CI machine, not for a shared workstation.
|
||||
|
||||
On a terminal, `service install` also asks whether to configure Local Network access (§2.6) when it
|
||||
is not already, defaulting to no. It never blocks: a scripted install with no terminal prints a
|
||||
pointer and carries on. `--grant-local-network allowlist|prompt|none` decides it up front instead of
|
||||
being asked.
|
||||
|
||||
`service uninstall` removes the LaunchAgent; it does not delete images or config.
|
||||
|
||||
### 2.6 macOS 15+ Local Network privacy prompt
|
||||
@@ -449,32 +454,41 @@ 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.
|
||||
|
||||
**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:
|
||||
`service install` offers to configure this, and it can also be done at any time:
|
||||
|
||||
```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"
|
||||
gitea-macos-runner permissions status # what is configured, and what to do about it
|
||||
gitea-macos-runner permissions grant # configure it
|
||||
```
|
||||
|
||||
Then **reboot** — these are read at boot, so restarting the service alone is not enough.
|
||||
`grant` has two methods. Both are one command; neither needs anything pasted.
|
||||
|
||||
**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.
|
||||
**`--method allowlist` (the default) is what a CI host wants.** It writes a subnet allowlist — the
|
||||
one deterministic option: no prompt, no GUI session, and nothing to redo after a rebuild. It asks
|
||||
for your sudo password, reports which preferences file the write actually landed in, and then offers
|
||||
to **reboot**, which is required: these values are read at boot, so restarting the service alone is
|
||||
not enough. Pass `--no-reboot` to defer that.
|
||||
|
||||
**Why not just approve the prompt?** Because on this host there is usually nothing able to show it,
|
||||
and when something does, it is attributed to the wrong program.
|
||||
The default grant is all of RFC 1918 — `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16` — the same
|
||||
set [Tart](https://tart.run/faq/) and orchard use. Narrow it with `--subnet`, repeatable, but **do
|
||||
not pin it 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 — and the failure surfaces as `No route to host`
|
||||
(errno 65) on the SSH connection, not as a permission error. `192.168.64.0/18` spans
|
||||
`192.168.64.0`–`192.168.127.255`, which is the narrowest entry that covers the drift. `doctor`
|
||||
reports `local network access` as a **pass** once it sees an allowlist covering 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).
|
||||
|
||||
**`--method prompt` takes effect immediately, with no reboot**, and is the better choice on a Mac
|
||||
you are sitting in front of. It launches the installed `.app` through LaunchServices — which is what
|
||||
makes the app its own responsible process — provokes the real system alert, and reports whether the
|
||||
grant took. It needs `make install` to have run, a GUI session to show the alert in, and a Developer
|
||||
ID signature for the grant to survive the next rebuild; `permissions status` reports that last one.
|
||||
|
||||
**Why the prompt needs that much machinery.** Left to itself, on this host there is usually nothing
|
||||
able to show it, and when something does, it is attributed to the wrong program.
|
||||
|
||||
An app appears under **System Settings → Privacy & Security → Local Network** only *after* it has
|
||||
attempted a connection to a guest, so an empty list on a fresh install is expected and means
|
||||
@@ -490,14 +504,19 @@ miss:
|
||||
approving it there does not carry over to the LaunchAgent. (If you are hunting for a row that
|
||||
seems missing, look for Terminal rather than for "Gitea macOS Runner".)
|
||||
|
||||
> **And under ad-hoc signing it is not durable anyway.** 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 presents as a
|
||||
> new app that must be approved again — and macOS offers no way to reset a Local Network decision
|
||||
> back to undetermined, so stale entries accumulate. Signing with a Developer ID certificate fixes
|
||||
> the churn (see [Code signing](#code-signing)); the subnet allowlist above, keyed on the network
|
||||
> rather than on the app, sidesteps the whole mechanism and is the recommendation for an unattended
|
||||
> machine either way.
|
||||
`permissions grant --method prompt` exists to thread that needle: it starts the app through
|
||||
LaunchServices rather than from the shell, so the app is its own responsible process and the
|
||||
decision is recorded against *its* identity — the same identity the LaunchAgent runs under.
|
||||
|
||||
> **Under an ad-hoc signature it is not durable anyway.** 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 presents as
|
||||
> a new app that must be approved again — and macOS offers no way to reset a Local Network decision
|
||||
> back to undetermined, so stale entries accumulate. A Developer ID signature fixes the churn, since
|
||||
> the identity is then anchored to the certificate rather than to the binary (see
|
||||
> [Code signing](#code-signing)) — that is what makes `--method prompt` worth using at all. The
|
||||
> subnet allowlist, keyed on the network rather than on the app, sidesteps the whole mechanism and
|
||||
> remains the recommendation for an unattended machine.
|
||||
|
||||
---
|
||||
|
||||
@@ -531,7 +550,7 @@ The checks, in order:
|
||||
| `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 |
|
||||
| `guest ssh` | Reachability of the most recent guest lease, when there is one. Warns on a timeout, which is most often a stale 24-hour lease for a guest that is already gone |
|
||||
| `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) |
|
||||
| `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. Fix either with `permissions grant` (§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
|
||||
|
||||
+59
-46
@@ -23,12 +23,12 @@ 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, and a run started from a shell is attributed to Terminal, not to the app | Allowlist the subnet with `defaults write com.apple.network.local-network` and reboot; the interactive grant does not reach the LaunchAgent |
|
||||
| VM boots but never gets an IP | DHCP lease not yet written, or Local Network privacy denial (macOS 15+) | Check `/var/db/dhcpd_leases`; `gitea-macos-runner permissions grant` |
|
||||
| Runner not listed under Privacy & Security → Local Network | Expected — the list is populated only after the app first attempts a local connection, and a run started from a shell is attributed to Terminal, not to the app | `gitea-macos-runner permissions grant` (allowlist, then reboot), or `--method prompt` to raise the alert as the app rather than as Terminal |
|
||||
| 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 |
|
||||
| VMs boot in a loop; every teardown says `reason=cancelled` and nothing is logged between the lease and the teardown | `scheduler.bootTimeoutSeconds` is below the guest's *worst-case* boot on a contended host, so each clone is killed while still starting — and each replacement makes the next one slower | Raise `scheduler.bootTimeoutSeconds` (default 900) and reduce the number of concurrent guests; see [The daemon boots VMs forever](#the-daemon-boots-vms-forever-and-every-teardown-says-reasoncancelled) |
|
||||
| `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 |
|
||||
| `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 | `gitea-macos-runner permissions grant`, then **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` | `gitea-macos-runner permissions grant` (defaults to all of RFC 1918) and reboot; `doctor` 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,26 +105,24 @@ 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: pre-authorize the VM subnet, then **reboot** (these are read at boot):
|
||||
2. No entry at all: check and fix the permission.
|
||||
|
||||
```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"
|
||||
gitea-macos-runner permissions status
|
||||
gitea-macos-runner permissions grant # then reboot when it offers
|
||||
```
|
||||
|
||||
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.
|
||||
`grant` pre-authorizes the VM subnets and offers to reboot, which is required — the allowlist is
|
||||
read at boot. It grants all of RFC 1918 by default; `--subnet` narrows it, but nothing narrower
|
||||
than `192.168.64.0/18` is safe, because 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.
|
||||
|
||||
Do not reach for the interactive grant instead. Booting a VM by hand from a Terminal makes the
|
||||
prompt (and the **System Settings → Privacy & Security → Local Network** row) belong to
|
||||
*Terminal* rather than to this app, and approving it there does not carry over to the
|
||||
LaunchAgent.
|
||||
This is the deterministic fix for an unattended host. On a Mac with someone in front of it,
|
||||
`permissions grant --method prompt` applies immediately with no reboot —
|
||||
see [Local Network: the app is not listed in System Settings](#local-network-the-app-is-not-listed-in-system-settings)
|
||||
for what it does and why granting the prompt by hand does not work.
|
||||
|
||||
---
|
||||
|
||||
@@ -156,23 +154,36 @@ gitea-macos-runner vm boot --image default
|
||||
prompt and the Settings row belong to Terminal, and allowing it there does **not** carry over to the
|
||||
LaunchAgent, which is the process that actually needs it. Meanwhile the LaunchAgent itself has no UI
|
||||
to show a prompt in, so from it the connection is denied outright and surfaces as `No route to host`
|
||||
(errno 65) rather than as a permission error. Between the two, there is no reliable way to grant
|
||||
this interactively on an unattended host.
|
||||
(errno 65) rather than as a permission error.
|
||||
|
||||
**Fix — deterministic, and what to use on any host running the LaunchAgent.** 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:
|
||||
subnets instead. The allowlist 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/18"
|
||||
sudo defaults write com.apple.network.local-network \
|
||||
AllowedWiFiLocalNetworkAddresses -array "192.168.64.0/18"
|
||||
gitea-macos-runner permissions grant
|
||||
```
|
||||
|
||||
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.
|
||||
That asks for your sudo password, writes both of Apple's keys (documented in TN3179 — the same pair
|
||||
[Tart's FAQ](https://tart.run/faq/) recommends for this exact problem on CI hosts), reports which
|
||||
preferences file the write landed in, and offers to reboot. Reboot is required: the values are read
|
||||
at boot. `doctor` then reports `local network access` as a **pass**.
|
||||
|
||||
**On a Mac you are sitting at,** `permissions grant --method prompt` is the alternative, and it
|
||||
needs no reboot. It launches the installed `.app` through LaunchServices instead of exec'ing it from
|
||||
the shell, which is exactly what makes the app its own responsible process — so the alert, and the
|
||||
Settings row it creates, belong to the app rather than to Terminal, and the decision applies to the
|
||||
LaunchAgent. It requires `make install` to have run, a GUI session, and a Developer ID signature to
|
||||
be durable (see below); `permissions status` reports all three.
|
||||
|
||||
**If `--method prompt` reports "still blocked" and you never saw an alert,** macOS most likely
|
||||
already has a decision on file for the app. It prompts exactly once per app identity and then
|
||||
answers from that record forever — silently, with `EHOSTUNREACH`, and with no supported way to reset
|
||||
it back to undetermined. The app *is* being evaluated under its own identity at that point (you can
|
||||
confirm with `log show --last 2m --predicate 'subsystem == "com.apple.networkextension"'`, which
|
||||
names the bundle ID on every attempt); the system simply is not asking. Switch the row on in
|
||||
**System Settings → Privacy & Security → Local Network**, or use the allowlist, which bypasses the
|
||||
per-app check entirely.
|
||||
|
||||
> **And an ad-hoc signed bundle cannot hold the grant anyway.** TN3179 notes that "local network
|
||||
> privacy uses your main executable UUID as part of its implementation". An ad-hoc signature has no
|
||||
@@ -636,27 +647,29 @@ Two details make it look intermittent rather than like a permission problem:
|
||||
responsible process, so the app's own identity is not what is being evaluated when you launch it
|
||||
by hand — and a grant given to Terminal does nothing for the LaunchAgent.
|
||||
|
||||
**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:
|
||||
**Fix.** Allowlist the subnets — the allowlist 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
|
||||
gitea-macos-runner permissions grant
|
||||
```
|
||||
|
||||
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.
|
||||
It writes both of Apple's keys with sudo, verifies the values read back, and offers to 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`.
|
||||
The default grant is all of RFC 1918. If you narrow it with `--subnet`, 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`, and
|
||||
`permissions grant` warns at the point you ask for something that narrow.
|
||||
|
||||
**If you are at the machine and would rather not reboot,** `permissions grant --method prompt`
|
||||
launches the installed app through LaunchServices so the system alert is attributed to the app
|
||||
rather than to Terminal, and takes effect immediately. It needs a GUI session and a Developer ID
|
||||
signature to stick across rebuilds.
|
||||
|
||||
**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
|
||||
|
||||
Reference in New Issue
Block a user