972 lines
41 KiB
Swift
972 lines
41 KiB
Swift
import Foundation
|
|
import RunnerCore
|
|
import Virtualization
|
|
|
|
/// The outcome of one preflight check.
|
|
public struct DoctorCheck: Sendable, Equatable {
|
|
/// How a check turned out.
|
|
public enum Result: Sendable, Equatable {
|
|
/// Requirement satisfied.
|
|
case pass
|
|
/// Requirement not satisfied; the daemon will not work.
|
|
case fail
|
|
/// Not a hard requirement, but worth knowing about.
|
|
case warn
|
|
/// Informational only.
|
|
case info
|
|
}
|
|
|
|
/// Short check name, e.g. `virtualization entitlement`.
|
|
public let name: String
|
|
/// Outcome.
|
|
public let result: Result
|
|
/// What was observed.
|
|
public let detail: String
|
|
/// What to do about it, when the outcome is not ``Result/pass``.
|
|
public let remediation: String?
|
|
|
|
public init(name: String, result: Result, detail: String, remediation: String? = nil) {
|
|
self.name = name
|
|
self.result = result
|
|
self.detail = detail
|
|
self.remediation = remediation
|
|
}
|
|
|
|
/// Whether this check blocks the daemon from working.
|
|
public var isBlocking: Bool { result == .fail }
|
|
}
|
|
|
|
extension DoctorCheck.Result {
|
|
/// Lowercase name, for JSON output and log lines.
|
|
public var label: String {
|
|
switch self {
|
|
case .pass: return "pass"
|
|
case .fail: return "fail"
|
|
case .warn: return "warn"
|
|
case .info: return "info"
|
|
}
|
|
}
|
|
|
|
/// Single-character marker used by ``Doctor/format(_:)``.
|
|
public var symbol: String {
|
|
switch self {
|
|
case .pass: return "✓"
|
|
case .fail: return "✗"
|
|
case .warn: return "!"
|
|
case .info: return "·"
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Preflight checks for a host that is supposed to run macOS guests.
|
|
///
|
|
/// Each of these corresponds to a failure mode that is otherwise diagnosed only
|
|
/// by a confusing runtime error deep inside the boot path, so `doctor` exists to
|
|
/// surface them all at once, before anything is installed.
|
|
public enum Doctor {
|
|
|
|
/// Runs every check.
|
|
///
|
|
/// Checks performed:
|
|
///
|
|
/// 1. **Architecture is arm64.** Virtualization cannot run macOS guests on
|
|
/// Intel at all.
|
|
/// 2. **Host macOS ≥ 26.** Required for ASIF disks; the guest-provisioning
|
|
/// automation additionally wants 27.
|
|
/// 3. **`VZVirtualMachine.isSupported`.** The framework's own verdict.
|
|
/// 4. **`com.apple.security.virtualization` entitlement present** on the
|
|
/// running binary, read with `codesign -d --entitlements - <path>`.
|
|
/// Running from `.build/` instead of the signed `.app` is the single most
|
|
/// common setup mistake, and this is what catches it.
|
|
/// 5. **Code identity is stable**, i.e. the bundle is signed with a real
|
|
/// team-anchored certificate rather than ad-hoc. Warns on ad-hoc,
|
|
/// because that is what makes Local Network grants evaporate on every
|
|
/// rebuild (check 12).
|
|
/// 6. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
|
|
/// write.
|
|
/// 7. **`login.keychain` unlocked**, via `security show-keychain-info
|
|
/// login.keychain`. macOS 15+ refuses to start a VM otherwise — the
|
|
/// reason the daemon must be a LaunchAgent in a logged-in session.
|
|
/// 8. **Gitea reachable and the token has admin scope**, probed with
|
|
/// ``GiteaClient/listRunners()``. A non-admin token fails here rather
|
|
/// than at the first poll.
|
|
/// 9. **Registration token resolvable** from file, inline value, or (if
|
|
/// enabled) the API.
|
|
/// 10. **Runner download URL is live**, via a one-byte ranged `GET` — the
|
|
/// same verb the real download uses, because the presigned redirect
|
|
/// target is signed per method. Catches a version bump that no longer
|
|
/// has a darwin-arm64 asset.
|
|
/// 11. **Guest SSH**, against whichever slot currently holds a DHCP lease —
|
|
/// the one check that exercises host → vmnet → guest `sshd` → password
|
|
/// auth end to end. Informational when no guest is up, since `doctor`
|
|
/// will not boot one.
|
|
/// 12. **Local Network privacy**. Passes when a subnet allowlist is set in
|
|
/// `com.apple.network.local-network`; otherwise informational. On
|
|
/// macOS 15+ the first attempt to reach a guest over the NAT link can
|
|
/// be blocked by the Local Network permission prompt, which a
|
|
/// background agent cannot answer.
|
|
///
|
|
/// - Parameter config: Validated configuration. Gitea-dependent checks are
|
|
/// skipped with a ``DoctorCheck/Result/warn`` when no admin token is set.
|
|
/// - Returns: Checks in the order above.
|
|
public static func runChecks(config: RunnerConfig) async -> [DoctorCheck] {
|
|
var checks = hostChecks()
|
|
checks.append(checkDiskSpace(config: config))
|
|
checks.append(checkLoginKeychain())
|
|
checks.append(contentsOf: await checkGitea(config: config))
|
|
checks.append(await checkRunnerDownloadURL(config: config))
|
|
checks.append(await checkGuestSSH(config: config))
|
|
checks.append(localNetworkNote())
|
|
return checks
|
|
}
|
|
|
|
/// Runs every check, loading configuration from `path` first.
|
|
///
|
|
/// The host checks still run when the configuration is missing or invalid,
|
|
/// which is the state a first-time operator is actually in.
|
|
///
|
|
/// - Parameter configPath: Path to the configuration file; tilde-expanded.
|
|
/// - Returns: Checks, with configuration loading itself reported as a check.
|
|
public static func runChecks(configPath: String) async -> [DoctorCheck] {
|
|
var checks = hostChecks()
|
|
|
|
let loaded: RunnerConfig
|
|
do {
|
|
loaded = try RunnerConfig.load(from: configPath).validated()
|
|
checks.append(
|
|
DoctorCheck(
|
|
name: "configuration",
|
|
result: .pass,
|
|
detail: "loaded and validated \(RunnerConfig.expandTilde(configPath))"
|
|
)
|
|
)
|
|
} catch {
|
|
checks.append(
|
|
DoctorCheck(
|
|
name: "configuration",
|
|
result: .fail,
|
|
detail: "\(error)",
|
|
remediation: "run `gitea-macos-runner config init` and edit \(RunnerConfig.expandTilde(configPath))"
|
|
)
|
|
)
|
|
checks.append(localNetworkNote())
|
|
return checks
|
|
}
|
|
|
|
checks.append(checkDiskSpace(config: loaded))
|
|
checks.append(checkLoginKeychain())
|
|
checks.append(contentsOf: await checkGitea(config: loaded))
|
|
checks.append(await checkRunnerDownloadURL(config: loaded))
|
|
checks.append(await checkGuestSSH(config: loaded))
|
|
checks.append(contentsOf: checkTokenFilePermissions(config: loaded))
|
|
checks.append(localNetworkNote())
|
|
return checks
|
|
}
|
|
|
|
/// The configuration-independent host checks: architecture, OS version,
|
|
/// framework support, entitlement, code identity.
|
|
public static func hostChecks() -> [DoctorCheck] {
|
|
[
|
|
checkHostCapability(),
|
|
checkVirtualizationSupported(),
|
|
checkVirtualizationEntitlement(),
|
|
checkCodeSignature(),
|
|
]
|
|
}
|
|
|
|
/// Whether the running binary carries `com.apple.security.virtualization`.
|
|
///
|
|
/// - Parameter binaryPath: Defaults to the current executable.
|
|
/// - Returns: The check result.
|
|
public static func checkVirtualizationEntitlement(
|
|
binaryPath: String = CommandLine.arguments.first ?? ""
|
|
) -> DoctorCheck {
|
|
let name = "virtualization entitlement"
|
|
let remediation = """
|
|
build and install the signed bundle: `make install`, then run \
|
|
~/Applications/GiteaMacosRunner.app/Contents/MacOS/gitea-macos-runner
|
|
"""
|
|
|
|
guard let executable = resolveExecutablePath(binaryPath) else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "could not locate the running executable to inspect",
|
|
remediation: remediation
|
|
)
|
|
}
|
|
|
|
// The entitlement lives on the signature, so a bare binary copied out of
|
|
// the bundle loses it. Report where we are as well as what we found.
|
|
let (signedTarget, inAppBundle) = signableTarget(for: executable)
|
|
|
|
let result = DoctorShell.run(
|
|
"/usr/bin/codesign",
|
|
["-d", "--entitlements", "-", "--xml", signedTarget]
|
|
)
|
|
let hasEntitlement = result.output.contains("com.apple.security.virtualization")
|
|
|
|
if hasEntitlement {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .pass,
|
|
detail: "present on \(signedTarget)"
|
|
)
|
|
}
|
|
|
|
if inAppBundle {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "\(signedTarget) is not signed with com.apple.security.virtualization",
|
|
remediation: "re-sign the bundle: `make sign` (or `make install`)"
|
|
)
|
|
}
|
|
|
|
// Running the plain SwiftPM binary is normal for `doctor`, `config`, and
|
|
// `service`; it only becomes fatal when a VM is actually started.
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "running an unsigned binary at \(executable); VM starts will fail",
|
|
remediation: remediation
|
|
)
|
|
}
|
|
|
|
/// Whether the bundle's code identity is stable across rebuilds.
|
|
///
|
|
/// This is not a cosmetic "is it properly signed" check — it is the root
|
|
/// cause of the project's most confusing failure. A Developer ID signature
|
|
/// carries a designated requirement anchored to the team
|
|
/// (`certificate leaf[subject.OU] = "…"`), so macOS recognises every later
|
|
/// build as the same program and the app's Local Network grant persists. An
|
|
/// **ad-hoc** signature has no such anchor, so per
|
|
/// [TN3179](https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy)
|
|
/// the system identifies the app by its main executable's Mach-O UUID —
|
|
/// which the linker regenerates on essentially every link. Each
|
|
/// `make install` therefore presents a program macOS has never seen, its
|
|
/// permission reverts to undetermined, and guest SSH starts failing with
|
|
/// `No route to host` minutes after a build that worked.
|
|
///
|
|
/// Ad-hoc is a `warn`, not a `fail`: everything still runs, and it is the
|
|
/// only option on a host without a certificate (CI signs this way
|
|
/// deliberately). It just needs the subnet allowlist to compensate.
|
|
///
|
|
/// - Parameter binaryPath: Defaults to the current executable.
|
|
/// - Returns: The check result.
|
|
public static func checkCodeSignature(
|
|
binaryPath: String = CommandLine.arguments.first ?? ""
|
|
) -> DoctorCheck {
|
|
let name = "code identity"
|
|
|
|
guard let executable = resolveExecutablePath(binaryPath) else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "could not locate the running executable to inspect",
|
|
remediation: "build and install the signed bundle: `make install`"
|
|
)
|
|
}
|
|
|
|
let (target, inAppBundle) = signableTarget(for: executable)
|
|
let result = DoctorShell.run("/usr/bin/codesign", ["-dv", target])
|
|
|
|
guard result.exitCode == 0 else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: inAppBundle ? .fail : .warn,
|
|
detail: "\(target) carries no code signature",
|
|
remediation: "sign the bundle: `make sign` (or `make install`)"
|
|
)
|
|
}
|
|
|
|
let team = value(of: "TeamIdentifier", in: result.output)
|
|
let identifier = value(of: "Identifier", in: result.output) ?? "?"
|
|
let hardened = result.output.contains("flags=") && result.output.contains("runtime")
|
|
|
|
guard let team, team != "not set" else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "\(identifier) is ad-hoc signed (no team identifier)",
|
|
remediation: """
|
|
An ad-hoc signature has no stable designated requirement, so macOS falls back \
|
|
to identifying this app by its Mach-O UUID — regenerated on every build. Any \
|
|
Local Network grant is withdrawn by the next `make install`, and guests then \
|
|
fail with "No route to host". Sign with a Developer ID certificate \
|
|
(`make sign TEAM_ID=<team>`), or set the subnet allowlist so no grant is \
|
|
needed at all — see the "local network access" check.
|
|
"""
|
|
)
|
|
}
|
|
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .pass,
|
|
detail: "\(identifier), team \(team)"
|
|
+ (hardened ? ", hardened runtime" : "")
|
|
)
|
|
}
|
|
|
|
/// Reads a `Key=value` line out of `codesign -dv` output.
|
|
///
|
|
/// `codesign` writes this block to stderr, one `Key=value` per line, and
|
|
/// repeats some keys (`Authority`); the first match is the one that matters.
|
|
private static func value(of key: String, in output: String) -> String? {
|
|
for line in output.split(separator: "\n") {
|
|
let trimmed = line.trimmingCharacters(in: .whitespaces)
|
|
guard trimmed.hasPrefix("\(key)=") else { continue }
|
|
return String(trimmed.dropFirst(key.count + 1))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The artifact `codesign` should be pointed at: the enclosing `.app` when
|
|
/// the executable lives inside one, otherwise the executable itself.
|
|
///
|
|
/// Signatures and entitlements are sealed on the bundle, so querying the
|
|
/// bare Mach-O inside it — or one copied out of it — answers the wrong
|
|
/// question.
|
|
///
|
|
/// - Parameter executable: An absolute, symlink-resolved executable path.
|
|
/// - Returns: The path to query, and whether it is an `.app` bundle.
|
|
private static func signableTarget(for executable: String) -> (path: String, inAppBundle: Bool) {
|
|
guard let marker = executable.range(of: ".app/Contents/MacOS/") else {
|
|
return (executable, false)
|
|
}
|
|
let bundle = executable.prefix(upTo: marker.upperBound)
|
|
.dropLast("/Contents/MacOS/".count)
|
|
return (String(bundle), true)
|
|
}
|
|
|
|
/// Whether `login.keychain` is currently unlocked.
|
|
public static func checkLoginKeychain() -> DoctorCheck {
|
|
let name = "login.keychain unlocked"
|
|
let result = DoctorShell.run("/usr/bin/security", ["show-keychain-info", "login.keychain"])
|
|
|
|
if result.exitCode == 0 {
|
|
return DoctorCheck(name: name, result: .pass, detail: "unlocked")
|
|
}
|
|
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "locked or unavailable (security exited \(result.exitCode))",
|
|
remediation: """
|
|
macOS 15+ refuses to start a VM while login.keychain is locked. Configure \
|
|
automatic login, and do not lock the session — the daemon runs as a \
|
|
LaunchAgent inside it.
|
|
"""
|
|
)
|
|
}
|
|
|
|
/// Whether the host architecture and macOS version can run macOS guests.
|
|
public static func checkHostCapability() -> DoctorCheck {
|
|
let name = "host capability"
|
|
let version = ProcessInfo.processInfo.operatingSystemVersion
|
|
let versionString = "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)"
|
|
|
|
// Emitted straight from the preprocessor branch rather than via a `let
|
|
// isAppleSilicon` flag: with the flag, one arm is a compile-time
|
|
// constant and the compiler warns that the other is dead code.
|
|
#if !arch(arm64)
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "not an Apple silicon host; macOS guests require arm64",
|
|
remediation: "run this daemon on an Apple silicon Mac"
|
|
)
|
|
#else
|
|
|
|
guard version.majorVersion >= 26 else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "macOS \(versionString); this daemon requires macOS 26 or newer",
|
|
remediation: "upgrade the host: ASIF sparse disks need macOS 26+"
|
|
)
|
|
}
|
|
|
|
if version.majorVersion < 27 {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "arm64, macOS \(versionString)",
|
|
remediation: """
|
|
automated guest provisioning (VZMacGuestProvisioningOptions) needs macOS 27 \
|
|
on both host and guest; on 26 the first boot's Setup Assistant must be \
|
|
completed by hand once per image
|
|
"""
|
|
)
|
|
}
|
|
|
|
return DoctorCheck(name: name, result: .pass, detail: "arm64, macOS \(versionString)")
|
|
#endif
|
|
}
|
|
|
|
/// The framework's own verdict on this host.
|
|
public static func checkVirtualizationSupported() -> DoctorCheck {
|
|
let name = "Virtualization.framework"
|
|
if VZVirtualMachine.isSupported {
|
|
return DoctorCheck(name: name, result: .pass, detail: "VZVirtualMachine.isSupported == true")
|
|
}
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "VZVirtualMachine.isSupported == false",
|
|
remediation: "this host cannot run virtual machines"
|
|
)
|
|
}
|
|
|
|
/// Free space on the store volume against `storage.minFreeDiskGB`.
|
|
public static func checkDiskSpace(config: RunnerConfig) -> DoctorCheck {
|
|
let name = "free disk space"
|
|
let store = VMStore(config: config)
|
|
do {
|
|
let free = try store.freeDiskSpace()
|
|
let freeGB = Double(free) / 1_073_741_824
|
|
let detail = String(
|
|
format: "%.1f GB free at %@ (minimum %d GB)",
|
|
freeGB, config.storeDirectoryURL.path, config.storage.minFreeDiskGB
|
|
)
|
|
if freeGB < Double(config.storage.minFreeDiskGB) {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: detail,
|
|
remediation: "free space, or lower storage.minFreeDiskGB"
|
|
)
|
|
}
|
|
return DoctorCheck(name: name, result: .pass, detail: detail)
|
|
} catch {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "could not measure free space: \(error)",
|
|
remediation: "check that \(config.storeDirectoryURL.path) exists and is readable"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Reachability, admin scope, and registration-token availability.
|
|
public static func checkGitea(config: RunnerConfig) async -> [DoctorCheck] {
|
|
var checks: [DoctorCheck] = []
|
|
|
|
let adminToken: String?
|
|
do {
|
|
adminToken = try config.resolveAdminToken()
|
|
} catch {
|
|
checks.append(
|
|
DoctorCheck(
|
|
name: "gitea admin token",
|
|
result: .fail,
|
|
detail: "\(error)",
|
|
remediation: "check gitea.adminTokenFile / gitea.adminToken"
|
|
)
|
|
)
|
|
return checks
|
|
}
|
|
|
|
guard let adminToken, !adminToken.isEmpty else {
|
|
checks.append(
|
|
DoctorCheck(
|
|
name: "gitea admin api",
|
|
result: .warn,
|
|
detail: "no admin token configured; skipped",
|
|
remediation: "set gitea.adminTokenFile to a file holding an ADMIN user's API token"
|
|
)
|
|
)
|
|
return checks
|
|
}
|
|
|
|
let client = GiteaClient(baseURL: config.gitea.instanceURL, token: adminToken)
|
|
do {
|
|
let runners = try await client.listRunners()
|
|
checks.append(
|
|
DoctorCheck(
|
|
name: "gitea admin api",
|
|
result: .pass,
|
|
detail: "\(config.gitea.instanceURL.absoluteString) reachable; \(runners.count) runner(s) registered"
|
|
)
|
|
)
|
|
} catch {
|
|
checks.append(
|
|
DoctorCheck(
|
|
name: "gitea admin api",
|
|
result: .fail,
|
|
detail: "\(error)",
|
|
remediation: """
|
|
every endpoint used lives under /api/v1/admin/actions/ — the token must \
|
|
belong to a Gitea administrator, and the instance must be reachable
|
|
"""
|
|
)
|
|
)
|
|
}
|
|
|
|
checks.append(await checkRegistrationToken(config: config, client: client))
|
|
return checks
|
|
}
|
|
|
|
/// Whether a registration token can be obtained at all.
|
|
private static func checkRegistrationToken(config: RunnerConfig, client: GiteaClient) async -> DoctorCheck {
|
|
let name = "registration token"
|
|
do {
|
|
if let staticToken = try config.resolveStaticRegistrationToken(), !staticToken.isEmpty {
|
|
return DoctorCheck(name: name, result: .pass, detail: "resolved from configuration")
|
|
}
|
|
} catch {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "\(error)",
|
|
remediation: "check gitea.registrationTokenFile"
|
|
)
|
|
}
|
|
|
|
guard config.gitea.fetchRegistrationTokenViaAPI else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "no static token configured and gitea.fetchRegistrationTokenViaAPI is off",
|
|
remediation: """
|
|
seed a fixed token server-side (GITEA_RUNNER_REGISTRATION_TOKEN) and point \
|
|
gitea.registrationTokenFile at a copy of it
|
|
"""
|
|
)
|
|
}
|
|
|
|
do {
|
|
let token = try await client.getRegistrationToken()
|
|
guard !token.isEmpty else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "the API returned an empty token",
|
|
remediation: "configure gitea.registrationTokenFile instead"
|
|
)
|
|
}
|
|
return DoctorCheck(name: name, result: .pass, detail: "fetched from the admin API")
|
|
} catch {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "\(error)",
|
|
remediation: "configure gitea.registrationTokenFile instead"
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Whether the configured `gitea-runner` asset still exists.
|
|
public static func checkRunnerDownloadURL(config: RunnerConfig) async -> DoctorCheck {
|
|
let name = "runner download url"
|
|
let url: URL
|
|
do {
|
|
url = try config.runner.resolvedDownloadURL
|
|
} catch {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: "\(error)",
|
|
remediation: "check runner.runnerDownloadURL and runner.version"
|
|
)
|
|
}
|
|
|
|
// A ranged GET, not a HEAD. gitea.com answers an asset request with a
|
|
// 303 to a presigned object-storage URL, and the signature covers the
|
|
// *method of the request that minted it*: ask with HEAD and you get a
|
|
// HEAD-signed URL. URLSession then follows the 303 and — per RFC 7231
|
|
// §6.4.4 — rewrites the method to GET, so the signed URL is replayed
|
|
// with the one verb it was not signed for and the store answers 403
|
|
// SignatureDoesNotMatch. Probing with the same verb the real download
|
|
// uses is the only way to make the answer mean anything. `bytes=0-0`
|
|
// keeps it to one byte instead of the whole 20-plus MB asset.
|
|
do {
|
|
let status = try await probeStatus(url: url, method: "GET", range: "bytes=0-0")
|
|
if status <= 399 {
|
|
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(status)")
|
|
}
|
|
// A host that rejects ranges outright still deserves a second look
|
|
// before we call the asset missing.
|
|
let fallback = try await probeStatus(url: url, method: "HEAD", range: nil)
|
|
if fallback <= 399 {
|
|
return DoctorCheck(name: name, result: .pass, detail: "\(url.absoluteString) → \(fallback)")
|
|
}
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "\(url.absoluteString) → \(status)",
|
|
remediation: "check runner.version and runner.runnerDownloadURL for a darwin-arm64 asset"
|
|
)
|
|
} catch {
|
|
// Best effort: a proxy or offline build host is not a reason to
|
|
// block the daemon.
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "could not reach \(url.absoluteString): \(error.localizedDescription)",
|
|
remediation: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Issues one probe request and reports its status code, or 0 if the
|
|
/// response was not HTTP.
|
|
private static func probeStatus(url: URL, method: String, range: String?) async throws -> Int {
|
|
var request = URLRequest(url: url)
|
|
request.httpMethod = method
|
|
request.timeoutInterval = 15
|
|
if let range {
|
|
request.setValue(range, forHTTPHeaderField: "Range")
|
|
}
|
|
let (_, response) = try await URLSession.shared.data(for: request)
|
|
return (response as? HTTPURLResponse)?.statusCode ?? 0
|
|
}
|
|
|
|
/// Warns about token files readable by other users on this Mac.
|
|
public static func checkTokenFilePermissions(config: RunnerConfig) -> [DoctorCheck] {
|
|
let insecure = config.insecureTokenFilePaths
|
|
guard !insecure.isEmpty else { return [] }
|
|
return [
|
|
DoctorCheck(
|
|
name: "token file permissions",
|
|
result: .warn,
|
|
detail: "group/world readable: \(insecure.joined(separator: ", "))",
|
|
remediation: "chmod 600 \(insecure.joined(separator: " "))"
|
|
)
|
|
]
|
|
}
|
|
|
|
/// Whether a guest that is up right now actually accepts an SSH session.
|
|
///
|
|
/// Every other check in this file inspects the host. This one exercises the
|
|
/// exact path the boot sequence depends on and that nothing else proves:
|
|
/// host → vmnet → guest `sshd` → password auth with `guest.username` /
|
|
/// `guest.password`. It is the difference between "the daemon never got a
|
|
/// runner online" and a named cause — wrong credentials, Local Network
|
|
/// privacy blocking the link, or a guest image whose Remote Login is off.
|
|
///
|
|
/// Read-only with respect to host state: it uses the MACs already persisted
|
|
/// in `state.json` and never generates them, so running `doctor` on a fresh
|
|
/// host does not quietly create the slot address table.
|
|
///
|
|
/// - Note: Informational when no guest is currently leased. `doctor` must not
|
|
/// boot a VM — that costs minutes and a slot out of the host's hard cap of
|
|
/// two — so with nothing running there is simply nothing to probe. To make
|
|
/// this check meaningful, leave a guest up (`gitea-macos-runner vm boot`)
|
|
/// and run `doctor` again.
|
|
public static func checkGuestSSH(config: RunnerConfig) async -> DoctorCheck {
|
|
let name = "guest ssh"
|
|
|
|
let macs: [String]
|
|
do {
|
|
macs = try VMStore(config: config).loadState().slotMACAddresses
|
|
} catch {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "could not read host state: \(error)",
|
|
remediation: "check that \(config.storeDirectoryURL.path) is readable"
|
|
)
|
|
}
|
|
|
|
guard !macs.isEmpty else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .info,
|
|
detail: "no slot MAC addresses assigned yet; skipped",
|
|
remediation: nil
|
|
)
|
|
}
|
|
|
|
// Newest lease wins if a slot somehow holds more than one: that is the
|
|
// guest currently on the link.
|
|
let leases = DHCPLeaseParser.parseFile()
|
|
guard let (mac, lease) = macs.lazy
|
|
.compactMap({ mac in DHCPLeaseParser.lease(forMAC: mac, in: leases).map { (mac, $0) } })
|
|
.first
|
|
else {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .info,
|
|
detail: "no guest currently holds a DHCP lease; skipped",
|
|
remediation: """
|
|
this check only runs against a guest that is already up. To exercise the \
|
|
host→guest SSH path, run `gitea-macos-runner vm boot --image default` and \
|
|
then `doctor` again.
|
|
"""
|
|
)
|
|
}
|
|
|
|
// Short and fixed rather than derived from `scheduler.bootTimeoutSeconds`:
|
|
// this probes a guest that is already booted, so a slow answer is a
|
|
// finding, not something to wait fifteen minutes for.
|
|
do {
|
|
try await waitForSSH(
|
|
host: lease.ipAddress,
|
|
username: config.guest.username,
|
|
password: config.guest.password,
|
|
timeout: .seconds(20),
|
|
pollInterval: .seconds(2)
|
|
)
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .pass,
|
|
detail: "authenticated to \(config.guest.username)@\(lease.ipAddress) (\(mac))"
|
|
)
|
|
} catch let error as CoreError {
|
|
let detail = "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)"
|
|
switch error {
|
|
case .timeout:
|
|
// Nothing answered. bootpd leases last 24 hours and the slot MACs
|
|
// are persistent, so on any host that has ever run the daemon the
|
|
// most likely explanation is a lease outliving the guest that held
|
|
// it — not a broken host. Calling that `.fail` would make `doctor`
|
|
// cry wolf on a perfectly healthy idle machine.
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: detail,
|
|
remediation: """
|
|
most likely a stale lease: bootpd keeps leases for 24 hours, so this \
|
|
address may belong to a guest that has already been torn down. If a \
|
|
guest really is up at this address, the daemon cannot reach it either — \
|
|
check that the host's Local Network permission is not dropping the \
|
|
connection (see the "local network access" check).
|
|
"""
|
|
)
|
|
default:
|
|
// Authentication reached the guest and was refused: the guest is up
|
|
// and the credentials are wrong. Nothing about that improves on its
|
|
// own, and every boot will fail the same way.
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .fail,
|
|
detail: detail,
|
|
remediation: """
|
|
the daemon authenticates over this exact path, so no boot can succeed \
|
|
while it fails. Check that guest.username and guest.password match an \
|
|
account in the guest image, and that Remote Login is enabled there.
|
|
"""
|
|
)
|
|
}
|
|
} catch {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "\(config.guest.username)@\(lease.ipAddress) (\(mac)): \(error)",
|
|
remediation: nil
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The macOS 15+ Local Network permission note.
|
|
///
|
|
/// Reports `.pass` when the host carries a subnet allowlist that actually
|
|
/// covers where guests turn up, because that bypasses the prompt entirely.
|
|
/// An allowlist that names some *other* subnet is worse than none, since it
|
|
/// looks configured while blocking every guest, so it warns rather than
|
|
/// passing. Without one this stays informational: we cannot see the grant
|
|
/// itself, since Local Network privacy is a Network Extension packet filter
|
|
/// rather than a TCC entry, so there is no database to query and `tccutil`
|
|
/// does not apply (Apple, TN3179).
|
|
public static func localNetworkNote() -> DoctorCheck {
|
|
let name = "local network access"
|
|
let allowed = localNetworkAllowlist()
|
|
if !allowed.isEmpty {
|
|
if allowed.contains(where: coversVMNetRange) {
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .pass,
|
|
detail: "subnet allowlist set: \(allowed.joined(separator: ", "))"
|
|
)
|
|
}
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .warn,
|
|
detail: "subnet allowlist set but does not cover the guest range: "
|
|
+ allowed.joined(separator: ", "),
|
|
remediation: """
|
|
Virtualization.framework's NAT does not stay on 192.168.64.0/24 — it moves \
|
|
to the next free /24 (192.168.65.x, .66.x, …) when one is taken, so \
|
|
an allowlist pinned to a single /24 stops working the day the subnet shifts \
|
|
and every guest connection then fails with "No route to host". Widen it to \
|
|
cover the whole span: sudo defaults write com.apple.network.local-network \
|
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
|
|
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
|
|
"""
|
|
)
|
|
}
|
|
|
|
return DoctorCheck(
|
|
name: name,
|
|
result: .info,
|
|
detail: "guests are reached over the host-private NAT link",
|
|
remediation: """
|
|
on macOS 15+ the first connection to a guest can be blocked by the Local Network \
|
|
privacy prompt, and frequently there is nothing able to answer it. A LaunchAgent \
|
|
has no UI to show it in; a run started from a shell is attributed to the \
|
|
*responsible* process, so both the prompt and the System Settings → Privacy & \
|
|
Security → Local Network row belong to Terminal rather than to this app — and \
|
|
granting it to Terminal does not carry over to the LaunchAgent. Prefer the subnet \
|
|
allowlist: it needs no prompt, covers every process, and survives rebuilds. \
|
|
sudo defaults write com.apple.network.local-network \
|
|
AllowedEthernetLocalNetworkAddresses -array "192.168.64.0/18" (same for \
|
|
AllowedWiFiLocalNetworkAddresses), then reboot. See docs/setup.md §2.6.
|
|
"""
|
|
)
|
|
}
|
|
|
|
/// The span of addresses a vmnet NAT link can plausibly use.
|
|
///
|
|
/// `192.168.64.0/24` is only the *first* choice: the subnet is picked at
|
|
/// runtime and steps to the next free /24 when that one is already in use,
|
|
/// which is why a host that worked yesterday can hand out `192.168.65.x`
|
|
/// today. Everything from 192.168.64.0 to 192.168.127.255 — a /18 — is
|
|
/// treated as guest territory so the allowlist survives that drift.
|
|
static let vmNetFirstAddress: UInt32 = 0xC0A8_4000 // 192.168.64.0
|
|
static let vmNetLastAddress: UInt32 = 0xC0A8_7FFF // 192.168.127.255
|
|
|
|
/// Whether one allowlist entry covers the whole guest range.
|
|
///
|
|
/// Deliberately all-or-nothing: partial cover is the failure mode being
|
|
/// warned about, so an entry that contains today's subnet but not
|
|
/// tomorrow's is not treated as good enough.
|
|
static func coversVMNetRange(_ entry: String) -> Bool {
|
|
let parts = entry.split(separator: "/", maxSplits: 1)
|
|
guard let base = ipv4Value(String(parts[0])) else { return false }
|
|
let prefix = parts.count == 2 ? Int(parts[1]) : 32
|
|
guard let prefix, (0...32).contains(prefix) else { return false }
|
|
|
|
let mask: UInt32 = prefix == 0 ? 0 : ~UInt32(0) << (32 - prefix)
|
|
let network = base & mask
|
|
let broadcast = network | ~mask
|
|
return network <= vmNetFirstAddress && broadcast >= vmNetLastAddress
|
|
}
|
|
|
|
/// Packs dotted-quad IPv4 into a comparable integer; nil for anything else
|
|
/// (an IPv6 entry, a hostname, a typo).
|
|
static func ipv4Value(_ text: String) -> UInt32? {
|
|
let octets = text.split(separator: ".", omittingEmptySubsequences: false)
|
|
guard octets.count == 4 else { return nil }
|
|
var value: UInt32 = 0
|
|
for octet in octets {
|
|
guard let number = UInt32(octet), number <= 255 else { return nil }
|
|
value = value << 8 | number
|
|
}
|
|
return value
|
|
}
|
|
|
|
/// Subnets pre-authorized for local network access on this host, if any.
|
|
///
|
|
/// Best effort and never fatal: an unreadable or absent preferences file
|
|
/// simply reads as "no allowlist". The domain is written with `sudo`, so
|
|
/// which preferences directory it lands in depends on whether that `sudo`
|
|
/// preserved `HOME` — check each candidate rather than guess.
|
|
static func localNetworkAllowlist() -> [String] {
|
|
let keys = ["AllowedEthernetLocalNetworkAddresses", "AllowedWiFiLocalNetworkAddresses"]
|
|
let candidates = [
|
|
"/var/root/Library/Preferences/com.apple.network.local-network.plist",
|
|
"/Library/Preferences/com.apple.network.local-network.plist",
|
|
NSHomeDirectory() + "/Library/Preferences/com.apple.network.local-network.plist",
|
|
]
|
|
|
|
var found: [String] = []
|
|
for path in candidates {
|
|
guard let data = FileManager.default.contents(atPath: path),
|
|
let plist = try? PropertyListSerialization.propertyList(
|
|
from: data, options: [], format: nil) as? [String: Any]
|
|
else { continue }
|
|
for key in keys {
|
|
for entry in (plist[key] as? [String] ?? []) where !found.contains(entry) {
|
|
found.append(entry)
|
|
}
|
|
}
|
|
}
|
|
return found
|
|
}
|
|
|
|
/// Renders checks as aligned, human-readable lines for the CLI.
|
|
public static func format(_ checks: [DoctorCheck]) -> String {
|
|
let width = checks.map(\.name.count).max() ?? 0
|
|
var lines: [String] = []
|
|
|
|
for check in checks {
|
|
let padded = check.name.padding(toLength: max(width, check.name.count), withPad: " ", startingAt: 0)
|
|
lines.append("\(check.result.symbol) \(padded) \(check.detail)")
|
|
if check.result != .pass, let remediation = check.remediation {
|
|
for (index, wrapped) in wrap(remediation, width: 76).enumerated() {
|
|
let prefix = index == 0 ? "→ " : " "
|
|
lines.append(String(repeating: " ", count: width + 4) + prefix + wrapped)
|
|
}
|
|
}
|
|
}
|
|
|
|
let failures = checks.filter(\.isBlocking).count
|
|
let warnings = checks.filter { $0.result == .warn }.count
|
|
lines.append("")
|
|
lines.append("\(checks.count) checks, \(failures) failed, \(warnings) warned")
|
|
return lines.joined(separator: "\n")
|
|
}
|
|
|
|
/// Greedy word wrap for remediation text.
|
|
private static func wrap(_ text: String, width: Int) -> [String] {
|
|
var lines: [String] = []
|
|
var current = ""
|
|
for word in text.split(whereSeparator: { $0 == " " || $0 == "\n" }) {
|
|
if current.isEmpty {
|
|
current = String(word)
|
|
} else if current.count + 1 + word.count <= width {
|
|
current += " " + word
|
|
} else {
|
|
lines.append(current)
|
|
current = String(word)
|
|
}
|
|
}
|
|
if !current.isEmpty { lines.append(current) }
|
|
return lines
|
|
}
|
|
|
|
/// Resolves the running executable, preferring the bundle's own record of it
|
|
/// over `argv[0]`, which may be a symlink or a bare command name.
|
|
private static func resolveExecutablePath(_ candidate: String) -> String? {
|
|
let fm = FileManager.default
|
|
if !candidate.isEmpty, candidate.hasPrefix("/"), fm.fileExists(atPath: candidate) {
|
|
return URL(fileURLWithPath: candidate).resolvingSymlinksInPath().path
|
|
}
|
|
if let executableURL = Bundle.main.executableURL {
|
|
return executableURL.resolvingSymlinksInPath().path
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
/// Minimal synchronous process runner for the tools `doctor` shells out to.
|
|
private enum DoctorShell {
|
|
struct Output {
|
|
let exitCode: Int32
|
|
let output: String
|
|
}
|
|
|
|
static func run(_ launchPath: String, _ arguments: [String]) -> Output {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: launchPath)
|
|
process.arguments = arguments
|
|
|
|
let pipe = Pipe()
|
|
process.standardOutput = pipe
|
|
// codesign and security both report on stderr; merge so callers can grep
|
|
// one stream.
|
|
process.standardError = pipe
|
|
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
return Output(exitCode: 127, output: "\(error)")
|
|
}
|
|
|
|
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
|
process.waitUntilExit()
|
|
return Output(exitCode: process.terminationStatus, output: String(data: data, encoding: .utf8) ?? "")
|
|
}
|
|
}
|