apps/rebreak-binder-mac/ — neue macOS-App die User durch den kompletten Self-Bind-Prozess führt: Welcome → Preflight → Supervise → Enroll → Configure (MDM-Push + Pre/Post-Check) → Sideload Lock-Profile (AirDrop). 3-Layer Smart-Resume: supervised? + Enrollment-Profil installed (cfgutil Ground-Truth)? + MDM-Ack fresh (NanoMDM-DB via ssh+psql)? Services: DeviceDetector (ideviceinfo + cfgutil), SuperviseRunner (spawnt supervise-magic CLI), MDMClient (PUT /v1/enqueue?push=1, Apple XML-Plist, identisch zum server-watcher-Format), MDMStatus (DB-Real- Check + ManagedApplicationList-Result-Read). Plus: - fix(supervise-magic): EOF nach ProcessMessage Response (ErrorCode=0) ist Success, nicht Error — vermeidet false-fail bei iPhone-Restore- Reboot - feat(mdm-profiles): rebreak-content-filter-mdm.mobileconfig als MDM-Push-Variante (ohne ConsentText, ohne globales allowAppRemoval= false — per-app via managed-state) End-to-End validiert: App-Push via Ad-Hoc-Manifest (silent), Managed- State via ManagedApplicationList-Query, NEFilter-Mode nach App-Force- Quit, Lock-Profile non-removable nach Sideload. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
135 lines
6.3 KiB
Swift
135 lines
6.3 KiB
Swift
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/<udid>` 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 "<query>"'` 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)
|
||
}
|
||
}
|