clusev/app/Livewire/Modals/HostShell.php

155 lines
5.6 KiB
PHP

<?php
namespace App\Livewire\Modals;
use App\Models\AuditEvent;
use App\Models\HostCredential;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use LivewireUI\Modal\ModalComponent;
/**
* Form modal: configure the SSH login for the "Clusev host" terminal. The target is ALWAYS the local
* machine Clusev runs on (the Docker host, reached at host.docker.internal — fixed, not a free-form
* host, because an arbitrary machine is what fleet servers are for). The operator only supplies the
* login (user + password/key, and the port). Stores a single encrypted HostCredential; on edit a
* blank secret keeps the stored one, and the clear-text secret is never echoed back into the form.
*/
class HostShell extends ModalComponent
{
/** The host terminal always targets the local machine; not operator-editable. */
private const HOST = 'host.docker.internal';
public int $port = 22;
public string $username = 'root';
public string $authType = 'password';
public string $secret = '';
public string $passphrase = '';
/** True when a HostCredential already exists — then a blank secret keeps the stored one. */
public bool $configured = false;
public static function modalMaxWidth(): string
{
return 'lg';
}
public function mount(): void
{
// The host SSH login is the Clusev machine itself — the most dangerous credential.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$this->configured = true;
$this->port = $hc->port;
$this->username = $hc->username;
$this->authType = $hc->auth_type;
// secret/passphrase stay blank — never reveal the stored login back into the form.
}
}
/**
* @return array<string, array<int, mixed>>
*/
protected function rules(): array
{
return [
'port' => ['required', 'integer', 'min:1', 'max:65535'],
'username' => ['required', 'string', 'max:120'],
'authType' => ['required', Rule::in(['password', 'key'])],
// On first setup the secret is required; when editing an existing login a blank
// secret means "keep the stored one".
'secret' => [$this->configured ? 'nullable' : 'required', 'string'],
'passphrase' => ['nullable', 'string'],
];
}
/**
* @return array<string, string>
*/
protected function validationAttributes(): array
{
return [
'port' => __('terminal.host_field_port'),
'username' => __('terminal.host_field_user'),
'authType' => __('terminal.host_field_auth'),
'secret' => $this->authType === 'key' ? __('terminal.host_field_key') : __('terminal.host_field_password'),
];
}
public function save(): void
{
// Re-gate on save: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$data = $this->validate();
$existing = HostCredential::current();
// Switching the auth method (password ↔ key) requires a fresh secret: keeping the stored one
// would feed e.g. an old password into key auth (or vice versa) and silently break the login.
if ($this->secret === '' && $existing !== null && $this->authType !== $existing->auth_type) {
throw ValidationException::withMessages([
'secret' => __('terminal.host_secret_required_for_auth_change'),
]);
}
$hc = $existing ?? new HostCredential;
$hc->host = self::HOST; // always the local machine — not operator-editable
$hc->port = (int) $data['port'];
$hc->username = trim($data['username']);
$hc->auth_type = $data['authType'] === 'key' ? 'key' : 'password';
// Only overwrite the secret when a new one was entered (blank = keep the stored one).
if ($this->secret !== '') {
$hc->secret = $this->secret;
$hc->passphrase = $this->authType === 'key' && $this->passphrase !== '' ? $this->passphrase : null;
}
$hc->save();
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.configure',
'target' => $hc->username.'@'.$hc->host.':'.$hc->port,
'ip' => request()->ip(),
]);
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_saved'));
$this->closeModal();
}
public function remove(): void
{
// Re-gate on remove: refuse a demoted user / hand-crafted /livewire/update after mount.
abort_unless(auth()->user()?->can('manage-fleet'), 403);
$hc = HostCredential::current();
if ($hc !== null) {
$target = $hc->username.'@'.$hc->host.':'.$hc->port;
HostCredential::query()->delete(); // singleton — clear any/all rows, not just the first
AuditEvent::create([
'user_id' => Auth::id(),
'actor' => Auth::user()?->name ?? 'system',
'action' => 'host.terminal.remove',
'target' => $target,
'ip' => request()->ip(),
]);
}
$this->dispatch('hostShellSaved');
$this->dispatch('notify', message: __('terminal.host_removed'));
$this->closeModal();
}
public function render()
{
return view('livewire.modals.host-shell');
}
}