Nucleic: The MacOS VM Base Image Provisioning
This commit is contained in:
@@ -30,6 +30,8 @@ public struct MacVMBundle: Sendable, Equatable {
|
||||
public var diskImageURL: URL { root.appendingPathComponent("Disk.img") }
|
||||
public var macAddressURL: URL { root.appendingPathComponent("MACAddress") }
|
||||
public var metadataURL: URL { root.appendingPathComponent("bundle.json") }
|
||||
/// Host-only MDM CA/configuration/device identity. Contains private keys; never copied into clones.
|
||||
public var mdmStateURL: URL { root.appendingPathComponent("mdm-state.json") }
|
||||
/// The saved runtime state (full guest RAM + device state) written by a **suspend-to-disk**
|
||||
/// (`VZVirtualMachine.saveMachineState(to:)`), consumed by the next boot's
|
||||
/// `restoreMachineState(from:)`. Present only while the VM is suspended to disk; deleted the
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import Foundation
|
||||
import NucleicMDM
|
||||
|
||||
/// Guest execution target for MDM orchestration. Base-maintenance VMs are intentionally absent from
|
||||
/// the ordinary `live` registry, so Mode A must be able to address their instance directly.
|
||||
private enum MDMGuestTarget: Sendable {
|
||||
case registered(name: String)
|
||||
#if arch(arm64)
|
||||
case instance(MacVMInstance, name: String)
|
||||
#endif
|
||||
|
||||
var name: String {
|
||||
switch self {
|
||||
case .registered(let name): return name
|
||||
#if arch(arm64)
|
||||
case .instance(_, let name): return name
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Host-side orchestration for the Nucleic MDM plane (docs/MACOS_VM_MDM.md §4). These are the reusable
|
||||
// building blocks the provisioning flow (Mode A) and the live-clone push path (Mode B) call. The MDM
|
||||
// *protocol* server, CA, and artifact generation live in the `NucleicMDM` module; this file wires them
|
||||
// to a running guest via the existing transports — `run(name:command:…)` (vsock exec) for in-guest
|
||||
// shell, and the `MacVMSurfaceHost` for the one GUI approval click. Everything here is Darwin-only in
|
||||
// to a running guest via the existing vsock exec transport (registered clone or direct maintenance
|
||||
// instance), and the `MacVMSurfaceHost` for GUI approval. Everything here is Darwin-only in
|
||||
// effect (it drives `NucleicMDMServer`, which is `#if canImport(Network)`).
|
||||
//
|
||||
// NOT unit-testable without a live guest — the guest-side enroll, the UAMDM GUI approval, and the
|
||||
@@ -24,6 +42,9 @@ extension MacVMEngine {
|
||||
case guestCommandFailed(String)
|
||||
case enrollmentNotApproved(String)
|
||||
case missingPolicyAssets([String])
|
||||
case invalidPolicyProfile(String)
|
||||
case policyCommandsFailed([String: String])
|
||||
case profilesMissing([String])
|
||||
case drainTimedOut(pending: Int)
|
||||
public var description: String {
|
||||
switch self {
|
||||
@@ -31,6 +52,15 @@ extension MacVMEngine {
|
||||
case .enrollmentNotApproved(let s): return "MDM enrollment not approved: \(s)"
|
||||
case .missingPolicyAssets(let names):
|
||||
return "required MDM policy assets are missing: \(names.joined(separator: ", "))"
|
||||
case .invalidPolicyProfile(let detail):
|
||||
return "invalid MDM policy profile: \(detail)"
|
||||
case .policyCommandsFailed(let statuses):
|
||||
let summary = statuses.keys.sorted().map { "\($0)=\(statuses[$0]!)" }
|
||||
.joined(separator: ", ")
|
||||
return "MDM policy commands did not all acknowledge: \(summary)"
|
||||
case .profilesMissing(let identifiers):
|
||||
return "MDM reported success but required profiles are missing: "
|
||||
+ identifiers.joined(separator: ", ")
|
||||
case .drainTimedOut(let p): return "MDM command drain timed out with \(p) still queued"
|
||||
}
|
||||
}
|
||||
@@ -61,6 +91,27 @@ extension MacVMEngine {
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Extract the top-level identifiers that must appear in the guest's configuration inventory.
|
||||
/// Payload UUIDs and nested payload identifiers are intentionally not accepted as substitutes.
|
||||
static func configurationProfileIdentifiers(in profiles: [Data]) throws -> [String] {
|
||||
try profiles.enumerated().map { index, data in
|
||||
let dictionary: [String: Any]
|
||||
do { dictionary = try MDMPlist.parse(data) }
|
||||
catch {
|
||||
throw MDMOrchestrationError.invalidPolicyProfile(
|
||||
"profile \(index + 1) is not a property-list dictionary: \(error)")
|
||||
}
|
||||
guard dictionary["PayloadType"] as? String == "Configuration",
|
||||
let identifier = dictionary["PayloadIdentifier"] as? String,
|
||||
!identifier.isEmpty
|
||||
else {
|
||||
throw MDMOrchestrationError.invalidPolicyProfile(
|
||||
"profile \(index + 1) has no top-level Configuration PayloadIdentifier")
|
||||
}
|
||||
return identifier
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the exact policy payloads to queue during Mode A enrollment.
|
||||
func resolveBuiltInMDMPolicyProfiles() throws -> [Data] {
|
||||
guard let urls = Self.builtInMDMPolicyProfileURLs(
|
||||
@@ -72,6 +123,17 @@ extension MacVMEngine {
|
||||
return try urls.map { try Data(contentsOf: $0) }
|
||||
}
|
||||
|
||||
/// Load the identity already associated with this base, or create it exactly once. A corrupt or
|
||||
/// unsupported state file is fatal: replacing it would orphan the enrollment baked into the VM.
|
||||
func loadOrCreateMDMController(for bundle: MacVMBundle) throws -> MDMServerController {
|
||||
if FileManager.default.fileExists(atPath: bundle.mdmStateURL.path) {
|
||||
return try MDMServerController.loadPersistedState(from: bundle.mdmStateURL)
|
||||
}
|
||||
let controller = try MDMServerController.makeFresh()
|
||||
try controller.writePersistedState(to: bundle.mdmStateURL)
|
||||
return controller
|
||||
}
|
||||
|
||||
// MARK: - Mode A — enroll the base at build time
|
||||
|
||||
/// Enroll the running base guest in the local MDM and install `profiles`, then verify — the Mode A
|
||||
@@ -84,19 +146,56 @@ extension MacVMEngine {
|
||||
/// `profiles`. `hostBindAddress` is the host's vmnet-facing address the guest reaches (the gateway).
|
||||
public func enrollBaseInMDM(
|
||||
name: String,
|
||||
bundle: MacVMBundle,
|
||||
shareDir: URL,
|
||||
hostBindAddress: String,
|
||||
profiles: [Data],
|
||||
surface: any MacVMSurfaceHost,
|
||||
controller: MDMServerController? = nil,
|
||||
approvalTimeout: TimeInterval = 120
|
||||
) async throws -> MDMOutcome {
|
||||
try await enrollBaseInMDM(
|
||||
target: .registered(name: name), bundle: bundle, shareDir: shareDir,
|
||||
hostBindAddress: hostBindAddress, profiles: profiles, surface: surface,
|
||||
controller: controller, approvalTimeout: approvalTimeout)
|
||||
}
|
||||
|
||||
#if arch(arm64)
|
||||
/// Mode A entry point for the base-maintenance VM, which is not present in the session registry.
|
||||
func enrollBaseInMDM(
|
||||
instance: MacVMInstance,
|
||||
name: String,
|
||||
bundle: MacVMBundle,
|
||||
shareDir: URL,
|
||||
hostBindAddress: String,
|
||||
profiles: [Data],
|
||||
surface: any MacVMSurfaceHost,
|
||||
controller: MDMServerController? = nil,
|
||||
approvalTimeout: TimeInterval = 120
|
||||
) async throws -> MDMOutcome {
|
||||
try await enrollBaseInMDM(
|
||||
target: .instance(instance, name: name), bundle: bundle, shareDir: shareDir,
|
||||
hostBindAddress: hostBindAddress, profiles: profiles, surface: surface,
|
||||
controller: controller, approvalTimeout: approvalTimeout)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func enrollBaseInMDM(
|
||||
target: MDMGuestTarget,
|
||||
bundle: MacVMBundle,
|
||||
shareDir: URL,
|
||||
hostBindAddress: String,
|
||||
profiles: [Data],
|
||||
surface: any MacVMSurfaceHost,
|
||||
controller: MDMServerController?,
|
||||
approvalTimeout: TimeInterval
|
||||
) async throws -> MDMOutcome {
|
||||
var log: [String] = []
|
||||
let controller = try controller ?? MDMServerController.makeFresh()
|
||||
let controller = try controller ?? loadOrCreateMDMController(for: bundle)
|
||||
|
||||
// Stage artifacts into the share and pre-queue the profiles.
|
||||
try controller.stageArtifacts(into: shareDir)
|
||||
await controller.queueInstallProfiles(profiles)
|
||||
let commandUUIDs = await controller.queueInstallProfiles(profiles)
|
||||
log.append("staged MDM artifacts into \(shareDir.lastPathComponent); queued \(profiles.count) profile(s)")
|
||||
|
||||
// Start the server on the vmnet-facing address (+ IP SAN so the cert also matches the raw IP).
|
||||
@@ -108,14 +207,13 @@ extension MacVMEngine {
|
||||
|
||||
// Run the guest enroll step (root; trusts the CA, pins /etc/hosts, opens the profile). This
|
||||
// runs through the agent on a normally-booted guest, where the provision share is reachable at
|
||||
// its automount path (the base build's space-free `provisionMountPoint` is specific to the
|
||||
// HID-driven bootstrap and is unmounted before the base is sealed).
|
||||
// its automount path. Mode A addresses the maintenance instance directly because base VMs
|
||||
// deliberately never enter the ordinary session registry.
|
||||
let scriptGuestPath =
|
||||
Self.guestSharePath(os: .macOS, name: Self.provisionShareName)
|
||||
+ "/\(MDMServerController.Artifact.enrollScript)"
|
||||
let enroll = try await run(
|
||||
name: name, command: "sudo /bin/bash \(Self.shQuote(scriptGuestPath))",
|
||||
workdir: nil)
|
||||
let enroll = try await runMDMGuest(
|
||||
target, command: "sudo /bin/bash \(Self.shQuote(scriptGuestPath))")
|
||||
guard enroll.exitCode == 0, enroll.stdout.contains("MDM_ENROLL_STAGED") else {
|
||||
throw MDMOrchestrationError.guestCommandFailed(
|
||||
"mdm-enroll.sh exit \(enroll.exitCode): \(enroll.stderr.suffix(400))")
|
||||
@@ -123,7 +221,7 @@ extension MacVMEngine {
|
||||
log.append("guest enroll staged (CA trusted, /etc/hosts pinned, profile opened)")
|
||||
|
||||
// Drive the UAMDM approval through System Settings ▸ Device Management (spike §8.3).
|
||||
try await approveEnrollmentViaComputerUse(name: name, surface: surface)
|
||||
try await approveEnrollmentViaComputerUse(target: target, surface: surface)
|
||||
log.append("drove System Settings enrollment approval")
|
||||
|
||||
// Wait for the device's post-TokenUpdate poll to drain the queue (no APNs).
|
||||
@@ -132,13 +230,28 @@ extension MacVMEngine {
|
||||
let pending = await controller.queue.pendingCount
|
||||
throw MDMOrchestrationError.drainTimedOut(pending: pending)
|
||||
}
|
||||
let installed = await controller.queue.acknowledgedCount
|
||||
log.append("drained \(installed) InstallProfile command(s)")
|
||||
let installed = try await acknowledgedProfileCount(
|
||||
commandUUIDs: commandUUIDs, controller: controller)
|
||||
log.append("acknowledged \(installed) InstallProfile command(s)")
|
||||
|
||||
// Verify enrollment + installed profiles from the guest's own view.
|
||||
let enrolled = await verifyEnrolled(name: name)
|
||||
log.append(enrolled ? "profiles status: User Approved" : "WARNING: enrollment not User-Approved")
|
||||
return MDMOutcome(enrolled: enrolled, profilesInstalled: installed, log: log)
|
||||
// Verify enrollment + exact installed profile identifiers from the guest's own view.
|
||||
let enrolled = await verifyEnrolled(target: target)
|
||||
guard enrolled else {
|
||||
throw MDMOrchestrationError.enrollmentNotApproved(
|
||||
"`profiles status -type enrollment` did not report User Approved")
|
||||
}
|
||||
log.append("profiles status: User Approved")
|
||||
let requiredIdentifiers = try Self.configurationProfileIdentifiers(in: profiles)
|
||||
let missingIdentifiers = try await missingInstalledProfileIdentifiers(
|
||||
target: target, requiredIdentifiers: requiredIdentifiers)
|
||||
guard missingIdentifiers.isEmpty else {
|
||||
throw MDMOrchestrationError.profilesMissing(missingIdentifiers)
|
||||
}
|
||||
log.append("verified installed profiles: \(requiredIdentifiers.joined(separator: ", "))")
|
||||
// Persist identifiers only after both MDM acknowledgements and the guest inventory agree.
|
||||
try controller.withProfileIdentifiers(requiredIdentifiers)
|
||||
.writePersistedState(to: bundle.mdmStateURL)
|
||||
return MDMOutcome(enrolled: true, profilesInstalled: installed, log: log)
|
||||
}
|
||||
|
||||
// MARK: - Mode B — push to a live clone
|
||||
@@ -155,7 +268,7 @@ extension MacVMEngine {
|
||||
timeout: TimeInterval = 90
|
||||
) async throws -> MDMOutcome {
|
||||
var log: [String] = []
|
||||
await controller.queue.enqueue(profiles.map { MDMCommand.installProfile($0) })
|
||||
let commandUUIDs = await controller.queueInstallProfiles(profiles)
|
||||
|
||||
let server = controller.makeServer(log: { line in NSLog("[mdm] %@", line) })
|
||||
let ipSANs = hostBindAddress == "127.0.0.1" ? [] : [hostBindAddress]
|
||||
@@ -179,8 +292,9 @@ extension MacVMEngine {
|
||||
guard drained else {
|
||||
throw MDMOrchestrationError.drainTimedOut(pending: await controller.queue.pendingCount)
|
||||
}
|
||||
let installed = await controller.queue.acknowledgedCount
|
||||
log.append("drained \(installed) InstallProfile command(s)")
|
||||
let installed = try await acknowledgedProfileCount(
|
||||
commandUUIDs: commandUUIDs, controller: controller)
|
||||
log.append("acknowledged \(installed) InstallProfile command(s)")
|
||||
return MDMOutcome(enrolled: true, profilesInstalled: installed, log: log)
|
||||
}
|
||||
|
||||
@@ -206,15 +320,75 @@ extension MacVMEngine {
|
||||
return "no candidate trigger succeeded (spike §8.2)"
|
||||
}
|
||||
|
||||
/// Whether the guest reports User-Approved MDM enrollment.
|
||||
/// Return required top-level profile identifiers absent from the guest's machine-level profile
|
||||
/// inventory. The XML form is requested so matching stable identifiers is locale-independent.
|
||||
private func missingInstalledProfileIdentifiers(
|
||||
target: MDMGuestTarget, requiredIdentifiers: [String]
|
||||
) async throws -> [String] {
|
||||
let result = try await runMDMGuest(
|
||||
target, command: "sudo profiles show -type configuration -output stdout-xml")
|
||||
guard result.exitCode == 0 else {
|
||||
throw MDMOrchestrationError.guestCommandFailed(
|
||||
"profiles inventory exit \(result.exitCode): \(result.stderr.suffix(400))")
|
||||
}
|
||||
let inventory = result.stdout + result.stderr
|
||||
return requiredIdentifiers.filter { !inventory.contains($0) }
|
||||
}
|
||||
|
||||
/// Whether a registered session guest reports User-Approved MDM enrollment.
|
||||
func verifyEnrolled(name: String) async -> Bool {
|
||||
guard let result = try? await run(
|
||||
name: name, command: "profiles status -type enrollment", workdir: nil)
|
||||
await verifyEnrolled(target: .registered(name: name))
|
||||
}
|
||||
|
||||
private func verifyEnrolled(target: MDMGuestTarget) async -> Bool {
|
||||
guard let result = try? await runMDMGuest(
|
||||
target, command: "profiles status -type enrollment")
|
||||
else { return false }
|
||||
let text = (result.stdout + result.stderr).lowercased()
|
||||
return text.contains("user approved") || text.contains("mdm enrollment: yes")
|
||||
}
|
||||
|
||||
/// Execute one bounded housekeeping command through either the ordinary registry path or the
|
||||
/// direct base-maintenance instance. Direct execution reuses the same vsock/nash transport.
|
||||
private func runMDMGuest(
|
||||
_ target: MDMGuestTarget, command: String
|
||||
) async throws -> (exitCode: Int32, stdout: String, stderr: String) {
|
||||
switch target {
|
||||
case .registered(let name):
|
||||
return try await run(
|
||||
name: name, command: command, workdir: nil,
|
||||
timeoutNanos: 60 * 1_000_000_000)
|
||||
#if arch(arm64)
|
||||
case .instance(let instance, let name):
|
||||
let channel = try await openExecChannel(
|
||||
instance: instance, label: name, workdir: nil, env: [:], remoteBody: command)
|
||||
async let stdout = Self.drainLines(channel.stdoutLines)
|
||||
async let stderr = Self.drainLines(channel.stderrLines)
|
||||
let outcome = await Self.awaitExit(channel, timeoutNanos: 60 * 1_000_000_000)
|
||||
let output = await stdout
|
||||
let errors = await stderr
|
||||
return Self.finalize(
|
||||
outcome, stdout: output, stderr: errors,
|
||||
timeoutNanos: 60 * 1_000_000_000)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
/// Require every command in this exact enqueue batch to finish as `Acknowledged`. A queue can be
|
||||
/// drained after an `Error`, so drain alone is not a policy-installation success signal.
|
||||
private func acknowledgedProfileCount(
|
||||
commandUUIDs: [String], controller: MDMServerController
|
||||
) async throws -> Int {
|
||||
let completed = await controller.queue.completionStatuses(for: commandUUIDs)
|
||||
let statuses = Dictionary(uniqueKeysWithValues: commandUUIDs.map { uuid in
|
||||
(uuid, completed[uuid] ?? "Missing")
|
||||
})
|
||||
guard statuses.values.allSatisfy({ $0 == "Acknowledged" }) else {
|
||||
throw MDMOrchestrationError.policyCommandsFailed(statuses)
|
||||
}
|
||||
return statuses.count
|
||||
}
|
||||
|
||||
/// Poll the queue until it drains or the deadline passes.
|
||||
private func waitForDrain(controller: MDMServerController, timeout: TimeInterval) async -> Bool {
|
||||
let deadline = Date().addingTimeInterval(timeout)
|
||||
@@ -230,17 +404,16 @@ extension MacVMEngine {
|
||||
/// macOS 27 is the §8.3 hardware spike. Uses the same synthesized-HID surface the provisioning
|
||||
/// bootstrap already uses to launch Terminal.
|
||||
private func approveEnrollmentViaComputerUse(
|
||||
name: String, surface: any MacVMSurfaceHost
|
||||
target: MDMGuestTarget, surface: any MacVMSurfaceHost
|
||||
) async throws {
|
||||
// Open the Profiles/Device Management pane directly, then let the operator-recorded sequence
|
||||
// (filled in during the spike) click Install ▸ enter the admin password ▸ confirm.
|
||||
_ = try? await run(
|
||||
name: name,
|
||||
command: "open 'x-apple.systempreferences:com.apple.preferences.configurationprofiles'",
|
||||
workdir: nil)
|
||||
_ = try? await runMDMGuest(
|
||||
target,
|
||||
command: "open 'x-apple.systempreferences:com.apple.preferences.configurationprofiles'")
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
// Placeholder approval sequence — REPLACE with the spike-verified coordinates/keys (§8.3).
|
||||
// The pending profile appears in the pane; double-clicking it opens the Install sheet.
|
||||
await surface.send(name: name, .key(chord: "return"))
|
||||
await surface.send(name: target.name, .key(chord: "return"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,6 +75,15 @@ public actor MDMCommandQueue {
|
||||
public var inFlightUUID: String? { inFlight?.uuid }
|
||||
public var completedCommands: [(command: MDMCommand, status: String)] { completed }
|
||||
|
||||
/// Terminal status for each requested command that has completed. Callers retain the UUIDs from
|
||||
/// their own enqueue batch so older acknowledgements cannot make a later policy push look valid.
|
||||
public func completionStatuses(for commandUUIDs: [String]) -> [String: String] {
|
||||
let wanted = Set(commandUUIDs)
|
||||
return Dictionary(uniqueKeysWithValues: completed.compactMap { entry in
|
||||
wanted.contains(entry.command.uuid) ? (entry.command.uuid, entry.status) : nil
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether every enqueued command has completed and none is in flight — the signal Mode A waits
|
||||
/// on before sealing the base.
|
||||
public var isDrained: Bool { pending.isEmpty && inFlight == nil }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Crypto
|
||||
import Foundation
|
||||
import SwiftASN1
|
||||
import X509
|
||||
import _CryptoExtras
|
||||
|
||||
@@ -35,6 +36,36 @@ public struct MDMDeviceIdentity: Sendable {
|
||||
certificate: cert, privateKey: key, password: randomPassword())
|
||||
}
|
||||
|
||||
// MARK: - Persistence (host-side only)
|
||||
|
||||
/// Codable representation retained beside the golden base. The private key and PKCS#12 password
|
||||
/// are secrets and must never be copied into a session clone or logged.
|
||||
public struct Persisted: Codable, Sendable {
|
||||
public var certificatePEM: String
|
||||
public var privateKeyPEM: String
|
||||
public var password: String
|
||||
|
||||
public init(certificatePEM: String, privateKeyPEM: String, password: String) {
|
||||
self.certificatePEM = certificatePEM
|
||||
self.privateKeyPEM = privateKeyPEM
|
||||
self.password = password
|
||||
}
|
||||
}
|
||||
|
||||
public func persisted() throws -> Persisted {
|
||||
Persisted(
|
||||
certificatePEM: try certificate.serializeAsPEM().pemString,
|
||||
privateKeyPEM: try Certificate.PrivateKey(privateKey).serializeAsPEM().pemString,
|
||||
password: password)
|
||||
}
|
||||
|
||||
public init(persisted p: Persisted) throws {
|
||||
let certificate = try Certificate(pemEncoded: p.certificatePEM)
|
||||
let document = try PEMDocument(pemString: p.privateKeyPEM)
|
||||
let privateKey = try _RSA.Signing.PrivateKey(derRepresentation: document.derBytes)
|
||||
self.init(certificate: certificate, privateKey: privateKey, password: p.password)
|
||||
}
|
||||
|
||||
/// The identity as a PKCS#12 blob for the profile payload.
|
||||
public func pkcs12() throws -> Data {
|
||||
try PKCS12.export(certificate: certificate, privateKey: privateKey, password: password)
|
||||
|
||||
@@ -16,6 +16,8 @@ public struct MDMServerController: Sendable {
|
||||
public let ca: MDMCertificateAuthority
|
||||
public let config: MDMConfiguration
|
||||
public let deviceIdentity: MDMDeviceIdentity
|
||||
/// Top-level policy identifiers last verified in the enrolled base. Empty before first success.
|
||||
public let profileIdentifiers: [String]
|
||||
public let queue: MDMCommandQueue
|
||||
|
||||
/// Staged-artifact filenames (shared by the generator here and the enroll script).
|
||||
@@ -38,14 +40,92 @@ public struct MDMServerController: Sendable {
|
||||
|
||||
public init(
|
||||
ca: MDMCertificateAuthority, config: MDMConfiguration,
|
||||
deviceIdentity: MDMDeviceIdentity, queue: MDMCommandQueue = MDMCommandQueue()
|
||||
deviceIdentity: MDMDeviceIdentity, profileIdentifiers: [String] = [],
|
||||
queue: MDMCommandQueue = MDMCommandQueue()
|
||||
) {
|
||||
self.ca = ca
|
||||
self.config = config
|
||||
self.deviceIdentity = deviceIdentity
|
||||
self.profileIdentifiers = profileIdentifiers
|
||||
self.queue = queue
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
|
||||
/// Complete host-side state required to contact an enrolled base or one of its clones later.
|
||||
/// Versioning makes future migrations explicit instead of silently replacing a trusted identity.
|
||||
public struct Persisted: Codable, Sendable {
|
||||
public var version: Int
|
||||
public var ca: MDMCertificateAuthority.Persisted
|
||||
public var config: MDMConfiguration
|
||||
public var deviceIdentity: MDMDeviceIdentity.Persisted
|
||||
public var profileIdentifiers: [String]
|
||||
|
||||
public init(
|
||||
version: Int = 1, ca: MDMCertificateAuthority.Persisted,
|
||||
config: MDMConfiguration, deviceIdentity: MDMDeviceIdentity.Persisted,
|
||||
profileIdentifiers: [String] = []
|
||||
) {
|
||||
self.version = version
|
||||
self.ca = ca
|
||||
self.config = config
|
||||
self.deviceIdentity = deviceIdentity
|
||||
self.profileIdentifiers = profileIdentifiers
|
||||
}
|
||||
}
|
||||
|
||||
public enum PersistenceError: Error, CustomStringConvertible {
|
||||
case unsupportedVersion(Int)
|
||||
|
||||
public var description: String {
|
||||
switch self {
|
||||
case .unsupportedVersion(let version):
|
||||
return "unsupported MDM controller state version \(version)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func persisted() throws -> Persisted {
|
||||
try Persisted(
|
||||
ca: ca.persisted(), config: config, deviceIdentity: deviceIdentity.persisted(),
|
||||
profileIdentifiers: profileIdentifiers)
|
||||
}
|
||||
|
||||
/// Return the same cryptographic identity and live queue with a newly verified policy inventory.
|
||||
public func withProfileIdentifiers(_ identifiers: [String]) -> MDMServerController {
|
||||
MDMServerController(
|
||||
ca: ca, config: config, deviceIdentity: deviceIdentity,
|
||||
profileIdentifiers: identifiers, queue: queue)
|
||||
}
|
||||
|
||||
public init(persisted state: Persisted, queue: MDMCommandQueue = MDMCommandQueue()) throws {
|
||||
guard state.version == 1 else {
|
||||
throw PersistenceError.unsupportedVersion(state.version)
|
||||
}
|
||||
self.init(
|
||||
ca: try MDMCertificateAuthority(persisted: state.ca), config: state.config,
|
||||
deviceIdentity: try MDMDeviceIdentity(persisted: state.deviceIdentity),
|
||||
profileIdentifiers: state.profileIdentifiers, queue: queue)
|
||||
}
|
||||
|
||||
/// Atomically write the controller state and restrict it to the current host user. This file
|
||||
/// contains the CA and device private keys; it belongs beside the base, never in the guest share.
|
||||
public func writePersistedState(to url: URL) throws {
|
||||
let fm = FileManager.default
|
||||
try fm.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
try encoder.encode(persisted()).write(to: url, options: .atomic)
|
||||
try fm.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
|
||||
}
|
||||
|
||||
public static func loadPersistedState(
|
||||
from url: URL, queue: MDMCommandQueue = MDMCommandQueue()
|
||||
) throws -> MDMServerController {
|
||||
let state = try JSONDecoder().decode(Persisted.self, from: Data(contentsOf: url))
|
||||
return try MDMServerController(persisted: state, queue: queue)
|
||||
}
|
||||
|
||||
// MARK: - Artifacts
|
||||
|
||||
/// The enrollment `.mobileconfig` bytes.
|
||||
@@ -115,9 +195,12 @@ public struct MDMServerController: Sendable {
|
||||
|
||||
// MARK: - Queue helpers
|
||||
|
||||
/// Queue `InstallProfile` for each payload (the notification-fix profiles in Mode A).
|
||||
public func queueInstallProfiles(_ profiles: [Data]) async {
|
||||
await queue.enqueue(profiles.map { MDMCommand.installProfile($0) })
|
||||
/// Queue `InstallProfile` for each payload and return the command UUIDs for exact verification.
|
||||
@discardableResult
|
||||
public func queueInstallProfiles(_ profiles: [Data]) async -> [String] {
|
||||
let commands = profiles.map { MDMCommand.installProfile($0) }
|
||||
await queue.enqueue(commands)
|
||||
return commands.map(\.uuid)
|
||||
}
|
||||
|
||||
// MARK: - Server (Darwin only)
|
||||
|
||||
@@ -347,6 +347,13 @@ import Testing
|
||||
#expect(desc.hasPrefix("Failed to provision the base VM:"))
|
||||
}
|
||||
|
||||
@Test func baseBundleKeepsMDMCredentialsHostSideBesideMetadata() {
|
||||
let bundle = MacVMBundle(root: URL(fileURLWithPath: "/tmp/example.bundle"))
|
||||
#expect(bundle.mdmStateURL.lastPathComponent == "mdm-state.json")
|
||||
#expect(bundle.mdmStateURL.deletingLastPathComponent() == bundle.root)
|
||||
#expect(bundle.mdmStateURL != bundle.metadataURL)
|
||||
}
|
||||
|
||||
// MARK: - Base status (bundle.json ⇄ MacVMBaseStatus, provisioning readback)
|
||||
|
||||
@Test func baseStatusJSONRoundTripsAndDefaultsMissingKeys() {
|
||||
@@ -1468,6 +1475,33 @@ import Testing
|
||||
|
||||
// MARK: - Built-in MDM policy assets
|
||||
|
||||
@Test func builtInMDMPoliciesExposeExactTopLevelIdentifiers() throws {
|
||||
let repoRoot = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent()
|
||||
let urls = try #require(MacVMEngine.builtInMDMPolicyProfileURLs(
|
||||
bundleResourceURL: nil, devRepoRoot: repoRoot))
|
||||
let identifiers = try MacVMEngine.configurationProfileIdentifiers(
|
||||
in: urls.map { try Data(contentsOf: $0) })
|
||||
#expect(identifiers == [
|
||||
"xyz.blakeslee.nucleic.pppc.vmagent",
|
||||
"xyz.blakeslee.nucleic.notifications.suppress",
|
||||
])
|
||||
}
|
||||
|
||||
@Test func configurationProfileIdentifierRejectsNestedOrMalformedPayloads() throws {
|
||||
let nestedOnly = try PropertyListSerialization.data(
|
||||
fromPropertyList: [
|
||||
"PayloadType": "Configuration",
|
||||
"PayloadContent": [["PayloadIdentifier": "nested.is.not.enough"]],
|
||||
], format: .xml, options: 0)
|
||||
#expect(throws: MacVMEngine.MDMOrchestrationError.self) {
|
||||
_ = try MacVMEngine.configurationProfileIdentifiers(in: [nestedOnly])
|
||||
}
|
||||
#expect(throws: MacVMEngine.MDMOrchestrationError.self) {
|
||||
_ = try MacVMEngine.configurationProfileIdentifiers(in: [Data("not a plist".utf8)])
|
||||
}
|
||||
}
|
||||
|
||||
@Test func builtInMDMPolicyResolutionRequiresACompleteSet() throws {
|
||||
let root = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("mdm-policy-\(UUID().uuidString)", isDirectory: true)
|
||||
|
||||
@@ -110,4 +110,23 @@ final class MDMCommandQueueTests: XCTestCase {
|
||||
let flight = await q.inFlightUUID
|
||||
XCTAssertEqual(flight, "X") // still in flight
|
||||
}
|
||||
|
||||
func testCompletionStatusesAreScopedToTheRequestedBatch() async throws {
|
||||
let q = MDMCommandQueue()
|
||||
await q.enqueue([
|
||||
MDMCommand.installProfile(Data("old".utf8), uuid: "OLD"),
|
||||
MDMCommand.installProfile(Data("ok".utf8), uuid: "OK"),
|
||||
MDMCommand.installProfile(Data("bad".utf8), uuid: "BAD"),
|
||||
])
|
||||
_ = await q.next(afterReport: idle())
|
||||
_ = await q.next(afterReport: ack("OLD"))
|
||||
_ = await q.next(afterReport: ack("OK"))
|
||||
_ = await q.next(afterReport:
|
||||
MDMCommandStatusReport(status: "Error", commandUUID: "BAD", udid: "DEV"))
|
||||
|
||||
let batch = await q.completionStatuses(for: ["OK", "BAD", "MISSING"])
|
||||
XCTAssertEqual(batch, ["OK": "Acknowledged", "BAD": "Error"])
|
||||
XCTAssertNil(batch["OLD"])
|
||||
XCTAssertNil(batch["MISSING"])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,10 +64,56 @@ final class MDMServerControllerTests: XCTestCase {
|
||||
XCTAssertGreaterThan(der.count, 300)
|
||||
}
|
||||
|
||||
func testQueueInstallProfilesEnqueues() async throws {
|
||||
func testQueueInstallProfilesEnqueuesAndReturnsBatchIDs() async throws {
|
||||
let controller = try MDMServerController.makeFresh()
|
||||
await controller.queueInstallProfiles([Data("a".utf8), Data("b".utf8)])
|
||||
let ids = await controller.queueInstallProfiles([Data("a".utf8), Data("b".utf8)])
|
||||
let count = await controller.queue.pendingCount
|
||||
XCTAssertEqual(count, 2)
|
||||
XCTAssertEqual(Set(ids).count, 2)
|
||||
}
|
||||
|
||||
func testPersistedControllerRoundTripsIdentityAndRestrictsFilePermissions() async throws {
|
||||
let controller = try MDMServerController.makeFresh(
|
||||
config: .generate(port: 9443, topicUUID: UUID(
|
||||
uuidString: "11111111-2222-3333-4444-555555555555")!))
|
||||
.withProfileIdentifiers([
|
||||
"xyz.blakeslee.nucleic.pppc.vmagent",
|
||||
"xyz.blakeslee.nucleic.notifications.suppress",
|
||||
])
|
||||
let dir = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("nucleic-mdm-state-\(UUID().uuidString)")
|
||||
defer { try? FileManager.default.removeItem(at: dir) }
|
||||
let stateURL = dir.appendingPathComponent("mdm-state.json")
|
||||
|
||||
try controller.writePersistedState(to: stateURL)
|
||||
let restored = try MDMServerController.loadPersistedState(from: stateURL)
|
||||
|
||||
XCTAssertEqual(restored.config, controller.config)
|
||||
XCTAssertEqual(try restored.caCertificateDER(), try controller.caCertificateDER())
|
||||
XCTAssertEqual(
|
||||
try restored.deviceIdentity.persisted().certificatePEM,
|
||||
try controller.deviceIdentity.persisted().certificatePEM)
|
||||
XCTAssertEqual(restored.deviceIdentity.password, controller.deviceIdentity.password)
|
||||
XCTAssertEqual(restored.profileIdentifiers, controller.profileIdentifiers)
|
||||
|
||||
// The reloaded private key still signs as the same device identity under the persisted CA.
|
||||
let body = Data("persisted identity".utf8)
|
||||
let signature = try MDMSignatureVerifier.sign(body: body, identity: restored.deviceIdentity)
|
||||
let signatureVerified = await MDMSignatureVerifier.verify(
|
||||
body: body, detachedCMS: signature, ca: restored.ca)
|
||||
XCTAssertTrue(signatureVerified)
|
||||
|
||||
let attrs = try FileManager.default.attributesOfItem(atPath: stateURL.path)
|
||||
let permissions = (attrs[.posixPermissions] as? NSNumber)?.intValue
|
||||
XCTAssertEqual(permissions.map { $0 & 0o777 }, 0o600)
|
||||
}
|
||||
|
||||
func testPersistedControllerRejectsUnknownVersion() throws {
|
||||
let controller = try MDMServerController.makeFresh()
|
||||
var state = try controller.persisted()
|
||||
state.version = 999
|
||||
XCTAssertThrowsError(try MDMServerController(persisted: state)) { error in
|
||||
XCTAssertTrue(String(describing: error).contains("version 999"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,12 +86,12 @@ remains optional until the lightweight MDM supports Declarative Device Managemen
|
||||
1. declarative account creation and minimal exec-agent install through the automount;
|
||||
2. MDM enrollment and required policy installation;
|
||||
3. full toolchain provisioning after policy is active.
|
||||
- [ ] Refactor enrollment to operate on a direct `MacVMInstance`.
|
||||
- [ ] Persist the MDM CA, configuration, device identity, and profile identifiers beside the base
|
||||
- [x] Refactor enrollment to operate on a direct `MacVMInstance`.
|
||||
- [x] Persist the MDM CA, configuration, device identity, and profile identifiers beside the base
|
||||
metadata with credential-appropriate permissions.
|
||||
- [ ] Replace the UAMDM placeholder with a bounded, state-aware Device Management flow that enters
|
||||
the generated guest password only in the expected authentication sheet.
|
||||
- [ ] Treat enrollment false, command errors, missing profile identifiers, or failed functional
|
||||
- [x] Treat enrollment false, command errors, missing profile identifiers, or failed functional
|
||||
probes as fatal.
|
||||
- [ ] Make re-provisioning update policy without attempting a second enrollment.
|
||||
- [ ] Require `mdmEnrolled`, required profiles, agent readiness, and a production-shaped workspace
|
||||
@@ -127,6 +127,20 @@ A base is releasable only when a clean macOS 27 build proves:
|
||||
- re-provisioning performs no duplicate enrollment;
|
||||
- a fresh clone boots, mounts, executes, and shuts down unattended.
|
||||
|
||||
## Implementation checkpoint — persisted Mode A state
|
||||
|
||||
- `mdm-state.json` now retains a versioned CA, configuration, device certificate/private key,
|
||||
PKCS#12 password, and the profile identifiers verified in the guest. It is mode `0600`, remains in
|
||||
the golden-base directory, and is deliberately excluded by the explicit clone copier.
|
||||
- Mode A accepts a direct maintenance `MacVMInstance` and uses the existing bounded vsock/nash exec
|
||||
channel instead of requiring the base to appear in the ordinary session registry.
|
||||
- A drained MDM queue is no longer sufficient: every UUID from the current policy batch must report
|
||||
`Acknowledged`, enrollment must report User Approved, and the guest's XML profile inventory must
|
||||
contain the exact top-level identifiers before they are persisted.
|
||||
- The production provisioning call site remains intentionally disabled until the macOS 27 UAMDM UI
|
||||
flow and guest-to-host listener binding are validated; enabling the current placeholder would
|
||||
replace one unattended prompt failure with another.
|
||||
|
||||
## Verification notes
|
||||
|
||||
- The two existing policy files parse as configuration-profile plists.
|
||||
|
||||
Reference in New Issue
Block a user