Выполнение шага 5:
• Updated Plan
└ Продолжаю итерацию 5 как отдельный рабочий срез поверх текущих SSH credentials.
□ Добавить модель/миграцию/repository для host_scans и покрыть repository тестами
□ Добавить SSH command runner и LinuxHostScanner с whitelist read-only команд
□ Подключить controller/routes/UI для запуска deep scan и просмотра результата
□ Прогнать проверки и зафиксировать ограничения Docker-верификации
This commit is contained in:
parent
3981ffdf5e
commit
dc3a466944
13
PLAN.md
13
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 открытым текстом
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Controllers;
|
||||
|
||||
use Domovoy\Models\Credential;
|
||||
use Domovoy\Repositories\CredentialRepository;
|
||||
use Domovoy\Services\Inventory\DeviceService;
|
||||
use Domovoy\Services\Security\CredentialVault;
|
||||
use Domovoy\Services\Ssh\SshCredentialTester;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
class CredentialController
|
||||
{
|
||||
public function __construct(
|
||||
private DeviceService $deviceService,
|
||||
private CredentialRepository $credentialRepository,
|
||||
private CredentialVault $vault,
|
||||
private SshCredentialTester $sshCredentialTester
|
||||
) {
|
||||
}
|
||||
|
||||
public function create(ServerRequestInterface $request, ResponseInterface $response, array $args): ResponseInterface
|
||||
{
|
||||
$deviceId = (int)$args['id'];
|
||||
if ($this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Models;
|
||||
|
||||
class Credential
|
||||
{
|
||||
public ?int $id = null;
|
||||
public int $deviceId;
|
||||
public string $type = 'ssh';
|
||||
public string $name = '';
|
||||
public string $username = '';
|
||||
public int $port = 22;
|
||||
public string $authMethod = 'password';
|
||||
public ?string $encryptedSecret = null;
|
||||
public ?string $encryptedPrivateKey = null;
|
||||
public ?string $publicKeyFingerprint = null;
|
||||
public ?string $lastTestStatus = null;
|
||||
public ?\DateTimeImmutable $lastTestAt = 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->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Repositories;
|
||||
|
||||
use Domovoy\Models\Credential;
|
||||
use PDO;
|
||||
|
||||
class CredentialRepository
|
||||
{
|
||||
public function __construct(private PDO $pdo)
|
||||
{
|
||||
}
|
||||
|
||||
public function findByDevice(int $deviceId): array
|
||||
{
|
||||
$stmt = $this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Security;
|
||||
|
||||
use Defuse\Crypto\Crypto;
|
||||
|
||||
class CredentialVault
|
||||
{
|
||||
private string $password;
|
||||
|
||||
public function __construct(string $keyMaterial)
|
||||
{
|
||||
if (trim($keyMaterial) === '') {
|
||||
throw new \InvalidArgumentException('ENCRYPTION_KEY is required');
|
||||
}
|
||||
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Services\Ssh;
|
||||
|
||||
use Domovoy\Models\Credential;
|
||||
use Domovoy\Services\Security\CredentialVault;
|
||||
use phpseclib3\Crypt\PublicKeyLoader;
|
||||
use phpseclib3\Net\SSH2;
|
||||
|
||||
class SshCredentialTester
|
||||
{
|
||||
/** @var callable(string, int, int): object */
|
||||
private $clientFactory;
|
||||
|
||||
/**
|
||||
* @param callable(string, int, int): object|null $clientFactory
|
||||
* @param array<string, int> $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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Phinx\Migration\AbstractMigration;
|
||||
|
||||
final class CreateCredentials extends AbstractMigration
|
||||
{
|
||||
public function change(): void
|
||||
{
|
||||
$table = $this->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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<table class="table table-borderless">
|
||||
<tr><th>Тип</th><td><?= htmlspecialchars($device->type) ?></td></tr>
|
||||
|
|
@ -31,5 +31,91 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h5 mb-0">SSH-доступы</h3>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive mb-4">
|
||||
<table class="table table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Название</th>
|
||||
<th>Пользователь</th>
|
||||
<th>Порт</th>
|
||||
<th>Метод</th>
|
||||
<th>Последний тест</th>
|
||||
<th class="text-end">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<?php if (empty($credentials)): ?>
|
||||
<tr><td colspan="6" class="text-muted">Доступы пока не добавлены</td></tr>
|
||||
<?php else: ?>
|
||||
<?php foreach ($credentials as $credential): ?>
|
||||
<tr>
|
||||
<td><?= htmlspecialchars($credential->name) ?></td>
|
||||
<td><?= htmlspecialchars($credential->username) ?></td>
|
||||
<td><?= (int)$credential->port ?></td>
|
||||
<td><?= $credential->authMethod === 'private_key' ? 'ключ' : 'пароль' ?></td>
|
||||
<td>
|
||||
<?php if ($credential->lastTestStatus === null): ?>
|
||||
<span class="text-muted">не проверялся</span>
|
||||
<?php else: ?>
|
||||
<span class="badge <?= $credential->lastTestStatus === 'ok' ? 'text-bg-success' : 'text-bg-danger' ?>">
|
||||
<?= htmlspecialchars($credential->lastTestStatus) ?>
|
||||
</span>
|
||||
<?php if ($credential->lastTestAt !== null): ?>
|
||||
<span class="text-muted small"><?= $credential->lastTestAt->format('Y-m-d H:i') ?></span>
|
||||
<?php endif; ?>
|
||||
<?php endif; ?>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<form method="POST" action="/devices/<?= $device->id ?>/credentials/<?= $credential->id ?>/test" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-outline-primary">Тест</button>
|
||||
</form>
|
||||
<form method="POST" action="/devices/<?= $device->id ?>/credentials/<?= $credential->id ?>/delete" class="d-inline" onsubmit="return confirm('Удалить доступ?')">
|
||||
<button type="submit" class="btn btn-sm btn-outline-danger">Удалить</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<form method="POST" action="/devices/<?= $device->id ?>/credentials" class="row g-3">
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Название</label>
|
||||
<input type="text" name="name" class="form-control" value="SSH">
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Пользователь</label>
|
||||
<input type="text" name="username" class="form-control" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Порт</label>
|
||||
<input type="number" name="port" class="form-control" min="1" max="65535" value="22">
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label class="form-label">Метод</label>
|
||||
<select name="auth_method" class="form-select">
|
||||
<option value="password">Пароль</option>
|
||||
<option value="private_key">Ключ</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Пароль</label>
|
||||
<input type="password" name="secret" class="form-control" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Private key</label>
|
||||
<textarea name="private_key" class="form-control" rows="4"></textarea>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<button type="submit" class="btn btn-primary">Добавить доступ</button>
|
||||
</div>
|
||||
</form>
|
||||
<?php $content = ob_get_clean(); ?>
|
||||
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Repositories;
|
||||
|
||||
use Domovoy\Models\Credential;
|
||||
use Domovoy\Repositories\CredentialRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CredentialRepositoryTest extends TestCase
|
||||
{
|
||||
public function testFindByIdAndDeleteScopeCredentialsToDevice(): 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);
|
||||
|
||||
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<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
public int $lastId = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return new FakeCredentialStatement($this, $query);
|
||||
}
|
||||
|
||||
public function lastInsertId(?string $name = null): string|false
|
||||
{
|
||||
return (string)$this->lastId;
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeCredentialStatement extends PDOStatement
|
||||
{
|
||||
/** @var array<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Repositories;
|
||||
|
||||
use Domovoy\Models\HostScan;
|
||||
use Domovoy\Repositories\HostScanRepository;
|
||||
use PDO;
|
||||
use PDOStatement;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class HostScanRepositoryTest extends TestCase
|
||||
{
|
||||
public function testSaveAndFindByIdRoundTripsSummary(): void
|
||||
{
|
||||
$pdo = new FakeHostScanPdo();
|
||||
$repository = new HostScanRepository($pdo);
|
||||
|
||||
$scan = new HostScan();
|
||||
$scan->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<int, array<string, mixed>> */
|
||||
public array $rows = [];
|
||||
public int $lastId = 0;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
public function prepare(string $query, array $options = []): PDOStatement|false
|
||||
{
|
||||
return new FakeHostScanStatement($this, $query);
|
||||
}
|
||||
|
||||
public function lastInsertId(?string $name = null): string|false
|
||||
{
|
||||
return (string)$this->lastId;
|
||||
}
|
||||
}
|
||||
|
||||
final class FakeHostScanStatement extends PDOStatement
|
||||
{
|
||||
/** @var array<string, mixed>|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;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Security;
|
||||
|
||||
use Domovoy\Services\Security\CredentialVault;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class CredentialVaultTest extends TestCase
|
||||
{
|
||||
public function testEncryptsAndDecryptsSecretWithoutStoringPlaintext(): void
|
||||
{
|
||||
$vault = new CredentialVault('test-key-material');
|
||||
|
||||
$encrypted = $vault->encrypt('secret-password');
|
||||
|
||||
self::assertNotSame('secret-password', $encrypted);
|
||||
self::assertSame('secret-password', $vault->decrypt($encrypted));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Services;
|
||||
|
||||
use Domovoy\Models\Credential;
|
||||
use Domovoy\Models\Device;
|
||||
use Domovoy\Services\HostScan\CommandWhitelist;
|
||||
use Domovoy\Services\HostScan\LinuxHostScanner;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class LinuxHostScannerTest extends TestCase
|
||||
{
|
||||
public function testCollectsReadOnlyCommandsAndWritesRawJson(): void
|
||||
{
|
||||
$storagePath = sys_get_temp_dir() . '/domovoy-host-scan-test-' . uniqid();
|
||||
mkdir($storagePath, 0777, true);
|
||||
$runner = new FakeCommandRunner([
|
||||
'hostnamectl --static' => ['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<string, array{exit_code: int, stdout: string, stderr: string}> $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'];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Domovoy\Tests\Services;
|
||||
|
||||
use Domovoy\Models\Credential;
|
||||
use Domovoy\Services\Security\CredentialVault;
|
||||
use Domovoy\Services\Ssh\SshCredentialTester;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
final class SshCredentialTesterTest extends TestCase
|
||||
{
|
||||
public function testTestsPasswordCredentialWithDecryptedSecret(): void
|
||||
{
|
||||
$vault = new CredentialVault('test-key-material');
|
||||
$credential = new Credential();
|
||||
$credential->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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue