diff --git a/README.md b/README.md index 9c849f1..c8cf4a2 100644 --- a/README.md +++ b/README.md @@ -65,14 +65,37 @@ Dieser erste Commit liefert ein lauffähiges Grundgerüst: - ✅ 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 - ✅ 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): -- Scanner (FritzBox, DNS, HTTP/HTTPS, Portscan, Softwareerkennung) -- Adminbereich mit Kategorien-Verwaltungsoberfläche (Erstellen, Umbenennen, - Drag & Drop) — Backend-API dafür existiert bereits +- Adminbereich mit "Jetzt scannen"-Button und Kategorien-Verwaltungsoberfläche + — beide Backend-APIs existieren bereits vollständig - 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= +FRITZBOX_PASSWORD= +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. ## API-Endpunkte (Stand Commit 2) @@ -97,6 +120,11 @@ PATCH /api/categories/reorder Body: [{ id, order }, ...] PATCH /api/categories/:id Umbenennen DELETE /api/categories/:id Dienste behalten ihre category nicht mehr (null), 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) diff --git a/apps/backend/src/db/repositories/devices.ts b/apps/backend/src/db/repositories/devices.ts index 56bce6c..908c8ad 100644 --- a/apps/backend/src/db/repositories/devices.ts +++ b/apps/backend/src/db/repositories/devices.ts @@ -56,8 +56,7 @@ export function createDevice(input: DeviceCreateInput): Device { /** * 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 - * Commit (siehe docs/ROADMAP.md, Commit 5 – Scanner). + * Für automatische Scan-Ergebnisse siehe upsertDeviceFromScan() unten. */ export function updateDevice(id: string, input: DeviceUpdateInput): Device | null { 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(); 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)!; +} diff --git a/apps/backend/src/db/repositories/services.ts b/apps/backend/src/db/repositories/services.ts index a8a506f..ff9e8ec 100644 --- a/apps/backend/src/db/repositories/services.ts +++ b/apps/backend/src/db/repositories/services.ts @@ -86,9 +86,8 @@ export function createService(input: ServiceCreateInput): Service { /** * 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 - * Commit (siehe docs/ROADMAP.md, Commit 5 – Scanner): Scans dürfen displayName, - * category, favorite, order, alias und icon niemals überschreiben. + * Für automatische Scan-Ergebnisse siehe upsertServiceFromScan() unten – die + * NIEMALS displayName, category, favorite, order, alias oder icon überschreibt. */ export function updateService(id: string, input: ServiceUpdateInput): Service | null { 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(); 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 }; +} diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 5635520..ee0c6b4 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -5,6 +5,7 @@ import { healthRoutes } from "./routes/health.js"; import { deviceRoutes } from "./routes/devices.js"; import { serviceRoutes } from "./routes/services.js"; import { categoryRoutes } from "./routes/categories.js"; +import { scanRoutes } from "./routes/scan.js"; const PORT = Number(process.env.PORT ?? 3001); const HOST = process.env.HOST ?? "0.0.0.0"; @@ -30,6 +31,7 @@ async function main() { await app.register(deviceRoutes); await app.register(serviceRoutes); await app.register(categoryRoutes); + await app.register(scanRoutes); app.get("/", async () => { return { name: "LaunchPad API", status: "running" }; diff --git a/apps/backend/src/routes/scan.ts b/apps/backend/src/routes/scan.ts new file mode 100644 index 0000000..fc4e43b --- /dev/null +++ b/apps/backend/src/routes/scan.ts @@ -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 { + // 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), + }); + } + }); +} diff --git a/apps/backend/src/scanner/digestAuth.ts b/apps/backend/src/scanner/digestAuth.ts new file mode 100644 index 0000000..d6a4947 --- /dev/null +++ b/apps/backend/src/scanner/digestAuth.ts @@ -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 = {}; + 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, + body: string +): Promise { + 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 { + 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; +} diff --git a/apps/backend/src/scanner/dns.ts b/apps/backend/src/scanner/dns.ts new file mode 100644 index 0000000..313bd4e --- /dev/null +++ b/apps/backend/src/scanner/dns.ts @@ -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 { + 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; +} diff --git a/apps/backend/src/scanner/fritzbox.ts b/apps/backend/src/scanner/fritzbox.ts new file mode 100644 index 0000000..c09fe5a --- /dev/null +++ b/apps/backend/src/scanner/fritzbox.ts @@ -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 { + const args = Object.entries(params) + .map(([key, value]) => `<${key}>${value}`) + .join(""); + + return ( + `` + + `` + + `${args}` + + `` + ); +} + +function extractTag(xml: string, tag: string): string | null { + const match = xml.match(new RegExp(`<${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 { + 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; +} diff --git a/apps/backend/src/scanner/http.ts b/apps/backend/src/scanner/http.ts new file mode 100644 index 0000000..c715937 --- /dev/null +++ b/apps/backend/src/scanner/http.ts @@ -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 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; + } +} diff --git a/apps/backend/src/scanner/networkScanner.ts b/apps/backend/src/scanner/networkScanner.ts new file mode 100644 index 0000000..accc421 --- /dev/null +++ b/apps/backend/src/scanner/networkScanner.ts @@ -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; +} diff --git a/apps/backend/src/scanner/ports.ts b/apps/backend/src/scanner/ports.ts new file mode 100644 index 0000000..01fc745 --- /dev/null +++ b/apps/backend/src/scanner/ports.ts @@ -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)); + }); +} diff --git a/apps/backend/src/scanner/softwareDetection.ts b/apps/backend/src/scanner/softwareDetection.ts new file mode 100644 index 0000000..c81b41b --- /dev/null +++ b/apps/backend/src/scanner/softwareDetection.ts @@ -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; +} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 76d58c8..f94fbd9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -31,17 +31,24 @@ Grundgerüst aus Commit 1. > Drag & Drop in der UI) wandert in Commit 6 (Adminbereich) – die Startseite > bleibt bewusst die minimalistische Suche, kein Verwaltungs-UI dort. -## Commit 5 — Scanner +## ✅ Commit 5 — Scanner (erledigt) -- FritzBox-Scanner (TR-064/UPnP) -- DNS-, HTTP-, HTTPS-Scanner -- Optionaler Portscanner mit definierter Portliste -- Titel- und Favicon-Auslesen -- Softwareerkennung (Home Assistant, Synology DSM, Frigate, Portainer, Grafana, - Proxmox, Immich, Paperless, Gitea, Vaultwarden, Jellyfin, Nextcloud, Pi-hole, - AdGuard, Unifi, …) -- "Jetzt scannen"-Button; niemals automatische Scans -- Scans überschreiben niemals Displayname, Kategorie, Favorit, Reihenfolge, Alias, Icon +- `apps/backend/src/scanner/`: DNS-Kandidaten-Auflösung, TCP-Portscan (80, 443 + + typische Ports), HTTP-Titel-/Favicon-Extraktion, Softwareerkennung per + Signatur-Liste, FritzBox-TR-064-Client (inkl. selbst implementierter + HTTP-Digest-Authentifizierung) +- `POST /api/scan/devices/:id` und `POST /api/scan/fritzbox` – ausschließlich + manuell auslösbar, kein automatischer/zeitgesteuerter Scan +- `upsertDeviceFromScan` / `upsertServiceFromScan` in den Repositories: + garantiert, dass Benutzerfelder (displayName, category, favorite, order, + 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