Skip to content

Commit

Permalink
refactor: ssh/sftp connections
Browse files Browse the repository at this point in the history
  • Loading branch information
lewislarsen authored Jul 25, 2024
1 parent b77de9f commit 6edd815
Show file tree
Hide file tree
Showing 28 changed files with 2,409 additions and 255 deletions.
6 changes: 6 additions & 0 deletions .git-blame-ignore-revs
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,9 @@ cfebd7374679440f4f65ae9a9834e468661884ac
804ea8cdef7c8fd0147edd1a51395c496f4277f4
3ed4621268fc5a9310b57d4768c72451d2472522
2840bdb93070016aecea2b31f9473c697b834bb2
ae756aa79581fcb25834604f29eff9e9bbe5abc4
dc4da567a2683bac3e0f4ae3384b3f03d2acbdde
8e43a02d7dc3a0cb5fe04301f31d5f77e1755391
92fead4d79fc6f51a20ad953a7e96ff2e8c5daa1
a2aeba000de332321193ca3261420fa02ab6bf48
01a0e2a1aef33b18be5e886745c577d9be510402
1 change: 1 addition & 0 deletions .github/workflows/pest-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ jobs:
run: |
cp .env.example.ci .env
php artisan key:generate
php artisan vanguard:generate-ssh-key
- name: Cache Laravel application
uses: actions/cache@v4
Expand Down
145 changes: 91 additions & 54 deletions app/Actions/RemoteServer/CheckRemoteServerConnection.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,110 +5,147 @@
namespace App\Actions\RemoteServer;

use App\Events\RemoteServerConnectivityStatusChanged;
use App\Facades\ServerConnection;
use App\Models\RemoteServer;
use App\Support\ServerConnection\Exceptions\ConnectionException;
use Exception;
use Illuminate\Support\Facades\Log;
use phpseclib3\Crypt\Common\PrivateKey;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
use RuntimeException;

