Files
gitea-macos-vm-orchestrator/Sources/RunnerHost/LocalNetworkPermission.swift
T

383 lines
17 KiB
Swift

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 <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 (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: 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 {
_ = 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<CChar>?] = ([executable] + arguments).map { strdup($0) }
argv.append(nil)
var envp: [UnsafeMutablePointer<CChar>?] = 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<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)
}
}