domovoy/app/Services/Discovery/NetworkScanner.php

201 lines
6.3 KiB
PHP

<?php
declare(strict_types=1);
namespace Domovoy\Services\Discovery;
use Domovoy\Models\DiscoveredHost;
use Domovoy\Models\NetworkRange;
use Domovoy\Repositories\DiscoveredHostRepository;
class NetworkScanner
{
private PingScanner $pingScanner;
private TcpPortScanner $tcpPortScanner;
private ArpTableReader $arpTableReader;
private HostFingerprintService $fingerprintService;
private DiscoveredHostRepository $discoveredHostRepository;
/** @var int[] */
private array $defaultPorts = [22, 23, 53, 80, 443, 445, 548, 631, 3306, 5432, 6379, 8000, 8080, 8443, 9000, 9090, 9100];
public function __construct(
PingScanner $pingScanner,
TcpPortScanner $tcpPortScanner,
ArpTableReader $arpTableReader,
HostFingerprintService $fingerprintService,
DiscoveredHostRepository $discoveredHostRepository
) {
$this->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;
}
}