clusev/app/Services/DockerService.php

118 lines
4.2 KiB
PHP

<?php
namespace App\Services;
use App\Models\Server;
use InvalidArgumentException;
/**
* Manage the containers running ON a fleet server, agentlessly over SSH. Every docker call goes
* through FleetService::runPrivileged (base64 transport → shell-injection-safe); every container
* id/name is validated before interpolation so a crafted name can neither inject a command nor a
* leading-dash argument. Mirrors the FleetService::serviceAction discipline.
*/
class DockerService
{
public const ACTIONS = ['start', 'stop', 'restart', 'pause', 'unpause'];
public function __construct(private FleetService $fleet) {}
public function available(Server $server): bool
{
$res = $this->fleet->runPrivileged($server, 'command -v docker >/dev/null 2>&1 && echo yes || echo no');
return trim($res['output']) === 'yes';
}
/**
* @return array<int, array{id:string,name:string,image:string,state:string,status:string,ports:string}>
*/
public function containers(Server $server): array
{
$res = $this->fleet->runPrivileged($server, "docker ps -a --format '{{json .}}'");
if (! $res['ok']) {
return []; // docker missing / daemon down / permission → empty, never throw to the UI
}
$out = [];
foreach (preg_split('/\r?\n/', trim($res['output'])) ?: [] as $line) {
$line = trim($line);
if ($line === '') {
continue;
}
$row = json_decode($line, true);
if (! is_array($row)) {
continue;
}
$out[] = [
'id' => (string) ($row['ID'] ?? ''),
'name' => (string) ($row['Names'] ?? ''),
'image' => (string) ($row['Image'] ?? ''),
'state' => strtolower((string) ($row['State'] ?? '')),
'status' => (string) ($row['Status'] ?? ''),
'ports' => (string) ($row['Ports'] ?? ''),
];
}
return $out;
}
public function containerAction(Server $server, string $id, string $op): array
{
if (! in_array($op, self::ACTIONS, true)) {
throw new InvalidArgumentException('Unbekannte Aktion.');
}
$this->assertValidRef($id);
$res = $this->fleet->runPrivileged($server, "docker {$op} {$id}");
return ['ok' => $res['ok'], 'output' => trim($res['output'])];
}
public function logs(Server $server, string $id, int $lines = 200): string
{
$this->assertValidRef($id);
$lines = max(1, min(2000, $lines));
// Cap by BYTES too (not just lines): one huge line or a binary-ish log could otherwise bloat
// the response or, via invalid UTF-8, break Livewire's JSON snapshot. head -c bounds it, and
// mb_scrub drops any invalid byte sequence before it reaches a Livewire public property.
$res = $this->fleet->runPrivileged($server, "docker logs --tail {$lines} {$id} 2>&1 | head -c 262144");
return mb_scrub(trim($res['output']), 'UTF-8');
}
/**
* @return array<int, array{name:string,status:string}>
*/
public function composeStacks(Server $server): array
{
$res = $this->fleet->runPrivileged($server, 'docker compose ls --format json 2>/dev/null');
if (! $res['ok']) {
return [];
}
$rows = json_decode(trim($res['output']), true);
if (! is_array($rows)) {
return [];
}
return array_map(fn ($r) => [
'name' => (string) ($r['Name'] ?? ''),
'status' => (string) ($r['Status'] ?? ''),
], array_values(array_filter($rows, 'is_array')));
}
/**
* A container id or name — hex id or a docker name. MUST start alphanumeric (blocks a leading
* dash = argument injection) and contain only [\w.-] (no shell metacharacters).
*/
private function assertValidRef(string $ref): void
{
// \A ... \z (not ^...$): \z anchors the ABSOLUTE end so a trailing newline can't sneak past.
if (! preg_match('/\A[a-zA-Z0-9][\w.-]*\z/', $ref)) {
throw new InvalidArgumentException('Ungueltige Container-Referenz.');
}
}
}