Stabilize discovery workflow

This commit is contained in:
mirivlad 2026-05-29 17:16:46 +08:00
parent 15772bc17e
commit 3981ffdf5e
40 changed files with 2039 additions and 211 deletions

View File

@ -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

3
.gitignore vendored
View File

@ -1,3 +1,6 @@
/vendor/ /vendor/
.env .env
.phpunit.result.cache .phpunit.result.cache
/storage/logs/*.log
/storage/scans/*
/storage/reports/*

65
PLAN.md
View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,96 @@
<?php
declare(strict_types=1);
namespace Domovoy\Controllers;
use Domovoy\Repositories\DiscoveredHostRepository;
use Domovoy\Repositories\NetworkRangeRepository;
use Domovoy\Services\Inventory\DeviceService;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class DiscoveriesController
{
public function __construct(
private DiscoveredHostRepository $discoveredHostRepository,
private NetworkRangeRepository $networkRangeRepository,
private DeviceService $deviceService
) {
}
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$filters = $this->filters($request);
$ranges = $this->networkRangeRepository->findAll();
$findings = $this->discoveredHostRepository->findFiltered(
$filters['range_id'],
$filters['scan_job_id'],
$filters['status'],
200,
$filters['sort'],
$filters['dir']
);
ob_start();
$username = $_SESSION['username'] ?? 'User';
require dirname(__DIR__, 2) . '/templates/discoveries/index.php';
$body = ob_get_clean();
$response->getBody()->write($body);
return $response;
}
public function table(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$filters = $this->filters($request);
$findings = $this->discoveredHostRepository->findFiltered(
$filters['range_id'],
$filters['scan_job_id'],
$filters['status'],
200,
$filters['sort'],
$filters['dir']
);
ob_start();
require dirname(__DIR__, 2) . '/templates/discoveries/_table.php';
$body = ob_get_clean();
$response->getBody()->write($body);
return $response;
}
public function merge(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$data = $request->getParsedBody();
$hostId = isset($data['host_id']) ? (int)$data['host_id'] : 0;
$deviceId = isset($data['device_id']) ? (int)$data['device_id'] : 0;
if ($hostId > 0 && $deviceId > 0) {
$this->deviceService->mergeDiscoveredHost($hostId, $deviceId);
}
$referer = $request->getHeaderLine('Referer') ?: '/discoveries';
return $response
->withHeader('Location', $referer)
->withStatus(302);
}
/**
* @return array{range_id: ?int, scan_job_id: ?int, status: ?string, sort: string, dir: string}
*/
private function filters(ServerRequestInterface $request): array
{
$query = $request->getQueryParams();
$status = (string)($query['status'] ?? 'new');
$sort = (string)($query['sort'] ?? 'ip');
$dir = strtolower((string)($query['dir'] ?? 'asc')) === 'desc' ? 'desc' : 'asc';
return [
'range_id' => isset($query['range_id']) && $query['range_id'] !== '' ? (int)$query['range_id'] : null,
'scan_job_id' => isset($query['scan_job_id']) && $query['scan_job_id'] !== '' ? (int)$query['scan_job_id'] : null,
'status' => $status === 'all' ? null : $status,
'sort' => $sort,
'dir' => $dir,
];
}
}

View File

@ -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();

View File

@ -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);
} }
} }

View File

@ -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();

View File

@ -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;
}
} }

View File

@ -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');

View File

@ -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;
$count = 2 ** (24 - $mask);
$ips = [];
for ($i = 1; $i <= $count; $i++) {
$ips[] = $prefix . '.' . ($offset + $i);
}
return $ips;
} }
return []; $base = ip2long($baseIp);
if ($base === false) {
throw new \InvalidArgumentException("Invalid IPv4 address: {$baseIp}");
}
$base = (int)sprintf('%u', $base);
$hostCount = 2 ** (32 - $mask);
$network = $base & ((0xFFFFFFFF << (32 - $mask)) & 0xFFFFFFFF);
if ($mask === 32) {
return [long2ip($network)];
}
$start = $mask === 31 ? $network : $network + 1;
$end = $mask === 31 ? $network + 1 : $network + $hostCount - 2;
$ips = [];
for ($ip = $start; $ip <= $end; $ip++) {
$ips[] = long2ip($ip);
}
return $ips;
} }
private function resolveHostname(string $ip): ?string private function resolveHostname(string $ip): ?string

View File

@ -19,51 +19,19 @@ class TcpPortScanner
$openPorts = []; $openPorts = [];
$timeoutSec = $timeoutMs / 1000; $timeoutSec = $timeoutMs / 1000;
if (function_exists('curl_multi_init')) { foreach ($ports as $port) {
// Parallel scan with curl_multi $errno = 0;
$chunks = array_chunk($ports, $maxConcurrency); $errstr = '';
foreach ($chunks as $chunk) { $fp = @stream_socket_client(
$multiHandle = curl_multi_init(); "tcp://{$ip}:{$port}",
$handles = []; $errno,
foreach ($chunk as $port) { $errstr,
$ch = curl_init(); $timeoutSec,
curl_setopt($ch, CURLOPT_URL, "http://{$ip}:{$port}"); STREAM_CLIENT_CONNECT
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); );
curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, $timeoutMs); if ($fp !== false) {
curl_setopt($ch, CURLOPT_TIMEOUT_MS, $timeoutMs); $openPorts[] = $port;
curl_setopt($ch, CURLOPT_NOBODY, true); fclose($fp);
curl_multi_add_handle($multiHandle, $ch);
$handles[$port] = $ch;
}
// Execute
$running = null;
do {
curl_multi_exec($multiHandle, $running);
curl_multi_select($multiHandle, 0.1);
} while ($running > 0);
// Check results
foreach ($handles as $port => $ch) {
$err = curl_errno($ch);
// CURLE_COULDNT_CONNECT (7) = port closed, others might mean open or filtered
if ($err !== CURLE_COULDNT_CONNECT) {
$openPorts[] = $port;
}
curl_multi_remove_handle($multiHandle, $ch);
curl_close($ch);
}
curl_multi_close($multiHandle);
}
} else {
// Sequential fallback
foreach ($ports as $port) {
$fp = @fsockopen($ip, $port, $errno, $errstr, $timeoutSec);
if ($fp !== false) {
$openPorts[] = $port;
fclose($fp);
}
} }
} }

View File

@ -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);
}
} }

View File

@ -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;

View File

@ -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}");
} }
$job->status = 'done'; if ($finalStatus === 'done') {
$job->finishedAt = new \DateTimeImmutable(); $job->status = 'done';
$job->finishedAt = new \DateTimeImmutable();
} elseif ($finalStatus === 'paused') {
$job->status = 'paused';
$job->finishedAt = null;
} elseif ($finalStatus === 'cancelled') {
$job->status = 'cancelled';
$job->finishedAt = new \DateTimeImmutable();
}
} catch (\Throwable $e) { } 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 : [];
} }
} }

View File

@ -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);

View File

@ -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

8
phpunit.xml Normal file
View File

@ -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>

View File

@ -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;
}

View File

@ -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);
});
})();

View File

@ -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');

53
scripts/bootstrap.sh Executable file
View File

@ -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}"

View File

@ -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 ==="

19
scripts/test-bootstrap-docs.sh Executable file
View File

@ -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"

11
scripts/test-compose-config.sh Executable file
View File

@ -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"

16
scripts/test-worker-config.sh Executable file
View File

@ -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"

View File

@ -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; ?>

View File

@ -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'; ?>

View File

@ -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>

View File

@ -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>

View File

@ -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'; ?>

View File

@ -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>

View File

@ -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()
{
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}