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) /// A child process could not be started or waited on. case spawnFailed(command: String, code: Int32) 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. """ case .spawnFailed(let command, let code): return "could not run \(command): \(String(cString: strerror(code))) (\(code))" } } } // 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 (index, arguments) in LocalNetworkPolicy.writeArguments(subnets: subnets).enumerated() { let sudoArguments = (allowPasswordPrompt ? [] : ["-n"]) + ["/usr/bin/defaults"] + arguments let exitCode = try runInForeground("/usr/bin/sudo", sudoArguments) guard exitCode == 0 else { if !allowPasswordPrompt { throw PermissionError.needsPassword(commands: commands) } throw PermissionError.writeFailed(command: commands[index], exitCode: exitCode) } } return AllowlistResult(requested: subnets, observed: observedStatus()) } /// The host's allowlist, read with root's privileges when this process's /// own are not enough. /// /// `sudo defaults write ` lands in `/var/root/Library/Preferences` /// on a stock host, and that directory is mode 700 — so the plain read in /// ``LocalNetworkPolicy/status()`` is refused and a write that worked /// perfectly looks like it vanished. Re-read the refused candidates as /// root instead. /// /// Always `sudo -n`, so this can never turn a status query into a password /// prompt. Right after a write the credentials are still cached and it /// simply works; later — after a reboot, say — it fails and the result /// stays ``LocalNetworkPolicy/Status/isIndeterminate``, which callers /// report as "cannot tell without root" rather than as "not configured". public static func observedStatus() -> LocalNetworkPolicy.Status { let unprivileged = LocalNetworkPolicy.status() guard !unprivileged.unreadablePaths.isEmpty else { return unprivileged } var sources: [(path: String, data: Data)] = [] for path in LocalNetworkPolicy.preferenceCandidates() { if let data = FileManager.default.contents(atPath: path) { sources.append((path, data)) } else if let data = readAsRoot(path) { sources.append((path, data)) } } let recovered = LocalNetworkPolicy.status(fromContentsOf: sources) // Nothing came back from the privileged read either: keep the // unprivileged answer, which still carries why it could not tell. guard recovered.isConfigured else { return unprivileged } return recovered } /// `sudo -n cat `, or nil if that fails for any reason. /// /// Both failure modes are ordinary rather than exceptional — the candidate /// usually does not exist, and `sudo -n` legitimately refuses when no /// credentials are cached — so stderr is discarded instead of being shown /// to the operator. `Process` is fine here, unlike in ``runInForeground``: /// `-n` never touches the terminal. private static func readAsRoot(_ path: String) -> Data? { let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/sudo") process.arguments = ["-n", "/bin/cat", path] let output = Pipe() process.standardOutput = output process.standardError = FileHandle.nullDevice process.standardInput = FileHandle.nullDevice guard (try? process.run()) != nil else { return nil } let data = output.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() guard process.terminationStatus == 0, !data.isEmpty else { return nil } return data } /// 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 { _ = try runInForeground("/usr/bin/sudo", ["/sbin/shutdown", "-r", "now"]) } /// Runs a command with this process's stdio *and its process group*, and /// returns its exit status. /// /// The process group is the whole reason this is not `Foundation.Process`. /// `Process` starts the child as its own process-group leader, so for the /// controlling terminal the child is a *background* job — and the terminal /// driver defends itself against those. `sudo`'s `tcsetattr` to turn echo /// off raises `SIGTTOU` and fails, so the password is typed in the clear; /// its read of the tty raises `SIGTTIN`, so Return never reaches `sudo` and /// the line editor just echoes a newline. Both symptoms, one cause. /// /// `posix_spawn` with no `POSIX_SPAWN_SETPGROUP` leaves the child in our /// process group, which is the terminal's foreground group, so `sudo` gets /// the terminal it expects. Stdio is inherited for the same reason it /// always was: the prompt and any "not in the sudoers file" complaint /// belong in front of the operator, not captured and paraphrased. private static func runInForeground(_ executable: String, _ arguments: [String]) throws -> Int32 { var argv: [UnsafeMutablePointer?] = ([executable] + arguments).map { strdup($0) } argv.append(nil) var envp: [UnsafeMutablePointer?] = ProcessInfo.processInfo.environment.map { strdup("\($0.key)=\($0.value)") } envp.append(nil) defer { for pointer in argv { free(pointer) } for pointer in envp { free(pointer) } } var pid: pid_t = 0 let spawned = posix_spawn(&pid, executable, nil, nil, argv, envp) guard spawned == 0 else { throw PermissionError.spawnFailed(command: executable, code: spawned) } var status: Int32 = 0 while waitpid(pid, &status, 0) < 0 { guard errno == EINTR else { throw PermissionError.spawnFailed(command: executable, code: errno) } } // WIFEXITED and friends are C macros, so Swift does not import them. let terminatingSignal = status & 0x7F return terminatingSignal == 0 ? (status >> 8) & 0xFF : 128 + terminatingSignal } // 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) } }