import Foundation /// HTTP-Client für NanoMDM (https://mdm.rebreak.org). /// Schickt Commands via POST /v1/enqueue/ mit Basic-Auth. /// /// Body-Format: Apple-XML-Plist mit Top-Level-Dict {CommandUUID, Command}. /// (NICHT JSON — NanoMDM erwartet Plist und versucht den Body als solches zu parsen.) /// /// Config wird aus ~/.config/rebreak-binder/config.json gelesen. struct MDMConfig: Codable { var mdmServer: String var mdmUser: String var mdmApiKey: String } enum MDMClientError: Error, LocalizedError { case configMissing(String) case configMalformed(String) case http(Int, String) case profileMissing(String) case encoding var errorDescription: String? { switch self { case .configMissing(let path): return "MDM-Config nicht gefunden: \(path). Bitte README → 'Config (lokal)'." case .configMalformed(let msg): return "MDM-Config ist kaputt: \(msg)" case .http(let status, let body): return "NanoMDM HTTP \(status): \(body)" case .profileMissing(let path): return "Profile-File nicht gefunden: \(path)" case .encoding: return "Plist-Encoding fehlgeschlagen." } } } enum MDMClient { static let configPath: String = { let home = FileManager.default.homeDirectoryForCurrentUser.path return "\(home)/.config/rebreak-binder/config.json" }() static let lockProfilePathCandidates: [String] = { let home = FileManager.default.homeDirectoryForCurrentUser.path return [ // MDM-Push-Variante OHNE ConsentText + OHNE allowAppRemoval=false // (App-Removal-Block kommt über managed-app-state, nicht global) "\(home)/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-mdm.mobileconfig", ] }() static func loadConfig() throws -> MDMConfig { let url = URL(fileURLWithPath: configPath) guard FileManager.default.fileExists(atPath: configPath) else { throw MDMClientError.configMissing(configPath) } do { let data = try Data(contentsOf: url) return try JSONDecoder().decode(MDMConfig.self, from: data) } catch { throw MDMClientError.configMalformed(error.localizedDescription) } } struct EnqueueResult { let commandUUID: String let responseBody: String } /// Wraps `command` in NanoMDM's expected envelope und schickt's an /// `/v1/enqueue/?push=1` (sofort-APNs, wie der server-watcher). /// Content-Type: `application/x-plist`. Returnt UUID + body. static func enqueue(udid: String, command: [String: Any]) async throws -> EnqueueResult { let cfg = try loadConfig() guard let url = URL(string: "\(cfg.mdmServer)/v1/enqueue/\(udid)?push=1") else { throw MDMClientError.configMalformed("server URL invalid") } let cmdUUID = UUID().uuidString let envelope: [String: Any] = [ "CommandUUID": cmdUUID, "Command": command, ] let plistData: Data do { plistData = try PropertyListSerialization.data(fromPropertyList: envelope, format: .xml, options: 0) } catch { throw MDMClientError.encoding } var req = URLRequest(url: url) req.httpMethod = "PUT" // wie der server-watcher req.setValue("application/x-plist", forHTTPHeaderField: "Content-Type") let creds = "\(cfg.mdmUser):\(cfg.mdmApiKey)" guard let credData = creds.data(using: .utf8) else { throw MDMClientError.encoding } req.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization") req.httpBody = plistData let (data, response) = try await URLSession.shared.data(for: req) let body = String(data: data, encoding: .utf8) ?? "" guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 throw MDMClientError.http(status, body) } return EnqueueResult(commandUUID: cmdUUID, responseBody: body) } // MARK: - High-level commands /// Take Management der bereits-installierten ReBreak-App (TestFlight). /// `ChangeManagementState=Managed` macht aus org.rebreak.app eine "managed app" /// (kein Wackel-X mehr auf supervised iPhones). /// Nur sinnvoll wenn App schon installiert ist — sonst no-op. static func takeManagement(udid: String, bundleID: String = "org.rebreak.app") async throws -> String { let cmd: [String: Any] = [ "RequestType": "InstallApplication", "Identifier": bundleID, "ChangeManagementState": "Managed", "ManagementFlags": 0, ] return try await enqueue(udid: udid, command: cmd).responseBody } /// Installiert die ReBreak-App via Ad-Hoc-Manifest (gehostet auf /// mdm.rebreak.org/install/manifest.plist). Auf supervised iPhones läuft /// das silent — iOS lädt die IPA aus dem Manifest + installiert sie direkt /// als managed-app. /// /// Format identisch zum server-side `install-trigger.sh`-Watcher: /// nur ManifestURL + ManagementFlags (keine zusätzlichen Felder). static func installApp( udid: String, manifestURL: String = "https://mdm.rebreak.org/install/manifest.plist" ) async throws -> String { let cmd: [String: Any] = [ "RequestType": "InstallApplication", "ManifestURL": manifestURL, "ManagementFlags": 0, ] return try await enqueue(udid: udid, command: cmd).responseBody } /// Entfernt eine App per MDM-Command (RemoveApplication). Funktioniert /// auf supervised-iPhones ohne User-Confirm. Returnt die EnqueueResult- /// commandUUID, damit Caller per readCommandResult auf Ack warten kann. static func removeApp(udid: String, bundleID: String = "org.rebreak.app") async throws -> EnqueueResult { let cmd: [String: Any] = [ "RequestType": "RemoveApplication", "Identifier": bundleID, ] return try await enqueue(udid: udid, command: cmd) } /// Pusht ManagedApplicationList-Query an iPhone (welche Apps managed?). /// Returnt die generierte CommandUUID — Caller liest danach via /// `MDMStatus.readCommandResult(udid:, commandUUID:)` das Ergebnis. static func queryManagedAppList(udid: String, bundleIDs: [String] = ["org.rebreak.app"]) async throws -> String { let cmd: [String: Any] = [ "RequestType": "ManagedApplicationList", "Identifiers": bundleIDs, ] return try await enqueue(udid: udid, command: cmd).commandUUID } /// Convenience: Push ManagedApplicationList, warte `waitSeconds`, /// lese result aus DB, parse ob `bundleID` State=Managed hat. /// Returnt nil wenn iPhone nicht ge-acked hat oder UDID nicht enrolled. static func checkAppIsManaged( udid: String, bundleID: String = "org.rebreak.app", waitSeconds: Int = 8 ) async throws -> Bool? { let cmdUUID = try await queryManagedAppList(udid: udid, bundleIDs: [bundleID]) try? await Task.sleep(for: .seconds(waitSeconds)) guard let result = try await MDMStatus.readCommandResult(udid: udid, commandUUID: cmdUUID) else { return nil // nicht ge-acked oder Channel tot } // Result-Plist enthält für jede App ein Dict mit Status-Field. // Schnell-Check: enthält das XML "Managed"-String unter einem bundleID-Block? // (Detail-parsing wäre besser, aber für MVP reicht string-search.) let lowercased = result.lowercased() return lowercased.contains("managed") } /// Settings-Command der die App in NEFilter-Mode schiebt (statt PacketTunnel-VPN). static func setSupervisedMode(udid: String, bundleID: String = "org.rebreak.app") async throws -> String { let cmd: [String: Any] = [ "RequestType": "Settings", "Settings": [ [ "Item": "ApplicationConfiguration", "Identifier": bundleID, "Configuration": [ "mdmSupervised": true, ], ], ], ] return try await enqueue(udid: udid, command: cmd).responseBody } /// Install des sideload-Lock-Profils (rebreak-content-filter-sideload.mobileconfig). /// Profile-Inhalt wird als `Payload`-Data eingebettet — PropertyListSerialization /// encoded es automatisch als `...` (base64). static func installLockProfile(udid: String) async throws -> String { guard let profilePath = lockProfilePathCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) else { throw MDMClientError.profileMissing(lockProfilePathCandidates.joined(separator: " | ")) } let profileData = try Data(contentsOf: URL(fileURLWithPath: profilePath)) let cmd: [String: Any] = [ "RequestType": "InstallProfile", "Payload": profileData, ] return try await enqueue(udid: udid, command: cmd).responseBody } // MARK: - Sanity check /// Versucht den NanoMDM-Server zu pingen (`/version`-Endpoint). /// Returnt z.B. "v0.9.0". static func ping() async throws -> String { let cfg = try loadConfig() guard let url = URL(string: "\(cfg.mdmServer)/version") else { throw MDMClientError.configMalformed("server URL invalid") } var req = URLRequest(url: url) let creds = "\(cfg.mdmUser):\(cfg.mdmApiKey)" guard let credData = creds.data(using: .utf8) else { throw MDMClientError.encoding } req.setValue("Basic \(credData.base64EncodedString())", forHTTPHeaderField: "Authorization") let (data, response) = try await URLSession.shared.data(for: req) let body = String(data: data, encoding: .utf8) ?? "" guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { let status = (response as? HTTPURLResponse)?.statusCode ?? -1 throw MDMClientError.http(status, body) } return body } }