diff --git a/apps/rebreak-magic/app/components/IosDeviceCard.vue b/apps/rebreak-magic/app/components/IosDeviceCard.vue index 47536e8..04337b8 100644 --- a/apps/rebreak-magic/app/components/IosDeviceCard.vue +++ b/apps/rebreak-magic/app/components/IosDeviceCard.vue @@ -337,6 +337,10 @@ const lockPhase = ref<"idle" | "loading" | "waiting" | "checking" | "success" | const lockError = ref(null); const lockLogs = ref([]); const lockQrUrl = ref(""); +// Wird true wenn die USB-Profilabfrage (cfgutil) fehlschlägt, z.B. weil das Gerät +// während des QR-Code-Flows kurz nicht erreichbar ist. In dem Fall vertrauen wir +// dem Backend-Status und flaggen keinen Lock-Profil-Mismatch. +const lockProfileUsbCheckFailed = ref(false); const LOCK_PROFILE_PATH = "/Users/chahinebrini/mono/rebreak-monorepo/ops/mdm/profiles/rebreak-content-filter-sideload.mobileconfig"; @@ -459,7 +463,10 @@ const mismatches = computed(() => { if (backend.company && local.organizationName !== backend.company) { list.push(`Organisation "${backend.company}" im Backend passt nicht zum lokalen Gerät`); } - if (backend.lockProfileInstalled !== localLock.value) { + // Wenn die lokale USB-Abfrage bewusst fehlgeschlagen ist (z.B. weil das Gerät + // während des QR-Code-Installations-Flows kurz nicht erreichbar war), vertrauen + // wir dem Backend-Status und zeigen keinen false-positive-Mismatch an. + if (backend.lockProfileInstalled !== localLock.value && !lockProfileUsbCheckFailed.value) { list.push("Lock-Profil-Status stimmt nicht überein"); } if (!localApp.value && !backend.lastAppPushAt) { @@ -725,6 +732,17 @@ watch( }, ); +// Wenn das iPhone neu erkannt wird und die Profil-Liste erfolgreich ausgelesen +// wurde, heben wir den USB-Failure-Flag wieder auf. +watch( + () => props.iphone?.installedProfileIDs, + (ids) => { + if (ids && ids.length > 0) { + lockProfileUsbCheckFailed.value = false; + } + }, +); + async function runAutoSync() { if (autoSyncing.value) return; @@ -809,19 +827,36 @@ async function checkInlineEnrollment() { enrollmentError.value = null; try { - const ids = await getInstalledProfiles(); - if (props.iphone) { - props.iphone.installedProfileIDs = ids; + let ids: string[] = []; + try { + ids = await getInstalledProfiles(); + if (props.iphone) { + props.iphone.installedProfileIDs = ids; + } + enrollmentLogs.value.push("✓ Lokale Profile via USB gelesen"); + } catch (usbErr: any) { + enrollmentLogs.value.push( + `⚠ USB-Profilabfrage nicht möglich: ${usbErr?.message ?? String(usbErr)}`, + ); + enrollmentLogs.value.push("→ Versuche MDM-Push als Alternative …"); } if (!ids.includes(ENROLLMENT_PROFILE_ID)) { - enrollmentError.value = "Enrollment-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren."; - enrollmentPhase.value = "error"; - return; + // Wenn cfgutil nicht greift, prüfen wir das Backend: sobald enrolled=true + // ist, wissen wir, dass das Profil installiert wurde. + await refreshMdmStatus(); + if (mdmState.value.data?.enrolled) { + enrollmentLogs.value.push("✓ Enrollment im Backend als aktiv bestätigt"); + } else { + enrollmentError.value = + "Enrollment-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren."; + enrollmentPhase.value = "error"; + return; + } + } else { + enrollmentLogs.value.push("✓ Enrollment-Profil lokal erkannt"); } - enrollmentLogs.value.push("✓ Enrollment-Profil erkannt"); - try { const push = await mdmPush(props.iphone.udid); enrollmentLogs.value.push(`✓ Push: ${push.push_result}`); @@ -885,18 +920,40 @@ async function checkInlineLockProfile() { lockPhase.value = "checking"; lockError.value = null; + lockProfileUsbCheckFailed.value = false; try { - const ids = await getInstalledProfiles(); - props.iphone.installedProfileIDs = ids; + let ids: string[] = []; + try { + ids = await getInstalledProfiles(); + props.iphone.installedProfileIDs = ids; + lockLogs.value.push("✓ Lokale Profile via USB gelesen"); + } catch (usbErr: any) { + lockProfileUsbCheckFailed.value = true; + lockLogs.value.push( + `⚠ USB-Profilabfrage nicht möglich: ${usbErr?.message ?? String(usbErr)}`, + ); + lockLogs.value.push("→ Verwende Backend-Status als Quelle der Wahrheit."); + } if (!ids.includes(LOCK_PROFILE_ID)) { - lockError.value = "Lock-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren."; + lockLogs.value.push("→ Prüfe Backend-Status …"); + await refreshMdmStatus(); + const backendInstalled = mdmState.value.data?.lockProfileInstalled ?? false; + + if (backendInstalled) { + lockLogs.value.push("✓ Lock-Profil im Backend als installiert bestätigt"); + lockPhase.value = "success"; + return; + } + + lockError.value = + "Lock-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren."; lockPhase.value = "error"; return; } - lockLogs.value.push("✓ Lock-Profil erkannt"); + lockLogs.value.push("✓ Lock-Profil lokal erkannt"); lockLogs.value.push("→ Hole aktuellen Backend-Status …"); await refreshMdmStatus(); diff --git a/backend/nitro.config.ts b/backend/nitro.config.ts index 5e1d423..b1690d1 100644 --- a/backend/nitro.config.ts +++ b/backend/nitro.config.ts @@ -66,6 +66,9 @@ export default defineNitroConfig({ process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "", // NanoMDM Postgres connection (e.g. postgres://nanomdm:PASS@178.105.101.137:5432/nanomdm). mdmDatabaseUrl: process.env.MDM_DATABASE_URL ?? "", + // NanoMDM API (e.g. https://mdm.rebreak.org). Used by health-cron to enqueue ProfileList. + mdmApiUrl: process.env.MDM_API_URL ?? "", + mdmApiKey: process.env.MDM_API_KEY ?? "", encryptionKey: process.env.ENCRYPTION_KEY ?? "", // ─── Admin / Cron ──────────────────────────────────────────────────── diff --git a/backend/server/db/mdm.ts b/backend/server/db/mdm.ts index f565444..2a8ece8 100644 --- a/backend/server/db/mdm.ts +++ b/backend/server/db/mdm.ts @@ -1,5 +1,7 @@ import { usePrisma } from "../utils/prisma"; import pg from "pg"; +import { randomUUID } from "node:crypto"; +import { upsertDeviceProtectionState } from "./device-protection"; const { Pool } = pg; @@ -252,3 +254,121 @@ export async function updateUserDeviceMdmHealth( }, }); } + +// ─── ProfileList-based lock-profile health check ───────────────────────────── + +const LOCK_PROFILE_ID = "org.rebreak.protection.contentfilter.sideload"; + +/** + * Send a ProfileList command to a device via the NanoMDM HTTP API. + * Returns true if the command was accepted by NanoMDM. + */ +export async function enqueueProfileListCommand(udid: string): Promise { + const config = useRuntimeConfig(); + const baseUrl = (config as any).mdmApiUrl as string | undefined; + const apiKey = (config as any).mdmApiKey as string | undefined; + + if (!baseUrl || !apiKey) { + throw new Error("MDM_API_URL or MDM_API_KEY not configured"); + } + + const commandUuid = randomUUID(); + const plist = ` + + + + CommandUUID + ${commandUuid} + Command + + RequestType + ProfileList + + +`; + + const auth = Buffer.from(`nanomdm:${apiKey}`).toString("base64"); + const url = `${baseUrl.replace(/\/$/, "")}/v1/enqueue/${encodeURIComponent(udid)}?push=1`; + + const res = await fetch(url, { + method: "PUT", + headers: { + Authorization: `Basic ${auth}`, + "Content-Type": "application/x-plist", + }, + body: plist, + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`NanoMDM enqueue failed: ${res.status} ${res.statusText} ${text}`); + } + + return true; +} + +/** + * Read the most recent ProfileList command result for a device. + * Returns null if no result exists yet. + */ +export async function getLatestProfileListResult( + udid: string, +): Promise<{ result: string; updatedAt: Date } | null> { + const pool = useMdmPool(); + const res = await pool.query<{ + result: string; + updated_at: Date; + }>( + `SELECT cr.result, cr.updated_at + FROM command_results cr + JOIN commands c ON c.command_uuid = cr.command_uuid + WHERE cr.id = $1 + AND c.request_type = 'ProfileList' + AND cr.status = 'Acknowledged' + ORDER BY cr.updated_at DESC + LIMIT 1`, + [udid], + ); + + return res.rows[0] ?? null; +} + +/** + * Extract all PayloadIdentifier values from a ProfileList response plist + * and check whether the ReBreak sideload lock profile is present. + */ +export function isLockProfileInstalled(xml: string): boolean { + const regex = /PayloadIdentifier<\/key>\s*([^<]+)<\/string>/g; + let match: RegExpExecArray | null; + const identifiers: string[] = []; + while ((match = regex.exec(xml)) !== null) { + identifiers.push(match[1]); + } + return identifiers.includes(LOCK_PROFILE_ID); +} + +/** + * If the lock profile is missing according to a ProfileList response, + * mark the device's nefilter protection state as inactive. + */ +export async function markNefilterInactiveIfLockProfileMissing( + userId: string, + deviceId: string, + xml: string, + lastSeenAt?: Date, +): Promise { + if (isLockProfileInstalled(xml)) { + return; + } + + await upsertDeviceProtectionState( + userId, + deviceId, + "ios", + "nefilter", + false, + lastSeenAt ?? new Date(), + "MDM ProfileList: lock profile not installed", + "mdm", + ); +} diff --git a/backend/server/plugins/mdm-health-cron.ts b/backend/server/plugins/mdm-health-cron.ts index 2939aab..663ac70 100644 --- a/backend/server/plugins/mdm-health-cron.ts +++ b/backend/server/plugins/mdm-health-cron.ts @@ -7,7 +7,11 @@ import { consola } from "consola"; import { getLinkedUserDevices, + getLatestProfileListResult, getMdmEnrollmentStatusesByUdids, + enqueueProfileListCommand, + isLockProfileInstalled, + markNefilterInactiveIfLockProfileMissing, updateUserDeviceMdmHealth, type MdmEnrollmentStatus, } from "../db/mdm"; @@ -61,6 +65,8 @@ async function runMdmHealthCheck() { let updated = 0; let unchanged = 0; + let profileListEnqueued = 0; + let nefilterDisabled = 0; for (const device of devices) { const status: MdmEnrollmentStatus = statuses.get(device.mdmId ?? "") ?? { @@ -74,23 +80,54 @@ async function runMdmHealthCheck() { device.mdmSupervised !== status.supervised || !sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt); - if (!changed) { + if (changed) { + try { + await updateUserDeviceMdmHealth(device.id, status); + updated++; + } catch (err: any) { + consola.error( + `[mdm-health-cron] Failed to update device ${device.id}: ${err?.message ?? err}`, + ); + } + } else { unchanged++; - continue; } - try { - await updateUserDeviceMdmHealth(device.id, status); - updated++; - } catch (err: any) { - consola.error( - `[mdm-health-cron] Failed to update device ${device.id}: ${err?.message ?? err}`, - ); + // ── Lock-profile check via ProfileList ─────────────────────────────── + if (status.enrolled && device.mdmId) { + try { + const profileList = await getLatestProfileListResult(device.mdmId); + if (profileList) { + const missing = !isLockProfileInstalled(profileList.result); + if (missing) { + await markNefilterInactiveIfLockProfileMissing( + device.userId, + device.deviceId, + profileList.result, + profileList.updatedAt, + ); + nefilterDisabled++; + } + } + } catch (err: any) { + consola.error( + `[mdm-health-cron] ProfileList parse failed for ${device.mdmId}: ${err?.message ?? err}`, + ); + } + + try { + await enqueueProfileListCommand(device.mdmId); + profileListEnqueued++; + } catch (err: any) { + consola.error( + `[mdm-health-cron] Failed to enqueue ProfileList for ${device.mdmId}: ${err?.message ?? err}`, + ); + } } } consola.success( - `[mdm-health-cron] Checked ${devices.length} devices in ${Date.now() - start}ms (${updated} updated, ${unchanged} unchanged)`, + `[mdm-health-cron] Checked ${devices.length} devices in ${Date.now() - start}ms (${updated} updated, ${unchanged} unchanged, ${profileListEnqueued} ProfileList enqueued, ${nefilterDisabled} nefilter disabled)`, ); } catch (err: any) { consola.error("[mdm-health-cron] run failed:", err?.message ?? err);