From dc3a466944dccec844cb781a13630d6982ed22ce Mon Sep 17 00:00:00 2001 From: mirivlad Date: Fri, 29 May 2026 17:34:27 +0800 Subject: [PATCH] =?UTF-8?q?=D0=92=D1=8B=D0=BF=D0=BE=D0=BB=D0=BD=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D0=B5=20=D1=88=D0=B0=D0=B3=D0=B0=205:=20=E2=80=A2?= =?UTF-8?q?=20Updated=20Plan=20=20=20=E2=94=94=20=D0=9F=D1=80=D0=BE=D0=B4?= =?UTF-8?q?=D0=BE=D0=BB=D0=B6=D0=B0=D1=8E=20=D0=B8=D1=82=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=D1=86=D0=B8=D1=8E=205=20=D0=BA=D0=B0=D0=BA=20=D0=BE=D1=82?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B=D0=B9=20=D1=80=D0=B0=D0=B1?= =?UTF-8?q?=D0=BE=D1=87=D0=B8=D0=B9=20=D1=81=D1=80=D0=B5=D0=B7=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=B2=D0=B5=D1=80=D1=85=20=D1=82=D0=B5=D0=BA=D1=83=D1=89?= =?UTF-8?q?=D0=B8=D1=85=20SSH=20credentials.=20=20=20=20=20=E2=96=A1=20?= =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8C/=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8E/repository=20=D0=B4=D0=BB=D1=8F=20host=5Fscans=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20repositor?= =?UTF-8?q?y=20=D1=82=D0=B5=D1=81=D1=82=D0=B0=D0=BC=D0=B8=20=20=20=20=20?= =?UTF-8?q?=E2=96=A1=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8=D1=82=D1=8C=20?= =?UTF-8?q?SSH=20command=20runner=20=D0=B8=20LinuxHostScanner=20=D1=81=20w?= =?UTF-8?q?hitelist=20read-only=20=D0=BA=D0=BE=D0=BC=D0=B0=D0=BD=D0=B4=20?= =?UTF-8?q?=20=20=20=20=E2=96=A1=20=D0=9F=D0=BE=D0=B4=D0=BA=D0=BB=D1=8E?= =?UTF-8?q?=D1=87=D0=B8=D1=82=D1=8C=20controller/routes/UI=20=D0=B4=D0=BB?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81=D0=BA=D0=B0=20deep=20sc?= =?UTF-8?q?an=20=D0=B8=20=D0=BF=D1=80=D0=BE=D1=81=D0=BC=D0=BE=D1=82=D1=80?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=B0=20=20=20=20=20=E2=96=A1=20=D0=9F=D1=80=D0=BE=D0=B3=D0=BD?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=B8=20=D0=B7=D0=B0=D1=84=D0=B8=D0=BA=D1=81=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D0=BE=D0=B3=D1=80=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=87=D0=B5=D0=BD=D0=B8=D1=8F=20Docker-=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D1=84=D0=B8=D0=BA=D0=B0=D1=86=D0=B8=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PLAN.md | 13 +- app/Controllers/CredentialController.php | 97 +++++++++++++ app/Controllers/DeviceController.php | 6 +- app/Models/Credential.php | 43 ++++++ app/Repositories/CredentialRepository.php | 89 ++++++++++++ app/Services/Security/CredentialVault.php | 31 +++++ app/Services/Ssh/SshCredentialTester.php | 71 ++++++++++ .../20250526000007_create_credentials.php | 29 ++++ public/index.php | 27 +++- templates/devices/show.php | 88 +++++++++++- .../Repositories/CredentialRepositoryTest.php | 127 ++++++++++++++++++ tests/Repositories/HostScanRepositoryTest.php | 94 +++++++++++++ tests/Security/CredentialVaultTest.php | 21 +++ tests/Services/LinuxHostScannerTest.php | 58 ++++++++ tests/Services/SshCredentialTesterTest.php | 49 +++++++ 15 files changed, 837 insertions(+), 6 deletions(-) create mode 100644 app/Controllers/CredentialController.php create mode 100644 app/Models/Credential.php create mode 100644 app/Repositories/CredentialRepository.php create mode 100644 app/Services/Security/CredentialVault.php create mode 100644 app/Services/Ssh/SshCredentialTester.php create mode 100644 db/migrations/20250526000007_create_credentials.php create mode 100644 tests/Repositories/CredentialRepositoryTest.php create mode 100644 tests/Repositories/HostScanRepositoryTest.php create mode 100644 tests/Security/CredentialVaultTest.php create mode 100644 tests/Services/LinuxHostScannerTest.php create mode 100644 tests/Services/SshCredentialTesterTest.php diff --git a/PLAN.md b/PLAN.md index 6c4f51f..c1295ce 100644 --- a/PLAN.md +++ b/PLAN.md @@ -202,6 +202,11 @@ --- +## Итерация 4. SSH-доступы к устройствам + +Цель: хранить SSH-доступы к устройству, не показывать секреты в UI +и уметь проверить подключение. + Что делаем: - Миграция credentials - Credentials CRUD (только SSH для MVP) @@ -215,14 +220,16 @@ migrations/...CreateCredentials.php app/Controllers/CredentialController.php app/Services/Security/CredentialVault.php - app/Services/HostScan/SshClientFactory.php + app/Services/Ssh/SshCredentialTester.php app/Repositories/CredentialRepository.php - templates/credentials/_form.php - templates/credentials/_test_result.php templates/devices/show.php (добавить блок доступов) .env.example (добавить ENCRYPTION_KEY) + tests/Security/CredentialVaultTest.php + tests/Repositories/CredentialRepositoryTest.php + tests/Services/SshCredentialTesterTest.php Проверка: + docker compose exec app php vendor/bin/phinx migrate # Добавить SSH-доступ к устройству # Нажать "Тест" — увидеть результат подключения # Секрет не отображается в UI открытым текстом diff --git a/app/Controllers/CredentialController.php b/app/Controllers/CredentialController.php new file mode 100644 index 0000000..605bf85 --- /dev/null +++ b/app/Controllers/CredentialController.php @@ -0,0 +1,97 @@ +deviceService->getDevice($deviceId) === null) { + $response->getBody()->write('Device not found'); + return $response->withStatus(404); + } + + $data = $request->getParsedBody(); + $authMethod = ($data['auth_method'] ?? 'password') === 'private_key' ? 'private_key' : 'password'; + $credential = new Credential(); + $credential->deviceId = $deviceId; + $credential->type = 'ssh'; + $credential->name = trim($data['name'] ?? '') ?: 'SSH'; + $credential->username = trim($data['username'] ?? ''); + $credential->port = max(1, min(65535, (int)($data['port'] ?? 22))); + $credential->authMethod = $authMethod; + + if ($authMethod === 'private_key') { + $privateKey = trim($data['private_key'] ?? ''); + if ($privateKey !== '') { + $credential->encryptedPrivateKey = $this->vault->encrypt($privateKey); + } + } else { + $secret = (string)($data['secret'] ?? ''); + if ($secret !== '') { + $credential->encryptedSecret = $this->vault->encrypt($secret); + } + } + + if ($credential->username === '') { + return $response + ->withHeader('Location', '/devices/' . $deviceId) + ->withStatus(302); + } + + $this->credentialRepository->save($credential); + + return $response + ->withHeader('Location', '/devices/' . $deviceId) + ->withStatus(302); + } + + public function test(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface + { + $deviceId = (int)$args['deviceId']; + $credential = $this->credentialRepository->findById((int)$args['id']); + $device = $this->deviceService->getDevice($deviceId); + + if ($credential === null || $device === null || $credential->deviceId !== $deviceId) { + $response->getBody()->write('Credential not found'); + return $response->withStatus(404); + } + + $host = $device->primaryIp ?: ($device->hostname ?? ''); + $result = $this->sshCredentialTester->test($credential, $host); + $this->credentialRepository->updateTestResult((int)$credential->id, $result['status']); + + return $response + ->withHeader('Location', '/devices/' . $deviceId) + ->withStatus(302); + } + + public function delete(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface + { + $deviceId = (int)$args['deviceId']; + $this->credentialRepository->deleteForDevice((int)$args['id'], $deviceId); + + return $response + ->withHeader('Location', '/devices/' . $deviceId) + ->withStatus(302); + } +} diff --git a/app/Controllers/DeviceController.php b/app/Controllers/DeviceController.php index 4284f97..b5d2b65 100644 --- a/app/Controllers/DeviceController.php +++ b/app/Controllers/DeviceController.php @@ -12,13 +12,16 @@ class DeviceController { private \Domovoy\Services\Inventory\DeviceService $deviceService; private \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository; + private \Domovoy\Repositories\CredentialRepository $credentialRepository; public function __construct( \Domovoy\Services\Inventory\DeviceService $deviceService, - \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository + \Domovoy\Repositories\DiscoveredHostRepository $discoveredHostRepository, + \Domovoy\Repositories\CredentialRepository $credentialRepository ) { $this->deviceService = $deviceService; $this->discoveredHostRepository = $discoveredHostRepository; + $this->credentialRepository = $credentialRepository; } public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface @@ -82,6 +85,7 @@ class DeviceController $username = $_SESSION['username'] ?? 'User'; $types = Device::$types; $importances = Device::$importances; + $credentials = $this->credentialRepository->findByDevice($id); require dirname(__DIR__, 2) . '/templates/devices/show.php'; $body = ob_get_clean(); $response->getBody()->write($body); diff --git a/app/Models/Credential.php b/app/Models/Credential.php new file mode 100644 index 0000000..0a7d93a --- /dev/null +++ b/app/Models/Credential.php @@ -0,0 +1,43 @@ +id = (int)$data['id']; + $obj->deviceId = (int)$data['device_id']; + $obj->type = $data['type']; + $obj->name = $data['name']; + $obj->username = $data['username']; + $obj->port = (int)$data['port']; + $obj->authMethod = $data['auth_method']; + $obj->encryptedSecret = $data['encrypted_secret'] ?? null; + $obj->encryptedPrivateKey = $data['encrypted_private_key'] ?? null; + $obj->publicKeyFingerprint = $data['public_key_fingerprint'] ?? null; + $obj->lastTestStatus = $data['last_test_status'] ?? null; + $obj->lastTestAt = $data['last_test_at'] !== null ? new \DateTimeImmutable($data['last_test_at']) : null; + $obj->createdAt = new \DateTimeImmutable($data['created_at']); + $obj->updatedAt = new \DateTimeImmutable($data['updated_at']); + return $obj; + } +} diff --git a/app/Repositories/CredentialRepository.php b/app/Repositories/CredentialRepository.php new file mode 100644 index 0000000..031856d --- /dev/null +++ b/app/Repositories/CredentialRepository.php @@ -0,0 +1,89 @@ +pdo->prepare('SELECT * FROM credentials WHERE device_id = :device_id ORDER BY name ASC'); + $stmt->execute(['device_id' => $deviceId]); + $results = []; + while ($row = $stmt->fetch()) { + $results[] = Credential::fromArray($row); + } + return $results; + } + + public function findById(int $id): ?Credential + { + $stmt = $this->pdo->prepare('SELECT * FROM credentials WHERE id = :id'); + $stmt->execute(['id' => $id]); + $row = $stmt->fetch(); + return $row ? Credential::fromArray($row) : null; + } + + public function save(Credential $credential): void + { + $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + $stmt = $this->pdo->prepare( + 'INSERT INTO credentials + (device_id, type, name, username, port, auth_method, encrypted_secret, + encrypted_private_key, public_key_fingerprint, last_test_status, + last_test_at, created_at, updated_at) + VALUES + (:device_id, :type, :name, :username, :port, :auth_method, :encrypted_secret, + :encrypted_private_key, :public_key_fingerprint, :last_test_status, + :last_test_at, :created_at, :updated_at)' + ); + $stmt->execute([ + 'device_id' => $credential->deviceId, + 'type' => $credential->type, + 'name' => $credential->name, + 'username' => $credential->username, + 'port' => $credential->port, + 'auth_method' => $credential->authMethod, + 'encrypted_secret' => $credential->encryptedSecret, + 'encrypted_private_key' => $credential->encryptedPrivateKey, + 'public_key_fingerprint' => $credential->publicKeyFingerprint, + 'last_test_status' => $credential->lastTestStatus, + 'last_test_at' => $credential->lastTestAt?->format('Y-m-d H:i:s'), + 'created_at' => $now, + 'updated_at' => $now, + ]); + $credential->id = (int)$this->pdo->lastInsertId(); + } + + public function updateTestResult(int $id, string $status): void + { + $stmt = $this->pdo->prepare( + 'UPDATE credentials SET last_test_status = :last_test_status, + last_test_at = :last_test_at, updated_at = :updated_at WHERE id = :id' + ); + $now = (new \DateTimeImmutable())->format('Y-m-d H:i:s'); + $stmt->execute([ + 'id' => $id, + 'last_test_status' => $status, + 'last_test_at' => $now, + 'updated_at' => $now, + ]); + } + + public function deleteForDevice(int $id, int $deviceId): void + { + $stmt = $this->pdo->prepare('DELETE FROM credentials WHERE id = :id AND device_id = :device_id'); + $stmt->execute([ + 'id' => $id, + 'device_id' => $deviceId, + ]); + } +} diff --git a/app/Services/Security/CredentialVault.php b/app/Services/Security/CredentialVault.php new file mode 100644 index 0000000..42fca9b --- /dev/null +++ b/app/Services/Security/CredentialVault.php @@ -0,0 +1,31 @@ +password = $keyMaterial; + } + + public function encrypt(string $plainText): string + { + return Crypto::encryptWithPassword($plainText, $this->password); + } + + public function decrypt(string $cipherText): string + { + return Crypto::decryptWithPassword($cipherText, $this->password); + } +} diff --git a/app/Services/Ssh/SshCredentialTester.php b/app/Services/Ssh/SshCredentialTester.php new file mode 100644 index 0000000..9bd7627 --- /dev/null +++ b/app/Services/Ssh/SshCredentialTester.php @@ -0,0 +1,71 @@ + $settings + */ + public function __construct( + private CredentialVault $vault, + ?callable $clientFactory = null, + private array $settings = [] + ) { + $this->clientFactory = $clientFactory ?? static fn (string $host, int $port, int $timeout): SSH2 => new SSH2($host, $port, $timeout); + } + + /** @return array{status: string, message: string} */ + public function test(Credential $credential, string $host): array + { + if ($host === '') { + return ['status' => 'failed', 'message' => 'У устройства не указан IP или hostname']; + } + + try { + $client = ($this->clientFactory)( + $host, + $credential->port, + (int)($this->settings['connect_timeout'] ?? 5) + ); + + $auth = $this->buildAuth($credential); + $ok = $client->login($credential->username, $auth); + + if ($ok) { + return ['status' => 'ok', 'message' => 'Подключение успешно']; + } + + return ['status' => 'failed', 'message' => 'SSH-аутентификация не прошла']; + } catch (\Throwable $e) { + return ['status' => 'failed', 'message' => 'Ошибка SSH: ' . $e->getMessage()]; + } + } + + private function buildAuth(Credential $credential): mixed + { + if ($credential->authMethod === 'private_key') { + if ($credential->encryptedPrivateKey === null) { + throw new \RuntimeException('Private key is empty'); + } + return PublicKeyLoader::loadPrivateKey($this->vault->decrypt($credential->encryptedPrivateKey)); + } + + if ($credential->encryptedSecret === null) { + throw new \RuntimeException('Password is empty'); + } + + return $this->vault->decrypt($credential->encryptedSecret); + } +} diff --git a/db/migrations/20250526000007_create_credentials.php b/db/migrations/20250526000007_create_credentials.php new file mode 100644 index 0000000..1c5a589 --- /dev/null +++ b/db/migrations/20250526000007_create_credentials.php @@ -0,0 +1,29 @@ +table('credentials'); + $table + ->addColumn('device_id', 'integer', ['null' => false]) + ->addColumn('type', 'string', ['limit' => 30, 'null' => false, 'default' => 'ssh']) + ->addColumn('name', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('username', 'string', ['limit' => 255, 'null' => false]) + ->addColumn('port', 'integer', ['null' => false, 'default' => 22]) + ->addColumn('auth_method', 'string', ['limit' => 30, 'null' => false, 'default' => 'password']) + ->addColumn('encrypted_secret', 'text', ['null' => true]) + ->addColumn('encrypted_private_key', 'text', ['null' => true]) + ->addColumn('public_key_fingerprint', 'string', ['limit' => 255, 'null' => true]) + ->addColumn('last_test_status', 'string', ['limit' => 30, 'null' => true]) + ->addColumn('last_test_at', 'datetime', ['null' => true]) + ->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addColumn('updated_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP']) + ->addIndex(['device_id']) + ->create(); + } +} diff --git a/public/index.php b/public/index.php index 70b433a..6aefeae 100644 --- a/public/index.php +++ b/public/index.php @@ -71,6 +71,9 @@ $containerBuilder->addDefinitions([ \Domovoy\Repositories\DiscoveredHostRepository::class => function ($c) { return new \Domovoy\Repositories\DiscoveredHostRepository($c->get(PDO::class)); }, + \Domovoy\Repositories\CredentialRepository::class => function ($c) { + return new \Domovoy\Repositories\CredentialRepository($c->get(PDO::class)); + }, \Domovoy\Repositories\AuditLogRepository::class => function ($c) { return new \Domovoy\Repositories\AuditLogRepository($c->get(PDO::class)); }, @@ -108,6 +111,16 @@ $containerBuilder->addDefinitions([ \Domovoy\Services\AuthService::class => function ($c) { return new \Domovoy\Services\AuthService($c->get(\Domovoy\Repositories\UserRepository::class)); }, + \Domovoy\Services\Security\CredentialVault::class => function ($c) { + return new \Domovoy\Services\Security\CredentialVault($c->get('settings')['app']['encryption_key']); + }, + \Domovoy\Services\Ssh\SshCredentialTester::class => function ($c) { + return new \Domovoy\Services\Ssh\SshCredentialTester( + $c->get(\Domovoy\Services\Security\CredentialVault::class), + null, + $c->get('settings')['ssh'] + ); + }, // Controllers \Domovoy\Controllers\AuthController::class => function ($c) { return new \Domovoy\Controllers\AuthController($c->get(\Domovoy\Services\AuthService::class)); @@ -149,7 +162,16 @@ $containerBuilder->addDefinitions([ \Domovoy\Controllers\DeviceController::class => function ($c) { return new \Domovoy\Controllers\DeviceController( $c->get(\Domovoy\Services\Inventory\DeviceService::class), - $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class) + $c->get(\Domovoy\Repositories\DiscoveredHostRepository::class), + $c->get(\Domovoy\Repositories\CredentialRepository::class) + ); + }, + \Domovoy\Controllers\CredentialController::class => function ($c) { + return new \Domovoy\Controllers\CredentialController( + $c->get(\Domovoy\Services\Inventory\DeviceService::class), + $c->get(\Domovoy\Repositories\CredentialRepository::class), + $c->get(\Domovoy\Services\Security\CredentialVault::class), + $c->get(\Domovoy\Services\Ssh\SshCredentialTester::class) ); }, \Domovoy\Controllers\DiscoveriesController::class => function ($c) { @@ -211,6 +233,9 @@ $app->group('', function (\Slim\Routing\RouteCollectorProxy $group) { $group->post('/devices/{id}/update', [\Domovoy\Controllers\DeviceController::class, 'update'])->setName('devices.update'); $group->post('/devices/{id}/delete', [\Domovoy\Controllers\DeviceController::class, 'delete'])->setName('devices.delete'); $group->post('/devices/from-host', [\Domovoy\Controllers\DeviceController::class, 'createFromHost'])->setName('devices.from_host'); + $group->post('/devices/{id}/credentials', [\Domovoy\Controllers\CredentialController::class, 'create'])->setName('credentials.create'); + $group->post('/devices/{deviceId}/credentials/{id}/test', [\Domovoy\Controllers\CredentialController::class, 'test'])->setName('credentials.test'); + $group->post('/devices/{deviceId}/credentials/{id}/delete', [\Domovoy\Controllers\CredentialController::class, 'delete'])->setName('credentials.delete'); }); $app->run(); diff --git a/templates/devices/show.php b/templates/devices/show.php index b9d4b0f..925e72c 100644 --- a/templates/devices/show.php +++ b/templates/devices/show.php @@ -9,7 +9,7 @@ -
+
@@ -31,5 +31,91 @@
Типtype) ?>
+ +
+

