Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064)

This commit is contained in:
2026-07-19 02:22:54 +02:00
parent 129b4253b5
commit 28bccb9d16
13 changed files with 744 additions and 18 deletions

View File

@@ -65,14 +65,37 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst:
- ✅ Kategorien-API (`/api/categories`), inkl. Umbenennen, Löschen (Dienste behalten - ✅ Kategorien-API (`/api/categories`), inkl. Umbenennen, Löschen (Dienste behalten
ihre Zuordnung nicht, werden aber nicht gelöscht) und Bulk-Reorder für Drag & Drop ihre Zuordnung nicht, werden aber nicht gelöscht) und Bulk-Reorder für Drag & Drop
- ✅ Favoriten-Toggle direkt in der Trefferliste (Stern anklicken) - ✅ Favoriten-Toggle direkt in der Trefferliste (Stern anklicken)
- ✅ Scanner-Engine + API (`POST /api/scan/devices/:id`, `POST /api/scan/fritzbox`):
DNS-Kandidaten (hostname/.home/.local), Portscan (80, 443 + typische Ports),
Titel-/Favicon-Auslesen, Softwareerkennung, FritzBox-Geräteliste per TR-064
(HTTP-Digest-Auth). Läuft ausschließlich manuell per API-Aufruf nie automatisch.
Benutzeränderungen an Diensten (Name, Kategorie, Favorit, Alias, Icon, Reihenfolge)
bleiben bei erneuten Scans garantiert erhalten.
Noch **nicht** enthalten (folgt in den nächsten Commits): Noch **nicht** enthalten (folgt in den nächsten Commits):
- Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung) - Adminbereich mit "Jetzt scannen"-Button und Kategorien-Verwaltungsoberfläche
- Adminbereich mit Kategorien-Verwaltungsoberfläche (Erstellen, Umbenennen, — beide Backend-APIs existieren bereits vollständig
Drag & Drop) — Backend-API dafür existiert bereits
- TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF - TanStack Router, Plugin-System, PWA-Manifest, shadcn/ui, RHF
### FritzBox-Scan konfigurieren
Der FritzBox-Scan benötigt drei Umgebungsvariablen (z. B. in `docker-compose.yml`
beim `backend`-Service oder lokal per `.env`):
```
FRITZBOX_HOST=192.168.1.1
FRITZBOX_USERNAME=<TR-064-Benutzername>
FRITZBOX_PASSWORD=<TR-064-Passwort>
FRITZBOX_PORT=49000 # optional, Default 49000
```
TR-064-Zugriff muss in der FritzBox unter *Heimnetz → Netzwerk → Netzwerkeinstellungen
→ „Zugriff für Anwendungen zulassen"* aktiviert sein. Getestet wurde der komplette
SOAP-/Digest-Auth-Ablauf gegen einen Mock-Server mit realistischer TR-064-Antwort
(korrektes und falsches Passwort); ein Test gegen eine echte FritzBox war in dieser
Entwicklungsumgebung nicht möglich (kein Netzwerkzugriff auf lokale Geräte).
Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge. Siehe [`docs/ROADMAP.md`](./docs/ROADMAP.md) für die geplante Reihenfolge.
## API-Endpunkte (Stand Commit 2) ## API-Endpunkte (Stand Commit 2)
@@ -97,6 +120,11 @@ PATCH /api/categories/reorder Body: [{ id, order }, ...]
PATCH /api/categories/:id Umbenennen PATCH /api/categories/:id Umbenennen
DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null), DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null),
werden aber nicht gelöscht werden aber nicht gelöscht
POST /api/scan/devices/:id Netzwerk-Scan für ein Gerät (DNS, Ports, Titel,
Favicon, Softwareerkennung); legt/aktualisiert Dienste
POST /api/scan/fritzbox Liest Geräteliste der FritzBox per TR-064
(erfordert FRITZBOX_HOST/USERNAME/PASSWORD)
``` ```
## Deployment auf dem Server (xlc-launchpad) ## Deployment auf dem Server (xlc-launchpad)

