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) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-14 10:26:15 +02:00
parent 2c25303903
commit a9334d11de
4 changed files with 63 additions and 3 deletions

View File

@ -4,9 +4,12 @@ namespace App\Livewire\Modals;
use App\Models\AuditEvent; use App\Models\AuditEvent;
use App\Models\Server; use App\Models\Server;
use App\Services\FleetService;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent; use LivewireUI\Modal\ModalComponent;
/** /**
@ -86,14 +89,17 @@ class CreateServer extends ModalComponent
{ {
$data = $this->validate(); $data = $this->validate();
// Atomic: a failure creating the credential or audit must not leave a // Atomic: create server + credential, then VERIFY the SSH login. A failed
// server row without its required SSH credential. // 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 = DB::transaction(function () use ($data) {
$server = Server::create([ $server = Server::create([
'name' => trim($data['name']), 'name' => trim($data['name']),
'ip' => trim($data['ip']), 'ip' => trim($data['ip']),
'ssh_port' => (int) $data['sshPort'], 'ssh_port' => (int) $data['sshPort'],
'status' => 'offline', 'status' => 'pending',
]); ]);
$server->credential()->create([ $server->credential()->create([
@ -104,6 +110,14 @@ class CreateServer extends ModalComponent
'passphrase' => $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null, '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([ AuditEvent::create([
'user_id' => Auth::id(), 'user_id' => Auth::id(),
'server_id' => $server->id, 'server_id' => $server->id,

View File

@ -38,6 +38,7 @@ return [
'credential_name_label' => 'Zugangs-Name (optional)', 'credential_name_label' => 'Zugangs-Name (optional)',
'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User', 'credential_name_placeholder' => 'z. B. Root-Login · Deploy-User',
'validation_ip_or_host' => 'Bitte eine gültige IP-Adresse oder einen Hostnamen angeben.', '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_name' => 'Name',
'attr_ip' => 'IP/Host', 'attr_ip' => 'IP/Host',
'attr_ssh_port' => 'SSH-Port', 'attr_ssh_port' => 'SSH-Port',

View File

@ -37,6 +37,7 @@ return [
'credential_name_label' => 'Access name (optional)', 'credential_name_label' => 'Access name (optional)',
'credential_name_placeholder' => 'e.g. Root login · Deploy user', 'credential_name_placeholder' => 'e.g. Root login · Deploy user',
'validation_ip_or_host' => 'Please enter a valid IP address or hostname.', '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_name' => 'Name',
'attr_ip' => 'IP/Host', 'attr_ip' => 'IP/Host',
'attr_ssh_port' => 'SSH port', 'attr_ssh_port' => 'SSH port',

View File

@ -0,0 +1,44 @@
<?php
namespace Tests\Feature;
use App\Livewire\Modals\CreateServer;
use App\Models\Server;
use App\Models\User;
use App\Services\FleetService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Features\SupportTesting\Testable;
use Livewire\Livewire;
use Tests\TestCase;
class CreateServerTest extends TestCase
{
use RefreshDatabase;
private function form(Testable $c): Testable
{
return $c->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);
}
}