diff --git a/Data/Server/Modules/admin/routes.py b/Data/Server/Modules/admin/routes.py index 7f44827..5f582b8 100644 --- a/Data/Server/Modules/admin/routes.py +++ b/Data/Server/Modules/admin/routes.py @@ -51,7 +51,7 @@ def register( return None cur.execute( """ - SELECT d.guid, ds.site_id, s.name + SELECT d.guid, d.ssl_key_fingerprint, ds.site_id, s.name FROM devices d LEFT JOIN device_sites ds ON ds.device_hostname = d.hostname LEFT JOIN sites s ON s.id = ds.site_id @@ -63,19 +63,21 @@ def register( if not row: return None existing_guid = normalize_guid(row[0]) + existing_fingerprint = (row[1] or "").strip().lower() pending_norm = normalize_guid(pending_guid) if existing_guid and pending_norm and existing_guid == pending_norm: return None - site_id_raw = row[1] + site_id_raw = row[2] site_id = None if site_id_raw is not None: try: site_id = int(site_id_raw) except (TypeError, ValueError): site_id = None - site_name = row[2] or "" + site_name = row[3] or "" return { "guid": existing_guid or None, + "ssl_key_fingerprint": existing_fingerprint or None, "site_id": site_id, "site_name": site_name, } @@ -275,14 +277,33 @@ def register( for row in rows: record_guid = row[2] hostname = row[3] - conflict = _hostname_conflict(cur, hostname, record_guid) + fingerprint_claimed = row[4] + claimed_fp_norm = (fingerprint_claimed or "").strip().lower() + conflict_raw = _hostname_conflict(cur, hostname, record_guid) + fingerprint_match = False + requires_prompt = False + conflict = None + if conflict_raw: + conflict_fp = (conflict_raw.get("ssl_key_fingerprint") or "").strip().lower() + fingerprint_match = bool(conflict_fp and claimed_fp_norm) and conflict_fp == claimed_fp_norm + requires_prompt = not fingerprint_match + conflict = { + **conflict_raw, + "fingerprint_match": fingerprint_match, + "requires_prompt": requires_prompt, + } + alternate_hostname = ( + _suggest_alternate_hostname(cur, hostname, record_guid) + if conflict_raw and requires_prompt + else None + ) approvals.append( { "id": row[0], "approval_reference": row[1], "guid": record_guid, "hostname_claimed": hostname, - "ssl_key_fingerprint_claimed": row[4], + "ssl_key_fingerprint_claimed": fingerprint_claimed, "enrollment_code_id": row[5], "status": row[6], "client_nonce": row[7], @@ -291,9 +312,9 @@ def register( "updated_at": row[10], "approved_by_user_id": row[11], "hostname_conflict": conflict, - "alternate_hostname": _suggest_alternate_hostname(cur, hostname, record_guid) - if conflict - else None, + "alternate_hostname": alternate_hostname, + "conflict_requires_prompt": requires_prompt, + "fingerprint_match": fingerprint_match, } ) finally: @@ -316,7 +337,10 @@ def register( cur = conn.cursor() cur.execute( """ - SELECT status + SELECT status, + guid, + hostname_claimed, + ssl_key_fingerprint_claimed FROM device_approvals WHERE id = ? """, @@ -328,19 +352,48 @@ def register( existing_status = (row[0] or "").strip().lower() if existing_status != "pending": return {"error": "approval_not_pending"}, 409 + stored_guid = row[1] + hostname_claimed = row[2] + fingerprint_claimed = (row[3] or "").strip().lower() + + guid_effective = normalize_guid(guid) if guid else normalize_guid(stored_guid) + resolution_effective = (resolution.strip().lower() if isinstance(resolution, str) else None) + + conflict = None + if status == "approved": + conflict = _hostname_conflict(cur, hostname_claimed, guid_effective) + if conflict: + conflict_fp = (conflict.get("ssl_key_fingerprint") or "").strip().lower() + fingerprint_match = bool(conflict_fp and fingerprint_claimed) and conflict_fp == fingerprint_claimed + if fingerprint_match: + guid_effective = conflict.get("guid") or guid_effective + if not resolution_effective: + resolution_effective = "auto_merge_fingerprint" + elif resolution_effective == "overwrite": + guid_effective = conflict.get("guid") or guid_effective + elif resolution_effective == "coexist": + pass + else: + return { + "error": "conflict_resolution_required", + "hostname": hostname_claimed, + }, 409 + + guid_to_store = guid_effective or normalize_guid(stored_guid) or None + approved_by = _lookup_user_id(cur, username) or username or "system" cur.execute( """ UPDATE device_approvals SET status = ?, - guid = COALESCE(?, guid), + guid = ?, approved_by_user_id = ?, updated_at = ? WHERE id = ? """, ( status, - guid, + guid_to_store, approved_by, _iso(_now()), approval_id, @@ -349,11 +402,11 @@ def register( conn.commit() finally: conn.close() - resolution_note = f" ({resolution})" if resolution else "" + resolution_note = f" ({resolution_effective})" if resolution_effective else "" log("server", f"device approval {approval_id} -> {status}{resolution_note} by {username}") payload: Dict[str, Any] = {"status": status} - if resolution: - payload["conflict_resolution"] = resolution + if resolution_effective: + payload["conflict_resolution"] = resolution_effective return payload, 200 @blueprint.route("/api/admin/device-approvals//approve", methods=["POST"]) diff --git a/Data/Server/WebUI/src/Devices/Device_Approvals.jsx b/Data/Server/WebUI/src/Devices/Device_Approvals.jsx index 6ae916a..e0a6940 100644 --- a/Data/Server/WebUI/src/Devices/Device_Approvals.jsx +++ b/Data/Server/WebUI/src/Devices/Device_Approvals.jsx @@ -165,6 +165,20 @@ function DeviceApprovals() { }); const body = await resp.json().catch(() => ({})); if (!resp.ok) { + if (resp.status === 409 && body.error === "conflict_resolution_required") { + const conflict = record.hostname_conflict; + const fallbackAlternate = + record.alternate_hostname || + (record.hostname_claimed ? `${record.hostname_claimed}-1` : ""); + if (conflict) { + setConflictPrompt({ + record, + conflict, + alternate: fallbackAlternate || "", + }); + } + return; + } throw new Error(body.error || `Approval failed (${resp.status})`); } const appliedResolution = (body.conflict_resolution || payload.conflict_resolution || "").toLowerCase(); @@ -173,6 +187,8 @@ function DeviceApprovals() { successMessage = "Enrollment approved; existing device overwritten"; } else if (appliedResolution === "coexist") { successMessage = "Enrollment approved; devices will co-exist"; + } else if (appliedResolution === "auto_merge_fingerprint") { + successMessage = "Enrollment approved; device reconnected with its existing identity"; } setFeedback({ type: "success", message: successMessage }); await loadApprovals(); @@ -192,7 +208,8 @@ function DeviceApprovals() { if (status !== "pending") return; const manualGuid = (guidInputs[record.id] || "").trim(); const conflict = record.hostname_conflict; - if (conflict && !manualGuid) { + const requiresPrompt = Boolean(conflict?.requires_prompt ?? record.conflict_requires_prompt); + if (requiresPrompt && !manualGuid) { const fallbackAlternate = record.alternate_hostname || (record.hostname_claimed ? `${record.hostname_claimed}-1` : "");