Итерация 2: сканирование сети

- Phinx миграции: network_ranges, scan_jobs, discovered_hosts, audit_log
- Models: NetworkRange, ScanJob, DiscoveredHost
- Repositories с CRUD для всех сущностей
- NetworkScanner: ping sweep + TCP connect + ARP table + reverse DNS
- PingScanner, TcpPortScanner (curl_multi для параллелизма), ArpTableReader
- HostFingerprintService: vendor detection + confidence scoring
- ScanJobRunner + CLI worker (bin/run-scan-worker.php)
- Discovery UI: управление диапазонами, запуск скана, таблица находок
- NetworkRangeController с валидацией CIDR
- Обновлён public/index.php с DI конфигурацией для новых сервисов
This commit is contained in:
2026-05-26 07:41:23 +08:00
parent e7732f5cee
commit 177e44f015
22 changed files with 1477 additions and 1 deletions
+43
View File
@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Domovoy\Services\Discovery;
class ArpTableReader
{
/**
* Read ARP table from system.
* Returns array keyed by IP => ['mac' => '...', 'vendor' => '...']
*/
public function read(): array
{
$entries = [];
// Try ip neigh first (modern Linux)
$output = [];
$exitCode = 0;
exec('ip neigh show 2>/dev/null', $output, $exitCode);
if ($exitCode === 0) {
foreach ($output as $line) {
if (preg_match('/^(\d+\.\d+\.\d+\.\d+)\s+.*lladdr\s+([0-9a-f:]+)/i', $line, $m)) {
$entries[$m[1]] = ['mac' => strtolower($m[2]), 'vendor' => null];
}
}
}
// Fallback: arp -a
if (empty($entries)) {
exec('arp -a 2>/dev/null', $output, $exitCode);
if ($exitCode === 0) {
foreach ($output as $line) {
if (preg_match('/\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)/i', $line, $m)) {
$entries[$m[1]] = ['mac' => strtolower($m[2]), 'vendor' => null];
}
}
}
}
return $entries;
}
}
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Domovoy\Services\Discovery;
class HostFingerprintService
{
/**
* Guess vendor based on open ports and hostname.
*/
public function guessVendor(array $openPorts, ?string $hostname): ?string
{
$portVendorMap = [
9100 => 'HP/Printer',
631 => 'CUPS/Apple',
548 => 'Apple/AFP',
445 => 'Microsoft/SMB',
22 => null, // Too common
];
foreach ($portVendorMap as $port => $vendor) {
if ($vendor !== null && in_array($port, $openPorts, true)) {
return $vendor;
}
}
if ($hostname !== null) {
$hostname = strtolower($hostname);
if (str_contains($hostname, 'apple') || str_contains($hostname, 'macbook') || str_contains($hostname, 'iphone')) {
return 'Apple';
}
if (str_contains($hostname, 'synology') || str_contains($hostname, 'nas')) {
return 'Synology';
}
if (str_contains($hostname, 'router') || str_contains($hostname, 'gateway')) {
return 'Router/Network';
}
}
return null;
}
/**
* Calculate confidence score 0-100 based on found data.
*/
public function calculateConfidence(int $openPortCount, bool $hasHostname): int
{
$score = 10; // Base: host is alive
if ($openPortCount > 0) {
$score += min(40, $openPortCount * 5);
}
if ($hasHostname) {
$score += 30;
}
return min(100, $score);
}
}
+122
View File
@@ -0,0 +1,122 @@
<?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): array
{
$ips = $this->enumerateIps($range->cidr);
$aliveHosts = [];
foreach ($ips as $ip) {
// Step 1: Ping sweep
if (!$this->pingScanner->ping($ip)) {
continue;
}
$host = new DiscoveredHost();
$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);
$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);
}
return $aliveHosts;
}
/**
* @return string[]
*/
private function enumerateIps(string $cidr): array
{
if (str_contains($cidr, '/32')) {
return [explode('/', $cidr)[0]];
}
// 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;
}
return [];
}
private function resolveHostname(string $ip): ?string
{
$hostname = @gethostbyaddr($ip);
return ($hostname !== false && $hostname !== $ip) ? $hostname : null;
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace Domovoy\Services\Discovery;
class PingScanner
{
/**
* Check if host is alive via ICMP ping.
* Requires ping command in container.
*/
public function ping(string $ip, int $timeoutMs = 500): bool
{
// Use ping with timeout (Linux: -W in seconds)
$timeoutSec = max(1, (int)ceil($timeoutMs / 1000));
$output = [];
$exitCode = 0;
exec(sprintf('ping -c 1 -W %d %s 2>/dev/null', $timeoutSec, escapeshellarg($ip)), $output, $exitCode);
return $exitCode === 0;
}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
namespace Domovoy\Services\Discovery;
class TcpPortScanner
{
/**
* Scan a list of TCP ports on a host.
*
* @param int[] $ports
* @param int $timeoutMs connection timeout per port
* @param int $maxConcurrency max parallel connections
* @return int[] list of open ports
*/
public function scan(string $ip, array $ports, int $timeoutMs = 200, int $maxConcurrency = 50): array
{
$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);
}
}
}
sort($openPorts);
return $openPorts;
}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace Domovoy\Services\Jobs;
use Domovoy\Models\ScanJob;
use Domovoy\Repositories\ScanJobRepository;
use Domovoy\Services\Discovery\NetworkScanner;
class ScanJobRunner
{
private ScanJobRepository $scanJobRepository;
private NetworkScanner $networkScanner;
public function __construct(
ScanJobRepository $scanJobRepository,
NetworkScanner $networkScanner
) {
$this->scanJobRepository = $scanJobRepository;
$this->networkScanner = $networkScanner;
}
/**
* Run a single pending scan job.
*/
public function runNext(): ?ScanJob
{
$pending = $this->scanJobRepository->findPending();
if (empty($pending)) {
return null;
}
$job = $pending[0];
$this->execute($job);
return $job;
}
/**
* Run all pending scan jobs in a loop.
*
* @param callable|null $onProgress Called after each job: fn(ScanJob $job)
*/
public function runAll(?callable $onProgress = null): int
{
$count = 0;
while (true) {
$job = $this->runNext();
if ($job === null) {
break;
}
$count++;
if ($onProgress !== null) {
$onProgress($job);
}
}
return $count;
}
public function execute(ScanJob $job): void
{
$job->status = 'running';
$job->startedAt = new \DateTimeImmutable();
$this->scanJobRepository->save($job);
try {
switch ($job->type) {
case 'network_discovery':
$this->runNetworkDiscovery($job);
break;
default:
throw new \RuntimeException("Unknown scan job type: {$job->type}");
}
$job->status = 'done';
$job->finishedAt = new \DateTimeImmutable();
} catch (\Throwable $e) {
$job->status = 'failed';
$job->errorMessage = $e->getMessage();
$job->finishedAt = new \DateTimeImmutable();
}
$this->scanJobRepository->save($job);
}
private function runNetworkDiscovery(ScanJob $job): void
{
// For MVP, we scan all enabled network ranges if no specific range set
// TODO: filter by specific range when network_range_id is set
$settings = require dirname(__DIR__, 3) . '/../phinx.php';
// Use the ranges from database via NetworkRangeRepository
// For now, this is handled by the controller that creates the job with a specific range
}
}