From cc716783ea1ebb76f744a7d9c64478a28c035e25 Mon Sep 17 00:00:00 2001 From: Olivier Date: Mon, 18 May 2026 11:17:55 +0200 Subject: [PATCH] feat: add optional TCP check to scan to filter proxy-ARP false positives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some gateways (e.g. UniFi) respond to ICMP for every IP in a subnet via proxy-ARP, spoofing the source IP so the existing ICMP guard cannot help. A secondary TCP probe (ports 22, 80, 443, 8080, 8443) distinguishes real hosts (RST/connect on closed ports) from ghost IPs (gateway drops SYN → timeout). The check is opt-in (disabled by default) to avoid missing devices whose firewall DROPs all probed ports. Co-Authored-By: Claude Sonnet 4.6 --- backend/routers/discovery.py | 33 ++++++++++++++++++++-- frontend/src/components/DiscoveryModal.vue | 16 +++++++++++ frontend/src/i18n.js | 6 ++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/backend/routers/discovery.py b/backend/routers/discovery.py index 7356603..d299cdc 100644 --- a/backend/routers/discovery.py +++ b/backend/routers/discovery.py @@ -1,4 +1,6 @@ +import errno import ipaddress +import socket import subprocess import time from concurrent.futures import ThreadPoolExecutor, as_completed @@ -23,6 +25,7 @@ class ScanTarget(BaseModel): class ScanRequest(BaseModel): dns_server: str = "8.8.8.8" targets: list[ScanTarget] + tcp_check: bool = False @field_validator("dns_server") @classmethod @@ -77,9 +80,35 @@ def _ptr_lookup(ip: str, nameserver: str) -> Optional[str]: return None -def _scan_one(ip: str, dns_server: str, vlan_id: int, cidr: str) -> Optional[DiscoveredHost]: +_TCP_PROBE_PORTS = (22, 80, 443, 8080, 8443) +_TCP_PROBE_TIMEOUT = 0.5 # seconds per port + + +def _tcp_check(ip: str) -> bool: + # Secondary check after ICMP: some gateways (e.g. UniFi) respond to ICMP + # for every IP in the subnet via proxy-ARP, spoofing the source IP so the + # source-IP guard in _ping() cannot help. A real host will reply to TCP + # (RST = port closed, or accept = port open); a ghost IP gets its SYN + # dropped by the gateway → timeout → False. + for port in _TCP_PROBE_PORTS: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.settimeout(_TCP_PROBE_TIMEOUT) + try: + err = sock.connect_ex((ip, port)) + if err == 0 or err == errno.ECONNREFUSED: + return True + except OSError: + pass + finally: + sock.close() + return False + + +def _scan_one(ip: str, dns_server: str, vlan_id: int, cidr: str, tcp_check: bool = False) -> Optional[DiscoveredHost]: if not _ping(ip): return None + if tcp_check and not _tcp_check(ip): + return None hostname = _ptr_lookup(ip, dns_server) return DiscoveredHost(ip=ip, hostname=hostname, vlan_id=vlan_id, cidr=cidr) @@ -146,7 +175,7 @@ def scan(req: ScanRequest): results: list[DiscoveredHost] = [] with ThreadPoolExecutor(max_workers=100) as pool: - futures = [pool.submit(_scan_one, *args) for args in tasks] + futures = [pool.submit(_scan_one, *args, tcp_check=req.tcp_check) for args in tasks] for f in as_completed(futures): host = f.result() if host: diff --git a/frontend/src/components/DiscoveryModal.vue b/frontend/src/components/DiscoveryModal.vue index 1d85b52..8b54606 100644 --- a/frontend/src/components/DiscoveryModal.vue +++ b/frontend/src/components/DiscoveryModal.vue @@ -55,6 +55,14 @@ +
+ +
{{ t('tcpCheckHint') }}
+
+
{{ configError }}
@@ -177,6 +185,7 @@ const emit = defineEmits(['close', 'refresh']) const step = ref('config') const dnsServer = ref('192.168.1.16') +const tcpCheck = ref(false) const selectedVlanIds = ref([]) const results = ref([]) const selectedIps = ref([]) @@ -259,6 +268,7 @@ async function startScan() { const resp = await discoveryApi.scan({ dns_server: dnsServer.value.trim(), targets, + tcp_check: tcpCheck.value, }) results.value = resp.data.hosts scanMeta.value = { total_scanned: resp.data.total_scanned, duration_s: resp.data.duration_s } @@ -410,6 +420,12 @@ code.ip { font-family: monospace; font-size: 13px; color: var(--text-primary); } .status.new { background: #DBEAFE; color: #1D4ED8; } .status.existing { background: var(--bg-page); color: var(--text-faint); } +.field-toggle { border: 1.5px solid var(--border); border-radius: 10px; padding: 12px; } +.toggle-row { display: flex; align-items: center; gap: 10px; cursor: pointer; } +.toggle-checkbox { width: 16px; height: 16px; flex-shrink: 0; cursor: pointer; accent-color: #3B82F6; } +.toggle-label { font-size: 13px; font-weight: 700; color: var(--text-secondary); } +.field-toggle .input-hint { margin-top: 6px; margin-bottom: 0; } + .warn-box { background: #FFFBEB; border: 1.5px solid #FCD34D; border-radius: 8px; padding: 12px; font-size: 13px; color: #92400E; diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js index 4988b55..9ec740f 100644 --- a/frontend/src/i18n.js +++ b/frontend/src/i18n.js @@ -128,6 +128,8 @@ const LANGS = { scanAddresses: 'adresses sur', scanVlans: 'VLAN(s)', scanNote: 'Chaque hôte est pingé puis interrogé en DNS.', + tcpCheckLabel: 'Vérification TCP (anti proxy-ARP)', + tcpCheckHint: 'Sonde chaque hôte sur les ports 22, 80, 443, 8080, 8443. Élimine les faux positifs UniFi/proxy-ARP, mais peut rater les équipements sans port TCP ouvert (smartphones, PC tout firewall, etc.).', hostsFound: 'hôte(s) découvert(s)', addressesScanned: 'adresses scannées', newHosts: 'nouveaux', @@ -285,6 +287,8 @@ const LANGS = { scanAddresses: 'addresses on', scanVlans: 'VLAN(s)', scanNote: 'Each host is pinged then queried via DNS.', + tcpCheckLabel: 'TCP check (anti proxy-ARP)', + tcpCheckHint: 'Probes each host on ports 22, 80, 443, 8080, 8443. Eliminates UniFi/proxy-ARP false positives, but may miss devices with no open TCP port (smartphones, fully-firewalled PCs, etc.).', hostsFound: 'host(s) found', addressesScanned: 'addresses scanned', newHosts: 'new', @@ -441,6 +445,8 @@ const LANGS = { scanAddresses: 'direcciones en', scanVlans: 'VLAN(s)', scanNote: 'Cada host es pingado y luego consultado en DNS.', + tcpCheckLabel: 'Verificación TCP (anti proxy-ARP)', + tcpCheckHint: 'Sondea cada host en los puertos 22, 80, 443, 8080, 8443. Elimina falsos positivos UniFi/proxy-ARP, pero puede omitir equipos sin puerto TCP abierto (smartphones, PC con firewall total, etc.).', hostsFound: 'host(s) descubierto(s)', addressesScanned: 'direcciones escaneadas', newHosts: 'nuevos',