netdiag-app/src/lib/tools/netzwerk/ipscan.ts
Eduard Wisch 8b369fc3bc Phase 3: IP-Scanner komplett überarbeitet — mehrgleisige Suche, Live-Fortschritt
Bisher fand der IP-Scan Geräte nur per ICMP-Ping; ein Windows-PC mit aktiver
Firewall (Ping blockiert, Ports offen) fiel komplett durch. Kein Fortschritt
während des Laufs, kein Abbrechen, kein Hinweis was gescannt wird.

Nativ (NetDiagScannerPlugin.kt):
- Mehrgleisige Suche: gefunden bei Ping ODER Port-Probe ODER ARP-Tabellen-
  Eintrag. Runde 1 über alle Adressen, ARP-Abgleich, Runde 2 NUR für die
  weiterhin stillen Adressen, ARP erneut abgleichen.
- startIpScan()/cancelIpScan() statt eines einzigen langen ipScan()-Promise:
  läuft als Lauf mit Live-Events (ipScanProgress/ipScanFinished), Abbruch
  liefert das bis dahin gefundene Teilergebnis statt nichts.
- Eigener limitedParallelism(128)-Dispatcher, damit ein großer Sweep keinen
  gleichzeitigen Ping/Monitor/Dauertest ausbremst.
- arpAvailable ehrlich über File.canRead() statt "Tabelle war halt leer".
- mDNS-Budget 4s -> 9s, Discovery/Auflösung entkoppelt (eigene Nachlaufzeit),
  discoveryOk meldet einen echten Suchfehler statt stiller 0 Treffer.

App:
- scanner.ts: neue Events onIpScanProgress/onIpScanFinished.
- ipscan.ts: protocolPatch statt direktem Beschreiben von ctx.protocol;
  fehlgeschlagener Scan erzeugt jetzt auch eine Messung (Status 2 statt
  nichts); "neu"/"nicht mehr erreichbar" als Diagnosefelder.
- ToolDialog: generischer Vorschau-Schritt (tool.preview — Adapter, eigene
  IP, Gateway, Adressenzahl; ab >1024 Adressen Bestätigung "Trotzdem
  scannen") und Live-Fortschritt (tool.supportsProgress — Fortschrittsbalken,
  laufende Trefferliste, Abbrechen). Beide Mechanismen sind generisch für
  alle Tools nutzbar, nicht IP-Scan-spezifisch fest verdrahtet.
- DeviceCard zeigt den Fundweg ("via Ping/Port/ARP/mDNS") als Badge.
- Geräteliste numerisch nach IP sortiert (Favoriten weiter zuerst).

Im Emulator getestet (nicht nur Codereview): ICMP per iptables zu einem
Ziel geblockt, nur TCP/22 offen -> Gerät korrekt "via Port" gefunden.
/24 zweimal komplett gescannt: gleiche 4 Geräte, zweiter Lauf ohne "neu".
/20 (4094 Adressen) löst die Bestätigung korrekt aus. Abbrechen mitten im
Lauf liefert sauber ein Teilergebnis. Sync nach Dolibarr per DB-Query
verifiziert.

Dabei zwei echte Bugs gefunden, die reines Codereview nicht gefunden hätte:
Fortschritt zeigte "504 von 254" (Runde 2 zählte im Zähler von Runde 1
weiter), und das native Feld hieß "gefundenVia" statt "foundVia" wie von
der TS-Seite erwartet — der Fundweg kam nie an.

Details: ROADMAP_UMSETZUNG.md Phase 3
2026-08-15 12:47:28 +02:00

342 lines
12 KiB
TypeScript

/**
* 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<IpScanResult> {
return new Promise((resolve, reject) => {
let offProgress = () => {};
let offFinished = () => {};
let cancelTimer: ReturnType<typeof setInterval> | 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,
params: [
{
key: 'subnet',
label: 'Netzbereich (CIDR) — leer = aktiver Adapter',
type: 'text',
placeholder: 'leer lassen → automatisch über WLAN/LAN',
},
],
async preview(ctx): Promise<ToolPreview> {
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<Device> & { 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,
};
},
};