feat(commands): ad-hoc fleet commands + runbooks (feature 4/8)

The multi-server amplifier: run a command (ad-hoc or a saved runbook) across the whole fleet,
a group, or a selection, with a per-server result. Because it is arbitrary remote execution,
authorization + audit are the whole feature.

- The command is intentionally arbitrary; it runs via FleetService::runPlain (the credential's
  login user, base64 transport, NO extra sudo) — the same privilege the web terminal already
  grants, batched. No injection to prevent; the concern is who may run + that every run is logged.
- CommandRunner.run: per-server runPlain, one server failing never aborts the batch, output
  byte-capped (256 KiB) + mb_scrub'd before it reaches a Livewire property.
- Commands\Index page, fully `operate`-gated: route can:operate + mount() + a per-method gate() on
  run/runRunbook/execute/createRunbook/confirmDeleteRunbook/deleteRunbook. Every run goes through a
  signed single-use ConfirmToken + R5 confirm modal ("run on N servers?"); the modal audits
  command.run from the sealed token so execute() does not re-audit. Targets resolve client UUIDs →
  ids server-side (withActiveCredential + inGroup + whereIn), and {command, serverIds} is SEALED in
  the token — a client can't swap the command or targets after confirm. A forged token is a no-op.
- Runbooks: saved name+command, operate-gated CRUD, delete via ConfirmToken.
- lang/{de,en}/commands.php + audit.php command.run / runbook.* + shell.nav_commands (de/en). No emoji.

15 new tests: CommandRunner (runs each server, one-failing-doesn't-abort, nonzero=not-ok, byte-cap
+scrub), component (route gating, ad-hoc run opens confirm, empty-command / no-target errors, execute
runs the SEALED targets, forged token runs nothing, group scope targets only members, runbook create
+audit + run + delete-via-token). 700 tests green, Pint, lang parity, Codex-reviewed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/v1-foundation
boban 2026-07-05 20:38:09 +02:00
parent 35b7c15598
commit 5e4f29216d
16 changed files with 813 additions and 0 deletions

View File

@ -0,0 +1,214 @@
<?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());
}
}

22
app/Models/Runbook.php Normal file
View File

