/** * Tool: IP-Scanner — findet Geräte im Subnetz. * Die gefundenen Geräte werden ins Protokoll übernommen. * * Netzbereich-Logik: ist im Dialog nichts angegeben, wird der im * Protokoll hinterlegte Netzbereich genutzt; ist auch der leer, * fragt das Tool den aktiven WLAN-/LAN-Adapter ab und scannt * dessen Subnetz direkt. * * Mehrgleisig (ROADMAP_UMSETZUNG.md Phase 3): der native Scan gilt ein Gerät * als gefunden, wenn es auf Ping ODER eine kurze Port-Probe ODER einen * ARP-Tabellen-Eintrag antwortet — plus mDNS/Bonjour hier oben drauf. Läuft * als eigener Lauf mit Live-Fortschritt/Abbruch (`ctx.onProgress`/ * `ctx.isCancelled`, siehe ToolDialog) statt eines einzigen langen Promise * ohne jede Rückmeldung. */ import { scanner, onIpScanProgress, onIpScanFinished, type MdnsDevice, type IpScanResult, } from '../../scanner'; import { debugLog } from '../../debuglog.svelte'; import type { Device } from '../../types'; import type { Tool, ToolContext, ToolPreview } from '../types'; /** Geräteart aus den angebotenen mDNS-Diensten ableiten */ function typeFromMdns(services: string[]): string { const s = services.join(' '); if (s.includes('_printer') || s.includes('_ipp') || s.includes('_pdl-datastream')) return 'Drucker'; if (s.includes('_googlecast')) return 'Chromecast/TV'; if (s.includes('_airplay') || s.includes('_raop')) return 'AirPlay-Gerät'; if (s.includes('_rtsp') || s.includes('_axis-video')) return 'Kamera'; if (s.includes('_hap')) return 'HomeKit-Gerät'; if (s.includes('_smb')) return 'NAS'; return ''; } /** IP-Adressen numerisch vergleichen (nicht als Text — "192.168.1.9" vor "192.168.1.10") */ function ipCompare(a: string, b: string): number { const pa = a.split('.').map(Number); const pb = b.split('.').map(Number); for (let i = 0; i < 4; i++) { const diff = (pa[i] ?? 0) - (pb[i] ?? 0); if (diff !== 0) return diff; } return 0; } /** Anzahl der Host-Adressen eines CIDR-Bereichs (fürs Vorschau-Fenster — muss nicht bis auf /31 exakt sein) */ function hostCountForCidr(cidr: string): number | null { const parts = cidr.trim().split('/'); const prefix = parts.length > 1 ? Number(parts[1].trim()) : 24; if (!Number.isFinite(prefix) || prefix < 0 || prefix > 32) return null; if (prefix >= 31) return 2 ** (32 - prefix); const count = 2 ** (32 - prefix) - 2; return count > 0 ? count : 0; } /** * Netzbereich nach derselben Regel wie `run()` ermitteln: Dialog-Eingabe → * Protokoll-Feld → aktiver Adapter. Wird von `preview()` UND `run()` * unabhängig aufgerufen (ein doppelter `getLocalSubnet()`-Aufruf bei leerem * Feld ist unkritisch, < 100 ms) — so funktioniert `run()` auch dann korrekt, * wenn ein Aufrufer die Vorschau übersprungen hat. */ async function resolveSubnet( ctx: ToolContext, ): Promise<{ subnet: string; source: 'eingabe' | 'protokoll' | 'adapter'; ip: string; gateway: string }> { const dialogValue = String(ctx.params.subnet ?? '').trim(); let subnet = dialogValue || String(ctx.protocol.subnet ?? '').trim(); const source: 'eingabe' | 'protokoll' | 'adapter' = dialogValue ? 'eingabe' : subnet ? 'protokoll' : 'adapter'; let ip = ''; let gateway = ''; if (!subnet) { try { const local = await scanner.getLocalSubnet(); subnet = String(local.subnet ?? '').trim(); ip = local.ip ?? ''; gateway = local.gateway ?? ''; } catch { /* kein Adapter ermittelbar — Aufrufer behandelt leeres subnet */ } } else { // Auch bei Eingabe/Protokoll-Quelle den Adapter fragen — nur für die // Vorschau-Anzeige (eigene IP/Gateway), best effort, kein Fehlerfall. try { const local = await scanner.getLocalSubnet(); ip = local.ip ?? ''; gateway = local.gateway ?? ''; } catch { /* egal, Vorschau zeigt dann nur den Netzbereich */ } } return { subnet, source, ip, gateway }; } /** * IP-Scan starten und auf das Endergebnis (`ipScanFinished`) warten, dabei * Fortschritt an `ctx.onProgress` durchreichen und `ctx.isCancelled()` * periodisch abfragen — wird die true, `cancelIpScan` auslösen. Liefert auch * bei Abbruch regulär auf (kein Reject), das Teilergebnis kommt über * `ipScanFinished`. */ function runIpScan(subnet: string, ctx: ToolContext): Promise { return new Promise((resolve, reject) => { let offProgress = () => {}; let offFinished = () => {}; let cancelTimer: ReturnType | undefined; let cancelSent = false; function cleanup() { offProgress(); offFinished(); if (cancelTimer) clearInterval(cancelTimer); } scanner .startIpScan({ subnet }) .then(({ runId }) => { offProgress = onIpScanProgress((e) => { if (e.runId !== runId) return; ctx.onProgress?.({ done: e.done, total: e.total, found: e.found, foundIps: e.foundIps }); }); offFinished = onIpScanFinished((e) => { if (e.runId !== runId) return; cleanup(); resolve(e); }); if (ctx.isCancelled) { cancelTimer = setInterval(() => { if (cancelSent) return; if (ctx.isCancelled?.()) { cancelSent = true; scanner.cancelIpScan({ runId }).catch(() => {}); } }, 300); } }) .catch((e) => { cleanup(); reject(e instanceof Error ? e : new Error(String(e))); }); }); } export const ipScanTool: Tool = { id: 'ipscan', category: 'netzwerk', name: 'IP-Scanner', icon: 'radar', description: 'Sucht Geräte per Ping, Port-Probe, ARP-Abgleich und mDNS/Bonjour.', scope: 'protocol', supportsProgress: true, // Netzbereich darf leer bleiben (wird ueber den Adapter aufgeloest) quickRun: true, params: [ { key: 'subnet', label: 'Netzbereich (CIDR) — leer = aktiver Adapter', type: 'text', placeholder: 'leer lassen → automatisch über WLAN/LAN', }, ], async preview(ctx): Promise { const { subnet, source, ip, gateway } = await resolveSubnet(ctx); if (!subnet) { return { lines: [{ label: 'Netzbereich', value: 'nicht ermittelbar' }], warning: 'Kein aktives WLAN/LAN gefunden — Netzbereich von Hand eintragen.', confirmLabel: 'Trotzdem versuchen', }; } const count = hostCountForCidr(subnet); const quelle = source === 'adapter' ? ' (Adapter erkannt)' : source === 'protokoll' ? ' (aus Protokoll)' : ''; const lines: ToolPreview['lines'] = [{ label: 'Netzbereich', value: `${subnet}${quelle}` }]; if (ip) lines.push({ label: 'Eigene IP', value: ip }); if (gateway) lines.push({ label: 'Gateway', value: gateway }); lines.push({ label: 'Zu prüfende Adressen', value: count != null ? String(count) : 'unbekannt' }); const big = count != null && count > 1024; return { lines, warning: big ? `Große Suche: ${count} Adressen — kann über eine Minute dauern und den Akku/WLAN-Funk stärker beanspruchen.` : undefined, confirmLabel: big ? 'Trotzdem scannen' : 'Scan starten', }; }, async run(ctx) { const { subnet, source } = await resolveSubnet(ctx); if (!subnet) { return { label: 'Kein Netzbereich — WLAN/LAN nicht aktiv?', result: { error: 'Netzbereich konnte nicht ermittelt werden' }, measureStatus: 2, }; } // Ermittelten Netzbereich ins Protokoll übernehmen, wenn dort noch leer — // als Patch, NICHT durch direktes Beschreiben von ctx.protocol (der // Aufrufer wendet das nach dem Lauf gezielt an, siehe ToolRunResult). const protocolPatch = !String(ctx.protocol.subnet ?? '').trim() ? { subnet } : undefined; debugLog.add( 'info', `IP-Scan: Dialog-Eingabe="${String(ctx.params.subnet ?? '')}", ` + `Protokoll-Subnetz="${String(ctx.protocol.subnet ?? '')}" → ` + `gescannt wird "${subnet}" (Quelle: ${source})`, ); const previousIps = new Set(ctx.protocol.devices.map((d) => d.ip)); let scanResult: IpScanResult; try { scanResult = await runIpScan(subnet, ctx); } catch (e) { // Ein gescheiterter Scan ist trotzdem eine Messung — kein stilles Nichts, // sonst sieht man im Protokoll nicht, dass hier überhaupt etwas versucht wurde. const msg = e instanceof Error ? e.message : String(e); debugLog.add('error', `IP-Scan (${subnet}) fehlgeschlagen:`, e); return { label: `IP-Scan fehlgeschlagen: ${msg}`, result: { subnet, fehler: msg }, measureStatus: 2, protocolPatch, }; } if (scanResult.error) { return { label: `IP-Scan fehlgeschlagen: ${scanResult.error}`, result: { subnet, fehler: scanResult.error, probed: scanResult.probed, arpAvailable: scanResult.arpAvailable, }, measureStatus: 2, protocolPatch, }; } // mDNS/Bonjour zusätzlich abfragen — liefert sprechende Namen und findet // Geräte, die weder auf Ping/Port noch per ARP auffallen (manche Kameras/ // Drucker). Best-Effort: discoveryOk=false wird als Warnung vermerkt, // macht den IP-Scan selbst aber nicht ungültig. let mdns: MdnsDevice[] = []; let mdnsOk = true; try { const r = await scanner.mdnsScan({ timeoutMs: 9000 }); mdns = r.devices; mdnsOk = r.discoveryOk !== false; } catch { mdnsOk = false; } const mdnsByIp = new Map(mdns.map((m) => [m.ip, m])); // Beide Quellen per IP zusammenführen const merged: (Partial & { ip: string })[] = scanResult.devices.map((d) => { const m = mdnsByIp.get(d.ip); if (!m) return d; return { ...d, mdnsName: m.name, mdnsServices: m.services, deviceType: d.deviceType || typeFromMdns(m.services), }; }); // Geräte, die nur per mDNS auftauchten, ergänzen for (const m of mdns) { if (merged.some((d) => d.ip === m.ip)) continue; merged.push({ ip: m.ip, hostname: m.name, mdnsName: m.name, mdnsServices: m.services, deviceType: typeFromMdns(m.services), foundVia: 'mdns', }); } merged.sort((a, b) => ipCompare(a.ip, b.ip)); // "Neu" / "Nicht mehr erreichbar" — Vergleich mit dem VOR diesem Lauf im // Protokoll bekannten Gerätebestand (Diagnosefeld + sprechendes Label). const neu = merged .filter((d) => !previousIps.has(d.ip)) .map((d) => d.ip) .sort(ipCompare); const gefundeneIps = new Set(merged.map((d) => d.ip)); const nichtMehrErreichbar = ctx.protocol.devices .filter((d) => !gefundeneIps.has(d.ip)) .map((d) => d.ip) .sort(ipCompare); const teile = [`${merged.length} Geräte im Netz ${subnet}`]; if (source === 'adapter') teile.push('Adapter erkannt'); if (scanResult.cancelled) teile.push('abgebrochen — Teilergebnis'); if (neu.length) teile.push(`${neu.length} neu`); if (nichtMehrErreichbar.length) teile.push(`${nichtMehrErreichbar.length} nicht mehr erreichbar`); if (!mdnsOk) teile.push('mDNS-Suche fehlgeschlagen'); debugLog.add( 'info', `IP-Scan Ergebnis: ${merged.length} Geräte in ${subnet} ` + `(${scanResult.probed} geprüft, ${scanResult.answered} geantwortet, ` + `${scanResult.durationMs} ms, arpAvailable=${scanResult.arpAvailable}, ` + `cancelled=${scanResult.cancelled}, mdns=${mdns.length}/${mdnsOk ? 'ok' : 'fehler'})`, ); return { label: teile.join(' · '), result: { subnet, quelle: source, count: merged.length, probed: scanResult.probed, answered: scanResult.answered, arpAvailable: scanResult.arpAvailable, durationMs: scanResult.durationMs, abgebrochen: scanResult.cancelled, neu, nichtMehrErreichbar, mdnsOk, }, // Abgebrochen mit Teilergebnis oder nichts gefunden -> Warnung statt OK; // ein bewusst abgebrochener Scan ist kein Fehler, aber eben unvollständig. measureStatus: scanResult.cancelled ? 1 : merged.length > 0 ? 0 : 1, devices: merged, protocolPatch, }; }, };