import http from "node:http"; import https from "node:https"; export interface DetectedApi { path: string; type: string; status: number; } interface RawProbeResult { status: number; contentType: string | null; wwwAuthenticate: string | null; bodySnippet: string; } const MAX_BODY_BYTES = 8_192; function fetchRaw(url: string, timeoutMs = 2500): Promise { return new Promise((resolve) => { const isHttps = url.startsWith("https://"); const client = isHttps ? https : http; const req = client.get( url, { timeout: timeoutMs, rejectUnauthorized: false, headers: { Accept: "application/json, */*" } }, (res) => { let body = ""; let received = 0; res.on("data", (chunk: Buffer) => { received += chunk.length; if (received <= MAX_BODY_BYTES) body += chunk.toString("utf-8"); }); res.on("end", () => { const contentType = res.headers["content-type"] ?? null; 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)); } ); req.on("timeout", () => { req.destroy(); resolve(null); }); req.on("error", () => resolve(null)); }); } /** * Wohlbekannte Pfade, unter denen selbstgehostete Software üblicherweise * 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 - bewusst nur Pfade, die // auf eine tatsächlich NUTZBARE/dokumentierte API hindeuten. Reine // Status-/Health-/Metrik-Endpunkte (/health, /metrics, /actuator, ...) // wurden bewusst rausgenommen: die bestätigen nur "hier läuft Software", // sind aber keine API, mit der man tatsächlich etwas anfangen kann. { 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: "/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: "/rest", type: "REST-API" }, { path: "/rpc", type: "JSON-RPC" }, { path: "/jsonrpc", type: "JSON-RPC" }, // Konkrete, in Homelabs verbreitete Software - jeweils ein Endpunkt IHRER // eigenen echten API (nicht nur ein Status-Ping), deshalb behalten. { 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/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 { const trimmed = body.trim(); return trimmed.startsWith("{") || trimmed.startsWith("["); } /** * 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"), * - 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 { const checks = await Promise.all( CANDIDATE_PATHS.map(async ({ path, type }) => { const result = await fetchRaw(`${baseUrl}${path}`); if (!result || result.status === 0 || result.status === 404) return null; const contentTypeIsApi = result.contentType?.includes("json") || result.contentType?.includes("graphql") || result.contentType?.includes("xml"); const bodyIsJson = looksLikeJson(result.bodySnippet); const hasAuthChallenge = !!result.wwwAuthenticate; if (!contentTypeIsApi && !bodyIsJson && !hasAuthChallenge) return null; const detected: DetectedApi = { path, type, status: result.status }; return detected; }) ); 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(); 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()); }