pingScanner = $pingScanner; $this->tcpPortScanner = $tcpPortScanner; $this->arpTableReader = $arpTableReader; $this->fingerprintService = $fingerprintService; $this->discoveredHostRepository = $discoveredHostRepository; } /** * @return DiscoveredHost[] */ 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++; // 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(); // Step 2: TCP port scan $openPorts = $this->tcpPortScanner->scan($ip, $this->defaultPorts, 200, 50); $host->openPorts = $openPorts; // Step 3: Reverse DNS $hostname = $this->resolveHostname($ip); $host->hostname = $hostname; // Step 4: Determine vendor by port patterns $host->vendor = $this->fingerprintService->guessVendor($openPorts, $hostname); // Step 5: Confidence score $host->confidence = $this->fingerprintService->calculateConfidence(count($openPorts), $hostname !== null); 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 [ 'hosts' => $aliveHosts, 'completed' => true, 'next_ip_index' => $nextIndex, 'total_ips' => count($ips), 'scanned_count' => $scannedCount, 'hosts_found' => count($aliveHosts), ]; } /** * @return string[] */ private function enumerateIps(string $cidr): array { if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) { throw new \InvalidArgumentException("Invalid IPv4 CIDR: {$cidr}"); } $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}"); } $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 { $hostname = @gethostbyaddr($ip); return ($hostname !== false && $hostname !== $ip) ? $hostname : null; } }