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

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 lockError = ref<string | null>(null);
const lockLogs = ref<string[]>([]); const lockLogs = ref<string[]>([]);
const lockQrUrl = 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"; 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) { if (backend.company && local.organizationName !== backend.company) {
list.push(`Organisation "${backend.company}" im Backend passt nicht zum lokalen Gerät`); 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"); list.push("Lock-Profil-Status stimmt nicht überein");
} }
if (!localApp.value && !backend.lastAppPushAt) { 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() { async function runAutoSync() {
if (autoSyncing.value) return; if (autoSyncing.value) return;
@ -809,18 +827,35 @@ async function checkInlineEnrollment() {
enrollmentError.value = null; enrollmentError.value = null;
try { try {
const ids = await getInstalledProfiles(); let ids: string[] = [];
try {
ids = await getInstalledProfiles();
if (props.iphone) { if (props.iphone) {
props.iphone.installedProfileIDs = ids; 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)) { if (!ids.includes(ENROLLMENT_PROFILE_ID)) {
enrollmentError.value = "Enrollment-Profil noch nicht installiert. Bitte QR-Code scannen und Profil installieren."; // 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"; enrollmentPhase.value = "error";
return; return;
} }
} else {
enrollmentLogs.value.push("✓ Enrollment-Profil erkannt"); enrollmentLogs.value.push("✓ Enrollment-Profil lokal erkannt");
}
try { try {
const push = await mdmPush(props.iphone.udid); const push = await mdmPush(props.iphone.udid);
@ -885,18 +920,40 @@ async function checkInlineLockProfile() {
lockPhase.value = "checking"; lockPhase.value = "checking";
lockError.value = null; lockError.value = null;
lockProfileUsbCheckFailed.value = false;
try { try {
const ids = await getInstalledProfiles(); let ids: string[] = [];
try {
ids = await getInstalledProfiles();
props.iphone.installedProfileIDs = ids; 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)) { 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"; lockPhase.value = "error";
return; return;
} }
lockLogs.value.push("✓ Lock-Profil erkannt"); lockLogs.value.push("✓ Lock-Profil lokal erkannt");
lockLogs.value.push("→ Hole aktuellen Backend-Status …"); lockLogs.value.push("→ Hole aktuellen Backend-Status …");
await refreshMdmStatus(); await refreshMdmStatus();

View File

@ -66,6 +66,9 @@ export default defineNitroConfig({
process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "", process.env.DATABASE_URL ?? process.env.NUXT_DATABASE_URL ?? "",
// NanoMDM Postgres connection (e.g. postgres://nanomdm:PASS@178.105.101.137:5432/nanomdm). // NanoMDM Postgres connection (e.g. postgres://nanomdm:PASS@178.105.101.137:5432/nanomdm).
mdmDatabaseUrl: process.env.MDM_DATABASE_URL ?? "", 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 ?? "", encryptionKey: process.env.ENCRYPTION_KEY ?? "",
// ─── Admin / Cron ──────────────────────────────────────────────────── // ─── Admin / Cron ────────────────────────────────────────────────────

View File

@ -1,5 +1,7 @@
import { usePrisma } from "../utils/prisma"; import { usePrisma } from "../utils/prisma";
import pg from "pg"; import pg from "pg";
import { randomUUID } from "node:crypto";
import { upsertDeviceProtectionState } from "./device-protection";
const { Pool } = pg; 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 { consola } from "consola";
import { import {
getLinkedUserDevices, getLinkedUserDevices,
getLatestProfileListResult,
getMdmEnrollmentStatusesByUdids, getMdmEnrollmentStatusesByUdids,
enqueueProfileListCommand,
isLockProfileInstalled,
markNefilterInactiveIfLockProfileMissing,
updateUserDeviceMdmHealth, updateUserDeviceMdmHealth,
type MdmEnrollmentStatus, type MdmEnrollmentStatus,
} from "../db/mdm"; } from "../db/mdm";
@ -61,6 +65,8 @@ async function runMdmHealthCheck() {
let updated = 0; let updated = 0;
let unchanged = 0; let unchanged = 0;
let profileListEnqueued = 0;
let nefilterDisabled = 0;
for (const device of devices) { for (const device of devices) {
const status: MdmEnrollmentStatus = statuses.get(device.mdmId ?? "") ?? { const status: MdmEnrollmentStatus = statuses.get(device.mdmId ?? "") ?? {
@ -74,11 +80,7 @@ async function runMdmHealthCheck() {
device.mdmSupervised !== status.supervised || device.mdmSupervised !== status.supervised ||
!sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt); !sameNullableDate(device.mdmLastSeenAt, status.lastSeenAt);
if (!changed) { if (changed) {
unchanged++;
continue;
}
try { try {
await updateUserDeviceMdmHealth(device.id, status); await updateUserDeviceMdmHealth(device.id, status);
updated++; updated++;
@ -87,10 +89,45 @@ async function runMdmHealthCheck() {
`[mdm-health-cron] Failed to update device ${device.id}: ${err?.message ?? err}`, `[mdm-health-cron] Failed to update device ${device.id}: ${err?.message ?? err}`,
); );
} }
} else {
unchanged++;
}
// ── 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( 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) { } catch (err: any) {
consola.error("[mdm-health-cron] run failed:", err?.message ?? err); consola.error("[mdm-health-cron] run failed:", err?.message ?? err);