Merge nucleic/vivid-glass-urchin-xoym into main
This commit is contained in:
+132
-24
@@ -78,25 +78,29 @@ public enum Doctor {
|
||||
/// 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. **Free disk ≥ `storage.minFreeDiskGB`.** CoW clones grow as guests
|
||||
/// 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.
|
||||
/// 6. **`login.keychain` unlocked**, via `security show-keychain-info
|
||||
/// 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.
|
||||
/// 7. **Gitea reachable and the token has admin scope**, probed with
|
||||
/// 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.
|
||||
/// 8. **Registration token resolvable** from file, inline value, or (if
|
||||
/// 9. **Registration token resolvable** from file, inline value, or (if
|
||||
/// enabled) the API.
|
||||
/// 9. **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.
|
||||
/// 10. **Guest SSH**, against whichever slot currently holds a DHCP lease —
|
||||
/// 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.
|
||||
/// 11. **Local Network privacy**. Passes when a subnet allowlist is set in
|
||||
/// 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
|
||||
@@ -160,12 +164,13 @@ public enum Doctor {
|
||||
}
|
||||
|
||||
/// The configuration-independent host checks: architecture, OS version,
|
||||
/// framework support, entitlement.
|
||||
/// framework support, entitlement, code identity.
|
||||
public static func hostChecks() -> [DoctorCheck] {
|
||||
[
|
||||
checkHostCapability(),
|
||||
checkVirtualizationSupported(),
|
||||
checkVirtualizationEntitlement(),
|
||||
checkCodeSignature(),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -193,11 +198,7 @@ public enum Doctor {
|
||||
|
||||
// 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 inAppBundle = executable.contains(".app/Contents/MacOS/")
|
||||
let signedTarget = inAppBundle
|
||||
? String(executable.prefix(upTo: executable.range(of: ".app/Contents/MacOS/")!.upperBound)
|
||||
.dropLast("/Contents/MacOS/".count))
|
||||
: executable
|
||||
let (signedTarget, inAppBundle) = signableTarget(for: executable)
|
||||
|
||||
let result = DoctorShell.run(
|
||||
"/usr/bin/codesign",
|
||||
@@ -232,6 +233,112 @@ public enum Doctor {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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"
|
||||
@@ -696,14 +803,15 @@ public enum Doctor {
|
||||
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, which a background LaunchAgent cannot answer. The app cannot be \
|
||||
pre-approved: it only appears under System Settings → Privacy & Security → Local \
|
||||
Network once it has actually attempted a guest connection. To trigger and answer \
|
||||
the prompt by hand, run `gitea-macos-runner vm boot --image default` once from a \
|
||||
Terminal in the GUI session. On an unattended CI host prefer the subnet \
|
||||
allowlist, which needs no prompt and survives rebuilds: sudo defaults write \
|
||||
com.apple.network.local-network AllowedEthernetLocalNetworkAddresses -array \
|
||||
"192.168.64.0/18" (then reboot). See docs/setup.md §2.6.
|
||||
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.
|
||||
"""
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user