Stabilize discovery workflow
This commit is contained in:
parent
15772bc17e
commit
3981ffdf5e
|
|
@ -1,6 +1,7 @@
|
|||
APP_ENV=development
|
||||
APP_SECRET=change-this-to-random-string
|
||||
ENCRYPTION_KEY=change-this-to-32-byte-hex
|
||||
DOMOVOY_HTTP_PORT=8080
|
||||
|
||||
DB_HOST=db
|
||||
DB_PORT=3306
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
/vendor/
|
||||
.env
|
||||
.phpunit.result.cache
|
||||
/storage/logs/*.log
|
||||
/storage/scans/*
|
||||
/storage/reports/*
|
||||
|
|
|
|||
65
PLAN.md
65
PLAN.md
|
|
@ -137,6 +137,71 @@
|
|||
|
||||
Цель: добавить SSH-доступ к устройству, проверить подключение.
|
||||
|
||||
---
|
||||
|
||||
## Корректирующая итерация 3.1. Стабилизация Discovery и Inventory
|
||||
|
||||
Цель: привести уже заявленные итерации 2-3 к рабочему состоянию перед
|
||||
переходом к SSH-доступам и deep scan. Эта итерация обязательна, потому что
|
||||
часть функциональности сейчас реализована как каркас, но не выполняет
|
||||
пользовательский сценарий полностью.
|
||||
|
||||
Что исправляем:
|
||||
- Worker должен реально выполнять `network_discovery`, а не только менять
|
||||
статус scan job на `done`.
|
||||
- `scan_jobs.network_range_id` должен использоваться worker-ом для выбора
|
||||
конкретного диапазона.
|
||||
- `NetworkScanner` должен корректно перечислять IPv4 CIDR:
|
||||
- `/32` — один IP;
|
||||
- `/31` — оба адреса;
|
||||
- `/30` и меньше — только usable host addresses без network/broadcast;
|
||||
- диапазоны крупнее `/24` для MVP запрещаются или явно ограничиваются,
|
||||
чтобы случайно не запустить огромный scan.
|
||||
- `discovered_hosts.scan_job_id` должен заполняться найденными хостами.
|
||||
- Повторное обнаружение того же IP/MAC не должно плодить неуправляемые
|
||||
дубликаты без обновления `last_seen`.
|
||||
- Добавление network range должно запрещать публичные IPv4-сети по умолчанию.
|
||||
Разрешены только private/local ranges из ТЗ:
|
||||
`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, link-local,
|
||||
loopback для локального smoke test.
|
||||
- Discovery UI должен показывать ошибку scan job, если worker не смог
|
||||
выполнить задачу.
|
||||
- Merge suggestions должны быть либо доведены до рабочего UI, либо явно
|
||||
исключены из статуса "готово" итерации 3.
|
||||
- Ссылки в меню на ещё не реализованные разделы (`/services`, `/documents`,
|
||||
`/settings`) не должны выглядеть как готовая функциональность.
|
||||
- `scripts/check.sh` должен проверять не только открытие `/login`, но и
|
||||
smoke path: миграции, создание пользователя, добавление range, создание
|
||||
scan job, запуск worker-а хотя бы на `/32` loopback/private IP.
|
||||
|
||||
Файлы (ожидаемо новые/изменённые):
|
||||
app/Services/Jobs/ScanJobRunner.php
|
||||
app/Services/Discovery/NetworkScanner.php
|
||||
app/Controllers/NetworkRangeController.php
|
||||
app/Repositories/DiscoveredHostRepository.php
|
||||
app/Repositories/NetworkRangeRepository.php
|
||||
app/Repositories/AuditLogRepository.php
|
||||
templates/discovery/index.php
|
||||
templates/layout.php
|
||||
scripts/check.sh
|
||||
tests/
|
||||
|
||||
Проверка:
|
||||
vendor/bin/phpunit
|
||||
composer validate --no-check-publish
|
||||
find app public bin tests -name "*.php" -print0 | xargs -0 -n1 php -l
|
||||
./scripts/check.sh
|
||||
|
||||
Критерий готовности:
|
||||
# Добавить private range, например 127.0.0.1/32 или 192.168.1.1/32
|
||||
# Запустить scan через UI
|
||||
docker compose exec app php bin/run-scan-worker.php
|
||||
# scan_job получает done/failed с понятной причиной
|
||||
# при успешном обнаружении discovered_hosts содержит scan_job_id
|
||||
# публичный range вроде 8.8.8.8/32 не принимается без отдельного разрешения
|
||||
|
||||
---
|
||||
|
||||
Что делаем:
|
||||
- Миграция credentials
|
||||
- Credentials CRUD (только SSH для MVP)
|
||||
|
|
|
|||
31
README.md
31
README.md
|
|
@ -28,18 +28,32 @@ Self-hosted система инвентаризации домашней и ма
|
|||
git clone <repo>
|
||||
cd domovoy
|
||||
cp .env.example .env
|
||||
# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD
|
||||
docker compose up -d --build
|
||||
docker compose exec app php vendor/bin/phinx migrate
|
||||
docker compose exec app php bin/setup first-admin
|
||||
# Откройте http://localhost:8080
|
||||
# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD.
|
||||
# Если 8080 занят, измените DOMOVOY_HTTP_PORT.
|
||||
./scripts/bootstrap.sh
|
||||
# Откройте http://localhost:${DOMOVOY_HTTP_PORT:-8080}
|
||||
```
|
||||
|
||||
## Запуск
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
# http://localhost:8080
|
||||
./scripts/bootstrap.sh
|
||||
# Скрипт поднимает контейнеры, ждёт БД, применяет новые миграции
|
||||
# и запускает scan worker.
|
||||
```
|
||||
|
||||
Сетевое сканирование выполняется не web-запросом, а отдельным контейнером
|
||||
`domovoy-worker`. Если scan job висит в `pending`, проверьте worker:
|
||||
|
||||
```bash
|
||||
docker compose ps worker
|
||||
docker compose logs -f worker
|
||||
```
|
||||
|
||||
Для первого пользователя откройте `/setup` в браузере или выполните:
|
||||
|
||||
```bash
|
||||
docker compose exec app php bin/console setup:user admin 'change-this-password'
|
||||
```
|
||||
|
||||
## Миграции
|
||||
|
|
@ -50,6 +64,9 @@ docker compose exec app php vendor/bin/phinx rollback
|
|||
docker compose exec app php vendor/bin/phinx create MigrationName
|
||||
```
|
||||
|
||||
Обычный запуск через `./scripts/bootstrap.sh` уже выполняет `phinx migrate`.
|
||||
Ручной запуск нужен только для обслуживания или отладки.
|
||||
|
||||
## Smoke test
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -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 : [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ $networkScanner = new \Domovoy\Services\Discovery\NetworkScanner(
|
|||
$discoveredHostRepository
|
||||
);
|
||||
|
||||
$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner);
|
||||
$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner, $networkRangeRepository);
|
||||
|
||||
$loopMode = in_array('--loop', $argv, true);
|
||||
|
||||
|
|
|
|||
|
|
@ -25,13 +25,36 @@ services:
|
|||
image: nginx:alpine
|
||||
container_name: domovoy-web
|
||||
ports:
|
||||
- "8080:80"
|
||||
- "${DOMOVOY_HTTP_PORT:-8080}:80"
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
- ./docker/web/default.conf:/etc/nginx/conf.d/default.conf
|
||||
depends_on:
|
||||
- app
|
||||
|
||||
worker:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/app/Dockerfile
|
||||
container_name: domovoy-worker
|
||||
command: php bin/run-scan-worker.php --loop
|
||||
volumes:
|
||||
- .:/var/www/html
|
||||
- ./storage:/var/www/html/storage
|
||||
depends_on:
|
||||
- db
|
||||
- app
|
||||
environment:
|
||||
- APP_ENV=${APP_ENV:-development}
|
||||
- APP_SECRET=${APP_SECRET}
|
||||
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||
- DB_HOST=${DB_HOST:-db}
|
||||
- DB_PORT=${DB_PORT:-3306}
|
||||
- DB_DATABASE=${DB_DATABASE:-domovoy}
|
||||
- DB_USERNAME=${DB_USERNAME:-domovoy}
|
||||
- DB_PASSWORD=${DB_PASSWORD:-domovoy}
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: mariadb:11
|
||||
container_name: domovoy-db
|
||||
|
|
|
|||
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<phpunit bootstrap="vendor/autoload.php" colors="true">
|
||||
<testsuites>
|
||||
<testsuite name="Domovoy">
|
||||
<directory>tests</directory>
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
</phpunit>
|
||||
|
|
@ -20,3 +20,18 @@ body {
|
|||
.navbar-brand {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
#sidebar-wrapper .sidebar-heading,
|
||||
#page-content-wrapper .navbar {
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
#sidebar-wrapper .sidebar-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ports-cell {
|
||||
max-width: 360px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
(function () {
|
||||
function request(method, url, body, target, swap) {
|
||||
fetch(url, {
|
||||
method: method,
|
||||
body: body,
|
||||
headers: body ? {'X-Requested-With': 'fetch'} : {'X-Requested-With': 'fetch'}
|
||||
})
|
||||
.then(function (response) { return response.text(); })
|
||||
.then(function (html) {
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
if (swap === 'outerHTML') {
|
||||
target.outerHTML = html;
|
||||
} else {
|
||||
target.innerHTML = html;
|
||||
}
|
||||
install(target);
|
||||
});
|
||||
}
|
||||
|
||||
function targetFor(element) {
|
||||
var selector = element.getAttribute('hx-target');
|
||||
return selector ? document.querySelector(selector) : element;
|
||||
}
|
||||
|
||||
function install(root) {
|
||||
root.querySelectorAll('[hx-get]').forEach(function (element) {
|
||||
if (element.dataset.domovoyGetInstalled === '1') {
|
||||
return;
|
||||
}
|
||||
element.dataset.domovoyGetInstalled = '1';
|
||||
var url = element.getAttribute('hx-get');
|
||||
var trigger = element.getAttribute('hx-trigger') || '';
|
||||
var swap = element.getAttribute('hx-swap') || 'innerHTML';
|
||||
|
||||
if (element.tagName === 'FORM' && trigger.includes('change')) {
|
||||
element.addEventListener('change', function () {
|
||||
var params = new URLSearchParams(new FormData(element));
|
||||
var nextUrl = url + '?' + params.toString();
|
||||
var target = targetFor(element);
|
||||
if (target) {
|
||||
target.setAttribute('hx-get', nextUrl);
|
||||
}
|
||||
request('GET', nextUrl, null, target, swap);
|
||||
});
|
||||
}
|
||||
|
||||
if (trigger.includes('load')) {
|
||||
request('GET', url, null, targetFor(element), swap);
|
||||
}
|
||||
|
||||
var every = trigger.match(/every\s+(\d+)s/);
|
||||
if (every) {
|
||||
window.setInterval(function () {
|
||||
request('GET', url, null, targetFor(element), swap);
|
||||
}, parseInt(every[1], 10) * 1000);
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelectorAll('[hx-post]').forEach(function (element) {
|
||||
if (element.dataset.domovoyPostInstalled === '1') {
|
||||
return;
|
||||
}
|
||||
element.dataset.domovoyPostInstalled = '1';
|
||||
element.addEventListener('click', function () {
|
||||
request(
|
||||
'POST',
|
||||
element.getAttribute('hx-post'),
|
||||
new FormData(),
|
||||
targetFor(element),
|
||||
element.getAttribute('hx-swap') || 'innerHTML'
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
install(document);
|
||||
});
|
||||
})();
|
||||
|
|
@ -100,7 +100,8 @@ $containerBuilder->addDefinitions([
|
|||
\Domovoy\Services\Jobs\ScanJobRunner::class => function ($c) {
|
||||
return new \Domovoy\Services\Jobs\ScanJobRunner(
|
||||
$c->get(\Domovoy\Repositories\ScanJobRepository::class),
|
||||
$c->get(\Domovoy\Services\Discovery\NetworkScanner::class)
|
||||
$c->get(\Domovoy\Services\Discovery\NetworkScanner::class),
|
||||
$c->get(\Domovoy\Repositories\NetworkRangeRepository::class)
|
||||
);
|
||||
},
|
||||
// Auth
|
||||
|
|
@ -137,7 +138,10 @@ $containerBuilder->addDefinitions([
|
|||
return new \Domovoy\Repositories\DeviceRepository($c->get(PDO::class));
|
||||
},
|
||||
\Domovoy\Services\Inventory\DeviceService::class => function ($c) {
|
||||
return new \Domovoy\Services\Inventory\DeviceService($c->get(\Domovoy\Repositories\DeviceRepository::class));
|
||||
return new \Domovoy\Services\Inventory\DeviceService(
|
||||
$c->get(\Domovoy\Repositories\DeviceRepository::class),
|
||||
$c->get(\Domovoy\Repositories\DiscoveredHostRepository::class)
|
||||
);
|
||||
},
|
||||
\Domovoy\Services\Inventory\MergeSuggestionService::class => function ($c) {
|
||||
return new \Domovoy\Services\Inventory\MergeSuggestionService($c->get(\Domovoy\Repositories\DeviceRepository::class));
|
||||
|
|
@ -148,6 +152,13 @@ $containerBuilder->addDefinitions([
|
|||
$c->get(\Domovoy\Repositories\DiscoveredHostRepository::class)
|
||||
);
|
||||
},
|
||||
\Domovoy\Controllers\DiscoveriesController::class => function ($c) {
|
||||
return new \Domovoy\Controllers\DiscoveriesController(
|
||||
$c->get(\Domovoy\Repositories\DiscoveredHostRepository::class),
|
||||
$c->get(\Domovoy\Repositories\NetworkRangeRepository::class),
|
||||
$c->get(\Domovoy\Services\Inventory\DeviceService::class)
|
||||
);
|
||||
},
|
||||
]);
|
||||
$container = $containerBuilder->build();
|
||||
|
||||
|
|
@ -175,6 +186,16 @@ $app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
|
|||
$group->get('/discovery', [\Domovoy\Controllers\DiscoveryController::class, 'index'])->setName('discovery');
|
||||
$group->post('/discovery/scan', [\Domovoy\Controllers\DiscoveryController::class, 'startScan'])->setName('discovery.scan');
|
||||
$group->post('/discovery/hosts/ignore', [\Domovoy\Controllers\DiscoveryController::class, 'ignoreHost'])->setName('discovery.hosts.ignore');
|
||||
$group->get('/discovery/jobs/active', [\Domovoy\Controllers\DiscoveryController::class, 'activeJobs'])->setName('discovery.jobs.active');
|
||||
$group->get('/discovery/jobs/history', [\Domovoy\Controllers\DiscoveryController::class, 'scanHistory'])->setName('discovery.jobs.history');
|
||||
$group->post('/discovery/jobs/{id}/pause', [\Domovoy\Controllers\DiscoveryController::class, 'pauseJob'])->setName('discovery.jobs.pause');
|
||||
$group->post('/discovery/jobs/{id}/resume', [\Domovoy\Controllers\DiscoveryController::class, 'resumeJob'])->setName('discovery.jobs.resume');
|
||||
$group->post('/discovery/jobs/{id}/cancel', [\Domovoy\Controllers\DiscoveryController::class, 'cancelJob'])->setName('discovery.jobs.cancel');
|
||||
|
||||
// Discoveries
|
||||
$group->get('/discoveries', [\Domovoy\Controllers\DiscoveriesController::class, 'index'])->setName('discoveries');
|
||||
$group->get('/discoveries/table', [\Domovoy\Controllers\DiscoveriesController::class, 'table'])->setName('discoveries.table');
|
||||
$group->post('/discoveries/merge', [\Domovoy\Controllers\DiscoveriesController::class, 'merge'])->setName('discoveries.merge');
|
||||
|
||||
// Network ranges CRUD
|
||||
$group->post('/discovery/ranges/create', [\Domovoy\Controllers\NetworkRangeController::class, 'create'])->setName('discovery.ranges.create');
|
||||
|
|
|
|||
|
|
@ -0,0 +1,53 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -f .env ]]; then
|
||||
cp .env.example .env
|
||||
echo "Created .env from .env.example. Edit secrets before production use."
|
||||
fi
|
||||
|
||||
echo "=== build and start database/app ==="
|
||||
docker compose up -d --build db app
|
||||
|
||||
echo "=== wait for database ==="
|
||||
attempt=1
|
||||
max_attempts=60
|
||||
until docker compose exec -T app php -r '
|
||||
$host = getenv("DB_HOST") ?: "db";
|
||||
$port = (int)(getenv("DB_PORT") ?: 3306);
|
||||
$database = getenv("DB_DATABASE") ?: "domovoy";
|
||||
$username = getenv("DB_USERNAME") ?: "domovoy";
|
||||
$password = getenv("DB_PASSWORD") ?: "domovoy";
|
||||
|
||||
try {
|
||||
new PDO(
|
||||
sprintf("mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4", $host, $port, $database),
|
||||
$username,
|
||||
$password,
|
||||
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
|
||||
);
|
||||
exit(0);
|
||||
} catch (Throwable $e) {
|
||||
fwrite(STDERR, $e->getMessage() . PHP_EOL);
|
||||
exit(1);
|
||||
}
|
||||
' >/dev/null 2>&1; do
|
||||
if (( attempt >= max_attempts )); then
|
||||
echo "Database did not become ready after ${max_attempts} attempts" >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "=== run database migrations ==="
|
||||
docker compose exec -T app php vendor/bin/phinx migrate
|
||||
|
||||
echo "=== start web and worker ==="
|
||||
docker compose up -d --build web worker
|
||||
|
||||
port="$(grep -E '^DOMOVOY_HTTP_PORT=' .env | tail -n 1 | cut -d= -f2- || true)"
|
||||
port="${port:-8080}"
|
||||
|
||||
echo "=== ready ==="
|
||||
echo "Open http://localhost:${port}"
|
||||
|
|
@ -13,16 +13,19 @@ find app public bin -name "*.php" -print0 | xargs -0 -n1 php -l
|
|||
echo "=== docker compose config ==="
|
||||
docker compose config > /dev/null
|
||||
|
||||
echo "=== docker compose up ==="
|
||||
docker compose up -d --build
|
||||
echo "=== docker compose port config ==="
|
||||
bash scripts/test-compose-config.sh
|
||||
|
||||
echo "=== wait for db ==="
|
||||
sleep 5
|
||||
echo "=== docker compose worker config ==="
|
||||
bash scripts/test-worker-config.sh
|
||||
|
||||
echo "=== phinx migrate ==="
|
||||
docker compose exec app php vendor/bin/phinx migrate
|
||||
echo "=== bootstrap docker stack ==="
|
||||
bash scripts/bootstrap.sh
|
||||
|
||||
http_port="$(grep -E '^DOMOVOY_HTTP_PORT=' .env 2>/dev/null | tail -n 1 | cut -d= -f2- || true)"
|
||||
http_port="${http_port:-8080}"
|
||||
|
||||
echo "=== HTTP health check ==="
|
||||
curl -sI http://localhost:8080/login | head -1
|
||||
curl -sI "http://localhost:${http_port}/login" | head -1
|
||||
|
||||
echo "=== ALL CHECKS PASSED ==="
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ ! -x scripts/bootstrap.sh ]]; then
|
||||
echo "Expected executable scripts/bootstrap.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q "./scripts/bootstrap.sh" README.md; then
|
||||
echo "Expected README.md to document ./scripts/bootstrap.sh" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if grep -q "bin/setup first-admin" README.md; then
|
||||
echo "README.md still references removed bin/setup first-admin command" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "bootstrap docs ok"
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output="$(DOMOVOY_HTTP_PORT=18080 docker compose config)"
|
||||
|
||||
if ! grep -q "published: \"18080\"" <<< "$output"; then
|
||||
echo "Expected web service to publish DOMOVOY_HTTP_PORT=18080" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "compose port config ok"
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
output="$(docker compose config)"
|
||||
|
||||
if ! grep -q "container_name: domovoy-worker" <<< "$output"; then
|
||||
echo "Expected compose config to define domovoy-worker" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q -- "- bin/run-scan-worker.php" <<< "$output" || ! grep -q -- "- --loop" <<< "$output"; then
|
||||
echo "Expected worker to run bin/run-scan-worker.php --loop" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "worker config ok"
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
<?php if (empty($findings)): ?>
|
||||
<div class="alert alert-info">Находок по выбранным фильтрам нет.</div>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$sort = $filters['sort'] ?? 'ip';
|
||||
$dir = $filters['dir'] ?? 'asc';
|
||||
$sortLink = static function (string $field, string $label) use ($filters, $sort, $dir): string {
|
||||
$nextDir = ($sort === $field && $dir === 'asc') ? 'desc' : 'asc';
|
||||
$params = array_filter([
|
||||
'range_id' => $filters['range_id'] ?? null,
|
||||
'scan_job_id' => $filters['scan_job_id'] ?? null,
|
||||
'status' => $filters['status'] ?? 'all',
|
||||
'sort' => $field,
|
||||
'dir' => $nextDir,
|
||||
], static fn ($value) => $value !== null && $value !== '');
|
||||
$mark = $sort === $field ? ($dir === 'asc' ? ' ↑' : ' ↓') : '';
|
||||
return '<a href="/discoveries?' . htmlspecialchars(http_build_query($params)) . '" class="text-dark text-decoration-none">' .
|
||||
htmlspecialchars($label . $mark) . '</a>';
|
||||
};
|
||||
?>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th><?= $sortLink('ip', 'IP') ?></th>
|
||||
<th><?= $sortLink('hostname', 'Hostname') ?></th>
|
||||
<th><?= $sortLink('mac', 'MAC') ?></th>
|
||||
<th><?= $sortLink('vendor', 'Vendor') ?></th>
|
||||
<th><?= $sortLink('ports', 'Порты') ?></th>
|
||||
<th><?= $sortLink('range', 'Диапазон') ?></th>
|
||||
<th><?= $sortLink('status', 'Статус') ?></th>
|
||||
<th><?= $sortLink('device', 'Устройство') ?></th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($findings as $host): ?>
|
||||
<?php $ports = json_decode($host['open_ports_json'] ?? '[]', true) ?: []; ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($host['ip_address']) ?></td>
|
||||
<td><?= htmlspecialchars($host['hostname'] ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($host['mac_address'] ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($host['vendor'] ?? '-') ?></td>
|
||||
<td class="ports-cell">
|
||||
<?php if (empty($ports)): ?>
|
||||
-
|
||||
<?php else: ?>
|
||||
<?= htmlspecialchars(implode(', ', array_slice($ports, 0, 6))) ?>
|
||||
<?php if (count($ports) > 6): ?>
|
||||
<span class="text-muted">+<?= count($ports) - 6 ?></span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (!empty($host['range_name'])): ?>
|
||||
<?= htmlspecialchars($host['range_name']) ?>
|
||||
<small class="text-muted"><?= htmlspecialchars($host['range_cidr'] ?? '') ?></small>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= htmlspecialchars($host['status']) ?></td>
|
||||
<td>
|
||||
<?php if (!empty($host['device_id'])): ?>
|
||||
<a href="/devices/<?= (int)$host['device_id'] ?>" class="badge text-bg-success text-decoration-none">
|
||||
<?= htmlspecialchars($host['device_name'] ?? 'Устройство создано') ?>
|
||||
</a>
|
||||
<?php else: ?>
|
||||
<?php
|
||||
$suggestedDeviceId = null;
|
||||
$suggestedDeviceName = null;
|
||||
$suggestionReason = null;
|
||||
$suggestionConfidence = null;
|
||||
if (!empty($host['suggested_mac_device_id'])) {
|
||||
$suggestedDeviceId = (int)$host['suggested_mac_device_id'];
|
||||
$suggestedDeviceName = $host['suggested_mac_device_name'];
|
||||
$suggestionReason = 'MAC';
|
||||
$suggestionConfidence = 90;
|
||||
} elseif (!empty($host['suggested_hostname_device_id'])) {
|
||||
$suggestedDeviceId = (int)$host['suggested_hostname_device_id'];
|
||||
$suggestedDeviceName = $host['suggested_hostname_device_name'];
|
||||
$suggestionReason = 'hostname';
|
||||
$suggestionConfidence = 60;
|
||||
} elseif (!empty($host['suggested_ip_device_id'])) {
|
||||
$suggestedDeviceId = (int)$host['suggested_ip_device_id'];
|
||||
$suggestedDeviceName = $host['suggested_ip_device_name'];
|
||||
$suggestionReason = 'IP';
|
||||
$suggestionConfidence = 40;
|
||||
}
|
||||
?>
|
||||
<?php if ($suggestedDeviceId !== null): ?>
|
||||
<span class="badge text-bg-warning">
|
||||
Похоже на <?= htmlspecialchars($suggestedDeviceName ?? ('#' . $suggestedDeviceId)) ?>
|
||||
(<?= htmlspecialchars($suggestionReason ?? '') ?>, <?= (int)$suggestionConfidence ?>%)
|
||||
</span>
|
||||
<?php else: ?>
|
||||
<span class="badge text-bg-secondary">нет</span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<?php if (empty($host['device_id']) && ($suggestedDeviceId ?? null) !== null && $host['status'] === 'new'): ?>
|
||||
<form method="POST" action="/discoveries/merge" class="d-inline">
|
||||
<input type="hidden" name="host_id" value="<?= (int)$host['id'] ?>">
|
||||
<input type="hidden" name="device_id" value="<?= (int)$suggestedDeviceId ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-success">Объединить</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if (empty($host['device_id']) && $host['status'] === 'new'): ?>
|
||||
<form method="POST" action="/devices/from-host" class="d-inline">
|
||||
<input type="hidden" name="host_id" value="<?= (int)$host['id'] ?>">
|
||||
<input type="hidden" name="name" value="<?= htmlspecialchars($host['hostname'] ?: $host['ip_address']) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Создать устройство</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
<?php if ($host['status'] === 'new'): ?>
|
||||
<form method="POST" action="/discovery/hosts/ignore" class="d-inline">
|
||||
<input type="hidden" name="host_id" value="<?= (int)$host['id'] ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Игнорировать</button>
|
||||
</form>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
<?php ob_start(); ?>
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="mb-0">Находки</h2>
|
||||
</div>
|
||||
|
||||
<form method="GET"
|
||||
action="/discoveries"
|
||||
class="row g-2 mb-4"
|
||||
hx-get="/discoveries/table"
|
||||
hx-target="#discoveries-table"
|
||||
hx-trigger="change">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Диапазон</label>
|
||||
<select name="range_id" class="form-select">
|
||||
<option value="">Все диапазоны</option>
|
||||
<?php foreach ($ranges as $range): ?>
|
||||
<option value="<?= (int)$range->id ?>" <?= $filters['range_id'] === $range->id ? 'selected' : '' ?>>
|
||||
<?= htmlspecialchars($range->name) ?> (<?= htmlspecialchars($range->cidr) ?>)
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Статус</label>
|
||||
<select name="status" class="form-select">
|
||||
<?php foreach (['new' => 'Новые', 'accepted' => 'Принятые', 'ignored' => 'Игнорированные', 'all' => 'Все'] as $value => $label): ?>
|
||||
<option value="<?= $value ?>" <?= ($filters['status'] ?? null) === ($value === 'all' ? null : $value) ? 'selected' : '' ?>>
|
||||
<?= $label ?>
|
||||
</option>
|
||||
<?php endforeach; ?>
|
||||
</select>
|
||||
</div>
|
||||
<?php if ($filters['scan_job_id'] !== null): ?>
|
||||
<input type="hidden" name="scan_job_id" value="<?= (int)$filters['scan_job_id'] ?>">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Сканирование</label>
|
||||
<input type="text" class="form-control" value="#<?= (int)$filters['scan_job_id'] ?>" disabled>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<input type="hidden" name="sort" value="<?= htmlspecialchars($filters['sort']) ?>">
|
||||
<input type="hidden" name="dir" value="<?= htmlspecialchars($filters['dir']) ?>">
|
||||
<div class="col-md-2 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-outline-primary w-100">Применить</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="discoveries-table"
|
||||
hx-get="/discoveries/table?<?= htmlspecialchars(http_build_query(array_filter([
|
||||
'range_id' => $filters['range_id'],
|
||||
'scan_job_id' => $filters['scan_job_id'],
|
||||
'status' => $filters['status'] ?? 'all',
|
||||
'sort' => $filters['sort'],
|
||||
'dir' => $filters['dir'],
|
||||
], static fn ($value) => $value !== null && $value !== ''))) ?>"
|
||||
hx-trigger="every 2s"
|
||||
hx-swap="innerHTML">
|
||||
<?php require dirname(__DIR__) . '/discoveries/_table.php'; ?>
|
||||
</div>
|
||||
<?php $content = ob_get_clean(); ?>
|
||||
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<div class="card mb-4">
|
||||
<div class="card-header">Активные сканирования</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($activeJobs)): ?>
|
||||
<p class="text-muted mb-0">Нет активных сканирований.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Прогресс</th>
|
||||
<th>Найдено</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($activeJobs as $job): ?>
|
||||
<?php $progress = json_decode($job->resultJson ?? '{}', true) ?: []; ?>
|
||||
<?php
|
||||
$totalIps = (int)($progress['total_ips'] ?? 0);
|
||||
$nextIpIndex = (int)($progress['next_ip_index'] ?? 0);
|
||||
$hostsFound = (int)($progress['hosts_found'] ?? 0);
|
||||
$percent = $totalIps > 0 ? min(100, (int)floor(($nextIpIndex / $totalIps) * 100)) : 0;
|
||||
$badgeClass = match ($job->status) {
|
||||
'pending' => 'bg-warning',
|
||||
'running' => 'bg-info',
|
||||
'paused' => 'bg-secondary',
|
||||
default => 'bg-light text-dark',
|
||||
};
|
||||
?>
|
||||
<tr>
|
||||
<td>#<?= (int)$job->id ?></td>
|
||||
<td><?= htmlspecialchars($job->type) ?></td>
|
||||
<td><span class="badge <?= $badgeClass ?>"><?= htmlspecialchars($job->status) ?></span></td>
|
||||
<td style="min-width: 180px;">
|
||||
<div class="progress" style="height: 8px;">
|
||||
<div class="progress-bar" style="width: <?= $percent ?>%"></div>
|
||||
</div>
|
||||
<small class="text-muted"><?= $nextIpIndex ?> / <?= $totalIps ?: '?' ?> IP</small>
|
||||
</td>
|
||||
<td><?= $hostsFound ?></td>
|
||||
<td>
|
||||
<a href="/discoveries?scan_job_id=<?= (int)$job->id ?>&status=all"
|
||||
class="btn btn-sm btn-outline-primary">
|
||||
Находки
|
||||
</a>
|
||||
<?php if ($job->status === 'running'): ?>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
hx-post="/discovery/jobs/<?= (int)$job->id ?>/pause"
|
||||
hx-target="#active-jobs"
|
||||
hx-swap="innerHTML">
|
||||
Пауза
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if ($job->status === 'paused'): ?>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-success"
|
||||
hx-post="/discovery/jobs/<?= (int)$job->id ?>/resume"
|
||||
hx-target="#active-jobs"
|
||||
hx-swap="innerHTML">
|
||||
Продолжить
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
<?php if (in_array($job->status, ['pending', 'running', 'paused'], true)): ?>
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-danger"
|
||||
hx-post="/discovery/jobs/<?= (int)$job->id ?>/cancel"
|
||||
hx-target="#active-jobs"
|
||||
hx-swap="innerHTML">
|
||||
Отменить
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<div class="card mb-4">
|
||||
<div class="card-header">История сканирования</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($scanJobs)): ?>
|
||||
<p class="text-muted">Нет запусков сканирования.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Результат</th>
|
||||
<th>Запущен</th>
|
||||
<th>Завершён</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($scanJobs as $job): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($job->type) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$badgeClass = match ($job->status) {
|
||||
'pending' => 'bg-warning',
|
||||
'running' => 'bg-info',
|
||||
'done' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
'paused' => 'bg-secondary',
|
||||
'cancelled' => 'bg-dark',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<span class="badge <?= $badgeClass ?>"><?= htmlspecialchars($job->status) ?></span>
|
||||
</td>
|
||||
<td>
|
||||
<?php if ($job->errorMessage !== null): ?>
|
||||
<span class="text-danger"><?= htmlspecialchars($job->errorMessage) ?></span>
|
||||
<?php elseif ($job->resultJson !== null): ?>
|
||||
<?php $result = json_decode($job->resultJson, true) ?: []; ?>
|
||||
<?= isset($result['hosts_found']) ? (int)$result['hosts_found'] . ' хост(ов)' : '-' ?>
|
||||
<?php else: ?>
|
||||
-
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td><?= $job->startedAt?->format('Y-m-d H:i:s') ?? '-' ?></td>
|
||||
<td><?= $job->finishedAt?->format('Y-m-d H:i:s') ?? '-' ?></td>
|
||||
<td>
|
||||
<a href="/discoveries?scan_job_id=<?= (int)$job->id ?>&status=all" class="btn btn-sm btn-outline-primary">
|
||||
Находки
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -48,6 +48,9 @@
|
|||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<a href="/discoveries?range_id=<?= (int)$range->id ?>&status=all" class="btn btn-sm btn-outline-primary">
|
||||
Находки
|
||||
</a>
|
||||
<form method="POST" action="/discovery/ranges/toggle" class="d-inline">
|
||||
<input type="hidden" name="id" value="<?= $range->id ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
|
|
@ -73,93 +76,19 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Jobs -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">История сканирования</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($scanJobs)): ?>
|
||||
<p class="text-muted">Нет запусков сканирования.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Запущен</th>
|
||||
<th>Завершён</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($scanJobs as $job): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($job->type) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$badgeClass = match ($job->status) {
|
||||
'pending' => 'bg-warning',
|
||||
'running' => 'bg-info',
|
||||
'done' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<span class="badge <?= $badgeClass ?>"><?= htmlspecialchars($job->status) ?></span>
|
||||
</td>
|
||||
<td><?= $job->startedAt?->format('Y-m-d H:i:s') ?? '-' ?></td>
|
||||
<td><?= $job->finishedAt?->format('Y-m-d H:i:s') ?? '-' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div id="active-jobs"
|
||||
hx-get="/discovery/jobs/active"
|
||||
hx-trigger="load, every 2s"
|
||||
hx-swap="innerHTML">
|
||||
<?php require dirname(__DIR__) . '/discovery/_active_jobs.php'; ?>
|
||||
</div>
|
||||
|
||||
<!-- New Hosts -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Новые находки (<?= count($newHosts) ?>)</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($newHosts)): ?>
|
||||
<p class="text-muted">Нет новых находок.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th>MAC</th>
|
||||
<th>Vendor</th>
|
||||
<th>Порты</th>
|
||||
<th>Уверенность</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($newHosts as $host): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($host->ipAddress) ?></td>
|
||||
<td><?= htmlspecialchars($host->hostname ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($host->macAddress ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($host->vendor ?? '-') ?></td>
|
||||
<td><?= empty($host->openPorts) ? '-' : implode(', ', $host->openPorts) ?></td>
|
||||
<td><?= $host->confidence ?>%</td>
|
||||
<td>
|
||||
<form method="POST" action="/devices/from-host" class="d-inline">
|
||||
<input type="hidden" name="host_id" value="<?= $host->id ?>">
|
||||
<input type="hidden" name="name" value="<?= htmlspecialchars($host->hostname ?: $host->ipAddress) ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Создать устройство</button>
|
||||
</form>
|
||||
<form method="POST" action="/discovery/hosts/ignore" class="d-inline">
|
||||
<input type="hidden" name="host_id" value="<?= $host->id ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">Игнорировать</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<div id="scan-history"
|
||||
hx-get="/discovery/jobs/history"
|
||||
hx-trigger="load, every 2s"
|
||||
hx-swap="innerHTML">
|
||||
<?php require dirname(__DIR__) . '/discovery/_scan_history.php'; ?>
|
||||
</div>
|
||||
|
||||
<?php $content = ob_get_clean(); ?>
|
||||
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
<div class="d-flex" id="wrapper">
|
||||
<!-- Sidebar -->
|
||||
<nav class="bg-dark text-white border-end" id="sidebar-wrapper" style="width: 250px; min-height: 100vh;">
|
||||
<div class="sidebar-heading p-3 border-bottom border-secondary">
|
||||
<div class="sidebar-heading px-3 border-bottom border-secondary">
|
||||
<a href="/dashboard" class="text-white text-decoration-none">
|
||||
<h4 class="mb-0">Домовой</h4>
|
||||
</a>
|
||||
|
|
@ -23,18 +23,21 @@
|
|||
<a href="/discovery" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||
Сканирование сети
|
||||
</a>
|
||||
<a href="/discoveries" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||
Находки
|
||||
</a>
|
||||
<a href="/devices" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||
Устройства
|
||||
</a>
|
||||
<a href="/services" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||
Сервисы
|
||||
</a>
|
||||
<a href="/documents" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||
Документы
|
||||
</a>
|
||||
<a href="/settings" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||
Настройки
|
||||
</a>
|
||||
<span class="list-group-item bg-dark text-white-50 border-0">
|
||||
Сервисы <small class="text-secondary">позже</small>
|
||||
</span>
|
||||
<span class="list-group-item bg-dark text-white-50 border-0">
|
||||
Документы <small class="text-secondary">позже</small>
|
||||
</span>
|
||||
<span class="list-group-item bg-dark text-white-50 border-0">
|
||||
Настройки <small class="text-secondary">позже</small>
|
||||
</span>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
|
|
@ -43,7 +46,6 @@
|
|||
<!-- Top Navbar -->
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||
<div class="container-fluid">
|
||||
<span class="navbar-brand text-light">Домовой</span>
|
||||
<div class="ms-auto d-flex align-items-center">
|
||||
<span class="text-light me-3"><?= htmlspecialchars($username ?? 'User') ?></span>
|
||||
<form method="POST" action="/logout" class="d-inline">
|
||||
|
|
@ -63,5 +65,6 @@
|
|||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/js/domovoy.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Controllers;
|
||||
|
||||
use Domovoy\Controllers\NetworkRangeController;
|
||||
use Domovoy\Repositories\NetworkRangeRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NetworkRangeControllerTest extends TestCase
|
||||
{
|
||||
public function testPrivateIpv4RangesAreAllowedAndPublicRangesAreRejected(): void
|
||||
{
|
||||
$controller = new NetworkRangeController(new NoopNetworkRangeRepository());
|
||||
$method = new \ReflectionMethod(NetworkRangeController::class, 'isValidCidr');
|
||||
$method->setAccessible(true);
|
||||
|
||||
self::assertTrue($method->invoke($controller, '10.0.0.0/8'));
|
||||
self::assertTrue($method->invoke($controller, '172.16.0.0/12'));
|
||||
self::assertTrue($method->invoke($controller, '192.168.1.1/32'));
|
||||
self::assertTrue($method->invoke($controller, '169.254.10.20/32'));
|
||||
self::assertTrue($method->invoke($controller, '127.0.0.1/32'));
|
||||
self::assertFalse($method->invoke($controller, '8.8.8.8/32'));
|
||||
self::assertFalse($method->invoke($controller, '1.1.1.0/24'));
|
||||
}
|
||||
}
|
||||
|
||||
final class NoopNetworkRangeRepository extends NetworkRangeRepository
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Discovery;
|
||||
|
||||
use Domovoy\Models\DiscoveredHost;
|
||||
use Domovoy\Models\NetworkRange;
|
||||
use Domovoy\Repositories\DiscoveredHostRepository;
|
||||
use Domovoy\Services\Discovery\ArpTableReader;
|
||||
use Domovoy\Services\Discovery\HostFingerprintService;
|
||||
use Domovoy\Services\Discovery\NetworkScanner;
|
||||
use Domovoy\Services\Discovery\PingScanner;
|
||||
use Domovoy\Services\Discovery\TcpPortScanner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class NetworkScannerTest extends TestCase
|
||||
{
|
||||
public function testScanEnumeratesUsableHostsForThirtyBitCidr(): void
|
||||
{
|
||||
$repository = new InMemoryDiscoveredHostRepository();
|
||||
$scanner = new NetworkScanner(
|
||||
new AlwaysAlivePingScanner(),
|
||||
new EmptyTcpPortScanner(),
|
||||
new EmptyArpTableReader(),
|
||||
new StableFingerprintService(),
|
||||
$repository
|
||||
);
|
||||
|
||||
$range = new NetworkRange();
|
||||
$range->cidr = '192.168.1.0/30';
|
||||
|
||||
$hosts = $scanner->scan($range, 123);
|
||||
|
||||
self::assertSame(['192.168.1.1', '192.168.1.2'], array_map(
|
||||
static fn (DiscoveredHost $host): string => $host->ipAddress,
|
||||
$hosts
|
||||
));
|
||||
self::assertSame([123, 123], array_map(
|
||||
static fn (DiscoveredHost $host): ?int => $host->scanJobId,
|
||||
$repository->saved
|
||||
));
|
||||
}
|
||||
|
||||
public function testScanRejectsRangesLargerThanTwentyFourForMvp(): void
|
||||
{
|
||||
$scanner = new NetworkScanner(
|
||||
new AlwaysAlivePingScanner(),
|
||||
new EmptyTcpPortScanner(),
|
||||
new EmptyArpTableReader(),
|
||||
new StableFingerprintService(),
|
||||
new InMemoryDiscoveredHostRepository()
|
||||
);
|
||||
|
||||
$range = new NetworkRange();
|
||||
$range->cidr = '192.168.0.0/23';
|
||||
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('larger than /24');
|
||||
|
||||
$scanner->scan($range, 321);
|
||||
}
|
||||
|
||||
public function testResumableScanStartsAtSavedIndexAndReportsProgress(): void
|
||||
{
|
||||
$repository = new InMemoryDiscoveredHostRepository();
|
||||
$scanner = new NetworkScanner(
|
||||
new AlwaysAlivePingScanner(),
|
||||
new EmptyTcpPortScanner(),
|
||||
new EmptyArpTableReader(),
|
||||
new StableFingerprintService(),
|
||||
$repository
|
||||
);
|
||||
|
||||
$range = new NetworkRange();
|
||||
$range->cidr = '192.168.1.0/30';
|
||||
$progress = [];
|
||||
|
||||
$result = $scanner->scanResumable(
|
||||
$range,
|
||||
456,
|
||||
1,
|
||||
static fn (): bool => false,
|
||||
static function (array $state) use (&$progress): void {
|
||||
$progress[] = $state;
|
||||
}
|
||||
);
|
||||
|
||||
self::assertSame(['192.168.1.2'], array_map(
|
||||
static fn (DiscoveredHost $host): string => $host->ipAddress,
|
||||
$result['hosts']
|
||||
));
|
||||
self::assertTrue($result['completed']);
|
||||
self::assertSame(2, $result['next_ip_index']);
|
||||
self::assertSame(2, $result['total_ips']);
|
||||
self::assertSame(1, $result['scanned_count']);
|
||||
self::assertSame(1, $progress[0]['next_ip_index']);
|
||||
self::assertSame(2, $progress[1]['next_ip_index']);
|
||||
}
|
||||
}
|
||||
|
||||
final class AlwaysAlivePingScanner extends PingScanner
|
||||
{
|
||||
public function ping(string $ip, int $timeoutMs = 500): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
final class EmptyTcpPortScanner extends TcpPortScanner
|
||||
{
|
||||
public function scan(string $ip, array $ports, int $timeoutMs = 200, int $maxConcurrency = 50): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
final class EmptyArpTableReader extends ArpTableReader
|
||||
{
|
||||
public function read(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
final class StableFingerprintService extends HostFingerprintService
|
||||
{
|
||||
public function guessVendor(array $openPorts, ?string $hostname = null): ?string
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public function calculateConfidence(int $openPortCount, bool $hasHostname): int
|
||||
{
|
||||
return 50;
|
||||
}
|
||||
}
|
||||
|
||||
final class InMemoryDiscoveredHostRepository extends DiscoveredHostRepository
|
||||
{
|
||||
/** @var DiscoveredHost[] */
|
||||
public array $saved = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function save(DiscoveredHost $host): void
|
||||
{
|
||||
$this->saved[] = $host;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Discovery;
|
||||
|
||||
use Domovoy\Services\Discovery\TcpPortScanner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class TcpPortScannerTest extends TestCase
|
||||
{
|
||||
public function testTimedOutConnectionIsNotReportedAsOpen(): void
|
||||
{
|
||||
$scanner = new TcpPortScanner();
|
||||
|
||||
$openPorts = $scanner->scan('10.255.255.1', [9100], 50, 1);
|
||||
|
||||
self::assertSame([], $openPorts);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Inventory;
|
||||
|
||||
use Domovoy\Models\Device;
|
||||
use Domovoy\Models\DiscoveredHost;
|
||||
use Domovoy\Repositories\DeviceRepository;
|
||||
use Domovoy\Repositories\DiscoveredHostRepository;
|
||||
use Domovoy\Services\Inventory\DeviceService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DeviceServiceMergeTest extends TestCase
|
||||
{
|
||||
public function testMergeDiscoveredHostLinksHostAndFillsBlankDeviceFields(): void
|
||||
{
|
||||
$device = new Device();
|
||||
$device->id = 5;
|
||||
$device->name = 'server';
|
||||
|
||||
$host = new DiscoveredHost();
|
||||
$host->id = 9;
|
||||
$host->ipAddress = '192.168.1.50';
|
||||
$host->macAddress = 'aa:bb:cc:dd:ee:ff';
|
||||
$host->hostname = 'server.local';
|
||||
$host->vendor = 'Intel';
|
||||
|
||||
$devices = new MergeDeviceRepository($device);
|
||||
$hosts = new MergeHostRepository($host);
|
||||
$service = new DeviceService($devices, $hosts);
|
||||
|
||||
$service->mergeDiscoveredHost(9, 5);
|
||||
|
||||
self::assertSame('192.168.1.50', $device->primaryIp);
|
||||
self::assertSame('aa:bb:cc:dd:ee:ff', $device->macAddress);
|
||||
self::assertSame('server.local', $device->hostname);
|
||||
self::assertSame('Intel', $device->vendor);
|
||||
self::assertSame('merged', $host->status);
|
||||
self::assertSame('5', $host->matchedDeviceId);
|
||||
self::assertSame($device, $devices->savedDevice);
|
||||
self::assertSame($host, $hosts->savedHost);
|
||||
}
|
||||
}
|
||||
|
||||
final class MergeDeviceRepository extends DeviceRepository
|
||||
{
|
||||
public ?Device $savedDevice = null;
|
||||
|
||||
public function __construct(private Device $device)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(int $id): ?Device
|
||||
{
|
||||
return $id === $this->device->id ? $this->device : null;
|
||||
}
|
||||
|
||||
public function save(Device $device): void
|
||||
{
|
||||
$this->savedDevice = $device;
|
||||
}
|
||||
}
|
||||
|
||||
final class MergeHostRepository extends DiscoveredHostRepository
|
||||
{
|
||||
public ?DiscoveredHost $savedHost = null;
|
||||
|
||||
public function __construct(private DiscoveredHost $host)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(int $id): ?DiscoveredHost
|
||||
{
|
||||
return $id === $this->host->id ? $this->host : null;
|
||||
}
|
||||
|
||||
public function save(DiscoveredHost $host): void
|
||||
{
|
||||
$this->savedHost = $host;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Inventory;
|
||||
|
||||
use Domovoy\Models\Device;
|
||||
use Domovoy\Models\DiscoveredHost;
|
||||
use Domovoy\Repositories\DeviceRepository;
|
||||
use Domovoy\Services\Inventory\MergeSuggestionService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class MergeSuggestionServiceTest extends TestCase
|
||||
{
|
||||
public function testSuggestsDeviceByIpWhenMacAndHostnameDoNotMatch(): void
|
||||
{
|
||||
$device = new Device();
|
||||
$device->id = 10;
|
||||
$device->name = 'router';
|
||||
$device->primaryIp = '192.168.1.1';
|
||||
|
||||
$repository = new FakeDeviceRepository(ipMatch: $device);
|
||||
$service = new MergeSuggestionService($repository);
|
||||
|
||||
$host = new DiscoveredHost();
|
||||
$host->ipAddress = '192.168.1.1';
|
||||
|
||||
$suggestions = $service->findSuggestions($host);
|
||||
|
||||
self::assertSame($device, $suggestions[0]['device']);
|
||||
self::assertSame(40, $suggestions[0]['confidence']);
|
||||
self::assertSame('IP адрес совпадает', $suggestions[0]['reason']);
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeDeviceRepository extends DeviceRepository
|
||||
{
|
||||
public function __construct(
|
||||
private ?Device $macMatch = null,
|
||||
private ?Device $nameMatch = null,
|
||||
private ?Device $ipMatch = null
|
||||
) {
|
||||
}
|
||||
|
||||
public function findByMac(string $macAddress): ?Device
|
||||
{
|
||||
return $this->macMatch;
|
||||
}
|
||||
|
||||
public function findByName(string $name): ?Device
|
||||
{
|
||||
return $this->nameMatch;
|
||||
}
|
||||
|
||||
public function findByIp(string $ipAddress): ?Device
|
||||
{
|
||||
return $this->ipMatch;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Jobs;
|
||||
|
||||
use Domovoy\Models\NetworkRange;
|
||||
use Domovoy\Models\ScanJob;
|
||||
use Domovoy\Repositories\NetworkRangeRepository;
|
||||
use Domovoy\Repositories\ScanJobRepository;
|
||||
use Domovoy\Services\Discovery\NetworkScanner;
|
||||
use Domovoy\Services\Jobs\ScanJobRunner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class ScanJobRunnerTest extends TestCase
|
||||
{
|
||||
public function testNetworkDiscoveryJobScansItsNetworkRange(): void
|
||||
{
|
||||
$job = new ScanJob();
|
||||
$job->id = 77;
|
||||
$job->type = 'network_discovery';
|
||||
$job->status = 'pending';
|
||||
$job->networkRangeId = 5;
|
||||
|
||||
$range = new NetworkRange();
|
||||
$range->id = 5;
|
||||
$range->cidr = '192.168.1.1/32';
|
||||
|
||||
$jobs = new InMemoryScanJobRepository([$job]);
|
||||
$ranges = new InMemoryNetworkRangeLookup([5 => $range]);
|
||||
$scanner = new RecordingNetworkScanner();
|
||||
|
||||
$runner = new ScanJobRunner($jobs, $scanner, $ranges);
|
||||
$runner->runNext();
|
||||
|
||||
self::assertSame('done', $job->status);
|
||||
self::assertSame($range, $scanner->scannedRange);
|
||||
self::assertSame(77, $scanner->scanJobId);
|
||||
}
|
||||
|
||||
public function testPausedDiscoveryJobKeepsProgressAndDoesNotBecomeDone(): void
|
||||
{
|
||||
$job = new ScanJob();
|
||||
$job->id = 88;
|
||||
$job->type = 'network_discovery';
|
||||
$job->status = 'pending';
|
||||
$job->networkRangeId = 5;
|
||||
$job->resultJson = json_encode([
|
||||
'next_ip_index' => 1,
|
||||
'hosts_found' => 2,
|
||||
]);
|
||||
|
||||
$range = new NetworkRange();
|
||||
$range->id = 5;
|
||||
$range->cidr = '192.168.1.0/30';
|
||||
|
||||
$jobs = new InMemoryScanJobRepository([$job]);
|
||||
$ranges = new InMemoryNetworkRangeLookup([5 => $range]);
|
||||
$scanner = new RecordingNetworkScanner();
|
||||
$scanner->result = [
|
||||
'hosts' => [],
|
||||
'completed' => false,
|
||||
'next_ip_index' => 2,
|
||||
'total_ips' => 2,
|
||||
'scanned_count' => 1,
|
||||
'hosts_found' => 2,
|
||||
];
|
||||
$jobs->statusAfterFirstProgressCheck = 'paused';
|
||||
|
||||
$runner = new ScanJobRunner($jobs, $scanner, $ranges);
|
||||
$runner->runNext();
|
||||
|
||||
self::assertSame('paused', $job->status);
|
||||
self::assertNull($job->finishedAt);
|
||||
self::assertSame(1, $scanner->startIndex);
|
||||
self::assertSame(2, json_decode($job->resultJson ?? '{}', true)['next_ip_index']);
|
||||
}
|
||||
}
|
||||
|
||||
final class InMemoryScanJobRepository extends ScanJobRepository
|
||||
{
|
||||
public ?string $statusAfterFirstProgressCheck = null;
|
||||
private int $findByIdCalls = 0;
|
||||
|
||||
/** @param ScanJob[] $pending */
|
||||
public function __construct(private array $pending)
|
||||
{
|
||||
}
|
||||
|
||||
public function findPending(): array
|
||||
{
|
||||
return $this->pending;
|
||||
}
|
||||
|
||||
public function save(ScanJob $job): void
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(int $id): ?ScanJob
|
||||
{
|
||||
$this->findByIdCalls++;
|
||||
if ($this->statusAfterFirstProgressCheck !== null && $this->findByIdCalls > 1) {
|
||||
$this->pending[0]->status = $this->statusAfterFirstProgressCheck;
|
||||
}
|
||||
return $this->pending[0] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
final class InMemoryNetworkRangeLookup extends NetworkRangeRepository
|
||||
{
|
||||
/** @param array<int, NetworkRange> $ranges */
|
||||
public function __construct(private array $ranges)
|
||||
{
|
||||
}
|
||||
|
||||
public function findById(int $id): ?NetworkRange
|
||||
{
|
||||
return $this->ranges[$id] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
final class RecordingNetworkScanner extends NetworkScanner
|
||||
{
|
||||
public ?NetworkRange $scannedRange = null;
|
||||
public ?int $scanJobId = null;
|
||||
public ?int $startIndex = null;
|
||||
public array $result = [
|
||||
'hosts' => [],
|
||||
'completed' => true,
|
||||
'next_ip_index' => 1,
|
||||
'total_ips' => 1,
|
||||
'scanned_count' => 1,
|
||||
'hosts_found' => 0,
|
||||
];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function scan(NetworkRange $range, ?int $scanJobId = null): array
|
||||
{
|
||||
$this->scannedRange = $range;
|
||||
$this->scanJobId = $scanJobId;
|
||||
return [];
|
||||
}
|
||||
|
||||
public function scanResumable(
|
||||
NetworkRange $range,
|
||||
int $scanJobId,
|
||||
int $startIndex,
|
||||
callable $shouldStop,
|
||||
callable $onProgress
|
||||
): array {
|
||||
$this->scannedRange = $range;
|
||||
$this->scanJobId = $scanJobId;
|
||||
$this->startIndex = $startIndex;
|
||||
$onProgress($this->result);
|
||||
return $this->result;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Repositories;
|
||||
|
||||
use Domovoy\Repositories\DiscoveredHostRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DiscoveredHostFilterTest extends TestCase
|
||||
{
|
||||
public function testFindFilteredAppliesRangeScanJobAndStatusFilters(): void
|
||||
{
|
||||
$pdo = new FilterCapturePdo();
|
||||
$repository = new DiscoveredHostRepository($pdo);
|
||||
|
||||
$repository->findFiltered(7, 3, 'new', 50);
|
||||
|
||||
self::assertStringContainsString('LEFT JOIN scan_jobs', $pdo->query);
|
||||
self::assertStringContainsString('LEFT JOIN network_ranges', $pdo->query);
|
||||
self::assertStringContainsString('LEFT JOIN devices', $pdo->query);
|
||||
self::assertStringContainsString('sj.network_range_id = :range_id', $pdo->query);
|
||||
self::assertStringContainsString('dh.scan_job_id = :scan_job_id', $pdo->query);
|
||||
self::assertStringContainsString('dh.status = :status', $pdo->query);
|
||||
self::assertSame(7, $pdo->bound['range_id']);
|
||||
self::assertSame(3, $pdo->bound['scan_job_id']);
|
||||
self::assertSame('new', $pdo->bound['status']);
|
||||
self::assertSame(50, $pdo->bound['limit']);
|
||||
}
|
||||
|
||||
public function testFindFilteredSortsByIpByDefault(): void
|
||||
{
|
||||
$pdo = new FilterCapturePdo();
|
||||
$repository = new DiscoveredHostRepository($pdo);
|
||||
|
||||
$repository->findFiltered(null, null, 'all', 50);
|
||||
|
||||
self::assertStringContainsString('ORDER BY INET_ATON(dh.ip_address) ASC', $pdo->query);
|
||||
}
|
||||
|
||||
public function testFindFilteredAppliesAllowedSortColumnAndDirection(): void
|
||||
{
|
||||
$pdo = new FilterCapturePdo();
|
||||
$repository = new DiscoveredHostRepository($pdo);
|
||||
|
||||
$repository->findFiltered(null, null, 'all', 50, 'hostname', 'desc');
|
||||
|
||||
self::assertStringContainsString('ORDER BY dh.hostname DESC', $pdo->query);
|
||||
}
|
||||
}
|
||||
|
||||
final class FilterCapturePdo extends PDO
|
||||
{
|
||||
public string $query = '';
|
||||
/** @var array<string, mixed> */
|
||||
public array $bound = [];
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
$this->query = $query;
|
||||
return new FilterCaptureStatement($this);
|
||||
}
|
||||
}
|
||||
|
||||
final class FilterCaptureStatement extends PDOStatement
|
||||
{
|
||||
public function __construct(private FilterCapturePdo $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function bindValue(string|int $param, mixed $value, int $type = PDO::PARAM_STR): bool
|
||||
{
|
||||
$this->pdo->bound[ltrim((string)$param, ':')] = $value;
|
||||
return true;
|
||||
}
|
||||
|
||||
public function execute(?array $params = null): bool
|
||||
{
|
||||
foreach (($params ?? []) as $key => $value) {
|
||||
$this->pdo->bound[ltrim((string)$key, ':')] = $value;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,129 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Repositories;
|
||||
|
||||
use Domovoy\Models\DiscoveredHost;
|
||||
use Domovoy\Repositories\DiscoveredHostRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class DiscoveredHostRepositoryTest extends TestCase
|
||||
{
|
||||
public function testSaveUpdatesExistingNewHostByMacInsteadOfCreatingDuplicate(): void
|
||||
{
|
||||
$pdo = new FakeDiscoveredHostPdo();
|
||||
$repository = new DiscoveredHostRepository($pdo);
|
||||
|
||||
$first = new DiscoveredHost();
|
||||
$first->scanJobId = 1;
|
||||
$first->ipAddress = '192.168.1.10';
|
||||
$first->macAddress = 'AA:BB:CC:DD:EE:FF';
|
||||
$first->hostname = 'old-host';
|
||||
$first->openPorts = [22];
|
||||
$repository->save($first);
|
||||
|
||||
$second = new DiscoveredHost();
|
||||
$second->scanJobId = 2;
|
||||
$second->ipAddress = '192.168.1.20';
|
||||
$second->macAddress = 'aa:bb:cc:dd:ee:ff';
|
||||
$second->hostname = 'new-host';
|
||||
$second->openPorts = [22, 80];
|
||||
$repository->save($second);
|
||||
|
||||
self::assertCount(1, $pdo->rows);
|
||||
self::assertSame(1, $first->id);
|
||||
self::assertSame(1, $second->id);
|
||||
self::assertSame(2, $pdo->rows[1]['scan_job_id']);
|
||||
self::assertSame('192.168.1.20', $pdo->rows[1]['ip_address']);
|
||||
self::assertSame('new-host', $pdo->rows[1]['hostname']);
|
||||
self::assertSame('[22,80]', $pdo->rows[1]['open_ports_json']);
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeDiscoveredHostPdo extends PDO
|
||||
{
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
public int $lastId = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return new FakeDiscoveredHostStatement($this, $query);
|
||||
}
|
||||
|
||||
public function lastInsertId(?string $name = null): string|false
|
||||
{
|
||||
return (string)$this->lastId;
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeDiscoveredHostStatement extends PDOStatement
|
||||
{
|
||||
/** @var array<string, mixed>|false */
|
||||
private array|false $result = false;
|
||||
|
||||
public function __construct(
|
||||
private FakeDiscoveredHostPdo $pdo,
|
||||
private string $query
|
||||
) {
|
||||
}
|
||||
|
||||
public function execute(?array $params = null): bool
|
||||
{
|
||||
$params ??= [];
|
||||
|
||||
if (str_contains($this->query, 'SELECT * FROM discovered_hosts WHERE id = :id')) {
|
||||
$this->result = $this->pdo->rows[(int)$params['id']] ?? false;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($this->query, 'WHERE mac_address = :mac_address')) {
|
||||
$this->result = false;
|
||||
foreach ($this->pdo->rows as $row) {
|
||||
if ($row['mac_address'] === $params['mac_address'] && $row['status'] === 'new') {
|
||||
$this->result = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($this->query, 'WHERE ip_address = :ip_address')) {
|
||||
$this->result = false;
|
||||
foreach ($this->pdo->rows as $row) {
|
||||
if ($row['ip_address'] === $params['ip_address'] && $row['status'] === 'new') {
|
||||
$this->result = $row;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($this->query, 'INSERT INTO discovered_hosts')) {
|
||||
$this->pdo->lastId++;
|
||||
$params['id'] = $this->pdo->lastId;
|
||||
$this->pdo->rows[$this->pdo->lastId] = $params;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (str_contains($this->query, 'UPDATE discovered_hosts SET')) {
|
||||
$id = (int)$params['id'];
|
||||
$this->pdo->rows[$id] = array_replace($this->pdo->rows[$id], $params);
|
||||
return true;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Unexpected query: ' . $this->query);
|
||||
}
|
||||
|
||||
public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed
|
||||
{
|
||||
return $this->result;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue