Merge nucleic/vivid-glass-urchin-xoym into main

This commit is contained in:
2026-08-07 15:41:01 -07:00
parent 6b0c01b74b
commit 902bea5091
11 changed files with 450 additions and 91 deletions
+132 -24
View File
@@ -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.
"""
)
}