generated from Dicken/dickendock
158 lines
6.4 KiB
TypeScript
158 lines
6.4 KiB
TypeScript
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<RawProbeResult | null> {
|
|
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<DetectedApi[]> {
|
|
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<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());
|
|
}
|