From 177e44f0150ceb7df72b3540b8080942a0e9892d Mon Sep 17 00:00:00 2001 From: mirivlad Date: Tue, 26 May 2026 07:41:23 +0800 Subject: [PATCH] =?UTF-8?q?=D0=98=D1=82=D0=B5=D1=80=D0=B0=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=202:=20=D1=81=D0=BA=D0=B0=D0=BD=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D0=BD=D0=B8=D0=B5=20=D1=81=D0=B5=D1=82=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phinx миграции: network_ranges, scan_jobs, discovered_hosts, audit_log - Models: NetworkRange, ScanJob, DiscoveredHost - Repositories с CRUD для всех сущностей - NetworkScanner: ping sweep + TCP connect + ARP table + reverse DNS - PingScanner, TcpPortScanner (curl_multi для параллелизма), ArpTableReader - HostFingerprintService: vendor detection + confidence scoring - ScanJobRunner + CLI worker (bin/run-scan-worker.php) - Discovery UI: управление диапазонами, запуск скана, таблица находок - NetworkRangeController с валидацией CIDR - Обновлён public/index.php с DI конфигурацией для новых сервисов --- app/Controllers/DiscoveryController.php | 92 ++++++++++ app/Controllers/NetworkRangeController.php | 94 ++++++++++ app/Models/DiscoveredHost.php | 51 ++++++ app/Models/NetworkRange.php | 27 +++ app/Models/ScanJob.php | 37 ++++ app/Repositories/AuditLogRepository.php | 41 +++++ app/Repositories/DiscoveredHostRepository.php | 112 ++++++++++++ app/Repositories/NetworkRangeRepository.php | 83 +++++++++ app/Repositories/ScanJobRepository.php | 98 +++++++++++ app/Services/Discovery/ArpTableReader.php | 43 +++++ .../Discovery/HostFingerprintService.php | 61 +++++++ app/Services/Discovery/NetworkScanner.php | 122 +++++++++++++ app/Services/Discovery/PingScanner.php | 22 +++ app/Services/Discovery/TcpPortScanner.php | 73 ++++++++ app/Services/Jobs/ScanJobRunner.php | 95 +++++++++++ bin/run-scan-worker.php | 92 ++++++++++ .../20250526000002_create_network_ranges.php | 21 +++ .../20250526000003_create_scan_jobs.php | 27 +++ ...20250526000004_create_discovered_hosts.php | 34 ++++ .../20250526000005_create_audit_log.php | 24 +++ public/index.php | 69 +++++++- templates/discovery/index.php | 160 ++++++++++++++++++ 22 files changed, 1477 insertions(+), 1 deletion(-) create mode 100644 app/Controllers/DiscoveryController.php create mode 100644 app/Controllers/NetworkRangeController.php create mode 100644 app/Models/DiscoveredHost.php create mode 100644 app/Models/NetworkRange.php create mode 100644 app/Models/ScanJob.php create mode 100644 app/Repositories/AuditLogRepository.php create mode 100644 app/Repositories/DiscoveredHostRepository.php create mode 100644 app/Repositories/NetworkRangeRepository.php create mode 100644 app/Repositories/ScanJobRepository.php create mode 100644 app/Services/Discovery/ArpTableReader.php create mode 100644 app/Services/Discovery/HostFingerprintService.php create mode 100644 app/Services/Discovery/NetworkScanner.php create mode 100644 app/Services/Discovery/PingScanner.php create mode 100644 app/Services/Discovery/TcpPortScanner.php create mode 100644 app/Services/Jobs/ScanJobRunner.php create mode 100644 bin/run-scan-worker.php create mode 100644 db/migrations/20250526000002_create_network_ranges.php create mode 100644 db/migrations/20250526000003_create_scan_jobs.php create mode 100644 db/migrations/20250526000004_create_discovered_hosts.php create mode 100644 db/migrations/20250526000005_create_audit_log.php create mode 100644 templates/discovery/index.php diff --git a/app/Controllers/DiscoveryController.php b/app/Controllers/DiscoveryController.php new file mode 100644 index 0000000..cee2c36 --- /dev/null +++ b/app/Controllers/DiscoveryController.php @@ -0,0 +1,92 @@ +networkRangeRepository = $networkRangeRepository; + $this->scanJobRepository = $scanJobRepository; + $this->discoveredHostRepository = $discoveredHostRepository; + } + + public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + ob_start(); + $username = $_SESSION['username'] ?? 'User'; + $ranges = $this->networkRangeRepository->findAll(); + $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); + return $response; + } + + public function startScan(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $data = $request->getParsedBody(); + $rangeId = $data['range_id'] ?? null; + + // Create scan job(s) + if ($rangeId !== null) { + $range = $this->networkRangeRepository->findById((int)$rangeId); + if ($range === null || !$range->enabled) { + return $response->withStatus(404)->write('Range not found'); + } + $this->createScanJob('network_discovery', $range->id); + } else { + // Scan all enabled ranges + $ranges = $this->networkRangeRepository->findEnabled(); + foreach ($ranges as $range) { + $this->createScanJob('network_discovery', $range->id); + } + } + + return $response + ->withHeader('Location', '/discovery') + ->withStatus(302); + } + + public function ignoreHost(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $data = $request->getParsedBody(); + $hostId = $data['host_id'] ?? null; + + if ($hostId !== null) { + $host = $this->discoveredHostRepository->findById((int)$hostId); + if ($host !== null) { + $host->status = 'ignored'; + $this->discoveredHostRepository->save($host); + } + } + + return $response + ->withHeader('Location', '/discovery') + ->withStatus(302); + } + + private function createScanJob(string $type, int $rangeId): void + { + $job = new \Domovoy\Models\ScanJob(); + $job->type = $type; + $job->status = 'pending'; + $job->networkRangeId = $rangeId; + $job->createdBy = $_SESSION['user_id'] ?? null; + $this->scanJobRepository->save($job); + } +} diff --git a/app/Controllers/NetworkRangeController.php b/app/Controllers/NetworkRangeController.php new file mode 100644 index 0000000..f59da77 --- /dev/null +++ b/app/Controllers/NetworkRangeController.php @@ -0,0 +1,94 @@ +repository = $repository; + } + + public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $data = $request->getParsedBody(); + $name = trim($data['name'] ?? ''); + $cidr = trim($data['cidr'] ?? ''); + + if ($name === '' || $cidr === '') { + $response->getBody()->write('Name and CIDR are required'); + return $response->withStatus(400); + } + + // Basic CIDR validation + if (!$this->isValidCidr($cidr)) { + $response->getBody()->write('Invalid CIDR format'); + return $response->withStatus(400); + } + + $range = new \Domovoy\Models\NetworkRange(); + $range->name = $name; + $range->cidr = $cidr; + $range->enabled = true; + $this->repository->save($range); + + return $response + ->withHeader('Location', '/discovery') + ->withStatus(302); + } + + public function toggle(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $data = $request->getParsedBody(); + $id = $data['id'] ?? null; + + if ($id !== null) { + $range = $this->repository->findById((int)$id); + if ($range !== null) { + $range->enabled = !$range->enabled; + $this->repository->save($range); + } + } + + return $response + ->withHeader('Location', '/discovery') + ->withStatus(302); + } + + public function delete(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface + { + $data = $request->getParsedBody(); + $id = $data['id'] ?? null; + + if ($id !== null) { + $this->repository->delete((int)$id); + } + + return $response + ->withHeader('Location', '/discovery') + ->withStatus(302); + } + + private function isValidCidr(string $cidr): bool + { + if (!str_contains($cidr, '/')) { + return false; + } + + [$ip, $mask] = explode('/', $cidr, 2); + + if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + return false; + } + + $maskInt = (int)$mask; + return $maskInt >= 8 && $maskInt <= 32; + } +} diff --git a/app/Models/DiscoveredHost.php b/app/Models/DiscoveredHost.php new file mode 100644 index 0000000..cbfc698 --- /dev/null +++ b/app/Models/DiscoveredHost.php @@ -0,0 +1,51 @@ +id = (int)$data['id']; + $obj->scanJobId = $data['scan_job_id'] !== null ? (int)$data['scan_job_id'] : null; + $obj->ipAddress = $data['ip_address']; + $obj->macAddress = $data['mac_address'] ?? null; + $obj->hostname = $data['hostname'] ?? null; + $obj->vendor = $data['vendor'] ?? null; + $obj->detectedOs = $data['detected_os'] ?? null; + $obj->openPorts = json_decode($data['open_ports_json'] ?? '[]', true); + $obj->protocols = json_decode($data['protocols_json'] ?? '[]', true); + $obj->fingerprint = json_decode($data['fingerprint_json'] ?? '{}', true); + $obj->confidence = (int)$data['confidence']; + $obj->status = $data['status']; + $obj->matchedDeviceId = $data['matched_device_id'] ?? null; + $obj->firstSeen = new \DateTimeImmutable($data['first_seen']); + $obj->lastSeen = new \DateTimeImmutable($data['last_seen']); + $obj->createdAt = new \DateTimeImmutable($data['created_at']); + $obj->updatedAt = new \DateTimeImmutable($data['updated_at']); + return $obj; + } +} diff --git a/app/Models/NetworkRange.php b/app/Models/NetworkRange.php new file mode 100644 index 0000000..3bc79a3 --- /dev/null +++ b/app/Models/NetworkRange.php @@ -0,0 +1,27 @@ +id = (int)$data['id']; + $obj->name = $data['name']; + $obj->cidr = $data['cidr']; + $obj->enabled = (bool)$data['enabled']; + $obj->createdAt = new \DateTimeImmutable($data['created_at']); + $obj->updatedAt = new \DateTimeImmutable($data['updated_at']); + return $obj; + } +} diff --git a/app/Models/ScanJob.php b/app/Models/ScanJob.php new file mode 100644 index 0000000..52533fc --- /dev/null +++ b/app/Models/ScanJob.php @@ -0,0 +1,37 @@ +id = (int)$data['id']; + $obj->type = $data['type']; + $obj->status = $data['status']; + $obj->networkRangeId = $data['network_range_id'] !== null ? (int)$data['network_range_id'] : null; + $obj->deviceId = $data['device_id'] ?? null; + $obj->startedAt = $data['started_at'] !== null ? new \DateTimeImmutable($data['started_at']) : null; + $obj->finishedAt = $data['finished_at'] !== null ? new \DateTimeImmutable($data['finished_at']) : null; + $obj->errorMessage = $data['error_message'] ?? null; + $obj->resultJson = $data['result_json'] ?? null; + $obj->createdBy = $data['created_by'] ?? null; + $obj->createdAt = new \DateTimeImmutable($data['created_at']); + return $obj; + } +} diff --git a/app/Repositories/AuditLogRepository.php b/app/Repositories/AuditLogRepository.php new file mode 100644 index 0000000..7801a30 --- /dev/null +++ b/app/Repositories/AuditLogRepository.php @@ -0,0 +1,41 @@ +pdo = $pdo; + } + + public function log(string $action, ?string $entityType = null, ?string $entityId = null, ?array $details = null, ?string $userId = null): void + { + $stmt = $this->pdo->prepare( + 'INSERT INTO audit_log (user_id, action, entity_type, entity_id, details_json, created_at) + VALUES (:user_id, :action, :entity_type, :entity_id, :details_json, :created_at)' + ); + $stmt->execute([ + 'user_id' => $userId, + 'action' => $action, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'details_json' => $details !== null ? json_encode($details) : null, + 'created_at' => (new \DateTimeImmutable())->format('Y-m-d H:i:s'), + ]); + } + + public function findRecent(int $limit = 20): array + { + $stmt = $this->pdo->prepare('SELECT * FROM audit_log ORDER BY created_at DESC LIMIT :limit'); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + return $stmt->fetchAll(); + } +} diff --git a/app/Repositories/DiscoveredHostRepository.php b/app/Repositories/DiscoveredHostRepository.php new file mode 100644 index 0000000..62efd5c --- /dev/null +++ b/app/Repositories/DiscoveredHostRepository.php @@ -0,0 +1,112 @@ +pdo = $pdo; + } + + public function findById(int $id): ?DiscoveredHost + { + $stmt = $this->pdo->prepare('SELECT * FROM discovered_hosts WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ? DiscoveredHost::fromArray($row) : null; + } + + public function findByStatus(string $status, int $limit = 100): array + { + $stmt = $this->pdo->prepare('SELECT * FROM discovered_hosts WHERE status = :status ORDER BY last_seen DESC LIMIT :limit'); + $stmt->bindValue('status', $status); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = DiscoveredHost::fromArray($row); + } + return $results; + } + + public function findByScanJob(int $scanJobId): array + { + $stmt = $this->pdo->prepare('SELECT * FROM discovered_hosts WHERE scan_job_id = :scan_job_id ORDER BY ip_address ASC'); + $stmt->execute(['scan_job_id' => $scanJobId]); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = DiscoveredHost::fromArray($row); + } + return $results; + } + + public function findAll(int $limit = 100): array + { + $stmt = $this->pdo->prepare('SELECT * FROM discovered_hosts ORDER BY last_seen DESC LIMIT :limit'); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = DiscoveredHost::fromArray($row); + } + return $results; + } + + public function save(DiscoveredHost $host): void + { + $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + if ($host->id === null) { + $stmt = $this->pdo->prepare( + 'INSERT INTO discovered_hosts + (scan_job_id, ip_address, mac_address, hostname, vendor, detected_os, + open_ports_json, protocols_json, fingerprint_json, confidence, status, + matched_device_id, first_seen, last_seen, created_at, updated_at) + VALUES + (:scan_job_id, :ip_address, :mac_address, :hostname, :vendor, :detected_os, + :open_ports_json, :protocols_json, :fingerprint_json, :confidence, :status, + :matched_device_id, :first_seen, :last_seen, :created_at, :updated_at)' + ); + $stmt->execute([ + 'scan_job_id' => $host->scanJobId, + 'ip_address' => $host->ipAddress, + 'mac_address' => $host->macAddress, + '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, + 'first_seen' => $now, + 'last_seen' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $host->id = (int)$this->pdo->lastInsertId(); + } 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 + WHERE id = :id' + ); + $stmt->execute([ + 'id' => $host->id, + 'status' => $host->status, + 'matched_device_id' => $host->matchedDeviceId, + 'last_seen' => $now, + 'updated_at' => $now, + ]); + } + } +} diff --git a/app/Repositories/NetworkRangeRepository.php b/app/Repositories/NetworkRangeRepository.php new file mode 100644 index 0000000..e94c72c --- /dev/null +++ b/app/Repositories/NetworkRangeRepository.php @@ -0,0 +1,83 @@ +pdo = $pdo; + } + + public function findById(int $id): ?NetworkRange + { + $stmt = $this->pdo->prepare('SELECT * FROM network_ranges WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ? NetworkRange::fromArray($row) : null; + } + + public function findAll(): array + { + $stmt = $this->pdo->query('SELECT * FROM network_ranges ORDER BY name ASC'); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = NetworkRange::fromArray($row); + } + return $results; + } + + public function findEnabled(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM network_ranges WHERE enabled = 1 ORDER BY name ASC'); + $stmt->execute(); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = NetworkRange::fromArray($row); + } + return $results; + } + + public function save(NetworkRange $range): void + { + $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + if ($range->id === null) { + $stmt = $this->pdo->prepare( + 'INSERT INTO network_ranges (name, cidr, enabled, created_at, updated_at) + VALUES (:name, :cidr, :enabled, :created_at, :updated_at)' + ); + $stmt->execute([ + 'name' => $range->name, + 'cidr' => $range->cidr, + 'enabled' => $range->enabled ? 1 : 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $range->id = (int)$this->pdo->lastInsertId(); + } else { + $stmt = $this->pdo->prepare( + 'UPDATE network_ranges SET name = :name, cidr = :cidr, enabled = :enabled, updated_at = :updated_at WHERE id = :id' + ); + $stmt->execute([ + 'id' => $range->id, + 'name' => $range->name, + 'cidr' => $range->cidr, + 'enabled' => $range->enabled ? 1 : 0, + 'updated_at' => $now, + ]); + } + } + + public function delete(int $id): void + { + $stmt = $this->pdo->prepare('DELETE FROM network_ranges WHERE id = :id'); + $stmt->execute(['id' => $id]); + } +} diff --git a/app/Repositories/ScanJobRepository.php b/app/Repositories/ScanJobRepository.php new file mode 100644 index 0000000..6aabf8c --- /dev/null +++ b/app/Repositories/ScanJobRepository.php @@ -0,0 +1,98 @@ +pdo = $pdo; + } + + public function findById(int $id): ?ScanJob + { + $stmt = $this->pdo->prepare('SELECT * FROM scan_jobs WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ? ScanJob::fromArray($row) : null; + } + + public function findPending(): array + { + $stmt = $this->pdo->prepare('SELECT * FROM scan_jobs WHERE status = :status ORDER BY created_at ASC'); + $stmt->execute(['status' => 'pending']); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = ScanJob::fromArray($row); + } + return $results; + } + + public function findRecent(int $limit = 10): array + { + $stmt = $this->pdo->prepare('SELECT * FROM scan_jobs ORDER BY created_at DESC LIMIT :limit'); + $stmt->bindValue('limit', $limit, PDO::PARAM_INT); + $stmt->execute(); + $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'); + $stmt->execute(['status' => 'running']); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = ScanJob::fromArray($row); + } + return $results; + } + + public function save(ScanJob $job): void + { + $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + if ($job->id === null) { + $stmt = $this->pdo->prepare( + 'INSERT INTO scan_jobs (type, status, network_range_id, device_id, created_by, created_at) + VALUES (:type, :status, :network_range_id, :device_id, :created_by, :created_at)' + ); + $stmt->execute([ + 'type' => $job->type, + 'status' => $job->status, + 'network_range_id' => $job->networkRangeId, + 'device_id' => $job->deviceId, + 'created_by' => $job->createdBy, + 'created_at' => $now, + ]); + $job->id = (int)$this->pdo->lastInsertId(); + } else { + $stmt = $this->pdo->prepare( + 'UPDATE scan_jobs SET type = :type, status = :status, network_range_id = :network_range_id, + device_id = :device_id, started_at = :started_at, finished_at = :finished_at, + error_message = :error_message, result_json = :result_json + WHERE id = :id' + ); + $stmt->execute([ + 'id' => $job->id, + 'type' => $job->type, + 'status' => $job->status, + 'network_range_id' => $job->networkRangeId, + 'device_id' => $job->deviceId, + 'started_at' => $job->startedAt?->format('Y-m-d H:i:s'), + 'finished_at' => $job->finishedAt?->format('Y-m-d H:i:s'), + 'error_message' => $job->errorMessage, + 'result_json' => $job->resultJson, + ]); + } + } +} diff --git a/app/Services/Discovery/ArpTableReader.php b/app/Services/Discovery/ArpTableReader.php new file mode 100644 index 0000000..2bf0cfa --- /dev/null +++ b/app/Services/Discovery/ArpTableReader.php @@ -0,0 +1,43 @@ + ['mac' => '...', 'vendor' => '...'] + */ + public function read(): array + { + $entries = []; + + // Try ip neigh first (modern Linux) + $output = []; + $exitCode = 0; + exec('ip neigh show 2>/dev/null', $output, $exitCode); + if ($exitCode === 0) { + foreach ($output as $line) { + if (preg_match('/^(\d+\.\d+\.\d+\.\d+)\s+.*lladdr\s+([0-9a-f:]+)/i', $line, $m)) { + $entries[$m[1]] = ['mac' => strtolower($m[2]), 'vendor' => null]; + } + } + } + + // Fallback: arp -a + if (empty($entries)) { + exec('arp -a 2>/dev/null', $output, $exitCode); + if ($exitCode === 0) { + foreach ($output as $line) { + if (preg_match('/\((\d+\.\d+\.\d+\.\d+)\)\s+at\s+([0-9a-f:]+)/i', $line, $m)) { + $entries[$m[1]] = ['mac' => strtolower($m[2]), 'vendor' => null]; + } + } + } + } + + return $entries; + } +} diff --git a/app/Services/Discovery/HostFingerprintService.php b/app/Services/Discovery/HostFingerprintService.php new file mode 100644 index 0000000..8cf7458 --- /dev/null +++ b/app/Services/Discovery/HostFingerprintService.php @@ -0,0 +1,61 @@ + 'HP/Printer', + 631 => 'CUPS/Apple', + 548 => 'Apple/AFP', + 445 => 'Microsoft/SMB', + 22 => null, // Too common + ]; + + foreach ($portVendorMap as $port => $vendor) { + if ($vendor !== null && in_array($port, $openPorts, true)) { + return $vendor; + } + } + + if ($hostname !== null) { + $hostname = strtolower($hostname); + if (str_contains($hostname, 'apple') || str_contains($hostname, 'macbook') || str_contains($hostname, 'iphone')) { + return 'Apple'; + } + if (str_contains($hostname, 'synology') || str_contains($hostname, 'nas')) { + return 'Synology'; + } + if (str_contains($hostname, 'router') || str_contains($hostname, 'gateway')) { + return 'Router/Network'; + } + } + + return null; + } + + /** + * Calculate confidence score 0-100 based on found data. + */ + public function calculateConfidence(int $openPortCount, bool $hasHostname): int + { + $score = 10; // Base: host is alive + + if ($openPortCount > 0) { + $score += min(40, $openPortCount * 5); + } + + if ($hasHostname) { + $score += 30; + } + + return min(100, $score); + } +} diff --git a/app/Services/Discovery/NetworkScanner.php b/app/Services/Discovery/NetworkScanner.php new file mode 100644 index 0000000..416daed --- /dev/null +++ b/app/Services/Discovery/NetworkScanner.php @@ -0,0 +1,122 @@ +pingScanner = $pingScanner; + $this->tcpPortScanner = $tcpPortScanner; + $this->arpTableReader = $arpTableReader; + $this->fingerprintService = $fingerprintService; + $this->discoveredHostRepository = $discoveredHostRepository; + } + + /** + * @return DiscoveredHost[] + */ + public function scan(NetworkRange $range): array + { + $ips = $this->enumerateIps($range->cidr); + $aliveHosts = []; + + foreach ($ips as $ip) { + // Step 1: Ping sweep + if (!$this->pingScanner->ping($ip)) { + continue; + } + + $host = new DiscoveredHost(); + $host->ipAddress = $ip; + $host->firstSeen = new \DateTimeImmutable(); + $host->lastSeen = new \DateTimeImmutable(); + + // Step 2: TCP port scan + $openPorts = $this->tcpPortScanner->scan($ip, $this->defaultPorts, 200, 50); + $host->openPorts = $openPorts; + + // Step 3: Reverse DNS + $hostname = $this->resolveHostname($ip); + $host->hostname = $hostname; + + // Step 4: Determine vendor by port patterns + $host->vendor = $this->fingerprintService->guessVendor($openPorts, $hostname); + + // 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); + } + + return $aliveHosts; + } + + /** + * @return string[] + */ + private function enumerateIps(string $cidr): array + { + if (str_contains($cidr, '/32')) { + return [explode('/', $cidr)[0]]; + } + + // 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; + } + + return []; + } + + private function resolveHostname(string $ip): ?string + { + $hostname = @gethostbyaddr($ip); + return ($hostname !== false && $hostname !== $ip) ? $hostname : null; + } +} diff --git a/app/Services/Discovery/PingScanner.php b/app/Services/Discovery/PingScanner.php new file mode 100644 index 0000000..d145b9b --- /dev/null +++ b/app/Services/Discovery/PingScanner.php @@ -0,0 +1,22 @@ +/dev/null', $timeoutSec, escapeshellarg($ip)), $output, $exitCode); + return $exitCode === 0; + } +} diff --git a/app/Services/Discovery/TcpPortScanner.php b/app/Services/Discovery/TcpPortScanner.php new file mode 100644 index 0000000..4297cb2 --- /dev/null +++ b/app/Services/Discovery/TcpPortScanner.php @@ -0,0 +1,73 @@ + 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); + } + } + } + + sort($openPorts); + return $openPorts; + } +} diff --git a/app/Services/Jobs/ScanJobRunner.php b/app/Services/Jobs/ScanJobRunner.php new file mode 100644 index 0000000..5e3cd61 --- /dev/null +++ b/app/Services/Jobs/ScanJobRunner.php @@ -0,0 +1,95 @@ +scanJobRepository = $scanJobRepository; + $this->networkScanner = $networkScanner; + } + + /** + * Run a single pending scan job. + */ + public function runNext(): ?ScanJob + { + $pending = $this->scanJobRepository->findPending(); + if (empty($pending)) { + return null; + } + + $job = $pending[0]; + $this->execute($job); + return $job; + } + + /** + * Run all pending scan jobs in a loop. + * + * @param callable|null $onProgress Called after each job: fn(ScanJob $job) + */ + public function runAll(?callable $onProgress = null): int + { + $count = 0; + while (true) { + $job = $this->runNext(); + if ($job === null) { + break; + } + $count++; + if ($onProgress !== null) { + $onProgress($job); + } + } + return $count; + } + + public function execute(ScanJob $job): void + { + $job->status = 'running'; + $job->startedAt = new \DateTimeImmutable(); + $this->scanJobRepository->save($job); + + try { + switch ($job->type) { + case 'network_discovery': + $this->runNetworkDiscovery($job); + break; + default: + throw new \RuntimeException("Unknown scan job type: {$job->type}"); + } + + $job->status = 'done'; + $job->finishedAt = new \DateTimeImmutable(); + } catch (\Throwable $e) { + $job->status = 'failed'; + $job->errorMessage = $e->getMessage(); + $job->finishedAt = new \DateTimeImmutable(); + } + + $this->scanJobRepository->save($job); + } + + private function runNetworkDiscovery(ScanJob $job): void + { + // 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'; + + // Use the ranges from database via NetworkRangeRepository + // For now, this is handled by the controller that creates the job with a specific range + } +} diff --git a/bin/run-scan-worker.php b/bin/run-scan-worker.php new file mode 100644 index 0000000..bcda9bc --- /dev/null +++ b/bin/run-scan-worker.php @@ -0,0 +1,92 @@ +safeLoad(); + +$pdo = new PDO( + sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', + getenv('DB_HOST') ?: 'db', + (int)(getenv('DB_PORT') ?: 3306), + getenv('DB_DATABASE') ?: 'domovoy' + ), + getenv('DB_USERNAME') ?: 'domovoy', + getenv('DB_PASSWORD') ?: 'domovoy', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, + ] +); + +// Build dependencies manually (no DI container in CLI) +$logger = new \Monolog\Logger('domovoy'); +$logger->pushHandler(new \Monolog\Handler\StreamHandler(dirname(__DIR__) . '/storage/logs/scan-worker.log', \Monolog\Level::Debug)); + +$networkRangeRepository = new \Domovoy\Repositories\NetworkRangeRepository($pdo); +$scanJobRepository = new \Domovoy\Repositories\ScanJobRepository($pdo); +$discoveredHostRepository = new \Domovoy\Repositories\DiscoveredHostRepository($pdo); + +$pingScanner = new \Domovoy\Services\Discovery\PingScanner(); +$tcpPortScanner = new \Domovoy\Services\Discovery\TcpPortScanner(); +$arpTableReader = new \Domovoy\Services\Discovery\ArpTableReader(); +$fingerprintService = new \Domovoy\Services\Discovery\HostFingerprintService(); + +$networkScanner = new \Domovoy\Services\Discovery\NetworkScanner( + $pingScanner, + $tcpPortScanner, + $arpTableReader, + $fingerprintService, + $discoveredHostRepository +); + +$runner = new \Domovoy\Services\Jobs\ScanJobRunner($scanJobRepository, $networkScanner); + +$loopMode = in_array('--loop', $argv, true); + +if ($loopMode) { + echo "Scan worker running in loop mode. Press Ctrl+C to stop.\n"; + $logger->info('Scan worker started in loop mode'); + + // Handle SIGTERM for graceful shutdown + if (function_exists('pcntl_signal')) { + pcntl_signal(SIGTERM, function () use ($logger) { + $logger->info('Received SIGTERM, shutting down gracefully'); + exit(0); + }); + } + + while (true) { + $job = $runner->runNext(); + if ($job === null) { + sleep(5); + continue; + } + $logger->info('Scan job completed', ['job_id' => $job->id, 'status' => $job->status]); + if (function_exists('pcntl_signal_dispatch')) { + pcntl_signal_dispatch(); + } + } +} else { + $job = $runner->runNext(); + if ($job === null) { + echo "No pending scan jobs found.\n"; + } else { + echo "Scan job #{$job->id} completed: {$job->status}\n"; + if ($job->errorMessage !== null) { + echo "Error: {$job->errorMessage}\n"; + } + } +} diff --git a/db/migrations/20250526000002_create_network_ranges.php b/db/migrations/20250526000002_create_network_ranges.php new file mode 100644 index 0000000..ae48516 --- /dev/null +++ b/db/migrations/20250526000002_create_network_ranges.php @@ -0,0 +1,21 @@ +table('network_ranges'); + $table + ->addColumn('name', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('cidr', 'string', ['limit' => 45, 'null' => false]) + ->addColumn('enabled', 'boolean', ['null' => false, 'default' => true]) + ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['cidr'], ['unique' => true]) + ->create(); + } +} diff --git a/db/migrations/20250526000003_create_scan_jobs.php b/db/migrations/20250526000003_create_scan_jobs.php new file mode 100644 index 0000000..2256b7b --- /dev/null +++ b/db/migrations/20250526000003_create_scan_jobs.php @@ -0,0 +1,27 @@ +table('scan_jobs'); + $table + ->addColumn('type', 'string', ['limit' => 50, 'null' => false]) + ->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'pending']) + ->addColumn('network_range_id', 'integer', ['null' => true]) + ->addColumn('device_id', 'string', ['limit' => 36, 'null' => true]) + ->addColumn('started_at', 'datetime', ['null' => true]) + ->addColumn('finished_at', 'datetime', ['null' => true]) + ->addColumn('error_message', 'text', ['null' => true]) + ->addColumn('result_json', 'text', ['null' => true]) + ->addColumn('created_by', 'string', ['limit' => 36, 'null' => true]) + ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['status']) + ->addIndex(['type']) + ->create(); + } +} diff --git a/db/migrations/20250526000004_create_discovered_hosts.php b/db/migrations/20250526000004_create_discovered_hosts.php new file mode 100644 index 0000000..a88dd89 --- /dev/null +++ b/db/migrations/20250526000004_create_discovered_hosts.php @@ -0,0 +1,34 @@ +table('discovered_hosts'); + $table + ->addColumn('scan_job_id', 'integer', ['null' => true]) + ->addColumn('ip_address', 'string', ['limit' => 45, 'null' => false]) + ->addColumn('mac_address', 'string', ['limit' => 17, 'null' => true]) + ->addColumn('hostname', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('vendor', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('detected_os', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('open_ports_json', 'text', ['null' => true]) + ->addColumn('protocols_json', 'text', ['null' => true]) + ->addColumn('fingerprint_json', 'text', ['null' => true]) + ->addColumn('confidence', 'integer', ['null' => false, 'default' => 50]) + ->addColumn('status', 'string', ['limit' => 20, 'null' => false, 'default' => 'new']) + ->addColumn('matched_device_id', 'integer', ['null' => true]) + ->addColumn('first_seen', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('last_seen', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['ip_address']) + ->addIndex(['mac_address']) + ->addIndex(['status']) + ->create(); + } +} diff --git a/db/migrations/20250526000005_create_audit_log.php b/db/migrations/20250526000005_create_audit_log.php new file mode 100644 index 0000000..3de28b2 --- /dev/null +++ b/db/migrations/20250526000005_create_audit_log.php @@ -0,0 +1,24 @@ +table('audit_log'); + $table + ->addColumn('user_id', 'string', ['limit' => 36, 'null' => true]) + ->addColumn('action', 'string', ['limit' => 100, 'null' => false]) + ->addColumn('entity_type', 'string', ['limit' => 50, 'null' => true]) + ->addColumn('entity_id', 'string', ['limit' => 36, 'null' => true]) + ->addColumn('details_json', 'text', ['null' => true]) + ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['action']) + ->addIndex(['entity_type', 'entity_id']) + ->addIndex(['created_at']) + ->create(); + } +} diff --git a/public/index.php b/public/index.php index 2c8cfa6..c54fd21 100644 --- a/public/index.php +++ b/public/index.php @@ -58,12 +58,56 @@ $containerBuilder->addDefinitions([ PDO::ATTR_EMULATE_PREPARES => false, ]); }, + // Repositories \Domovoy\Repositories\UserRepository::class => function ($c) { return new \Domovoy\Repositories\UserRepository($c->get(PDO::class)); }, + \Domovoy\Repositories\NetworkRangeRepository::class => function ($c) { + return new \Domovoy\Repositories\NetworkRangeRepository($c->get(PDO::class)); + }, + \Domovoy\Repositories\ScanJobRepository::class => function ($c) { + return new \Domovoy\Repositories\ScanJobRepository($c->get(PDO::class)); + }, + \Domovoy\Repositories\DiscoveredHostRepository::class => function ($c) { + return new \Domovoy\Repositories\DiscoveredHostRepository($c->get(PDO::class)); + }, + \Domovoy\Repositories\AuditLogRepository::class => function ($c) { + return new \Domovoy\Repositories\AuditLogRepository($c->get(PDO::class)); + }, + // Discovery services + \Domovoy\Services\Discovery\PingScanner::class => function () { + return new \Domovoy\Services\Discovery\PingScanner(); + }, + \Domovoy\Services\Discovery\TcpPortScanner::class => function () { + return new \Domovoy\Services\Discovery\TcpPortScanner(); + }, + \Domovoy\Services\Discovery\ArpTableReader::class => function () { + return new \Domovoy\Services\Discovery\ArpTableReader(); + }, + \Domovoy\Services\Discovery\HostFingerprintService::class => function () { + return new \Domovoy\Services\Discovery\HostFingerprintService(); + }, + \Domovoy\Services\Discovery\NetworkScanner::class => function ($c) { + return new \Domovoy\Services\Discovery\NetworkScanner( + $c->get(\Domovoy\Services\Discovery\PingScanner::class), + $c->get(\Domovoy\Services\Discovery\TcpPortScanner::class), + $c->get(\Domovoy\Services\Discovery\ArpTableReader::class), + $c->get(\Domovoy\Services\Discovery\HostFingerprintService::class), + $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class) + ); + }, + // Job runner + \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) + ); + }, + // Auth \Domovoy\Services\AuthService::class => function ($c) { return new \Domovoy\Services\AuthService($c->get(\Domovoy\Repositories\UserRepository::class)); }, + // Controllers \Domovoy\Controllers\AuthController::class => function ($c) { return new \Domovoy\Controllers\AuthController($c->get(\Domovoy\Services\AuthService::class)); }, @@ -73,6 +117,18 @@ $containerBuilder->addDefinitions([ \Domovoy\Controllers\DashboardController::class => function () { return new \Domovoy\Controllers\DashboardController(); }, + \Domovoy\Controllers\DiscoveryController::class => function ($c) { + return new \Domovoy\Controllers\DiscoveryController( + $c->get(\Domovoy\Repositories\NetworkRangeRepository::class), + $c->get(\Domovoy\Repositories\ScanJobRepository::class), + $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class) + ); + }, + \Domovoy\Controllers\NetworkRangeController::class => function ($c) { + return new \Domovoy\Controllers\NetworkRangeController( + $c->get(\Domovoy\Repositories\NetworkRangeRepository::class) + ); + }, ]); $container = $containerBuilder->build(); @@ -85,15 +141,26 @@ $app->addBodyParsingMiddleware(); $app->add(new \Domovoy\Middleware\AuthMiddleware()); $app->addErrorMiddleware(true, true, true); -// Routes +// Public routes $app->get('/login', [\Domovoy\Controllers\AuthController::class, 'loginForm'])->setName('login'); $app->post('/login', [\Domovoy\Controllers\AuthController::class, 'login'])->setName('login.post'); $app->get('/setup', [\Domovoy\Controllers\SetupController::class, 'form'])->setName('setup'); $app->post('/setup', [\Domovoy\Controllers\SetupController::class, 'create'])->setName('setup.post'); +// Protected routes $app->group('', function (\Slim\Routing\RouteCollectorProxy $group) { $group->get('/dashboard', [\Domovoy\Controllers\DashboardController::class, 'index'])->setName('dashboard'); $group->post('/logout', [\Domovoy\Controllers\AuthController::class, 'logout'])->setName('logout'); + + // 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/hosts/ignore', [\Domovoy\Controllers\DiscoveryController::class, 'ignoreHost'])->setName('discovery.hosts.ignore'); + + // Network ranges CRUD + $group->post('/discovery/ranges/create', [\Domovoy\Controllers\NetworkRangeController::class, 'create'])->setName('discovery.ranges.create'); + $group->post('/discovery/ranges/toggle', [\Domovoy\Controllers\NetworkRangeController::class, 'toggle'])->setName('discovery.ranges.toggle'); + $group->post('/discovery/ranges/delete', [\Domovoy\Controllers\NetworkRangeController::class, 'delete'])->setName('discovery.ranges.delete'); }); $app->run(); diff --git a/templates/discovery/index.php b/templates/discovery/index.php new file mode 100644 index 0000000..6ae8c78 --- /dev/null +++ b/templates/discovery/index.php @@ -0,0 +1,160 @@ + +

Сканирование сети

+ + +
+
Добавить диапазон
+
+
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
Диапазоны
+
+ +

Нет диапазонов. Добавьте первый диапазон выше.

+ + + + + + + + + + + + + + + + + + + + +
НазваниеCIDRСтатусДействия
name) ?>cidr) ?> + enabled): ?> + Включён + + Выключен + + +
+ + +
+
+ + +
+
+ + +
+ +
+
+
+ + +
+
История сканирования
+
+ +

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

+ + + + + + + + + + + + + + + + + + + + +
ТипСтатусЗапущенЗавершён
type) ?> + status) { + 'pending' => 'bg-warning', + 'running' => 'bg-info', + 'done' => 'bg-success', + 'failed' => 'bg-danger', + default => 'bg-secondary', + }; + ?> + status) ?> + startedAt?->format('Y-m-d H:i:s') ?? '-' ?>finishedAt?->format('Y-m-d H:i:s') ?? '-' ?>
+ +
+
+ + +
+
Новые находки ()
+
+ +

Нет новых находок.

+ + + + + + + + + + + + + + + + + + + + + + + + + + +
IPHostnameMACVendorПортыУверенностьДействия
ipAddress) ?>hostname ?? '-') ?>macAddress ?? '-') ?>vendor ?? '-') ?>openPorts) ? '-' : implode(', ', $host->openPorts) ?>confidence ?>% +
+ + +
+
+ +
+
+ +