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
@@ -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)
}
}
}