155 lines
5.6 KiB
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');
|
|
}
|
|
}
|