164 lines
7.2 KiB
Swift
164 lines
7.2 KiB
Swift
import Foundation
|
|
|
|
/// The MDM **check-in** messages a device PUTs to `/checkin` (docs/MACOS_VM_MDM.md §4). Bodies are
|
|
/// XML plists carrying a `MessageType`. We model only the three the guest actually sends in our flow.
|
|
public enum MDMCheckInMessage: Sendable, Equatable {
|
|
/// First contact: the device offers its UDID (and the profile's Topic). The server accepts.
|
|
case authenticate(udid: String, topic: String?)
|
|
/// The device reports its APNs push token + magic. This is the **pivot**: after a successful
|
|
/// `TokenUpdate` the device immediately polls `/server`, which is where the queued
|
|
/// `InstallProfile` commands drain with no APNs (the load-bearing §2.4 assumption).
|
|
case tokenUpdate(udid: String, pushMagic: String?, tokenHex: String?)
|
|
/// The device is unenrolling (profile removed / `CheckOutWhenRemoved`).
|
|
case checkOut(udid: String)
|
|
/// Any other check-in `MessageType` we don't model (`SetBootstrapToken`, `GetBootstrapToken`,
|
|
/// `DeclarativeManagement`, …). These are acked with an empty 200 rather than rejected: a 400 on
|
|
/// the check-in channel aborts enrollment, and modern `mdmclient` sends several of these during a
|
|
/// normal handshake (finding 8 / Workstream 2.2).
|
|
case other(type: String, udid: String)
|
|
|
|
/// Parse a check-in plist body. Only a body that is not a property list with a `MessageType` is a
|
|
/// hard error; an unrecognized type is carried as `.other` so the server can ack it.
|
|
public static func parse(_ body: Data) throws -> MDMCheckInMessage {
|
|
let dict = try MDMPlist.parse(body)
|
|
guard let type = dict["MessageType"] as? String else {
|
|
throw MDMProtocolError.malformed("check-in body has no MessageType")
|
|
}
|
|
let udid = (dict["UDID"] as? String) ?? (dict["EnrollmentID"] as? String) ?? ""
|
|
switch type {
|
|
case "Authenticate":
|
|
return .authenticate(udid: udid, topic: dict["Topic"] as? String)
|
|
case "TokenUpdate":
|
|
let tokenHex = (dict["Token"] as? Data).map { $0.map { String(format: "%02x", $0) }.joined() }
|
|
return .tokenUpdate(
|
|
udid: udid, pushMagic: dict["PushMagic"] as? String, tokenHex: tokenHex)
|
|
case "CheckOut":
|
|
return .checkOut(udid: udid)
|
|
default:
|
|
return .other(type: type, udid: udid)
|
|
}
|
|
}
|
|
|
|
public var udid: String {
|
|
switch self {
|
|
case .authenticate(let u, _), .tokenUpdate(let u, _, _), .checkOut(let u),
|
|
.other(_, let u):
|
|
return u
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A device's status report on the command channel (`/server`). The device PUTs one of these; the
|
|
/// server answers with the next command (or an empty 200 to end the poll loop).
|
|
public struct MDMCommandStatusReport: Sendable, Equatable {
|
|
/// `Idle` (ready for the next command), `Acknowledged`, `Error`, `CommandFormatError`, or
|
|
/// `NotNow` (busy — retry later).
|
|
public var status: String
|
|
/// The command being reported on (absent for the initial `Idle`).
|
|
public var commandUUID: String?
|
|
public var udid: String
|
|
/// A one-line summary of the `ErrorChain` when `status == "Error"`, for logging. Flattened to a
|
|
/// string so the report stays `Sendable` (the raw plist is `[[String: Any]]`).
|
|
public var errorSummary: String?
|
|
|
|
public var isIdle: Bool { status == "Idle" }
|
|
public var isTerminal: Bool { status == "Acknowledged" || status == "Error" }
|
|
|
|
public static func parse(_ body: Data) throws -> MDMCommandStatusReport {
|
|
let dict = try MDMPlist.parse(body)
|
|
guard let status = dict["Status"] as? String else {
|
|
throw MDMProtocolError.malformed("command report has no Status")
|
|
}
|
|
let udid = (dict["UDID"] as? String) ?? (dict["EnrollmentID"] as? String) ?? ""
|
|
var summary: String?
|
|
if let chain = dict["ErrorChain"] as? [[String: Any]], !chain.isEmpty {
|
|
summary = chain.compactMap { entry in
|
|
(entry["USEnglishDescription"] as? String) ?? (entry["LocalizedDescription"] as? String)
|
|
}.joined(separator: "; ")
|
|
}
|
|
return MDMCommandStatusReport(
|
|
status: status, commandUUID: dict["CommandUUID"] as? String, udid: udid,
|
|
errorSummary: summary)
|
|
}
|
|
|
|
public init(status: String, commandUUID: String?, udid: String, errorSummary: String? = nil) {
|
|
self.status = status
|
|
self.commandUUID = commandUUID
|
|
self.udid = udid
|
|
self.errorSummary = errorSummary
|
|
}
|
|
|
|
public static func == (l: MDMCommandStatusReport, r: MDMCommandStatusReport) -> Bool {
|
|
l.status == r.status && l.commandUUID == r.commandUUID && l.udid == r.udid
|
|
}
|
|
}
|
|
|
|
/// One MDM command the server delivers on the `/server` channel. We implement the two the
|
|
/// notification fix needs — `InstallProfile` and `RemoveProfile` — plus a generic escape hatch for
|
|
/// later payload types (P3).
|
|
public struct MDMCommand: Sendable, Equatable {
|
|
public var uuid: String
|
|
public var requestType: String
|
|
/// Extra keys merged into the `Command` dict (e.g. `Payload`, `Identifier`). Kept as an ordered
|
|
/// list of (key, value) so serialization is deterministic for tests; values are plist-safe.
|
|
private var fields: [(String, PlistValue)]
|
|
|
|
public init(uuid: String = UUID().uuidString, requestType: String, fields: [(String, PlistValue)] = []) {
|
|
self.uuid = uuid
|
|
self.requestType = requestType
|
|
self.fields = fields
|
|
}
|
|
|
|
/// `InstallProfile` — installs the given `.mobileconfig` bytes (the PPPC / notifications payloads).
|
|
public static func installProfile(_ profile: Data, uuid: String = UUID().uuidString) -> MDMCommand {
|
|
MDMCommand(uuid: uuid, requestType: "InstallProfile", fields: [("Payload", .data(profile))])
|
|
}
|
|
|
|
/// `RemoveProfile` — removes an installed profile by its top-level `PayloadIdentifier`.
|
|
public static func removeProfile(identifier: String, uuid: String = UUID().uuidString) -> MDMCommand {
|
|
MDMCommand(
|
|
uuid: uuid, requestType: "RemoveProfile", fields: [("Identifier", .string(identifier))])
|
|
}
|
|
|
|
/// Serialize to the wire plist: `{ CommandUUID, Command: { RequestType, …fields } }`.
|
|
public func plist() throws -> Data {
|
|
var command: [String: Any] = ["RequestType": requestType]
|
|
for (k, v) in fields { command[k] = v.any }
|
|
let top: [String: Any] = ["CommandUUID": uuid, "Command": command]
|
|
return try MDMPlist.serialize(top)
|
|
}
|
|
|
|
public static func == (l: MDMCommand, r: MDMCommand) -> Bool {
|
|
l.uuid == r.uuid && l.requestType == r.requestType
|
|
}
|
|
}
|
|
|
|
/// A minimal plist-value enum so `MDMCommand` fields carry only encodable, deterministic values.
|
|
public enum PlistValue: Sendable {
|
|
case string(String)
|
|
case data(Data)
|
|
case bool(Bool)
|
|
case int(Int)
|
|
|
|
var any: Any {
|
|
switch self {
|
|
case .string(let s): return s
|
|
case .data(let d): return d
|
|
case .bool(let b): return b
|
|
case .int(let i): return i
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum MDMProtocolError: Error, CustomStringConvertible {
|
|
case malformed(String)
|
|
case unsupported(String)
|
|
|
|
public var description: String {
|
|
switch self {
|
|
case .malformed(let s): return "malformed MDM message: \(s)"
|
|
case .unsupported(let s): return s
|
|
}
|
|
}
|
|
}
|