Stabilize discovery workflow
This commit is contained in:
parent
15772bc17e
commit
3981ffdf5e
|
|
@ -1,6 +1,7 @@
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
APP_SECRET=change-this-to-random-string
|
APP_SECRET=change-this-to-random-string
|
||||||
ENCRYPTION_KEY=change-this-to-32-byte-hex
|
ENCRYPTION_KEY=change-this-to-32-byte-hex
|
||||||
|
DOMOVOY_HTTP_PORT=8080
|
||||||
|
|
||||||
DB_HOST=db
|
DB_HOST=db
|
||||||
DB_PORT=3306
|
DB_PORT=3306
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,6 @@
|
||||||
/vendor/
|
/vendor/
|
||||||
.env
|
.env
|
||||||
.phpunit.result.cache
|
.phpunit.result.cache
|
||||||
|
/storage/logs/*.log
|
||||||
|
/storage/scans/*
|
||||||
|
/storage/reports/*
|
||||||
|
|
|
||||||
65
PLAN.md
65
PLAN.md
|
|
@ -137,6 +137,71 @@
|
||||||
|
|
||||||
Цель: добавить SSH-доступ к устройству, проверить подключение.
|
Цель: добавить 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
|
||||||
- Credentials CRUD (только SSH для MVP)
|
- Credentials CRUD (только SSH для MVP)
|
||||||
|
|
|
||||||
31
README.md
31
README.md
|
|
@ -28,18 +28,32 @@ Self-hosted система инвентаризации домашней и ма
|
||||||
git clone <repo>
|
git clone <repo>
|
||||||
cd domovoy
|
cd domovoy
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD
|
# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD.
|
||||||
docker compose up -d --build
|
# Если 8080 занят, измените DOMOVOY_HTTP_PORT.
|
||||||
docker compose exec app php vendor/bin/phinx migrate
|
./scripts/bootstrap.sh
|
||||||
docker compose exec app php bin/setup first-admin
|
# Откройте http://localhost:${DOMOVOY_HTTP_PORT:-8080}
|
||||||
# Откройте http://localhost:8080
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Запуск
|
## Запуск
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker compose up -d
|
./scripts/bootstrap.sh
|
||||||
# http://localhost:8080
|
# Скрипт поднимает контейнеры, ждёт БД, применяет новые миграции
|
||||||
|
# и запускает 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
|
docker compose exec app php vendor/bin/phinx create MigrationName
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Обычный запуск через `./scripts/bootstrap.sh` уже выполняет `phinx migrate`.
|
||||||
|
Ручной запуск нужен только для обслуживания или отладки.
|
||||||
|
|
||||||
## Smoke test
|
## Smoke test
|
||||||
|
|
||||||
```bash
|
```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();
|
ob_start();
|
||||||
$username = $_SESSION['username'] ?? 'User';
|
$username = $_SESSION['username'] ?? 'User';
|
||||||
$ranges = $this->networkRangeRepository->findAll();
|
$ranges = $this->networkRangeRepository->findAll();
|
||||||
|
$activeJobs = $this->scanJobRepository->findActive();
|
||||||
$scanJobs = $this->scanJobRepository->findRecent(10);
|
$scanJobs = $this->scanJobRepository->findRecent(10);
|
||||||
$newHosts = $this->discoveredHostRepository->findByStatus('new', 20);
|
|
||||||
require dirname(__DIR__, 2) . '/templates/discovery/index.php';
|
require dirname(__DIR__, 2) . '/templates/discovery/index.php';
|
||||||
$body = ob_get_clean();
|
$body = ob_get_clean();
|
||||||
$response->getBody()->write($body);
|
$response->getBody()->write($body);
|
||||||
|
|
@ -80,6 +80,53 @@ class DiscoveryController
|
||||||
->withStatus(302);
|
->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
|
private function createScanJob(string $type, int $rangeId): void
|
||||||
{
|
{
|
||||||
$job = new \Domovoy\Models\ScanJob();
|
$job = new \Domovoy\Models\ScanJob();
|
||||||
|
|
|
||||||
|
|
@ -78,17 +78,44 @@ class NetworkRangeController
|
||||||
|
|
||||||
private function isValidCidr(string $cidr): bool
|
private function isValidCidr(string $cidr): bool
|
||||||
{
|
{
|
||||||
if (!str_contains($cidr, '/')) {
|
if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
[$ip, $mask] = explode('/', $cidr, 2);
|
$ip = $matches[1];
|
||||||
|
$maskInt = (int)$matches[2];
|
||||||
|
|
||||||
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$maskInt = (int)$mask;
|
if ($maskInt < 8 || $maskInt > 32) {
|
||||||
return $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;
|
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
|
public function getCount(): int
|
||||||
{
|
{
|
||||||
return (int)$this->pdo->query('SELECT COUNT(*) FROM devices')->fetchColumn();
|
return (int)$this->pdo->query('SELECT COUNT(*) FROM devices')->fetchColumn();
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,94 @@ class DiscoveredHostRepository
|
||||||
return $results;
|
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
|
public function save(DiscoveredHost $host): void
|
||||||
{
|
{
|
||||||
$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
|
$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) {
|
if ($host->id === null) {
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'INSERT INTO discovered_hosts
|
'INSERT INTO discovered_hosts
|
||||||
|
|
@ -77,7 +162,7 @@ class DiscoveredHostRepository
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
'scan_job_id' => $host->scanJobId,
|
'scan_job_id' => $host->scanJobId,
|
||||||
'ip_address' => $host->ipAddress,
|
'ip_address' => $host->ipAddress,
|
||||||
'mac_address' => $host->macAddress,
|
'mac_address' => $host->macAddress !== null ? strtolower($host->macAddress) : null,
|
||||||
'hostname' => $host->hostname,
|
'hostname' => $host->hostname,
|
||||||
'vendor' => $host->vendor,
|
'vendor' => $host->vendor,
|
||||||
'detected_os' => $host->detectedOs,
|
'detected_os' => $host->detectedOs,
|
||||||
|
|
@ -96,12 +181,27 @@ class DiscoveredHostRepository
|
||||||
} else {
|
} else {
|
||||||
$stmt = $this->pdo->prepare(
|
$stmt = $this->pdo->prepare(
|
||||||
'UPDATE discovered_hosts SET
|
'UPDATE discovered_hosts SET
|
||||||
status = :status, matched_device_id = :matched_device_id,
|
scan_job_id = :scan_job_id, ip_address = :ip_address,
|
||||||
last_seen = :last_seen, updated_at = :updated_at
|
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'
|
WHERE id = :id'
|
||||||
);
|
);
|
||||||
$stmt->execute([
|
$stmt->execute([
|
||||||
'id' => $host->id,
|
'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,
|
'status' => $host->status,
|
||||||
'matched_device_id' => $host->matchedDeviceId,
|
'matched_device_id' => $host->matchedDeviceId,
|
||||||
'last_seen' => $now,
|
'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;
|
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
|
public function findRunning(): array
|
||||||
{
|
{
|
||||||
$stmt = $this->pdo->prepare('SELECT * FROM scan_jobs WHERE status = :status ORDER BY started_at ASC');
|
$stmt = $this->pdo->prepare('SELECT * FROM scan_jobs WHERE status = :status ORDER BY started_at ASC');
|
||||||
|
|
@ -58,6 +71,22 @@ class ScanJobRepository
|
||||||
return $results;
|
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
|
public function save(ScanJob $job): void
|
||||||
{
|
{
|
||||||
$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
|
$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
|
||||||
|
|
|
||||||
|
|
@ -36,18 +36,76 @@ class NetworkScanner
|
||||||
/**
|
/**
|
||||||
* @return DiscoveredHost[]
|
* @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);
|
$ips = $this->enumerateIps($range->cidr);
|
||||||
$aliveHosts = [];
|
$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
|
// Step 1: Ping sweep
|
||||||
if (!$this->pingScanner->ping($ip)) {
|
if (!$this->pingScanner->ping($ip)) {
|
||||||
|
$onProgress([
|
||||||
|
'next_ip_index' => $nextIndex,
|
||||||
|
'total_ips' => count($ips),
|
||||||
|
'scanned_count' => $scannedCount,
|
||||||
|
'hosts_found' => count($aliveHosts),
|
||||||
|
]);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$host = new DiscoveredHost();
|
$host = new DiscoveredHost();
|
||||||
|
$host->scanJobId = $scanJobId > 0 ? $scanJobId : null;
|
||||||
$host->ipAddress = $ip;
|
$host->ipAddress = $ip;
|
||||||
$host->firstSeen = new \DateTimeImmutable();
|
$host->firstSeen = new \DateTimeImmutable();
|
||||||
$host->lastSeen = new \DateTimeImmutable();
|
$host->lastSeen = new \DateTimeImmutable();
|
||||||
|
|
@ -66,20 +124,30 @@ class NetworkScanner
|
||||||
// Step 5: Confidence score
|
// Step 5: Confidence score
|
||||||
$host->confidence = $this->fingerprintService->calculateConfidence(count($openPorts), $hostname !== null);
|
$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])) {
|
if (isset($arpEntries[$host->ipAddress])) {
|
||||||
$host->macAddress = $arpEntries[$host->ipAddress]['mac'] ?? null;
|
$host->macAddress = $arpEntries[$host->ipAddress]['mac'] ?? null;
|
||||||
$host->vendor = $host->vendor ?: ($arpEntries[$host->ipAddress]['vendor'] ?? null);
|
$host->vendor = $host->vendor ?: ($arpEntries[$host->ipAddress]['vendor'] ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->discoveredHostRepository->save($host);
|
$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
|
private function enumerateIps(string $cidr): array
|
||||||
{
|
{
|
||||||
if (str_contains($cidr, '/32')) {
|
if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) {
|
||||||
return [explode('/', $cidr)[0]];
|
throw new \InvalidArgumentException("Invalid IPv4 CIDR: {$cidr}");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only handle /24 or larger for MVP (to avoid scanning huge ranges)
|
$baseIp = $matches[1];
|
||||||
if (preg_match('#^(\d+\.\d+\.\d+)\.(\d+)/(\d+)$#', $cidr, $m)) {
|
$mask = (int)$matches[2];
|
||||||
$prefix = $m[1];
|
if ($mask < 24) {
|
||||||
$suffix = (int)$m[2];
|
throw new \InvalidArgumentException('Network ranges larger than /24 are disabled for the MVP scanner');
|
||||||
$mask = (int)$m[3];
|
}
|
||||||
|
if ($mask > 32) {
|
||||||
if ($mask > 24) {
|
throw new \InvalidArgumentException("Invalid IPv4 CIDR mask: {$cidr}");
|
||||||
// Treat smaller than /24 as single IP
|
|
||||||
return [$cidr];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$offset = $mask === 24 ? 0 : $suffix;
|
$base = ip2long($baseIp);
|
||||||
$count = 2 ** (24 - $mask);
|
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 = [];
|
$ips = [];
|
||||||
for ($i = 1; $i <= $count; $i++) {
|
for ($ip = $start; $ip <= $end; $ip++) {
|
||||||
$ips[] = $prefix . '.' . ($offset + $i);
|
$ips[] = long2ip($ip);
|
||||||
}
|
|
||||||
return $ips;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [];
|
return $ips;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveHostname(string $ip): ?string
|
private function resolveHostname(string $ip): ?string
|
||||||
|
|
|
||||||
|
|
@ -19,53 +19,21 @@ class TcpPortScanner
|
||||||
$openPorts = [];
|
$openPorts = [];
|
||||||
$timeoutSec = $timeoutMs / 1000;
|
$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) {
|
foreach ($ports as $port) {
|
||||||
$fp = @fsockopen($ip, $port, $errno, $errstr, $timeoutSec);
|
$errno = 0;
|
||||||
|
$errstr = '';
|
||||||
|
$fp = @stream_socket_client(
|
||||||
|
"tcp://{$ip}:{$port}",
|
||||||
|
$errno,
|
||||||
|
$errstr,
|
||||||
|
$timeoutSec,
|
||||||
|
STREAM_CLIENT_CONNECT
|
||||||
|
);
|
||||||
if ($fp !== false) {
|
if ($fp !== false) {
|
||||||
$openPorts[] = $port;
|
$openPorts[] = $port;
|
||||||
fclose($fp);
|
fclose($fp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
sort($openPorts);
|
sort($openPorts);
|
||||||
return $openPorts;
|
return $openPorts;
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,20 @@ namespace Domovoy\Services\Inventory;
|
||||||
|
|
||||||
use Domovoy\Models\Device;
|
use Domovoy\Models\Device;
|
||||||
use Domovoy\Repositories\DeviceRepository;
|
use Domovoy\Repositories\DeviceRepository;
|
||||||
|
use Domovoy\Repositories\DiscoveredHostRepository;
|
||||||
|
|
||||||
class DeviceService
|
class DeviceService
|
||||||
{
|
{
|
||||||
private DeviceRepository $deviceRepository;
|
private DeviceRepository $deviceRepository;
|
||||||
|
private ?DiscoveredHostRepository $discoveredHostRepository;
|
||||||
|
|
||||||
public function __construct(DeviceRepository $deviceRepository)
|
public function __construct(
|
||||||
|
DeviceRepository $deviceRepository,
|
||||||
|
?DiscoveredHostRepository $discoveredHostRepository = null
|
||||||
|
)
|
||||||
{
|
{
|
||||||
$this->deviceRepository = $deviceRepository;
|
$this->deviceRepository = $deviceRepository;
|
||||||
|
$this->discoveredHostRepository = $discoveredHostRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function createFromDiscoveredHost(
|
public function createFromDiscoveredHost(
|
||||||
|
|
@ -56,4 +62,31 @@ class DeviceService
|
||||||
{
|
{
|
||||||
$this->deviceRepository->delete($id);
|
$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->ipAddress !== '') {
|
||||||
if ($host->primaryIp !== null) {
|
$device = $this->deviceRepository->findByIp($host->ipAddress);
|
||||||
// Would need findByIp in repository — skip for now
|
if ($device !== null) {
|
||||||
|
$suggestions[] = [
|
||||||
|
'device' => $device,
|
||||||
|
'confidence' => 40,
|
||||||
|
'reason' => 'IP адрес совпадает',
|
||||||
|
];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $suggestions;
|
return $suggestions;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ declare(strict_types=1);
|
||||||
namespace Domovoy\Services\Jobs;
|
namespace Domovoy\Services\Jobs;
|
||||||
|
|
||||||
use Domovoy\Models\ScanJob;
|
use Domovoy\Models\ScanJob;
|
||||||
|
use Domovoy\Repositories\NetworkRangeRepository;
|
||||||
use Domovoy\Repositories\ScanJobRepository;
|
use Domovoy\Repositories\ScanJobRepository;
|
||||||
use Domovoy\Services\Discovery\NetworkScanner;
|
use Domovoy\Services\Discovery\NetworkScanner;
|
||||||
|
|
||||||
|
|
@ -12,13 +13,16 @@ class ScanJobRunner
|
||||||
{
|
{
|
||||||
private ScanJobRepository $scanJobRepository;
|
private ScanJobRepository $scanJobRepository;
|
||||||
private NetworkScanner $networkScanner;
|
private NetworkScanner $networkScanner;
|
||||||
|
private NetworkRangeRepository $networkRangeRepository;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
ScanJobRepository $scanJobRepository,
|
ScanJobRepository $scanJobRepository,
|
||||||
NetworkScanner $networkScanner
|
NetworkScanner $networkScanner,
|
||||||
|
NetworkRangeRepository $networkRangeRepository
|
||||||
) {
|
) {
|
||||||
$this->scanJobRepository = $scanJobRepository;
|
$this->scanJobRepository = $scanJobRepository;
|
||||||
$this->networkScanner = $networkScanner;
|
$this->networkScanner = $networkScanner;
|
||||||
|
$this->networkRangeRepository = $networkRangeRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -64,16 +68,25 @@ class ScanJobRunner
|
||||||
$this->scanJobRepository->save($job);
|
$this->scanJobRepository->save($job);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
$finalStatus = null;
|
||||||
switch ($job->type) {
|
switch ($job->type) {
|
||||||
case 'network_discovery':
|
case 'network_discovery':
|
||||||
$this->runNetworkDiscovery($job);
|
$finalStatus = $this->runNetworkDiscovery($job);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
throw new \RuntimeException("Unknown scan job type: {$job->type}");
|
throw new \RuntimeException("Unknown scan job type: {$job->type}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($finalStatus === 'done') {
|
||||||
$job->status = 'done';
|
$job->status = 'done';
|
||||||
$job->finishedAt = new \DateTimeImmutable();
|
$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) {
|
} catch (\Throwable $e) {
|
||||||
$job->status = 'failed';
|
$job->status = 'failed';
|
||||||
$job->errorMessage = $e->getMessage();
|
$job->errorMessage = $e->getMessage();
|
||||||
|
|
@ -83,13 +96,77 @@ class ScanJobRunner
|
||||||
$this->scanJobRepository->save($job);
|
$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
|
if ($job->networkRangeId === null) {
|
||||||
// TODO: filter by specific range when network_range_id is set
|
throw new \RuntimeException('Network discovery job has no network_range_id');
|
||||||
$settings = require dirname(__DIR__, 3) . '/../phinx.php';
|
}
|
||||||
|
|
||||||
// Use the ranges from database via NetworkRangeRepository
|
$range = $this->networkRangeRepository->findById($job->networkRangeId);
|
||||||
// For now, this is handled by the controller that creates the job with a specific range
|
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
|
$discoveredHostRepository
|
||||||
);
|
);
|
||||||
|
|
||||||
$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner);
|
$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner, $networkRangeRepository);
|
||||||
|
|
||||||
$loopMode = in_array('--loop', $argv, true);
|
$loopMode = in_array('--loop', $argv, true);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,13 +25,36 @@ services:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
container_name: domovoy-web
|
container_name: domovoy-web
|
||||||
ports:
|
ports:
|
||||||
- "8080:80"
|
- "${DOMOVOY_HTTP_PORT:-8080}:80"
|
||||||
volumes:
|
volumes:
|
||||||
- .:/var/www/html
|
- .:/var/www/html
|
||||||
- ./docker/web/default.conf:/etc/nginx/conf.d/default.conf
|
- ./docker/web/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
depends_on:
|
depends_on:
|
||||||
- app
|
- 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:
|
db:
|
||||||
image: mariadb:11
|
image: mariadb:11
|
||||||
container_name: domovoy-db
|
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 {
|
.navbar-brand {
|
||||||
font-weight: bold;
|
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) {
|
\Domovoy\Services\Jobs\ScanJobRunner::class => function ($c) {
|
||||||
return new \Domovoy\Services\Jobs\ScanJobRunner(
|
return new \Domovoy\Services\Jobs\ScanJobRunner(
|
||||||
$c->get(\Domovoy\Repositories\ScanJobRepository::class),
|
$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
|
// Auth
|
||||||
|
|
@ -137,7 +138,10 @@ $containerBuilder->addDefinitions([
|
||||||
return new \Domovoy\Repositories\DeviceRepository($c->get(PDO::class));
|
return new \Domovoy\Repositories\DeviceRepository($c->get(PDO::class));
|
||||||
},
|
},
|
||||||
\Domovoy\Services\Inventory\DeviceService::class => function ($c) {
|
\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) {
|
\Domovoy\Services\Inventory\MergeSuggestionService::class => function ($c) {
|
||||||
return new \Domovoy\Services\Inventory\MergeSuggestionService($c->get(\Domovoy\Repositories\DeviceRepository::class));
|
return new \Domovoy\Services\Inventory\MergeSuggestionService($c->get(\Domovoy\Repositories\DeviceRepository::class));
|
||||||
|
|
@ -148,6 +152,13 @@ $containerBuilder->addDefinitions([
|
||||||
$c->get(\Domovoy\Repositories\DiscoveredHostRepository::class)
|
$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();
|
$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->get('/discovery', [\Domovoy\Controllers\DiscoveryController::class, 'index'])->setName('discovery');
|
||||||
$group->post('/discovery/scan', [\Domovoy\Controllers\DiscoveryController::class, 'startScan'])->setName('discovery.scan');
|
$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->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
|
// Network ranges CRUD
|
||||||
$group->post('/discovery/ranges/create', [\Domovoy\Controllers\NetworkRangeController::class, 'create'])->setName('discovery.ranges.create');
|
$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 ==="
|
echo "=== docker compose config ==="
|
||||||
docker compose config > /dev/null
|
docker compose config > /dev/null
|
||||||
|
|
||||||
echo "=== docker compose up ==="
|
echo "=== docker compose port config ==="
|
||||||
docker compose up -d --build
|
bash scripts/test-compose-config.sh
|
||||||
|
|
||||||
echo "=== wait for db ==="
|
echo "=== docker compose worker config ==="
|
||||||
sleep 5
|
bash scripts/test-worker-config.sh
|
||||||
|
|
||||||
echo "=== phinx migrate ==="
|
echo "=== bootstrap docker stack ==="
|
||||||
docker compose exec app php vendor/bin/phinx migrate
|
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 ==="
|
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 ==="
|
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; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
<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">
|
<form method="POST" action="/discovery/ranges/toggle" class="d-inline">
|
||||||
<input type="hidden" name="id" value="<?= $range->id ?>">
|
<input type="hidden" name="id" value="<?= $range->id ?>">
|
||||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||||
|
|
@ -73,93 +76,19 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Scan Jobs -->
|
<div id="active-jobs"
|
||||||
<div class="card mb-4">
|
hx-get="/discovery/jobs/active"
|
||||||
<div class="card-header">История сканирования</div>
|
hx-trigger="load, every 2s"
|
||||||
<div class="card-body">
|
hx-swap="innerHTML">
|
||||||
<?php if (empty($scanJobs)): ?>
|
<?php require dirname(__DIR__) . '/discovery/_active_jobs.php'; ?>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<!-- New Hosts -->
|
<div id="scan-history"
|
||||||
<div class="card mb-4">
|
hx-get="/discovery/jobs/history"
|
||||||
<div class="card-header">Новые находки (<?= count($newHosts) ?>)</div>
|
hx-trigger="load, every 2s"
|
||||||
<div class="card-body">
|
hx-swap="innerHTML">
|
||||||
<?php if (empty($newHosts)): ?>
|
<?php require dirname(__DIR__) . '/discovery/_scan_history.php'; ?>
|
||||||
<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>
|
</div>
|
||||||
|
|
||||||
<?php $content = ob_get_clean(); ?>
|
<?php $content = ob_get_clean(); ?>
|
||||||
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@
|
||||||
<div class="d-flex" id="wrapper">
|
<div class="d-flex" id="wrapper">
|
||||||
<!-- Sidebar -->
|
<!-- Sidebar -->
|
||||||
<nav class="bg-dark text-white border-end" id="sidebar-wrapper" style="width: 250px; min-height: 100vh;">
|
<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">
|
<a href="/dashboard" class="text-white text-decoration-none">
|
||||||
<h4 class="mb-0">Домовой</h4>
|
<h4 class="mb-0">Домовой</h4>
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -23,18 +23,21 @@
|
||||||
<a href="/discovery" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
<a href="/discovery" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
Сканирование сети
|
Сканирование сети
|
||||||
</a>
|
</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 href="/devices" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
Устройства
|
Устройства
|
||||||
</a>
|
</a>
|
||||||
<a href="/services" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
<span class="list-group-item bg-dark text-white-50 border-0">
|
||||||
Сервисы
|
Сервисы <small class="text-secondary">позже</small>
|
||||||
</a>
|
</span>
|
||||||
<a href="/documents" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
<span class="list-group-item bg-dark text-white-50 border-0">
|
||||||
Документы
|
Документы <small class="text-secondary">позже</small>
|
||||||
</a>
|
</span>
|
||||||
<a href="/settings" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
<span class="list-group-item bg-dark text-white-50 border-0">
|
||||||
Настройки
|
Настройки <small class="text-secondary">позже</small>
|
||||||
</a>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
|
@ -43,7 +46,6 @@
|
||||||
<!-- Top Navbar -->
|
<!-- Top Navbar -->
|
||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<span class="navbar-brand text-light">Домовой</span>
|
|
||||||
<div class="ms-auto d-flex align-items-center">
|
<div class="ms-auto d-flex align-items-center">
|
||||||
<span class="text-light me-3"><?= htmlspecialchars($username ?? 'User') ?></span>
|
<span class="text-light me-3"><?= htmlspecialchars($username ?? 'User') ?></span>
|
||||||
<form method="POST" action="/logout" class="d-inline">
|
<form method="POST" action="/logout" class="d-inline">
|
||||||
|
|
@ -63,5 +65,6 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</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