Skip to content

Commit

Permalink
Starts working on Ssh connections
Browse files Browse the repository at this point in the history
see #10
  • Loading branch information
butschster committed Dec 2, 2023
1 parent 96b7ae1 commit 53a91b5
Show file tree
Hide file tree
Showing 33 changed files with 1,012 additions and 18 deletions.
2 changes: 1 addition & 1 deletion app/modules/Events/Domain/Events/EventWasReceived.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions app/modules/SshTunnel/Application/CommandBuilder.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Application;

use Spiral\Files\FilesInterface;
use Webmozart\Assert\Assert;

final class CommandBuilder
{
private const SSH_SERVER_ALIVE_INTERVAL = 15;

private int $sshPort = 22;
private string $user = 'root';
private ?string $sshHost = null;
private ?string $password = null;
private ?string $privateKey = null;
private array $forwardPorts = [];
private bool $compression = false;

public function __construct(
private readonly FilesInterface $files,
) {
}

public function host(string $host): self
{
Assert::notEmpty($host, 'Host cannot be empty');
$this->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;
}
}
17 changes: 17 additions & 0 deletions app/modules/SshTunnel/Application/SshTunnelBootloader.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Application;

use Spiral\Boot\Bootloader\Bootloader;

final class SshTunnelBootloader extends Bootloader
{
public function defineSingletons(): array
{
return [

];
}
}
74 changes: 74 additions & 0 deletions app/modules/SshTunnel/Application/SshTunnelService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Application;

use App\Application\Domain\ValueObjects\Uuid;
use Spiral\Exceptions\ExceptionReporterInterface;
use Spiral\RoadRunner\Services\Exception\ServiceException;
use Spiral\RoadRunner\Services\Manager;

final class SshTunnelService
{
private const NAME_PREFIX = 'ssh-';

public function __construct(
private readonly Manager $manager,
private readonly ExceptionReporterInterface $reporter,
) {
}

/**
* Check if the tunnel is connected.
*/
public function isRunning(Uuid $connectionUuid): bool
{
return $this->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;
}
}
33 changes: 33 additions & 0 deletions app/modules/SshTunnel/Domain/Connection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Domain;

use App\Application\Domain\ValueObjects\Uuid;
use Cycle\Annotated\Annotation\Column;
use Cycle\Annotated\Annotation\Entity;

#[Entity(
repository: ConnectionRepositoryInterface::class
)]
class Connection
{
public function __construct(
#[Column(type: 'string(36)', primary: true, typecast: 'uuid')]
public Uuid $uuid,
#[Column(type: 'string')]
public string $name,
#[Column(type: 'string')]
public string $host,
#[Column(type: 'string')]
public string $user = 'root',
#[Column(type: 'integer')]
public int $port = 22,
#[Column(type: 'string', nullable: true, default: null)]
public ?string $password = null,
#[Column(type: 'string', nullable: true, default: null)]
public ?string $privateKey = null,
) {
}
}
17 changes: 17 additions & 0 deletions app/modules/SshTunnel/Domain/ConnectionFactoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Domain;

interface ConnectionFactoryInterface
{
public function make(
string $name,
string $host,
string $user = 'root',
int $port = 22,
?string $password = null,
?string $privateKey = null,
): Connection;
}
24 changes: 24 additions & 0 deletions app/modules/SshTunnel/Domain/ConnectionRepositoryInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Domain;

use App\Application\Domain\ValueObjects\Uuid;
use Cycle\ORM\RepositoryInterface;

/**
* @extends RepositoryInterface<Connection>
*/
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;
}
39 changes: 39 additions & 0 deletions app/modules/SshTunnel/Domain/Events/ConnectionStored.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Domain\Events;

use App\Application\Broadcasting\Channel\SettingsChannel;
use App\Application\Broadcasting\ShouldBroadcastInterface;
use Modules\SshTunnel\Domain\Connection;
use Stringable;

final class ConnectionStored implements ShouldBroadcastInterface
{
public function __construct(
public readonly Connection $connection,
) {
}

public function getEventName(): string
{
return 'ssh.connection.stored';
}

public function getBroadcastTopics(): iterable|string|Stringable
{
return new SettingsChannel();
}

public function jsonSerialize(): array
{
return [
'name' => $this->connection->name,
'host' => $this->connection->host,
'user' => $this->connection->user,
'port' => $this->connection->port,
'privateKey' => $this->connection->privateKey,
];
}
}
10 changes: 10 additions & 0 deletions app/modules/SshTunnel/Exception/ConnectionException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Exception;

class ConnectionException extends \DomainException
{

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Exception;

final class ConnectionNotEstablishedException extends ConnectionException
{

}
10 changes: 10 additions & 0 deletions app/modules/SshTunnel/Exception/ConnectionNotFoundException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

declare(strict_types=1);

namespace Modules\SshTunnel\Exception;

final class ConnectionNotFoundException extends ConnectionException
{

}
Loading

0 comments on commit 53a91b5

Please sign in to comment.