Skip to content

Commit

Permalink
Merge pull request #2 from vanguardsh/feat-remove-ssh-key-from-server
Browse files Browse the repository at this point in the history
Feat: SSH Key Removal
  • Loading branch information
lewislarsen authored Jun 14, 2024
2 parents 77c586b + 34da9c1 commit 8eed3cf
Show file tree
Hide file tree
Showing 17 changed files with 350 additions and 4 deletions.
40 changes: 40 additions & 0 deletions app/Actions/RemoteServer/RemoveSSHKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace App\Actions\RemoteServer;

use App\Mail\RemoteServers\FailedToRemoveKey;
use App\Mail\RemoteServers\SuccessfullyRemovedKey;
use App\Models\RemoteServer;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use phpseclib3\Crypt\PublicKeyLoader;
use phpseclib3\Net\SSH2;
use RuntimeException;

class RemoveSSHKey
{
public function handle(RemoteServer $remoteServer): void
{
Log::info('Removing SSH key from server.', ['server_id' => $remoteServer->id]);

$key = PublicKeyLoader::load(get_ssh_private_key(), config('app.ssh.passphrase'));

try {
$ssh = new SSH2($remoteServer->ip_address, $remoteServer->port, 5);

$ssh->login($remoteServer->username, $key);

$vanguardsPublicKey = get_ssh_public_key();

$ssh->exec("sed -i '/{$vanguardsPublicKey}/d' ~/.ssh/authorized_keys");

Log::info('Removed SSH key from server.', ['server_id' => $remoteServer->id]);
Log::info('Updated server to indicate SSH key was removed.', ['server_id' => $remoteServer->id]);
Mail::to($remoteServer->user->email)->queue(new SuccessfullyRemovedKey($remoteServer));

} catch (RuntimeException $e) {
Log::debug('[SSH Key Removal] Failed to connect to remote server', ['error' => $e->getMessage()]);
Mail::to($remoteServer->user->email)->queue(new FailedToRemoveKey($remoteServer, $e->getMessage()));
}
}
}
4 changes: 4 additions & 0 deletions app/Http/Controllers/RemoteServers/EditController.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class EditController extends Controller
{
public function __invoke(Request $request, RemoteServer $remoteServer): View
{
$remoteServer->query()
->whereNull('marked_for_deletion_at')
->findOrFail($remoteServer->id);

return view('remote-servers.edit', [
'remoteServer' => $remoteServer,
]);
Expand Down
30 changes: 30 additions & 0 deletions app/Jobs/RemoteServers/RemoveSSHKeyJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

namespace App\Jobs\RemoteServers;

use App\Actions\RemoteServer\RemoveSSHKey;
use App\Models\RemoteServer;
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;

class RemoveSSHKeyJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(public RemoteServer $remoteServer)
{
//
}

public function handle(): void
{
Log::info('Removing SSH key from server.', ['server_id' => $this->remoteServer->id]);

$action = new RemoveSSHKey;
$action->handle($this->remoteServer);
}
}
27 changes: 27 additions & 0 deletions app/Jobs/RemoteServers/RemoveServerJob.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace App\Jobs\RemoteServers;

use App\Models\RemoteServer;
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;

class RemoveServerJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public function __construct(public RemoteServer $remoteServer)
{
//
}

public function handle(): void
{
Log::info('Removing server.', ['server_id' => $this->remoteServer->id]);
$this->remoteServer->forceDelete();
}
}
4 changes: 2 additions & 2 deletions app/Livewire/RemoteServers/DeleteRemoteServerForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,9 @@ public function delete(): RedirectResponse|Redirector
{
$this->authorize('forceDelete', $this->remoteServer);

$this->remoteServer->delete();
$this->remoteServer->removeServer();

Toaster::success('Remote server has been removed.');
Toaster::success('Remote server will be removed shortly.');

return Redirect::route('remote-servers.index');
}
Expand Down
1 change: 1 addition & 0 deletions app/Livewire/RemoteServers/IndexTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class IndexTable extends Component
public function render(): View
{
$remoteServers = RemoteServer::where('user_id', Auth::id())
->whereNull('marked_for_deletion_at')
->orderBy('created_at', 'desc')
->paginate(30, pageName: 'remote-servers');

Expand Down
36 changes: 36 additions & 0 deletions app/Mail/RemoteServers/FailedToRemoveKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace App\Mail\RemoteServers;

use App\Models\RemoteServer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class FailedToRemoveKey extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;

public function __construct(public readonly RemoteServer $remoteServer, public readonly string $message = '')
{
//
}

public function envelope(): Envelope
{
return new Envelope(
subject: __('Failed to Remove SSH Key'),
);
}

public function content(): Content
{
return new Content(
markdown: 'mail.remote-servers.failed-to-remove-key',
with: ['remoteServer' => $this->remoteServer, 'message' => $this->message, 'user' => $this->remoteServer->user],
);
}
}
36 changes: 36 additions & 0 deletions app/Mail/RemoteServers/SuccessfullyRemovedKey.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace App\Mail\RemoteServers;

use App\Models\RemoteServer;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;

class SuccessfullyRemovedKey extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;

public function __construct(public readonly RemoteServer $remoteServer)
{
//
}

public function envelope(): Envelope
{
return new Envelope(
subject: __('Notice of SSH Key Removal'),
);
}

public function content(): Content
{
return new Content(
markdown: 'mail.remote-servers.successfully-removed-key',
with: ['remoteServer' => $this->remoteServer, 'user' => $this->remoteServer->user],
);
}
}
29 changes: 29 additions & 0 deletions app/Models/RemoteServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace App\Models;

