Neues Werkzeug: IP-Konflikt-Prüfung

- arpConflictScan (Plugin): pingt das Subnetz über mehrere Runden und liest
  je Runde die ARP-Tabelle; mehrere MACs pro IP = Konflikt. Kein Root nötig
- Erkennt den Fall, dass /proc/net/arp nicht lesbar ist (Android-Limit) und
  meldet das ehrlich, statt fälschlich Entwarnung zu geben
- ipconflict.ts: neues Protokoll-Tool, in der Tool-Registry eingetragen —
  listet betroffene IPs samt der konkurrierenden MAC-Adressen

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-05-19 23:00:32 +02:00
parent fd75748cb9
commit 9ee9c954b2
5 changed files with 250 additions and 0 deletions

View file

@ -450,6 +450,73 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIpv4(i: Int): String =
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
/* --------------------------------------------------------------------- */
/* IP-Konflikt-Prüfung — eine IP, von zwei Geräten gleichzeitig benutzt */
/* --------------------------------------------------------------------- */
/**
* Sucht IP-Adressen, die im Netz von mehr als einem Gerät benutzt werden.
*
* Ohne Root lässt sich kein Roh-ARP mitschneiden der praktikable Weg:
* über mehrere Runden das Subnetz anpingen (das erzwingt jeweils eine
* ARP-Auflösung) und nach jeder Runde /proc/net/arp auslesen. Erscheint für
* dieselbe IP über die Runden hinweg mehr als eine MAC, nutzen zwei Geräte
* diese Adresse Konflikt.
*
* Risiko: /proc/net/arp kann auf neueren Android-Versionen leer sein
* dann meldet das Ergebnis `arpAvailable = false`.
*/
@PluginMethod
fun arpConflictScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val rounds = (call.getInt("rounds") ?: 4).coerceIn(2, 10)
val delayMs = (call.getInt("delayMs") ?: 600).coerceIn(0, 5000).toLong()
val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
try {
val seen = HashMap<String, MutableSet<String>>()
var arpEverFilled = false
for (r in 0 until rounds) {
withContext(Dispatchers.IO) {
hosts.map { ipInt ->
async {
try {
InetAddress.getByName(intToIpv4(ipInt)).isReachable(300)
} catch (_: Exception) {
false
}
}
}.awaitAll()
}
val arp = readArpTable()
if (arp.isNotEmpty()) arpEverFilled = true
for ((ip, mac) in arp) {
seen.getOrPut(ip) { HashSet() }.add(mac)
}
if (r < rounds - 1 && delayMs > 0) Thread.sleep(delayMs)
}
val conflicts = JSArray()
for ((ip, macs) in seen) {
if (macs.size > 1) {
val macArr = JSArray()
macs.forEach { macArr.put(it) }
conflicts.put(JSObject().put("ip", ip).put("macs", macArr))
}
}
resolve(call, JSObject()
.put("conflicts", conflicts)
.put("checked", seen.size)
.put("rounds", rounds)
.put("arpAvailable", arpEverFilled))
} catch (e: Exception) {
call.reject("arpConflictScan: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Port-Scan */
/* --------------------------------------------------------------------- */

View file

@ -450,6 +450,73 @@ class NetDiagScannerPlugin : Plugin() {
private fun intToIpv4(i: Int): String =
"${(i shr 24) and 0xFF}.${(i shr 16) and 0xFF}.${(i shr 8) and 0xFF}.${i and 0xFF}"
/* --------------------------------------------------------------------- */
/* IP-Konflikt-Prüfung — eine IP, von zwei Geräten gleichzeitig benutzt */
/* --------------------------------------------------------------------- */
/**
* Sucht IP-Adressen, die im Netz von mehr als einem Gerät benutzt werden.
*
* Ohne Root lässt sich kein Roh-ARP mitschneiden der praktikable Weg:
* über mehrere Runden das Subnetz anpingen (das erzwingt jeweils eine
* ARP-Auflösung) und nach jeder Runde /proc/net/arp auslesen. Erscheint für
* dieselbe IP über die Runden hinweg mehr als eine MAC, nutzen zwei Geräte
* diese Adresse Konflikt.
*
* Risiko: /proc/net/arp kann auf neueren Android-Versionen leer sein
* dann meldet das Ergebnis `arpAvailable = false`.
*/
@PluginMethod
fun arpConflictScan(call: PluginCall) {
val subnet = call.getString("subnet") ?: return call.reject("subnet fehlt")
val rounds = (call.getInt("rounds") ?: 4).coerceIn(2, 10)
val delayMs = (call.getInt("delayMs") ?: 600).coerceIn(0, 5000).toLong()
val hosts = hostsInSubnet(subnet)
if (hosts.isEmpty()) {
return call.reject("Subnetz ungültig oder zu groß (max /16): $subnet")
}
io.launch {
try {
val seen = HashMap<String, MutableSet<String>>()
var arpEverFilled = false
for (r in 0 until rounds) {
withContext(Dispatchers.IO) {
hosts.map { ipInt ->
async {
try {
InetAddress.getByName(intToIpv4(ipInt)).isReachable(300)
} catch (_: Exception) {
false
}
}
}.awaitAll()
}
val arp = readArpTable()
if (arp.isNotEmpty()) arpEverFilled = true
for ((ip, mac) in arp) {
seen.getOrPut(ip) { HashSet() }.add(mac)
}
if (r < rounds - 1 && delayMs > 0) Thread.sleep(delayMs)
}
val conflicts = JSArray()
for ((ip, macs) in seen) {
if (macs.size > 1) {
val macArr = JSArray()
macs.forEach { macArr.put(it) }
conflicts.put(JSObject().put("ip", ip).put("macs", macArr))
}
}
resolve(call, JSObject()
.put("conflicts", conflicts)
.put("checked", seen.size)
.put("rounds", rounds)
.put("arpAvailable", arpEverFilled))
} catch (e: Exception) {
call.reject("arpConflictScan: ${e.message}")
}
}
}
/* --------------------------------------------------------------------- */
/* Port-Scan */
/* --------------------------------------------------------------------- */

View file

@ -30,6 +30,16 @@ export interface MdnsDevice {
/** angebotene Diensttypen, z.B. ['_googlecast._tcp', '_printer._tcp'] */
services: string[];
}
/** Ergebnis der IP-Konflikt-Prüfung */
export interface ConflictScanResult {
/** IP-Adressen, die von mehr als einem Gerät benutzt werden */
conflicts: { ip: string; macs: string[] }[];
/** Anzahl der Adressen, für die überhaupt eine MAC gesehen wurde */
checked: number;
rounds: number;
/** false = ARP-Tabelle nicht lesbar (Android-Einschränkung, dann keine Aussage möglich) */
arpAvailable: boolean;
}
export interface OpenPort {
port: number;
service?: string;
@ -78,6 +88,12 @@ export interface NetDiagScannerPlugin {
ipScan(opts: { subnet: string }): Promise<{ devices: ScannedDevice[] }>;
/** mDNS/Bonjour-Dienstsuche: Drucker, Kameras, Chromecast, AirPlay … */
mdnsScan(opts: { timeoutMs?: number }): Promise<{ devices: MdnsDevice[] }>;
/** IP-Konflikt-Prüfung: findet IP-Adressen, die zwei Geräte gleichzeitig benutzen */
arpConflictScan(opts: {
subnet: string;
rounds?: number;
delayMs?: number;
}): Promise<ConflictScanResult>;
/** Port-Scan eines Geräts */
portScan(opts: { ip: string; ports: number[] }): Promise<{ open: OpenPort[] }>;
/** Ping-Qualität (Latenz, Jitter, Paketverlust) */
@ -175,6 +191,14 @@ const mock: NetDiagScannerPlugin = {
],
};
},
async arpConflictScan() {
return {
conflicts: [{ ip: '192.168.1.40', macs: ['AA:BB:CC:00:00:28', 'AA:BB:CC:00:00:99'] }],
checked: 12,
rounds: 4,
arpAvailable: true,
};
},
async portScan(opts) {
const all: OpenPort[] = [
{ port: 80, service: 'http' },

View file

@ -10,6 +10,7 @@
import type { Tool, ToolCategory } from './types';
import { dhcpCheckTool } from './netzwerk/dhcpcheck';
import { ipConflictTool } from './netzwerk/ipconflict';
import { ipScanTool } from './netzwerk/ipscan';
import { pingTool } from './netzwerk/ping';
import { portScanTool } from './netzwerk/portscan';
@ -28,6 +29,7 @@ export const TOOLS: Tool[] = [
pingTool,
wifiScanTool,
dhcpCheckTool,
ipConflictTool,
snmpTool,
tracerouteTool,
stressTestTool,

View file

@ -0,0 +1,90 @@
/**
* Tool: IP-Konflikt-Prüfung findet IP-Adressen, die von zwei Geräten
* gleichzeitig benutzt werden und so das Netz durcheinanderbringen.
*
* Verfahren ohne Root: Das Subnetz wird über mehrere Runden angepingt und
* jeweils die ARP-Tabelle ausgelesen. Tauchen für eine IP mehrere MAC-Adressen
* auf, nutzen mehrere Geräte dieselbe Adresse ein Konflikt.
*/
import { scanner } from '../../scanner';
import type { MeasureStatus, Tool } from '../types';
export const ipConflictTool: Tool = {
id: 'ipconflict',
category: 'netzwerk',
name: 'IP-Konflikt',
icon: 'alert-triangle',
description: 'Findet IP-Adressen, die zwei Geräte gleichzeitig benutzen.',
scope: 'protocol',
params: [
{
key: 'subnet',
label: 'Netzbereich (CIDR) — leer = aktiver Adapter',
type: 'text',
placeholder: 'leer lassen → automatisch über WLAN/LAN',
},
{
key: 'rounds',
label: 'Prüfrunden (mehr = zuverlässiger, dauert länger)',
type: 'number',
default: 4,
},
],
async run(ctx) {
// Netzbereich: Dialog → Protokoll → aktiver Adapter
let subnet =
String(ctx.params.subnet ?? '').trim() || String(ctx.protocol.subnet ?? '').trim();
if (!subnet) {
try {
subnet = String((await scanner.getLocalSubnet()).subnet ?? '').trim();
} catch {
/* unten abgefangen */
}
}
if (!subnet) {
return {
label: 'Kein Netzbereich — WLAN/LAN nicht aktiv?',
result: { error: 'Netzbereich konnte nicht ermittelt werden' },
measureStatus: 2,
};
}
const rounds = Number(ctx.params.rounds) || 4;
const res = await scanner.arpConflictScan({ subnet, rounds });
// ARP-Tabelle nicht lesbar → ehrliche Rückmeldung statt falscher Entwarnung
if (!res.arpAvailable) {
return {
label: 'ARP-Tabelle nicht lesbar — Konfliktprüfung nicht möglich',
result: {
subnet,
hinweis:
'Android gibt /proc/net/arp auf diesem Gerät nicht frei. Eine ' +
'zuverlässige IP-Konflikt-Erkennung ist ohne Root hier leider nicht möglich.',
},
measureStatus: 1,
};
}
const n = res.conflicts.length;
const status: MeasureStatus = n > 0 ? 2 : 0;
return {
label:
n > 0
? `${n} IP-Konflikt${n > 1 ? 'e' : ''} gefunden!`
: `Kein Konflikt — ${res.checked} Adressen geprüft`,
result: {
subnet,
geprueft: res.checked,
runden: res.rounds,
konflikte: res.conflicts.map((c) => `${c.ip}${c.macs.join(' / ')}`),
hinweis:
n > 0
? 'Mehrere MAC-Adressen pro IP — diese Geräte stören sich gegenseitig.'
: 'Jede gefundene IP wird von genau einem Gerät benutzt.',
},
measureStatus: status,
};
},
};