Merge nucleic/vivid-glass-urchin-xoym into main
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,16 @@ struct ServiceCommand: AsyncParsableCommand {
|
||||
@Option(name: .long, help: "Path to the installed executable (default: ~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner).")
|
||||
var executable: String?
|
||||
|
||||
/// How to configure Local Network access, if it is not already.
|
||||
///
|
||||
/// Unset means "decide at run time": ask on a terminal, skip with a
|
||||
/// pointer otherwise. `none` suppresses the question outright, for a
|
||||
/// scripted install that has its own arrangements.
|
||||
@Option(
|
||||
name: .customLong("grant-local-network"),
|
||||
help: "Configure macOS Local Network access during install: allowlist, prompt, or none.")
|
||||
var grantLocalNetwork: LocalNetworkGrantChoice?
|
||||
|
||||
func run() async throws {
|
||||
let executablePath = executable ?? LaunchdService.defaultExecutablePath
|
||||
|
||||
@@ -59,8 +69,81 @@ struct ServiceCommand: AsyncParsableCommand {
|
||||
print("program: \(RunnerConfig.expandTilde(executablePath)) daemon")
|
||||
print("logs: \(LaunchdService.logDirectoryURL.path)")
|
||||
print("")
|
||||
|
||||
offerLocalNetworkGrant()
|
||||
|
||||
print("check it with: gitea-macos-runner service status")
|
||||
}
|
||||
|
||||
/// Offers to configure Local Network access, if it is not already.
|
||||
///
|
||||
/// This is where the question belongs. The agent that was just
|
||||
/// installed is the process that will be blocked, it has no UI to ask
|
||||
/// with, and the symptom when it is blocked — every guest boots, no job
|
||||
/// starts, `No route to host` — points nowhere near the cause. Asking
|
||||
/// now costs one prompt; not asking costs a debugging session.
|
||||
///
|
||||
/// Never fatal: a failed or declined grant leaves a perfectly good
|
||||
/// installed agent, so this reports and returns rather than throwing.
|
||||
private func offerLocalNetworkGrant() {
|
||||
guard grantLocalNetwork != .skip else { return }
|
||||
guard !LocalNetworkPolicy.status().coversGuestRange else { return }
|
||||
|
||||
let method: LocalNetworkPermission.Method
|
||||
switch grantLocalNetwork {
|
||||
case .allowlist: method = .allowlist
|
||||
case .prompt: method = .prompt
|
||||
case .skip: return // handled above; here for exhaustiveness
|
||||
case nil:
|
||||
// Not asked for either way: decide from the terminal. A piped
|
||||
// or launchd-driven install must not stop on a question, so it
|
||||
// gets the pointer and carries on.
|
||||
guard isatty(fileno(stdin)) == 1 else {
|
||||
CLI.note("""
|
||||
note: macOS Local Network access is not configured. Until it is, guests \
|
||||
boot but SSH fails with "No route to host". Configure it with \
|
||||
`gitea-macos-runner permissions grant`.
|
||||
""")
|
||||
print("")
|
||||
return
|
||||
}
|
||||
CLI.note("""
|
||||
macOS Local Network access is not configured. Without it the agent starts \
|
||||
guests fine but cannot reach them, and every job fails with "No route to \
|
||||
host". Granting it writes a subnet allowlist with sudo and needs a reboot.
|
||||
""")
|
||||
guard CLI.confirm("configure it now?") else {
|
||||
CLI.note("skipped; run `gitea-macos-runner permissions grant` later")
|
||||
print("")
|
||||
return
|
||||
}
|
||||
method = .allowlist
|
||||
}
|
||||
|
||||
// Deliberately swallowed. The agent is installed and correct at
|
||||
// this point; a declined sudo password should not turn a successful
|
||||
// install into a failure.
|
||||
do {
|
||||
try LocalNetworkGrantFlow.run(method: method)
|
||||
} catch {
|
||||
CLI.note("could not configure it: \(error)")
|
||||
CLI.note("the agent is installed; run `gitea-macos-runner permissions grant` to retry")
|
||||
}
|
||||
print("")
|
||||
}
|
||||
}
|
||||
|
||||
/// `--grant-local-network`'s values: the two grant methods plus an explicit
|
||||
/// opt-out, which the method enum itself has no business carrying.
|
||||
///
|
||||
/// The opt-out case is spelled `skip` rather than `none` so that
|
||||
/// `choice == .skip` cannot be read as `Optional.none` — the option is
|
||||
/// itself optional, and "not passed" means something different from
|
||||
/// "passed `none`".
|
||||
enum LocalNetworkGrantChoice: String, ExpressibleByArgument, CaseIterable {
|
||||
case allowlist
|
||||
case prompt
|
||||
case skip = "none"
|
||||
}
|
||||
|
||||
/// `service uninstall` — unload and remove the plist.
|
||||
|
||||
@@ -45,6 +45,7 @@ struct GiteaMacOSRunner: AsyncParsableCommand {
|
||||
ServiceCommand.self,
|
||||
DoctorCommand.self,
|
||||
ConfigCommand.self,
|
||||
PermissionsCommand.self,
|
||||
],
|
||||
defaultSubcommand: nil
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user