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
+96
View File
@@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Domovoy\Controllers;
use Domovoy\Repositories\DiscoveredHostRepository;
use Domovoy\Repositories\NetworkRangeRepository;
use Domovoy\Services\Inventory\DeviceService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class DiscoveriesController
{
public function __construct(
private DiscoveredHostRepository $discoveredHostRepository,
private NetworkRangeRepository $networkRangeRepository,
private DeviceService $deviceService
) {
}
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$filters = $this->filters($request);
$ranges = $this->networkRangeRepository->findAll();
$findings = $this->discoveredHostRepository->findFiltered(
$filters['range_id'],
$filters['scan_job_id'],
$filters['status'],
200,
$filters['sort'],
$filters['dir']
);
ob_start();
$username = $_SESSION['username'] ?? 'User';
require dirname(__DIR__, 2) . '/templates/discoveries/index.php';
$body = ob_get_clean();
$response->getBody()->write($body);
return $response;
}
public function table(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$filters = $this->filters($request);
$findings = $this->discoveredHostRepository->findFiltered(
$filters['range_id'],
$filters['scan_job_id'],
$filters['status'],
200,
$filters['sort'],
$filters['dir']
);
ob_start();
require dirname(__DIR__, 2) . '/templates/discoveries/_table.php';
$body = ob_get_clean();
$response->getBody()->write($body);
return $response;
}
public function merge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$data = $request->getParsedBody();
$hostId = isset($data['host_id']) ? (int)$data['host_id'] : 0;
$deviceId = isset($data['device_id']) ? (int)$data['device_id'] : 0;
if ($hostId > 0 && $deviceId > 0) {
$this->deviceService->mergeDiscoveredHost($hostId, $deviceId);
}
$referer = $request->getHeaderLine('Referer') ?: '/discoveries';
return $response
->withHeader('Location', $referer)
->withStatus(302);
}
/**
* @return array{range_id: ?int, scan_job_id: ?int, status: ?string, sort: string, dir: string}
*/
private function filters(ServerRequestInterface $request): array
{
$query = $request->getQueryParams();
$status = (string)($query['status'] ?? 'new');
$sort = (string)($query['sort'] ?? 'ip');
$dir = strtolower((string)($query['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
return [
'range_id' => isset($query['range_id']) && $query['range_id'] !== '' ? (int)$query['range_id'] : null,
'scan_job_id' => isset($query['scan_job_id']) && $query['scan_job_id'] !== '' ? (int)$query['scan_job_id'] : null,
'status' => $status === 'all' ? null : $status,
'sort' => $sort,
'dir' => $dir,
];
}
}
+48 -1
View File
@@ -29,8 +29,8 @@ class DiscoveryController
ob_start();
$username = $_SESSION['username'] ?? 'User';
$ranges = $this->networkRangeRepository->findAll();
$activeJobs = $this->scanJobRepository->findActive();
$scanJobs = $this->scanJobRepository->findRecent(10);
$newHosts = $this->discoveredHostRepository->findByStatus('new', 20);
require dirname(__DIR__, 2) . '/templates/discovery/index.php';
$body = ob_get_clean();
$response->getBody()->write($body);
@@ -80,6 +80,53 @@ class DiscoveryController
->withStatus(302);
}
public function activeJobs(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$activeJobs = $this->scanJobRepository->findActive();
ob_start();
require dirname(__DIR__, 2) . '/templates/discovery/_active_jobs.php';
$body = ob_get_clean();
$response->getBody()->write($body);
return $response;
}
public function scanHistory(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$scanJobs = $this->scanJobRepository->findRecent(10);
ob_start();
require dirname(__DIR__, 2) . '/templates/discovery/_scan_history.php';
$body = ob_get_clean();
$response->getBody()->write($body);
return $response;
}
public function pauseJob(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$job = $this->scanJobRepository->findById((int)$args['id']);
if ($job !== null && $job->status === 'running') {
$this->scanJobRepository->updateStatus($job->id, 'paused');
}
return $this->activeJobs($request, $response);
}
public function resumeJob(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$job = $this->scanJobRepository->findById((int)$args['id']);
if ($job !== null && $job->status === 'paused') {
$this->scanJobRepository->updateStatus($job->id, 'pending');
}
return $this->activeJobs($request, $response);
}
public function cancelJob(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
{
$job = $this->scanJobRepository->findById((int)$args['id']);
if ($job !== null && in_array($job->status, ['pending', 'running', 'paused'], true)) {
$this->scanJobRepository->updateStatus($job->id, 'cancelled');
}
return $this->activeJobs($request, $response);
}
private function createScanJob(string $type, int $rangeId): void
{
$job = new \Domovoy\Models\ScanJob();
+31 -4
View File
@@ -78,17 +78,44 @@ class NetworkRangeController
private function isValidCidr(string $cidr): bool
{
if (!str_contains($cidr, '/')) {
if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) {
return false;
}
[$ip, $mask] = explode('/', $cidr, 2);
$ip = $matches[1];
$maskInt = (int)$matches[2];
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return false;
}
$maskInt = (int)$mask;
return $maskInt >= 8 && $maskInt <= 32;
if ($maskInt < 8 || $maskInt > 32) {
return false;
}
$long = ip2long($ip);
if ($long === false) {
return false;
}
$value = (int)sprintf('%u', $long);
return $this->isInRange($value, '10.0.0.0', 8)
|| $this->isInRange($value, '172.16.0.0', 12)
|| $this->isInRange($value, '192.168.0.0', 16)
|| $this->isInRange($value, '169.254.0.0', 16)
|| $this->isInRange($value, '127.0.0.0', 8);
}
private function isInRange(int $ip, string $network, int $mask): bool
{
$networkLong = ip2long($network);
if ($networkLong === false) {
return false;
}
$networkValue = (int)sprintf('%u', $networkLong);
$maskValue = (0xFFFFFFFF << (32 - $mask)) & 0xFFFFFFFF;
return ($ip & $maskValue) === ($networkValue & $maskValue);
}
}
+8
View File
@@ -52,6 +52,14 @@ class DeviceRepository
return $row ? Device::fromArray($row) : null;
}
public function findByIp(string $ipAddress): ?Device
{
$stmt = $this->pdo->prepare('SELECT * FROM devices WHERE primary_ip = :ip');
$stmt->execute(['ip' => $ipAddress]);
$row = $stmt->fetch();
return $row ? Device::fromArray($row) : null;
}
public function getCount(): int
{
return (int)$this->pdo->query('SELECT COUNT(*) FROM devices')->fetchColumn();
+135 -3
View File
@@ -60,9 +60,94 @@ class DiscoveredHostRepository
return $results;
}
public function findFiltered(
?int $rangeId = null,
?int $scanJobId = null,
?string $status = 'new',
int $limit = 100,
string $sortBy = 'ip',
string $sortDir = 'asc'
): array {
$where = [];
$params = [];
if ($rangeId !== null) {
$where[] = 'sj.network_range_id = :range_id';
$params['range_id'] = $rangeId;
}
if ($scanJobId !== null) {
$where[] = 'dh.scan_job_id = :scan_job_id';
$params['scan_job_id'] = $scanJobId;
}
if ($status !== null && $status !== '' && $status !== 'all') {
$where[] = 'dh.status = :status';
$params['status'] = $status;
}
$sql = 'SELECT dh.*, sj.network_range_id, nr.name AS range_name, nr.cidr AS range_cidr,
d.id AS device_id, d.name AS device_name,
mac_device.id AS suggested_mac_device_id,
mac_device.name AS suggested_mac_device_name,
hostname_device.id AS suggested_hostname_device_id,
hostname_device.name AS suggested_hostname_device_name,
ip_device.id AS suggested_ip_device_id,
ip_device.name AS suggested_ip_device_name
FROM discovered_hosts dh
LEFT JOIN scan_jobs sj ON sj.id = dh.scan_job_id
LEFT JOIN network_ranges nr ON nr.id = sj.network_range_id
LEFT JOIN devices d ON d.id = dh.matched_device_id
LEFT JOIN devices mac_device ON mac_device.mac_address = dh.mac_address
LEFT JOIN devices hostname_device ON hostname_device.name = dh.hostname
LEFT JOIN devices ip_device ON ip_device.primary_ip = dh.ip_address';
if (!empty($where)) {
$sql .= ' WHERE ' . implode(' AND ', $where);
}
$sortColumn = $this->sortColumn($sortBy);
$direction = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC';
$sql .= " ORDER BY {$sortColumn} {$direction} LIMIT :limit";
$stmt = $this->pdo->prepare($sql);
foreach ($params as $key => $value) {
$stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR);
}
$stmt->bindValue('limit', $limit, PDO::PARAM_INT);
$stmt->execute();
$results = [];
while ($row = $stmt->fetch()) {
$results[] = $row;
}
return $results;
}
private function sortColumn(string $sortBy): string
{
return match ($sortBy) {
'hostname' => 'dh.hostname',
'mac' => 'dh.mac_address',
'vendor' => 'dh.vendor',
'ports' => 'dh.open_ports_json',
'range' => 'nr.name',
'status' => 'dh.status',
'device' => 'd.name',
'last_seen' => 'dh.last_seen',
default => 'INET_ATON(dh.ip_address)',
};
}
public function save(DiscoveredHost $host): void
{
$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
if ($host->id === null) {
$existing = $this->findExistingNewHost($host);
if ($existing !== null) {
$host->id = $existing->id;
$host->firstSeen = $existing->firstSeen;
}
}
if ($host->id === null) {
$stmt = $this->pdo->prepare(
'INSERT INTO discovered_hosts
@@ -77,7 +162,7 @@ class DiscoveredHostRepository
$stmt->execute([
'scan_job_id' => $host->scanJobId,
'ip_address' => $host->ipAddress,
'mac_address' => $host->macAddress,
'mac_address' => $host->macAddress !== null ? strtolower($host->macAddress) : null,
'hostname' => $host->hostname,
'vendor' => $host->vendor,
'detected_os' => $host->detectedOs,
@@ -96,12 +181,27 @@ class DiscoveredHostRepository
} else {
$stmt = $this->pdo->prepare(
'UPDATE discovered_hosts SET
status = :status, matched_device_id = :matched_device_id,
last_seen = :last_seen, updated_at = :updated_at
scan_job_id = :scan_job_id, ip_address = :ip_address,
mac_address = :mac_address, hostname = :hostname, vendor = :vendor,
detected_os = :detected_os, open_ports_json = :open_ports_json,
protocols_json = :protocols_json, fingerprint_json = :fingerprint_json,
confidence = :confidence, status = :status,
matched_device_id = :matched_device_id, last_seen = :last_seen,
updated_at = :updated_at
WHERE id = :id'
);
$stmt->execute([
'id' => $host->id,
'scan_job_id' => $host->scanJobId,
'ip_address' => $host->ipAddress,
'mac_address' => $host->macAddress !== null ? strtolower($host->macAddress) : null,
'hostname' => $host->hostname,
'vendor' => $host->vendor,
'detected_os' => $host->detectedOs,
'open_ports_json' => json_encode($host->openPorts),
'protocols_json' => json_encode($host->protocols),
'fingerprint_json' => json_encode($host->fingerprint),
'confidence' => $host->confidence,
'status' => $host->status,
'matched_device_id' => $host->matchedDeviceId,
'last_seen' => $now,
@@ -109,4 +209,36 @@ class DiscoveredHostRepository
]);
}
}
private function findExistingNewHost(DiscoveredHost $host): ?DiscoveredHost
{
if ($host->macAddress !== null && trim($host->macAddress) !== '') {
$stmt = $this->pdo->prepare(
'SELECT * FROM discovered_hosts
WHERE mac_address = :mac_address AND status = :status
ORDER BY last_seen DESC LIMIT 1'
);
$stmt->execute([
'mac_address' => strtolower($host->macAddress),
'status' => 'new',
]);
$row = $stmt->fetch();
if ($row) {
return DiscoveredHost::fromArray($row);
}
}
$stmt = $this->pdo->prepare(
'SELECT * FROM discovered_hosts
WHERE ip_address = :ip_address AND status = :status
ORDER BY last_seen DESC LIMIT 1'
);
$stmt->execute([
'ip_address' => $host->ipAddress,
'status' => 'new',
]);
$row = $stmt->fetch();
return $row ? DiscoveredHost::fromArray($row) : null;
}
}
+29
View File
@@ -47,6 +47,19 @@ class ScanJobRepository
return $results;
}
public function findActive(): array
{
$statuses = ['pending', 'running', 'paused'];
$placeholders = implode(',', array_fill(0, count($statuses), '?'));
$stmt = $this->pdo->prepare("SELECT * FROM scan_jobs WHERE status IN ({$placeholders}) ORDER BY created_at ASC");
$stmt->execute($statuses);
$results = [];
while ($row = $stmt->fetch()) {
$results[] = ScanJob::fromArray($row);
}
return $results;
}
public function findRunning(): array
{
$stmt = $this->pdo->prepare('SELECT * FROM scan_jobs WHERE status = :status ORDER BY started_at ASC');
@@ -58,6 +71,22 @@ class ScanJobRepository
return $results;
}
public function updateStatus(int $id, string $status): void
{
$finishedAt = in_array($status, ['done', 'failed', 'cancelled'], true)
? (new \DateTimeImmutable())->format('Y-m-d H:i:s')
: null;
$stmt = $this->pdo->prepare(
'UPDATE scan_jobs SET status = :status, finished_at = :finished_at WHERE id = :id'
);
$stmt->execute([
'id' => $id,
'status' => $status,
'finished_at' => $finishedAt,
]);
}
public function save(ScanJob $job): void
{
$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
+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);
}
}
+34 -1
View File
@@ -6,14 +6,20 @@ namespace Domovoy\Services\Inventory;
use Domovoy\Models\Device;
use Domovoy\Repositories\DeviceRepository;
use Domovoy\Repositories\DiscoveredHostRepository;
class DeviceService
{
private DeviceRepository $deviceRepository;
private ?DiscoveredHostRepository $discoveredHostRepository;
public function __construct(DeviceRepository $deviceRepository)
public function __construct(
DeviceRepository $deviceRepository,
?DiscoveredHostRepository $discoveredHostRepository = null
)
{
$this->deviceRepository = $deviceRepository;
$this->discoveredHostRepository = $discoveredHostRepository;
}
public function createFromDiscoveredHost(
@@ -56,4 +62,31 @@ class DeviceService
{
$this->deviceRepository->delete($id);
}
public function mergeDiscoveredHost(int $hostId, int $deviceId): void
{
if ($this->discoveredHostRepository === null) {
throw new \RuntimeException('DiscoveredHostRepository is required for merge');
}
$host = $this->discoveredHostRepository->findById($hostId);
if ($host === null) {
throw new \RuntimeException('Discovered host not found');
}
$device = $this->deviceRepository->findById($deviceId);
if ($device === null) {
throw new \RuntimeException('Device not found');
}
$device->primaryIp = $device->primaryIp ?: $host->ipAddress;
$device->macAddress = $device->macAddress ?: $host->macAddress;
$device->hostname = $device->hostname ?: $host->hostname;
$device->vendor = $device->vendor ?: $host->vendor;
$this->deviceRepository->save($device);
$host->status = 'merged';
$host->matchedDeviceId = (string)$device->id;
$this->discoveredHostRepository->save($host);
}
}
@@ -48,9 +48,15 @@ class MergeSuggestionService
}
}
// Low confidence: IP match
if ($host->primaryIp !== null) {
// Would need findByIp in repository — skip for now
if ($host->ipAddress !== '') {
$device = $this->deviceRepository->findByIp($host->ipAddress);
if ($device !== null) {
$suggestions[] = [
'device' => $device,
'confidence' => 40,
'reason' => 'IP адрес совпадает',
];
}
}
return $suggestions;
+87 -10
View File
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Domovoy\Services\Jobs;
use Domovoy\Models\ScanJob;
use Domovoy\Repositories\NetworkRangeRepository;
use Domovoy\Repositories\ScanJobRepository;
use Domovoy\Services\Discovery\NetworkScanner;
@@ -12,13 +13,16 @@ class ScanJobRunner
{
private ScanJobRepository $scanJobRepository;
private NetworkScanner $networkScanner;
private NetworkRangeRepository $networkRangeRepository;
public function __construct(
ScanJobRepository $scanJobRepository,
NetworkScanner $networkScanner
NetworkScanner $networkScanner,
NetworkRangeRepository $networkRangeRepository
) {
$this->scanJobRepository = $scanJobRepository;
$this->networkScanner = $networkScanner;
$this->networkRangeRepository = $networkRangeRepository;
}
/**
@@ -64,16 +68,25 @@ class ScanJobRunner
$this->scanJobRepository->save($job);
try {
$finalStatus = null;
switch ($job->type) {
case 'network_discovery':
$this->runNetworkDiscovery($job);
$finalStatus = $this->runNetworkDiscovery($job);
break;
default:
throw new \RuntimeException("Unknown scan job type: {$job->type}");
}
$job->status = 'done';
$job->finishedAt = new \DateTimeImmutable();
if ($finalStatus === 'done') {
$job->status = 'done';
$job->finishedAt = new \DateTimeImmutable();
} elseif ($finalStatus === 'paused') {
$job->status = 'paused';
$job->finishedAt = null;
} elseif ($finalStatus === 'cancelled') {
$job->status = 'cancelled';
$job->finishedAt = new \DateTimeImmutable();
}
} catch (\Throwable $e) {
$job->status = 'failed';
$job->errorMessage = $e->getMessage();
@@ -83,13 +96,77 @@ class ScanJobRunner
$this->scanJobRepository->save($job);
}
private function runNetworkDiscovery(ScanJob $job): void
private function runNetworkDiscovery(ScanJob $job): string
{
// 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';
if ($job->networkRangeId === null) {
throw new \RuntimeException('Network discovery job has no network_range_id');
}
// Use the ranges from database via NetworkRangeRepository
// For now, this is handled by the controller that creates the job with a specific range
$range = $this->networkRangeRepository->findById($job->networkRangeId);
if ($range === null) {
throw new \RuntimeException("Network range #{$job->networkRangeId} not found");
}
if (!$range->enabled) {
throw new \RuntimeException("Network range #{$job->networkRangeId} is disabled");
}
$existingProgress = $this->decodeProgress($job);
$startIndex = (int)($existingProgress['next_ip_index'] ?? 0);
$previousHostsFound = (int)($existingProgress['hosts_found'] ?? 0);
$result = $this->networkScanner->scanResumable(
$range,
$job->id ?? 0,
$startIndex,
function () use ($job): bool {
$fresh = $this->scanJobRepository->findById((int)$job->id);
return $fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true);
},
function (array $state) use ($job, $range, $previousHostsFound): void {
$fresh = $this->scanJobRepository->findById((int)$job->id);
if ($fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true)) {
$job->status = $fresh->status;
}
$job->resultJson = json_encode([
'network_range_id' => $range->id,
'cidr' => $range->cidr,
'next_ip_index' => $state['next_ip_index'],
'total_ips' => $state['total_ips'],
'scanned_count' => $state['scanned_count'],
'hosts_found' => $previousHostsFound + $state['hosts_found'],
], JSON_THROW_ON_ERROR);
$this->scanJobRepository->save($job);
}
);
$job->resultJson = json_encode([
'network_range_id' => $range->id,
'cidr' => $range->cidr,
'next_ip_index' => $result['next_ip_index'],
'total_ips' => $result['total_ips'],
'scanned_count' => $result['scanned_count'],
'hosts_found' => $previousHostsFound + $result['hosts_found'],
], JSON_THROW_ON_ERROR);
if ($result['completed']) {
return 'done';
}
$fresh = $this->scanJobRepository->findById((int)$job->id);
if ($fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true)) {
return $fresh->status;
}
return 'paused';
}
private function decodeProgress(ScanJob $job): array
{
if ($job->resultJson === null || $job->resultJson === '') {
return [];
}
$decoded = json_decode($job->resultJson, true);
return is_array($decoded) ? $decoded : [];
}
}