mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
fix(files): browse containers without a shell through a helper container
Closes #12305 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
88070a15d5
commit
b4a4df2706
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* The container cannot run `sh` (scratch or distroless images), so the
|
||||
* file browser cannot list or change its files with docker exec.
|
||||
*/
|
||||
class ContainerShellUnavailableException extends RuntimeException {}
|
||||
@@ -24,7 +24,7 @@ class FileBrowserDownloadController extends Controller
|
||||
|
||||
public function __invoke(string $token): StreamedResponse
|
||||
{
|
||||
/** @var array{user_id:int,resource_type:class-string,resource_id:int,server_id:int,container:string,path:string,name:string,is_dir:bool}|null $download */
|
||||
/** @var array{user_id:int,resource_type:class-string,resource_id:int,server_id:int,container:string,path:string,name:string,is_dir:bool,use_helper?:bool}|null $download */
|
||||
$download = Cache::pull(self::cacheKey($token));
|
||||
abort_unless(is_array($download) && $download['user_id'] === auth()->id(), 404);
|
||||
|
||||
@@ -33,7 +33,7 @@ class FileBrowserDownloadController extends Controller
|
||||
Gate::authorize('view', $resource);
|
||||
abort_unless((int) $resource->environment->project->team_id === (int) auth()->user()->currentTeam()->id, 403);
|
||||
|
||||
$filesystem = new ContainerFilesystemService(Server::findOrFail($download['server_id']), $download['container']);
|
||||
$filesystem = new ContainerFilesystemService(Server::findOrFail($download['server_id']), $download['container'], $download['use_helper'] ?? false);
|
||||
|
||||
return response()->streamDownload(function () use ($filesystem, $download) {
|
||||
set_time_limit(0);
|
||||
|
||||
@@ -36,6 +36,7 @@ class PrepareFileDownloadJob implements ShouldQueue
|
||||
public bool $isDir,
|
||||
public string $downloadName,
|
||||
public int $s3StorageId,
|
||||
public bool $useHelper = false,
|
||||
) {}
|
||||
|
||||
public static function cacheKey(string $downloadId): string
|
||||
@@ -60,7 +61,7 @@ class PrepareFileDownloadJob implements ShouldQueue
|
||||
if ($this->container === null) {
|
||||
instant_remote_process([FileDownloadStorageService::buildHostFileUploadCommand($this->path, $upload['url'], $upload['headers'])], $server, timeout: 3600);
|
||||
} else {
|
||||
(new ContainerFilesystemService($server, $this->container))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']);
|
||||
(new ContainerFilesystemService($server, $this->container, $this->useHelper))->uploadToS3($this->path, $this->isDir, $upload['url'], $upload['headers']);
|
||||
}
|
||||
|
||||
$this->store(['status' => 'ready', 'url' => $downloads->downloadUrl($storage, $key)]);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Livewire\Project\Shared;
|
||||
|
||||
use App\Exceptions\ContainerShellUnavailableException;
|
||||
use App\Http\Controllers\FileBrowserDownloadController;
|
||||
use App\Jobs\PrepareFileDownloadJob;
|
||||
use App\Models\Application;
|
||||
@@ -14,6 +15,7 @@ use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Attributes\Locked;
|
||||
@@ -44,6 +46,17 @@ class FileBrowser extends Component
|
||||
#[Locked]
|
||||
public bool $loadFailed = false;
|
||||
|
||||
/** The container has no usable shell, so a helper container reaches its files. */
|
||||
#[Locked]
|
||||
public bool $useHelper = false;
|
||||
|
||||
#[Locked]
|
||||
public bool $shellUnavailable = false;
|
||||
|
||||
/** Why the helper container failed, shown when the reason is known. */
|
||||
#[Locked]
|
||||
public ?string $helperError = null;
|
||||
|
||||
#[Locked]
|
||||
public ?string $container = null;
|
||||
|
||||
@@ -103,6 +116,7 @@ class FileBrowser extends Component
|
||||
try {
|
||||
$this->availableContainers = $this->containerRunning ? $this->resolveContainers() : [];
|
||||
$this->container = $this->availableContainers[0] ?? null;
|
||||
$this->useHelper = false;
|
||||
if (is_null($this->container)) {
|
||||
$this->containerRunning = false;
|
||||
} else {
|
||||
@@ -125,6 +139,7 @@ class FileBrowser extends Component
|
||||
}
|
||||
$this->closeEditor();
|
||||
$this->container = $name;
|
||||
$this->useHelper = false;
|
||||
$this->currentPath = $this->service()->defaultRoot();
|
||||
$this->loadEntries();
|
||||
}
|
||||
@@ -132,6 +147,9 @@ class FileBrowser extends Component
|
||||
public function open(string $name): void
|
||||
{
|
||||
$this->guard();
|
||||
if ($this->isHelperSymlink($name)) {
|
||||
return;
|
||||
}
|
||||
$this->currentPath = $this->childPath($name);
|
||||
$this->loadEntries();
|
||||
}
|
||||
@@ -215,6 +233,9 @@ class FileBrowser extends Component
|
||||
public function openEditor(string $name): void
|
||||
{
|
||||
$this->guard();
|
||||
if ($this->isHelperSymlink($name)) {
|
||||
return;
|
||||
}
|
||||
$path = $this->childPath($name);
|
||||
try {
|
||||
// read() guards binary/oversized files internally and throws.
|
||||
@@ -276,6 +297,9 @@ class FileBrowser extends Component
|
||||
public function download(string $name): void
|
||||
{
|
||||
$this->guard();
|
||||
if ($this->isHelperSymlink($name)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$path = $this->childPath($name);
|
||||
$target = $this->service()->downloadInfo($path);
|
||||
@@ -297,7 +321,7 @@ class FileBrowser extends Component
|
||||
}
|
||||
$downloadId = (string) Str::uuid();
|
||||
$this->pendingDownloads[] = $downloadId;
|
||||
PrepareFileDownloadJob::dispatch($downloadId, auth()->id(), $this->server()->id, $this->container, $path, $target['isDir'], $downloadName, $s3Storage->id);
|
||||
PrepareFileDownloadJob::dispatch($downloadId, auth()->id(), $this->server()->id, $this->container, $path, $target['isDir'], $downloadName, $s3Storage->id, $this->useHelper);
|
||||
$this->dispatch('info', 'Preparing '.$downloadName.'. The download starts when it is ready.');
|
||||
|
||||
return;
|
||||
@@ -313,6 +337,7 @@ class FileBrowser extends Component
|
||||
'path' => $path,
|
||||
'name' => $downloadName,
|
||||
'is_dir' => $target['isDir'],
|
||||
'use_helper' => $this->useHelper,
|
||||
], FileBrowserDownloadController::TOKEN_TTL_SECONDS);
|
||||
|
||||
$this->redirect(route('project.files.download', $token));
|
||||
@@ -442,7 +467,24 @@ class FileBrowser extends Component
|
||||
|
||||
protected function loadEntries(): void
|
||||
{
|
||||
$entries = $this->service()->list($this->currentPath);
|
||||
$this->shellUnavailable = false;
|
||||
$this->helperError = null;
|
||||
try {
|
||||
$entries = $this->service()->list($this->currentPath);
|
||||
} catch (ContainerShellUnavailableException $e) {
|
||||
if (! $this->useHelper) {
|
||||
$this->useHelper = true;
|
||||
$this->loadEntries();
|
||||
|
||||
return;
|
||||
}
|
||||
Log::warning('File browser helper container failed', ['container' => $this->container, 'error' => $e->getMessage()]);
|
||||
$entries = [];
|
||||
$this->shellUnavailable = true;
|
||||
if (preg_match('/Unable to find image|failed to resolve reference|pull access denied|manifest unknown/i', $e->getMessage())) {
|
||||
$this->helperError = 'The server could not pull the Coolify helper image ('.coolifyHelperImage().':'.getHelperVersion().'). Check that the server can reach the registry.';
|
||||
}
|
||||
}
|
||||
$this->entries = array_map(fn ($e) => [
|
||||
'name' => $e->name,
|
||||
'type' => $e->type,
|
||||
@@ -456,7 +498,22 @@ class FileBrowser extends Component
|
||||
|
||||
protected function service(): ContainerFilesystemService
|
||||
{
|
||||
return new ContainerFilesystemService($this->server(), $this->container);
|
||||
return new ContainerFilesystemService($this->server(), $this->container, $this->useHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* The helper resolves absolute symlinks in its own file system, not in the
|
||||
* container, so it would show the wrong files. It cannot change them,
|
||||
* because its root file system is read-only.
|
||||
*/
|
||||
protected function isHelperSymlink(string $name): bool
|
||||
{
|
||||
if (! $this->useHelper || ! collect($this->entries)->contains(fn (array $entry) => $entry['name'] === $name && $entry['type'] === 'symlink')) {
|
||||
return false;
|
||||
}
|
||||
$this->dispatch('error', 'Symlinks cannot be opened in a container without a shell. Open the target path instead.');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
protected function server(): Server
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\FileEntry;
|
||||
use App\Exceptions\ContainerShellUnavailableException;
|
||||
use App\Helpers\SshMultiplexingHelper;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\Server;
|
||||
@@ -20,9 +21,30 @@ class ContainerFilesystemService
|
||||
/** Largest file, or uncompressed folder, the file browser may download. */
|
||||
public const MAX_DOWNLOAD_SIZE = 1024 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Linux capability names by bit number, as Docker expects them in --cap-add.
|
||||
*/
|
||||
private const CAPABILITIES = [
|
||||
'CHOWN', 'DAC_OVERRIDE', 'DAC_READ_SEARCH', 'FOWNER', 'FSETID', 'KILL', 'SETGID', 'SETUID',
|
||||
'SETPCAP', 'LINUX_IMMUTABLE', 'NET_BIND_SERVICE', 'NET_BROADCAST', 'NET_ADMIN', 'NET_RAW',
|
||||
'IPC_LOCK', 'IPC_OWNER', 'SYS_MODULE', 'SYS_RAWIO', 'SYS_CHROOT', 'SYS_PTRACE', 'SYS_PACCT',
|
||||
'SYS_ADMIN', 'SYS_BOOT', 'SYS_NICE', 'SYS_RESOURCE', 'SYS_TIME', 'SYS_TTY_CONFIG', 'MKNOD',
|
||||
'LEASE', 'AUDIT_WRITE', 'AUDIT_CONTROL', 'SETFCAP', 'MAC_OVERRIDE', 'MAC_ADMIN', 'SYSLOG',
|
||||
'WAKE_ALARM', 'BLOCK_SUSPEND', 'AUDIT_READ', 'PERFMON', 'BPF', 'CHECKPOINT_RESTORE',
|
||||
];
|
||||
|
||||
/** @var array{user: string, groups: array<int, string>, capabilities: array<int, string>}|null */
|
||||
private ?array $helperIdentity = null;
|
||||
|
||||
/**
|
||||
* With $useHelper, commands run in a short-lived helper container that
|
||||
* shares the PID namespace of the target and reaches its files through
|
||||
* /proc/1/root. This supports images without a shell (scratch, distroless).
|
||||
*/
|
||||
public function __construct(
|
||||
private Server $server,
|
||||
private string $container,
|
||||
private bool $useHelper = false,
|
||||
) {}
|
||||
|
||||
public function buildListCommand(string $path): string
|
||||
@@ -41,18 +63,34 @@ class ContainerFilesystemService
|
||||
// strips that prefix. Names containing a tab/newline are skipped (v1 scope).
|
||||
$tab = "\t";
|
||||
$format = '%F'.$tab.'%s'.$tab.'%Y'.$tab.'%a'.$tab.'%U'.$tab.'%G'.$tab.'%n';
|
||||
$inner = 'cd '.$escapedPath.' 2>/dev/null || exit 0; '
|
||||
.'find . ! -name . -prune -exec stat -c "'.$format.'" -- {} + 2>/dev/null';
|
||||
// Exit 127 when find or stat is missing, so list() can switch to the helper.
|
||||
$inner = 'command -v find >/dev/null && command -v stat >/dev/null || exit 127; '
|
||||
.'cd '.$escapedPath.' 2>/dev/null || exit 0; '
|
||||
.'find . ! -name . -prune -exec stat -c "'.$format.'" -- {} + 2>/dev/null; exit 0';
|
||||
|
||||
return $this->dockerExecShell($inner);
|
||||
}
|
||||
|
||||
/**
|
||||
* docker exec exits with 126 or 127 when it cannot start `sh` or the
|
||||
* listing tools in the container. Other docker exec failures still list
|
||||
* as an empty folder. In helper mode, every failure is reported.
|
||||
*
|
||||
* @return array<int, FileEntry>
|
||||
*
|
||||
* @throws ContainerShellUnavailableException when the files cannot be listed this way
|
||||
*/
|
||||
public function list(string $path): array
|
||||
{
|
||||
$raw = instant_remote_process([$this->buildListCommand($path)], $this->server, throwError: false);
|
||||
try {
|
||||
$raw = instant_remote_process([$this->buildListCommand($path)], $this->server);
|
||||
} catch (\RuntimeException $e) {
|
||||
if ($this->useHelper || in_array($e->getCode(), [126, 127], true)) {
|
||||
throw new ContainerShellUnavailableException($e->getMessage(), (int) $e->getCode(), $e);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
return $this->parseListing($raw);
|
||||
}
|
||||
@@ -96,6 +134,10 @@ class ContainerFilesystemService
|
||||
if ($status !== 'OK' || $content === false || strlen($content) > $max) {
|
||||
throw new \RuntimeException('Could not read file.');
|
||||
}
|
||||
// BusyBox grep ignores -I, so a binary file can pass the remote probe.
|
||||
if (str_contains($content, "\0")) {
|
||||
throw new \RuntimeException('File is not editable (binary or too large).');
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
@@ -337,6 +379,9 @@ class ContainerFilesystemService
|
||||
public function buildDownloadCommand(string $path, bool $isDir): string
|
||||
{
|
||||
$escaped = $this->escapePath($path, 'download path');
|
||||
if ($this->useHelper) {
|
||||
return $this->dockerExecShell($isDir ? "tar czf - -C {$escaped} ." : "cat -- {$escaped}");
|
||||
}
|
||||
$escapedContainer = escapeshellarg($this->container);
|
||||
|
||||
return $isDir
|
||||
@@ -414,13 +459,94 @@ class ContainerFilesystemService
|
||||
{
|
||||
validateShellSafePath($path, $label);
|
||||
|
||||
return escapeshellarg($path);
|
||||
return escapeshellarg(($this->useHelper ? '/proc/1/root' : '').$path);
|
||||
}
|
||||
|
||||
protected function dockerExecShell(string $innerShell): string
|
||||
{
|
||||
$escapedContainer = escapeshellarg($this->container);
|
||||
if (! $this->useHelper) {
|
||||
return "docker exec {$escapedContainer} sh -c ".escapeshellarg($innerShell);
|
||||
}
|
||||
|
||||
return "docker exec {$escapedContainer} sh -c ".escapeshellarg($innerShell);
|
||||
// The helper gets exactly the user, groups and capabilities of the
|
||||
// target process: the kernel requires them to reach /proc/1/root,
|
||||
// and nothing more is given. It has no network, a read-only root
|
||||
// file system, and no Docker socket or host mounts, and it is removed
|
||||
// when the command ends. Exit 126 means the kernel denied access.
|
||||
$identity = $this->helperIdentity();
|
||||
$options = [
|
||||
'--rm',
|
||||
'--pid container:'.$escapedContainer,
|
||||
'--user '.escapeshellarg($identity['user']),
|
||||
...array_map(fn (string $group) => '--group-add '.escapeshellarg($group), $identity['groups']),
|
||||
'--cap-drop ALL',
|
||||
...array_map(fn (string $capability) => '--cap-add '.$capability, $identity['capabilities']),
|
||||
'--security-opt no-new-privileges',
|
||||
'--network none',
|
||||
'--ipc none',
|
||||
'--read-only',
|
||||
'--log-driver none',
|
||||
'--pids-limit 64',
|
||||
'--label coolify.file-browser=true',
|
||||
"--entrypoint ''",
|
||||
];
|
||||
$image = escapeshellarg(coolifyHelperImage().':'.getHelperVersion());
|
||||
|
||||
return 'docker run '.implode(' ', $options)." {$image} sh -c "
|
||||
.escapeshellarg('[ -x /proc/1/root/ ] || exit 126; '.$innerShell);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the effective user, group, supplementary groups and permitted
|
||||
* capabilities of the main process of the target container.
|
||||
*
|
||||
* @return array{user: string, groups: array<int, string>, capabilities: array<int, string>}
|
||||
*/
|
||||
protected function helperIdentity(): array
|
||||
{
|
||||
if ($this->helperIdentity !== null) {
|
||||
return $this->helperIdentity;
|
||||
}
|
||||
|
||||
$escapedContainer = escapeshellarg($this->container);
|
||||
$status = (string) instant_remote_process(
|
||||
["cat /proc/$(docker inspect --format '{{.State.Pid}}' {$escapedContainer})/status"],
|
||||
$this->server,
|
||||
);
|
||||
|
||||
return $this->helperIdentity = $this->parseProcessStatus($status);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{user: string, groups: array<int, string>, capabilities: array<int, string>}
|
||||
*/
|
||||
public function parseProcessStatus(string $status): array
|
||||
{
|
||||
$fields = [];
|
||||
foreach (explode("\n", $status) as $line) {
|
||||
[$key, $value] = array_pad(explode(':', $line, 2), 2, '');
|
||||
$fields[trim($key)] = preg_split('/\s+/', trim($value), -1, PREG_SPLIT_NO_EMPTY);
|
||||
}
|
||||
|
||||
$uid = $fields['Uid'][1] ?? '';
|
||||
$gid = $fields['Gid'][1] ?? '';
|
||||
$capabilityMask = $fields['CapPrm'][0] ?? '';
|
||||
if (! ctype_digit($uid) || ! ctype_digit($gid) || ! ctype_xdigit($capabilityMask) || strlen($capabilityMask) > 16) {
|
||||
throw new \RuntimeException('Could not read the user of the container process.');
|
||||
}
|
||||
|
||||
$groups = array_values(array_filter($fields['Groups'] ?? [], fn (string $group) => ctype_digit($group) && $group !== $gid));
|
||||
|
||||
// Read the mask one hex digit at a time; hexdec() turns 64-bit masks into floats.
|
||||
$digits = strrev($capabilityMask);
|
||||
$capabilities = [];
|
||||
foreach (self::CAPABILITIES as $bit => $name) {
|
||||
if (hexdec($digits[intdiv($bit, 4)] ?? '0') & (1 << ($bit % 4))) {
|
||||
$capabilities[] = $name;
|
||||
}
|
||||
}
|
||||
|
||||
return ['user' => "{$uid}:{$gid}", 'groups' => $groups, 'capabilities' => $capabilities];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@
|
||||
delay skips the overlay for fast requests. --}}
|
||||
run(label, action) {
|
||||
const timer = setTimeout(() => this.busy = label, 150);
|
||||
return Promise.resolve().then(action).catch(() => {}).finally(() => {
|
||||
{{-- Start the action now: callers clear their inputs right after run(). --}}
|
||||
return new Promise((resolve) => resolve(action())).catch(() => {}).finally(() => {
|
||||
clearTimeout(timer);
|
||||
this.busy = null;
|
||||
});
|
||||
@@ -64,7 +65,7 @@
|
||||
</x-slot:badges>
|
||||
|
||||
<x-slot:actions>
|
||||
@if ($containerRunning && $filesLoaded)
|
||||
@if ($containerRunning && $filesLoaded && ! $shellUnavailable)
|
||||
<input type="file" class="hidden" x-ref="uploadInput"
|
||||
@change="if ($event.target.files.length) { uploadFile($event.target.files[0]); $event.target.value = '' }" />
|
||||
<x-forms.button type="button" title="Upload a file to this folder" canGate="canAccessTerminal" :canResource="$resource"
|
||||
@@ -153,6 +154,13 @@
|
||||
<x-reicon name="folder" class="size-8" />
|
||||
</x-slot:icon>
|
||||
</x-empty>
|
||||
@elseif ($shellUnavailable)
|
||||
<x-empty title="File browser is not available"
|
||||
description="Coolify cannot open the files of this container. {{ $helperError }}">
|
||||
<x-slot:icon>
|
||||
<x-reicon name="folder" class="size-8" />
|
||||
</x-slot:icon>
|
||||
</x-empty>
|
||||
@else
|
||||
{{-- Keep the current listing visible while an action runs: blur it
|
||||
and show the action label (from run()) on top of it. --}}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
use App\Data\FileEntry;
|
||||
use App\Exceptions\ContainerShellUnavailableException;
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
@@ -114,6 +115,108 @@ it('lists a directory by running the built command over SSH', function () {
|
||||
expect($entries[0]->name)->toBe('src');
|
||||
});
|
||||
|
||||
it('reports a container without a shell instead of an empty folder', function (int $exitCode) {
|
||||
Process::fake(['*' => Process::result(
|
||||
errorOutput: 'OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH',
|
||||
exitCode: $exitCode,
|
||||
)]);
|
||||
|
||||
(new ContainerFilesystemService(fsServer(), 'app-123'))->list('/');
|
||||
})->with([126, 127])->throws(ContainerShellUnavailableException::class);
|
||||
|
||||
it('lists other docker exec failures as an empty folder', function () {
|
||||
Process::fake(['*' => Process::result(errorOutput: 'Error response from daemon: container is not running', exitCode: 1)]);
|
||||
|
||||
expect((new ContainerFilesystemService(fsServer(), 'app-123'))->list('/'))->toBe([]);
|
||||
});
|
||||
|
||||
function fsProcessStatus(string $uid, string $gid, string $groups, string $capPrm): string
|
||||
{
|
||||
return "Name:\tapp\nUid:\t{$uid}\t{$uid}\t{$uid}\t{$uid}\nGid:\t{$gid}\t{$gid}\t{$gid}\t{$gid}\nGroups:\t{$groups} \nCapInh:\t0000000000000000\nCapPrm:\t{$capPrm}\nCapEff:\t{$capPrm}\n";
|
||||
}
|
||||
|
||||
it('reads the user, groups and capabilities of the container process for the helper', function () {
|
||||
// Docker's default capability set for a root process.
|
||||
expect(fsService()->parseProcessStatus(fsProcessStatus('0', '0', '0 1 2', '00000000a80425fb')))->toBe([
|
||||
'user' => '0:0',
|
||||
'groups' => ['1', '2'],
|
||||
'capabilities' => ['CHOWN', 'DAC_OVERRIDE', 'FOWNER', 'FSETID', 'KILL', 'SETGID', 'SETUID', 'SETPCAP', 'NET_BIND_SERVICE', 'NET_RAW', 'SYS_CHROOT', 'MKNOD', 'AUDIT_WRITE', 'SETFCAP'],
|
||||
]);
|
||||
expect(fsService()->parseProcessStatus(fsProcessStatus('65532', '0', '0', '0000000000000000')))
|
||||
->toBe(['user' => '65532:0', 'groups' => [], 'capabilities' => []]);
|
||||
expect(fsService()->parseProcessStatus(fsProcessStatus('0', '0', '', '000001ffffffffff'))['capabilities'])
|
||||
->toHaveCount(41);
|
||||
});
|
||||
|
||||
it('rejects an unreadable process status', function (string $status) {
|
||||
fsService()->parseProcessStatus($status);
|
||||
})->with(['', fsProcessStatus('x', '0', '', '0'), fsProcessStatus('0', '0', '', 'zz')])->throws(RuntimeException::class);
|
||||
|
||||
it('lists through a locked-down helper container that runs as the container process', function () {
|
||||
$server = fsServer();
|
||||
$server->update(['user' => 'root']);
|
||||
$commands = [];
|
||||
Process::fake(function ($process) use (&$commands) {
|
||||
$commands[] = $process->command;
|
||||
|
||||
return str_contains($process->command, '/status')
|
||||
? Process::result(output: fsProcessStatus('65532', '0', '0 1000', '0000000000000000'))
|
||||
: Process::result(output: "regular file\t9\t1\t644\troot\troot\t./users.yml");
|
||||
});
|
||||
|
||||
$entries = (new ContainerFilesystemService($server, 'app-123', useHelper: true))->list('/data');
|
||||
|
||||
expect($entries)->toHaveCount(1)
|
||||
->and($entries[0]->name)->toBe('users.yml')
|
||||
->and($commands[0])->toContain("cat /proc/$(docker inspect --format '{{.State.Pid}}' 'app-123')/status")
|
||||
->and($commands[1])->toContain("docker run --rm --pid container:'app-123' --user '65532:0' --group-add '1000' --cap-drop ALL --security-opt no-new-privileges --network none --ipc none --read-only --log-driver none --pids-limit 64")
|
||||
->not->toContain('--cap-add')
|
||||
->not->toContain('docker exec')
|
||||
->toContain('/proc/1/root/data')
|
||||
->toContain('exit 126');
|
||||
});
|
||||
|
||||
it('reports every helper failure instead of an empty folder', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb')))
|
||||
->push(Process::result(errorOutput: 'Unable to find image', exitCode: 125))]);
|
||||
|
||||
(new ContainerFilesystemService(fsServer(), 'app-123', useHelper: true))->list('/');
|
||||
})->throws(ContainerShellUnavailableException::class, 'Unable to find image');
|
||||
|
||||
it('wraps the helper command in one sudo shell for non-root servers', function () {
|
||||
$server = fsServer();
|
||||
$server->update(['user' => 'deploy']);
|
||||
$commands = [];
|
||||
Process::fake(function ($process) use (&$commands) {
|
||||
$commands[] = $process->command;
|
||||
|
||||
return count($commands) === 1
|
||||
? Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb'))
|
||||
: Process::result(output: '');
|
||||
});
|
||||
|
||||
(new ContainerFilesystemService($server, 'app-123', useHelper: true))->list('/');
|
||||
|
||||
expect($commands[0])->toContain("sudo cat /proc/$(sudo docker inspect --format '{{.State.Pid}}' 'app-123')/status")
|
||||
->and($commands[1])->toContain("sudo bash -c 'docker run --rm")
|
||||
->and(substr_count($commands[1], 'sudo'))->toBe(1);
|
||||
});
|
||||
|
||||
it('streams downloads through the helper container in helper mode', function () {
|
||||
Process::fake(['*' => Process::result(output: fsProcessStatus('0', '0', '0', '00000000a80425fb'))]);
|
||||
$service = new ContainerFilesystemService(fsServer(), 'app-123', useHelper: true);
|
||||
|
||||
expect($service->buildDownloadCommand('/data', true))->toStartWith('docker run --rm')->toContain("tar czf - -C '\\''/proc/1/root/data'\\'' .")
|
||||
->and($service->buildDownloadCommand('/data/users.yml', false))->toContain("cat -- '\\''/proc/1/root/data/users.yml'\\''");
|
||||
});
|
||||
|
||||
it('rejects a binary file that passes a grep without -I support', function () {
|
||||
Process::fake(['*' => Process::result(output: "OK\n".base64_encode("ELF\0binary"))]);
|
||||
|
||||
(new ContainerFilesystemService(fsServer(), 'app-123'))->read('/pause');
|
||||
})->throws(RuntimeException::class, 'File is not editable');
|
||||
|
||||
it('falls back to / when the container has no WorkingDir', function () {
|
||||
Process::fake(['*' => Process::result(output: '')]);
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\ContainerFilesystemService;
|
||||
use App\Services\FileDownloadStorageService;
|
||||
use Illuminate\Contracts\Process\ProcessResult;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Process\Factory;
|
||||
@@ -142,6 +143,86 @@ function fbRunningDatabase($environment, $destination): StandalonePostgresql
|
||||
]);
|
||||
}
|
||||
|
||||
function fbNoShellResult(): ProcessResult
|
||||
{
|
||||
return Process::result(
|
||||
errorOutput: 'OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATH',
|
||||
exitCode: 127,
|
||||
);
|
||||
}
|
||||
|
||||
function fbRootProcessStatus(): string
|
||||
{
|
||||
return "Uid:\t0\t0\t0\t0\nGid:\t0\t0\t0\t0\nGroups:\t0\nCapPrm:\t00000000a80425fb\n";
|
||||
}
|
||||
|
||||
it('browses a container without a shell through the helper container', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/')) // defaultRoot inspect
|
||||
->push(fbNoShellResult()) // docker exec list
|
||||
->push(Process::result(output: fbRootProcessStatus())) // helper identity
|
||||
->push(Process::result(output: "directory\t0\t1\t755\troot\troot\t./data\nsymbolic link\t4\t1\t777\troot\troot\t./etc-link"))]); // helper list
|
||||
|
||||
$database = fbRunningDatabase($this->environment, $this->destination);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(FileBrowser::class, ['resource' => $database])
|
||||
->call('loadFiles')
|
||||
->assertSet('useHelper', true)
|
||||
->assertSet('shellUnavailable', false)
|
||||
->assertSee('data')
|
||||
->assertSee('Upload a file to this folder')
|
||||
->call('open', 'etc-link')
|
||||
->assertDispatched('error', 'Symlinks cannot be opened in a container without a shell. Open the target path instead.')
|
||||
->assertSet('currentPath', '/');
|
||||
});
|
||||
|
||||
it('explains when neither the shell nor the helper container can open the files', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/'))
|
||||
->push(fbNoShellResult())
|
||||
->push(Process::result(output: fbRootProcessStatus()))
|
||||
->push(Process::result(errorOutput: 'Unable to find image', exitCode: 125))]);
|
||||
|
||||
$database = fbRunningDatabase($this->environment, $this->destination);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
Livewire::test(FileBrowser::class, ['resource' => $database])
|
||||
->call('loadFiles')
|
||||
->assertSet('loadFailed', false)
|
||||
->assertSet('shellUnavailable', true)
|
||||
->assertSee('Coolify cannot open the files of this container.')
|
||||
->assertSee('The server could not pull the Coolify helper image')
|
||||
->assertDontSee('This folder is empty')
|
||||
->assertDontSee('Upload a file to this folder');
|
||||
});
|
||||
|
||||
it('keeps helper mode in the download token', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/'))
|
||||
->push(fbNoShellResult())
|
||||
->push(Process::result(output: fbRootProcessStatus()))
|
||||
->push(Process::result(output: "directory\t0\t1\t755\troot\troot\t./data"))
|
||||
->push(Process::result(output: fbRootProcessStatus()))
|
||||
->push(Process::result(output: 'dir 4'))]);
|
||||
|
||||
$database = fbRunningDatabase($this->environment, $this->destination);
|
||||
|
||||
$this->actingAs($this->admin);
|
||||
session(['currentTeam' => $this->team]);
|
||||
|
||||
$component = Livewire::test(FileBrowser::class, ['resource' => $database])
|
||||
->call('loadFiles')
|
||||
->call('download', 'data');
|
||||
|
||||
$token = Str::afterLast($component->effects['redirect'], '/');
|
||||
expect(Cache::get(FileBrowserDownloadController::cacheKey($token)))->toMatchArray(['use_helper' => true, 'path' => '/data']);
|
||||
});
|
||||
|
||||
it('creates a directory and re-lists', function () {
|
||||
Process::fake(['*' => Process::sequence()
|
||||
->push(Process::result(output: '/data')) // defaultRoot
|
||||
|
||||
@@ -60,3 +60,32 @@ it('opens a directory whose name contains an apostrophe', function () {
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'file-browser-apostrophe');
|
||||
});
|
||||
|
||||
it('creates a file with the typed name from the New menu', function () {
|
||||
config(['constants.ssh.mux_enabled' => false]);
|
||||
Storage::fake('ssh-keys');
|
||||
$this->postgres->update(['status' => 'running']);
|
||||
$commands = [];
|
||||
Process::fake(function ($process) use (&$commands) {
|
||||
$commands[] = $process->command;
|
||||
|
||||
return Process::result(output: str_contains($process->command, 'docker inspect') ? '/data' : '');
|
||||
});
|
||||
loginAndSkipBoarding();
|
||||
$url = databaseConfigurationUrl(
|
||||
$this->stack['project'],
|
||||
$this->stack['environment'],
|
||||
$this->postgres
|
||||
).'/files';
|
||||
$page = visit($url)
|
||||
->assertSee('This folder is empty')
|
||||
->click('button:visible:has-text("New")')
|
||||
->click('button:visible:has-text("New file")')
|
||||
->fill('#newFileName', 'asd')
|
||||
->click('button:visible:has-text("Create file")')
|
||||
->assertSee('File created.')
|
||||
->assertNoJavaScriptErrors()
|
||||
->screenshot(filename: 'file-browser-create-file');
|
||||
|
||||
expect(collect($commands)->contains(fn (string $command) => str_contains($command, '/data/asd')))->toBeTrue();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user