feat(control): real systemctl actions + sudo-aware journal

Service start/stop/restart now run over SSH (root direct, else `sudo -n`) and
report the real result instead of an optimistic state flip; the Services list is
reloaded afterwards to reflect the actual unit state. journalctl in systemd() is
likewise run via sudo/root so the full system journal is available once a
privileged credential exists. Unit names are validated before interpolation.

Without a privileged credential the action fails honestly ("sudo: a password is
required") rather than pretending to succeed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
feat/v1-foundation
boban 2026-06-12 22:53:47 +02:00
parent 1376772a0c
commit f849076418
2 changed files with 54 additions and 7 deletions

View File

@ -79,14 +79,32 @@ class Index extends Component
);
}
/** Applies the confirmed service state change (optimistic until SSH exec lands). */
/** Performs the confirmed systemctl action over SSH, then reloads real state. */
#[On('serviceConfirmed')]
public function applyService(string $op, string $name): void
public function applyService(string $op, string $name, FleetService $fleet): void
{
foreach ($this->services as $i => $svc) {
if ($svc['name'] === $name) {
$this->services[$i]['status'] = $op === 'stop' ? 'offline' : 'online';
}
$active = $this->activeServer();
if (! $active || ! $active->credential_exists) {
return;
}
try {
$res = $fleet->serviceAction($active, $op, $name);
} catch (Throwable $e) {
$this->dispatch('notify', message: "{$name}: ".$e->getMessage());
return;
}
$this->dispatch('notify', message: $res['ok']
? "{$name}: {$op} ausgeführt."
: "{$name}: fehlgeschlagen — ".\Illuminate\Support\Str::limit($res['output'] ?: 'keine Rechte', 90));
try {
$this->services = $fleet->systemd($active)['services'];
} catch (Throwable) {
// keep current list on reload failure
}
}

View File

@ -211,7 +211,7 @@ class FleetService
$cmd = 'export LC_ALL=C; '
.'echo '.self::MARK.'units===; systemctl list-units --type=service --all --plain --no-legend --no-pager; '
.'echo '.self::MARK.'files===; systemctl list-unit-files --type=service --no-legend --no-pager; '
.'echo '.self::MARK.'journal===; journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1';
.'echo '.self::MARK.'journal===; if [ "$(id -u)" = 0 ]; then SUDO=""; else SUDO="sudo -n"; fi; $SUDO journalctl -n '.(int) $journalLines.' --no-pager -o short-iso 2>&1';
try {
$s = $this->sections($ssh->exec($cmd));
@ -373,6 +373,35 @@ class FleetService
}
}
/**
* systemctl start/stop/restart. Runs directly as root, else via `sudo -n`
* (needs a root or nopasswd-sudo credential).
*
* @return array{ok: bool, output: string}
*/
public function serviceAction(Server $server, string $op, string $unit): array
{
if (! in_array($op, ['start', 'stop', 'restart'], true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
if (! preg_match('/^[\w@.:-]+$/', $unit)) {
throw new InvalidArgumentException('Ungueltige Unit.');
}
$ssh = $this->client($server);
try {
[$out, $code] = $ssh->run(
'export LC_ALL=C; if [ "$(id -u)" = 0 ]; then SUDO=""; else SUDO="sudo -n"; fi; '
."\$SUDO systemctl {$op} {$unit} 2>&1"
);
return ['ok' => $code === 0, 'output' => trim($out)];
} finally {
$ssh->disconnect();
}
}
// ── parsers ─────────────────────────────────────────────────────────
private function lines(string $body): array