generated from Dicken/dickendock
Commit 5: Scanner-Engine (DNS, Portscan, Titel/Favicon, Softwareerkennung, FritzBox TR-064)
This commit is contained in:
34
README.md
34
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=<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.
|
||||
|
||||
## 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)
|
||||
|
||||
@@ -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)!;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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" };
|
||||
|
||||
94
apps/backend/src/routes/scan.ts
Normal file
94
apps/backend/src/routes/scan.ts
Normal 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),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
139
apps/backend/src/scanner/digestAuth.ts
Normal file
139
apps/backend/src/scanner/digestAuth.ts
Normal 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;
|
||||
}
|
||||
25
apps/backend/src/scanner/dns.ts
Normal file
25
apps/backend/src/scanner/dns.ts
Normal 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;
|
||||
}
|
||||
66
apps/backend/src/scanner/fritzbox.ts
Normal file
66
apps/backend/src/scanner/fritzbox.ts
Normal 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;
|
||||
}
|
||||
84
apps/backend/src/scanner/http.ts
Normal file
84
apps/backend/src/scanner/http.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
66
apps/backend/src/scanner/networkScanner.ts
Normal file
66
apps/backend/src/scanner/networkScanner.ts
Normal 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;
|
||||
}
|
||||
26
apps/backend/src/scanner/ports.ts
Normal file
26
apps/backend/src/scanner/ports.ts
Normal 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));
|
||||
});
|
||||
}
|
||||
49
apps/backend/src/scanner/softwareDetection.ts
Normal file
49
apps/backend/src/scanner/softwareDetection.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user