import Foundation /// Real-Check ob ein iPhone (per UDID) tatsächlich beim NanoMDM enrolled ist /// und wann es zuletzt Commands acked hat. Implementiert via SSH+psql gegen /// den rebreak-mdm-Server (Phase 1 / lokal-only). /// /// Production-Pfad wäre ein dedizierter `mdm.rebreak.org/status/` REST-Endpoint — /// solange den NanoMDM nicht hat, ist SSH der pragmatische Weg. struct EnrollmentStatus: Equatable { /// True wenn UDID in NanoMDM-`devices`-Tabelle vorhanden. var isEnrolled: Bool /// Wann das iPhone zuletzt sein Auth-Token erneuert hat (≈ enrollment-time /// oder letzter MDM-handshake). var tokenUpdateAt: Date? /// Wann iPhone zuletzt einen Command Acknowledged hat. nil = nie. var lastAckAt: Date? /// Wieviele Commands aktuell in der Queue stecken + active sind. var pendingCommandCount: Int /// Heuristik: "frisch" wenn MDM-Channel kürzlich aktiv war. /// Entweder iPhone hat kürzlich Command acked, ODER hat kürzlich /// sein Auth-Token erneuert (= frisch enrolled). Letzteres deckt /// den Fall ab dass nach Re-Enroll noch keine Commands gepusht wurden. var isFresh: Bool { let now = Date() if let ack = lastAckAt, now.timeIntervalSince(ack) < 30 * 60 { return true } if let tok = tokenUpdateAt, now.timeIntervalSince(tok) < 30 * 60 { return true } return false } } enum MDMStatusError: Error, LocalizedError { case sshFailed(String) case parseError(String) var errorDescription: String? { switch self { case .sshFailed(let msg): return "SSH zu rebreak-mdm fehlgeschlagen: \(msg)" case .parseError(let msg): return "DB-Output-Parse-Error: \(msg)" } } } enum MDMStatus { /// SSH-Host-Alias aus ~/.ssh/config. Vorbedingung: `ssh rebreak-mdm` /// funktioniert ohne Passwort-Prompt. static let sshHost = "rebreak-mdm" /// Spawnt `ssh rebreak-mdm 'psql ... -c ""'` und parsed die Antwort. /// Output-Format: tab-separated, eine Zeile pro Row. static func query(udid: String) async throws -> EnrollmentStatus { // ⚠️ udid ist user-controlled (kommt aus libimobiledevice-Output). // Apple-UDIDs sind aber strikt hex+dash → wir validieren defensiv. guard udid.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else { throw MDMStatusError.parseError("UDID hat unerwartetes Format: \(udid)") } // Date-Format mit space-separator (kein 'T'), weil 'T' in einfachen // Anführungszeichen über doppelte-Anführungszeichen-shell quoting bricht. let sql = """ SELECT \ (SELECT count(*) FROM devices WHERE id='\(udid)') AS enrolled, \ (SELECT to_char(token_update_at, 'YYYY-MM-DD HH24:MI:SS') FROM devices WHERE id='\(udid)') AS token_update_at, \ (SELECT to_char(max(updated_at), 'YYYY-MM-DD HH24:MI:SS') FROM command_results WHERE id='\(udid)') AS last_ack, \ (SELECT count(*) FROM enrollment_queue WHERE id='\(udid)' AND active=true) AS pending """ let remoteCmd = #"source /opt/nanomdm/.env; PGPASSWORD=$NANOMDM_DB_PASS psql -h 127.0.0.1 -U nanomdm -d nanomdm -A -F$'\t' -t -c "\#(sql)""# let result = try await ProcessRunner.run( "/usr/bin/ssh", arguments: [ "-o", "BatchMode=yes", "-o", "ConnectTimeout=5", sshHost, remoteCmd, ] ) if result.exitCode != 0 { throw MDMStatusError.sshFailed(result.stderr.isEmpty ? result.stdout : result.stderr) } let line = result.stdout.split(separator: "\n").first.map(String.init) ?? "" let fields = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init) guard fields.count >= 4 else { throw MDMStatusError.parseError("Erwartete 4 Felder, bekam: \(fields.count) — Output: \(result.stdout)") } let isEnrolled = (Int(fields[0]) ?? 0) > 0 let tokenUpdateAt = parseTimestamp(fields[1]) let lastAckAt = parseTimestamp(fields[2]) let pending = Int(fields[3].trimmingCharacters(in: .whitespaces)) ?? 0 return EnrollmentStatus( isEnrolled: isEnrolled, tokenUpdateAt: tokenUpdateAt, lastAckAt: lastAckAt, pendingCommandCount: pending ) } /// Liest das `result`-Field eines spezifischen Commands aus command_results. /// Returnt nil wenn iPhone noch nicht ge-acked hat oder command-uuid nicht /// existiert. Result ist Apple-Plist-XML (response payload des iPhones). static func readCommandResult(udid: String, commandUUID: String) async throws -> String? { guard udid.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else { throw MDMStatusError.parseError("UDID-Format: \(udid)") } guard commandUUID.range(of: "^[A-Fa-f0-9-]{20,50}$", options: .regularExpression) != nil else { throw MDMStatusError.parseError("CommandUUID-Format: \(commandUUID)") } let sql = "SELECT result FROM command_results WHERE id='\(udid)' AND command_uuid='\(commandUUID)' AND status='Acknowledged' LIMIT 1" let remoteCmd = #"source /opt/nanomdm/.env; PGPASSWORD=$NANOMDM_DB_PASS psql -h 127.0.0.1 -U nanomdm -d nanomdm -A -t -c "\#(sql)""# let result = try await ProcessRunner.run( "/usr/bin/ssh", arguments: ["-o", "BatchMode=yes", "-o", "ConnectTimeout=5", sshHost, remoteCmd] ) if result.exitCode != 0 { throw MDMStatusError.sshFailed(result.stderr) } let trimmed = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func parseTimestamp(_ s: String) -> Date? { let trimmed = s.trimmingCharacters(in: .whitespaces) if trimmed.isEmpty { return nil } let fb = DateFormatter() // psql to_char(... 'YYYY-MM-DD HH24:MI:SS') liefert "2026-05-27 05:52:57" // (UTC, ohne offset suffix — wir wissen aber dass postgres UTC liefert) fb.dateFormat = "yyyy-MM-dd HH:mm:ss" fb.timeZone = TimeZone(secondsFromGMT: 0) fb.locale = Locale(identifier: "en_US_POSIX") return fb.date(from: trimmed) } }