871 lines
32 KiB
PHP
871 lines
32 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\Server;
|
|
use App\Support\Ssh\CredentialVault;
|
|
use App\Support\Ssh\Sftp;
|
|
use App\Support\Ssh\SshClient;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use InvalidArgumentException;
|
|
use phpseclib3\Crypt\PublicKeyLoader;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Reads real host facts over SSH and maps raw command output into the shapes the
|
|
* Livewire pages already consume. All parsing forces the C locale (the target may
|
|
* be localized) and prefers /proc over `free`/`top` for locale-independent numbers.
|
|
*
|
|
* Every public reader connects, runs one compound command, parses, disconnects.
|
|
* Connection/parse failures bubble up as RuntimeException — callers render an
|
|
* offline state instead of crashing.
|
|
*/
|
|
class FleetService
|
|
{
|
|
private const MARK = '===CLUSEV:';
|
|
|
|
// Unique marker the connection probe echoes — proves the exec channel works.
|
|
private const PROBE_MARKER = 'CLUSEV_PROBE_OK';
|
|
|
|
public function __construct(private readonly CredentialVault $vault) {}
|
|
|
|
private function client(Server $server, int $timeout = 12): SshClient
|
|
{
|
|
return (new SshClient($this->vault, timeout: $timeout))->connect($server);
|
|
}
|
|
|
|
/**
|
|
* A remote `priv()` shell function: runs its arguments directly when already
|
|
* root, otherwise via sudo. For a password credential the stored password is
|
|
* fed to `sudo -S` over stdin — the PLAINTEXT password never appears in any
|
|
* argv; only its base64 is transiently in the remote `sh -c` argv (reversible
|
|
* only by a co-located local user during the sub-second exec). Key/other auth
|
|
* falls back to passwordless `sudo -n`. Prefix this string, then `priv <cmd>`.
|
|
*/
|
|
private function sudoFn(Server $server): string
|
|
{
|
|
$cred = $server->credential;
|
|
$password = ($cred && $cred->auth_type === 'password') ? (string) $cred->secret : '';
|
|
|
|
if ($password !== '') {
|
|
$b64 = base64_encode($password."\n");
|
|
$branch = 'echo \''.$b64.'\' | base64 -d | sudo -S -p \'\' "$@"';
|
|
} else {
|
|
$branch = 'sudo -n "$@"';
|
|
}
|
|
|
|
return 'priv() { if [ "$(id -u)" = 0 ]; then "$@"; else '.$branch.'; fi; }; ';
|
|
}
|
|
|
|
/**
|
|
* Run an arbitrary command as root (sudo password / direct root), returning
|
|
* [ok, output]. The command runs under `sh -c` so pipes/redirects work; it is
|
|
* passed base64-encoded so quoting/escaping is never an issue.
|
|
*
|
|
* @return array{ok: bool, output: string}
|
|
*/
|
|
public function runPrivileged(Server $server, string $command, int $timeout = 60): array
|
|
{
|
|
$b64 = base64_encode($command);
|
|
$ssh = $this->client($server, $timeout);
|
|
|
|
try {
|
|
[$out, $code] = $ssh->run(
|
|
'export LC_ALL=C; '.$this->sudoFn($server).
|
|
'priv sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1'
|
|
);
|
|
|
|
return ['ok' => $code === 0, 'output' => trim($out)];
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run an unprivileged command as the SSH user, returning [ok, output].
|
|
*
|
|
* @return array{ok: bool, output: string}
|
|
*/
|
|
public function runPlain(Server $server, string $command, int $timeout = 60): array
|
|
{
|
|
$b64 = base64_encode($command);
|
|
$ssh = $this->client($server, $timeout);
|
|
|
|
try {
|
|
[$out, $code] = $ssh->run('export LC_ALL=C; sh -c "$(printf %s \''.$b64.'\' | base64 -d)" 2>&1');
|
|
|
|
return ['ok' => $code === 0, 'output' => trim($out)];
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
}
|
|
|
|
/** Split a marker-delimited compound output into [section => body]. */
|
|
private function sections(string $out): array
|
|
{
|
|
$parts = [];
|
|
$current = null;
|
|
foreach (preg_split('/\R/', $out) as $line) {
|
|
if (str_starts_with($line, self::MARK)) {
|
|
$current = rtrim(substr($line, strlen(self::MARK)), '=');
|
|
$parts[$current] = [];
|
|
|
|
continue;
|
|
}
|
|
if ($current !== null) {
|
|
$parts[$current][] = $line;
|
|
}
|
|
}
|
|
|
|
return array_map(fn (array $l): string => implode("\n", $l), $parts);
|
|
}
|
|
|
|
/**
|
|
* Probe the server's stored credential: connect + a trivial exec. NEVER throws —
|
|
* returns the failure reason instead, for the create-server guard to surface.
|
|
* Uses a short timeout so an unreachable host fails fast.
|
|
*
|
|
* @return array{ok: bool, error: ?string}
|
|
*/
|
|
public function testConnection(Server $server): array
|
|
{
|
|
try {
|
|
$ssh = (new SshClient($this->vault, timeout: 10))->connect($server);
|
|
try {
|
|
// Require a unique marker in the OUTPUT to prove the exec channel really
|
|
// works — auth alone is not enough, since every fleet operation runs
|
|
// commands (a server that authenticates but rejects exec would otherwise
|
|
// be accepted). Checking the output, not the exit code, also tolerates
|
|
// SSH servers that omit the optional exit-status packet.
|
|
[$out] = $ssh->run('echo '.self::PROBE_MARKER);
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
|
|
if (! str_contains($out, self::PROBE_MARKER)) {
|
|
return ['ok' => false, 'error' => __('backend.ssh_probe_no_exec')];
|
|
}
|
|
|
|
return ['ok' => true, 'error' => null];
|
|
} catch (Throwable $e) {
|
|
return ['ok' => false, 'error' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
// ── Metrics (light — used by the poller and dashboard fallback) ──────
|
|
|
|
/** @return array{cpu:int,mem:int,disk:int,load:float} */
|
|
public function metrics(Server $server): array
|
|
{
|
|
$ssh = $this->client($server);
|
|
|
|
try {
|
|
return $this->readMetrics($ssh);
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run the metrics command on an already-connected client. The poller reuses
|
|
* one long-lived connection per server (a single login, not one per tick).
|
|
*
|
|
* @return array{cpu:int,mem:int,disk:int,load:float}
|
|
*/
|
|
public function readMetrics(SshClient $ssh): array
|
|
{
|
|
$cmd = 'export LC_ALL=C; '
|
|
.'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; '
|
|
.'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; '
|
|
.'echo '.self::MARK.'stat2===; head -1 /proc/stat; '
|
|
.'echo '.self::MARK.'load===; cat /proc/loadavg; '
|
|
.'echo '.self::MARK.'cores===; nproc; '
|
|
.'echo '.self::MARK.'disk===; df -B1 --output=size,used,pcent / | tail -1';
|
|
|
|
$s = $this->sections($ssh->exec($cmd));
|
|
|
|
$memTotalKb = $this->memTotalKb($s['mem'] ?? '');
|
|
$memAvailKb = (int) (preg_match('/MemAvailable:\s+(\d+)/', $s['mem'] ?? '', $mm) ? $mm[1] : 0);
|
|
$df = preg_split('/\s+/', trim($s['disk'] ?? '')); // size used pcent
|
|
|
|
return [
|
|
'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''),
|
|
'mem' => $this->memPercent($s['mem'] ?? ''),
|
|
'disk' => (int) str_replace('%', '', $df[2] ?? '0'),
|
|
'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0),
|
|
'cores' => max(1, (int) trim($s['cores'] ?? '1')),
|
|
'mem_used' => $memTotalKb > 0 ? round(($memTotalKb - $memAvailKb) / 1048576, 1) : 0.0,
|
|
'mem_total' => $memTotalKb > 0 ? round($memTotalKb / 1048576, 1) : 0.0,
|
|
'disk_used' => isset($df[1]) && ctype_digit($df[1]) ? (int) round((int) $df[1] / 1_000_000_000) : 0,
|
|
'disk_total' => isset($df[0]) && ctype_digit($df[0]) ? (int) round((int) $df[0] / 1_000_000_000) : 0,
|
|
];
|
|
}
|
|
|
|
/** Poll metrics and persist them onto the Server row. */
|
|
public function refresh(Server $server): array
|
|
{
|
|
$m = $this->metrics($server);
|
|
$this->applyMetrics($server, $m);
|
|
|
|
return $m;
|
|
}
|
|
|
|
/** Persist a metrics reading onto the Server row + cache (latest + rolling history). */
|
|
public function applyMetrics(Server $server, array $m): void
|
|
{
|
|
$server->forceFill([
|
|
'cpu' => $m['cpu'],
|
|
'mem' => $m['mem'],
|
|
'disk' => $m['disk'],
|
|
'status' => $this->statusFor($m),
|
|
'last_seen_at' => now(),
|
|
])->save();
|
|
|
|
Cache::put("metrics:latest:{$server->id}", $m, now()->addMinutes(5));
|
|
|
|
$history = Cache::get("metrics:history:{$server->id}", []);
|
|
$history[] = ['cpu' => $m['cpu'], 'mem' => $m['mem'], 'disk' => $m['disk'], 'load' => round($m['load'], 2)];
|
|
Cache::put("metrics:history:{$server->id}", array_slice($history, -40), now()->addHour());
|
|
}
|
|
|
|
/** Last persisted metrics reading (cache), or null. */
|
|
public function latest(Server $server): ?array
|
|
{
|
|
return Cache::get("metrics:latest:{$server->id}");
|
|
}
|
|
|
|
/**
|
|
* Rolling cpu/mem history for sparklines/charts.
|
|
*
|
|
* @return array{cpu: array<int,int>, mem: array<int,int>}
|
|
*/
|
|
public function history(Server $server): array
|
|
{
|
|
$h = Cache::get("metrics:history:{$server->id}", []);
|
|
|
|
return [
|
|
'cpu' => array_map('intval', array_column($h, 'cpu')),
|
|
'mem' => array_map('intval', array_column($h, 'mem')),
|
|
'disk' => array_map('intval', array_column($h, 'disk')),
|
|
'load' => array_map('floatval', array_column($h, 'load')),
|
|
];
|
|
}
|
|
|
|
private function statusFor(array $m): string
|
|
{
|
|
return match (true) {
|
|
$m['cpu'] >= 90 || $m['mem'] >= 92 || $m['disk'] >= 90 => 'warning',
|
|
default => 'online',
|
|
};
|
|
}
|
|
|
|
// ── Full host snapshot (Server-Details) ─────────────────────────────
|
|
|
|
public function snapshot(Server $server): array
|
|
{
|
|
$ssh = $this->client($server);
|
|
$cmd = 'export LC_ALL=C; '
|
|
.'echo '.self::MARK.'mem===; grep -E "MemTotal|MemAvailable" /proc/meminfo; '
|
|
.'echo '.self::MARK.'stat1===; head -1 /proc/stat; sleep 1; '
|
|
.'echo '.self::MARK.'stat2===; head -1 /proc/stat; '
|
|
.'echo '.self::MARK.'load===; cat /proc/loadavg; '
|
|
.'echo '.self::MARK.'uptime===; cat /proc/uptime; '
|
|
.'echo '.self::MARK.'os===; . /etc/os-release 2>/dev/null; echo "$PRETTY_NAME"; uname -r; uname -m; nproc; hostname; systemd-detect-virt 2>/dev/null || echo -; '
|
|
.'echo '.self::MARK.'df===; df -B1 -x tmpfs -x devtmpfs -x overlay -x squashfs --output=target,fstype,size,pcent; '
|
|
.'echo '.self::MARK.'addr===; ip -o -4 addr show; '
|
|
.'echo '.self::MARK.'link===; ip -o link show; '
|
|
.'echo '.self::MARK.'netdev===; cat /proc/net/dev; '
|
|
// hardening state (sshd -T + pkg checks) is read privileged via HardeningService::state().
|
|
.'echo '.self::MARK.'keys===; ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null';
|
|
|
|
try {
|
|
$s = $this->sections($ssh->exec($cmd));
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
|
|
$osLines = $this->lines($s['os'] ?? '');
|
|
$totalKb = $this->memTotalKb($s['mem'] ?? '');
|
|
$volumes = $this->parseVolumes($s['df'] ?? '');
|
|
$rootBytes = collect($volumes)->firstWhere('mount', '/')['bytes'] ?? 0;
|
|
|
|
return [
|
|
'metrics' => [
|
|
'cpu' => $this->cpuPercent($s['stat1'] ?? '', $s['stat2'] ?? ''),
|
|
'mem' => $this->memPercent($s['mem'] ?? ''),
|
|
'load' => (float) (preg_split('/\s+/', trim($s['load'] ?? '0'))[0] ?? 0),
|
|
],
|
|
'identity' => [
|
|
'os' => $osLines[0] ?? '—',
|
|
'kernel' => $osLines[1] ?? '—',
|
|
'arch' => $osLines[2] ?? '—',
|
|
'cores' => (int) ($osLines[3] ?? 0),
|
|
'hostname' => $osLines[4] ?? '—',
|
|
'virt' => $osLines[5] ?? '—',
|
|
'ram_gb' => $totalKb > 0 ? round($totalKb / 1048576, 1) : 0,
|
|
'disk_gb' => $rootBytes > 0 ? (int) round($rootBytes / 1e9) : 0,
|
|
'uptime' => $this->humanUptime((float) (preg_split('/\s+/', trim($s['uptime'] ?? '0'))[0] ?? 0)),
|
|
],
|
|
'volumes' => $volumes,
|
|
'interfaces' => $this->parseInterfaces($s['addr'] ?? '', $s['link'] ?? '', $s['netdev'] ?? ''),
|
|
'sshKeys' => $this->parseKeys($s['keys'] ?? ''),
|
|
];
|
|
}
|
|
|
|
// ── systemd services + journal (Services page) ──────────────────────
|
|
|
|
/** @return array{services:array<int,array>,journal:array<int,array>,cursor:?string} */
|
|
public function systemd(Server $server, int $journalLines = 25): array
|
|
{
|
|
$ssh = $this->client($server);
|
|
$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===; '.$this->sudoFn($server).'priv journalctl -n '.(int) $journalLines.' --no-pager -o short-iso --show-cursor 2>&1';
|
|
|
|
try {
|
|
$s = $this->sections($ssh->exec($cmd));
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
|
|
[$body, $cursor] = $this->splitJournalCursor($s['journal'] ?? '');
|
|
$journal = $this->parseJournalLines($body);
|
|
if ($journal === []) {
|
|
$journal[] = ['time' => '—', 'unit' => 'journal', 'level' => 'warn', 'text' => __('backend.journal_needs_privileges')];
|
|
}
|
|
|
|
return [
|
|
'services' => $this->parseUnits($s['units'] ?? '', $s['files'] ?? ''),
|
|
'journal' => $journal,
|
|
'cursor' => $cursor,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Incremental journal read for the live poll: only entries AFTER $cursor, plus
|
|
* the new cursor to resume from. Without a valid cursor (first call) it returns
|
|
* the last $tail entries like systemd(). Empty result = nothing new since the
|
|
* cursor (NOT a permission problem), so no placeholder row is injected.
|
|
*
|
|
* This is a bounded LIVE TAIL, not a complete log shipper: `-n $tail` keeps the
|
|
* NEWEST $tail entries since the cursor, so a single tick that bursts past $tail
|
|
* lines shows the most recent ones (what "live" means) and drops the older overflow
|
|
* — which the capped on-screen window would evict anyway. Set $tail to the display
|
|
* ceiling so the visible window is always fully filled. Replaying a full backlog is
|
|
* the deferred web terminal's job, not this poll's.
|
|
*
|
|
* @return array{journal:array<int,array>,cursor:?string}
|
|
*/
|
|
public function journalSince(Server $server, ?string $cursor, int $tail = 200): array
|
|
{
|
|
// Journald cursors are `key=hex` segments joined by `;` — reject anything else
|
|
// before it reaches the shell, then single-quote it.
|
|
$valid = is_string($cursor) && preg_match('/^[a-z0-9;=]+$/i', $cursor) === 1;
|
|
$selector = $valid ? "--after-cursor='".$cursor."'" : '';
|
|
|
|
$ssh = $this->client($server);
|
|
$cmd = 'export LC_ALL=C; '.$this->sudoFn($server)
|
|
.'priv journalctl '.$selector.' -n '.(int) $tail.' --no-pager -o short-iso --show-cursor 2>&1';
|
|
|
|
try {
|
|
$out = $ssh->exec($cmd);
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
|
|
[$body, $next] = $this->splitJournalCursor($out);
|
|
|
|
return [
|
|
'journal' => $this->parseJournalLines($body),
|
|
'cursor' => $next ?? $cursor, // keep the old cursor when nothing new arrived
|
|
];
|
|
}
|
|
|
|
// ── SFTP-ish directory listing (Files page) ─────────────────────────
|
|
|
|
/** @return array<int,array{name:string,type:string,size:?int,perms:string,owner:string,modified:string}> */
|
|
public function files(Server $server, string $path): array
|
|
{
|
|
$ssh = $this->client($server);
|
|
$safe = "'".str_replace("'", "'\\''", $path)."'";
|
|
|
|
try {
|
|
[$out, $code] = $ssh->run('export LC_ALL=C; ls -la --time-style=long-iso '.$safe.' 2>/dev/null');
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
|
|
if ($code !== 0) {
|
|
return [];
|
|
}
|
|
|
|
$entries = [];
|
|
foreach ($this->lines($out) as $line) {
|
|
if ($line === '' || str_starts_with($line, 'total ') || str_starts_with($line, 'insgesamt ')) {
|
|
continue;
|
|
}
|
|
// perms links owner group size YYYY-MM-DD HH:MM name...
|
|
$p = preg_split('/\s+/', $line, 8);
|
|
if (count($p) < 8) {
|
|
continue;
|
|
}
|
|
[$perms, , $owner, , $size, $date, $time, $name] = $p;
|
|
if ($name === '.' || $name === '..') {
|
|
continue;
|
|
}
|
|
$type = match ($perms[0] ?? '-') {
|
|
'd' => 'dir',
|
|
'l' => 'link',
|
|
default => 'file',
|
|
};
|
|
// strip a "-> target" suffix from symlink names
|
|
if ($type === 'link' && str_contains($name, ' -> ')) {
|
|
$name = strstr($name, ' -> ', true);
|
|
}
|
|
$entries[] = [
|
|
'name' => $name,
|
|
'type' => $type,
|
|
'size' => $type === 'dir' ? null : (int) $size,
|
|
'perms' => $perms,
|
|
'owner' => $owner,
|
|
'modified' => $date.' '.$time,
|
|
];
|
|
}
|
|
|
|
usort($entries, fn ($a, $b) => [$a['type'] !== 'dir', $a['name']] <=> [$b['type'] !== 'dir', $b['name']]);
|
|
|
|
return $entries;
|
|
}
|
|
|
|
// ── control (write) actions — work without sudo, in the SSH user's space ─
|
|
|
|
/** Append a public key to the SSH user's authorized_keys. */
|
|
public function addAuthorizedKey(Server $server, string $publicKey): void
|
|
{
|
|
$key = trim(preg_replace('/\s+/', ' ', $publicKey) ?? '');
|
|
|
|
if (! preg_match('#^(ssh-(rsa|ed25519|dss)|ecdsa-sha2-\S+)\s+[A-Za-z0-9+/=]+#', $key)) {
|
|
throw new InvalidArgumentException('Ungueltiger SSH-Public-Key.');
|
|
}
|
|
|
|
$ssh = $this->client($server);
|
|
|
|
try {
|
|
// base64-encode so the key never touches the shell unquoted
|
|
$b64 = base64_encode($key);
|
|
[$out] = $ssh->run(
|
|
'umask 077; mkdir -p ~/.ssh && touch ~/.ssh/authorized_keys && chmod 700 ~/.ssh && chmod 600 ~/.ssh/authorized_keys; '
|
|
."k=$(printf '%s' '{$b64}' | base64 -d); "
|
|
.'grep -qxF "$k" ~/.ssh/authorized_keys || printf "%s\n" "$k" >> ~/.ssh/authorized_keys; echo DONE'
|
|
);
|
|
|
|
if (! str_contains($out, 'DONE')) {
|
|
throw new RuntimeException('Schreiben der authorized_keys fehlgeschlagen.');
|
|
}
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
}
|
|
|
|
/** Remove an authorized key by SHA256 fingerprint, preserving all others. */
|
|
public function removeAuthorizedKey(Server $server, string $fingerprint): void
|
|
{
|
|
$sftp = (new Sftp($this->vault))->connect($server);
|
|
|
|
try {
|
|
$content = $sftp->get('.ssh/authorized_keys');
|
|
if (trim($content) === '') {
|
|
throw new RuntimeException(__('backend.authorized_keys_unreadable'));
|
|
}
|
|
|
|
$lines = array_values(array_filter(
|
|
array_map('rtrim', preg_split('/\R/', $content)),
|
|
fn ($l) => $l !== ''
|
|
));
|
|
$kept = array_values(array_filter($lines, fn ($l) => $this->keyFingerprint($l) !== $fingerprint));
|
|
|
|
if (count($kept) === count($lines)) {
|
|
return; // nothing matched — never rewrite blindly (no lockout)
|
|
}
|
|
|
|
$sftp->put('.ssh/authorized_keys', $kept === [] ? '' : implode("\n", $kept)."\n");
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
private function keyFingerprint(string $line): ?string
|
|
{
|
|
try {
|
|
return 'SHA256:'.PublicKeyLoader::load($line)->getFingerprint('sha256');
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Re-read just the authorized keys (after an add/remove). */
|
|
public function sshKeys(Server $server): array
|
|
{
|
|
$ssh = $this->client($server);
|
|
|
|
try {
|
|
return $this->parseKeys($ssh->exec('ssh-keygen -lf ~/.ssh/authorized_keys 2>/dev/null'));
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
}
|
|
|
|
/** Delete a remote file over SFTP (needs write permission; no sudo). */
|
|
public function deleteFile(Server $server, string $path): void
|
|
{
|
|
$sftp = (new Sftp($this->vault))->connect($server);
|
|
|
|
try {
|
|
if (! $sftp->delete($path)) {
|
|
throw new RuntimeException(__('backend.delete_failed_perms'));
|
|
}
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
/** Raw file content for download (capped at 50 MB). */
|
|
public function getFile(Server $server, string $path): string
|
|
{
|
|
$sftp = (new Sftp($this->vault))->connect($server);
|
|
|
|
try {
|
|
if ($sftp->size($path) > 50_000_000) {
|
|
throw new RuntimeException(__('backend.file_too_large_download'));
|
|
}
|
|
|
|
return (string) $sftp->get($path);
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Read a file for the editor: size-capped, with binary detection.
|
|
*
|
|
* @return array{content: string, binary: bool, tooBig: bool}
|
|
*/
|
|
public function readFile(Server $server, string $path, int $maxBytes = 262144): array
|
|
{
|
|
$sftp = (new Sftp($this->vault))->connect($server);
|
|
|
|
try {
|
|
$size = $sftp->size($path);
|
|
$content = $size > $maxBytes ? '' : (string) $sftp->get($path);
|
|
$binary = $content !== '' && (str_contains(substr($content, 0, 8000), "\0") || ! mb_check_encoding($content, 'UTF-8'));
|
|
|
|
// Never hand back raw non-UTF-8 bytes: bound to a Livewire public property they break the
|
|
// JSON snapshot ("undefined is not valid JSON"). The caller renders a binary notice instead.
|
|
return ['content' => $binary ? '' : $content, 'binary' => $binary, 'tooBig' => $size > $maxBytes];
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
public function writeFile(Server $server, string $path, string $content): void
|
|
{
|
|
$sftp = (new Sftp($this->vault))->connect($server);
|
|
|
|
try {
|
|
if (! $sftp->put($path, $content)) {
|
|
throw new RuntimeException(__('backend.save_failed_perms'));
|
|
}
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
public function uploadFile(Server $server, string $remotePath, string $localPath): void
|
|
{
|
|
$sftp = (new Sftp($this->vault))->connect($server);
|
|
|
|
try {
|
|
if (! $sftp->putFromFile($remotePath, $localPath)) {
|
|
throw new RuntimeException(__('backend.upload_failed_perms'));
|
|
}
|
|
} finally {
|
|
$sftp->disconnect();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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; '.$this->sudoFn($server)."priv systemctl {$op} {$unit} 2>&1"
|
|
);
|
|
|
|
return ['ok' => $code === 0, 'output' => trim($out)];
|
|
} finally {
|
|
$ssh->disconnect();
|
|
}
|
|
}
|
|
|
|
// ── parsers ─────────────────────────────────────────────────────────
|
|
|
|
private function lines(string $body): array
|
|
{
|
|
return array_values(array_filter(
|
|
array_map('rtrim', preg_split('/\R/', $body)),
|
|
fn ($l) => $l !== ''
|
|
));
|
|
}
|
|
|
|
private function cpuPercent(string $a, string $b): int
|
|
{
|
|
$fa = $this->cpuFields($a);
|
|
$fb = $this->cpuFields($b);
|
|
if (! $fa || ! $fb) {
|
|
return 0;
|
|
}
|
|
$totalA = array_sum($fa);
|
|
$totalB = array_sum($fb);
|
|
$dTotal = $totalB - $totalA;
|
|
$dIdle = ($fb['idle'] + $fb['iowait']) - ($fa['idle'] + $fa['iowait']);
|
|
if ($dTotal <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return max(0, min(100, (int) round(100 * ($dTotal - $dIdle) / $dTotal)));
|
|
}
|
|
|
|
private function cpuFields(string $line): array
|
|
{
|
|
$p = preg_split('/\s+/', trim($line));
|
|
if (($p[0] ?? '') !== 'cpu') {
|
|
return [];
|
|
}
|
|
$n = array_map('intval', array_slice($p, 1, 8));
|
|
|
|
return [
|
|
'user' => $n[0] ?? 0, 'nice' => $n[1] ?? 0, 'system' => $n[2] ?? 0,
|
|
'idle' => $n[3] ?? 0, 'iowait' => $n[4] ?? 0, 'irq' => $n[5] ?? 0,
|
|
'softirq' => $n[6] ?? 0, 'steal' => $n[7] ?? 0,
|
|
];
|
|
}
|
|
|
|
private function memTotalKb(string $body): int
|
|
{
|
|
return (int) (preg_match('/MemTotal:\s+(\d+)/', $body, $m) ? $m[1] : 0);
|
|
}
|
|
|
|
private function memPercent(string $body): int
|
|
{
|
|
$total = $this->memTotalKb($body);
|
|
$avail = (int) (preg_match('/MemAvailable:\s+(\d+)/', $body, $m) ? $m[1] : 0);
|
|
|
|
return $total > 0 ? max(0, min(100, (int) round(100 * ($total - $avail) / $total))) : 0;
|
|
}
|
|
|
|
private function humanUptime(float $seconds): string
|
|
{
|
|
$d = intdiv((int) $seconds, 86400);
|
|
$h = intdiv((int) $seconds % 86400, 3600);
|
|
|
|
return $d > 0 ? "{$d}d {$h}h" : "{$h}h";
|
|
}
|
|
|
|
private function parseVolumes(string $body): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->lines($body) as $line) {
|
|
// df --output=target,fstype,size,pcent; the mount (target) can contain
|
|
// spaces, so pop the three trailing single-token fields and rejoin the rest.
|
|
$p = preg_split('/\s+/', trim($line));
|
|
if (count($p) < 4 || ! str_starts_with($p[0], '/')) {
|
|
continue; // header / noise
|
|
}
|
|
$pcent = array_pop($p);
|
|
$size = array_pop($p);
|
|
$fs = array_pop($p);
|
|
$mount = implode(' ', $p);
|
|
if (! ctype_digit($size)) {
|
|
continue;
|
|
}
|
|
$out[] = [
|
|
'mount' => $mount,
|
|
'fs' => $fs,
|
|
'size' => $this->humanBytes((int) $size),
|
|
'bytes' => (int) $size,
|
|
'used' => (int) str_replace('%', '', $pcent),
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function parseInterfaces(string $addr, string $link, string $netdev): array
|
|
{
|
|
$ips = [];
|
|
foreach ($this->lines($addr) as $line) {
|
|
// "2: ens18 inet 10.10.90.162/24 brd ..."
|
|
if (preg_match('/^\d+:\s+(\S+)\s+inet\s+([\d.]+)/', $line, $m)) {
|
|
$ips[$m[1]][] = $m[2];
|
|
}
|
|
}
|
|
$macs = [];
|
|
foreach ($this->lines($link) as $line) {
|
|
if (preg_match('/^\d+:\s+([^:@]+)[:@].*link\/\w+\s+([0-9a-f:]{17})/', $line, $m)) {
|
|
$macs[trim($m[1])] = $m[2];
|
|
}
|
|
}
|
|
$traffic = [];
|
|
foreach ($this->lines($netdev) as $line) {
|
|
if (preg_match('/^\s*([^:]+):\s*(\d+)(?:\s+\d+){7}\s+(\d+)/', $line, $m)) {
|
|
$traffic[trim($m[1])] = ['rx' => (int) $m[2], 'tx' => (int) $m[3]];
|
|
}
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($ips as $name => $list) {
|
|
$out[] = [
|
|
'name' => $name,
|
|
'ip' => implode(', ', $list),
|
|
'mac' => $macs[$name] ?? '—',
|
|
'rx' => isset($traffic[$name]) ? $this->humanBytes($traffic[$name]['rx']) : '—',
|
|
'tx' => isset($traffic[$name]) ? $this->humanBytes($traffic[$name]['tx']) : '—',
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function parseKeys(string $body): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->lines($body) as $line) {
|
|
// "3072 SHA256:xxxx comment with spaces (RSA)"
|
|
if (! preg_match('/^(\d+)\s+(\S+)\s+(.*?)\s*\(([^)]+)\)\s*$/', $line, $m)) {
|
|
continue;
|
|
}
|
|
$algo = strtolower($m[4]);
|
|
$out[] = [
|
|
'comment' => trim($m[3]) ?: '—',
|
|
'type' => $algo.'-'.$m[1],
|
|
'fingerprint' => $m[2],
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function parseUnits(string $units, string $files): array
|
|
{
|
|
$enabled = [];
|
|
foreach ($this->lines($files) as $line) {
|
|
$p = preg_split('/\s+/', trim($line));
|
|
if (count($p) >= 2) {
|
|
$enabled[$p[0]] = in_array($p[1], ['enabled', 'static', 'enabled-runtime'], true);
|
|
}
|
|
}
|
|
|
|
$out = [];
|
|
foreach ($this->lines($units) as $line) {
|
|
// UNIT LOAD ACTIVE SUB DESCRIPTION
|
|
$p = preg_split('/\s+/', trim($line), 5);
|
|
if (count($p) < 5) {
|
|
continue;
|
|
}
|
|
[$unit, $load, $active, $sub, $desc] = $p;
|
|
if ($load === 'not-found') {
|
|
continue;
|
|
}
|
|
$out[] = [
|
|
'name' => $unit,
|
|
'status' => match ($active) {
|
|
'active' => 'online',
|
|
'failed' => 'offline',
|
|
'activating', 'deactivating', 'reloading' => 'warning',
|
|
default => 'offline', // inactive/dead
|
|
},
|
|
'sub' => $sub,
|
|
'enabled' => $enabled[$unit] ?? false,
|
|
'desc' => $desc,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Pull the trailing `-- cursor: X` line (journalctl --show-cursor) off the body.
|
|
*
|
|
* @return array{0:string,1:?string} [body without the cursor line, cursor or null]
|
|
*/
|
|
private function splitJournalCursor(string $body): array
|
|
{
|
|
$cursor = null;
|
|
$kept = [];
|
|
foreach ($this->lines($body) as $line) {
|
|
if (preg_match('/^-- cursor:\s*(\S+)/', $line, $m)) {
|
|
$cursor = $m[1];
|
|
|
|
continue;
|
|
}
|
|
$kept[] = $line;
|
|
}
|
|
|
|
return [implode("\n", $kept), $cursor];
|
|
}
|
|
|
|
/** Parse journalctl `short-iso` lines into structured rows. No placeholder on empty. */
|
|
private function parseJournalLines(string $body): array
|
|
{
|
|
$out = [];
|
|
foreach ($this->lines($body) as $line) {
|
|
// "2026-06-12T21:05:26+02:00 host unit[pid]: message"
|
|
if (! preg_match('/^(\d{4}-\d\d-\d\dT[\d:]+)\S*\s+\S+\s+([^\s\[]+)(?:\[\d+\])?:\s+(.*)$/', $line, $m)) {
|
|
continue;
|
|
}
|
|
$text = $m[3];
|
|
$level = match (true) {
|
|
(bool) preg_match('/\b(fail|failed|error|fatal|denied|refused)\b/i', $text) => 'error',
|
|
(bool) preg_match('/\b(warn|warning|deprecat)\b/i', $text) => 'warn',
|
|
default => 'info',
|
|
};
|
|
$out[] = [
|
|
'time' => str_replace('T', ' ', substr($m[1], 0, 19)),
|
|
'unit' => $m[2],
|
|
'level' => $level,
|
|
'text' => $text,
|
|
];
|
|
}
|
|
|
|
return $out;
|
|
}
|
|
|
|
private function humanBytes(int $bytes): string
|
|
{
|
|
if ($bytes <= 0) {
|
|
return '0 B';
|
|
}
|
|
$units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
|
$i = (int) floor(log($bytes, 1024));
|
|
$i = min($i, count($units) - 1);
|
|
|
|
return round($bytes / (1024 ** $i), $i >= 3 ? 2 : 0).' '.$units[$i];
|
|
}
|
|
}
|