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
342 lines
12 KiB
TypeScript
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,
|
|
};
|
|
},
|
|
};
|