generated from Dicken/dickendock
round27: Ping-Regression rueckgaengig gemacht (Echo/Samsung-Geraete ignorieren ICMP), Aktualisiert-Text entfernt, Suchmaschinen-Hinweis+README, API-Scanner stark erweitert mit eigenem Reiter
This commit is contained in:
@@ -27,15 +27,29 @@ export function listDetectedApis(): DetectedApiEntry[] {
|
||||
return db.select().from(detectedApis).all().map(mapRow);
|
||||
}
|
||||
|
||||
export function getApisForService(serviceId: string): DetectedApiEntry[] {
|
||||
return db.select().from(detectedApis).where(eq(detectedApis.serviceId, serviceId)).all().map(mapRow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt die gespeicherten API-Funde eines Dienstes komplett durch die
|
||||
* neuen Ergebnisse eines Scan-Laufs - kein Anhäufen von veralteten
|
||||
* Einträgen, wenn sich z. B. der API-Pfad einer Software mal ändert.
|
||||
*
|
||||
* Liefert zusätzlich `added` zurück: die Einträge, die VORHER noch nicht
|
||||
* gespeichert waren (nach Pfad+Typ verglichen) - damit der Scanner auf der
|
||||
* Scanner-Seite nach dem ersten Mal nur noch NEUE/geänderte Funde anzeigen
|
||||
* kann, statt bei jedem erneuten Scan die komplette (meist unveränderte)
|
||||
* Liste erneut runterzurattern. Die vollständige Liste bleibt jederzeit
|
||||
* unter Admin -> APIs einsehbar.
|
||||
*/
|
||||
export function replaceApisForService(
|
||||
serviceId: string,
|
||||
found: { path: string; type: string; status: number }[]
|
||||
): DetectedApiEntry[] {
|
||||
): { saved: DetectedApiEntry[]; added: DetectedApiEntry[] } {
|
||||
const before = getApisForService(serviceId);
|
||||
const beforeKeys = new Set(before.map((e) => `${e.path}::${e.type}`));
|
||||
|
||||
db.delete(detectedApis).where(eq(detectedApis.serviceId, serviceId)).run();
|
||||
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -52,7 +66,10 @@ export function replaceApisForService(
|
||||
db.insert(detectedApis).values(rows).run();
|
||||
}
|
||||
|
||||
return rows.map(mapRow);
|
||||
const saved = rows.map(mapRow);
|
||||
const added = saved.filter((e) => !beforeKeys.has(`${e.path}::${e.type}`));
|
||||
|
||||
return { saved, added };
|
||||
}
|
||||
|
||||
export function deleteApisForService(serviceId: string): void {
|
||||
|
||||
@@ -19,13 +19,17 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||
const services = serviceRepo.listServices();
|
||||
let servicesWithApi = 0;
|
||||
let totalFound = 0;
|
||||
const newFindings: { serviceId: string; serviceName: string; apis: apiRepo.DetectedApiEntry[] }[] = [];
|
||||
|
||||
for (const service of services) {
|
||||
const found = await detectApis(service.url);
|
||||
if (found.length > 0) {
|
||||
apiRepo.replaceApisForService(service.id, found);
|
||||
const { added } = apiRepo.replaceApisForService(service.id, found);
|
||||
servicesWithApi++;
|
||||
totalFound += found.length;
|
||||
if (added.length > 0) {
|
||||
newFindings.push({ serviceId: service.id, serviceName: service.displayName, apis: added });
|
||||
}
|
||||
} else {
|
||||
apiRepo.deleteApisForService(service.id);
|
||||
}
|
||||
@@ -38,7 +42,7 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||
message: `API-Scan: ${services.length} Dienst(e) geprüft, bei ${servicesWithApi} Dienst(en) ${totalFound} API-Endpunkt(e) gefunden.`,
|
||||
});
|
||||
|
||||
return { checked: services.length, servicesWithApi, totalFound };
|
||||
return { checked: services.length, servicesWithApi, totalFound, newFindings };
|
||||
});
|
||||
|
||||
app.post("/api/scan/apis/:serviceId", async (request, reply) => {
|
||||
@@ -49,9 +53,10 @@ export async function apiRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
const found = await detectApis(service.url);
|
||||
const saved = found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : [];
|
||||
const { saved, added } =
|
||||
found.length > 0 ? apiRepo.replaceApisForService(service.id, found) : { saved: [], added: [] };
|
||||
if (found.length === 0) apiRepo.deleteApisForService(service.id);
|
||||
|
||||
return { serviceId, apis: saved };
|
||||
return { serviceId, apis: saved, added };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export interface DetectedApi {
|
||||
interface RawProbeResult {
|
||||
status: number;
|
||||
contentType: string | null;
|
||||
wwwAuthenticate: string | null;
|
||||
bodySnippet: string;
|
||||
}
|
||||
|
||||
@@ -32,7 +33,13 @@ function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null>
|
||||
});
|
||||
res.on("end", () => {
|
||||
const contentType = res.headers["content-type"] ?? null;
|
||||
resolve({ status: res.statusCode ?? 0, contentType, bodySnippet: body });
|
||||
const wwwAuthenticate = res.headers["www-authenticate"] ?? null;
|
||||
resolve({
|
||||
status: res.statusCode ?? 0,
|
||||
contentType,
|
||||
wwwAuthenticate: Array.isArray(wwwAuthenticate) ? wwwAuthenticate[0] : wwwAuthenticate,
|
||||
bodySnippet: body,
|
||||
});
|
||||
});
|
||||
res.on("error", () => resolve(null));
|
||||
}
|
||||
@@ -47,20 +54,58 @@ function fetchRaw(url: string, timeoutMs = 2500): Promise<RawProbeResult | null>
|
||||
|
||||
/**
|
||||
* Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise
|
||||
* ihre API bzw. deren Dokumentation/Schema anbietet. Bewusst eine
|
||||
* kuratierte, kurze Liste statt eines vollständigen Wortlisten-Bruteforce -
|
||||
* das hier ist ein Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||
* ihre API bzw. deren Dokumentation/Schema anbietet - sowohl generische
|
||||
* REST/OpenAPI/GraphQL-Konventionen als auch Pfade konkreter, in Homelabs
|
||||
* verbreiteter Software (Home Assistant, Proxmox, Portainer, die *arr-Reihe,
|
||||
* Grafana, Jellyfin/Plex, Pi-hole, Unifi, ...). Bewusst eine kuratierte
|
||||
* Liste statt eines vollständigen Wortlisten-Bruteforce - das hier ist ein
|
||||
* Hinweis-Scanner, kein Sicherheits-/Pentesting-Werkzeug.
|
||||
*/
|
||||
const CANDIDATE_PATHS: { path: string; type: string }[] = [
|
||||
// Generische REST-/OpenAPI-/GraphQL-Konventionen
|
||||
{ path: "/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/openapi.yaml", type: "OpenAPI" },
|
||||
{ path: "/swagger.json", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/swagger.yaml", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/api-docs", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/v2/api-docs", type: "OpenAPI (Swagger)" },
|
||||
{ path: "/swagger/index.html", type: "Swagger-UI" },
|
||||
{ path: "/swagger-ui", type: "Swagger-UI" },
|
||||
{ path: "/redoc", type: "OpenAPI (ReDoc)" },
|
||||
{ path: "/docs", type: "API-Dokumentation" },
|
||||
{ path: "/graphql", type: "GraphQL" },
|
||||
{ path: "/graphiql", type: "GraphQL" },
|
||||
{ path: "/api/graphql", type: "GraphQL" },
|
||||
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/api/v3", type: "REST-API" },
|
||||
{ path: "/api/v2", type: "REST-API" },
|
||||
{ path: "/api/v1", type: "REST-API" },
|
||||
{ path: "/api", type: "REST-API" },
|
||||
{ path: "/.well-known/openapi.json", type: "OpenAPI" },
|
||||
{ path: "/rest", type: "REST-API" },
|
||||
{ path: "/rpc", type: "JSON-RPC" },
|
||||
{ path: "/jsonrpc", type: "JSON-RPC" },
|
||||
{ path: "/api/status", type: "REST-API" },
|
||||
{ path: "/api/system", type: "REST-API" },
|
||||
{ path: "/api/info", type: "REST-API" },
|
||||
{ path: "/api/version", type: "REST-API" },
|
||||
{ path: "/api/config", type: "REST-API" },
|
||||
{ path: "/actuator", type: "Spring-Boot-Actuator" },
|
||||
{ path: "/actuator/health", type: "Spring-Boot-Actuator" },
|
||||
{ path: "/metrics", type: "Metriken (Prometheus-Format)" },
|
||||
{ path: "/healthz", type: "Health-Endpunkt" },
|
||||
{ path: "/health", type: "Health-Endpunkt" },
|
||||
// Konkrete, in Homelabs verbreitete Software
|
||||
{ path: "/api/config/core", type: "Home Assistant API" }, // erfordert Auth, meldet sich aber als API
|
||||
{ path: "/api2/json/version", type: "Proxmox API" },
|
||||
{ path: "/api/status", type: "Portainer API" },
|
||||
{ path: "/api/v3/system/status", type: "Sonarr/Radarr/Prowlarr API" },
|
||||
{ path: "/admin/api.php", type: "Pi-hole API" },
|
||||
{ path: "/System/Info/Public", type: "Jellyfin API" },
|
||||
{ path: "/identity", type: "Plex API" },
|
||||
{ path: "/api/health", type: "Grafana API" },
|
||||
{ path: "/api/self", type: "Unifi-Controller API" },
|
||||
{ path: "/api/v2/server/about", type: "Nextcloud API" },
|
||||
{ path: "/ocs/v1.php/cloud/capabilities", type: "Nextcloud API" },
|
||||
];
|
||||
|
||||
function looksLikeJson(body: string): boolean {
|
||||
@@ -70,11 +115,15 @@ function looksLikeJson(body: string): boolean {
|
||||
|
||||
/**
|
||||
* Prüft die kuratierten Kandidaten-Pfade unter einer Basis-URL parallel und
|
||||
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten: eine
|
||||
* JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
||||
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"), oder ein
|
||||
* Content-Type, der explizit auf JSON/GraphQL hindeutet. Reine HTML-Seiten
|
||||
* (z. B. eine 404-Fehlerseite des Frontends) zählen nicht.
|
||||
* liefert alle, die auf eine tatsächlich vorhandene API hindeuten:
|
||||
* - eine JSON-Antwort (egal ob 200 oder z. B. 401 "unauthorized" - eine
|
||||
* JSON-Fehlermeldung zeigt trotzdem "hier läuft eine API"),
|
||||
* - ein Content-Type, der explizit auf JSON/GraphQL/XML-API hindeutet,
|
||||
* - oder ein "WWW-Authenticate"-Header (401 mit diesem Header ist ein sehr
|
||||
* starkes Signal für eine authentifizierungspflichtige API, selbst wenn
|
||||
* der Body selbst nur eine schlichte Textmeldung ist).
|
||||
* Reine HTML-Seiten (z. B. eine 404-Fehlerseite des Frontends oder eine
|
||||
* Login-Weiterleitung ohne API-Signal) zählen nicht.
|
||||
*/
|
||||
export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||
const checks = await Promise.all(
|
||||
@@ -83,15 +132,33 @@ export async function detectApis(baseUrl: string): Promise<DetectedApi[]> {
|
||||
if (!result || result.status === 0 || result.status === 404) return null;
|
||||
|
||||
const contentTypeIsApi =
|
||||
result.contentType?.includes("json") || result.contentType?.includes("graphql");
|
||||
result.contentType?.includes("json") ||
|
||||
result.contentType?.includes("graphql") ||
|
||||
result.contentType?.includes("xml");
|
||||
const bodyIsJson = looksLikeJson(result.bodySnippet);
|
||||
const hasAuthChallenge = !!result.wwwAuthenticate;
|
||||
|
||||
if (!contentTypeIsApi && !bodyIsJson) return null;
|
||||
if (!contentTypeIsApi && !bodyIsJson && !hasAuthChallenge) return null;
|
||||
|
||||
const detected: DetectedApi = { path, type, status: result.status };
|
||||
return detected;
|
||||
})
|
||||
);
|
||||
|
||||
return checks.filter((c): c is DetectedApi => c !== null);
|
||||
const matches = checks.filter((c): c is DetectedApi => c !== null);
|
||||
|
||||
// Mehrere Kandidaten-Einträge können denselben Pfad haben (z. B. das
|
||||
// generische "/api/status" -> "REST-API" UND das Portainer-spezifische
|
||||
// "/api/status" -> "Portainer API") - nach Pfad deduplizieren, dabei die
|
||||
// spezifischere (nicht-generische) Beschriftung bevorzugen.
|
||||
const GENERIC_TYPES = new Set(["REST-API", "JSON-RPC", "Health-Endpunkt"]);
|
||||
const byPath = new Map<string, DetectedApi>();
|
||||
for (const match of matches) {
|
||||
const existing = byPath.get(match.path);
|
||||
if (!existing || (GENERIC_TYPES.has(existing.type) && !GENERIC_TYPES.has(match.type))) {
|
||||
byPath.set(match.path, match);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(byPath.values());
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
|
||||
import { probeHttp } from "./http.js";
|
||||
import { detectSoftware } from "./softwareDetection.js";
|
||||
import { findBestIconMatch } from "./iconDb.js";
|
||||
import { pingHost, isPingBinaryConfirmedMissing } from "./ping.js";
|
||||
|
||||
export interface ScanTarget {
|
||||
hostname: string;
|
||||
@@ -91,18 +90,6 @@ export async function scanDeviceServices(
|
||||
// gemeldeten Namen ein.
|
||||
const suggestedHostname = dnsResult ? null : await reverseLookup(device.ip);
|
||||
|
||||
// Vor dem eigentlichen Portscan erst ein einzelner Ping (ICMP) - ist das
|
||||
// Gerät gar nicht erreichbar (aus, im Standby, vom Netz getrennt), spart
|
||||
// das den kompletten Portscan (auch parallel noch ~800ms) UND alle
|
||||
// nachfolgenden DNS/HTTP-Versuche. Nur wenn KEIN Ping ankommt wird
|
||||
// übersprungen - manche Geräte blocken ICMP, antworten aber auf TCP, dafür
|
||||
// bleibt genau deswegen bewusst KEIN weiterer früher Abbruch bestehen,
|
||||
// sondern nur dieser eine zusätzliche, sehr schnelle Vorab-Check.
|
||||
const reachable = await pingHost(device.ip);
|
||||
if (!reachable && !isPingBinaryConfirmedMissing()) {
|
||||
return { services: [], suggestedHostname };
|
||||
}
|
||||
|
||||
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
|
||||
|
||||
// Die offenen Ports werden PARALLEL geprüft, nicht nacheinander - bei
|
||||
|
||||
Reference in New Issue
Block a user