/**
* Manages remote server connection checks
*
* This class is responsible for verifying the connectivity status of remote servers.
* It can perform checks using either a server ID or connection details provided directly.
*/
class CheckRemoteServerConnection
{
/**
* @return array<string, mixed>
* Check connection status of a remote server by its ID
*
* @throws Exception
* Retrieves the server by ID and initiates a connection check.
*
* @param int $remoteServerId The ID of the remote server to check
* @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results
*
* @throws Exception If the server cannot be found or connection fails unexpectedly
*/
public function byRemoteServerId(int $remoteServerId): array
{
$remoteServer = RemoteServer::findOrFail($remoteServerId);

Log::debug('[Server Connection Check] Beginning connection check of server.', ['id' => $remoteServer->id]);

return $this->checkServerConnection([
'host' => $remoteServer->ip_address,
'port' => $remoteServer->port,
'username' => $remoteServer->username,
], $remoteServer);
return $this->checkServerConnection($remoteServer);
}

/**
* @param array<string, mixed> $remoteServerConnectionDetails
* @return array<string, mixed>
* Check connection status using provided server connection details
*
* Creates a temporary RemoteServer instance and initiates a connection check.
*
* @throws Exception
* @param array<string, mixed> $remoteServerConnectionDetails Connection details including host, port, and username
* @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results
*
* @throws Exception If required connection details are missing or connection fails unexpectedly
*/
public function byRemoteServerConnectionDetails(array $remoteServerConnectionDetails): array
{
Log::debug('[Server Connection Check] Beginning connection check of server by connection details.', ['connection_details' => $remoteServerConnectionDetails]);

return $this->checkServerConnection($remoteServerConnectionDetails);
if (! isset($remoteServerConnectionDetails['host'], $remoteServerConnectionDetails['port'], $remoteServerConnectionDetails['username'])) {
throw new RuntimeException('Missing required data to check server connection. Ensure host, port, and username are provided.');
}

$remoteServer = new RemoteServer([
'ip_address' => $remoteServerConnectionDetails['host'],
'port' => $remoteServerConnectionDetails['port'],
'username' => $remoteServerConnectionDetails['username'],
]);

return $this->checkServerConnection($remoteServer);
}

/**
* @param array<string, mixed> $data
* @return array<string, mixed>
* Perform the actual server connection check
*
* Attempts to establish an SSH connection to the server and updates its status accordingly.
*
* @param RemoteServer $remoteServer The server to check
* @return array{status: string, connectivity_status: string, message?: string, error?: string} Connection check results
*
* @throws Exception
* @throws Exception If connection fails unexpectedly
*/
private function checkServerConnection(array $data, ?RemoteServer $remoteServer = null): array
private function checkServerConnection(RemoteServer $remoteServer): array
{
if (! array_key_exists('host', $data) || ! array_key_exists('port', $data) || ! array_key_exists('username', $data)) {
throw new RuntimeException('Missing required data to check server connection. Ensure host, port, and username are provided.');
}

try {
/** @var PrivateKey $key */
$key = PublicKeyLoader::load(get_ssh_private_key(), config('app.ssh.passphrase'));
$connection = ServerConnection::connectFromModel($remoteServer)
->establish();

$ssh2 = new SSH2($data['host'], $data['port']);
if ($connection->connected()) {
$this->updateServerStatus($remoteServer, RemoteServer::STATUS_ONLINE);

// Attempt to login
if (! $ssh2->login($data['username'], $key)) {
Log::debug('[Server Connection Check] Failed to connect to remote server', ['error' => $ssh2->getLastError()]);

$remoteServer?->update([
'connectivity_status' => RemoteServer::STATUS_OFFLINE,
]);
Log::debug('[Server Connection Check] Successfully connected to remote server');

return [
'error' => $ssh2->getLastError(),
'status' => 'error',
'status' => 'success',
'connectivity_status' => RemoteServer::STATUS_ONLINE,
'message' => 'Successfully connected to remote server',
];
}

$remoteServer?->update([
'connectivity_status' => RemoteServer::STATUS_ONLINE,
]);
// If we reach here, the connection was established but not explicitly connected
$this->updateServerStatus($remoteServer, RemoteServer::STATUS_OFFLINE);

Log::debug('[Server Connection Check] Successfully connected to remote server');

if ($remoteServer instanceof RemoteServer) {
Log::debug('[Server Connection Check] Dispatching RemoteServerConnectivityStatusChanged event (result: ' . $remoteServer->getAttribute('connectivity_status') . ')', ['remote_server' => $remoteServer]);
RemoteServerConnectivityStatusChanged::dispatch($remoteServer, $remoteServer->getAttribute('connectivity_status'));
}
Log::debug('[Server Connection Check] Connection established but not explicitly connected');

return [
'status' => 'success',
'status' => 'error',
'connectivity_status' => RemoteServer::STATUS_OFFLINE,
'message' => 'Connection established but not explicitly connected',
'error' => 'Connection not fully established',
];

} catch (Exception $exception) {
Log::error('[Server Connection Check] Failed to connect to remote server', ['error' => $exception->getMessage()]);

$remoteServer?->update([
'connectivity_status' => RemoteServer::STATUS_OFFLINE,
} catch (ConnectionException $exception) {
Log::info('[Server Connection Check] Unable to connect to remote server (offline)', [
'error' => $exception->getMessage(),
'server_id' => $remoteServer->getAttribute('id'),
]);

if ($remoteServer instanceof RemoteServer) {
Log::debug('[Server Connection Check] Dispatching RemoteServerConnectivityStatusChanged event (result: ' . $remoteServer->getAttribute('connectivity_status') . ')', ['remote_server' => $remoteServer]);
RemoteServerConnectivityStatusChanged::dispatch($remoteServer, $remoteServer->getAttribute('connectivity_status'));
}
$this->updateServerStatus($remoteServer, RemoteServer::STATUS_OFFLINE);

return [
'error' => $exception->getMessage(),
'status' => 'error',
'connectivity_status' => RemoteServer::STATUS_OFFLINE,
'message' => 'Server is offline or unreachable',
'error' => $exception->getMessage(),
];
} finally {
if (isset($connection)) {
$connection->disconnect();
}
}
}

/**
* Update the connectivity status of a remote server
*
* Updates the server's status in the database and dispatches a status change event.
*
* @param RemoteServer $remoteServer The server to update
* @param string $status The new connectivity status
*/
private function updateServerStatus(RemoteServer $remoteServer, string $status): void
{
if (! $remoteServer->exists()) {
return;
}

$remoteServer->update([
'connectivity_status' => $status,
]);

Log::debug('[Server Connection Check] Dispatching RemoteServerConnectivityStatusChanged event (result: ' . $status . ')', ['remote_server' => $remoteServer]);
RemoteServerConnectivityStatusChanged::dispatch($remoteServer, $status);
}
}
55 changes: 55 additions & 0 deletions app/Facades/ServerConnection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace App\Facades;

use App\Models\RemoteServer;
use App\Support\ServerConnection\Fakes\ServerConnectionFake;
use App\Support\ServerConnection\PendingConnection;
use App\Support\ServerConnection\ServerConnectionManager;
use Illuminate\Support\Facades\Facade;

/**
* Facade for ServerConnectionManager.
*
* This facade provides a static interface to the ServerConnectionManager,
* allowing for easy access to server connection functionality throughout the application.
*
* @method static PendingConnection connect(string $host = '', int $port = 22, string $username = 'root')
* @method static PendingConnection connectFromModel(RemoteServer $server)
* @method static ServerConnectionFake fake()
* @method static void assertConnected()
* @method static void assertDisconnected()
* @method static void assertNotConnected()
* @method static void assertCommandRan(?string $command = null)
* @method static void assertAnyCommandRan()
* @method static void assertNoCommandsRan()
* @method static void assertFileUploaded(string $localPath, string $remotePath)
* @method static void assertFileDownloaded(string $remotePath, string $localPath)
* @method static void assertOutput(string $output)
* @method static void assertConnectionAttempted(array $connectionDetails)
* @method static void defaultPrivateKey(string $path)
* @method static void defaultPassphrase(string $passphrase)
* @method static ServerConnectionFake shouldConnect()
* @method static ServerConnectionFake shouldNotConnect()
* @method static string getPrivateKeyContent(string $path)
* @method static string getDefaultPrivateKey()
* @method static string getDefaultPassphrase()
* @method static string getDefaultPrivateKeyPath()
* @method static string getDefaultPublicKeyPath()
* @method static string getDefaultPublicKey()
* @method static string getPublicKeyContent(string $path)
*
* @see ServerConnectionManager
*/
class ServerConnection extends Facade
{
/**
* Get the registered name of the component.
*/
protected static function getFacadeAccessor(): string
{
return ServerConnectionManager::class;
}
}
37 changes: 30 additions & 7 deletions app/Jobs/CheckRemoteServerConnectionJob.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,21 @@
namespace App\Jobs;

use App\Actions\RemoteServer\CheckRemoteServerConnection;
use Exception;
use Illuminate\Bus\Batchable;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;

/**
* Job to check the connection status of a remote server
*
* This job is responsible for initiating a connection check to a specific remote server.
* It can be dispatched to the queue for asynchronous processing.
*/
class CheckRemoteServerConnectionJob implements ShouldQueue
{
use Batchable;
Expand All @@ -20,14 +28,29 @@ class CheckRemoteServerConnectionJob implements ShouldQueue
use Queueable;
use SerializesModels;

public function __construct(public int $remoteServerId)
{
//
}
/**
* Create a new job instance
*
* @param int $remoteServerId The ID of the remote server to check
*/
public function __construct(public readonly int $remoteServerId) {}

public function handle(): void
/**
* Execute the job
*
* Performs the connection check for the specified remote server and logs the result.
*
* @param CheckRemoteServerConnection $checkRemoteServerConnection The action to perform the connection check
*
* @throws Exception
*/
public function handle(CheckRemoteServerConnection $checkRemoteServerConnection): void
{
$checkRemoteServerConnection = new CheckRemoteServerConnection;
$checkRemoteServerConnection->byRemoteServerId($this->remoteServerId);
$result = $checkRemoteServerConnection->byRemoteServerId($this->remoteServerId);

Log::info('[Server Connection Check Job] Completed', [
'server_id' => $this->remoteServerId,
'result' => $result,
]);
}
}
12 changes: 9 additions & 3 deletions app/Livewire/RemoteServers/CreateRemoteServerForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,16 +114,22 @@ public function usingServerProvider(string $provider): void
private function connectionAttempt(): bool
{
$checkRemoteServerConnection = new CheckRemoteServerConnection;

$response = $checkRemoteServerConnection->byRemoteServerConnectionDetails([
'host' => $this->host,
'port' => $this->port,
'username' => $this->username,
]);

if ($response['status'] === 'error') {
$this->connectionError = $response['error'];
$status = $response['status'] ?? 'error';
$error = $response['error'] ?? $response['message'] ?? 'Unknown error occurred';

if ($status === 'error') {
$this->connectionError = $error;

return false;
}

return $response['status'] === 'success';
return true;
}
}
Loading

0 comments on commit 6edd815

Please sign in to comment.