@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
/** A saved, reusable fleet command. */
class Runbook extends Model
{
protected $guarded = [];
protected static function booted(): void
{
static::creating(fn (self $r) => $r->uuid ??= (string) Str::uuid());
}
public function getRouteKeyName(): string
{
return 'uuid';
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Services;
use App\Models\Server;
use Illuminate\Support\Collection;
use Throwable;
/**
* Runs one (arbitrary, user-supplied) command across a set of servers and collects a per-server
* result. The command is INTENTIONALLY arbitrary it runs via FleetService::runPlain (the
* credential's login user, base64 transport, no extra sudo) exactly as the web terminal would.
* One server failing (connect/exec) never aborts the batch. Remote output is byte-capped +
* mb_scrub'd so it can safely land in a Livewire property.
*/
class CommandRunner
{
private const MAX_BYTES = 262144; // 256 KiB per server
public function __construct(private FleetService $fleet) {}
/**
* @param Collection<int, Server> $servers
* @return array<int, array{server:string, ok:bool, output:string}>
*/
public function run(string $command, Collection $servers, int $timeout = 60): array
{
$results = [];
foreach ($servers as $server) {
try {
$res = $this->fleet->runPlain($server, $command, $timeout);
$results[] = ['server' => $server->name, 'ok' => (bool) $res['ok'], 'output' => $this->clean($res['output'])];
} catch (Throwable $e) {
$results[] = ['server' => $server->name, 'ok' => false, 'output' => $this->clean($e->getMessage())];
}
}
return $results;
}
private function clean(string $output): string
{
return mb_scrub(mb_strcut($output, 0, self::MAX_BYTES), 'UTF-8');
}
}

View File

@ -68,6 +68,8 @@ class ConfirmToken
'releaseYank',
'groupConfirmed',
'alertRuleDeleted',
'commandRun',
'runbookDeleted',
];
/** How long an issued confirm stays valid (seconds) — generous for a human click. */

View File

@ -0,0 +1,24 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('runbooks', function (Blueprint $table) {
$table->id();
$table->uuid('uuid')->unique();
$table->string('name')->unique();
$table->text('command');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('runbooks');
}
};

View File

@ -57,7 +57,10 @@ return [
'fail2ban.configure' => 'fail2ban konfiguriert',
'fail2ban.ignoreip_add' => 'fail2ban: Ausnahme hinzugefügt',
'fail2ban.ignoreip_remove' => 'fail2ban: Ausnahme entfernt',
'command.run' => 'Fleet-Befehl ausgeführt',
'docker.action' => 'Container-Aktion',
'runbook.create' => 'Runbook angelegt',
'runbook.delete' => 'Runbook gelöscht',
'file.edit' => 'Datei bearbeitet',
'firewall.rule_add' => 'Firewall-Regel hinzugefügt',
'firewall.rule_delete' => 'Firewall-Regel gelöscht',

50
lang/de/commands.php Normal file
View File

@ -0,0 +1,50 @@
<?php
return [
'eyebrow' => 'Flotte',
'title' => 'Befehle',
'subtitle' => 'Befehl oder Runbook über die Flotte ausführen',
// Ad-hoc
'adhoc_title' => 'Ad-hoc-Befehl',
'command_label' => 'Befehl',
'command_placeholder' => 'z. B. uptime, systemctl status nginx …',
'scope_label' => 'Ziel',
'scope_all' => 'Ganze Flotte',
'scope_group' => 'Gruppe',
'scope_servers' => 'Auswahl',
'target_group_label' => 'Gruppe',
'target_servers_label' => 'Server',
'run' => 'Ausführen',
// Results
'results_title' => 'Ergebnis',
'no_results' => 'Noch kein Lauf.',
'exit_ok' => 'OK',
'exit_fail' => 'Fehler',
// Runbooks
'runbooks_title' => 'Runbooks',
'new_runbook' => 'Neues Runbook',
'runbook_name_label' => 'Name',
'runbook_command_label' => 'Befehl',
'create' => 'Speichern',
'no_runbooks_title' => 'Keine Runbooks',
'no_runbooks' => 'Noch keine gespeicherten Befehle.',
// Confirm run
'confirm_heading' => 'Befehl ausführen',
'confirm_body' => 'Auf :count Server(n) ausführen: „:command“?',
'n_servers' => '{1}:count Server|[2,*]:count Server',
// Delete runbook
'delete_heading' => 'Runbook löschen',
'delete_body' => 'Das Runbook „:name“ wird gelöscht.',
// Toasts / validation
'ran' => ':ok/:total Server erfolgreich.',
'runbook_created' => 'Runbook „:name“ gespeichert.',
'runbook_deleted' => 'Runbook „:name“ gelöscht.',
'command_required' => 'Bitte einen Befehl eingeben.',
'no_targets' => 'Keine Server im Ziel (mit aktivem Credential).',
];

View File

@ -23,6 +23,7 @@ return [
'nav_settings' => 'Einstellungen',
'nav_system' => 'System',
'nav_docker' => 'Docker',
'nav_commands' => 'Befehle',
'nav_alerts' => 'Alarme',
'nav_threats' => 'Bedrohungen',
'nav_versions' => 'Version',

View File

@ -57,7 +57,10 @@ return [
'fail2ban.configure' => 'fail2ban configured',
'fail2ban.ignoreip_add' => 'fail2ban: exception added',
'fail2ban.ignoreip_remove' => 'fail2ban: exception removed',
'command.run' => 'Fleet command run',
'docker.action' => 'Container action',
'runbook.create' => 'Runbook created',
'runbook.delete' => 'Runbook deleted',
'file.edit' => 'File edited',
'firewall.rule_add' => 'Firewall rule added',
'firewall.rule_delete' => 'Firewall rule deleted',

50
lang/en/commands.php Normal file
View File

@ -0,0 +1,50 @@
<?php
return [
'eyebrow' => 'Fleet',
'title' => 'Commands',
'subtitle' => 'Run a command or runbook across the fleet',
// Ad-hoc
'adhoc_title' => 'Ad-hoc command',
'command_label' => 'Command',
'command_placeholder' => 'e.g. uptime, systemctl status nginx …',
'scope_label' => 'Target',
'scope_all' => 'Whole fleet',
'scope_group' => 'Group',
'scope_servers' => 'Selection',
'target_group_label' => 'Group',
'target_servers_label' => 'Servers',
'run' => 'Run',
// Results
'results_title' => 'Result',
'no_results' => 'No run yet.',
'exit_ok' => 'OK',
'exit_fail' => 'Failed',
// Runbooks
'runbooks_title' => 'Runbooks',
'new_runbook' => 'New runbook',
'runbook_name_label' => 'Name',
'runbook_command_label' => 'Command',
'create' => 'Save',
'no_runbooks_title' => 'No runbooks',
'no_runbooks' => 'No saved commands yet.',
// Confirm run
'confirm_heading' => 'Run command',
'confirm_body' => 'Run on :count server(s): “:command”?',
'n_servers' => '{1}:count server|[2,*]:count servers',
// Delete runbook
'delete_heading' => 'Delete runbook',
'delete_body' => 'The runbook “:name” will be deleted.',
// Toasts / validation
'ran' => ':ok/:total servers succeeded.',
'runbook_created' => 'Runbook “:name” saved.',
'runbook_deleted' => 'Runbook “:name” deleted.',
'command_required' => 'Please enter a command.',
'no_targets' => 'No servers in target (with an active credential).',
];

View File

@ -23,6 +23,7 @@ return [
'nav_settings' => 'Settings',
'nav_system' => 'System',
'nav_docker' => 'Docker',
'nav_commands' => 'Commands',
'nav_alerts' => 'Alerts',
'nav_threats' => 'Threats',
'nav_versions' => 'Version',

View File

@ -47,6 +47,9 @@
<x-nav-item icon="server" href="/servers" :active="request()->is('servers*')" data-tour="servers">{{ __('shell.nav_servers') }}</x-nav-item>
<x-nav-item icon="cpu" href="/services" :active="request()->is('services*')">{{ __('shell.nav_services') }}</x-nav-item>
<x-nav-item icon="box" href="/docker" :active="request()->is('docker*')">{{ __('shell.nav_docker') }}</x-nav-item>
@can('operate')
<x-nav-item icon="command" href="/commands" :active="request()->is('commands*')">{{ __('shell.nav_commands') }}</x-nav-item>
@endcan
<x-nav-item icon="folder" href="/files" :active="request()->is('files*')">{{ __('shell.nav_files') }}</x-nav-item>
<x-nav-item icon="audit" href="/audit" :active="request()->is('audit*')">{{ __('shell.nav_audit') }}</x-nav-item>
@can('manage-network')

View File

@ -0,0 +1,126 @@
@php
$field = 'w-full rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink focus:border-accent/40 focus:outline-none';
$label = 'mb-1 block font-mono text-[11px] uppercase tracking-wider text-ink-3';
@endphp
<div class="space-y-5">
{{-- Header --}}
<div>
<p class="font-mono text-[11px] uppercase tracking-[0.2em] text-accent-text">{{ __('commands.eyebrow') }}</p>
<h2 class="mt-0.5 font-display text-xl font-semibold text-ink sm:text-2xl">{{ __('commands.title') }}</h2>
<p class="mt-1 text-sm text-ink-3">{{ __('commands.subtitle') }}</p>
</div>
{{-- Ad-hoc --}}
<x-panel :title="__('commands.adhoc_title')">
<form wire:submit="run" class="space-y-4">
<div>
<label class="{{ $label }}">{{ __('commands.command_label') }}</label>
<textarea wire:model="command" rows="2" class="{{ $field }} font-mono placeholder:text-ink-4" placeholder="{{ __('commands.command_placeholder') }}"></textarea>
@error('command') <p class="mt-1 flex items-center gap-1.5 font-mono text-[11px] text-offline"><x-icon name="alert" class="h-3.5 w-3.5 shrink-0" />{{ $message }}</p> @enderror
</div>
<div>
<label class="{{ $label }}">{{ __('commands.scope_label') }}</label>
<div class="flex flex-wrap gap-1.5">
@foreach (['all', 'group', 'servers'] as $s)
<button type="button" wire:click="$set('scopeType', '{{ $s }}')"
@class([
'inline-flex min-h-9 items-center rounded-md border px-3 text-xs transition-colors',
'border-accent/25 bg-accent/10 text-ink' => $scopeType === $s,
'border-line bg-inset text-ink-2 hover:bg-raised hover:text-ink' => $scopeType !== $s,
])>{{ __('commands.scope_'.$s) }}</button>
@endforeach
</div>
</div>
@if ($scopeType === 'group')
<div>
<label class="{{ $label }}">{{ __('commands.target_group_label') }}</label>
<select wire:model="scopeGroupUuid" class="{{ $field }}">
<option value=""></option>
@foreach ($groups as $g)
<option value="{{ $g->uuid }}">{{ $g->name }}</option>
@endforeach
</select>
</div>
@elseif ($scopeType === 'servers')
<div>
<label class="{{ $label }}">{{ __('commands.target_servers_label') }}</label>
<div class="grid gap-1.5 sm:grid-cols-2 lg:grid-cols-3">
@foreach ($servers as $srv)
<label class="flex min-h-11 items-center gap-2.5 rounded-md border border-line bg-inset px-3 py-2 text-sm text-ink-2 transition-colors hover:bg-raised sm:min-h-0">
<input type="checkbox" value="{{ $srv->uuid }}" wire:model="selectedServers" class="h-4 w-4 shrink-0 rounded border-line bg-inset text-accent focus:ring-accent/40" />
<span class="min-w-0 truncate">{{ $srv->name }}</span>
</label>
@endforeach
</div>
</div>
@endif
<div class="flex justify-end">
<x-btn variant="primary" size="lg" type="submit">
<x-icon name="command" class="h-3.5 w-3.5" /> {{ __('commands.run') }}
</x-btn>
</div>
</form>
</x-panel>
{{-- Results --}}
@if (! empty($results))
<x-panel :title="__('commands.results_title')" :padded="false">
<div class="divide-y divide-line">
@foreach ($results as $r)
<div wire:key="res-{{ $loop->index }}" class="px-4 py-3 sm:px-5">
<div class="flex items-center gap-2.5">
<x-status-dot :status="$r['ok'] ? 'online' : 'offline'" class="h-2.5 w-2.5" />
<span class="min-w-0 flex-1 truncate font-mono text-sm text-ink">{{ $r['server'] }}</span>
<x-status-pill :status="$r['ok'] ? 'online' : 'offline'">{{ $r['ok'] ? __('commands.exit_ok') : __('commands.exit_fail') }}</x-status-pill>
</div>
@if ($r['output'] !== '')
<pre class="mt-2 max-h-64 overflow-auto rounded-md border border-line bg-void p-3 font-mono text-[11px] leading-relaxed text-ink-2">{{ $r['output'] }}</pre>
@endif
</div>
@endforeach
</div>
</x-panel>
@endif
{{-- Runbooks --}}
<x-panel :title="__('commands.runbooks_title')" :padded="false">
@if ($runbooks->isEmpty())
<div class="px-4 py-6 text-center sm:px-5">
<p class="font-display text-sm font-semibold text-ink">{{ __('commands.no_runbooks_title') }}</p>
<p class="mt-1 text-xs text-ink-3">{{ __('commands.no_runbooks') }}</p>
</div>
@else
<div class="divide-y divide-line">
@foreach ($runbooks as $rb)
<div wire:key="rb-{{ $rb->uuid }}" class="flex flex-wrap items-center gap-3 px-4 py-3 sm:px-5">
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink">{{ $rb->name }}</p>
<p class="truncate font-mono text-[11px] text-ink-3">{{ $rb->command }}</p>
</div>
<x-btn variant="secondary" wire:click="runRunbook('{{ $rb->uuid }}')">{{ __('commands.run') }}</x-btn>
<x-modal-trigger variant="danger-soft" action="confirmDeleteRunbook('{{ $rb->uuid }}')">{{ __('common.delete') }}</x-modal-trigger>
</div>
@endforeach
</div>
@endif
<div class="border-t border-line p-4 sm:p-5">
<p class="{{ $label }}">{{ __('commands.new_runbook') }}</p>
<form wire:submit="createRunbook" class="grid gap-3 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] sm:items-start">
<div>
<input wire:model="runbookName" type="text" maxlength="80" placeholder="{{ __('commands.runbook_name_label') }}" class="{{ $field }} placeholder:text-ink-4" />
@error('runbookName') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<div>
<input wire:model="runbookCommand" type="text" maxlength="4000" placeholder="{{ __('commands.runbook_command_label') }}" class="{{ $field }} font-mono placeholder:text-ink-4" />
@error('runbookCommand') <p class="mt-1 font-mono text-[11px] text-offline">{{ $message }}</p> @enderror
</div>
<x-btn variant="primary" size="lg" type="submit">{{ __('commands.create') }}</x-btn>
</form>
</div>
</x-panel>
</div>

View File

@ -8,6 +8,7 @@ use App\Http\Middleware\VerifyTerminalSidecarSecret;
use App\Livewire\Alerts;
use App\Livewire\Audit;
use App\Livewire\Auth;
use App\Livewire\Commands;
use App\Livewire\Dashboard;
use App\Livewire\Docker;
use App\Livewire\Files;
@ -224,6 +225,7 @@ Route::middleware('auth')->group(function () {
Route::get('/services', Services\Index::class)->name('services.index');
Route::get('/docker', Docker\Index::class)->name('docker');
Route::get('/commands', Commands\Index::class)->middleware('can:operate')->name('commands');
Route::get('/files', Files\Index::class)->name('files.index');
Route::get('/audit', Audit\Index::class)->name('audit.index');

View File

@ -0,0 +1,81 @@
<?php
namespace Tests\Feature;
use App\Models\Server;
use App\Services\CommandRunner;
use App\Services\FleetService;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Mockery;
use Tests\TestCase;
class CommandRunnerTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function server(string $name, string $ip): Server
{
return Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
}
public function test_runs_the_command_on_every_server_and_collects_results(): void
{
$a = $this->server('a', '10.0.0.1');
$b = $this->server('b', '10.0.0.2');
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPlain')->twice()->andReturn(['ok' => true, 'output' => 'done']);
$results = (new CommandRunner($fleet))->run('uptime', new Collection([$a, $b]));
$this->assertCount(2, $results);
$this->assertSame('a', $results[0]['server']);
$this->assertTrue($results[0]['ok']);
$this->assertSame('done', $results[0]['output']);
}
public function test_one_server_failing_does_not_abort_the_batch(): void
{
$a = $this->server('a', '10.0.0.1');
$b = $this->server('b', '10.0.0.2');
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPlain')->once()->with($a, Mockery::any(), Mockery::any())->andThrow(new \RuntimeException('ssh timeout'));
$fleet->shouldReceive('runPlain')->once()->with($b, Mockery::any(), Mockery::any())->andReturn(['ok' => true, 'output' => 'ok']);
$results = (new CommandRunner($fleet))->run('uptime', new Collection([$a, $b]));
$this->assertFalse($results[0]['ok']); // a failed
$this->assertStringContainsString('ssh timeout', $results[0]['output']);
$this->assertTrue($results[1]['ok']); // b still ran
}
public function test_a_nonzero_exit_is_reported_as_not_ok(): void
{
$a = $this->server('a', '10.0.0.1');
$fleet = Mockery::mock(FleetService::class);
$fleet->shouldReceive('runPlain')->once()->andReturn(['ok' => false, 'output' => 'command not found']);
$results = (new CommandRunner($fleet))->run('nope', new Collection([$a]));
$this->assertFalse($results[0]['ok']);
}
public function test_output_is_byte_capped_and_utf8_scrubbed(): void
{
$a = $this->server('a', '10.0.0.1');
$fleet = Mockery::mock(FleetService::class);
// 300 KiB of output + an invalid UTF-8 byte → must be capped and scrubbed (no broken snapshot).
$fleet->shouldReceive('runPlain')->once()->andReturn(['ok' => true, 'output' => str_repeat('x', 300000)."\xB1"]);
$results = (new CommandRunner($fleet))->run('cat big', new Collection([$a]));
$this->assertLessThanOrEqual(262144, strlen($results[0]['output']));
$this->assertSame($results[0]['output'], mb_scrub($results[0]['output'], 'UTF-8')); // already valid UTF-8
}
}

View File

@ -0,0 +1,185 @@
<?php
namespace Tests\Feature;
use App\Livewire\Commands\Index;
use App\Models\Runbook;
use App\Models\Server;
use App\Models\ServerGroup;
use App\Models\User;
use App\Services\CommandRunner;
use App\Support\Confirm\ConfirmToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
use Mockery;
use Tests\TestCase;
class CommandsComponentTest extends TestCase
{
use RefreshDatabase;
protected function tearDown(): void
{
Mockery::close();
parent::tearDown();
}
private function admin(): User
{
return User::factory()->create(['must_change_password' => false]);
}
private function operator(): User
{
return User::factory()->operator()->create(['must_change_password' => false]);
}
private function viewer(): User
{
return User::factory()->viewer()->create(['must_change_password' => false]);
}
private function server(string $name = 'box', string $ip = '10.0.0.1'): Server
{
$s = Server::create(['name' => $name, 'ip' => $ip, 'ssh_port' => 22, 'status' => 'online']);
$s->credential()->create(['username' => 'root', 'auth_type' => 'password', 'secret' => 'x']);
return $s;
}
// ── route gating (operate) ──────────────────────────────────────────────────
public function test_viewer_cannot_open_commands(): void
{
$this->actingAs($this->viewer())->get('/commands')->assertForbidden();
}
public function test_operator_can_open_commands(): void
{
$this->actingAs($this->operator())->get('/commands')->assertOk();
}
// ── ad-hoc run through the confirm token ────────────────────────────────────
public function test_run_opens_a_confirm_for_the_selected_targets(): void
{
$this->actingAs($this->operator());
$this->server();
Livewire::test(Index::class)
->set('command', 'uptime')
->set('scopeType', 'all')
->call('run')
->assertHasNoErrors()
->assertDispatched('openModal');
}
public function test_run_requires_a_command(): void
{
$this->actingAs($this->operator());
$this->server();
Livewire::test(Index::class)->set('command', ' ')->call('run')->assertHasErrors('command');
}
public function test_run_requires_at_least_one_target(): void
{
$this->actingAs($this->operator()); // no servers created → empty target
Livewire::test(Index::class)->set('command', 'uptime')->set('scopeType', 'all')->call('run')->assertHasErrors('command');
}
public function test_execute_runs_the_command_over_the_sealed_targets(): void
{
$this->actingAs($this->operator());
$server = $this->server();
$runner = Mockery::mock(CommandRunner::class);
$runner->shouldReceive('run')->once()
->with('uptime', Mockery::on(fn ($servers) => $servers->pluck('id')->all() === [$server->id]))
->andReturn([['server' => 'box', 'ok' => true, 'output' => 'up 3 days']]);
app()->instance(CommandRunner::class, $runner);
$token = ConfirmToken::issue('commandRun', ['command' => 'uptime', 'serverIds' => [$server->id]], 'command.run', 'uptime · 1', null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)
->call('execute', $token)
->assertOk()
->assertSet('results.0.output', 'up 3 days');
}
public function test_a_forged_run_token_executes_nothing(): void
{
$this->actingAs($this->operator());
$runner = Mockery::mock(CommandRunner::class);
$runner->shouldReceive('run')->never();
app()->instance(CommandRunner::class, $runner);
Livewire::test(Index::class)->call('execute', 'not-a-token')->assertSet('results', []);
}
// (A viewer cannot even mount the component — the route + mount operate-gate blocks the whole
// page, proven by test_viewer_cannot_open_commands; every mutating method re-gates as defence.)
// ── runbooks ────────────────────────────────────────────────────────────────
public function test_operator_creates_a_runbook_and_it_is_audited(): void
{
$this->actingAs($this->operator());
Livewire::test(Index::class)
->set('runbookName', 'Restart nginx')
->set('runbookCommand', 'systemctl restart nginx')
->call('createRunbook')
->assertHasNoErrors();
$this->assertDatabaseHas('runbooks', ['name' => 'Restart nginx']);
$this->assertDatabaseHas('audit_events', ['action' => 'runbook.create', 'target' => 'Restart nginx']);
}
public function test_runbook_run_opens_a_confirm_with_the_saved_command(): void
{
$this->actingAs($this->operator());
$this->server();
$rb = Runbook::create(['name' => 'up', 'command' => 'uptime']);
Livewire::test(Index::class)
->set('scopeType', 'all')
->call('runRunbook', $rb->uuid)
->assertDispatched('openModal');
}
public function test_runbook_delete_via_a_confirmed_token(): void
{
$this->actingAs($this->operator());
$rb = Runbook::create(['name' => 'gone', 'command' => 'x']);
$token = ConfirmToken::issue('runbookDeleted', ['uuid' => $rb->uuid], 'runbook.delete', $rb->name, null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('deleteRunbook', $token)->assertOk();
$this->assertDatabaseMissing('runbooks', ['id' => $rb->id]);
}
public function test_group_scope_only_targets_group_members(): void
{
$this->actingAs($this->operator());
$group = ServerGroup::create(['name' => 'web', 'color' => 'cyan']);
$member = $this->server('web1', '10.0.0.1');
$this->server('db1', '10.0.0.2'); // not in the group
$group->servers()->attach($member->id);
$runner = Mockery::mock(CommandRunner::class);
$runner->shouldReceive('run')->once()
->with('hostname', Mockery::on(fn ($servers) => $servers->pluck('id')->all() === [$member->id]))
->andReturn([]);
app()->instance(CommandRunner::class, $runner);
// Issue a token as the component would for a group scope, then execute.
$token = ConfirmToken::issue('commandRun', ['command' => 'hostname', 'serverIds' => [$member->id]], 'command.run', 'x', null);
ConfirmToken::confirm($token);
Livewire::test(Index::class)->call('execute', $token)->assertOk();
}
}