generated from Dicken/dickendock
round37: API-Scanner Kontrollprobe entschaerft (weniger Fehlalarme false negatives), Alle-bestaetigen-Buttons, Scan-Buttons bei Geraeten zusammengelegt
This commit is contained in:
@@ -128,21 +128,14 @@ function isApiLikeResponse(result: RawProbeResult): boolean {
|
|||||||
* Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine
|
* Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine
|
||||||
* Login-Weiterleitung ohne API-Signal) zählen nicht.
|
* Login-Weiterleitung ohne API-Signal) zählen nicht.
|
||||||
*
|
*
|
||||||
* VORAB ein Kontroll-Check gegen einen garantiert nicht existierenden Pfad:
|
* KEIN pauschaler Kontroll-Check gegen einen zufälligen Pfad mehr (frühere
|
||||||
* manche Geräte (z. B. IP-Kameras) beantworten JEDE Anfrage einheitlich mit
|
* Version): viele Selfhosted-Programme (Sonarr, Portainer, Home Assistant, …)
|
||||||
* z. B. Basic-Auth-Aufforderung oder demselben JSON-Fehlerobjekt - dann
|
* fordern für JEDE unauthentifizierte Anfrage einheitlich eine Anmeldung -
|
||||||
* würden buchstäblich ALLE Kandidaten-Pfade fälschlich als "API gefunden"
|
* das sah wie ein Gerät aus, "dem man nicht trauen kann", war aber normales,
|
||||||
* durchgehen (siehe Bugreport: eine Reolink-Türklingel "hatte" plötzlich
|
* erwartbares Verhalten und hat massenhaft echte Treffer unterdrückt.
|
||||||
* Home-Assistant-, Proxmox- UND Portainer-APIs). Reagiert das Kontroll-Pfad
|
* Stattdessen wird JEDER einzelne Treffer für sich geprüft (siehe unten).
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||||
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(
|
const checks = await Promise.all(
|
||||||
CANDIDATE_PATHS.map(async ({ path, type }) => {
|
CANDIDATE_PATHS.map(async ({ path, type }) => {
|
||||||
const result = await fetchRaw(`${baseUrl}${path}`);
|
const result = await fetchRaw(`${baseUrl}${path}`);
|
||||||
@@ -156,19 +149,28 @@ export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
|||||||
|
|
||||||
let matches = checks.filter((c): c is DetectedApi => c !== null);
|
let matches = checks.filter((c): c is DetectedApi => c !== null);
|
||||||
|
|
||||||
// Zweite Stufe gegen Catch-all-Verhalten, das die Kontrollprobe oben nicht
|
// Gegen Catch-all-Verhalten (z. B. FritzBox/Hue Bridge: 404 für "echte"
|
||||||
// erwischt (z. B. FritzBox/Hue Bridge: 404 für "echte" Fremdpfade, aber
|
// Fremdpfade, aber jede Anfrage UNTER einem bekannten API-Pfad wird gleich
|
||||||
// jede Anfrage UNTER/NAHE einem bekannten API-Pfad wird gleich generisch
|
// generisch beantwortet - ganz unabhängig vom genauen Unterpfad): für
|
||||||
// beantwortet). Für jeden Treffer wird eine offensichtlich erfundene
|
// jeden Treffer werden ZWEI erfundene Varianten geprüft - (a) derselbe
|
||||||
// Variante DESSELBEN Pfads geprüft (letztes Segment mit Zufallssuffix).
|
// Pfad mit verändertem letzten Segment, (b) ein komplett zufälliger Pfad
|
||||||
// Antwortet die genauso "positiv", war der ursprüngliche Treffer
|
// im selben Verzeichnis. Antwortet EINE der beiden genauso "positiv", war
|
||||||
// vermutlich nur ein generischer Handler, kein echter Endpunkt.
|
// der ursprüngliche Treffer nur ein genereller Handler, kein echter,
|
||||||
|
// spezifischer Endpunkt.
|
||||||
const verified = await Promise.all(
|
const verified = await Promise.all(
|
||||||
matches.map(async (m) => {
|
matches.map(async (m) => {
|
||||||
const mutatedPath = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`);
|
const mutatedLastSegment = m.path.replace(/([^/]+)$/, `$1-x${Date.now().toString(36)}`);
|
||||||
const sibling = await fetchRaw(`${baseUrl}${mutatedPath}`);
|
const directoryPrefix = m.path.slice(0, m.path.lastIndexOf("/") + 1);
|
||||||
const isCatchAll =
|
const randomSibling = `${directoryPrefix}__lp_probe_${Date.now().toString(36)}__`;
|
||||||
!!sibling && sibling.status !== 404 && sibling.status !== 0 && isApiLikeResponse(sibling);
|
|
||||||
|
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;
|
return isCatchAll ? null : m;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState, type FormEvent } from "react";
|
import { useMemo, useState, type FormEvent } from "react";
|
||||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
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 { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Button, Favicon } from "@launchpad/ui";
|
import { Button, Favicon } from "@launchpad/ui";
|
||||||
import type { Service } from "@launchpad/shared";
|
import type { Service } from "@launchpad/shared";
|
||||||
@@ -413,25 +413,13 @@ function DeviceRow({ device }: { device: DeviceWithServices }) {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setScanMessage(null);
|
setScanMessage("Portscan läuft (alle 65535 Ports) …");
|
||||||
scanMutation.mutate(false);
|
|
||||||
}}
|
|
||||||
disabled={scanMutation.isPending}
|
|
||||||
>
|
|
||||||
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="icon"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => {
|
|
||||||
setScanMessage("Vollständiger Portscan (1-65535) läuft, kann etwas dauern …");
|
|
||||||
scanMutation.mutate(true);
|
scanMutation.mutate(true);
|
||||||
}}
|
}}
|
||||||
disabled={scanMutation.isPending}
|
disabled={scanMutation.isPending}
|
||||||
title="Vollständigen Portscan (alle 65535 Ports statt nur der üblichen) durchführen - für Geräte mit vielen Diensten auf unüblichen Ports"
|
title="Prüft alle 65535 Ports (nicht nur die üblichen) - dauert dadurch ein paar Sekunden länger, findet dafür auch Dienste auf unüblichen Ports"
|
||||||
aria-label="Vollständigen Portscan durchführen"
|
|
||||||
>
|
>
|
||||||
<FontAwesomeIcon icon={faMagnifyingGlassPlus} />
|
{scanMutation.isPending ? "Scanne …" : "Jetzt scannen"}
|
||||||
</Button>
|
</Button>
|
||||||
{scanMutation.isPending && scanAbort ? (
|
{scanMutation.isPending && scanAbort ? (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -133,9 +133,17 @@ function NameChangesReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{nameChanges.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
<p className="font-medium text-blue-700 dark:text-blue-400">
|
||||||
</p>
|
{nameChanges.length} erkannte Änderung(en) weichen vom gespeicherten Wert ab:
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => nameChanges.forEach((c) => onResolve(nameChangeKey(c)))}
|
||||||
|
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
Alle bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{nameChanges.map((c) => (
|
{nameChanges.map((c) => (
|
||||||
<li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
|
<li key={nameChangeKey(c)} className="flex items-center justify-between gap-2">
|
||||||
@@ -169,9 +177,17 @@ function NewServicesList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-emerald-700 dark:text-emerald-400">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{newServices.length} neue(r) Dienst(e) gefunden:
|
<p className="font-medium text-emerald-700 dark:text-emerald-400">
|
||||||
</p>
|
{newServices.length} neue(r) Dienst(e) gefunden:
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => newServices.forEach((s) => onResolve(s.id))}
|
||||||
|
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
Alle bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{newServices.map((s) => (
|
{newServices.map((s) => (
|
||||||
<li key={s.id} className="flex items-center justify-between gap-2">
|
<li key={s.id} className="flex items-center justify-between gap-2">
|
||||||
@@ -215,10 +231,18 @@ function StaleServicesReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
|
<p className="font-medium text-amber-700 dark:text-amber-400">
|
||||||
nicht mehr offen):
|
{staleServices.length} Dienst(e) über alle gescannten Geräte hinweg nicht mehr gefunden (Port
|
||||||
</p>
|
nicht mehr offen):
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => staleServices.forEach((s) => onResolve(s.id))}
|
||||||
|
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
Alle bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{staleServices.map((s) => (
|
{staleServices.map((s) => (
|
||||||
<li key={s.id} className="flex items-center justify-between gap-2">
|
<li key={s.id} className="flex items-center justify-between gap-2">
|
||||||
@@ -263,9 +287,17 @@ function DeviceChangesReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-blue-500/30 bg-blue-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-blue-700 dark:text-blue-400">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
<p className="font-medium text-blue-700 dark:text-blue-400">
|
||||||
</p>
|
{deviceChanges.length} Geräte-Änderung(en) weichen von den gespeicherten Werten ab:
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => deviceChanges.forEach((c) => onResolve(`${c.deviceId}:${c.field}`))}
|
||||||
|
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
Alle bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
<ul className="max-h-60 space-y-1 overflow-y-auto">
|
||||||
{deviceChanges.map((c) => (
|
{deviceChanges.map((c) => (
|
||||||
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
<li key={`${c.deviceId}:${c.field}`} className="flex items-center justify-between gap-2">
|
||||||
@@ -308,10 +340,18 @@ function StaleDevicesReview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
<div className="mt-3 rounded-xl border border-amber-500/30 bg-amber-500/5 p-3 text-xs">
|
||||||
<p className="mb-2 font-medium text-amber-700 dark:text-amber-400">
|
<div className="mb-2 flex items-center justify-between gap-2">
|
||||||
{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
<p className="font-medium text-amber-700 dark:text-amber-400">
|
||||||
mehr:
|
{staleDevices.length} Gerät(e), die die FritzBox früher gemeldet hatte, diesmal aber nicht
|
||||||
</p>
|
mehr:
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => staleDevices.forEach((d) => onResolve(d.id))}
|
||||||
|
className="shrink-0 text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
Alle bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{staleDevices.map((d) => (
|
{staleDevices.map((d) => (
|
||||||
<li key={d.id} className="flex items-center justify-between gap-2">
|
<li key={d.id} className="flex items-center justify-between gap-2">
|
||||||
@@ -416,7 +456,16 @@ function ApiScannerCard() {
|
|||||||
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
{status ? <p className="mt-2 text-sm text-black/50 dark:text-white/50">{status}</p> : null}
|
||||||
|
|
||||||
{lastNewFindings.length > 0 ? (
|
{lastNewFindings.length > 0 ? (
|
||||||
<ul className="mt-4 max-h-72 space-y-2 overflow-y-auto text-sm">
|
<>
|
||||||
|
<div className="mt-4 flex items-center justify-end">
|
||||||
|
<button
|
||||||
|
onClick={() => setLastNewFindings([])}
|
||||||
|
className="text-xs text-black/40 underline hover:text-black/60 dark:text-white/40 dark:hover:text-white/60"
|
||||||
|
>
|
||||||
|
Alle bestätigen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="max-h-72 space-y-2 overflow-y-auto text-sm">
|
||||||
{lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
|
{lastNewFindings.map(({ serviceId, serviceName, apis: entries }) => (
|
||||||
<li key={serviceId} className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2">
|
<li key={serviceId} className="rounded-lg border border-emerald-500/30 bg-emerald-500/5 p-2">
|
||||||
<div className="flex items-center justify-between gap-2">
|
<div className="flex items-center justify-between gap-2">
|
||||||
@@ -445,6 +494,7 @@ function ApiScannerCard() {
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user