clusev/app/Livewire/Modals/CreateServer.php

153 lines
5.7 KiB
PHP

<?php
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;
/**
* Form modal: register a new server in the fleet and deposit its SSH access in
* the encrypted vault. The server starts as 'offline'; the first poll/connect
* promotes it. The SSH port lives on the servers table (column ssh_port), which
* is what the SSH layer (SshClient/Sftp) reads.
*/
class CreateServer extends ModalComponent
{
public string $name = '';
public string $ip = '';
public int $sshPort = 22;
public string $username = '';
public string $authType = 'password';
public string $secret = '';
public string $passphrase = '';
public string $credentialName = '';
public function mount(): void
{
// Registering a server + depositing its SSH login is a fleet-admin action.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
}
public static function modalMaxWidth(): string
{
return 'lg';
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'name' => ['required', 'string', 'max:120'],
// Accept either a real IP (validated by PHP's filter) or a valid DNS hostname.
'ip' => ['required', 'string', 'max:255', function ($attribute, $value, $fail) {
$value = (string) $value;
$isIp = filter_var($value, FILTER_VALIDATE_IP) !== false;
// A digits-and-dots string is an IPv4 attempt — it must be a VALID IP,
// not a numeric "hostname" (so 999.999.999.999 is rejected).
$numericDotted = preg_match('/^[0-9.]+$/', $value) === 1;
$isHost = ! $numericDotted && preg_match('/^(?=.{1,253}$)(?:[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)*[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/', $value) === 1;
if (! $isIp && ! $isHost) {
$fail(__('modals.create_server.validation_ip_or_host'));
}
}],
'sshPort' => ['required', 'integer', 'min:1', 'max:65535'],
'username' => ['required', 'string', 'max:120'],
'authType' => ['required', Rule::in(['password', 'key'])],
'secret' => ['required', 'string'],
'passphrase' => ['nullable', 'string'],
'credentialName' => ['nullable', 'string', 'max:120'],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
return [
'name' => __('modals.create_server.attr_name'),
'ip' => __('modals.create_server.attr_ip'),
'sshPort' => __('modals.create_server.attr_ssh_port'),
'username' => __('modals.create_server.attr_user'),
'authType' => __('modals.create_server.attr_auth'),
'secret' => $this->authType === 'key' ? __('modals.create_server.attr_secret_key') : __('modals.create_server.attr_secret_password'),
'credentialName' => __('modals.create_server.attr_credential_name'),
];
}
public function save(): void
{
// Re-gate on save: mount()'s guard is not enough — a demotion mid-session or a
// hand-crafted /livewire/update must still be refused at the write.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
// 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' => 'pending',
]);
$server->credential()->create([
'name' => trim($this->credentialName) !== '' ? trim($this->credentialName) : null,
'username' => trim($data['username']),
'auth_type' => $data['authType'] === 'key' ? 'key' : 'password',
'secret' => $this->secret,
'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,
'actor' => Auth::user()?->name ?? 'system',
'action' => 'server.create',
'target' => $server->name.' · '.$server->ip,
'ip' => request()->ip(),
]);
return $server;
});
$this->dispatch('serverCreated');
$this->dispatch('notify', message: __('modals.create_server.notify_added'));
$this->closeModal();
}
public function render()
{
return view('livewire.modals.create-server');
}
}