From 26739f94879f89c7b20fb6ab4969f922a6cc6097 Mon Sep 17 00:00:00 2001 From: Andrew Moore Date: Fri, 7 Aug 2026 16:38:56 -0700 Subject: [PATCH] Merge nucleic/vivid-glass-urchin-xoym into main --- README.md | 6 +- Sources/RunnerCore/LocalNetworkPolicy.swift | 218 +++++++++++ Sources/RunnerCore/SSHExec.swift | 21 +- Sources/RunnerHost/Doctor.swift | 95 +---- .../RunnerHost/LocalNetworkPermission.swift | 347 ++++++++++++++++++ .../CommandPermissions.swift | 261 +++++++++++++ .../gitea-macos-runner/CommandService.swift | 83 +++++ Sources/gitea-macos-runner/Main.swift | 1 + .../LocalNetworkPolicyTests.swift | 142 +++++++ docs/DESIGN.md | 32 +- docs/setup.md | 77 ++-- docs/troubleshooting.md | 105 +++--- 12 files changed, 1214 insertions(+), 174 deletions(-) create mode 100644 Sources/RunnerCore/LocalNetworkPolicy.swift create mode 100644 Sources/RunnerHost/LocalNetworkPermission.swift create mode 100644 Sources/gitea-macos-runner/CommandPermissions.swift create mode 100644 Tests/RunnerCoreTests/LocalNetworkPolicyTests.swift diff --git a/README.md b/README.md index c5da65a..f9adaa0 100644 --- a/README.md +++ b/README.md @@ -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. | diff --git a/Sources/RunnerCore/LocalNetworkPolicy.swift b/Sources/RunnerCore/LocalNetworkPolicy.swift new file mode 100644 index 0000000..9ff3237 --- /dev/null +++ b/Sources/RunnerCore/LocalNetworkPolicy.swift @@ -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 + } +} diff --git a/Sources/RunnerCore/SSHExec.swift b/Sources/RunnerCore/SSHExec.swift index 88ea030..11bca2f 100644 --- a/Sources/RunnerCore/SSHExec.swift +++ b/Sources/RunnerCore/SSHExec.swift @@ -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) """ } diff --git a/Sources/RunnerHost/Doctor.swift b/Sources/RunnerHost/Doctor.swift index 36a306f..57c4727 100644 --- a/Sources/RunnerHost/Doctor.swift +++ b/Sources/RunnerHost/Doctor.swift @@ -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 diff --git a/Sources/RunnerHost/LocalNetworkPermission.swift b/Sources/RunnerHost/LocalNetworkPermission.swift new file mode 100644 index 0000000..55acf7a --- /dev/null +++ b/Sources/RunnerHost/LocalNetworkPermission.swift @@ -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 ` 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.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.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) + } +} diff --git a/Sources/gitea-macos-runner/CommandPermissions.swift b/Sources/gitea-macos-runner/CommandPermissions.swift new file mode 100644 index 0000000..374b67d --- /dev/null +++ b/Sources/gitea-macos-runner/CommandPermissions.swift @@ -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) + } + } +} diff --git a/Sources/gitea-macos-runner/CommandService.swift b/Sources/gitea-macos-runner/CommandService.swift index 453f599..f071783 100644 --- a/Sources/gitea-macos-runner/CommandService.swift +++ b/Sources/gitea-macos-runner/CommandService.swift @@ -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. diff --git a/Sources/gitea-macos-runner/Main.swift b/Sources/gitea-macos-runner/Main.swift index de5d3ea..425a6b9 100644 --- a/Sources/gitea-macos-runner/Main.swift +++ b/Sources/gitea-macos-runner/Main.swift @@ -45,6 +45,7 @@ struct GiteaMacOSRunner: AsyncParsableCommand { ServiceCommand.self, DoctorCommand.self, ConfigCommand.self, + PermissionsCommand.self, ], defaultSubcommand: nil ) diff --git a/Tests/RunnerCoreTests/LocalNetworkPolicyTests.swift b/Tests/RunnerCoreTests/LocalNetworkPolicyTests.swift new file mode 100644 index 0000000..43d4db9 --- /dev/null +++ b/Tests/RunnerCoreTests/LocalNetworkPolicyTests.swift @@ -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")) + } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md index bad0185..2f29e1c 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -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. diff --git a/docs/setup.md b/docs/setup.md index 20b4136..c8db2bf 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 6c6133b..53037c6 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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 ` | @@ -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