From a9334d11de1d2779b97a0d28dbe1d74c04574c88 Mon Sep 17 00:00:00 2001 From: boban Date: Sun, 14 Jun 2026 10:26:15 +0200 Subject: [PATCH] feat(servers): verify SSH on create + start in "Initialisierung" CreateServer now probes the entered SSH login inside the create transaction; a failed probe throws and rolls back (no half-registered server), surfacing the SSH reason on the form. Successful creation starts the server as `pending` ("Initialisierung") instead of red/offline until first contact promotes it. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/Livewire/Modals/CreateServer.php | 20 +++++++++++-- lang/de/modals.php | 1 + lang/en/modals.php | 1 + tests/Feature/CreateServerTest.php | 44 ++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/CreateServerTest.php diff --git a/app/Livewire/Modals/CreateServer.php b/app/Livewire/Modals/CreateServer.php index 541a06a..43dda88 100644 --- a/app/Livewire/Modals/CreateServer.php +++ b/app/Livewire/Modals/CreateServer.php @@ -4,9 +4,12 @@ namespace App\Livewire\Modals; use App\Models\AuditEvent; use App\Models\Server; +use App\Services\FleetService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\DB; +use Illuminate\Support\Str; use Illuminate\Validation\Rule; +use Illuminate\Validation\ValidationException; use LivewireUI\Modal\ModalComponent; /** @@ -86,14 +89,17 @@ class CreateServer extends ModalComponent { $data = $this->validate(); - // Atomic: a failure creating the credential or audit must not leave a - // server row without its required SSH credential. + // Atomic: create server + credential, then VERIFY the SSH login. A failed + // probe throws -> the whole transaction rolls back, so no half-registered + // server is left behind, and the operator sees the real SSH reason on the + // form. A verified server starts as 'pending' ("Initialisierung") — never + // red — until the first contact promotes it. $server = DB::transaction(function () use ($data) { $server = Server::create([ 'name' => trim($data['name']), 'ip' => trim($data['ip']), 'ssh_port' => (int) $data['sshPort'], - 'status' => 'offline', + 'status' => 'pending', ]); $server->credential()->create([ @@ -104,6 +110,14 @@ class CreateServer extends ModalComponent 'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null, ]); + $server->load('credential'); + $probe = app(FleetService::class)->testConnection($server); + if (! $probe['ok']) { + throw ValidationException::withMessages([ + 'secret' => __('modals.create_server.validation_ssh_failed', ['error' => Str::limit((string) $probe['error'], 120)]), + ]); + } + AuditEvent::create([ 'user_id' => Auth::id(), 'server_id' => $server->id, diff --git a/lang/de/modals.php b/lang/de/modals.php index f045efe..96b2c89 100644 --- a/lang/de/modals.php +++ b/lang/de/modals.php @@ -38,6 +38,7 @@ return [ 'credential_name_label' => 'Zugangs-Name (optional)', 'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User', 'validation_ip_or_host' => 'Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.', + 'validation_ssh_failed' => 'SSH-Verbindung fehlgeschlagen: :error. Bitte Zugangsdaten prüfen.', 'attr_name' => 'Name', 'attr_ip' => 'IP/Host', 'attr_ssh_port' => 'SSH-Port', diff --git a/lang/en/modals.php b/lang/en/modals.php index e6c0d38..0f839e4 100644 --- a/lang/en/modals.php +++ b/lang/en/modals.php @@ -37,6 +37,7 @@ return [ 'credential_name_label' => 'Access name (optional)', 'credential_name_placeholder' => 'e.g. Root login · Deploy user', 'validation_ip_or_host' => 'Please enter a valid IP address or hostname.', + 'validation_ssh_failed' => 'SSH connection failed: :error. Please check the credentials.', 'attr_name' => 'Name', 'attr_ip' => 'IP/Host', 'attr_ssh_port' => 'SSH port', diff --git a/tests/Feature/CreateServerTest.php b/tests/Feature/CreateServerTest.php new file mode 100644 index 0000000..52e3560 --- /dev/null +++ b/tests/Feature/CreateServerTest.php @@ -0,0 +1,44 @@ +set('name', 'web-1')->set('ip', '10.0.0.5')->set('sshPort', 22) + ->set('username', 'root')->set('authType', 'password')->set('secret', 'pw'); + } + + public function test_bad_ssh_blocks_creation_and_rolls_back(): void + { + $this->actingAs(User::factory()->create()); + $this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => false, 'error' => 'auth failed'])); + + $this->form(Livewire::test(CreateServer::class))->call('save')->assertHasErrors('secret'); + + $this->assertSame(0, Server::count()); + } + + public function test_good_ssh_creates_server_as_pending(): void + { + $this->actingAs(User::factory()->create()); + $this->mock(FleetService::class, fn ($m) => $m->shouldReceive('testConnection')->andReturn(['ok' => true, 'error' => null])); + + $this->form(Livewire::test(CreateServer::class))->call('save')->assertHasNoErrors(); + + $this->assertSame(1, Server::count()); + $this->assertSame('pending', Server::first()->status); + } +}