201 lines
6.3 KiB
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;
|
|
}
|
|
}
|