diff --git a/apps/backend/src/scanner/apiDetector.ts b/apps/backend/src/scanner/apiDetector.ts index f684d83..8f38265 100644 --- a/apps/backend/src/scanner/apiDetector.ts +++ b/apps/backend/src/scanner/apiDetector.ts @@ -128,21 +128,14 @@ function isApiLikeResponse(result: RawProbeResult): boolean { * Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine * Login-Weiterleitung ohne API-Signal) zählen nicht. * - * VORAB ein Kontroll-Check gegen einen garantiert nicht existierenden Pfad: - * manche Geräte (z. B. IP-Kameras) beantworten JEDE Anfrage einheitlich mit - * z. B. Basic-Auth-Aufforderung oder demselben JSON-Fehlerobjekt - dann - * würden buchstäblich ALLE Kandidaten-Pfade fälschlich als "API gefunden" - * durchgehen (siehe Bugreport: eine Reolink-Türklingel "hatte" plötzlich - * Home-Assistant-, Proxmox- UND Portainer-APIs). Reagiert das Kontroll-Pfad - * genauso "positiv" wie ein echter Treffer, ist dem Gerät für diese Prüfung - * grundsätzlich nicht zu trauen - dann lieber gar kein Ergebnis als 30 falsche. + * KEIN pauschaler Kontroll-Check gegen einen zufälligen Pfad mehr (frühere + * Version): viele Selfhosted-Programme (Sonarr, Portainer, Home Assistant, …) + * fordern für JEDE unauthentifizierte Anfrage einheitlich eine Anmeldung - + * das sah wie ein Gerät aus, "dem man nicht trauen kann", war aber normales, + * erwartbares Verhalten und hat massenhaft echte Treffer unterdrückt. + * Stattdessen wird JEDER einzelne Treffer für sich geprüft (siehe unten). */ export async function detectApis(baseUrl: string): Promise { - const control = await fetchRaw(`${baseUrl}/__launchpad_api_probe_${Date.now()}__`); - if (control && control.status !== 404 && control.status !== 0 && isApiLikeResponse(control)) { - return []; - } - const checks = await Promise.all( CANDIDATE_PATHS.map(async ({ path, type }) => { const result = await fetchRaw(`${baseUrl}${path}`); @@ -156,19 +149,28 @@ export async function detectApis(baseUrl: string): Promise { let matches = checks.filter((c): c is DetectedApi => c !== null); - // Zweite Stufe gegen Catch-all-Verhalten, das die Kontrollprobe oben nicht - // erwischt (z. B. FritzBox/Hue Bridge: 404 für "echte" Fremdpfade, aber - // jede Anfrage UNTER/NAHE einem bekannten API-Pfad wird gleich generisch - // beantwortet). Für jeden Treffer wird eine offensichtlich erfundene - // Variante DESSELBEN Pfads geprüft (letztes Segment mit Zufallssuffix). - // Antwortet die genauso "positiv", war der ursprüngliche Treffer - // vermutlich nur ein generischer Handler, kein echter Endpunkt. + // Gegen Catch-all-Verhalten (z. B. FritzBox/Hue Bridge: 404 für "echte" + // Fremdpfade, aber jede Anfrage UNTER einem bekannten API-Pfad wird gleich + // generisch beantwortet - ganz unabhängig vom genauen Unterpfad): für + // jeden Treffer werden ZWEI erfundene Varianten geprüft - (a) derselbe + // Pfad mit verändertem letzten Segment, (b) ein komplett zufälliger Pfad + // im selben Verzeichnis. Antwortet EINE der beiden genauso "positiv", war + // der ursprüngliche Treffer nur ein genereller Handler, kein echter, + // spezifischer Endpunkt. const verified = await Promise.all( matches.map(async (m) => { - const mutatedPath = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`); - const sibling = await fetchRaw(`${baseUrl}${mutatedPath}`); - const isCatchAll = - !!sibling && sibling.status !== 404 && sibling.status !== 0 && isApiLikeResponse(sibling); + const mutatedLastSegment = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`); + const directoryPrefix = m.path.slice(0, m.path.lastIndexOf("/") + 1); + const randomSibling = `${directoryPrefix}__lp_probe_${Date.now().toString(36)}__`; + + const [siblingA, siblingB] = await Promise.all([ + fetchRaw(`${baseUrl}${mutatedLastSegment}`), + directoryPrefix.length > 1 ? fetchRaw(`${baseUrl}${randomSibling}`) : Promise.resolve(null), + ]); + + const isCatchAll = [siblingA, siblingB].some( + (s) => !!s && s.status !== 404 && s.status !== 0 && isApiLikeResponse(s) + ); return isCatchAll ? null : m; }) ); diff --git a/apps/frontend/src/routes/admin/DevicesPage.tsx b/apps/frontend/src/routes/admin/DevicesPage.tsx index 66ecb5b..df53ebd 100644 --- a/apps/frontend/src/routes/admin/DevicesPage.tsx +++ b/apps/frontend/src/routes/admin/DevicesPage.tsx @@ -1,6 +1,6 @@ import { useMemo, useState, type FormEvent } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown, faMagnifyingGlassPlus } from "@fortawesome/free-solid-svg-icons"; +import { faBan, faCheck, faFloppyDisk, faPen, faTrash, faXmark, faSort, faSortUp, faSortDown } from "@fortawesome/free-solid-svg-icons"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { Button, Favicon } from "@launchpad/ui"; import type { Service } from "@launchpad/shared"; @@ -413,25 +413,13 @@ function DeviceRow({ device }: { device: DeviceWithServices }) { - {scanMutation.isPending && scanAbort ? ( +