Stabilize discovery workflow

This commit is contained in:
2026-05-29 17:16:46 +08:00
parent 15772bc17e
commit 3981ffdf5e
40 changed files with 2039 additions and 211 deletions
+108 -30
View File
@@ -36,18 +36,76 @@ class NetworkScanner
/**
* @return DiscoveredHost[]
*/
public function scan(NetworkRange $range): array
public function scan(NetworkRange $range, ?int $scanJobId = null): array
{
$result = $this->scanResumable(
$range,
$scanJobId ?? 0,
0,
static fn (): bool => false,
static function (array $state): void {
}
);
return $result['hosts'];
}
/**
* @return array{hosts: DiscoveredHost[], completed: bool, next_ip_index: int, total_ips: int, scanned_count: int, hosts_found: int}
*/
public function scanResumable(
NetworkRange $range,
int $scanJobId,
int $startIndex,
callable $shouldStop,
callable $onProgress
): array
{
$ips = $this->enumerateIps($range->cidr);
$aliveHosts = [];
$scannedCount = 0;
$nextIndex = max(0, $startIndex);
$arpEntries = $this->arpTableReader->read();
$onProgress([
'next_ip_index' => $nextIndex,
'total_ips' => count($ips),
'scanned_count' => $scannedCount,
'hosts_found' => count($aliveHosts),
]);
foreach ($ips as $index => $ip) {
if ($index < $nextIndex) {
continue;
}
if ($shouldStop()) {
return [
'hosts' => $aliveHosts,
'completed' => false,
'next_ip_index' => $index,
'total_ips' => count($ips),
'scanned_count' => $scannedCount,
'hosts_found' => count($aliveHosts),
];
}
$nextIndex = $index + 1;
$scannedCount++;
foreach ($ips as $ip) {
// Step 1: Ping sweep
if (!$this->pingScanner->ping($ip)) {
$onProgress([
'next_ip_index' => $nextIndex,
'total_ips' => count($ips),
'scanned_count' => $scannedCount,
'hosts_found' => count($aliveHosts),
]);
continue;
}
$host = new DiscoveredHost();
$host->scanJobId = $scanJobId > 0 ? $scanJobId : null;
$host->ipAddress = $ip;
$host->firstSeen = new \DateTimeImmutable();
$host->lastSeen = new \DateTimeImmutable();
@@ -66,20 +124,30 @@ class NetworkScanner
// Step 5: Confidence score
$host->confidence = $this->fingerprintService->calculateConfidence(count($openPorts), $hostname !== null);
$aliveHosts[] = $host;
}
// Step 6: ARP table enrichment
$arpEntries = $this->arpTableReader->read();
foreach ($aliveHosts as $host) {
if (isset($arpEntries[$host->ipAddress])) {
$host->macAddress = $arpEntries[$host->ipAddress]['mac'] ?? null;
$host->vendor = $host->vendor ?: ($arpEntries[$host->ipAddress]['vendor'] ?? null);
}
$this->discoveredHostRepository->save($host);
$aliveHosts[] = $host;
$onProgress([
'next_ip_index' => $nextIndex,
'total_ips' => count($ips),
'scanned_count' => $scannedCount,
'hosts_found' => count($aliveHosts),
]);
}
return $aliveHosts;
return [
'hosts' => $aliveHosts,
'completed' => true,
'next_ip_index' => $nextIndex,
'total_ips' => count($ips),
'scanned_count' => $scannedCount,
'hosts_found' => count($aliveHosts),
];
}
/**
@@ -87,31 +155,41 @@ class NetworkScanner
*/
private function enumerateIps(string $cidr): array
{
if (str_contains($cidr, '/32')) {
return [explode('/', $cidr)[0]];
if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) {
throw new \InvalidArgumentException("Invalid IPv4 CIDR: {$cidr}");
}
// Only handle /24 or larger for MVP (to avoid scanning huge ranges)
if (preg_match('#^(\d+\.\d+\.\d+)\.(\d+)/(\d+)$#', $cidr, $m)) {
$prefix = $m[1];
$suffix = (int)$m[2];
$mask = (int)$m[3];
if ($mask > 24) {
// Treat smaller than /24 as single IP
return [$cidr];
}
$offset = $mask === 24 ? 0 : $suffix;
$count = 2 ** (24 - $mask);
$ips = [];
for ($i = 1; $i <= $count; $i++) {
$ips[] = $prefix . '.' . ($offset + $i);
}
return $ips;
$baseIp = $matches[1];
$mask = (int)$matches[2];
if ($mask < 24) {
throw new \InvalidArgumentException('Network ranges larger than /24 are disabled for the MVP scanner');
}
if ($mask > 32) {
throw new \InvalidArgumentException("Invalid IPv4 CIDR mask: {$cidr}");
}
return [];
$base = ip2long($baseIp);
if ($base === false) {
throw new \InvalidArgumentException("Invalid IPv4 address: {$baseIp}");
}
$base = (int)sprintf('%u', $base);
$hostCount = 2 ** (32 - $mask);
$network = $base & ((0xFFFFFFFF << (32 - $mask)) & 0xFFFFFFFF);
if ($mask === 32) {
return [long2ip($network)];
}
$start = $mask === 31 ? $network : $network + 1;
$end = $mask === 31 ? $network + 1 : $network + $hostCount - 2;
$ips = [];
for ($ip = $start; $ip <= $end; $ip++) {
$ips[] = long2ip($ip);
}
return $ips;
}
private function resolveHostname(string $ip): ?string
+13 -45
View File
@@ -19,51 +19,19 @@ class TcpPortScanner
$openPorts = [];
$timeoutSec = $timeoutMs / 1000;
if (function_exists('curl_multi_init')) {
// Parallel scan with curl_multi
$chunks = array_chunk($ports, $maxConcurrency);
foreach ($chunks as $chunk) {
$multiHandle = curl_multi_init();
$handles = [];
foreach ($chunk as $port) {
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "http://{$ip}:{$port}");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, $timeoutMs);
curl_setopt($ch, CURLOPT_TIMEOUT_MS, $timeoutMs);
curl_setopt($ch, CURLOPT_NOBODY, true);
curl_multi_add_handle($multiHandle, $ch);
$handles[$port] = $ch;
}
// Execute
$running = null;
do {
curl_multi_exec($multiHandle, $running);
curl_multi_select($multiHandle, 0.1);
} while ($running > 0);
// Check results
foreach ($handles as $port => $ch) {
$err = curl_errno($ch);
// CURLE_COULDNT_CONNECT (7) = port closed, others might mean open or filtered
if ($err !== CURLE_COULDNT_CONNECT) {
$openPorts[] = $port;
}
curl_multi_remove_handle($multiHandle, $ch);
curl_close($ch);
}
curl_multi_close($multiHandle);
}
} else {
// Sequential fallback
foreach ($ports as $port) {
$fp = @fsockopen($ip, $port, $errno, $errstr, $timeoutSec);
if ($fp !== false) {
$openPorts[] = $port;
fclose($fp);
}
foreach ($ports as $port) {
$errno = 0;
$errstr = '';
$fp = @stream_socket_client(
"tcp://{$ip}:{$port}",
$errno,
$errstr,
$timeoutSec,
STREAM_CLIENT_CONNECT
);
if ($fp !== false) {
$openPorts[] = $port;
fclose($fp);
}
}