diff --git a/.env.example b/.env.example index 61f0c77..3bffb1d 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,7 @@ APP_ENV=development APP_SECRET=change-this-to-random-string ENCRYPTION_KEY=change-this-to-32-byte-hex +DOMOVOY_HTTP_PORT=8080 DB_HOST=db DB_PORT=3306 diff --git a/.gitignore b/.gitignore index 41e3c84..03cfef5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ /vendor/ .env .phpunit.result.cache +/storage/logs/*.log +/storage/scans/* +/storage/reports/* diff --git a/PLAN.md b/PLAN.md index 26a5998..6c4f51f 100644 --- a/PLAN.md +++ b/PLAN.md @@ -137,6 +137,71 @@ Цель: добавить SSH-доступ к устройству, проверить подключение. +--- + +## Корректирующая итерация 3.1. Стабилизация Discovery и Inventory + +Цель: привести уже заявленные итерации 2-3 к рабочему состоянию перед +переходом к SSH-доступам и deep scan. Эта итерация обязательна, потому что +часть функциональности сейчас реализована как каркас, но не выполняет +пользовательский сценарий полностью. + +Что исправляем: +- Worker должен реально выполнять `network_discovery`, а не только менять + статус scan job на `done`. +- `scan_jobs.network_range_id` должен использоваться worker-ом для выбора + конкретного диапазона. +- `NetworkScanner` должен корректно перечислять IPv4 CIDR: + - `/32` — один IP; + - `/31` — оба адреса; + - `/30` и меньше — только usable host addresses без network/broadcast; + - диапазоны крупнее `/24` для MVP запрещаются или явно ограничиваются, + чтобы случайно не запустить огромный scan. +- `discovered_hosts.scan_job_id` должен заполняться найденными хостами. +- Повторное обнаружение того же IP/MAC не должно плодить неуправляемые + дубликаты без обновления `last_seen`. +- Добавление network range должно запрещать публичные IPv4-сети по умолчанию. + Разрешены только private/local ranges из ТЗ: + `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`, link-local, + loopback для локального smoke test. +- Discovery UI должен показывать ошибку scan job, если worker не смог + выполнить задачу. +- Merge suggestions должны быть либо доведены до рабочего UI, либо явно + исключены из статуса "готово" итерации 3. +- Ссылки в меню на ещё не реализованные разделы (`/services`, `/documents`, + `/settings`) не должны выглядеть как готовая функциональность. +- `scripts/check.sh` должен проверять не только открытие `/login`, но и + smoke path: миграции, создание пользователя, добавление range, создание + scan job, запуск worker-а хотя бы на `/32` loopback/private IP. + +Файлы (ожидаемо новые/изменённые): + app/Services/Jobs/ScanJobRunner.php + app/Services/Discovery/NetworkScanner.php + app/Controllers/NetworkRangeController.php + app/Repositories/DiscoveredHostRepository.php + app/Repositories/NetworkRangeRepository.php + app/Repositories/AuditLogRepository.php + templates/discovery/index.php + templates/layout.php + scripts/check.sh + tests/ + +Проверка: + vendor/bin/phpunit + composer validate --no-check-publish + find app public bin tests -name "*.php" -print0 | xargs -0 -n1 php -l + ./scripts/check.sh + +Критерий готовности: + # Добавить private range, например 127.0.0.1/32 или 192.168.1.1/32 + # Запустить scan через UI + docker compose exec app php bin/run-scan-worker.php + # scan_job получает done/failed с понятной причиной + # при успешном обнаружении discovered_hosts содержит scan_job_id + # публичный range вроде 8.8.8.8/32 не принимается без отдельного разрешения + +--- + Что делаем: - Миграция credentials - Credentials CRUD (только SSH для MVP) diff --git a/README.md b/README.md index 47563f3..675e786 100644 --- a/README.md +++ b/README.md @@ -28,18 +28,32 @@ Self-hosted система инвентаризации домашней и ма git clone cd domovoy cp .env.example .env -# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD -docker compose up -d --build -docker compose exec app php vendor/bin/phinx migrate -docker compose exec app php bin/setup first-admin -# Откройте http://localhost:8080 +# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD. +# Если 8080 занят, измените DOMOVOY_HTTP_PORT. +./scripts/bootstrap.sh +# Откройте http://localhost:${DOMOVOY_HTTP_PORT:-8080} ``` ## Запуск ```bash -docker compose up -d -# http://localhost:8080 +./scripts/bootstrap.sh +# Скрипт поднимает контейнеры, ждёт БД, применяет новые миграции +# и запускает scan worker. +``` + +Сетевое сканирование выполняется не web-запросом, а отдельным контейнером +`domovoy-worker`. Если scan job висит в `pending`, проверьте worker: + +```bash +docker compose ps worker +docker compose logs -f worker +``` + +Для первого пользователя откройте `/setup` в браузере или выполните: + +```bash +docker compose exec app php bin/console setup:user admin 'change-this-password' ``` ## Миграции @@ -50,6 +64,9 @@ docker compose exec app php vendor/bin/phinx rollback docker compose exec app php vendor/bin/phinx create MigrationName ``` +Обычный запуск через `./scripts/bootstrap.sh` уже выполняет `phinx migrate`. +Ручной запуск нужен только для обслуживания или отладки. + ## Smoke test ```bash diff --git a/app/Controllers/DiscoveriesController.php b/app/Controllers/DiscoveriesController.php new file mode 100644 index 0000000..ab116b4 --- /dev/null +++ b/app/Controllers/DiscoveriesController.php @@ -0,0 +1,96 @@ +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, + ]; + } +} diff --git a/app/Controllers/DiscoveryController.php b/app/Controllers/DiscoveryController.php index cee2c36..8fbc038 100644 --- a/app/Controllers/DiscoveryController.php +++ b/app/Controllers/DiscoveryController.php @@ -29,8 +29,8 @@ class DiscoveryController ob_start(); $username = $_SESSION['username'] ?? 'User'; $ranges = $this->networkRangeRepository->findAll(); + $activeJobs = $this->scanJobRepository->findActive(); $scanJobs = $this->scanJobRepository->findRecent(10); - $newHosts = $this->discoveredHostRepository->findByStatus('new', 20); require dirname(__DIR__, 2) . '/templates/discovery/index.php'; $body = ob_get_clean(); $response->getBody()->write($body); @@ -80,6 +80,53 @@ class DiscoveryController ->withStatus(302); } + public function activeJobs(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $activeJobs = $this->scanJobRepository->findActive(); + ob_start(); + require dirname(__DIR__, 2) . '/templates/discovery/_active_jobs.php'; + $body = ob_get_clean(); + $response->getBody()->write($body); + return $response; + } + + public function scanHistory(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $scanJobs = $this->scanJobRepository->findRecent(10); + ob_start(); + require dirname(__DIR__, 2) . '/templates/discovery/_scan_history.php'; + $body = ob_get_clean(); + $response->getBody()->write($body); + return $response; + } + + public function pauseJob(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface + { + $job = $this->scanJobRepository->findById((int)$args['id']); + if ($job !== null && $job->status === 'running') { + $this->scanJobRepository->updateStatus($job->id, 'paused'); + } + return $this->activeJobs($request, $response); + } + + public function resumeJob(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface + { + $job = $this->scanJobRepository->findById((int)$args['id']); + if ($job !== null && $job->status === 'paused') { + $this->scanJobRepository->updateStatus($job->id, 'pending'); + } + return $this->activeJobs($request, $response); + } + + public function cancelJob(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface + { + $job = $this->scanJobRepository->findById((int)$args['id']); + if ($job !== null && in_array($job->status, ['pending', 'running', 'paused'], true)) { + $this->scanJobRepository->updateStatus($job->id, 'cancelled'); + } + return $this->activeJobs($request, $response); + } + private function createScanJob(string $type, int $rangeId): void { $job = new \Domovoy\Models\ScanJob(); diff --git a/app/Controllers/NetworkRangeController.php b/app/Controllers/NetworkRangeController.php index f59da77..b0e8018 100644 --- a/app/Controllers/NetworkRangeController.php +++ b/app/Controllers/NetworkRangeController.php @@ -78,17 +78,44 @@ class NetworkRangeController private function isValidCidr(string $cidr): bool { - if (!str_contains($cidr, '/')) { + if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) { return false; } - [$ip, $mask] = explode('/', $cidr, 2); + $ip = $matches[1]; + $maskInt = (int)$matches[2]; if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { return false; } - $maskInt = (int)$mask; - return $maskInt >= 8 && $maskInt <= 32; + if ($maskInt < 8 || $maskInt > 32) { + return false; + } + + $long = ip2long($ip); + if ($long === false) { + return false; + } + + $value = (int)sprintf('%u', $long); + return $this->isInRange($value, '10.0.0.0', 8) + || $this->isInRange($value, '172.16.0.0', 12) + || $this->isInRange($value, '192.168.0.0', 16) + || $this->isInRange($value, '169.254.0.0', 16) + || $this->isInRange($value, '127.0.0.0', 8); + } + + private function isInRange(int $ip, string $network, int $mask): bool + { + $networkLong = ip2long($network); + if ($networkLong === false) { + return false; + } + + $networkValue = (int)sprintf('%u', $networkLong); + $maskValue = (0xFFFFFFFF << (32 - $mask)) & 0xFFFFFFFF; + + return ($ip & $maskValue) === ($networkValue & $maskValue); } } diff --git a/app/Repositories/DeviceRepository.php b/app/Repositories/DeviceRepository.php index 34a3043..f876e0e 100644 --- a/app/Repositories/DeviceRepository.php +++ b/app/Repositories/DeviceRepository.php @@ -52,6 +52,14 @@ class DeviceRepository return $row ? Device::fromArray($row) : null; } + public function findByIp(string $ipAddress): ?Device + { + $stmt = $this->pdo->prepare('SELECT * FROM devices WHERE primary_ip = :ip'); + $stmt->execute(['ip' => $ipAddress]); + $row = $stmt->fetch(); + return $row ? Device::fromArray($row) : null; + } + public function getCount(): int { return (int)$this->pdo->query('SELECT COUNT(*) FROM devices')->fetchColumn(); diff --git a/app/Repositories/DiscoveredHostRepository.php b/app/Repositories/DiscoveredHostRepository.php index 62efd5c..cfcbbbf 100644 --- a/app/Repositories/DiscoveredHostRepository.php +++ b/app/Repositories/DiscoveredHostRepository.php @@ -60,9 +60,94 @@ class DiscoveredHostRepository return $results; } + public function findFiltered( + ?int $rangeId = null, + ?int $scanJobId = null, + ?string $status = 'new', + int $limit = 100, + string $sortBy = 'ip', + string $sortDir = 'asc' + ): array { + $where = []; + $params = []; + + if ($rangeId !== null) { + $where[] = 'sj.network_range_id = :range_id'; + $params['range_id'] = $rangeId; + } + if ($scanJobId !== null) { + $where[] = 'dh.scan_job_id = :scan_job_id'; + $params['scan_job_id'] = $scanJobId; + } + if ($status !== null && $status !== '' && $status !== 'all') { + $where[] = 'dh.status = :status'; + $params['status'] = $status; + } + + $sql = 'SELECT dh.*, sj.network_range_id, nr.name AS range_name, nr.cidr AS range_cidr, + d.id AS device_id, d.name AS device_name, + mac_device.id AS suggested_mac_device_id, + mac_device.name AS suggested_mac_device_name, + hostname_device.id AS suggested_hostname_device_id, + hostname_device.name AS suggested_hostname_device_name, + ip_device.id AS suggested_ip_device_id, + ip_device.name AS suggested_ip_device_name + FROM discovered_hosts dh + LEFT JOIN scan_jobs sj ON sj.id = dh.scan_job_id + LEFT JOIN network_ranges nr ON nr.id = sj.network_range_id + LEFT JOIN devices d ON d.id = dh.matched_device_id + LEFT JOIN devices mac_device ON mac_device.mac_address = dh.mac_address + LEFT JOIN devices hostname_device ON hostname_device.name = dh.hostname + LEFT JOIN devices ip_device ON ip_device.primary_ip = dh.ip_address'; + + if (!empty($where)) { + $sql .= ' WHERE ' . implode(' AND ', $where); + } + + $sortColumn = $this->sortColumn($sortBy); + $direction = strtolower($sortDir) === 'desc' ? 'DESC' : 'ASC'; + $sql .= " ORDER BY {$sortColumn} {$direction} LIMIT :limit"; + + $stmt = $this->pdo->prepare($sql); + foreach ($params as $key => $value) { + $stmt->bindValue($key, $value, is_int($value) ? PDO::PARAM_INT : PDO::PARAM_STR); + } + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + + $results = []; + while ($row = $stmt->fetch()) { + $results[] = $row; + } + return $results; + } + + private function sortColumn(string $sortBy): string + { + return match ($sortBy) { + 'hostname' => 'dh.hostname', + 'mac' => 'dh.mac_address', + 'vendor' => 'dh.vendor', + 'ports' => 'dh.open_ports_json', + 'range' => 'nr.name', + 'status' => 'dh.status', + 'device' => 'd.name', + 'last_seen' => 'dh.last_seen', + default => 'INET_ATON(dh.ip_address)', + }; + } + public function save(DiscoveredHost $host): void { $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + if ($host->id === null) { + $existing = $this->findExistingNewHost($host); + if ($existing !== null) { + $host->id = $existing->id; + $host->firstSeen = $existing->firstSeen; + } + } + if ($host->id === null) { $stmt = $this->pdo->prepare( 'INSERT INTO discovered_hosts @@ -77,7 +162,7 @@ class DiscoveredHostRepository $stmt->execute([ 'scan_job_id' => $host->scanJobId, 'ip_address' => $host->ipAddress, - 'mac_address' => $host->macAddress, + 'mac_address' => $host->macAddress !== null ? strtolower($host->macAddress) : null, 'hostname' => $host->hostname, 'vendor' => $host->vendor, 'detected_os' => $host->detectedOs, @@ -96,12 +181,27 @@ class DiscoveredHostRepository } else { $stmt = $this->pdo->prepare( 'UPDATE discovered_hosts SET - status = :status, matched_device_id = :matched_device_id, - last_seen = :last_seen, updated_at = :updated_at + scan_job_id = :scan_job_id, ip_address = :ip_address, + mac_address = :mac_address, hostname = :hostname, vendor = :vendor, + detected_os = :detected_os, open_ports_json = :open_ports_json, + protocols_json = :protocols_json, fingerprint_json = :fingerprint_json, + confidence = :confidence, status = :status, + matched_device_id = :matched_device_id, last_seen = :last_seen, + updated_at = :updated_at WHERE id = :id' ); $stmt->execute([ 'id' => $host->id, + 'scan_job_id' => $host->scanJobId, + 'ip_address' => $host->ipAddress, + 'mac_address' => $host->macAddress !== null ? strtolower($host->macAddress) : null, + 'hostname' => $host->hostname, + 'vendor' => $host->vendor, + 'detected_os' => $host->detectedOs, + 'open_ports_json' => json_encode($host->openPorts), + 'protocols_json' => json_encode($host->protocols), + 'fingerprint_json' => json_encode($host->fingerprint), + 'confidence' => $host->confidence, 'status' => $host->status, 'matched_device_id' => $host->matchedDeviceId, 'last_seen' => $now, @@ -109,4 +209,36 @@ class DiscoveredHostRepository ]); } } + + private function findExistingNewHost(DiscoveredHost $host): ?DiscoveredHost + { + if ($host->macAddress !== null && trim($host->macAddress) !== '') { + $stmt = $this->pdo->prepare( + 'SELECT * FROM discovered_hosts + WHERE mac_address = :mac_address AND status = :status + ORDER BY last_seen DESC LIMIT 1' + ); + $stmt->execute([ + 'mac_address' => strtolower($host->macAddress), + 'status' => 'new', + ]); + $row = $stmt->fetch(); + if ($row) { + return DiscoveredHost::fromArray($row); + } + } + + $stmt = $this->pdo->prepare( + 'SELECT * FROM discovered_hosts + WHERE ip_address = :ip_address AND status = :status + ORDER BY last_seen DESC LIMIT 1' + ); + $stmt->execute([ + 'ip_address' => $host->ipAddress, + 'status' => 'new', + ]); + $row = $stmt->fetch(); + + return $row ? DiscoveredHost::fromArray($row) : null; + } } diff --git a/app/Repositories/ScanJobRepository.php b/app/Repositories/ScanJobRepository.php index 6aabf8c..98a0335 100644 --- a/app/Repositories/ScanJobRepository.php +++ b/app/Repositories/ScanJobRepository.php @@ -47,6 +47,19 @@ class ScanJobRepository return $results; } + public function findActive(): array + { + $statuses = ['pending', 'running', 'paused']; + $placeholders = implode(',', array_fill(0, count($statuses), '?')); + $stmt = $this->pdo->prepare("SELECT * FROM scan_jobs WHERE status IN ({$placeholders}) ORDER BY created_at ASC"); + $stmt->execute($statuses); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = ScanJob::fromArray($row); + } + return $results; + } + public function findRunning(): array { $stmt = $this->pdo->prepare('SELECT * FROM scan_jobs WHERE status = :status ORDER BY started_at ASC'); @@ -58,6 +71,22 @@ class ScanJobRepository return $results; } + public function updateStatus(int $id, string $status): void + { + $finishedAt = in_array($status, ['done', 'failed', 'cancelled'], true) + ? (new \DateTimeImmutable())->format('Y-m-d H:i:s') + : null; + + $stmt = $this->pdo->prepare( + 'UPDATE scan_jobs SET status = :status, finished_at = :finished_at WHERE id = :id' + ); + $stmt->execute([ + 'id' => $id, + 'status' => $status, + 'finished_at' => $finishedAt, + ]); + } + public function save(ScanJob $job): void { $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); diff --git a/app/Services/Discovery/NetworkScanner.php b/app/Services/Discovery/NetworkScanner.php index 416daed..a703eb8 100644 --- a/app/Services/Discovery/NetworkScanner.php +++ b/app/Services/Discovery/NetworkScanner.php @@ -36,18 +36,76 @@ class NetworkScanner /** * @return DiscoveredHost[] */ - public function scan(NetworkRange $range): array + public function scan(NetworkRange $range, ?int $scanJobId = null): array + { + $result = $this->scanResumable( + $range, + $scanJobId ?? 0, + 0, + static fn (): bool => false, + static function (array $state): void { + } + ); + + return $result['hosts']; + } + + /** + * @return array{hosts: DiscoveredHost[], completed: bool, next_ip_index: int, total_ips: int, scanned_count: int, hosts_found: int} + */ + public function scanResumable( + NetworkRange $range, + int $scanJobId, + int $startIndex, + callable $shouldStop, + callable $onProgress + ): array { $ips = $this->enumerateIps($range->cidr); $aliveHosts = []; + $scannedCount = 0; + $nextIndex = max(0, $startIndex); + $arpEntries = $this->arpTableReader->read(); + + $onProgress([ + 'next_ip_index' => $nextIndex, + 'total_ips' => count($ips), + 'scanned_count' => $scannedCount, + 'hosts_found' => count($aliveHosts), + ]); + + foreach ($ips as $index => $ip) { + if ($index < $nextIndex) { + continue; + } + + if ($shouldStop()) { + return [ + 'hosts' => $aliveHosts, + 'completed' => false, + 'next_ip_index' => $index, + 'total_ips' => count($ips), + 'scanned_count' => $scannedCount, + 'hosts_found' => count($aliveHosts), + ]; + } + + $nextIndex = $index + 1; + $scannedCount++; - foreach ($ips as $ip) { // Step 1: Ping sweep if (!$this->pingScanner->ping($ip)) { + $onProgress([ + 'next_ip_index' => $nextIndex, + 'total_ips' => count($ips), + 'scanned_count' => $scannedCount, + 'hosts_found' => count($aliveHosts), + ]); continue; } $host = new DiscoveredHost(); + $host->scanJobId = $scanJobId > 0 ? $scanJobId : null; $host->ipAddress = $ip; $host->firstSeen = new \DateTimeImmutable(); $host->lastSeen = new \DateTimeImmutable(); @@ -66,20 +124,30 @@ class NetworkScanner // Step 5: Confidence score $host->confidence = $this->fingerprintService->calculateConfidence(count($openPorts), $hostname !== null); - $aliveHosts[] = $host; - } - - // Step 6: ARP table enrichment - $arpEntries = $this->arpTableReader->read(); - foreach ($aliveHosts as $host) { if (isset($arpEntries[$host->ipAddress])) { $host->macAddress = $arpEntries[$host->ipAddress]['mac'] ?? null; $host->vendor = $host->vendor ?: ($arpEntries[$host->ipAddress]['vendor'] ?? null); } + $this->discoveredHostRepository->save($host); + + $aliveHosts[] = $host; + $onProgress([ + 'next_ip_index' => $nextIndex, + 'total_ips' => count($ips), + 'scanned_count' => $scannedCount, + 'hosts_found' => count($aliveHosts), + ]); } - return $aliveHosts; + return [ + 'hosts' => $aliveHosts, + 'completed' => true, + 'next_ip_index' => $nextIndex, + 'total_ips' => count($ips), + 'scanned_count' => $scannedCount, + 'hosts_found' => count($aliveHosts), + ]; } /** @@ -87,31 +155,41 @@ class NetworkScanner */ private function enumerateIps(string $cidr): array { - if (str_contains($cidr, '/32')) { - return [explode('/', $cidr)[0]]; + if (!preg_match('#^(\d+\.\d+\.\d+\.\d+)/(\d{1,2})$#', $cidr, $matches)) { + throw new \InvalidArgumentException("Invalid IPv4 CIDR: {$cidr}"); } - // Only handle /24 or larger for MVP (to avoid scanning huge ranges) - if (preg_match('#^(\d+\.\d+\.\d+)\.(\d+)/(\d+)$#', $cidr, $m)) { - $prefix = $m[1]; - $suffix = (int)$m[2]; - $mask = (int)$m[3]; - - if ($mask > 24) { - // Treat smaller than /24 as single IP - return [$cidr]; - } - - $offset = $mask === 24 ? 0 : $suffix; - $count = 2 ** (24 - $mask); - $ips = []; - for ($i = 1; $i <= $count; $i++) { - $ips[] = $prefix . '.' . ($offset + $i); - } - return $ips; + $baseIp = $matches[1]; + $mask = (int)$matches[2]; + if ($mask < 24) { + throw new \InvalidArgumentException('Network ranges larger than /24 are disabled for the MVP scanner'); + } + if ($mask > 32) { + throw new \InvalidArgumentException("Invalid IPv4 CIDR mask: {$cidr}"); } - return []; + $base = ip2long($baseIp); + if ($base === false) { + throw new \InvalidArgumentException("Invalid IPv4 address: {$baseIp}"); + } + + $base = (int)sprintf('%u', $base); + $hostCount = 2 ** (32 - $mask); + $network = $base & ((0xFFFFFFFF << (32 - $mask)) & 0xFFFFFFFF); + + if ($mask === 32) { + return [long2ip($network)]; + } + + $start = $mask === 31 ? $network : $network + 1; + $end = $mask === 31 ? $network + 1 : $network + $hostCount - 2; + + $ips = []; + for ($ip = $start; $ip <= $end; $ip++) { + $ips[] = long2ip($ip); + } + + return $ips; } private function resolveHostname(string $ip): ?string diff --git a/app/Services/Discovery/TcpPortScanner.php b/app/Services/Discovery/TcpPortScanner.php index 4297cb2..0c72105 100644 --- a/app/Services/Discovery/TcpPortScanner.php +++ b/app/Services/Discovery/TcpPortScanner.php @@ -19,51 +19,19 @@ class TcpPortScanner $openPorts = []; $timeoutSec = $timeoutMs / 1000; - if (function_exists('curl_multi_init')) { - // Parallel scan with curl_multi - $chunks = array_chunk($ports, $maxConcurrency); - foreach ($chunks as $chunk) { - $multiHandle = curl_multi_init(); - $handles = []; - foreach ($chunk as $port) { - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, "http://{$ip}:{$port}"); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT_MS, $timeoutMs); - curl_setopt($ch, CURLOPT_TIMEOUT_MS, $timeoutMs); - curl_setopt($ch, CURLOPT_NOBODY, true); - curl_multi_add_handle($multiHandle, $ch); - $handles[$port] = $ch; - } - - // Execute - $running = null; - do { - curl_multi_exec($multiHandle, $running); - curl_multi_select($multiHandle, 0.1); - } while ($running > 0); - - // Check results - foreach ($handles as $port => $ch) { - $err = curl_errno($ch); - // CURLE_COULDNT_CONNECT (7) = port closed, others might mean open or filtered - if ($err !== CURLE_COULDNT_CONNECT) { - $openPorts[] = $port; - } - curl_multi_remove_handle($multiHandle, $ch); - curl_close($ch); - } - - curl_multi_close($multiHandle); - } - } else { - // Sequential fallback - foreach ($ports as $port) { - $fp = @fsockopen($ip, $port, $errno, $errstr, $timeoutSec); - if ($fp !== false) { - $openPorts[] = $port; - fclose($fp); - } + foreach ($ports as $port) { + $errno = 0; + $errstr = ''; + $fp = @stream_socket_client( + "tcp://{$ip}:{$port}", + $errno, + $errstr, + $timeoutSec, + STREAM_CLIENT_CONNECT + ); + if ($fp !== false) { + $openPorts[] = $port; + fclose($fp); } } diff --git a/app/Services/Inventory/DeviceService.php b/app/Services/Inventory/DeviceService.php index 65ce06a..da8a579 100644 --- a/app/Services/Inventory/DeviceService.php +++ b/app/Services/Inventory/DeviceService.php @@ -6,14 +6,20 @@ namespace Domovoy\Services\Inventory; use Domovoy\Models\Device; use Domovoy\Repositories\DeviceRepository; +use Domovoy\Repositories\DiscoveredHostRepository; class DeviceService { private DeviceRepository $deviceRepository; + private ?DiscoveredHostRepository $discoveredHostRepository; - public function __construct(DeviceRepository $deviceRepository) + public function __construct( + DeviceRepository $deviceRepository, + ?DiscoveredHostRepository $discoveredHostRepository = null + ) { $this->deviceRepository = $deviceRepository; + $this->discoveredHostRepository = $discoveredHostRepository; } public function createFromDiscoveredHost( @@ -56,4 +62,31 @@ class DeviceService { $this->deviceRepository->delete($id); } + + public function mergeDiscoveredHost(int $hostId, int $deviceId): void + { + if ($this->discoveredHostRepository === null) { + throw new \RuntimeException('DiscoveredHostRepository is required for merge'); + } + + $host = $this->discoveredHostRepository->findById($hostId); + if ($host === null) { + throw new \RuntimeException('Discovered host not found'); + } + + $device = $this->deviceRepository->findById($deviceId); + if ($device === null) { + throw new \RuntimeException('Device not found'); + } + + $device->primaryIp = $device->primaryIp ?: $host->ipAddress; + $device->macAddress = $device->macAddress ?: $host->macAddress; + $device->hostname = $device->hostname ?: $host->hostname; + $device->vendor = $device->vendor ?: $host->vendor; + $this->deviceRepository->save($device); + + $host->status = 'merged'; + $host->matchedDeviceId = (string)$device->id; + $this->discoveredHostRepository->save($host); + } } diff --git a/app/Services/Inventory/MergeSuggestionService.php b/app/Services/Inventory/MergeSuggestionService.php index fba0ba5..b21491d 100644 --- a/app/Services/Inventory/MergeSuggestionService.php +++ b/app/Services/Inventory/MergeSuggestionService.php @@ -48,9 +48,15 @@ class MergeSuggestionService } } - // Low confidence: IP match - if ($host->primaryIp !== null) { - // Would need findByIp in repository — skip for now + if ($host->ipAddress !== '') { + $device = $this->deviceRepository->findByIp($host->ipAddress); + if ($device !== null) { + $suggestions[] = [ + 'device' => $device, + 'confidence' => 40, + 'reason' => 'IP адрес совпадает', + ]; + } } return $suggestions; diff --git a/app/Services/Jobs/ScanJobRunner.php b/app/Services/Jobs/ScanJobRunner.php index 5e3cd61..b9fa940 100644 --- a/app/Services/Jobs/ScanJobRunner.php +++ b/app/Services/Jobs/ScanJobRunner.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Domovoy\Services\Jobs; use Domovoy\Models\ScanJob; +use Domovoy\Repositories\NetworkRangeRepository; use Domovoy\Repositories\ScanJobRepository; use Domovoy\Services\Discovery\NetworkScanner; @@ -12,13 +13,16 @@ class ScanJobRunner { private ScanJobRepository $scanJobRepository; private NetworkScanner $networkScanner; + private NetworkRangeRepository $networkRangeRepository; public function __construct( ScanJobRepository $scanJobRepository, - NetworkScanner $networkScanner + NetworkScanner $networkScanner, + NetworkRangeRepository $networkRangeRepository ) { $this->scanJobRepository = $scanJobRepository; $this->networkScanner = $networkScanner; + $this->networkRangeRepository = $networkRangeRepository; } /** @@ -64,16 +68,25 @@ class ScanJobRunner $this->scanJobRepository->save($job); try { + $finalStatus = null; switch ($job->type) { case 'network_discovery': - $this->runNetworkDiscovery($job); + $finalStatus = $this->runNetworkDiscovery($job); break; default: throw new \RuntimeException("Unknown scan job type: {$job->type}"); } - $job->status = 'done'; - $job->finishedAt = new \DateTimeImmutable(); + if ($finalStatus === 'done') { + $job->status = 'done'; + $job->finishedAt = new \DateTimeImmutable(); + } elseif ($finalStatus === 'paused') { + $job->status = 'paused'; + $job->finishedAt = null; + } elseif ($finalStatus === 'cancelled') { + $job->status = 'cancelled'; + $job->finishedAt = new \DateTimeImmutable(); + } } catch (\Throwable $e) { $job->status = 'failed'; $job->errorMessage = $e->getMessage(); @@ -83,13 +96,77 @@ class ScanJobRunner $this->scanJobRepository->save($job); } - private function runNetworkDiscovery(ScanJob $job): void + private function runNetworkDiscovery(ScanJob $job): string { - // For MVP, we scan all enabled network ranges if no specific range set - // TODO: filter by specific range when network_range_id is set - $settings = require dirname(__DIR__, 3) . '/../phinx.php'; + if ($job->networkRangeId === null) { + throw new \RuntimeException('Network discovery job has no network_range_id'); + } - // Use the ranges from database via NetworkRangeRepository - // For now, this is handled by the controller that creates the job with a specific range + $range = $this->networkRangeRepository->findById($job->networkRangeId); + if ($range === null) { + throw new \RuntimeException("Network range #{$job->networkRangeId} not found"); + } + if (!$range->enabled) { + throw new \RuntimeException("Network range #{$job->networkRangeId} is disabled"); + } + + $existingProgress = $this->decodeProgress($job); + $startIndex = (int)($existingProgress['next_ip_index'] ?? 0); + $previousHostsFound = (int)($existingProgress['hosts_found'] ?? 0); + + $result = $this->networkScanner->scanResumable( + $range, + $job->id ?? 0, + $startIndex, + function () use ($job): bool { + $fresh = $this->scanJobRepository->findById((int)$job->id); + return $fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true); + }, + function (array $state) use ($job, $range, $previousHostsFound): void { + $fresh = $this->scanJobRepository->findById((int)$job->id); + if ($fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true)) { + $job->status = $fresh->status; + } + $job->resultJson = json_encode([ + 'network_range_id' => $range->id, + 'cidr' => $range->cidr, + 'next_ip_index' => $state['next_ip_index'], + 'total_ips' => $state['total_ips'], + 'scanned_count' => $state['scanned_count'], + 'hosts_found' => $previousHostsFound + $state['hosts_found'], + ], JSON_THROW_ON_ERROR); + $this->scanJobRepository->save($job); + } + ); + + $job->resultJson = json_encode([ + 'network_range_id' => $range->id, + 'cidr' => $range->cidr, + 'next_ip_index' => $result['next_ip_index'], + 'total_ips' => $result['total_ips'], + 'scanned_count' => $result['scanned_count'], + 'hosts_found' => $previousHostsFound + $result['hosts_found'], + ], JSON_THROW_ON_ERROR); + + if ($result['completed']) { + return 'done'; + } + + $fresh = $this->scanJobRepository->findById((int)$job->id); + if ($fresh !== null && in_array($fresh->status, ['paused', 'cancelled'], true)) { + return $fresh->status; + } + + return 'paused'; + } + + private function decodeProgress(ScanJob $job): array + { + if ($job->resultJson === null || $job->resultJson === '') { + return []; + } + + $decoded = json_decode($job->resultJson, true); + return is_array($decoded) ? $decoded : []; } } diff --git a/bin/run-scan-worker.php b/bin/run-scan-worker.php index bcda9bc..392f2f7 100644 --- a/bin/run-scan-worker.php +++ b/bin/run-scan-worker.php @@ -52,7 +52,7 @@ $networkScanner = new \Domovoy\Services\Discovery\NetworkScanner( $discoveredHostRepository ); -$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner); +$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner, $networkRangeRepository); $loopMode = in_array('--loop', $argv, true); diff --git a/docker-compose.yml b/docker-compose.yml index ed8c1a3..7f8b72d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,13 +25,36 @@ services: image: nginx:alpine container_name: domovoy-web ports: - - "8080:80" + - "${DOMOVOY_HTTP_PORT:-8080}:80" volumes: - .:/var/www/html - ./docker/web/default.conf:/etc/nginx/conf.d/default.conf depends_on: - app + worker: + build: + context: . + dockerfile: docker/app/Dockerfile + container_name: domovoy-worker + command: php bin/run-scan-worker.php --loop + volumes: + - .:/var/www/html + - ./storage:/var/www/html/storage + depends_on: + - db + - app + environment: + - APP_ENV=${APP_ENV:-development} + - APP_SECRET=${APP_SECRET} + - ENCRYPTION_KEY=${ENCRYPTION_KEY} + - DB_HOST=${DB_HOST:-db} + - DB_PORT=${DB_PORT:-3306} + - DB_DATABASE=${DB_DATABASE:-domovoy} + - DB_USERNAME=${DB_USERNAME:-domovoy} + - DB_PASSWORD=${DB_PASSWORD:-domovoy} + restart: unless-stopped + db: image: mariadb:11 container_name: domovoy-db diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..3e3c153 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,8 @@ + + + + + tests + + + diff --git a/public/assets/css/app.css b/public/assets/css/app.css index 4e73771..aa088ee 100644 --- a/public/assets/css/app.css +++ b/public/assets/css/app.css @@ -20,3 +20,18 @@ body { .navbar-brand { font-weight: bold; } + +#sidebar-wrapper .sidebar-heading, +#page-content-wrapper .navbar { + min-height: 56px; +} + +#sidebar-wrapper .sidebar-heading { + display: flex; + align-items: center; +} + +.ports-cell { + max-width: 360px; + white-space: normal; +} diff --git a/public/assets/js/domovoy.js b/public/assets/js/domovoy.js new file mode 100644 index 0000000..a8adf1e --- /dev/null +++ b/public/assets/js/domovoy.js @@ -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); + }); +})(); diff --git a/public/index.php b/public/index.php index e8a8a0f..70b433a 100644 --- a/public/index.php +++ b/public/index.php @@ -100,7 +100,8 @@ $containerBuilder->addDefinitions([ \Domovoy\Services\Jobs\ScanJobRunner::class => function ($c) { return new \Domovoy\Services\Jobs\ScanJobRunner( $c->get(\Domovoy\Repositories\ScanJobRepository::class), - $c->get(\Domovoy\Services\Discovery\NetworkScanner::class) + $c->get(\Domovoy\Services\Discovery\NetworkScanner::class), + $c->get(\Domovoy\Repositories\NetworkRangeRepository::class) ); }, // Auth @@ -137,7 +138,10 @@ $containerBuilder->addDefinitions([ return new \Domovoy\Repositories\DeviceRepository($c->get(PDO::class)); }, \Domovoy\Services\Inventory\DeviceService::class => function ($c) { - return new \Domovoy\Services\Inventory\DeviceService($c->get(\Domovoy\Repositories\DeviceRepository::class)); + return new \Domovoy\Services\Inventory\DeviceService( + $c->get(\Domovoy\Repositories\DeviceRepository::class), + $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class) + ); }, \Domovoy\Services\Inventory\MergeSuggestionService::class => function ($c) { return new \Domovoy\Services\Inventory\MergeSuggestionService($c->get(\Domovoy\Repositories\DeviceRepository::class)); @@ -148,6 +152,13 @@ $containerBuilder->addDefinitions([ $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class) ); }, + \Domovoy\Controllers\DiscoveriesController::class => function ($c) { + return new \Domovoy\Controllers\DiscoveriesController( + $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class), + $c->get(\Domovoy\Repositories\NetworkRangeRepository::class), + $c->get(\Domovoy\Services\Inventory\DeviceService::class) + ); + }, ]); $container = $containerBuilder->build(); @@ -175,6 +186,16 @@ $app->group('', function (\Slim\Routing\RouteCollectorProxy $group) { $group->get('/discovery', [\Domovoy\Controllers\DiscoveryController::class, 'index'])->setName('discovery'); $group->post('/discovery/scan', [\Domovoy\Controllers\DiscoveryController::class, 'startScan'])->setName('discovery.scan'); $group->post('/discovery/hosts/ignore', [\Domovoy\Controllers\DiscoveryController::class, 'ignoreHost'])->setName('discovery.hosts.ignore'); + $group->get('/discovery/jobs/active', [\Domovoy\Controllers\DiscoveryController::class, 'activeJobs'])->setName('discovery.jobs.active'); + $group->get('/discovery/jobs/history', [\Domovoy\Controllers\DiscoveryController::class, 'scanHistory'])->setName('discovery.jobs.history'); + $group->post('/discovery/jobs/{id}/pause', [\Domovoy\Controllers\DiscoveryController::class, 'pauseJob'])->setName('discovery.jobs.pause'); + $group->post('/discovery/jobs/{id}/resume', [\Domovoy\Controllers\DiscoveryController::class, 'resumeJob'])->setName('discovery.jobs.resume'); + $group->post('/discovery/jobs/{id}/cancel', [\Domovoy\Controllers\DiscoveryController::class, 'cancelJob'])->setName('discovery.jobs.cancel'); + + // Discoveries + $group->get('/discoveries', [\Domovoy\Controllers\DiscoveriesController::class, 'index'])->setName('discoveries'); + $group->get('/discoveries/table', [\Domovoy\Controllers\DiscoveriesController::class, 'table'])->setName('discoveries.table'); + $group->post('/discoveries/merge', [\Domovoy\Controllers\DiscoveriesController::class, 'merge'])->setName('discoveries.merge'); // Network ranges CRUD $group->post('/discovery/ranges/create', [\Domovoy\Controllers\NetworkRangeController::class, 'create'])->setName('discovery.ranges.create'); diff --git a/scripts/bootstrap.sh b/scripts/bootstrap.sh new file mode 100755 index 0000000..c4534de --- /dev/null +++ b/scripts/bootstrap.sh @@ -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}" diff --git a/scripts/check.sh b/scripts/check.sh index 0e7f627..d5e9b70 100755 --- a/scripts/check.sh +++ b/scripts/check.sh @@ -13,16 +13,19 @@ find app public bin -name "*.php" -print0 | xargs -0 -n1 php -l echo "=== docker compose config ===" docker compose config > /dev/null -echo "=== docker compose up ===" -docker compose up -d --build +echo "=== docker compose port config ===" +bash scripts/test-compose-config.sh -echo "=== wait for db ===" -sleep 5 +echo "=== docker compose worker config ===" +bash scripts/test-worker-config.sh -echo "=== phinx migrate ===" -docker compose exec app php vendor/bin/phinx migrate +echo "=== bootstrap docker stack ===" +bash scripts/bootstrap.sh + +http_port="$(grep -E '^DOMOVOY_HTTP_PORT=' .env 2>/dev/null | tail -n 1 | cut -d= -f2- || true)" +http_port="${http_port:-8080}" echo "=== HTTP health check ===" -curl -sI http://localhost:8080/login | head -1 +curl -sI "http://localhost:${http_port}/login" | head -1 echo "=== ALL CHECKS PASSED ===" diff --git a/scripts/test-bootstrap-docs.sh b/scripts/test-bootstrap-docs.sh new file mode 100755 index 0000000..55695f7 --- /dev/null +++ b/scripts/test-bootstrap-docs.sh @@ -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" diff --git a/scripts/test-compose-config.sh b/scripts/test-compose-config.sh new file mode 100755 index 0000000..7ad4b87 --- /dev/null +++ b/scripts/test-compose-config.sh @@ -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" diff --git a/scripts/test-worker-config.sh b/scripts/test-worker-config.sh new file mode 100755 index 0000000..66730c8 --- /dev/null +++ b/scripts/test-worker-config.sh @@ -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" diff --git a/templates/discoveries/_table.php b/templates/discoveries/_table.php new file mode 100644 index 0000000..3e6d057 --- /dev/null +++ b/templates/discoveries/_table.php @@ -0,0 +1,126 @@ + +
Находок по выбранным фильтрам нет.
+ + $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 '' . + htmlspecialchars($label . $mark) . ''; + }; + ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Действия
+ + - + + + 6): ?> + + + + + + + + + + - + + + + + + + + + + + Похоже на + (, %) + + + нет + + + + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+ +
+ diff --git a/templates/discoveries/index.php b/templates/discoveries/index.php new file mode 100644 index 0000000..ca80a9d --- /dev/null +++ b/templates/discoveries/index.php @@ -0,0 +1,60 @@ + +
+

Находки

+
+ +
+
+ + +
+
+ + +
+ + +
+ + +
+ + + +
+ +
+
+ +
+ +
+ + diff --git a/templates/discovery/_active_jobs.php b/templates/discovery/_active_jobs.php new file mode 100644 index 0000000..15c4d38 --- /dev/null +++ b/templates/discovery/_active_jobs.php @@ -0,0 +1,83 @@ +
+
Активные сканирования
+
+ +

Нет активных сканирований.

+ + + + + + + + + + + + + + + resultJson ?? '{}', true) ?: []; ?> + 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', + }; + ?> + + + + + + + + + + +
IDТипСтатусПрогрессНайденоДействия
#id ?>type) ?>status) ?> +
+
+
+ / IP +
+ + Находки + + status === 'running'): ?> + + + status === 'paused'): ?> + + + status, ['pending', 'running', 'paused'], true)): ?> + + +
+ +
+
diff --git a/templates/discovery/_scan_history.php b/templates/discovery/_scan_history.php new file mode 100644 index 0000000..dd3ca9c --- /dev/null +++ b/templates/discovery/_scan_history.php @@ -0,0 +1,59 @@ +
+
История сканирования
+
+ +

Нет запусков сканирования.

+ + + + + + + + + + + + + + + + + + + + + + + + +
ТипСтатусРезультатЗапущенЗавершёнДействия
type) ?> + status) { + 'pending' => 'bg-warning', + 'running' => 'bg-info', + 'done' => 'bg-success', + 'failed' => 'bg-danger', + 'paused' => 'bg-secondary', + 'cancelled' => 'bg-dark', + default => 'bg-secondary', + }; + ?> + status) ?> + + errorMessage !== null): ?> + errorMessage) ?> + resultJson !== null): ?> + resultJson, true) ?: []; ?> + + + - + + startedAt?->format('Y-m-d H:i:s') ?? '-' ?>finishedAt?->format('Y-m-d H:i:s') ?? '-' ?> + + Находки + +
+ +
+
diff --git a/templates/discovery/index.php b/templates/discovery/index.php index 0759a18..8a44fe5 100644 --- a/templates/discovery/index.php +++ b/templates/discovery/index.php @@ -48,6 +48,9 @@ + + Находки +