use App\Jobs\CheckRemoteServerConnectionJob;
use App\Jobs\RemoteServers\RemoveServerJob;
use App\Jobs\RemoteServers\RemoveSSHKeyJob;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
Expand Down Expand Up @@ -124,4 +126,31 @@ public function isUnknown(): bool
{
return $this->connectivity_status === self::STATUS_UNKNOWN;
}

public function isMarkedForDeletion(): bool
{
return ! empty($this->marked_for_deletion_at);
}

public function setMarkedForDeletion(): void
{
$this->update(['marked_for_deletion_at' => now()]);
$this->save();
$this->refresh();
}

public function removeServer(): void
{
$this->setMarkedForDeletion();
$this->removeSSHKey();

// We delay so the key has time to be removed before the server is removed!
RemoveServerJob::dispatch($this)
->delay(now()->addMinutes(2));
}

public function removeSSHKey(): void
{
RemoveSSHKeyJob::dispatch($this);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('remote_servers', function (Blueprint $table) {
$table->timestamp('marked_for_deletion_at')->nullable();
});
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@
</span>
@svg('heroicon-o-play', 'w-4 h-4')
</x-secondary-button>
@elseif ($backupTask->remoteServer->isMarkedForDeletion())
<x-secondary-button iconOnly type="button" class="cursor-not-allowed bg-opacity-50" disabled
title=" {{ __('Remote server is marked for deletion') }}">
<span class="sr-only">
{{ __('Remote server is marked for deletion') }}
</span>
@svg('heroicon-o-play', 'w-4 h-4')
</x-secondary-button>
@else
<x-secondary-button iconOnly wire:click="runTask" type="button" title="{{ __('Click to run this task') }}">
<span class="sr-only">Run Task</span>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
<p class="text-gray-800 dark:text-gray-200 my-3">
{{ __('This action cannot be undone. All your backups will still exist at the backup destination.') }}
</p>

<p class="text-gray-800 dark:text-gray-200 my-3">
{{ __('Vanguard will attempt to remove its SSH keys from your remote server, however please double check your `~/.ssh/authorized_keys` file afterwards.') }}
</p>

<div class="flex space-x-5">
<div class="w-4/6">
<x-danger-button type="button" wire:click="delete" class="mt-4" centered wire:loading.attr="disabled"
Expand Down
18 changes: 18 additions & 0 deletions resources/views/mail/remote-servers/failed-to-remove-key.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<x-mail::message>
# {{ $remoteServer->label }} - Failed to Remove Key

Hey, {{ $user->first_name }}!

We have failed to remove our SSH key from the server: {{ $remoteServer->label }}.

You can find the error message below:

<x-mail::panel>
{{ $message }}
</x-mail::panel>

Please connect to {{ $remoteServer->label }} through your preferred SSH client and remove the key manually by navigating to the `~/.ssh/authorized_keys` file.

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<x-mail::message>
# {{ $remoteServer->label }} - Successfully Removed Key

Hey, {{ $user->first_name }}!

We have successfully removed our SSH key from the server: {{ $remoteServer->label }}.

If you have any questions, please let us know.

Thanks,<br>
{{ config('app.name') }}
</x-mail::message>
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
<?php

use App\Jobs\RemoteServers\RemoveServerJob;
use App\Jobs\RemoteServers\RemoveSSHKeyJob;
use App\Livewire\RemoteServers\DeleteRemoteServerForm;
use App\Models\RemoteServer;
use App\Models\User;
Expand All @@ -11,6 +13,7 @@
});

test('a remote server can be deleted by its creator', function () {
Queue::fake();

$user = User::factory()->create();
$remoteServer = RemoteServer::factory()->create([
Expand All @@ -22,12 +25,15 @@
Livewire::test(DeleteRemoteServerForm::class, ['remoteServer' => $remoteServer])
->call('delete');

$this->assertDatabaseMissing('remote_servers', ['id' => $remoteServer->id]);
Queue::assertPushed(RemoveServerJob::class);
Queue::assertPushed(RemoveSSHKeyJob::class);

$this->assertTrue($remoteServer->fresh()->isMarkedForDeletion());
$this->assertAuthenticatedAs($user);
});

test('a remote server cannot be deleted by another user', function () {

Queue::fake();
$user = User::factory()->create();
$remoteServer = RemoteServer::factory()->create();

Expand All @@ -37,6 +43,9 @@
->call('delete')
->assertForbidden();

Queue::assertNotPushed(RemoveSSHKeyJob::class);
Queue::assertNotPushed(RemoveServerJob::class);

$this->assertDatabaseHas('remote_servers', ['id' => $remoteServer->id]);
$this->assertAuthenticatedAs($user);
});
20 changes: 20 additions & 0 deletions tests/Feature/RemoteServers/Pages/EditPageTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,26 @@

$this->assertAuthenticatedAs($user);
$this->assertEquals($user->id, $remoteServer->user_id);
$this->assertFalse($remoteServer->isMarkedForDeletion());
});

test('the page cannot be rendered if the remote server has been marked for deletion', function () {

$user = User::factory()->create();

$remoteServer = RemoteServer::factory()->create([
'user_id' => $user->id,
]);

$remoteServer->setMarkedForDeletion();

$response = $this->actingAs($user)->get(route('remote-servers.edit', $remoteServer));

$response->assertNotFound();

$this->assertAuthenticatedAs($user);
$this->assertEquals($user->id, $remoteServer->user_id);
$this->assertTrue($remoteServer->isMarkedForDeletion());
});

test('the page is not rendered by unauthorized users', function () {
Expand Down
Loading

0 comments on commit 8eed3cf

Please sign in to comment.