feat: add optional TCP check to scan to filter proxy-ARP false positives
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 <noreply@anthropic.com>
This commit is contained in:
@@ -1,4 +1,6 @@
|
|||||||
|
import errno
|
||||||
import ipaddress
|
import ipaddress
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import time
|
import time
|
||||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
@@ -23,6 +25,7 @@ class ScanTarget(BaseModel):
|
|||||||
class ScanRequest(BaseModel):
|
class ScanRequest(BaseModel):
|
||||||
dns_server: str = "8.8.8.8"
|
dns_server: str = "8.8.8.8"
|
||||||
targets: list[ScanTarget]
|
targets: list[ScanTarget]
|
||||||
|
tcp_check: bool = False
|
||||||
|
|
||||||
@field_validator("dns_server")
|
@field_validator("dns_server")
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -77,9 +80,35 @@ def _ptr_lookup(ip: str, nameserver: str) -> Optional[str]:
|
|||||||
return None
|
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):
|
if not _ping(ip):
|
||||||
return None
|
return None
|
||||||
|
if tcp_check and not _tcp_check(ip):
|
||||||
|
return None
|
||||||
hostname = _ptr_lookup(ip, dns_server)
|
hostname = _ptr_lookup(ip, dns_server)
|
||||||
return DiscoveredHost(ip=ip, hostname=hostname, vlan_id=vlan_id, cidr=cidr)
|
return DiscoveredHost(ip=ip, hostname=hostname, vlan_id=vlan_id, cidr=cidr)
|
||||||
|
|
||||||
@@ -146,7 +175,7 @@ def scan(req: ScanRequest):
|
|||||||
results: list[DiscoveredHost] = []
|
results: list[DiscoveredHost] = []
|
||||||
|
|
||||||
with ThreadPoolExecutor(max_workers=100) as pool:
|
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):
|
for f in as_completed(futures):
|
||||||
host = f.result()
|
host = f.result()
|
||||||
if host:
|
if host:
|
||||||
|
|||||||
@@ -55,6 +55,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="field field-toggle">
|
||||||
|
<label class="toggle-row">
|
||||||
|
<input type="checkbox" v-model="tcpCheck" class="toggle-checkbox" />
|
||||||
|
<span class="toggle-label">{{ t('tcpCheckLabel') }}</span>
|
||||||
|
</label>
|
||||||
|
<div class="input-hint">{{ t('tcpCheckHint') }}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="configError" class="error-box">{{ configError }}</div>
|
<div v-if="configError" class="error-box">{{ configError }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -177,6 +185,7 @@ const emit = defineEmits(['close', 'refresh'])
|
|||||||
|
|
||||||
const step = ref('config')
|
const step = ref('config')
|
||||||
const dnsServer = ref('192.168.1.16')
|
const dnsServer = ref('192.168.1.16')
|
||||||
|
const tcpCheck = ref(false)
|
||||||
const selectedVlanIds = ref([])
|
const selectedVlanIds = ref([])
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const selectedIps = ref([])
|
const selectedIps = ref([])
|
||||||
@@ -259,6 +268,7 @@ async function startScan() {
|
|||||||
const resp = await discoveryApi.scan({
|
const resp = await discoveryApi.scan({
|
||||||
dns_server: dnsServer.value.trim(),
|
dns_server: dnsServer.value.trim(),
|
||||||
targets,
|
targets,
|
||||||
|
tcp_check: tcpCheck.value,
|
||||||
})
|
})
|
||||||
results.value = resp.data.hosts
|
results.value = resp.data.hosts
|
||||||
scanMeta.value = { total_scanned: resp.data.total_scanned, duration_s: resp.data.duration_s }
|
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.new { background: #DBEAFE; color: #1D4ED8; }
|
||||||
.status.existing { background: var(--bg-page); color: var(--text-faint); }
|
.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 {
|
.warn-box {
|
||||||
background: #FFFBEB; border: 1.5px solid #FCD34D; border-radius: 8px;
|
background: #FFFBEB; border: 1.5px solid #FCD34D; border-radius: 8px;
|
||||||
padding: 12px; font-size: 13px; color: #92400E;
|
padding: 12px; font-size: 13px; color: #92400E;
|
||||||
|
|||||||
@@ -128,6 +128,8 @@ const LANGS = {
|
|||||||
scanAddresses: 'adresses sur',
|
scanAddresses: 'adresses sur',
|
||||||
scanVlans: 'VLAN(s)',
|
scanVlans: 'VLAN(s)',
|
||||||
scanNote: 'Chaque hôte est pingé puis interrogé en DNS.',
|
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)',
|
hostsFound: 'hôte(s) découvert(s)',
|
||||||
addressesScanned: 'adresses scannées',
|
addressesScanned: 'adresses scannées',
|
||||||
newHosts: 'nouveaux',
|
newHosts: 'nouveaux',
|
||||||
@@ -285,6 +287,8 @@ const LANGS = {
|
|||||||
scanAddresses: 'addresses on',
|
scanAddresses: 'addresses on',
|
||||||
scanVlans: 'VLAN(s)',
|
scanVlans: 'VLAN(s)',
|
||||||
scanNote: 'Each host is pinged then queried via DNS.',
|
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',
|
hostsFound: 'host(s) found',
|
||||||
addressesScanned: 'addresses scanned',
|
addressesScanned: 'addresses scanned',
|
||||||
newHosts: 'new',
|
newHosts: 'new',
|
||||||
@@ -441,6 +445,8 @@ const LANGS = {
|
|||||||
scanAddresses: 'direcciones en',
|
scanAddresses: 'direcciones en',
|
||||||
scanVlans: 'VLAN(s)',
|
scanVlans: 'VLAN(s)',
|
||||||
scanNote: 'Cada host es pingado y luego consultado en DNS.',
|
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)',
|
hostsFound: 'host(s) descubierto(s)',
|
||||||
addressesScanned: 'direcciones escaneadas',
|
addressesScanned: 'direcciones escaneadas',
|
||||||
newHosts: 'nuevos',
|
newHosts: 'nuevos',
|
||||||
|
|||||||
Reference in New Issue
Block a user