383 lines
17 KiB
Swift
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)
|
|
}
|
|
}
|