215 lines
7.1 KiB
PHP
215 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Livewire\Commands;
|
|
|
|
use App\Models\AuditEvent;
|
|
use App\Models\Runbook;
|
|
use App\Models\Server;
|
|
use App\Models\ServerGroup;
|
|
use App\Services\CommandRunner;
|
|
use App\Support\Confirm\ConfirmToken;
|
|
use App\Support\Confirm\InvalidConfirmToken;
|
|
use Illuminate\Contracts\View\View;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\On;
|
|
use Livewire\Component;
|
|
|
|
/**
|
|
* Ad-hoc fleet command / runbook runner. SECURITY-CRITICAL: arbitrary remote execution, so the whole
|
|
* page is `operate`-gated (route + mount + per-method), every run goes through a signed ConfirmToken +
|
|
* R5 confirm modal, and EVERY run is audited (command + target count). Targets resolve client UUIDs →
|
|
* ids server-side; only active-credential servers are run. Output is byte-capped + scrubbed by
|
|
* CommandRunner. The command runs as the credential login user (runPlain) — no extra sudo escalation.
|
|
*/
|
|
#[Layout('layouts.app')]
|
|
class Index extends Component
|
|
{
|
|
public string $command = '';
|
|
|
|
public string $scopeType = 'all'; // all | group | servers
|
|
|
|
public ?string $scopeGroupUuid = null;
|
|
|
|
/** @var array<int, string> selected server uuids (scopeType=servers) */
|
|
public array $selectedServers = [];
|
|
|
|
// New-runbook form
|
|
public string $runbookName = '';
|
|
|
|
public string $runbookCommand = '';
|
|
|
|
/** @var array<int, array{server:string, ok:bool, output:string}> last run's results */
|
|
public array $results = [];
|
|
|
|
public function mount(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('operate'), 403);
|
|
}
|
|
|
|
public function title(): string
|
|
{
|
|
return __('commands.title');
|
|
}
|
|
|
|
private function gate(): void
|
|
{
|
|
abort_unless(Auth::user()?->can('operate'), 403);
|
|
}
|
|
|
|
/** Resolve the current target selection to active-credential servers (uuids → models). */
|
|
private function targetServers(): Collection
|
|
{
|
|
$q = Server::withActiveCredential();
|
|
|
|
if ($this->scopeType === 'group' && $this->scopeGroupUuid) {
|
|
$group = ServerGroup::where('uuid', $this->scopeGroupUuid)->first();
|
|
|
|
return $group ? $q->inGroup($group->id)->get() : new Collection;
|
|
}
|
|
|
|
if ($this->scopeType === 'servers') {
|
|
return $q->whereIn('uuid', $this->selectedServers)->get();
|
|
}
|
|
|
|
return $q->get(); // 'all'
|
|
}
|
|
|
|
private function issueRun(string $command): void
|
|
{
|
|
$command = trim($command);
|
|
if ($command === '') {
|
|
$this->addError('command', __('commands.command_required'));
|
|
|
|
return;
|
|
}
|
|
|
|
$servers = $this->targetServers();
|
|
if ($servers->isEmpty()) {
|
|
$this->addError('command', __('commands.no_targets'));
|
|
|
|
return;
|
|
}
|
|
|
|
$ids = $servers->pluck('id')->all();
|
|
$target = Str::limit($command, 60).' · '.__('commands.n_servers', ['count' => count($ids)]);
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('commands.confirm_heading'),
|
|
'body' => __('commands.confirm_body', ['count' => count($ids), 'command' => Str::limit($command, 120)]),
|
|
'confirmLabel' => __('commands.run'),
|
|
'danger' => true,
|
|
'icon' => 'command',
|
|
'notify' => '', // the handler reports the real outcome
|
|
'token' => ConfirmToken::issue('commandRun', ['command' => $command, 'serverIds' => $ids], 'command.run', $target, null),
|
|
],
|
|
);
|
|
}
|
|
|
|
/** Ad-hoc run from the textarea. */
|
|
public function run(): void
|
|
{
|
|
$this->gate();
|
|
$this->issueRun($this->command);
|
|
}
|
|
|
|
/** Run a saved runbook (its command + the current target selection). */
|
|
public function runRunbook(string $uuid): void
|
|
{
|
|
$this->gate();
|
|
$runbook = Runbook::where('uuid', $uuid)->firstOrFail();
|
|
$this->issueRun($runbook->command);
|
|
}
|
|
|
|
#[On('commandRun')]
|
|
public function execute(string $confirmToken, CommandRunner $runner): void
|
|
{
|
|
$this->gate();
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'commandRun');
|
|
} catch (InvalidConfirmToken) {
|
|
return; // forged / replayed / direct-bypass — no-op (the modal already audited command.run)
|
|
}
|
|
|
|
$servers = Server::whereIn('id', $payload['params']['serverIds'])->get();
|
|
$this->results = $runner->run($payload['params']['command'], $servers);
|
|
|
|
$ok = count(array_filter($this->results, fn ($r) => $r['ok']));
|
|
$this->dispatch('notify', message: __('commands.ran', ['ok' => $ok, 'total' => count($this->results)]));
|
|
}
|
|
|
|
public function createRunbook(): void
|
|
{
|
|
$this->gate();
|
|
|
|
$data = $this->validate([
|
|
'runbookName' => ['required', 'string', 'max:80', Rule::unique('runbooks', 'name')],
|
|
'runbookCommand' => ['required', 'string', 'max:4000'],
|
|
]);
|
|
|
|
$runbook = Runbook::create(['name' => $data['runbookName'], 'command' => $data['runbookCommand']]);
|
|
$this->audit('runbook.create', $runbook->name);
|
|
$this->reset('runbookName', 'runbookCommand');
|
|
$this->dispatch('notify', message: __('commands.runbook_created', ['name' => $runbook->name]));
|
|
}
|
|
|
|
public function confirmDeleteRunbook(string $uuid): void
|
|
{
|
|
$this->gate();
|
|
$runbook = Runbook::where('uuid', $uuid)->firstOrFail();
|
|
|
|
$this->dispatch('openModal',
|
|
component: 'modals.confirm-action',
|
|
arguments: [
|
|
'heading' => __('commands.delete_heading'),
|
|
'body' => __('commands.delete_body', ['name' => $runbook->name]),
|
|
'confirmLabel' => __('common.delete'),
|
|
'danger' => true,
|
|
'icon' => 'trash',
|
|
'notify' => __('commands.runbook_deleted', ['name' => $runbook->name]),
|
|
'token' => ConfirmToken::issue('runbookDeleted', ['uuid' => $runbook->uuid], 'runbook.delete', $runbook->name, null),
|
|
],
|
|
);
|
|
}
|
|
|
|
#[On('runbookDeleted')]
|
|
public function deleteRunbook(string $confirmToken): void
|
|
{
|
|
$this->gate();
|
|
|
|
try {
|
|
$payload = ConfirmToken::consume($confirmToken, 'runbookDeleted');
|
|
} catch (InvalidConfirmToken) {
|
|
return;
|
|
}
|
|
|
|
Runbook::where('uuid', $payload['params']['uuid'])->first()?->delete();
|
|
}
|
|
|
|
private function audit(string $action, string $target): void
|
|
{
|
|
AuditEvent::create([
|
|
'user_id' => Auth::id(),
|
|
'actor' => Auth::user()?->name ?? 'system',
|
|
'action' => $action,
|
|
'target' => $target,
|
|
'ip' => request()->ip(),
|
|
]);
|
|
}
|
|
|
|
public function render(): View
|
|
{
|
|
return view('livewire.commands.index', [
|
|
'runbooks' => Runbook::orderBy('name')->get(),
|
|
'groups' => ServerGroup::orderBy('name')->get(['uuid', 'name']),
|
|
'servers' => Server::orderBy('name')->get(['uuid', 'name']),
|
|
])->title($this->title());
|
|
}
|
|
}
|