feat(mdm): healthcheck sends ProfileList, disables nefilter when lock profile missing; cfgutil fallback in Magic App
Some checks failed
Build ReBreak Magic Windows / NSIS Installer (x64) (push) Waiting to run
Deploy Staging / Build backend (Nitro) (push) Waiting to run
Deploy Staging / Deploy zu Hetzner (push) Blocked by required conditions
ci/woodpecker/push/woodpecker Pipeline failed

This commit is contained in:
chahinebrini 2026-06-18 14:21:45 +02:00
parent 33df768702
commit e20b21e0ef
4 changed files with 240 additions and 23 deletions

View File

@ -337,6 +337,10 @@ const lockPhase = ref<"idle" | "loading" | "waiting" | "checking" | "success" |
const lockError = ref<string | null>(null);
const lockLogs = ref<string[]>([]);
const lockQrUrl = ref<string>("");
// 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();

View File

@ -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 ────────────────────────────────────────────────────

View File

@ -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<boolean> {
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 = `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CommandUUID</key>
<string>${commandUuid}</string>
<key>Command</key>
<dict>
<key>RequestType</key>
<string>ProfileList</string>
</dict>
</dict>
</plist>`;
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 = /<key>PayloadIdentifier<\/key>\s*<string>([^<]+)<\/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<void> {
if (isLockProfileInstalled(xml)) {
return;
}
await upsertDeviceProtectionState(
userId,
deviceId,
"ios",
"nefilter",
false,
lastSeenAt ?? new Date(),
"MDM ProfileList: lock profile not installed",
"mdm",
);
}

View File

@ -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);