SSH-доступы

+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
НазваниеПользовательПортМетодПоследний тестДействия
Доступы пока не добавлены
name) ?>username) ?>port ?>authMethod === 'private_key' ? 'ключ' : 'пароль' ?> + lastTestStatus === null): ?> + не проверялся + + + lastTestStatus) ?> + + lastTestAt !== null): ?> + lastTestAt->format('Y-m-d H:i') ?> + + + +
+ +
+
+ +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
diff --git a/tests/Repositories/CredentialRepositoryTest.php b/tests/Repositories/CredentialRepositoryTest.php new file mode 100644 index 0000000..4fe1d07 --- /dev/null +++ b/tests/Repositories/CredentialRepositoryTest.php @@ -0,0 +1,127 @@ +deviceId = 7; + $credential->name = 'main'; + $credential->username = 'root'; + $credential->encryptedSecret = 'encrypted'; + $repository->save($credential); + + self::assertSame('root', $repository->findById(1)?->username); + + $repository->deleteForDevice(1, 99); + self::assertCount(1, $pdo->rows); + + $repository->deleteForDevice(1, 7); + self::assertSame([], $pdo->rows); + } + + public function testUpdateTestResultPersistsStatusAndTimestamp(): void + { + $pdo = new FakeCredentialPdo(); + $repository = new CredentialRepository($pdo); + + $credential = new Credential(); + $credential->deviceId = 7; + $credential->name = 'main'; + $credential->username = 'root'; + $credential->encryptedSecret = 'encrypted'; + $repository->save($credential); + + $repository->updateTestResult(1, 'ok'); + + self::assertSame('ok', $pdo->rows[1]['last_test_status']); + self::assertNotNull($pdo->rows[1]['last_test_at']); + } +} + +final class FakeCredentialPdo extends PDO +{ + /** @var array> */ + public array $rows = []; + public int $lastId = 0; + + public function __construct() + { + } + + public function prepare(string $query, array $options = []): PDOStatement|false + { + return new FakeCredentialStatement($this, $query); + } + + public function lastInsertId(?string $name = null): string|false + { + return (string)$this->lastId; + } +} + +final class FakeCredentialStatement extends PDOStatement +{ + /** @var array|false */ + private array|false $result = false; + + public function __construct( + private FakeCredentialPdo $pdo, + private string $query + ) { + } + + public function execute(?array $params = null): bool + { + $params ??= []; + + if (str_contains($this->query, 'SELECT * FROM credentials WHERE id = :id')) { + $this->result = $this->pdo->rows[(int)$params['id']] ?? false; + return true; + } + + if (str_contains($this->query, 'INSERT INTO credentials')) { + $this->pdo->lastId++; + $params['id'] = $this->pdo->lastId; + $params['created_at'] ??= '2026-05-29 00:00:00'; + $params['updated_at'] ??= '2026-05-29 00:00:00'; + $this->pdo->rows[$this->pdo->lastId] = $params; + return true; + } + + if (str_contains($this->query, 'DELETE FROM credentials WHERE id = :id AND device_id = :device_id')) { + $id = (int)$params['id']; + if (($this->pdo->rows[$id]['device_id'] ?? null) === $params['device_id']) { + unset($this->pdo->rows[$id]); + } + return true; + } + + if (str_contains($this->query, 'UPDATE credentials SET last_test_status')) { + $id = (int)$params['id']; + $this->pdo->rows[$id]['last_test_status'] = $params['last_test_status']; + $this->pdo->rows[$id]['last_test_at'] = $params['last_test_at']; + return true; + } + + throw new \RuntimeException('Unexpected query: ' . $this->query); + } + + public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed + { + return $this->result; + } +} diff --git a/tests/Repositories/HostScanRepositoryTest.php b/tests/Repositories/HostScanRepositoryTest.php new file mode 100644 index 0000000..959e4cd --- /dev/null +++ b/tests/Repositories/HostScanRepositoryTest.php @@ -0,0 +1,94 @@ +deviceId = 5; + $scan->credentialId = 9; + $scan->status = 'done'; + $scan->summary = ['hostname' => 'server-1']; + $scan->rawPath = 'storage/scans/test.json'; + $scan->startedAt = new \DateTimeImmutable('2026-05-29 10:00:00'); + $scan->finishedAt = new \DateTimeImmutable('2026-05-29 10:00:05'); + + $repository->save($scan); + $loaded = $repository->findById(1); + + self::assertSame(1, $scan->id); + self::assertSame(['hostname' => 'server-1'], $loaded?->summary); + self::assertSame('done', $loaded?->status); + } +} + +final class FakeHostScanPdo extends PDO +{ + /** @var array> */ + public array $rows = []; + public int $lastId = 0; + + public function __construct() + { + } + + public function prepare(string $query, array $options = []): PDOStatement|false + { + return new FakeHostScanStatement($this, $query); + } + + public function lastInsertId(?string $name = null): string|false + { + return (string)$this->lastId; + } +} + +final class FakeHostScanStatement extends PDOStatement +{ + /** @var array|false */ + private array|false $result = false; + + public function __construct( + private FakeHostScanPdo $pdo, + private string $query + ) { + } + + public function execute(?array $params = null): bool + { + $params ??= []; + + if (str_contains($this->query, 'INSERT INTO host_scans')) { + $this->pdo->lastId++; + $params['id'] = $this->pdo->lastId; + $params['created_at'] ??= '2026-05-29 00:00:00'; + $this->pdo->rows[$this->pdo->lastId] = $params; + return true; + } + + if (str_contains($this->query, 'SELECT * FROM host_scans WHERE id = :id')) { + $this->result = $this->pdo->rows[(int)$params['id']] ?? false; + return true; + } + + throw new \RuntimeException('Unexpected query: ' . $this->query); + } + + public function fetch(int $mode = PDO::FETCH_DEFAULT, int $cursorOrientation = PDO::FETCH_ORI_NEXT, int $cursorOffset = 0): mixed + { + return $this->result; + } +} diff --git a/tests/Security/CredentialVaultTest.php b/tests/Security/CredentialVaultTest.php new file mode 100644 index 0000000..c7705d6 --- /dev/null +++ b/tests/Security/CredentialVaultTest.php @@ -0,0 +1,21 @@ +encrypt('secret-password'); + + self::assertNotSame('secret-password', $encrypted); + self::assertSame('secret-password', $vault->decrypt($encrypted)); + } +} diff --git a/tests/Services/LinuxHostScannerTest.php b/tests/Services/LinuxHostScannerTest.php new file mode 100644 index 0000000..50ad00b --- /dev/null +++ b/tests/Services/LinuxHostScannerTest.php @@ -0,0 +1,58 @@ + ['exit_code' => 0, 'stdout' => "server-1\n", 'stderr' => ''], + 'cat /etc/os-release' => ['exit_code' => 0, 'stdout' => "PRETTY_NAME=\"Debian GNU/Linux 12\"\n", 'stderr' => ''], + 'uname -r' => ['exit_code' => 0, 'stdout' => "6.1.0\n", 'stderr' => ''], + 'df -h --output=source,size,used,avail,pcent,target' => ['exit_code' => 0, 'stdout' => "Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 20G 10G 10G 50% /\n", 'stderr' => ''], + 'systemctl --failed --no-pager --plain' => ['exit_code' => 0, 'stdout' => "0 loaded units listed.\n", 'stderr' => ''], + 'crontab -l' => ['exit_code' => 1, 'stdout' => '', 'stderr' => 'no crontab for root'], + ]); + $scanner = new LinuxHostScanner($runner, new CommandWhitelist(), $storagePath); + + $device = new Device(); + $device->id = 5; + $device->primaryIp = '192.168.1.10'; + $credential = new Credential(); + $credential->id = 9; + + $result = $scanner->scan($device, $credential); + + self::assertSame('server-1', $result['summary']['hostname']); + self::assertSame('Debian GNU/Linux 12', $result['summary']['os']); + self::assertSame('6.1.0', $result['summary']['kernel']); + self::assertFileExists($result['raw_path']); + self::assertStringContainsString('hostnamectl', file_get_contents($result['raw_path'])); + } +} + +final class FakeCommandRunner +{ + /** @param array $responses */ + public function __construct(private array $responses) + { + } + + /** @return array{exit_code: int, stdout: string, stderr: string} */ + public function run(Credential $credential, string $host, array $command): array + { + $key = implode(' ', $command); + return $this->responses[$key] ?? ['exit_code' => 127, 'stdout' => '', 'stderr' => 'missing fake response']; + } +} diff --git a/tests/Services/SshCredentialTesterTest.php b/tests/Services/SshCredentialTesterTest.php new file mode 100644 index 0000000..ac68796 --- /dev/null +++ b/tests/Services/SshCredentialTesterTest.php @@ -0,0 +1,49 @@ +username = 'root'; + $credential->authMethod = 'password'; + $credential->encryptedSecret = $vault->encrypt('secret-password'); + + $tester = new SshCredentialTester($vault, function (string $host, int $port, int $timeout) { + self::assertSame('192.168.1.10', $host); + self::assertSame(22, $port); + self::assertSame(5, $timeout); + return new FakeSshClient(); + }, ['connect_timeout' => 5]); + + $result = $tester->test($credential, '192.168.1.10'); + + self::assertSame('ok', $result['status']); + self::assertSame('Подключение успешно', $result['message']); + self::assertSame('root', FakeSshClient::$lastUsername); + self::assertSame('secret-password', FakeSshClient::$lastPassword); + } +} + +final class FakeSshClient +{ + public static ?string $lastUsername = null; + public static ?string $lastPassword = null; + + public function login(string $username, string $password): bool + { + self::$lastUsername = $username; + self::$lastPassword = $password; + return true; + } +}