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 = LocalNetworkPermission.observedStatus() if !status.sourcePaths.isEmpty { print("") for path in status.sourcePaths { print("allowlist read from: \(path)") } } if status.isIndeterminate { print("") print("The allowlist lives in root's preferences, which only root can read, so") print("this cannot tell whether it is already set. For a definitive answer:") print(" sudo gitea-macos-runner permissions status") } guard !status.coversGuestRange else { return } print("") // Not visible is not the same as not set, and an unconfigured host // looks identical to a configured one from an ordinary login — so // offer the commands without asserting anything is broken. print(status.isIndeterminate ? "if it is not set, either of these sets it:" : "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. if result.verified { 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 """) } } else if result.observed.isIndeterminate { // The write succeeded but there is no way to look: the allowlist // lands in root's preferences, and this host does not keep sudo // credentials cached long enough for the read-back to use them. // Unknown is not failure — say so plainly rather than either // claiming success or crying wolf. CLI.note(""" wrote \(subnets.joined(separator: ", ")), but could not read it back to \ confirm — that needs administrator rights this process no longer holds. \ Check it with: sudo defaults read \(LocalNetworkPolicy.domain) """) } 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("") 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) } } }