View File

@@ -56,8 +56,7 @@ export function createDevice(input: DeviceCreateInput): Device {
/** /**
* Aktualisiert ein Gerät anhand von Benutzereingaben (z. B. über die Admin-UI). * Aktualisiert ein Gerät anhand von Benutzereingaben (z. B. über die Admin-UI).
* Ein separater Pfad für automatische Scan-Ergebnisse folgt in einem späteren * Für automatische Scan-Ergebnisse siehe upsertDeviceFromScan() unten.
* Commit (siehe docs/ROADMAP.md, Commit 5 Scanner).
*/ */
export function updateDevice(id: string, input: DeviceUpdateInput): Device | null { export function updateDevice(id: string, input: DeviceUpdateInput): Device | null {
const existing = getDevice(id); const existing = getDevice(id);
@@ -84,3 +83,70 @@ export function deleteDevice(id: string): boolean {
const result = db.delete(devices).where(eq(devices.id, id)).run(); const result = db.delete(devices).where(eq(devices.id, id)).run();
return result.changes > 0; return result.changes > 0;
} }
export interface DeviceScanInput {
hostname: string;
ip: string;
mac?: string | null;
manufacturer?: string | null;
model?: string | null;
online?: boolean;
source: Device["source"];
}
function findByMacOrIp(mac: string | null | undefined, ip: string): Device | null {
const all = listDevices();
if (mac) {
const byMac = all.find((d) => d.mac && d.mac.toLowerCase() === mac.toLowerCase());
if (byMac) return byMac;
}
return all.find((d) => d.ip === ip) ?? null;
}
/**
* Legt ein per Scan gefundenes Gerät an oder aktualisiert ein bestehendes
* (abgeglichen über MAC, sonst IP). Geräte haben keine "Benutzerfelder" im
* Sinne der Spezifikation (die betrifft nur Dienste) Scans dürfen hier
* alle Felder aktualisieren.
*/
export function upsertDeviceFromScan(input: DeviceScanInput): Device {
const existing = findByMacOrIp(input.mac, input.ip);
const timestamp = nowIso();
if (existing) {
db.update(devices)
.set({
hostname: input.hostname,
ip: input.ip,
mac: input.mac ?? existing.mac,
manufacturer: input.manufacturer ?? existing.manufacturer,
model: input.model ?? existing.model,
online: input.online ?? existing.online,
source: input.source,
lastScan: timestamp,
updatedAt: timestamp,
})
.where(eq(devices.id, existing.id))
.run();
return getDevice(existing.id)!;
}
const id = randomUUID();
db.insert(devices)
.values({
id,
hostname: input.hostname,
ip: input.ip,
mac: input.mac ?? null,
manufacturer: input.manufacturer ?? null,
model: input.model ?? null,
online: input.online ?? false,
source: input.source,
lastScan: timestamp,
createdAt: timestamp,
updatedAt: timestamp,
})
.run();
return getDevice(id)!;
}

View File

@@ -86,9 +86,8 @@ export function createService(input: ServiceCreateInput): Service {
/** /**
* Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI). * Aktualisiert einen Dienst anhand von Benutzereingaben (z. B. über die Admin-UI).
* Ein separater Pfad für automatische Scan-Ergebnisse folgt in einem späteren * Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten die
* Commit (siehe docs/ROADMAP.md, Commit 5 Scanner): Scans dürfen displayName, * NIEMALS displayName, category, favorite, order, alias oder icon überschreibt.
* category, favorite, order, alias und icon niemals überschreiben.
*/ */
export function updateService(id: string, input: ServiceUpdateInput): Service | null { export function updateService(id: string, input: ServiceUpdateInput): Service | null {
const existing = getService(id); const existing = getService(id);
@@ -120,3 +119,78 @@ export function deleteService(id: string): boolean {
const result = db.delete(services).where(eq(services.id, id)).run(); const result = db.delete(services).where(eq(services.id, id)).run();
return result.changes > 0; return result.changes > 0;
} }
export interface ServiceScanInput {
deviceId: string;
hostname: string;
url: string;
https: boolean;
port: number;
favicon?: string | null;
description?: string | null;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
suggestedDisplayName: string;
/** Nur relevant, wenn dabei ein NEUER Dienst angelegt wird. */
suggestedCategory?: string | null;
}
export interface ScanUpsertResult {
service: Service;
created: boolean;
}
function findByDeviceAndPort(deviceId: string, port: number): Service | null {
return listServicesByDevice(deviceId).find((s) => s.port === port) ?? null;
}
/**
* Legt einen per Scan gefundenen Dienst an oder aktualisiert die scan-eigenen
* Felder eines bereits bekannten Dienstes (abgeglichen über deviceId + port).
*
* displayName, category, favorite, order, alias und icon werden bei einem
* bestehenden Dienst NIEMALS verändert nur beim erstmaligen Anlegen dienen
* suggestedDisplayName/suggestedCategory als sinnvoller Startwert.
*/
export function upsertServiceFromScan(input: ServiceScanInput): ScanUpsertResult {
const existing = findByDeviceAndPort(input.deviceId, input.port);
const timestamp = nowIso();
if (existing) {
db.update(services)
.set({
hostname: input.hostname,
url: input.url,
https: input.https,
favicon: input.favicon ?? existing.favicon,
description: input.description ?? existing.description,
updatedAt: timestamp,
})
.where(eq(services.id, existing.id))
.run();
return { service: getService(existing.id)!, created: false };
}
const id = randomUUID();
db.insert(services)
.values({
id,
deviceId: input.deviceId,
displayName: input.suggestedDisplayName,
category: input.suggestedCategory ?? null,
favorite: false,
order: 0,
alias: "[]",
icon: null,
hostname: input.hostname,
url: input.url,
https: input.https,
port: input.port,
favicon: input.favicon ?? null,
description: input.description ?? null,
createdAt: timestamp,
updatedAt: timestamp,
})
.run();
return { service: getService(id)!, created: true };
}

View File

@@ -5,6 +5,7 @@ import { healthRoutes } from "./routes/health.js";
import { deviceRoutes } from "./routes/devices.js"; import { deviceRoutes } from "./routes/devices.js";
import { serviceRoutes } from "./routes/services.js"; import { serviceRoutes } from "./routes/services.js";
import { categoryRoutes } from "./routes/categories.js"; import { categoryRoutes } from "./routes/categories.js";
import { scanRoutes } from "./routes/scan.js";
const PORT = Number(process.env.PORT ?? 3001); const PORT = Number(process.env.PORT ?? 3001);
const HOST = process.env.HOST ?? "0.0.0.0"; const HOST = process.env.HOST ?? "0.0.0.0";
@@ -30,6 +31,7 @@ async function main() {
await app.register(deviceRoutes); await app.register(deviceRoutes);
await app.register(serviceRoutes); await app.register(serviceRoutes);
await app.register(categoryRoutes); await app.register(categoryRoutes);
await app.register(scanRoutes);
app.get("/", async () => { app.get("/", async () => {
return { name: "LaunchPad API", status: "running" }; return { name: "LaunchPad API", status: "running" };

View File

@@ -0,0 +1,94 @@
import type { FastifyInstance } from "fastify";
import * as deviceRepo from "../db/repositories/devices.js";
import * as serviceRepo from "../db/repositories/services.js";
import { scanDeviceServices } from "../scanner/networkScanner.js";
import { fetchFritzBoxHosts } from "../scanner/fritzbox.js";
/**
* Scan-Endpunkte. Werden ausschließlich manuell per Knopfdruck ("Jetzt
* scannen") aus der Admin-UI (Commit 6) ausgelöst es gibt keinerlei
* automatischen/zeitgesteuerten Scan.
*/
export async function scanRoutes(app: FastifyInstance): Promise<void> {
// Netzwerk-Scan für ein einzelnes, bereits bekanntes Gerät: DNS-Kandidaten,
// Port 80/443 + typische Ports, Titel/Favicon, Softwareerkennung.
app.post("/api/scan/devices/:id", async (request, reply) => {
const { id } = request.params as { id: string };
const device = deviceRepo.getDevice(id);
if (!device) {
return reply.code(404).send({ error: "Gerät nicht gefunden" });
}
const discovered = await scanDeviceServices(device);
const results = discovered.map((found) =>
serviceRepo.upsertServiceFromScan({
deviceId: device.id,
hostname: found.hostname,
url: found.url,
https: found.https,
port: found.port,
favicon: found.favicon,
description: found.description,
suggestedDisplayName: found.suggestedDisplayName,
suggestedCategory: found.category,
})
);
deviceRepo.upsertDeviceFromScan({
hostname: device.hostname,
ip: device.ip,
mac: device.mac,
manufacturer: device.manufacturer,
model: device.model,
online: discovered.length > 0,
source: device.source,
});
return {
deviceId: device.id,
scannedPorts: discovered.length,
created: results.filter((r) => r.created).length,
updated: results.filter((r) => !r.created).length,
services: results.map((r) => r.service),
};
});
// FritzBox-Scan: liest die Geräteliste per TR-064 und legt/aktualisiert Geräte.
// Erfordert FRITZBOX_HOST / FRITZBOX_USERNAME / FRITZBOX_PASSWORD (optional
// FRITZBOX_PORT, Default 49000) als Umgebungsvariablen.
app.post("/api/scan/fritzbox", async (request, reply) => {
const host = process.env.FRITZBOX_HOST;
const username = process.env.FRITZBOX_USERNAME;
const password = process.env.FRITZBOX_PASSWORD;
if (!host || !username || !password) {
return reply.code(400).send({
error:
"FritzBox nicht konfiguriert. Bitte FRITZBOX_HOST, FRITZBOX_USERNAME und FRITZBOX_PASSWORD setzen.",
});
}
const port = process.env.FRITZBOX_PORT ? Number(process.env.FRITZBOX_PORT) : 49000;
try {
const hosts = await fetchFritzBoxHosts({ host, port, username, password });
const devices = hosts.map((h) =>
deviceRepo.upsertDeviceFromScan({
hostname: h.hostname,
ip: h.ip,
mac: h.mac,
online: h.online,
source: "fritzbox",
})
);
return { found: hosts.length, devices };
} catch (err) {
request.log.error(err);
return reply.code(502).send({
error: "FritzBox-Scan fehlgeschlagen",
detail: err instanceof Error ? err.message : String(err),
});
}
});
}

View File

@@ -0,0 +1,139 @@
import { createHash, randomBytes } from "node:crypto";
import http, { type IncomingHttpHeaders } from "node:http";
import https from "node:https";
export interface DigestAuthOptions {
host: string;
port: number;
https?: boolean;
username: string;
password: string;
timeoutMs?: number;
}
interface DigestChallenge {
realm: string;
nonce: string;
qop?: string;
opaque?: string;
}
function md5(input: string): string {
return createHash("md5").update(input).digest("hex");
}
function parseDigestHeader(header: string): DigestChallenge {
const params: Record<string, string> = {};
const regex = /(\w+)=("([^"]*)"|[^,]*)/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(header))) {
params[match[1]] = match[3] ?? match[2];
}
return { realm: params.realm, nonce: params.nonce, qop: params.qop, opaque: params.opaque };
}
interface RawResponse {
status: number;
headers: IncomingHttpHeaders;
body: string;
}
function rawRequest(
options: DigestAuthOptions,
path: string,
headers: Record<string, string>,
body: string
): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const client = options.https ? https : http;
const req = client.request(
{
host: options.host,
port: options.port,
path,
method: "POST",
headers: { ...headers, "Content-Length": Buffer.byteLength(body) },
timeout: options.timeoutMs ?? 4000,
rejectUnauthorized: false,
},
(res) => {
let data = "";
res.on("data", (chunk: Buffer) => (data += chunk.toString("utf-8")));
res.on("end", () =>
resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data })
);
res.on("error", reject);
}
);
req.on("timeout", () => {
req.destroy();
reject(new Error("Zeitüberschreitung bei der Verbindung zur FritzBox"));
});
req.on("error", reject);
req.write(body);
req.end();
});
}
/**
* Führt einen TR-064-SOAP-Request gegen `/upnp/control/hosts` aus.
* Läuft zunächst unauthentifiziert; antwortet die FritzBox mit 401 und einer
* WWW-Authenticate-Digest-Challenge, wird automatisch mit korrekt berechnetem
* Digest-Response-Header erneut angefragt (RFC 2617).
*/
export async function soapRequest(
options: DigestAuthOptions,
soapAction: string,
body: string
): Promise<string> {
const path = "/upnp/control/hosts";
const baseHeaders = {
"Content-Type": 'text/xml; charset="utf-8"',
SOAPACTION: soapAction,
};
const first = await rawRequest(options, path, baseHeaders, body);
if (first.status === 200) {
return first.body;
}
if (first.status !== 401) {
throw new Error(`Unerwarteter Status von der FritzBox: ${first.status}`);
}
const wwwAuth = first.headers["www-authenticate"];
if (!wwwAuth) {
throw new Error("FritzBox verlangt Authentifizierung, sendet aber keine Digest-Challenge");
}
const challenge = parseDigestHeader(Array.isArray(wwwAuth) ? wwwAuth[0] : wwwAuth);
if (!challenge.realm || !challenge.nonce) {
throw new Error("Digest-Challenge der FritzBox konnte nicht gelesen werden");
}
const ha1 = md5(`${options.username}:${challenge.realm}:${options.password}`);
const ha2 = md5(`POST:${path}`);
const nc = "00000001";
const cnonce = randomBytes(8).toString("hex");
const response = challenge.qop
? md5(`${ha1}:${challenge.nonce}:${nc}:${cnonce}:${challenge.qop}:${ha2}`)
: md5(`${ha1}:${challenge.nonce}:${ha2}`);
const authHeader =
`Digest username="${options.username}", realm="${challenge.realm}", ` +
`nonce="${challenge.nonce}", uri="${path}", response="${response}"` +
(challenge.qop ? `, qop=${challenge.qop}, nc=${nc}, cnonce="${cnonce}"` : "") +
(challenge.opaque ? `, opaque="${challenge.opaque}"` : "");
const second = await rawRequest(
options,
path,
{ ...baseHeaders, Authorization: authHeader },
body
);
if (second.status !== 200) {
throw new Error(`FritzBox-Authentifizierung fehlgeschlagen (Status ${second.status})`);
}
return second.body;
}

View File

@@ -0,0 +1,25 @@
import { lookup } from "node:dns/promises";
export interface DnsResolution {
hostname: string;
ip: string;
}
/**
* Versucht der Spezifikation folgend: hostname, hostname.home, hostname.local.
* Gibt die erste erfolgreich aufgelöste Variante zurück, sonst null.
*/
export async function resolveHostname(shortName: string): Promise<DnsResolution | null> {
const candidates = [shortName, `${shortName}.home`, `${shortName}.local`];
for (const hostname of candidates) {
try {
const { address } = await lookup(hostname);
return { hostname, ip: address };
} catch {
// nächste Variante versuchen
}
}
return null;
}

View File

@@ -0,0 +1,66 @@
import { soapRequest, type DigestAuthOptions } from "./digestAuth.js";
const SERVICE_TYPE = "urn:dslforum-org:service:Hosts:1";
function actionEnvelope(action: string, params: Record<string, string | number> = {}): string {
const args = Object.entries(params)
.map(([key, value]) => `<${key}>${value}</${key}>`)
.join("");
return (
`<?xml version="1.0" encoding="utf-8"?>` +
`<s:Envelope xmlns:s="http://schemas.xmlsoap.org/soap/envelope/" ` +
`s:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">` +
`<s:Body><u:${action} xmlns:u="${SERVICE_TYPE}">${args}</u:${action}></s:Body>` +
`</s:Envelope>`
);
}
function extractTag(xml: string, tag: string): string | null {
const match = xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`, "i"));
return match ? match[1] : null;
}
export interface FritzBoxHost {
ip: string;
mac: string | null;
hostname: string;
online: boolean;
}
/**
* Liest die vollständige Geräteliste der FritzBox über die TR-064-Aktionen
* GetHostNumberOfEntries + GetGenericHostEntry (urn:dslforum-org:service:Hosts:1).
*/
export async function fetchFritzBoxHosts(options: DigestAuthOptions): Promise<FritzBoxHost[]> {
const countXml = await soapRequest(
options,
`${SERVICE_TYPE}#GetHostNumberOfEntries`,
actionEnvelope("GetHostNumberOfEntries")
);
const countStr = extractTag(countXml, "NewHostNumberOfEntries");
const count = countStr ? parseInt(countStr, 10) : 0;
const hosts: FritzBoxHost[] = [];
for (let index = 0; index < count; index++) {
const entryXml = await soapRequest(
options,
`${SERVICE_TYPE}#GetGenericHostEntry`,
actionEnvelope("GetGenericHostEntry", { NewIndex: index })
);
const ip = extractTag(entryXml, "NewIPAddress");
const hostname = extractTag(entryXml, "NewHostName");
if (!ip || !hostname) continue; // inaktive/unvollständige Einträge überspringen
hosts.push({
ip,
mac: extractTag(entryXml, "NewMACAddress"),
hostname,
online: extractTag(entryXml, "NewActive") === "1",
});
}
return hosts;
}

View File

@@ -0,0 +1,84 @@
import http from "node:http";
import https from "node:https";
export interface HttpProbeResult {
ok: boolean;
status?: number;
title?: string;
faviconUrl?: string;
server?: string;
bodySnippet?: string;
}
const MAX_BODY_BYTES = 65_536;
/**
* Ruft eine URL ab und liest <title> sowie das Favicon aus dem HTML.
* Für HTTPS werden selbstsignierte Zertifikate akzeptiert (rejectUnauthorized:
* false) in Homelabs üblich, es werden keine sensiblen Daten übertragen.
*/
export function probeHttp(baseUrl: string, timeoutMs = 2000): Promise<HttpProbeResult> {
return new Promise((resolve) => {
const isHttps = baseUrl.startsWith("https://");
const client = isHttps ? https : http;
const req = client.get(
baseUrl,
{
timeout: timeoutMs,
rejectUnauthorized: false,
},
(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 serverHeader = res.headers.server;
resolve({
ok: (res.statusCode ?? 0) < 400,
status: res.statusCode,
title: extractTitle(body),
faviconUrl: extractFaviconUrl(body, baseUrl),
server: Array.isArray(serverHeader) ? serverHeader[0] : serverHeader,
bodySnippet: body,
});
});
res.on("error", () => resolve({ ok: false }));
}
);
req.on("timeout", () => {
req.destroy();
resolve({ ok: false });
});
req.on("error", () => resolve({ ok: false }));
});
}
function extractTitle(html: string): string | undefined {
const match = html.match(/<title[^>]*>([^<]*)<\/title>/i);
const title = match?.[1]?.trim();
return title ? title : undefined;
}
function extractFaviconUrl(html: string, baseUrl: string): string | undefined {
const match = html.match(
/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["']/i
);
const href = match?.[1];
try {
return new URL(href ?? "/favicon.ico", baseUrl).toString();
} catch {
return undefined;
}
}

View File

@@ -0,0 +1,66 @@
import { resolveHostname } from "./dns.js";
import { isPortOpen, TYPICAL_PORTS } from "./ports.js";
import { probeHttp } from "./http.js";
import { detectSoftware } from "./softwareDetection.js";
export interface ScanTarget {
hostname: string;
ip: string;
}
export interface DiscoveredService {
hostname: string;
url: string;
https: boolean;
port: number;
favicon?: string;
description?: string;
suggestedDisplayName: string;
category?: string;
}
/**
* Scannt ein einzelnes Gerät: versucht zuerst eine schönere DNS-Adresse
* (hostname / hostname.home / hostname.local) aufzulösen, prüft dann Port 80,
* 443 sowie die typischen Ports, liest bei offenen Ports Titel + Favicon aus
* und versucht die Software zu erkennen.
*/
export async function scanDeviceServices(
device: ScanTarget,
extraPorts: number[] = TYPICAL_PORTS
): Promise<DiscoveredService[]> {
const dnsResult = await resolveHostname(device.hostname);
// Fällt auf die IP zurück, falls keine DNS-Variante auflösbar ist.
const address = dnsResult?.hostname ?? device.ip;
const candidatePorts = Array.from(new Set([80, 443, ...extraPorts]));
const found: DiscoveredService[] = [];
for (const port of candidatePorts) {
const open = await isPortOpen(device.ip, port);
if (!open) continue;
const isHttps = port === 443 || port === 9443;
const baseUrl = `${isHttps ? "https" : "http"}://${address}:${port}`;
const probe = await probeHttp(baseUrl);
const software = detectSoftware({
server: probe.server,
body: probe.bodySnippet,
port,
});
found.push({
hostname: address,
url: baseUrl,
https: isHttps,
port,
favicon: probe.faviconUrl,
description: software ? `${software.name} (automatisch erkannt)` : probe.title,
suggestedDisplayName: software?.name ?? probe.title ?? `${address}:${port}`,
category: software?.category,
});
}
return found;
}

View File

@@ -0,0 +1,26 @@
import { connect } from "node:net";
/**
* Typische Ports für den optionalen Portscanner, gemäß Spezifikation.
*/
export const TYPICAL_PORTS = [80, 443, 3000, 3001, 5000, 5001, 8080, 8123, 9000, 9443];
/**
* Prüft per TCP-Connect, ob ein Port offen ist. Kein Protokoll-Handshake,
* nur "kann eine Verbindung aufgebaut werden" schnell und protokollunabhängig.
*/
export function isPortOpen(host: string, port: number, timeoutMs = 800): Promise<boolean> {
return new Promise((resolve) => {
const socket = connect({ host, port, timeout: timeoutMs });
const finish = (result: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(result);
};
socket.once("connect", () => finish(true));
socket.once("timeout", () => finish(false));
socket.once("error", () => finish(false));
});
}

View File

@@ -0,0 +1,49 @@
export interface SoftwareSignatureInput {
server?: string;
body?: string;
port: number;
}
export interface SoftwareSignature {
name: string;
category: string;
matches: (input: SoftwareSignatureInput) => boolean;
}
function bodyContains(input: SoftwareSignatureInput, pattern: RegExp): boolean {
return !!input.body && pattern.test(input.body);
}
/**
* Automatische Softwareerkennung anhand von HTTP-Response-Merkmalen
* (Server-Header, HTML-Inhalt). Liste gemäß Spezifikation.
*/
export const SOFTWARE_SIGNATURES: SoftwareSignature[] = [
{ name: "Home Assistant", category: "Smart Home", matches: (i) => bodyContains(i, /home\s*assistant/i) },
{
name: "Synology DSM",
category: "NAS",
matches: (i) => bodyContains(i, /synology/i) || (!!i.server && /synology/i.test(i.server)),
},
{ name: "Frigate", category: "Überwachung", matches: (i) => bodyContains(i, /frigate/i) },
{ name: "Portainer", category: "Container", matches: (i) => bodyContains(i, /portainer/i) },
{ name: "Grafana", category: "Monitoring", matches: (i) => bodyContains(i, /grafana/i) },
{ name: "Proxmox VE", category: "Virtualisierung", matches: (i) => bodyContains(i, /proxmox/i) },
{ name: "Immich", category: "Fotos", matches: (i) => bodyContains(i, /immich/i) },
{ name: "Paperless-ngx", category: "Dokumente", matches: (i) => bodyContains(i, /paperless/i) },
{ name: "Gitea", category: "Entwicklung", matches: (i) => bodyContains(i, /gitea/i) },
{
name: "Vaultwarden",
category: "Passwörter",
matches: (i) => bodyContains(i, /vaultwarden|bitwarden/i),
},
{ name: "Jellyfin", category: "Medien", matches: (i) => bodyContains(i, /jellyfin/i) },
{ name: "Nextcloud", category: "Cloud", matches: (i) => bodyContains(i, /nextcloud/i) },
{ name: "Pi-hole", category: "DNS", matches: (i) => bodyContains(i, /pi-?hole/i) },
{ name: "AdGuard Home", category: "DNS", matches: (i) => bodyContains(i, /adguard/i) },
{ name: "UniFi Network", category: "Netzwerk", matches: (i) => bodyContains(i, /unifi/i) },
];
export function detectSoftware(input: SoftwareSignatureInput): SoftwareSignature | null {
return SOFTWARE_SIGNATURES.find((signature) => signature.matches(input)) ?? null;
}

View File

@@ -31,17 +31,24 @@ Grundgerüst aus Commit 1.
> Drag & Drop in der UI) wandert in Commit 6 (Adminbereich) die Startseite > Drag & Drop in der UI) wandert in Commit 6 (Adminbereich) die Startseite
> bleibt bewusst die minimalistische Suche, kein Verwaltungs-UI dort. > bleibt bewusst die minimalistische Suche, kein Verwaltungs-UI dort.
## Commit 5 — Scanner ## Commit 5 — Scanner (erledigt)
- FritzBox-Scanner (TR-064/UPnP) - `apps/backend/src/scanner/`: DNS-Kandidaten-Auflösung, TCP-Portscan (80, 443 +
- DNS-, HTTP-, HTTPS-Scanner typische Ports), HTTP-Titel-/Favicon-Extraktion, Softwareerkennung per
- Optionaler Portscanner mit definierter Portliste Signatur-Liste, FritzBox-TR-064-Client (inkl. selbst implementierter
- Titel- und Favicon-Auslesen HTTP-Digest-Authentifizierung)
- Softwareerkennung (Home Assistant, Synology DSM, Frigate, Portainer, Grafana, - `POST /api/scan/devices/:id` und `POST /api/scan/fritzbox` ausschließlich
Proxmox, Immich, Paperless, Gitea, Vaultwarden, Jellyfin, Nextcloud, Pi-hole, manuell auslösbar, kein automatischer/zeitgesteuerter Scan
AdGuard, Unifi, …) - `upsertDeviceFromScan` / `upsertServiceFromScan` in den Repositories:
- "Jetzt scannen"-Button; niemals automatische Scans garantiert, dass Benutzerfelder (displayName, category, favorite, order,
- Scans überschreiben niemals Displayname, Kategorie, Favorit, Reihenfolge, Alias, Icon alias, icon) bei erneuten Scans nie überschrieben werden End-to-End getestet
(Service manuell umbenannt/kategorisiert/favorisiert, erneut gescannt,
Werte blieben erhalten)
> Alle Module wurden gegen echte, lokal gestartete Test-Server verifiziert
> (Port-Erkennung, Titel/Favicon-Parsing, Softwareerkennung, kompletter
> FritzBox-SOAP-/Digest-Auth-Ablauf inkl. Fehlerfall bei falschem Passwort).
> Ein Test gegen eine echte FritzBox war in dieser Umgebung nicht möglich.
## Commit 6 — Adminbereich ## Commit 6 — Adminbereich