feat(mdm): healthcheck sends ProfileList, disables nefilter when lock profile missing; cfgutil fallback in Magic App
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 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,19 +827,36 @@ async function checkInlineEnrollment() {
|
|||||||
enrollmentError.value = null;
|
enrollmentError.value = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const ids = await getInstalledProfiles();
|
let ids: string[] = [];
|
||||||
if (props.iphone) {
|
try {
|
||||||
props.iphone.installedProfileIDs = ids;
|
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)) {
|
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
|
||||||
enrollmentPhase.value = "error";
|
// ist, wissen wir, dass das Profil installiert wurde.
|
||||||
return;
|
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 {
|
try {
|
||||||
const push = await mdmPush(props.iphone.udid);
|
const push = await mdmPush(props.iphone.udid);
|
||||||
enrollmentLogs.value.push(`✓ Push: ${push.push_result}`);
|
enrollmentLogs.value.push(`✓ Push: ${push.push_result}`);
|
||||||
@ -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[] = [];
|
||||||
props.iphone.installedProfileIDs = ids;
|
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)) {
|
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();
|
||||||
|
|||||||
@ -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 ────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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,23 +80,54 @@ 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) {
|
||||||
|
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++;
|
unchanged++;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// ── Lock-profile check via ProfileList ───────────────────────────────
|
||||||
await updateUserDeviceMdmHealth(device.id, status);
|
if (status.enrolled && device.mdmId) {
|
||||||
updated++;
|
try {
|
||||||
} catch (err: any) {
|
const profileList = await getLatestProfileListResult(device.mdmId);
|
||||||
consola.error(
|
if (profileList) {
|
||||||
`[mdm-health-cron] Failed to update device ${device.id}: ${err?.message ?? err}`,
|
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);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user