Merge nucleic/vivid-glass-urchin-xoym into main

This commit is contained in:
2026-08-07 16:38:56 -07:00
parent 902bea5091
commit 26739f9487
12 changed files with 1214 additions and 174 deletions
+218
View File
@@ -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
}
}
+11 -10
View File
@@ -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)
"""
}
+12 -83
View File
@@ -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.
+1
View File
@@ -45,6 +45,7 @@ struct GiteaMacOSRunner: AsyncParsableCommand {
ServiceCommand.self,
DoctorCommand.self,
ConfigCommand.self,
PermissionsCommand.self,
],
defaultSubcommand: nil
)