diff --git a/app/modules/Events/Domain/Events/EventWasReceived.php b/app/modules/Events/Domain/Events/EventWasReceived.php index 1e56f60e..e745237a 100644 --- a/app/modules/Events/Domain/Events/EventWasReceived.php +++ b/app/modules/Events/Domain/Events/EventWasReceived.php @@ -22,7 +22,7 @@ public function __construct( public function jsonSerialize(): array { return [ - 'projectId' => $this->projectId, + 'project_id' => $this->projectId, 'uuid' => (string)$this->uuid, 'type' => $this->type, 'payload' => $this->payload, diff --git a/app/modules/SshTunnel/Application/CommandBuilder.php b/app/modules/SshTunnel/Application/CommandBuilder.php new file mode 100644 index 00000000..2f744b6a --- /dev/null +++ b/app/modules/SshTunnel/Application/CommandBuilder.php @@ -0,0 +1,130 @@ +sshHost = $host; + + return $this; + } + + public function user(string $user): self + { + Assert::notEmpty($user, 'User cannot be empty'); + $this->user = $user; + + return $this; + } + + public function sshPort(int $port): self + { + Assert::greaterThanEq($port, 1, 'SSH port cannot be less than 1'); + Assert::lessThan($port, 65536, 'SSH port cannot be greater than 65535'); + + $this->sshPort = $port; + + return $this; + } + + public function password(string $password): self + { + Assert::notEmpty($password, 'Password cannot be empty'); + $this->password = $password; + + return $this; + } + + public function privateKey(string $privateKey): self + { + Assert::notEmpty($privateKey, 'Private key cannot be empty'); + $this->privateKey = $this->writeKeyToFile($privateKey); + \usleep(500_000); + + return $this; + } + + public function forwardPort(int $localPort, int $remotePort): self + { + $this->forwardPorts[] = [ + 'localPort' => $localPort, + 'remotePort' => $remotePort, + ]; + + return $this; + } + + public function build(): array + { + Assert::true($this->password !== null || $this->privateKey !== null, 'Password or private key must be set'); + + $cmd = [ + 'ssh', + '-p', + $this->sshPort, + ...\array_map( + callback: fn(array $port) => \sprintf( + '-R %d:%s:%d', + $port['localPort'], + 'localhost', + $port['remotePort'], + ), + array: $this->forwardPorts, + ), + '-i', + $this->privateKey, + '-N', + '-o', + \sprintf('ServerAliveInterval=%d', self::SSH_SERVER_ALIVE_INTERVAL), + '-o', + 'ExitOnForwardFailure=yes', + '-o', + 'StrictHostKeyChecking=no', + \sprintf('%s@%s', $this->user, $this->sshHost), + ]; + + if ($this->compression) { + $cmd[] = '-C'; + } + + return $cmd; + } + + private function writeKeyToFile(string $key): string + { + $fileName = (string)\tempnam('/tmp/', 'ssh-key-'); + + $this->files->append($fileName, $key); + $this->files->setPermissions($fileName, 0400); + + return (string)\realpath($fileName); + } + + public function getPrivateKey(): ?string + { + return $this->privateKey; + } +} diff --git a/app/modules/SshTunnel/Application/SshTunnelBootloader.php b/app/modules/SshTunnel/Application/SshTunnelBootloader.php new file mode 100644 index 00000000..7ac4ecf5 --- /dev/null +++ b/app/modules/SshTunnel/Application/SshTunnelBootloader.php @@ -0,0 +1,17 @@ +manager->statuses($this->getTunnelName($connectionUuid)) !== []; + } + + /** + * Get the list of all connected tunnels. + */ + public function list(): array + { + return \array_map( + callback: fn(string $name) => $this->manager->statuses($name), + array: \array_filter( + $this->manager->list(), + static fn(string $service) => \str_starts_with($service, self::NAME_PREFIX), + ), + ); + } + + /** + * Connect to the remote server. + */ + public function connect(Uuid $connectionUuid): void + { + $this->manager->create( + name: $this->getTunnelName($connectionUuid), + command: \sprintf('php app.php ssh:tunnel %s', $connectionUuid), + processNum: 1, + remainAfterExit: true, + serviceNameInLogs: true, + ); + } + + /** + * Disconnect from the remote server. + */ + public function disconnect(Uuid $connectionUuid): void + { + try { + $this->manager->terminate($this->getTunnelName($connectionUuid)); + } catch (ServiceException $e) { + $this->reporter->report($e); + } + } + + private function getTunnelName(Uuid $connectionUuid): string + { + return self::NAME_PREFIX . $connectionUuid; + } +} diff --git a/app/modules/SshTunnel/Domain/Connection.php b/app/modules/SshTunnel/Domain/Connection.php new file mode 100644 index 00000000..39d58563 --- /dev/null +++ b/app/modules/SshTunnel/Domain/Connection.php @@ -0,0 +1,33 @@ + + */ +interface ConnectionRepositoryInterface extends RepositoryInterface +{ + /** + * Create connection entity. + */ + public function store(Connection $connection): bool; + + /** + * Delete connection entity by primary key. + */ + public function deleteByPK(Uuid $uuid): bool; +} diff --git a/app/modules/SshTunnel/Domain/Events/ConnectionStored.php b/app/modules/SshTunnel/Domain/Events/ConnectionStored.php new file mode 100644 index 00000000..feba8172 --- /dev/null +++ b/app/modules/SshTunnel/Domain/Events/ConnectionStored.php @@ -0,0 +1,39 @@ + $this->connection->name, + 'host' => $this->connection->host, + 'user' => $this->connection->user, + 'port' => $this->connection->port, + 'privateKey' => $this->connection->privateKey, + ]; + } +} diff --git a/app/modules/SshTunnel/Exception/ConnectionException.php b/app/modules/SshTunnel/Exception/ConnectionException.php new file mode 100644 index 00000000..fc0d43c6 --- /dev/null +++ b/app/modules/SshTunnel/Exception/ConnectionException.php @@ -0,0 +1,10 @@ +connections->findByPK($command->connectionUuid); + + if ($connection === null) { + throw new ConnectionNotFoundException('Connection not found'); + } + + try { + $this->service->disconnect($connection); + } catch (\Throwable $e) { + throw new ConnectionException(previous: $e); + } + } +} diff --git a/app/modules/SshTunnel/Interfaces/Commands/ConnectHandler.php b/app/modules/SshTunnel/Interfaces/Commands/ConnectHandler.php new file mode 100644 index 00000000..fe10909b --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Commands/ConnectHandler.php @@ -0,0 +1,37 @@ +connections->findByPK($command->connectionUuid); + + if ($connection === null) { + throw new ConnectionNotFoundException('Connection not found'); + } + + try { + $this->service->connect($connection); + } catch (\Throwable $e) { + throw new ConnectionNotEstablishedException(previous: $e); + } + } +} diff --git a/app/modules/SshTunnel/Interfaces/Commands/DeleteConnectionHandler.php b/app/modules/SshTunnel/Interfaces/Commands/DeleteConnectionHandler.php new file mode 100644 index 00000000..7d2bc9f9 --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Commands/DeleteConnectionHandler.php @@ -0,0 +1,23 @@ +connections->deleteByPK($command->connectionUuid); + } +} diff --git a/app/modules/SshTunnel/Interfaces/Commands/StoreConnectionHandler.php b/app/modules/SshTunnel/Interfaces/Commands/StoreConnectionHandler.php new file mode 100644 index 00000000..16cf5069 --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Commands/StoreConnectionHandler.php @@ -0,0 +1,41 @@ +factory->make( + name: $command->name, + host: $command->host, + user: $command->user, + port: $command->port, + password: $command->password, + privateKey: $command->privateKey, + ); + + $this->connections->store($connection); + + $this->dispatcher->dispatch( + new ConnectionStored(connection: $connection), + ); + } +} diff --git a/app/modules/SshTunnel/Interfaces/Console/SshTunnelCommand.php b/app/modules/SshTunnel/Interfaces/Console/SshTunnelCommand.php new file mode 100644 index 00000000..b65cf5f4 --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Console/SshTunnelCommand.php @@ -0,0 +1,87 @@ +findByPK($this->connectionUuid); + + if (!$connection) { + $this->error('Connection not found'); + return self::FAILURE; + } + + $command = $builder->host($connection->host) + ->sshPort($connection->port) + ->user($connection->user) + ->privateKey($connection->privateKey) + ->forwardPort(8000, 8000) + ->forwardPort(1025, 1025) + ->forwardPort(9913, 9913) + ->forwardPort(9912, 9912) + ->build(); + + if ($this->isVerbose()) { + $this->info('SSH command:'); + $this->comment(\implode(' ', $command)); + } + + $this->provateKeyPath = $builder->getPrivateKey(); + + $this->process = new Process(command: $command, timeout: null); + $this->process->start(function () { + $this->writeln('SSH tunnel started'); + }); + + $this->process->wait(function ($type, $buffer) { + $this->writeln($buffer); + }); + + return self::SUCCESS; + } + + public function getSubscribedSignals(): array + { + return [ + SIGINT, + SIGTERM, + ]; + } + + public function handleSignal(int $signal,): int|false + { + $this->process->stop(); + + $this->warning('SSH tunnel stopped'); + + if ($this->provateKeyPath) { + unlink($this->provateKeyPath); + $this->warning('SSH private key deleted'); + } + + return false; + } +} diff --git a/app/modules/SshTunnel/Interfaces/Http/Controllers/DeleteAction.php b/app/modules/SshTunnel/Interfaces/Http/Controllers/DeleteAction.php new file mode 100644 index 00000000..5b96c617 --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Http/Controllers/DeleteAction.php @@ -0,0 +1,27 @@ +', name: 'ssh.delete', methods: 'DELETE', group: 'api')] + public function __invoke(CommandBusInterface $bus, Uuid $uuid): ResourceInterface + { + $bus->dispatch( + new DeleteSshConnection( + connectionUuid: $uuid, + ), + ); + + return new SuccessResource(); + } +} diff --git a/app/modules/SshTunnel/Interfaces/Http/Controllers/ShowAction.php b/app/modules/SshTunnel/Interfaces/Http/Controllers/ShowAction.php new file mode 100644 index 00000000..3d6e86c6 --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Http/Controllers/ShowAction.php @@ -0,0 +1,33 @@ +', name: 'ssh.show', methods: 'GET', group: 'api')] + public function __invoke(QueryBusInterface $bus, Uuid $uuid): ResourceInterface + { + try { + return new SshConnectionResource( + $bus->ask( + new FindSshConnectionByUuid( + uuid: $uuid, + ), + ), + ); + } catch (EntityNotFoundException $e) { + throw new NotFoundException($e->getMessage()); + } + } +} diff --git a/app/modules/SshTunnel/Interfaces/Http/Controllers/StoreAction.php b/app/modules/SshTunnel/Interfaces/Http/Controllers/StoreAction.php new file mode 100644 index 00000000..118bb72e --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Http/Controllers/StoreAction.php @@ -0,0 +1,31 @@ +dispatch( + new StoreSshConnection( + name: $request->name, + host: $request->host, + user: $request->user, + port: $request->port, + privateKey: $request->privateKey, + ), + ); + + return new SuccessResource(); + } +} diff --git a/app/modules/SshTunnel/Interfaces/Http/Request/StoreRequest.php b/app/modules/SshTunnel/Interfaces/Http/Request/StoreRequest.php new file mode 100644 index 00000000..23750871 --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Http/Request/StoreRequest.php @@ -0,0 +1,47 @@ + ['required', 'string'], + 'host' => ['required', 'string'], + 'user' => ['required', 'string'], + 'port' => ['int'], + 'privateKey' => ['required', 'string'], + ]); + } +} diff --git a/app/modules/SshTunnel/Interfaces/Http/Resources/SshConnectionResource.php b/app/modules/SshTunnel/Interfaces/Http/Resources/SshConnectionResource.php new file mode 100644 index 00000000..e552fa5e --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Http/Resources/SshConnectionResource.php @@ -0,0 +1,31 @@ + (string)$this->data->uuid, + 'name' => $this->data->name, + 'host' => $this->data->host, + 'port' => $this->data->port, + 'user' => $this->data->user, + 'private_key' => $this->data->privateKey, + ]; + } +} diff --git a/app/modules/SshTunnel/Interfaces/Queries/FindSshConnectionByUuidHandler.php b/app/modules/SshTunnel/Interfaces/Queries/FindSshConnectionByUuidHandler.php new file mode 100644 index 00000000..f61011eb --- /dev/null +++ b/app/modules/SshTunnel/Interfaces/Queries/FindSshConnectionByUuidHandler.php @@ -0,0 +1,32 @@ +connections->findByPK((string)$query->uuid); + if (!$connection) { + throw new EntityNotFoundException( + \sprintf('Connection with given uuid [%s] was not found.', (string)$query->uuid), + ); + } + + return $connection; + } +} diff --git a/app/modules/SshTunnel/Mapper/JsonConnectionMapper.php b/app/modules/SshTunnel/Mapper/JsonConnectionMapper.php new file mode 100644 index 00000000..25ae4ebe --- /dev/null +++ b/app/modules/SshTunnel/Mapper/JsonConnectionMapper.php @@ -0,0 +1,37 @@ + (string)$connection->uuid, + 'name' => $connection->name, + 'host' => $connection->host, + 'user' => $connection->user, + 'port' => $connection->port, + 'password' => $connection->password, + 'privateKey' => $connection->privateKey, + ]; + } +} diff --git a/app/src/Application/Bootloader/PersistenceBootloader.php b/app/src/Application/Bootloader/PersistenceBootloader.php index 560c2251..cd019d68 100644 --- a/app/src/Application/Bootloader/PersistenceBootloader.php +++ b/app/src/Application/Bootloader/PersistenceBootloader.php @@ -4,6 +4,7 @@ namespace App\Application\Bootloader; +use App\Application\Persistence\ArraySshConnectionRepository; use App\Application\Persistence\CacheEventRepository; use App\Application\Persistence\CycleOrmEventRepository; use App\Application\Persistence\MongoDBEventRepository; @@ -12,7 +13,10 @@ use Cycle\ORM\Select; use Modules\Events\Domain\Event; use Modules\Events\Domain\EventRepositoryInterface; +use Modules\SshTunnel\Domain\Connection; +use Modules\SshTunnel\Domain\ConnectionRepositoryInterface; use MongoDB\Database; +use Ramsey\Uuid\Uuid; use Spiral\Boot\Bootloader\Bootloader; use Spiral\Boot\EnvironmentInterface; use Spiral\Cache\CacheStorageProviderInterface; @@ -20,22 +24,28 @@ final class PersistenceBootloader extends Bootloader { - protected const SINGLETONS = [ - EventRepositoryInterface::class => [self::class, 'createRepository'], - CycleOrmEventRepository::class => [self::class, 'createCycleOrmEventRepository'], - MongoDBEventRepository::class => [self::class, 'createMongoDBEventRepository'], - CacheEventRepository::class => [self::class, 'createCacheEventRepository'], - ]; + public function defineSingletons(): array + { + return [ + // Events + EventRepositoryInterface::class => [self::class, 'creatEventRepository'], + CycleOrmEventRepository::class => [self::class, 'createCycleOrmEventRepository'], + MongoDBEventRepository::class => [self::class, 'createMongoDBEventRepository'], + CacheEventRepository::class => [self::class, 'createCacheEventRepository'], - private function createCacheEventRepository( - CacheStorageProviderInterface $provider, - ): EventRepositoryInterface { - return new CacheEventRepository($provider); + // SSH Tunnel + ConnectionRepositoryInterface::class => [self::class, 'creatConnectionRepository'], + ]; + } + + private function creatConnectionRepository(): ConnectionRepositoryInterface + { + return new ArraySshConnectionRepository([]); } - private function createRepository( + private function creatEventRepository( FactoryInterface $factory, - EnvironmentInterface $env + EnvironmentInterface $env, ): EventRepositoryInterface { return match ($env->get('PERSISTENCE_DRIVER', 'cache')) { 'cycle' => $factory->make(CycleOrmEventRepository::class), @@ -45,9 +55,15 @@ private function createRepository( }; } + private function createCacheEventRepository( + CacheStorageProviderInterface $provider, + ): EventRepositoryInterface { + return new CacheEventRepository($provider); + } + private function createCycleOrmEventRepository( ORMInterface $orm, - EntityManagerInterface $manager + EntityManagerInterface $manager, ): CycleOrmEventRepository { return new CycleOrmEventRepository($manager, new Select($orm, Event::class)); } @@ -55,7 +71,7 @@ private function createCycleOrmEventRepository( private function createMongoDBEventRepository(Database $database): MongoDBEventRepository { return new MongoDBEventRepository( - $database->selectCollection('events') + $database->selectCollection('events'), ); } } diff --git a/app/src/Application/Broadcasting/Channel/SettingsChannel.php b/app/src/Application/Broadcasting/Channel/SettingsChannel.php new file mode 100644 index 00000000..d602dac9 --- /dev/null +++ b/app/src/Application/Broadcasting/Channel/SettingsChannel.php @@ -0,0 +1,13 @@ +connections[(string)$connection->uuid] = $connection; + return true; + } + + public function deleteByPK(Uuid $uuid): bool + { + if (!isset($this->connections[(string)$uuid])) { + return false; + } + + unset($this->connections[(string)$uuid]); + return true; + } + + /** + * @param Uuid|string $id + */ + public function findByPK(mixed $id): ?object + { + if (!isset($this->connections[(string)$id])) { + return null; + } + + return $this->connections[(string)$id]; + } + + public function findOne(array $scope = []): ?object + { + throw new \BadMethodCallException('Not available'); + } + + public function findAll(array $scope = []): iterable + { + return $this->connections; + } +} diff --git a/app/src/Interfaces/Centrifugo/ConnectService.php b/app/src/Interfaces/Centrifugo/ConnectService.php index 1483375e..611fe9af 100644 --- a/app/src/Interfaces/Centrifugo/ConnectService.php +++ b/app/src/Interfaces/Centrifugo/ConnectService.php @@ -4,6 +4,8 @@ namespace App\Interfaces\Centrifugo; +use App\Application\Broadcasting\Channel\EventsChannel; +use App\Application\Broadcasting\Channel\SettingsChannel; use RoadRunner\Centrifugo\Payload\ConnectResponse; use RoadRunner\Centrifugo\Request; use RoadRunner\Centrifugo\Request\RequestInterface; @@ -19,9 +21,12 @@ public function handle(RequestInterface $request): void try { $request->respond( new ConnectResponse( - user: (string) $request->getAttribute('user_id'), - channels: ['events'], - ) + user: (string)$request->getAttribute('user_id'), + channels: [ + (string)new EventsChannel(), + (string)new SettingsChannel(), + ], + ), ); } catch (\Throwable $e) { $request->error($e->getCode(), $e->getMessage()); diff --git a/composer.json b/composer.json index 6d98bb4b..125a93c6 100644 --- a/composer.json +++ b/composer.json @@ -41,8 +41,10 @@ "spiral/framework": "^3.10", "spiral/nyholm-bridge": "^1.3", "spiral/roadrunner-bridge": "^3.0", + "spiral/roadrunner-services": "^2.1", "spiral/validator": "^1.1", "symfony/mime": "^6.2", + "symfony/process": "^6.3", "symfony/var-dumper": "^6.1", "zbateson/mail-mime-parser": "^2.0" },