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 - `. /// 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=`), 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) ?? "") } }