feat(mdm): healthcheck sends ProfileList, disables nefilter when lock profile missing; cfgutil fallback in Magic App
Some checks failed
Some checks failed
This commit is contained in:
parent
33df768702
commit
e20b21e0ef
@ -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();
|
||||
|
||||
@ -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 ────────────────────────────────────────────────────
|
||||
|
||||
@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user