Итерация 2: сканирование сети
- 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 конфигурацией для новых сервисов
This commit is contained in:
parent
e7732f5cee
commit
177e44f015
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Controllers;
|
||||
|
||||
use Domovoy\Models\NetworkRange;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class DiscoveryController
|
||||
{
|
||||
private \Domovoy\Repositories\NetworkRangeRepository $networkRangeRepository;
|
||||
private \Domovoy\Repositories\ScanJobRepository $scanJobRepository;
|
||||
private \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository;
|
||||
|
||||
public function __construct(
|
||||
\Domovoy\Repositories\NetworkRangeRepository $networkRangeRepository,
|
||||
\Domovoy\Repositories\ScanJobRepository $scanJobRepository,
|
||||
\Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository
|
||||
) {
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Controllers;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class NetworkRangeController
|
||||
{
|
||||
private \Domovoy\Repositories\NetworkRangeRepository $repository;
|
||||
|
||||
public function __construct(\Domovoy\Repositories\NetworkRangeRepository $repository)
|
||||
{
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Models;
|
||||
|
||||
class DiscoveredHost
|
||||
{
|
||||
public ?int $id = null;
|
||||
public ?int $scanJobId = null;
|
||||
public string $ipAddress = '';
|
||||
public ?string $macAddress = null;
|
||||
public ?string $hostname = null;
|
||||
public ?string $vendor = null;
|
||||
public ?string $detectedOs = null;
|
||||
/** @var int[] */
|
||||
public array $openPorts = [];
|
||||
/** @var string[] */
|
||||
public array $protocols = [];
|
||||
public array $fingerprint = [];
|
||||
public int $confidence = 50;
|
||||
public string $status = 'new';
|
||||
public ?string $matchedDeviceId = null;
|
||||
public ?\DateTimeImmutable $firstSeen = null;
|
||||
public ?\DateTimeImmutable $lastSeen = null;
|
||||
public ?\DateTimeImmutable $createdAt = null;
|
||||
public ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$obj = new self();
|
||||
$obj->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Models;
|
||||
|
||||
class NetworkRange
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $name = '';
|
||||
public string $cidr = '';
|
||||
public bool $enabled = true;
|
||||
public ?\DateTimeImmutable $createdAt = null;
|
||||
public ?\DateTimeImmutable $updatedAt = null;
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$obj = new self();
|
||||
$obj->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Models;
|
||||
|
||||
class ScanJob
|
||||
{
|
||||
public ?int $id = null;
|
||||
public string $type = '';
|
||||
public string $status = 'pending';
|
||||
public ?int $networkRangeId = null;
|
||||
public ?string $deviceId = null;
|
||||
public ?\DateTimeImmutable $startedAt = null;
|
||||
public ?\DateTimeImmutable $finishedAt = null;
|
||||
public ?string $errorMessage = null;
|
||||
public ?string $resultJson = null;
|
||||
public ?string $createdBy = null;
|
||||
public ?\DateTimeImmutable $createdAt = null;
|
||||
|
||||
public static function fromArray(array $data): self
|
||||
{
|
||||
$obj = new self();
|
||||
$obj->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Repositories;
|
||||
|
||||
use PDO;
|
||||
|
||||
class AuditLogRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Repositories;
|
||||
|
||||
use Domovoy\Models\DiscoveredHost;
|
||||
use PDO;
|
||||
|
||||
class DiscoveredHostRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Repositories;
|
||||
|
||||
use Domovoy\Models\NetworkRange;
|
||||
use PDO;
|
||||
|
||||
class NetworkRangeRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Repositories;
|
||||
|
||||
use Domovoy\Models\ScanJob;
|
||||
use PDO;
|
||||
|
||||
class ScanJobRepository
|
||||
{
|
||||
private PDO $pdo;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Discovery;
|
||||
|
||||
class ArpTableReader
|
||||
{
|
||||
/**
|
||||
* Read ARP table from system.
|
||||
* Returns array keyed by IP => ['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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Discovery;
|
||||
|
||||
class HostFingerprintService
|
||||
{
|
||||
/**
|
||||
* Guess vendor based on open ports and hostname.
|
||||
*/
|
||||
public function guessVendor(array $openPorts, ?string $hostname): ?string
|
||||
{
|
||||
$portVendorMap = [
|
||||
9100 => '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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Discovery;
|
||||
|
||||
use Domovoy\Models\DiscoveredHost;
|
||||
use Domovoy\Models\NetworkRange;
|
||||
use Domovoy\Repositories\DiscoveredHostRepository;
|
||||
|
||||
class NetworkScanner
|
||||
{
|
||||
private PingScanner $pingScanner;
|
||||
private TcpPortScanner $tcpPortScanner;
|
||||
private ArpTableReader $arpTableReader;
|
||||
private HostFingerprintService $fingerprintService;
|
||||
private DiscoveredHostRepository $discoveredHostRepository;
|
||||
|
||||
/** @var int[] */
|
||||
private array $defaultPorts = [22, 23, 53, 80, 443, 445, 548, 631, 3306, 5432, 6379, 8000, 8080, 8443, 9000, 9090, 9100];
|
||||
|
||||
public function __construct(
|
||||
PingScanner $pingScanner,
|
||||
TcpPortScanner $tcpPortScanner,
|
||||
ArpTableReader $arpTableReader,
|
||||
HostFingerprintService $fingerprintService,
|
||||
DiscoveredHostRepository $discoveredHostRepository
|
||||
) {
|
||||
$this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Discovery;
|
||||
|
||||
class PingScanner
|
||||
{
|
||||
/**
|
||||
* Check if host is alive via ICMP ping.
|
||||
* Requires ping command in container.
|
||||
*/
|
||||
public function ping(string $ip, int $timeoutMs = 500): bool
|
||||
{
|
||||
// Use ping with timeout (Linux: -W in seconds)
|
||||
$timeoutSec = max(1, (int)ceil($timeoutMs / 1000));
|
||||
$output = [];
|
||||
$exitCode = 0;
|
||||
exec(sprintf('ping -c 1 -W %d %s 2>/dev/null', $timeoutSec, escapeshellarg($ip)), $output, $exitCode);
|
||||
return $exitCode === 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Discovery;
|
||||
|
||||
class TcpPortScanner
|
||||
{
|
||||
/**
|
||||
* Scan a list of TCP ports on a host.
|
||||
*
|
||||
* @param int[] $ports
|
||||
* @param int $timeoutMs connection timeout per port
|
||||
* @param int $maxConcurrency max parallel connections
|
||||
* @return int[] list of open ports
|
||||
*/
|
||||
public function scan(string $ip, array $ports, int $timeoutMs = 200, int $maxConcurrency = 50): array
|
||||
{
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sort($openPorts);
|
||||
return $openPorts;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Jobs;
|
||||
|
||||
use Domovoy\Models\ScanJob;
|
||||
use Domovoy\Repositories\ScanJobRepository;
|
||||
use Domovoy\Services\Discovery\NetworkScanner;
|
||||
|
||||
class ScanJobRunner
|
||||
{
|
||||
private ScanJobRepository $scanJobRepository;
|
||||
private NetworkScanner $networkScanner;
|
||||
|
||||
public function __construct(
|
||||
ScanJobRepository $scanJobRepository,
|
||||
NetworkScanner $networkScanner
|
||||
) {
|
||||
$this->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
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* CLI worker for running pending scan jobs.
|
||||
*
|
||||
* Usage:
|
||||
* php bin/run-scan-worker.php # Run one pending job and exit
|
||||
* php bin/run-scan-worker.php --loop # Infinite loop, process all pending jobs
|
||||
*/
|
||||
|
||||
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||
|
||||
$dotenv = \Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||
$dotenv->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";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateNetworkRanges extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateScanJobs extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateDiscoveredHosts extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateAuditLog extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,160 @@
|
|||
<?php ob_start(); ?>
|
||||
<h2>Сканирование сети</h2>
|
||||
|
||||
<!-- Add Range Form -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Добавить диапазон</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="/discovery/ranges/create" class="row g-2">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="name" class="form-control" placeholder="Название (Home LAN)" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<input type="text" name="cidr" class="form-control" placeholder="CIDR (192.168.1.0/24)" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">Добавить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Ranges -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Диапазоны</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($ranges)): ?>
|
||||
<p class="text-muted">Нет диапазонов. Добавьте первый диапазон выше.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>CIDR</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($ranges as $range): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($range->name) ?></td>
|
||||
<td><?= htmlspecialchars($range->cidr) ?></td>
|
||||
<td>
|
||||
<?php if ($range->enabled): ?>
|
||||
<span class="badge bg-success">Включён</span>
|
||||
<?php else: ?>
|
||||
<span class="badge bg-secondary">Выключен</span>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td>
|
||||
<form method="POST" action="/discovery/ranges/toggle" class="d-inline">
|
||||
<input type="hidden" name="id" value="<?= $range->id ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-secondary">
|
||||
<?= $range->enabled ? 'Выключить' : 'Включить' ?>
|
||||
</button>
|
||||
</form>
|
||||
<form method="POST" action="/discovery/ranges/delete" class="d-inline" onsubmit="return confirm('Удалить?')">
|
||||
<input type="hidden" name="id" value="<?= $range->id ?>">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
|
||||
<form method="POST" action="/discovery/scan" class="mt-3">
|
||||
<button type="submit" class="btn btn-success" <?= empty($ranges) ? 'disabled' : '' ?>>
|
||||
Запустить скан
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scan Jobs -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">История сканирования</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($scanJobs)): ?>
|
||||
<p class="text-muted">Нет запусков сканирования.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Тип</th>
|
||||
<th>Статус</th>
|
||||
<th>Запущен</th>
|
||||
<th>Завершён</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($scanJobs as $job): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($job->type) ?></td>
|
||||
<td>
|
||||
<?php
|
||||
$badgeClass = match ($job->status) {
|
||||
'pending' => 'bg-warning',
|
||||
'running' => 'bg-info',
|
||||
'done' => 'bg-success',
|
||||
'failed' => 'bg-danger',
|
||||
default => 'bg-secondary',
|
||||
};
|
||||
?>
|
||||
<span class="badge <?= $badgeClass ?>"><?= htmlspecialchars($job->status) ?></span>
|
||||
</td>
|
||||
<td><?= $job->startedAt?->format('Y-m-d H:i:s') ?? '-' ?></td>
|
||||
<td><?= $job->finishedAt?->format('Y-m-d H:i:s') ?? '-' ?></td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New Hosts -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">Новые находки (<?= count($newHosts) ?>)</div>
|
||||
<div class="card-body">
|
||||
<?php if (empty($newHosts)): ?>
|
||||
<p class="text-muted">Нет новых находок.</p>
|
||||
<?php else: ?>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Hostname</th>
|
||||
<th>MAC</th>
|
||||
<th>Vendor</th>
|
||||
<th>Порты</th>
|
||||
<th>Уверенность</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php foreach ($newHosts as $host): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($host->ipAddress) ?></td>
|
||||
<td><?= htmlspecialchars($host->hostname ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($host->macAddress ?? '-') ?></td>
|
||||
<td><?= htmlspecialchars($host->vendor ?? '-') ?></td>
|
||||
<td><?= empty($host->openPorts) ? '-' : implode(', ', $host->openPorts) ?></td>
|
||||
<td><?= $host->confidence ?>%</td>
|
||||
<td>
|
||||
<form method="POST" action="/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>
|
||||
<?php $content = ob_get_clean(); ?>
|
||||
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||
Loading…
Reference in New Issue