chahinebrini 2cb1f8ad6e feat(binder-mac): SwiftUI Wizard für Self-Bind End-to-End-Flow
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>
2026-05-27 08:37:14 +02:00

135 lines
6.3 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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)
}
}