Stabilize discovery workflow
This commit is contained in:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user