// MobileBackup2-Pfad für Re-Supervise auf already-supervised Devices. // Wird automatisch von Supervise() gewählt wenn Device schon supervised + --force. package supervise import ( "errors" "fmt" "os" "strconv" "strings" "time" ios "github.com/danielpaulus/go-ios/ios" "github.com/google/uuid" "github.com/raynis/rebreak-supervise-magic/internal/afclock" "github.com/raynis/rebreak-supervise-magic/internal/cert" "github.com/raynis/rebreak-supervise-magic/internal/cloudconfig" "github.com/raynis/rebreak-supervise-magic/internal/device" "github.com/raynis/rebreak-supervise-magic/internal/mcinstall" "github.com/raynis/rebreak-supervise-magic/internal/mobilebackup2" "github.com/raynis/rebreak-supervise-magic/internal/notification_proxy" ) // SuperviseViaBackup nutzt den MobileBackup2-Restore-Trick. Funktioniert // auch auf already-supervised Devices (umgeht Apple's 14002-Check via // "scheinbarer Restore" + DEP-mode CloudConfigurationDetails). func SuperviseViaBackup(udid string, opts Options) error { logf := makeLogger(opts.Verbose) logf("[backup-flow] step 1/8: connecting ...") conn, err := device.Connect(udid) if err != nil { return fmt.Errorf("step 1: %w", err) } defer conn.Close() info, err := conn.Info() if err != nil { return fmt.Errorf("step 1: info: %w", err) } logf("[backup-flow] step 2/8: loading supervision identity ...") id, err := cert.LoadOrCreate() if err != nil { return fmt.Errorf("step 2: %w", err) } logf(" ✓ cert %d bytes", len(id.CertDER)) logf("[backup-flow] step 3/8: building backup files ...") now := time.Now() vars := mobilebackup2.TemplateVars{ BackupUUID: uuid.New().String(), BackupGUID: uuid.New().String(), Date: mobilebackup2.FormatBackupDate(now), BuildVersion: asString(info["BuildVersion"]), ProductType: asString(info["ProductType"]), ProductVersion: asString(info["ProductVersion"]), SerialNumber: asString(info["SerialNumber"]), UDID: udid, DeviceName: asString(info["DeviceName"]), } // Default jetzt TL-parity, weil dynamische Metadaten (Date/UUID) in Tests // mit Settings-/Onboarding-Drift korrelierten. vars.Date = "2024-11-27T21:34:13Z" vars.BackupUUID = "F344B3BB-38A5-4110-8F55-1369B2255A14" if os.Getenv("REBREAK_MB2_DYNAMIC_METADATA") == "1" { // Debug-Override: früheres Verhalten mit runtime Date/UUID. vars.Date = mobilebackup2.FormatBackupDate(now) vars.BackupUUID = uuid.New().String() } cloudCfg, err := cloudconfig.Build(cloudconfig.BuildOptions{ OrganizationName: opts.OrgName, SupervisorCert: id.CertDER, }) if err != nil { return fmt.Errorf("step 3: cloudconfig: %w", err) } logf(" ✓ CloudConfigurationDetails.plist: %d bytes", len(cloudCfg)) statusPlist, err := mobilebackup2.RenderStatusPlist(vars) if err != nil { return fmt.Errorf("step 3: status: %w", err) } infoPlist, err := mobilebackup2.RenderInfoPlist(vars) if err != nil { return fmt.Errorf("step 3: info: %w", err) } manifestPlist, err := mobilebackup2.RenderManifestPlist(vars) if err != nil { return fmt.Errorf("step 3: manifest: %w", err) } if os.Getenv("REBREAK_MB2_TL_VERBATIM_META") == "1" { statusPlist = mobilebackup2.TLStatusPlist() manifestPlist = mobilebackup2.TLManifestPlist() logf(" ⚠ using TL verbatim Status.plist + Manifest.plist") } // Manifest.db: generiere mit echter CC-Größe damit Size-Field korrekt ist. // TL verbatim hat Size=412 hardcoded — wenn unsere CC-Plist größer ist, // entstehen Size-Mismatches. Generierte DB hat identische MBFile-Felder // (Mode/GroupID/Timestamps) zu TL. Fallback auf TL verbatim per Env. var manifestDB []byte if os.Getenv("REBREAK_MB2_TL_VERBATIM_MANIFEST_DB") == "1" { manifestDB = mobilebackup2.TLManifestDB() logf(" ⚠ using TL verbatim Manifest.db (size=412 hardcoded)") } else { entries := mobilebackup2.DefaultRestoreEntries(int64(len(cloudCfg))) var dbErr error manifestDB, dbErr = mobilebackup2.BuildManifestDB(entries) if dbErr != nil { // Fallback auf TL verbatim wenn Generierung fehlschlägt manifestDB = mobilebackup2.TLManifestDB() logf(" ⚠ manifest-db generation failed (%v) — using TL verbatim", dbErr) } } logf(" ✓ Status.plist %dB, Info.plist %dB, Manifest.plist %dB, Manifest.db %dB (cc-size=%dB)", len(statusPlist), len(infoPlist), len(manifestPlist), len(manifestDB), len(cloudCfg)) // fileID für CloudConfigurationDetails.plist cloudCfgFileID := mobilebackup2.ComputeFileID( mobilebackup2.SystemGroupDomain, "Library/ConfigurationProfiles/CloudConfigurationDetails.plist", ) logf(" ✓ cloud-cfg fileID: %s", cloudCfgFileID) // FileProvider: maps requested-filename → content provider := func(relpath string) ([]byte, bool) { // Strip leading path components if iPhone prefixes with UDID switch relpath { case "Status.plist", udid + "/Status.plist": return statusPlist, true case "Info.plist", udid + "/Info.plist": return infoPlist, true case "Manifest.plist", udid + "/Manifest.plist": return manifestPlist, true case "Manifest.db", udid + "/Manifest.db": return manifestDB, true case cloudCfgFileID, udid + "/" + cloudCfgFileID, udid + "/" + cloudCfgFileID[:2] + "/" + cloudCfgFileID: return cloudCfg, true } return nil, false } if opts.DryRun { logf("[backup-flow] step 4-8: DRY-RUN — skipping MobileBackup2 send") return nil } logf("[backup-flow] step 4a/8: PostNotification syncWillStart (one-shot) ...") if err := notification_proxy.PostOnce(conn.Device(), notification_proxy.SyncWillStart); err != nil { return fmt.Errorf("step 4a: %w", err) } logf("[backup-flow] step 4b/8: acquiring AFC sync-lock ...") lock, err := afclock.Acquire(conn.Device()) if err != nil { return fmt.Errorf("step 4b: %w", err) } defer lock.Release() logf(" ✓ /com.apple.itunes.lock_sync opened") logf("[backup-flow] step 4c/8: PostNotification syncLockRequest (one-shot) ...") if err := notification_proxy.PostOnce(conn.Device(), notification_proxy.SyncLockRequest); err != nil { return fmt.Errorf("step 4c: %w", err) } logf("[backup-flow] step 4d/8: opening MobileBackup2 service ...") mb2, err := mobilebackup2.Open(conn.Device()) if err != nil { return fmt.Errorf("step 4d: %w", err) } defer mb2.Close() logf("[backup-flow] step 5/8: BaseVersionExchange ...") if err := mb2.BaseVersionExchange(); err != nil { return fmt.Errorf("step 5: %w", err) } logf(" ✓ negotiated protocol version %.1f", mb2.ProtocolVersion()) logf("[backup-flow] step 6a/8: PostNotification syncDidStart (one-shot) ...") if err := notification_proxy.PostOnce(conn.Device(), notification_proxy.SyncDidStart); err != nil { return fmt.Errorf("step 6a: %w", err) } logf("[backup-flow] step 6b/8: send Hello handshake ...") if err := mb2.SendHello(); err != nil { return fmt.Errorf("step 6b: %w", err) } logf("[backup-flow] step 6c/8: send Restore command ...") if err := mb2.Start(udid, nil); err != nil { return fmt.Errorf("step 6c: %w", err) } logf("[backup-flow] step 7/8: serving files to device ...") progress := func(event, info string) { if opts.Verbose { logf(" [mb2] %s: %s", event, info) } } serveTimeout := 5 * time.Minute if v := os.Getenv("REBREAK_MB2_SERVE_TIMEOUT_SEC"); v != "" { if sec, err := strconv.Atoi(v); err == nil && sec > 0 { serveTimeout = time.Duration(sec) * time.Second } } if err := serveFilesWithTimeout(mb2, provider, progress, serveTimeout); err != nil { return fmt.Errorf("step 7: %w", err) } logf(" ✓ file-serve loop complete") if os.Getenv("REBREAK_SEND_SYNC_DID_FINISH") == "1" { logf("[backup-flow] step 7b/8: PostNotification syncDidFinish (one-shot) ...") if err := notification_proxy.PostOnce(conn.Device(), notification_proxy.SyncDidFinish); err != nil { logf(" ⚠ syncDidFinish failed (best-effort): %v", err) } } else { logf("[backup-flow] step 7b/8: skip syncDidFinish (TL-parity default)") } logf("[backup-flow] step 8/9: waiting for device reboot + verifying ...") // Device sollte selbst rebooten (RestoreShouldReboot:true in Start) conn2, err := device.WaitForReconnect(udid, 180*time.Second) if err != nil { return fmt.Errorf("step 8: reconnect: %w", err) } defer conn2.Close() logf(" ✓ device back online") if os.Getenv("REBREAK_SKIP_POST_COMMIT") == "1" { logf("[backup-flow] step 9/9: SKIPPED (REBREAK_SKIP_POST_COMMIT=1)") logf(" ✓ DONE — Settings should show 'Verwaltet von %s'", opts.OrgName) return nil } // Step 9: Post-Reboot MCInstall-Commit. // // 2026-05-28: Ohne diesen Step landet iPhone nach Reboot im "iPhone ist // teilweise eingerichtet"-Dialog (Setup Assistant zeigt Sprache/Region/ // Anrede + Resume-Setup-Dialog). TL committet die CloudConfiguration via // MCInstall NACH dem MobileBackup2-Restore → Setup-Assistant erkennt den // Restore als final → iOS macht Rest im Hintergrund → direkt Kamera/SOS. // // Wir sind nach dem Plist-Plant der aktuelle Supervisor, also kein // Escalate nötig. Retry-Loop weil MCInstall-Service kurz nach Reboot // noch nicht ready ist (typically 5-20s). logf("[backup-flow] step 9/9: committing CloudConfiguration via MCInstall (post-reboot) ...") if err := commitViaMCInstall(conn2, id.CertDER, opts.OrgName, logf); err != nil { return fmt.Errorf("step 9: %w", err) } logf(" ✓ DONE — Settings should show 'Verwaltet von %s'", opts.OrgName) return nil } // commitViaMCInstall stempelt den supervised-state final via MCInstall. // Required NACH MobileBackup2-Restore um den Setup-Assistant-Resume-Dialog // zu vermeiden. // // Flow: // 1. Warm-up sleep — lockdownd braucht ~30-45s post-reboot bis stabil // 2. Silent re-pair — pair-record wird beim Re-Supervise invalidiert, // aber Re-Pair via go-ios geht silent (empirisch verifiziert mit // `idevicepair pair -u ` durch User 2026-05-28) // 3. MCInstall.Supervise mit retry-loop func commitViaMCInstall(conn *device.Conn, certDER []byte, orgName string, logf func(string, ...any)) error { // Phase 1: warm-up nach Reboot warmupSec := 45 if v := os.Getenv("REBREAK_POST_COMMIT_WARMUP_SEC"); v != "" { if n, err := strconv.Atoi(v); err == nil && n >= 0 { warmupSec = n } } if warmupSec > 0 { logf(" · waiting %ds for lockdownd warm-up after reboot ...", warmupSec) time.Sleep(time.Duration(warmupSec) * time.Second) } // Phase 2: silent re-pair if os.Getenv("REBREAK_POST_COMMIT_SKIP_PAIR") != "1" { logf(" · attempting silent re-pair ...") // Re-Connect — sodass wir das aktuelle DeviceEntry haben conn2, rerr := device.Connect(conn.UDID()) if rerr != nil { logf(" ⚠ re-connect for pair failed: %v (continuing)", rerr) } else { if pairErr := ios.Pair(conn2.Device()); pairErr != nil { if strings.Contains(pairErr.Error(), "PairingDialog") { return fmt.Errorf("re-pair requires Trust-prompt on device — please tap 'Trust' on iPhone and re-run") } logf(" ⚠ pair returned: %v (continuing — old record may still be valid)", pairErr) } else { logf(" ✓ device re-paired silent") } } } // Phase 3: MCInstall retry-loop const maxAttempts = 30 const retryDelay = 2 * time.Second var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { // Re-Connect für jeden attempt — DeviceEntry kann stale werden conn3, cerr := device.Connect(conn.UDID()) if cerr != nil { lastErr = cerr logf(" · attempt %d/%d: re-connect failed: %v", attempt, maxAttempts, cerr) time.Sleep(retryDelay) continue } mc, err := mcinstall.Open(conn3.Device()) if err != nil { lastErr = err logf(" · attempt %d/%d: MCInstall.Open failed: %v", attempt, maxAttempts, err) time.Sleep(retryDelay) continue } resp, err := mc.Supervise(mcinstall.SuperviseConfig{ OrganizationName: orgName, CertDER: certDER, AllowPairing: true, }) mc.Close() if err != nil { lastErr = err logf(" · attempt %d/%d: Supervise failed: %v", attempt, maxAttempts, err) time.Sleep(retryDelay) continue } if resp == nil { lastErr = fmt.Errorf("MCInstall returned nil CloudConfiguration") logf(" · attempt %d/%d: empty response", attempt, maxAttempts) time.Sleep(retryDelay) continue } logf(" ✓ MCInstall commit acknowledged (attempt %d)", attempt) return nil } return fmt.Errorf("MCInstall commit failed after %d attempts: %w", maxAttempts, lastErr) } func asString(v interface{}) string { if s, ok := v.(string); ok { return s } return "" } // SentinelBackupAborted — wenn user den Backup-Flow abbricht var SentinelBackupAborted = errors.New("backup-flow aborted") func serveFilesWithTimeout( mb2 *mobilebackup2.Client, provider mobilebackup2.FileProvider, onProgress func(event string, info string), timeout time.Duration, ) error { done := make(chan error, 1) go func() { done <- mb2.ServeFiles(provider, onProgress) }() if timeout <= 0 { return <-done } select { case err := <-done: return err case <-time.After(timeout): _ = mb2.Close() return fmt.Errorf("serve timeout after %s", timeout) } }