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
parent
35b7c15598
commit
5e4f29216d
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,8 @@ class ConfirmToken
|
|||
'releaseYank',
|
||||
'groupConfirmed',
|
||||
'alertRuleDeleted',
|
||||
'commandRun',
|
||||
'runbookDeleted',
|
||||
];
|
||||
|
||||
/** How long an issued confirm stays valid (seconds) — generous for a human click. */
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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).',
|
||||
];
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue