netdiag-app/src/lib/tools/netzwerk/ipscan.ts
Eduard Wisch 85e68ca528 Schnellstart, Sprung zum Ergebnis und Parametergedaechtnis
Werkzeuge waren zu umstaendlich, um sie beim Kunden wirklich zu benutzen: die
Produktivdaten zeigen 19 Protokolle, aber nur 26 Messungen, fast durchweg ein
einzelner IP-Scan. Der Standardfall "IP-Scan im aktuellen Netz" kostete drei
Tipper, obwohl das einzige Feld ausdruecklich leer bleiben darf.

- Kurzer Tap auf eine Werkzeugkarte startet sofort, langes Halten oeffnet die
  Optionen. Auf den betroffenen Karten steht "Tippen startet · Halten fuer
  Optionen" - sonst ist das Verhalten unsichtbar und der erste versehentliche
  Scan ueberrascht.
- quickRun wird je Werkzeug AUSDRUECKLICH gesetzt, nicht hergeleitet:
  ToolParamField kennt kein 'required', die Pflicht steht allein im run()-Rumpf.
  portscan.ports hat keinen Vorgabewert und ist optional, iperf.host hat
  ebenfalls keinen und ist Pflicht - eine Heuristik ueber "hat Default" wuerde
  genau falsch herum raten. iperf bleibt deshalb bewusst ohne Schnellstart.
- Der Lauf bleibt zwingend im ToolDialog: dort sitzen Bildschirmsperre,
  Fortschritt mit Abbrechen und die EINZIGE Fehlerbehandlung eines
  Werkzeuglaufs (runTool selbst hat kein try/catch). Ein Schnellstart daran
  vorbei wuerde Fehler verschlucken.
- Die Vorschau wird nicht uebersprungen, sondern nur ohne Warnung automatisch
  durchgewunken. Der IP-Scan warnt ab ~1000 Adressen - genau der Fall, in dem
  ein versehentlicher Tap sonst minutenlang das falsche Netz absucht.
- Langdruck bricht ab, sobald der Finger mehr als 10 px wandert: das
  Werkzeug-Raster liegt in einem Scroll-Bereich, sonst oeffnet jeder Wisch nach
  einer halben Sekunde den Dialog.

- Nach einer Messung wird zum Ergebnis gescrollt und es 2,5 s hervorgehoben.
  Bei 46 Geraeten liegt die neue Karte sonst dutzende Bildschirme entfernt.
  block:'center', weil die feste Aktionsleiste den unteren Rand verdeckt; bei
  einer Geraete-Messung wird die Geraetekarte angesteuert, denn dort hinein
  wird gerendert.
- Verwendete Parameter stehen in der Messkarte. Seit dem Schnellstart laeuft
  eine Messung auch ohne Dialog - dann ist das der einzige Ort, an dem man
  sieht, WOMIT gemessen wurde. Beschriftung aus tool.params, nicht aus der
  Ergebnistabelle: 'count' heisst dort "Gefundene Geraete", als Ping-Parameter
  aber "Anzahl Pakete".
- Zuletzt benutzte Parameter werden gemerkt (neues Modul toolparams.ts).
  Ortsgebundene Schluessel wie 'subnet' ausdruecklich NICHT: ein aus dem
  vorigen Kundennetz uebernommener Netzbereich waere schlimmer als gar keiner,
  der Schnellstart wuerde stillschweigend das falsche Netz scannen.

Im Emulator geprueft, und dabei zwei Fehler gefunden, die im Code nicht
auffielen: die Parameterzeile wiederholte "Ziel: 8.8.8.8" direkt ueber dem
Ergebnis (jetzt werden Werte ausgelassen, die das Ergebnis ohnehin nennt), und
Traceroute schrieb "1 Hops" ins Kundendokument (jetzt "1 Station").
2026-08-16 19:09:19 +02:00

344 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,
// 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<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,
};
},
};