Выполнение шага 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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user