feat(server): add Docker registry logins page with per-server login (#12048)

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andras Bacsai
2026-09-30 12:11:37 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent f622213988
commit 03a056709d
21 changed files with 2956 additions and 0 deletions
@@ -0,0 +1,348 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Services\DockerRegistryLogins;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
use Throwable;
class ServerDockerRegistriesController extends Controller
{
private function teamIdOrAbort(): int|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return $teamId;
}
private function findServerForTeam(int $teamId, string $uuid): ?Server
{
return Server::whereTeamId($teamId)->whereUuid($uuid)->first();
}
private function serverNotReachableResponse(): JsonResponse
{
return response()->json(['message' => 'The server is not reachable. Validate the server first.'], 400);
}
private function invalidRegistryResponse(): JsonResponse
{
return response()->json([
'message' => 'Validation failed.',
'errors' => ['registry' => ['Enter a registry host, for example ghcr.io or registry.example.com:5000.']],
], 422);
}
#[OA\Get(
summary: 'List server registry logins',
description: 'List the Docker registries a server is logged in to and the registries its resources pull from or push to. Reads the Docker config of the server over SSH. Credentials are never returned, only registry names and usernames. Requires the `read:sensitive` ability.',
path: '/servers/{uuid}/registries',
operationId: 'list-server-registries',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Registry logins of the server.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(
property: 'registries',
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
new OA\Property(property: 'registry', type: 'string', example: 'ghcr.io'),
new OA\Property(property: 'logged_in', type: 'boolean', example: true),
new OA\Property(property: 'source', type: 'string', nullable: true, enum: ['auths', 'credHelpers'], description: 'Where the Docker config stores the credentials.'),
new OA\Property(property: 'username', type: 'string', nullable: true, example: 'octocat'),
new OA\Property(
property: 'used_by',
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
new OA\Property(property: 'type', type: 'string', example: 'Application'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'link', type: 'string', nullable: true),
]
)
),
]
)
),
new OA\Property(property: 'error', type: 'string', nullable: true, description: 'Set when the logins could not be read from the server.'),
]
)
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
],
)]
public function index(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$server = $this->findServerForTeam($teamId, $uuid);
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
$error = null;
$loggedIn = [];
if (! $server->isFunctional()) {
$error = 'The server is not reachable. Validate the server to read its registry logins.';
} else {
try {
$loggedIn = DockerRegistryLogins::forServer($server);
} catch (Throwable) {
$error = 'Could not read the Docker configuration on this server.';
}
}
return response()->json([
'registries' => DockerRegistryLogins::rows($server, $loggedIn),
'error' => $error,
]);
}
#[OA\Post(
summary: 'Log in to a registry on a server',
description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.',
path: '/servers/{uuid}/registries',
operationId: 'login-server-registry',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
],
requestBody: new OA\RequestBody(
required: true,
content: new OA\JsonContent(
required: ['registry', 'username', 'password'],
type: 'object',
properties: [
new OA\Property(property: 'registry', type: 'string', example: 'ghcr.io', description: 'Registry host, optionally with a port. Use docker.io for Docker Hub.'),
new OA\Property(property: 'username', type: 'string', example: 'octocat'),
new OA\Property(property: 'password', type: 'string', description: 'Password or access token. Never returned.'),
]
)
),
responses: [
new OA\Response(
response: 200,
description: 'Logged in.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Logged in to ghcr.io.'),
]
)
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function login(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$allowedFields = ['registry', 'username', 'password'];
$input = $request->only($allowedFields);
if (is_string($input['registry'] ?? null)) {
$input['registry'] = DockerRegistryLogins::normalizeRegistry($input['registry']);
}
$validator = customApiValidator($input, [
'registry' => ['required', 'string', 'max:255', 'regex:'.DockerRegistryLogins::REGISTRY_PATTERN],
'username' => ['required', 'string', 'max:255', 'regex:/^[^\\s]+$/'],
'password' => ['required', 'string', 'max:20000'],
], [
'registry.regex' => 'Enter a registry host, for example ghcr.io or registry.example.com:5000.',
'username.regex' => 'The username must not contain spaces.',
]);
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
foreach ($extraFields as $field) {
$errors->add($field, 'This field is not allowed.');
}
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$server = $this->findServerForTeam($teamId, $uuid);
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if (! $server->isFunctional()) {
return $this->serverNotReachableResponse();
}
$registry = $input['registry'];
try {
DockerRegistryLogins::login($server, $registry, $input['username'], $input['password']);
} catch (Throwable $exception) {
DockerRegistryLogins::audit($server, 'login', $registry, succeeded: false, source: 'api');
return response()->json(['message' => 'Login failed: '.$exception->getMessage()], 400);
}
DockerRegistryLogins::audit($server, 'login', $registry, source: 'api');
return response()->json(['message' => "Logged in to {$registry}."]);
}
#[OA\Post(
summary: 'Check a registry login on a server',
description: 'Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server.',
path: '/servers/{uuid}/registries/{registry}/check',
operationId: 'check-server-registry-login',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'registry', in: 'path', required: true, description: 'Registry host, optionally with a port, for example registry.example.com:5000.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'The login works.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'The login for ghcr.io works.'),
]
)
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function check(Request $request, string $uuid, string $registry): JsonResponse
{
return $this->runOnRegistry($uuid, $registry, null, function (Server $server, string $registry) {
DockerRegistryLogins::checkLogin($server, $registry);
return "The login for {$registry} works.";
}, 'Login check failed');
}
#[OA\Delete(
summary: 'Log out from a registry on a server',
description: 'Run docker logout on the server for a registry.',
path: '/servers/{uuid}/registries/{registry}',
operationId: 'logout-server-registry',
security: [['bearerAuth' => []]],
tags: ['Servers'],
parameters: [
new OA\Parameter(name: 'uuid', in: 'path', required: true, description: 'Server UUID', schema: new OA\Schema(type: 'string')),
new OA\Parameter(name: 'registry', in: 'path', required: true, description: 'Registry host, optionally with a port, for example registry.example.com:5000.', schema: new OA\Schema(type: 'string')),
],
responses: [
new OA\Response(
response: 200,
description: 'Logged out.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Logged out from ghcr.io.'),
]
)
),
new OA\Response(response: 401, ref: '#/components/responses/401'),
new OA\Response(response: 400, ref: '#/components/responses/400'),
new OA\Response(response: 404, ref: '#/components/responses/404'),
new OA\Response(response: 422, ref: '#/components/responses/422'),
],
)]
public function logout(Request $request, string $uuid, string $registry): JsonResponse
{
return $this->runOnRegistry($uuid, $registry, 'logout', function (Server $server, string $registry) {
DockerRegistryLogins::logout($server, $registry);
return "Logged out from {$registry}.";
}, 'Logout failed');
}
/**
* @param ?string $auditAction audit event action for changes; null for read-only checks
* @param callable(Server, string): string $operation runs the command and returns the success message
*/
private function runOnRegistry(string $uuid, string $registry, ?string $auditAction, callable $operation, string $failure): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$registry = DockerRegistryLogins::normalizeRegistry($registry);
if (strlen($registry) > 255 || ! preg_match(DockerRegistryLogins::REGISTRY_PATTERN, $registry)) {
return $this->invalidRegistryResponse();
}
$server = $this->findServerForTeam($teamId, $uuid);
if (! $server) {
return response()->json(['message' => 'Server not found.'], 404);
}
$this->authorize('update', $server);
if (! $server->isFunctional()) {
return $this->serverNotReachableResponse();
}
try {
$message = $operation($server, $registry);
} catch (Throwable $exception) {
if ($auditAction !== null) {
DockerRegistryLogins::audit($server, $auditAction, $registry, succeeded: false, source: 'api');
}
return response()->json(['message' => "{$failure}: {$exception->getMessage()}"], 400);
}
if ($auditAction !== null) {
DockerRegistryLogins::audit($server, $auditAction, $registry, source: 'api');
}
return response()->json(['message' => $message]);
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Livewire\Component;
class DockerRegistries extends Component
{
public function boot(): void
{
abort_unless(auth()->user()?->isAdmin(), 403);
}
public function render()
{
$servers = Server::ownedByCurrentTeamCached()
->filter(fn (Server $server) => auth()->user()->can('update', $server))
->values();
return view('livewire.server.docker-registries', [
'servers' => $servers,
]);
}
}
@@ -0,0 +1,197 @@
<?php
namespace App\Livewire\Server\DockerRegistries;
use App\Models\Server;
use App\Services\DockerRegistryLogins;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Throwable;
class Login extends Component
{
use AuthorizesRequests;
/**
* A provider with a registry has one fixed host. A provider with a host pattern has one host per region or account,
* so the user types it and it must match the pattern.
*
* @var array<string, array{label: string, registry: string, username: string, placeholder: string, hint: string, host_pattern?: string}>
*/
public const PROVIDERS = [
'dockerhub' => ['label' => 'Docker Hub', 'registry' => 'docker.io', 'username' => '', 'placeholder' => 'docker.io', 'hint' => 'Use your Docker Hub username and a personal access token.'],
'ghcr' => ['label' => 'GitHub Container Registry', 'registry' => 'ghcr.io', 'username' => '', 'placeholder' => 'ghcr.io', 'hint' => 'Use your GitHub username and a personal access token (classic) with the read:packages scope.'],
'gitlab' => ['label' => 'GitLab Container Registry', 'registry' => 'registry.gitlab.com', 'username' => '', 'placeholder' => 'registry.gitlab.com', 'hint' => 'Use a deploy token or a personal access token with the read_registry scope.'],
'google' => ['label' => 'Google Artifact Registry', 'registry' => '', 'username' => '_json_key', 'placeholder' => 'europe-west1-docker.pkg.dev', 'hint' => 'Paste the JSON key of a service account with the Artifact Registry Reader role.', 'host_pattern' => '/(^|\.)gcr\.io$|-docker\.pkg\.dev$/'],
'azure' => ['label' => 'Azure Container Registry', 'registry' => '', 'username' => '', 'placeholder' => 'myregistry.azurecr.io', 'hint' => 'Use a service principal ID and secret, or a repository-scoped token.', 'host_pattern' => '/\.azurecr\.(io|cn|us)$/'],
'aws' => ['label' => 'AWS ECR', 'registry' => '', 'username' => 'AWS', 'placeholder' => '123456789012.dkr.ecr.eu-west-1.amazonaws.com', 'hint' => 'Paste the output of aws ecr get-login-password. This token expires after 12 hours.', 'host_pattern' => '/^[0-9]{12}\.dkr\.ecr(-fips)?\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$/'],
'custom' => ['label' => 'Other', 'registry' => '', 'username' => '', 'placeholder' => 'registry.example.com', 'hint' => ''],
];
/** The server of a card. Null on the overview page, where the user picks the servers. */
#[Locked]
public ?Server $server = null;
/** @var array<int, string> uuids of the servers to log in to, when there is no card server */
public array $selectedServers = [];
public string $provider = 'custom';
/** Registry of an existing login being edited; editing keeps the registry fixed. */
#[Locked]
public ?string $editRegistry = null;
public string $registry = '';
public string $username = '';
public string $password = '';
/**
* The server parameter has no type hint on purpose: with one, Livewire would inject an empty Server model on the overview page.
*
* @param Server|null $server
*/
public function mount($server = null, ?string $editRegistry = null, ?string $currentUsername = null, ?string $presetRegistry = null): void
{
if ($server instanceof Server) {
$this->authorize('update', $server);
$this->server = $server;
} else {
abort_unless(auth()->user()?->isAdmin(), 403);
}
if ($editRegistry !== null) {
$this->editRegistry = DockerRegistryLogins::normalizeRegistry($editRegistry);
$this->provider = self::providerFor($this->editRegistry);
$this->registry = $this->editRegistry;
$this->username = $currentUsername ?? '';
} elseif ($presetRegistry !== null) {
$this->registry = DockerRegistryLogins::normalizeRegistry($presetRegistry);
$this->provider = self::providerFor($this->registry);
$this->username = $this->preset()['username'];
}
}
/**
* Servers of the current team that the user may log in to from the overview page.
*
* @return Collection<int, Server>
*/
private function availableServers(): Collection
{
return Server::ownedByCurrentTeamCached()
->filter(fn (Server $server) => $server->isFunctional() && auth()->user()->can('update', $server))
->values();
}
/**
* @return Collection<int, Server>
*/
private function targetServers(): Collection
{
if ($this->server !== null) {
$this->authorize('update', $this->server);
return collect([$this->server]);
}
$this->validate(['selectedServers' => ['required', 'array', 'min:1']], ['selectedServers.required' => 'Choose at least one server.']);
$servers = Server::ownedByCurrentTeam()->whereIn('uuid', $this->selectedServers)->get();
abort_if($servers->count() !== count(array_unique($this->selectedServers)), 403);
$servers->each(fn (Server $server) => $this->authorize('update', $server));
return $servers;
}
public static function providerFor(string $registry): string
{
foreach (self::PROVIDERS as $provider => $preset) {
if ($preset['registry'] === $registry || (isset($preset['host_pattern']) && preg_match($preset['host_pattern'], $registry))) {
return $provider;
}
}
return 'custom';
}
/**
* @return array{label: string, registry: string, username: string, placeholder: string, hint: string, host_pattern?: string}
*/
private function preset(): array
{
return self::PROVIDERS[$this->provider] ?? self::PROVIDERS['custom'];
}
public function updatedProvider(): void
{
$preset = $this->preset();
$this->registry = $this->editRegistry ?? $preset['registry'];
$this->username = $preset['username'];
$this->password = '';
}
public function login(): void
{
$servers = $this->targetServers();
$preset = $this->preset();
$this->registry = $this->editRegistry
?? ($preset['registry'] !== '' ? $preset['registry'] : DockerRegistryLogins::normalizeRegistry($this->registry));
$hostRules = $this->editRegistry === null && isset($preset['host_pattern']) ? ['regex:'.$preset['host_pattern']] : [];
$this->validate([
'registry' => ['required', 'string', 'max:255', 'regex:'.DockerRegistryLogins::REGISTRY_PATTERN, ...$hostRules],
'username' => ['required', 'string', 'max:255', 'regex:/^[^\\s]+$/'],
'password' => ['required', 'string', 'max:20000'],
], [
'registry.regex' => $hostRules
? "Enter a valid {$preset['label']} host, for example {$preset['placeholder']}. Choose Other for a different registry."
: 'Enter a registry host, for example ghcr.io or registry.example.com:5000.',
'username.regex' => 'The username must not contain spaces.',
]);
$failures = [];
foreach ($servers as $server) {
try {
DockerRegistryLogins::login($server, $this->registry, $this->username, $this->password);
DockerRegistryLogins::audit($server, $this->auditAction(), $this->registry);
$this->dispatch('registryLoginsChanged', serverId: $server->id);
} catch (Throwable $exception) {
DockerRegistryLogins::audit($server, $this->auditAction(), $this->registry, succeeded: false);
$failures[$server->name] = $exception->getMessage();
}
}
$this->password = '';
if ($failures !== []) {
$details = collect($failures)->map(fn (string $error, string $name) => count($servers) > 1 ? e($name).': '.e($error) : e($error))->implode('<br>');
$this->dispatch('error', count($failures) === count($servers) ? 'Login failed.' : 'Login failed on some servers.', $details);
return;
}
$this->dispatch('success', $this->editRegistry ? "Updated the login for {$this->registry}." : "Logged in to {$this->registry}.");
if ($this->editRegistry === null) {
$this->reset('provider', 'registry', 'username', 'selectedServers');
}
$this->dispatch('close-modal');
}
private function auditAction(): string
{
return $this->editRegistry ? 'login_updated' : 'login';
}
public function render()
{
return view('livewire.server.docker-registries.login', [
'serverOptions' => $this->server === null ? $this->availableServers() : collect(),
'preset' => $this->preset(),
'providerOptions' => collect(self::PROVIDERS)
->map(fn (array $preset, string $value) => ['value' => $value, 'label' => $preset['label']])
->values()
->all(),
]);
}
}
@@ -0,0 +1,120 @@
<?php
namespace App\Livewire\Server\DockerRegistries;
use App\Models\Server;
use App\Services\DockerRegistryLogins;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Lazy;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
use Livewire\Component;
use Throwable;
#[Lazy]
class ServerRegistries extends Component
{
use AuthorizesRequests;
#[Locked]
public Server $server;
/** @var array<int, array{registry: string, logged_in: bool, source: ?string, username: ?string, used_by: array<int, array{type: string, name: string, link: ?string}>}> */
public array $registries = [];
public ?string $error = null;
/** @var array<string, array{ok: bool, message: string}> result of the last login check, per registry */
public array $loginChecks = [];
public function mount(Server $server): void
{
$this->authorize('update', $server);
$this->server = $server;
$this->loadRegistries();
}
public function loadRegistries(): void
{
$this->authorize('update', $this->server);
$this->error = null;
$this->loginChecks = [];
$loggedIn = [];
if (! $this->server->isFunctional()) {
$this->error = 'The server is not reachable. Validate the server to read its registry logins.';
} else {
try {
$loggedIn = DockerRegistryLogins::forServer($this->server);
} catch (Throwable) {
$this->error = 'Could not read the Docker configuration on this server.';
}
}
$this->registries = DockerRegistryLogins::rows($this->server, $loggedIn);
}
#[On('registryLoginsChanged')]
public function refreshAfterLogin(int $serverId): void
{
if ($serverId === $this->server->id) {
$this->loadRegistries();
}
}
public function testLogin(string $registry): void
{
$this->authorize('update', $this->server);
$registry = DockerRegistryLogins::normalizeRegistry($registry);
$isLoggedIn = collect($this->registries)->contains(fn (array $row) => $row['registry'] === $registry && $row['logged_in']);
if (! $isLoggedIn || ! preg_match(DockerRegistryLogins::REGISTRY_PATTERN, $registry)) {
return;
}
try {
DockerRegistryLogins::checkLogin($this->server, $registry);
$this->loginChecks[$registry] = ['ok' => true, 'message' => 'The login works.'];
$this->dispatch('success', "The login for {$registry} works.");
} catch (Throwable $exception) {
$this->loginChecks[$registry] = ['ok' => false, 'message' => $exception->getMessage()];
$this->dispatch('error', "The login for {$registry} does not work.", e($exception->getMessage()));
}
}
public function logout(string $registry): void
{
$this->authorize('update', $this->server);
$registry = DockerRegistryLogins::normalizeRegistry($registry);
if (! preg_match(DockerRegistryLogins::REGISTRY_PATTERN, $registry)) {
$this->dispatch('error', 'Logout failed.', 'The registry name is not valid.');
return;
}
try {
DockerRegistryLogins::logout($this->server, $registry);
} catch (Throwable $exception) {
$this->dispatch('error', 'Logout failed.', e($exception->getMessage()));
return;
}
DockerRegistryLogins::audit($this->server, 'logout', $registry);
$this->dispatch('success', "Deleted the login for {$registry}.");
$this->loadRegistries();
}
public function placeholder(): string
{
return <<<'HTML'
<div class="rounded-xl border border-neutral-200 bg-white p-4 text-[12px] text-neutral-500 shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05] dark:text-fg-faint">
Loading registries...
</div>
HTML;
}
public function render()
{
return view('livewire.server.docker-registries.server-registries');
}
}
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Livewire\Server;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Registries extends Component
{
use AuthorizesRequests;
public Server $server;
public function mount(string $server_uuid)
{
try {
$this->server = Server::ownedByCurrentTeam()->whereUuid($server_uuid)->firstOrFail();
} catch (\Throwable) {
return redirect()->route('server.index');
}
$this->authorize('update', $this->server);
}
public function render()
{
return view('livewire.server.registries');
}
}
+295
View File
@@ -0,0 +1,295 @@
<?php
namespace App\Services;
use App\Helpers\SshMultiplexingHelper;
use App\Models\Application;
use App\Models\Server;
use App\Models\Service;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Process;
use RuntimeException;
class DockerRegistryLogins
{
public const DOCKER_HUB = 'docker.io';
public const REGISTRY_PATTERN = '/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?(:[0-9]{1,5})?$/';
/**
* Read the registries a server is logged in to from the Docker config that deployments mount.
*
* The config holds credentials, so only registry names and usernames leave this method.
*
* @return array<string, array{source: string, username: ?string}> registry => credential source (auths or credHelpers) and username
*/
public static function forServer(Server $server): array
{
$config = instant_remote_process(['cat "$HOME/.docker/config.json" 2>/dev/null || true'], $server);
return self::parseConfig($config);
}
/**
* @return array<string, array{source: string, username: ?string}>
*/
public static function parseConfig(?string $config): array
{
$decoded = json_decode((string) $config, true);
if (! is_array($decoded)) {
return [];
}
$registries = [];
foreach (['auths', 'credHelpers'] as $source) {
if (! is_array($decoded[$source] ?? null)) {
continue;
}
foreach ($decoded[$source] as $key => $entry) {
$registry = self::normalizeRegistry((string) $key);
if (preg_match(self::REGISTRY_PATTERN, $registry) && ! isset($registries[$registry])) {
$registries[$registry] = [
'source' => $source,
'username' => $source === 'auths' && is_array($entry) ? self::usernameFromAuth($entry) : null,
];
}
}
}
ksort($registries);
return $registries;
}
/**
* Combine the logins of a server with the registries its resources need, one row per registry.
*
* @param array<string, array{source: string, username: ?string}> $loggedIn
* @return array<int, array{registry: string, logged_in: bool, source: ?string, username: ?string, used_by: array<int, array{type: string, name: string, link: ?string}>}>
*/
public static function rows(Server $server, array $loggedIn): array
{
$usedBy = self::usersOf($server)->groupBy('registry');
return collect(array_keys($loggedIn))
->merge($usedBy->keys())
->unique()
->sort()
->map(fn (string $registry) => [
'registry' => $registry,
'logged_in' => isset($loggedIn[$registry]),
'source' => $loggedIn[$registry]['source'] ?? null,
'username' => $loggedIn[$registry]['username'] ?? null,
'used_by' => ($usedBy[$registry] ?? collect())
->map(fn (array $user) => Arr::except($user, 'registry'))
->values()
->all(),
])
->values()
->all();
}
/**
* Every resource that pulls or pushes a named image on this server, with the registry of that image.
*
* A build server builds and pushes for any application of its team that uses build servers, because
* the deployment picks a random build server. A service is listed once per registry.
*
* @return Collection<int, array{registry: string, type: string, name: string, link: ?string}>
*/
public static function usersOf(Server $server): Collection
{
$user = fn (string $image, string $type, string $name, ?string $link) => [
'registry' => self::registryFromImage($image),
'type' => $type,
'name' => $name,
'link' => $link,
];
$applications = $server->applications()
->filter(fn (Application $application) => filled($application->docker_registry_image_name))
->map(fn (Application $application) => $user($application->docker_registry_image_name, 'Application', $application->name, $application->link()));
$databases = $server->databases()
->filter(fn ($database) => filled($database->image))
->map(fn ($database) => $user($database->image, 'Database', $database->name, $database->link()));
$services = $server->services()->with(['applications', 'databases'])->get()
->flatMap(fn (Service $service) => $service->applications->concat($service->databases)
->filter(fn ($container) => filled($container->image))
->map(fn ($container) => $user($container->image, 'Service', $service->name, $service->link())))
->unique(fn (array $entry) => $entry['registry'].'|'.$entry['link'].'|'.$entry['name']);
$builds = $server->isBuildServer()
? self::applicationsBuiltOnBuildServers($server)
->map(fn (Application $application) => $user($application->docker_registry_image_name, 'Build', $application->name, $application->link()))
: collect();
return $applications->concat($databases)->concat($services)->concat($builds)->values();
}
/**
* Applications of the build server's team that a deployment builds on a build server and pushes to a registry.
*
* @return Collection<int, Application>
*/
private static function applicationsBuiltOnBuildServers(Server $buildServer): Collection
{
return Application::query()
->whereRelation('environment.project', 'team_id', $buildServer->team_id)
->whereNotNull('docker_registry_image_name')
->where('docker_registry_image_name', '!=', '')
->with(['settings', 'destination.server.settings'])
->orderBy('name')
->get()
->filter(function (Application $application) use ($buildServer) {
$deploymentServer = $application->destination?->server;
if ($deploymentServer === null || $deploymentServer->is($buildServer)) {
return false;
}
$mustBuildElsewhere = ! $deploymentServer->canBuildApplications()
&& ! in_array($application->build_pack, ['dockerimage', 'dockercompose'], true);
return $mustBuildElsewhere || (bool) data_get($application, 'settings.is_build_server_enabled');
})
->values();
}
/**
* Docker stores "username:password" base64 encoded in "auth"; keep only the username part.
*
* @param array<string, mixed> $entry
*/
private static function usernameFromAuth(array $entry): ?string
{
if (is_string($entry['username'] ?? null) && $entry['username'] !== '') {
return $entry['username'];
}
$decoded = is_string($entry['auth'] ?? null) ? base64_decode($entry['auth'], true) : false;
if ($decoded === false || ! str_contains($decoded, ':')) {
return null;
}
$username = explode(':', $decoded, 2)[0];
return $username !== '' && mb_check_encoding($username, 'UTF-8') ? $username : null;
}
/**
* Turn a config key such as "https://index.docker.io/v1/" into a registry host.
*/
public static function normalizeRegistry(string $key): string
{
$host = strtolower(trim($key));
$host = preg_replace('#^[a-z][a-z0-9+.-]*://#', '', $host);
$host = explode('/', $host, 2)[0];
return in_array($host, ['index.docker.io', 'registry-1.docker.io', 'registry.hub.docker.com', 'docker.io'], true)
? self::DOCKER_HUB
: $host;
}
/**
* Return the registry host of an image reference, following Docker's own rule:
* the first path part is a registry only if it has a dot or a port, or is "localhost".
*/
public static function registryFromImage(string $image): string
{
$image = trim($image);
if ($image === '' || ! str_contains($image, '/')) {
return self::DOCKER_HUB;
}
$first = explode('/', $image, 2)[0];
if (str_contains($first, '.') || str_contains($first, ':') || $first === 'localhost') {
return self::normalizeRegistry($first);
}
return self::DOCKER_HUB;
}
/**
* Run docker login on the server, writing to the same config file that deployments mount.
*
* The secret goes to docker through stdin via the shell builtin echo, so it is never a process argument
* on the server, and it is kept out of SSH error logs and exception messages.
*/
public static function login(Server $server, string $registry, string $username, string $password): void
{
$secret = base64_encode($password);
self::run(
$server,
"echo '{$secret}' | base64 -d | docker --config \"\$HOME/.docker\" login --username ".escapeshellarg($username).' --password-stdin'.self::registryArgument($registry),
[$password, $secret],
);
}
/**
* Check that the saved login still works. Without a username, docker login reuses the saved credentials
* (also through credential helpers) and fails at once instead of prompting, because stdin is empty.
*/
public static function checkLogin(Server $server, string $registry): void
{
self::run(
$server,
'docker --config "$HOME/.docker" login'.self::registryArgument($registry).' </dev/null',
emptyErrorMessage: "There is no saved login for {$registry} on this server.",
);
}
public static function logout(Server $server, string $registry): void
{
self::run($server, 'docker --config "$HOME/.docker" logout'.self::registryArgument($registry));
}
public static function audit(Server $server, string $action, string $registry, bool $succeeded = true, string $source = 'ui'): void
{
auditLog("{$source}.server.registry_{$action}", [
'server_uuid' => $server->uuid,
'server_name' => $server->name,
'team_id' => $server->team_id,
'registry' => $registry,
'outcome' => $succeeded ? 'success' : 'failed',
], $succeeded ? 'info' : 'warning');
}
/**
* Docker keeps Docker Hub logins under its own default key, so let docker pick it.
*/
private static function registryArgument(string $registry): string
{
return $registry === self::DOCKER_HUB ? '' : ' '.escapeshellarg($registry);
}
/**
* @param array<int, string> $secrets values that must not appear in the error message
* @param string|null $emptyErrorMessage message when docker gives no useful error line
*/
private static function run(Server $server, string $command, array $secrets = [], ?string $emptyErrorMessage = null): void
{
$commands = $server->isNonRoot() ? parseCommandsByLineForSudo(collect([$command]), $server) : [$command];
$process = Process::timeout(60)->run(SshMultiplexingHelper::generateSshCommand($server, implode("\n", $commands)));
if ($process->exitCode() === 0) {
return;
}
$lines = collect(preg_split('/\R/', trim($process->errorOutput()."\n".$process->output())))
->map(fn (string $line) => trim(str_replace('Login did not succeed, error:', '', $line)))
->reject(fn (string $line) => $line === ''
|| str_starts_with($line, 'WARNING!')
|| str_contains($line, 'credential-stores')
|| str_starts_with($line, 'Info ->')
|| str_starts_with($line, 'Authenticating with existing credentials')
|| str_contains($line, 'cannot perform an interactive login')
|| str_starts_with($line, 'Control socket connect')
|| str_starts_with($line, 'ControlSocket ')
|| str_starts_with($line, 'Warning: Permanently added'));
$dockerErrors = $lines->filter(fn (string $line) => str_starts_with($line, 'Error'));
$error = ($dockerErrors->isNotEmpty() ? $dockerErrors : $lines)->implode(' ');
$secrets = array_filter($secrets, fn (string $secret) => $secret !== '');
$error = str_replace($secrets, '***', $error);
throw new RuntimeException($error !== '' ? $error : ($emptyErrorMessage ?? "The command failed with exit code {$process->exitCode()}."));
}
}
+325
View File
@@ -15669,6 +15669,331 @@
]
}
},
"\/servers\/{uuid}\/registries": {
"get": {
"tags": [
"Servers"
],
"summary": "List server registry logins",
"description": "List the Docker registries a server is logged in to and the registries its resources pull from or push to. Reads the Docker config of the server over SSH. Credentials are never returned, only registry names and usernames. Requires the `read:sensitive` ability.",
"operationId": "list-server-registries",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Server UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Registry logins of the server.",
"content": {
"application\/json": {
"schema": {
"properties": {
"registries": {
"type": "array",
"items": {
"properties": {
"registry": {
"type": "string",
"example": "ghcr.io"
},
"logged_in": {
"type": "boolean",
"example": true
},
"source": {
"description": "Where the Docker config stores the credentials.",
"type": [
"string",
"null"
],
"enum": [
"auths",
"credHelpers"
]
},
"username": {
"type": [
"string",
"null"
],
"example": "octocat"
},
"used_by": {
"type": "array",
"items": {
"properties": {
"type": {
"type": "string",
"example": "Application"
},
"name": {
"type": "string"
},
"link": {
"type": [
"string",
"null"
]
}
},
"type": "object"
}
}
},
"type": "object"
}
},
"error": {
"description": "Set when the logins could not be read from the server.",
"type": [
"string",
"null"
]
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
}
},
"security": [
{
"bearerAuth": []
}
]
},
"post": {
"tags": [
"Servers"
],
"summary": "Log in to a registry on a server",
"description": "Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.",
"operationId": "login-server-registry",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Server UUID",
"required": true,
"schema": {
"type": "string"
}
}
],
"requestBody": {
"required": true,
"content": {
"application\/json": {
"schema": {
"required": [
"registry",
"username",
"password"
],
"properties": {
"registry": {
"description": "Registry host, optionally with a port. Use docker.io for Docker Hub.",
"type": "string",
"example": "ghcr.io"
},
"username": {
"type": "string",
"example": "octocat"
},
"password": {
"description": "Password or access token. Never returned.",
"type": "string"
}
},
"type": "object"
}
}
}
},
"responses": {
"200": {
"description": "Logged in.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Logged in to ghcr.io."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/{uuid}\/registries\/{registry}\/check": {
"post": {
"tags": [
"Servers"
],
"summary": "Check a registry login on a server",
"description": "Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server.",
"operationId": "check-server-registry-login",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Server UUID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "registry",
"in": "path",
"description": "Registry host, optionally with a port, for example registry.example.com:5000.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "The login works.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "The login for ghcr.io works."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/{uuid}\/registries\/{registry}": {
"delete": {
"tags": [
"Servers"
],
"summary": "Log out from a registry on a server",
"description": "Run docker logout on the server for a registry.",
"operationId": "logout-server-registry",
"parameters": [
{
"name": "uuid",
"in": "path",
"description": "Server UUID",
"required": true,
"schema": {
"type": "string"
}
},
{
"name": "registry",
"in": "path",
"description": "Registry host, optionally with a port, for example registry.example.com:5000.",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "Logged out.",
"content": {
"application\/json": {
"schema": {
"properties": {
"message": {
"type": "string",
"example": "Logged out from ghcr.io."
}
},
"type": "object"
}
}
}
},
"401": {
"$ref": "#\/components\/responses\/401"
},
"400": {
"$ref": "#\/components\/responses\/400"
},
"404": {
"$ref": "#\/components\/responses\/404"
},
"422": {
"$ref": "#\/components\/responses\/422"
}
},
"security": [
{
"bearerAuth": []
}
]
}
},
"\/servers\/{uuid}\/log-drains": {
"get": {
"tags": [
+173
View File
@@ -10012,6 +10012,179 @@ paths:
security:
-
bearerAuth: []
'/servers/{uuid}/registries':
get:
tags:
- Servers
summary: 'List server registry logins'
description: 'List the Docker registries a server is logged in to and the registries its resources pull from or push to. Reads the Docker config of the server over SSH. Credentials are never returned, only registry names and usernames. Requires the `read:sensitive` ability.'
operationId: list-server-registries
parameters:
-
name: uuid
in: path
description: 'Server UUID'
required: true
schema:
type: string
responses:
'200':
description: 'Registry logins of the server.'
content:
application/json:
schema:
properties:
registries: { type: array, items: { properties: { registry: { type: string, example: ghcr.io }, logged_in: { type: boolean, example: true }, source: { description: 'Where the Docker config stores the credentials.', type: [string, 'null'], enum: [auths, credHelpers] }, username: { type: [string, 'null'], example: octocat }, used_by: { type: array, items: { properties: { type: { type: string, example: Application }, name: { type: string }, link: { type: [string, 'null'] } }, type: object } } }, type: object } }
error: { description: 'Set when the logins could not be read from the server.', type: [string, 'null'] }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
security:
-
bearerAuth: []
post:
tags:
- Servers
summary: 'Log in to a registry on a server'
description: 'Run docker login on the server, writing to the Docker config that deployments use. Logging in again to the same registry replaces the saved login.'
operationId: login-server-registry
parameters:
-
name: uuid
in: path
description: 'Server UUID'
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
required:
- registry
- username
- password
properties:
registry:
description: 'Registry host, optionally with a port. Use docker.io for Docker Hub.'
type: string
example: ghcr.io
username:
type: string
example: octocat
password:
description: 'Password or access token. Never returned.'
type: string
type: object
responses:
'200':
description: 'Logged in.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Logged in to ghcr.io.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/servers/{uuid}/registries/{registry}/check':
post:
tags:
- Servers
summary: 'Check a registry login on a server'
description: 'Check that the saved login of the server for a registry still works. Runs docker login with the saved credentials on the server.'
operationId: check-server-registry-login
parameters:
-
name: uuid
in: path
description: 'Server UUID'
required: true
schema:
type: string
-
name: registry
in: path
description: 'Registry host, optionally with a port, for example registry.example.com:5000.'
required: true
schema:
type: string
responses:
'200':
description: 'The login works.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'The login for ghcr.io works.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/servers/{uuid}/registries/{registry}':
delete:
tags:
- Servers
summary: 'Log out from a registry on a server'
description: 'Run docker logout on the server for a registry.'
operationId: logout-server-registry
parameters:
-
name: uuid
in: path
description: 'Server UUID'
required: true
schema:
type: string
-
name: registry
in: path
description: 'Registry host, optionally with a port, for example registry.example.com:5000.'
required: true
schema:
type: string
responses:
'200':
description: 'Logged out.'
content:
application/json:
schema:
properties:
message: { type: string, example: 'Logged out from ghcr.io.' }
type: object
'401':
$ref: '#/components/responses/401'
'400':
$ref: '#/components/responses/400'
'404':
$ref: '#/components/responses/404'
'422':
$ref: '#/components/responses/422'
security:
-
bearerAuth: []
'/servers/{uuid}/log-drains':
get:
tags:
@@ -134,6 +134,16 @@
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Destinations</span>
</a>
</li>
@if (auth()->user()?->isAdmin())
<li>
<a title="Registries" {{ wireNavigate() }}
class="{{ request()->is('registries*') ? 'menu-item-active menu-item' : 'menu-item' }}"
:class="collapsed && 'lg:justify-center lg:px-0'" href="{{ route('registries.index') }}">
<x-reicon name="layers" class="menu-item-icon" />
<span class="menu-item-label" :class="collapsed && 'lg:hidden'">Registries</span>
</a>
</li>
@endif
<li>
<a title="S3 Storage" {{ wireNavigate() }}
class="{{ request()->is('storages*') ? 'menu-item-active menu-item' : 'menu-item' }}"
@@ -135,6 +135,14 @@
'group' => 'Operations',
'visible' => $server->isFunctional(),
],
[
'label' => 'Registries',
'route' => 'server.registries',
'active' => $activeMenu === 'registries',
'icon' => 'layers',
'group' => 'Operations',
'visible' => auth()->user()?->can('update', $server),
],
[
'label' => 'Log Drains',
'route' => 'server.log-drains',
@@ -90,6 +90,7 @@
request()->routeIs('server.*') => 'Servers',
request()->routeIs('source.*') => 'Sources',
request()->routeIs('destination.*') => 'Destinations',
request()->routeIs('registries.*') => 'Registries',
request()->routeIs('storage.*') => 'S3 Storage',
request()->routeIs('shared-variables.*') => 'Shared Variables',
request()->routeIs('team.*') => 'Team',
@@ -114,6 +115,9 @@
['label' => 'Servers', 'href' => url('/servers')],
['label' => 'Sources', 'href' => route('source.all')],
['label' => 'Destinations', 'href' => route('destination.index')],
auth()->user()?->isAdmin()
? ['label' => 'Registries', 'href' => route('registries.index')]
: null,
['label' => 'S3 Storage', 'href' => route('storage.index')],
['label' => 'Shared Variables', 'href' => route('shared-variables.index')],
['label' => 'Team', 'href' => route('team.index')],
@@ -0,0 +1,34 @@
<div class="w-full">
<x-slot:title>
Docker Registries | Coolify
</x-slot>
<div class="mb-5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="flex min-w-0 flex-col gap-1">
<h1 class="min-w-0 text-[24px]! leading-7! font-semibold! tracking-tight!">Docker Registries</h1>
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
Registry logins on each server, read from the Docker config that deployments use. Use <b>Multi-server login</b>,
or run <code>docker login &lt;registry&gt;</code> on a server.
</p>
</div>
@if ($servers->isNotEmpty())
<x-modal-input buttonTitle="Multi-server login" title="Log in on multiple servers" isHighlightedButton
subtitle="Runs docker login on each server that you choose.">
<livewire:server.docker-registries.login key="registry-login-overview" />
</x-modal-input>
@endif
</div>
@if ($servers->isEmpty())
<x-empty title="No servers yet"
description="Add a server to manage its Docker registry logins."
icon-name="servers" />
@else
<div class="flex flex-col gap-4">
@foreach ($servers as $server)
<livewire:server.docker-registries.server-registries :server="$server"
:key="'docker-registries-'.$server->uuid" />
@endforeach
</div>
@endif
</div>
@@ -0,0 +1,50 @@
<div>
<form wire:submit="login" class="flex flex-col gap-3">
@if ($server === null)
<div>
<p class="mb-1.5 text-sm font-medium">Servers <x-highlighted text="*" /></p>
@if ($serverOptions->isEmpty())
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">No reachable server to log in to.</p>
@else
<div class="flex max-h-48 flex-col overflow-y-auto">
@foreach ($serverOptions as $option)
<x-forms.checkbox id="registry-login-server-{{ $option->uuid }}" :domValue="$option->uuid"
wire:model="selectedServers" wire:key="registry-login-server-{{ $option->uuid }}"
:label="e($option->name)" fullWidth />
@endforeach
</div>
@endif
@error('selectedServers')
<p class="mt-1 text-[12px] text-red-500">{{ $message }}</p>
@enderror
</div>
@endif
@if ($editRegistry === null)
<x-forms.listbox id="provider" label="Provider" live portal :options="$providerOptions" />
@endif
@if ($preset['hint'])
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">{{ $preset['hint'] }}</p>
@endif
<div class="grid gap-3 md:grid-cols-2">
<x-forms.input id="registry" label="Registry" required :placeholder="$preset['placeholder']"
:readonly="$editRegistry !== null || $preset['registry'] !== ''" />
<x-forms.input id="username" label="Username" required />
</div>
@if ($provider === 'google')
<x-forms.textarea id="password" label="Service account JSON key" rows="6" required monospace
wire:key="registry-password-json" />
@else
<x-forms.input id="password" type="password" label="Password or token" required
wire:key="registry-password-token" />
@endif
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
Coolify does not save the token. Docker saves the login on the server in
<code>~/.docker/config.json</code>, encoded but not encrypted.
</p>
<div class="flex justify-end">
<x-forms.button type="submit" defaultClass="button button-highlighted">
{{ $editRegistry ? 'Update login' : 'Log in' }}
</x-forms.button>
</div>
</form>
</div>
@@ -0,0 +1,144 @@
@php
// Public Docker Hub images need no login, so an unused Docker Hub row would only add noise.
$dockerHubRow = collect($registries)->first(fn (array $row) => $row['registry'] === 'docker.io' && ! $row['logged_in']);
$visibleRows = collect($registries)->reject(fn (array $row) => $row === $dockerHubRow)->values()->all();
@endphp
<div class="overflow-hidden rounded-xl border border-neutral-200 bg-white shadow-sm dark:border-white/[0.08] dark:bg-white/[0.05]">
<div class="flex items-center gap-3 border-b border-neutral-200 bg-neutral-50 px-4 py-2.5 dark:border-white/[0.08] dark:bg-white/[0.05]">
<x-reicon name="servers" class="size-4 shrink-0 text-neutral-500 dark:text-fg-dim" />
<a href="{{ route('server.show', ['server_uuid' => $server->uuid]) }}" {{ wireNavigate() }}
class="min-w-0 truncate text-[13px] font-semibold text-black hover:underline dark:text-fg">
{{ $server->name }}
</a>
<div class="ml-auto flex shrink-0 items-center gap-2">
<button type="button" wire:click="loadRegistries" wire:loading.attr="disabled" class="button w-fit">
<x-reicon name="refresh" class="size-3.5" />
Refresh
</button>
@if ($server->isFunctional())
<x-modal-input buttonTitle="New" title="Log in to a registry"
subtitle="Runs docker login on {{ $server->name }}.">
<livewire:server.docker-registries.login :server="$server"
:key="'registry-login-'.$server->uuid" />
</x-modal-input>
@endif
</div>
</div>
@if ($error)
<div class="px-4 py-3 text-[12px] text-red-700 dark:text-red-300">{{ $error }}</div>
@endif
@if (empty($visibleRows))
@unless ($error)
<div class="px-4 py-3 text-[12px] text-neutral-500 dark:text-fg-faint">
Not logged in to any registry, and no resource on this server uses a private registry host.
</div>
@endunless
@else
<div class="overflow-x-auto">
<div
class="grid min-w-[760px] grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)_14rem] border-b border-neutral-200 px-4 py-2 text-[11px] font-medium text-neutral-500 dark:border-white/[0.08] dark:text-fg-faint">
<div>Registry</div>
<div>Status</div>
<div>Used by</div>
<div></div>
</div>
@foreach ($visibleRows as $row)
@php
$usedByCount = count($row['used_by']);
$usedBySummary = collect($row['used_by'])->countBy('type')
->map(fn (int $count, string $type) => $count.' '.Str::plural(strtolower($type), $count))
->implode(' · ');
@endphp
<div wire:key="{{ $server->uuid }}-{{ $row['registry'] }}" x-data="{ showUsers: false }"
class="min-w-[760px] border-b border-neutral-200 last:border-b-0 dark:border-white/[0.07]">
<div class="grid min-h-11 grid-cols-[minmax(0,1fr)_9rem_minmax(0,1fr)_14rem] items-center px-4 py-2 text-[12px]">
<div class="truncate font-mono">{{ $row['registry'] }}</div>
<div>
@if ($row['logged_in'] && ($loginChecks[$row['registry']]['ok'] ?? true) === false)
<x-status-badge type="error" label="Login failed"
:title="$loginChecks[$row['registry']]['message']" />
@elseif ($row['logged_in'])
<x-status-badge type="success"
:label="$row['source'] === 'credHelpers' ? 'Credential helper' : 'Logged in'" />
@elseif ($error)
<x-status-badge label="Unknown" />
@else
<x-status-badge :type="$row['registry'] === 'docker.io' ? 'neutral' : 'warning'" label="Not logged in"
:title="$row['registry'] === 'docker.io' ? 'Public Docker Hub images do not need a login.' : null" />
@endif
</div>
<div class="min-w-0 text-neutral-600 dark:text-fg-dim">
@if ($usedByCount === 0)
<span class="text-neutral-400 dark:text-fg-faint">-</span>
@elseif ($usedByCount === 1)
@include('livewire.server.docker-registries.used-by-item', ['user' => $row['used_by'][0]])
@else
<button type="button" x-on:click="showUsers = !showUsers" :aria-expanded="showUsers"
class="inline-flex max-w-full items-center gap-1 hover:text-black dark:hover:text-fg">
<span class="truncate">{{ $usedBySummary }}</span>
<x-reicon name="chevron-down" class="size-3 shrink-0 transition-transform"
x-bind:class="showUsers && 'rotate-180'" />
</button>
@endif
</div>
<div class="flex justify-end gap-2">
@if ($row['logged_in'])
<button type="button" wire:click="testLogin(@js($row['registry']))" wire:loading.attr="disabled"
wire:target="testLogin" class="button w-fit"
title="Run docker login with the saved credentials">Test</button>
@elseif (! $error && $server->isFunctional())
<x-modal-input buttonTitle="Log in" title="Log in to {{ $row['registry'] }}"
subtitle="Runs docker login on {{ $server->name }}.">
<livewire:server.docker-registries.login :server="$server" :presetRegistry="$row['registry']"
:key="'registry-login-'.$server->uuid.'-'.$row['registry']" />
</x-modal-input>
@endif
@if ($row['logged_in'] && $row['source'] === 'auths')
<x-modal-input buttonTitle="Edit" title="Edit login for {{ $row['registry'] }}"
subtitle="Runs docker login on {{ $server->name }} again. The new values replace the old login.">
<livewire:server.docker-registries.login :server="$server" :editRegistry="$row['registry']"
:currentUsername="$row['username']"
:key="'registry-edit-'.$server->uuid.'-'.$row['registry']" />
</x-modal-input>
<x-modal-confirmation title="Delete the login for {{ $row['registry'] }}?" buttonTitle="Delete" isErrorButton
submitAction="logout({{ $row['registry'] }})" :actions="[
'Runs docker logout on '.$server->name.' and removes the saved credentials.',
'The server can no longer pull private images from this registry.',
'Deployments that need this registry fail until you log in again.',
]" :confirmWithText="false" :confirmWithPassword="false" />
@endif
</div>
</div>
@if ($usedByCount > 1)
<div x-show="showUsers" x-cloak
class="grid gap-x-6 gap-y-3 bg-neutral-50 px-4 py-3 text-[12px] sm:grid-cols-3 dark:bg-white/[0.02]">
@foreach (collect($row['used_by'])->groupBy('type') as $type => $users)
<div class="min-w-0">
<p class="mb-1 text-[11px] font-medium text-neutral-500 dark:text-fg-faint">
{{ Str::plural($type) }} ({{ $users->count() }})
</p>
<ul class="flex flex-col gap-0.5 text-neutral-600 dark:text-fg-dim">
@foreach ($users as $user)
<li class="truncate">
@include('livewire.server.docker-registries.used-by-item', ['user' => $user, 'withType' => false])
</li>
@endforeach
</ul>
</div>
@endforeach
</div>
@endif
</div>
@endforeach
</div>
@endif
@if ($dockerHubRow && ! $error)
<div class="border-t border-neutral-200 px-4 py-2.5 text-[12px] text-neutral-500 dark:border-white/[0.07] dark:text-fg-faint">
{{ trans_choice(':count resource on this server uses|:count resources on this server use', count($dockerHubRow['used_by'])) }}
Docker Hub images. Public images need no login. Log in to Docker Hub for private images or higher pull limits.
</div>
@endif
</div>
@@ -0,0 +1,10 @@
<span class="inline-flex max-w-full min-w-0 items-baseline gap-1">
@if ($user['link'])
<a href="{{ $user['link'] }}" {{ wireNavigate() }} class="truncate hover:underline">{{ $user['name'] }}</a>
@else
<span class="truncate">{{ $user['name'] }}</span>
@endif
@if ($withType ?? true)
<span class="shrink-0 text-[11px] text-neutral-400 dark:text-fg-faint">{{ $user['type'] }}</span>
@endif
</span>
@@ -0,0 +1,21 @@
<div>
<x-slot:title>
{{ data_get_str($server, 'name')->limit(10) }} > Registries | Coolify
</x-slot>
<livewire:server.navbar :server="$server" />
<div
class="server-settings-workspace application-settings-workspace mt-4 grid w-full max-w-none min-w-0 gap-8 lg:mt-0 xl:grid-cols-[210px_minmax(0,1fr)] xl:gap-8">
<x-server.sidebar :server="$server" activeMenu="registries" />
<div class="application-settings-form flex w-full flex-col gap-3">
<livewire:server.docker-registries.server-registries :server="$server"
:key="'server-registries-'.$server->uuid" />
<p class="text-[12px] text-neutral-500 dark:text-fg-dim">
Registry logins are read from the Docker config that deployments use. Use <b>Log in</b>, or run
<code>docker login &lt;registry&gt;</code> on the server.
</p>
</div>
</div>
</div>
+6
View File
@@ -24,6 +24,7 @@ use App\Http\Controllers\Api\SecurityController;
use App\Http\Controllers\Api\SentinelController;
use App\Http\Controllers\Api\ServerCloudflareTunnelController;
use App\Http\Controllers\Api\ServerDockerCleanupController;
use App\Http\Controllers\Api\ServerDockerRegistriesController;
use App\Http\Controllers\Api\ServerLogDrainsController;
use App\Http\Controllers\Api\ServerProxyController;
use App\Http\Controllers\Api\ServersController;
@@ -191,6 +192,11 @@ Route::group([
Route::get('/servers/{uuid}/validate', [OtherController::class, 'post_required'])->middleware(['api.ability:write']);
Route::post('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'index'])->middleware(['api.ability:read:sensitive']);
Route::post('/servers/{uuid}/registries', [ServerDockerRegistriesController::class, 'login'])->middleware(['api.ability:write']);
Route::post('/servers/{uuid}/registries/{registry}/check', [ServerDockerRegistriesController::class, 'check'])->middleware(['api.ability:write']);
Route::delete('/servers/{uuid}/registries/{registry}', [ServerDockerRegistriesController::class, 'logout'])->middleware(['api.ability:write']);
Route::get('/servers/{uuid}/proxy', [ServerProxyController::class, 'show'])->middleware(['api.ability:read']);
Route::patch('/servers/{uuid}/proxy', [ServerProxyController::class, 'update'])->middleware(['api.ability:write']);
Route::put('/servers/{uuid}/proxy/configuration', [ServerProxyController::class, 'saveConfiguration'])->middleware(['api.ability:write']);
+4
View File
@@ -65,12 +65,14 @@ use App\Livewire\Server\Delete as DeleteServer;
use App\Livewire\Server\Destinations as ServerDestinations;
use App\Livewire\Server\DockerCleanup;
use App\Livewire\Server\DockerImages;
use App\Livewire\Server\DockerRegistries as ServerDockerRegistries;
use App\Livewire\Server\Index as ServerIndex;
use App\Livewire\Server\LogDrains;
use App\Livewire\Server\PrivateKey\Show as PrivateKeyShow;
use App\Livewire\Server\Proxy\DynamicConfigurations as ProxyDynamicConfigurations;
use App\Livewire\Server\Proxy\Logs as ProxyLogs;
use App\Livewire\Server\Proxy\Show as ProxyShow;
use App\Livewire\Server\Registries as ServerRegistries;
use App\Livewire\Server\Resources as ResourcesShow;
use App\Livewire\Server\Security\Patches;
use App\Livewire\Server\Security\TerminalAccess;
@@ -370,6 +372,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
});
Route::get('/servers', ServerIndex::class)->name('server.index');
Route::get('/registries', ServerDockerRegistries::class)->name('registries.index');
Route::get('/servers/import', ServerTransferImport::class)->name('server.transfer.import')->middleware('can:create,'.Server::class);
Route::get('/servers/new', ServerCreatePage::class)->name('server.create')->middleware('can:create,'.Server::class);
Route::get('/servers/new/{type}/{token_uuid}', ServerCreatePage::class)->name('server.create.token')->middleware('can:create,'.Server::class)->whereIn('type', ['hetzner', 'vultr', 'digital-ocean']);
@@ -398,6 +401,7 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/terminal', ExecuteContainerCommand::class)->name('server.command')->middleware('can.access.terminal');
Route::get('/docker-cleanup', DockerCleanup::class)->name('server.docker-cleanup');
Route::get('/images', DockerImages::class)->name('server.docker-images');
Route::get('/registries', ServerRegistries::class)->name('server.registries')->middleware('can.update.resource');
Route::get('/security', fn () => redirect(route('dashboard')))->name('server.security')->middleware('can.update.resource');
Route::get('/security/patches', Patches::class)->name('server.security.patches')->middleware('can.update.resource');
Route::get('/security/terminal-access', TerminalAccess::class)->name('server.security.terminal-access')->middleware('can.update.resource');
@@ -0,0 +1,332 @@
<?php
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
config([
'cache.default' => 'array',
'session.driver' => 'array',
'app.maintenance.driver' => 'file',
'constants.ssh.mux_enabled' => false,
]);
InstanceSettings::unguarded(fn () => InstanceSettings::firstOrCreate(
['id' => 0],
['is_api_enabled' => true],
));
$this->team = Team::factory()->create();
$this->user = User::factory()->create();
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
session(['currentTeam' => $this->team]);
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$this->server->refresh();
$this->writeToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'write']);
$this->sensitiveToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']);
});
function serverRegistriesApiToken(User $user, Team $team, array $abilities): string
{
$plainTextToken = Str::random(40);
$token = $user->tokens()->create([
'name' => 'server-registries-api-test-'.Str::random(6),
'token' => hash('sha256', $plainTextToken),
'abilities' => $abilities,
'team_id' => $team->id,
]);
return $token->getKey().'|'.$plainTextToken;
}
function serverRegistriesApiHeaders(string $bearerToken): array
{
return [
'Authorization' => 'Bearer '.$bearerToken,
'Content-Type' => 'application/json',
];
}
/**
* Record every SSH command and answer the Docker config read with the given config.
*
* @param array<int, string> $commands
*/
function fakeServerRegistriesSsh(array &$commands, string $config = '', string $error = ''): void
{
Process::fake(function ($process) use (&$commands, $config, $error) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
$commands[] = $command;
if (str_contains($command, '.docker/config.json')) {
return Process::result(output: $config);
}
return $error !== '' ? Process::result(errorOutput: $error, exitCode: 1) : Process::result(output: '');
});
}
test('GET /api/v1/servers/{uuid}/registries lists registry logins without secrets', function () {
$environment = Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$application = Application::factory()->create([
'name' => 'private-app',
'docker_registry_image_name' => 'registry.example.com:5000/team/app',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
$commands = [];
fakeServerRegistriesSsh($commands, json_encode([
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:SECRET_TOKEN')]],
]));
$response = $this->withHeaders(serverRegistriesApiHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertOk()
->assertExactJson([
'registries' => [
['registry' => 'ghcr.io', 'logged_in' => true, 'source' => 'auths', 'username' => 'octocat', 'used_by' => []],
[
'registry' => 'registry.example.com:5000',
'logged_in' => false,
'source' => null,
'username' => null,
'used_by' => [['type' => 'Application', 'name' => 'private-app', 'link' => $application->link()]],
],
],
'error' => null,
]);
expect($response->getContent())
->not->toContain('SECRET_TOKEN')
->not->toContain(base64_encode('octocat:SECRET_TOKEN'));
});
test('GET /api/v1/servers/{uuid}/registries returns an error without SSH when the server is not reachable', function () {
$this->server->settings->update(['is_reachable' => false]);
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->sensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertOk()
->assertJsonPath('registries', [])
->assertJsonPath('error', 'The server is not reachable. Validate the server to read its registry logins.');
Process::assertNothingRan();
});
test('GET /api/v1/servers/{uuid}/registries requires read:sensitive', function () {
Process::fake();
$readToken = serverRegistriesApiToken($this->user, $this->team, ['read']);
$this->withHeaders(serverRegistriesApiHeaders($readToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertForbidden();
Process::assertNothingRan();
});
test('POST /api/v1/servers/{uuid}/registries logs in through stdin', function () {
$commands = [];
fakeServerRegistriesSsh($commands);
$token = "ghp_SECRET'token\"with\$special";
$response = $this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries", [
'registry' => 'https://GHCR.io/',
'username' => 'octocat',
'password' => $token,
])
->assertOk()
->assertExactJson(['message' => 'Logged in to ghcr.io.']);
expect($response->getContent())->not->toContain('ghp_SECRET');
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
expect($login)
->toContain("login --username 'octocat' --password-stdin 'ghcr.io'")
->not->toContain('ghp_SECRET');
$event = AuditEvent::query()->where('event', 'api.server.registry_login')->sole();
expect($event->metadata['registry'])->toBe('ghcr.io')
->and($event->metadata['outcome'])->toBe('success')
->and($event->metadata['server_uuid'])->toBe($this->server->uuid)
->and(json_encode($event->metadata))->not->toContain('ghp_SECRET');
expect(AuditEvent::query()->where('event', 'like', 'ui.%')->exists())->toBeFalse();
});
test('POST /api/v1/servers/{uuid}/registries validates the input before running a command', function (array $payload, string $errorField) {
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries", $payload)
->assertUnprocessable()
->assertJsonValidationErrors($errorField);
Process::assertNothingRan();
})->with([
'invalid registry' => [['registry' => 'bad host;rm', 'username' => 'octocat', 'password' => 'secret'], 'registry'],
'missing registry' => [['username' => 'octocat', 'password' => 'secret'], 'registry'],
'username with spaces' => [['registry' => 'ghcr.io', 'username' => 'octo cat', 'password' => 'secret'], 'username'],
'missing password' => [['registry' => 'ghcr.io', 'username' => 'octocat'], 'password'],
'too long password' => [['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => str_repeat('a', 20001)], 'password'],
'unknown field' => [['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret', 'extra' => 'x'], 'extra'],
]);
test('POST /api/v1/servers/{uuid}/registries returns the docker error with the token masked', function () {
$commands = [];
fakeServerRegistriesSsh($commands, error: "WARNING! Using --password via the CLI is insecure.\nError response from daemon: unauthorized: bad token SECRET_TOKEN");
$response = $this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries", [
'registry' => 'ghcr.io',
'username' => 'octocat',
'password' => 'SECRET_TOKEN',
])
->assertStatus(400)
->assertExactJson(['message' => 'Login failed: Error response from daemon: unauthorized: bad token ***']);
expect($response->getContent())->not->toContain('SECRET_TOKEN');
expect(AuditEvent::query()->where('event', 'api.server.registry_login')->sole()->metadata['outcome'])->toBe('failed');
});
test('POST /api/v1/servers/{uuid}/registries/{registry}/check checks the saved login', function () {
$commands = [];
fakeServerRegistriesSsh($commands);
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries/registry.example.com:5000/check")
->assertOk()
->assertExactJson(['message' => 'The login for registry.example.com:5000 works.']);
expect(collect($commands)->first(fn (string $command) => str_contains($command, ' login')))
->toContain("login 'registry.example.com:5000' </dev/null");
});
test('POST /api/v1/servers/{uuid}/registries/{registry}/check returns the docker error', function () {
$commands = [];
fakeServerRegistriesSsh($commands, error: 'Error: Cannot perform an interactive login from a non TTY device');
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->postJson("/api/v1/servers/{$this->server->uuid}/registries/ghcr.io/check")
->assertStatus(400)
->assertJsonPath('message', fn (string $message) => str_starts_with($message, 'Login check failed:'));
});
test('DELETE /api/v1/servers/{uuid}/registries/{registry} logs out', function () {
$commands = [];
fakeServerRegistriesSsh($commands);
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->deleteJson("/api/v1/servers/{$this->server->uuid}/registries/ghcr.io")
->assertOk()
->assertExactJson(['message' => 'Logged out from ghcr.io.']);
expect(collect($commands)->first(fn (string $command) => str_contains($command, ' logout')))
->toContain("logout 'ghcr.io'");
$event = AuditEvent::query()->where('event', 'api.server.registry_logout')->sole();
expect($event->metadata['registry'])->toBe('ghcr.io')
->and($event->metadata['outcome'])->toBe('success');
});
test('registry path parameters are validated before running a command', function (string $method, string $suffix) {
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->json($method, "/api/v1/servers/{$this->server->uuid}/registries/{$suffix}")
->assertUnprocessable()
->assertJsonValidationErrors('registry');
Process::assertNothingRan();
})->with([
'check' => ['POST', 'bad%20host;rm/check'],
'logout' => ['DELETE', 'bad%20host;rm'],
]);
test('mutating registry endpoints refuse unreachable servers without SSH', function () {
$this->server->settings->update(['is_reachable' => false]);
Process::fake();
$this->withHeaders(serverRegistriesApiHeaders($this->writeToken))
->deleteJson("/api/v1/servers/{$this->server->uuid}/registries/ghcr.io")
->assertStatus(400);
Process::assertNothingRan();
});
test('registry endpoints return 404 for a server of another team', function (string $method, string $suffix, array $payload) {
Process::fake();
$otherTeam = Team::factory()->create();
$otherServer = Server::factory()->create([
'team_id' => $otherTeam->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $otherTeam->id])->id,
]);
$rootToken = serverRegistriesApiToken($this->user, $this->team, ['root']);
$this->withHeaders(serverRegistriesApiHeaders($rootToken))
->json($method, "/api/v1/servers/{$otherServer->uuid}/registries{$suffix}", $payload)
->assertNotFound();
Process::assertNothingRan();
})->with([
'list' => ['GET', '', []],
'login' => ['POST', '', ['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret']],
'check' => ['POST', '/ghcr.io/check', []],
'logout' => ['DELETE', '/ghcr.io', []],
]);
test('registry endpoints that run commands require the write ability', function (string $method, string $suffix, array $payload) {
Process::fake();
$readToken = serverRegistriesApiToken($this->user, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverRegistriesApiHeaders($readToken))
->json($method, "/api/v1/servers/{$this->server->uuid}/registries{$suffix}", $payload)
->assertForbidden();
Process::assertNothingRan();
})->with([
'login' => ['POST', '', ['registry' => 'ghcr.io', 'username' => 'octocat', 'password' => 'secret']],
'check' => ['POST', '/ghcr.io/check', []],
'logout' => ['DELETE', '/ghcr.io', []],
]);
test('members cannot use registry endpoints', function () {
Process::fake();
$member = User::factory()->create();
$this->team->members()->attach($member->id, ['role' => 'member']);
$memberReadToken = serverRegistriesApiToken($member, $this->team, ['read']);
$memberSensitiveToken = serverRegistriesApiToken($member, $this->team, ['read', 'read:sensitive']);
$this->withHeaders(serverRegistriesApiHeaders($memberReadToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertForbidden();
$this->withHeaders(serverRegistriesApiHeaders($memberSensitiveToken))
->getJson("/api/v1/servers/{$this->server->uuid}/registries")
->assertForbidden();
Process::assertNothingRan();
});
@@ -0,0 +1,764 @@
<?php
use App\Livewire\Server\DockerRegistries;
use App\Livewire\Server\DockerRegistries\Login;
use App\Livewire\Server\DockerRegistries\ServerRegistries;
use App\Models\Application;
use App\Models\AuditEvent;
use App\Models\Environment;
use App\Models\InstanceSettings;
use App\Models\PrivateKey;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Models\StandalonePostgresql;
use App\Models\Team;
use App\Models\User;
use App\Services\DockerRegistryLogins;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Str;
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
Queue::fake();
$this->withoutVite();
$this->withoutDefer();
InstanceSettings::forceCreate(['id' => 0]);
config(['constants.ssh.mux_enabled' => false]);
$this->team = Team::factory()->create();
$this->server = Server::factory()->create([
'team_id' => $this->team->id,
'private_key_id' => PrivateKey::factory()->create(['team_id' => $this->team->id])->id,
]);
$destination = $this->server->standaloneDockers()->firstOrFail();
$environment = Environment::factory()->create([
'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id,
]);
$this->environment = $environment;
$this->destination = $destination;
$this->privateApp = Application::factory()->create([
'name' => 'private-app',
'docker_registry_image_name' => 'registry.example.com/team/app',
'environment_id' => $environment->id,
'destination_id' => $destination->id,
'destination_type' => $destination->getMorphClass(),
]);
});
function actingAsDockerRegistriesRole(Team $team, string $role): User
{
$user = User::factory()->create();
$user->teams()->attach($team, ['role' => $role]);
test()->actingAs($user);
session(['currentTeam' => $team]);
return $user;
}
function makeDockerRegistriesServerReachable(Server $server): void
{
$server->settings->update(['is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$server->refresh();
}
it('shows which registries a server is logged in to and which ones its applications need', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
Process::fake(function ($process) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
return Process::result(output: str_contains($command, '.docker/config.json')
? json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('user:SECRET_TOKEN')]]])
: '');
});
$component = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->assertSet('error', null)
->assertSet('registries', [
['registry' => 'ghcr.io', 'logged_in' => true, 'source' => 'auths', 'username' => 'user', 'used_by' => []],
[
'registry' => 'registry.example.com',
'logged_in' => false,
'source' => null,
'username' => null,
'used_by' => [['type' => 'Application', 'name' => 'private-app', 'link' => $this->privateApp->link()]],
],
])
->assertSee('Logged in')
->assertSee('Not logged in')
->assertSee('private-app');
expect($component->html())->not->toContain('SECRET_TOKEN')
->and(json_encode($component->instance()->registries))->not->toContain('SECRET_TOKEN');
});
it('shows an error and keeps the application registries when the server is not reachable', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
Process::fake();
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->assertSet('error', 'The server is not reachable. Validate the server to read its registry logins.')
->assertSee('registry.example.com')
->assertSee('Unknown');
Process::assertNothingRan();
});
it('lists the team servers for admins on the registries page', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
Livewire::test(DockerRegistries::class)
->assertViewHas('servers', fn ($servers) => $servers->pluck('id')->all() === [$this->server->id]);
$this->get(route('registries.index'))
->assertOk()
->assertSee('href="'.route('registries.index').'"', false);
$this->get(route('server.registries', ['server_uuid' => $this->server->uuid]))
->assertOk()
->assertSee('href="'.route('server.registries', ['server_uuid' => $this->server->uuid]).'"', false);
});
it('does not show registry logins to members', function () {
actingAsDockerRegistriesRole($this->team, 'member');
Livewire::test(DockerRegistries::class)->assertForbidden();
$this->get(route('server.show', ['server_uuid' => $this->server->uuid]))
->assertOk()
->assertDontSee('href="'.route('registries.index').'"', false)
->assertDontSee('href="'.route('server.registries', ['server_uuid' => $this->server->uuid]).'"', false);
$this->get(route('server.registries', ['server_uuid' => $this->server->uuid]))->assertForbidden();
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])->assertForbidden();
});
it('does not show registry logins of another team server', function () {
actingAsDockerRegistriesRole(Team::factory()->create(), 'owner');
Livewire::test(DockerRegistries::class)
->assertViewHas('servers', fn ($servers) => $servers->isEmpty());
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])->assertForbidden();
});
it('reads the Docker config of a non-root server user through sudo', function () {
$server = Server::factory()->make(['user' => 'deploy']);
$commands = parseCommandsByLineForSudo(collect(['cat "$HOME/.docker/config.json" 2>/dev/null || true']), $server);
expect($commands)->toBe(['sudo cat "$HOME/.docker/config.json" 2>/dev/null || sudo true']);
});
/**
* @return array<int, string>
*/
function fakeDockerRegistryServer(string $loginError = ''): array
{
$commands = [];
Process::fake(function ($process) use (&$commands, $loginError) {
$command = is_array($process->command) ? implode(' ', $process->command) : $process->command;
$commands[] = $command;
if ($loginError !== '' && str_contains($command, ' login ')) {
return Process::result(errorOutput: $loginError, exitCode: 1);
}
return Process::result(output: str_contains($command, '.docker/config.json') && str_contains($command, 'cat ')
? json_encode(['auths' => ['ghcr.io' => ['auth' => 'x']]])
: '');
});
return $commands;
}
it('logs in to a registry with the token on stdin and never as plain text in the command', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
$commands = [];
Process::fake(function ($process) use (&$commands) {
$commands[] = $process->command;
return Process::result(output: '');
});
$token = "ghp_SECRET'token\"with\$special";
Livewire::test(Login::class, ['server' => $this->server])
->set('registry', 'https://GHCR.io/')
->set('username', 'octocat')
->set('password', $token)
->call('login')
->assertHasNoErrors()
->assertSet('password', '')
->assertSet('registry', '')
->assertDispatched('success')
->assertDispatched('close-modal')
->assertDispatched('registryLoginsChanged', serverId: $this->server->id);
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
expect($login)
->toContain("echo '".base64_encode($token)."' | base64 -d | docker --config \"\$HOME/.docker\" login --username 'octocat' --password-stdin 'ghcr.io'")
->not->toContain('ghp_SECRET');
$event = AuditEvent::query()->where('event', 'ui.server.registry_login')->sole();
expect($event->metadata['registry'])->toBe('ghcr.io')
->and($event->metadata['outcome'])->toBe('success')
->and(json_encode($event->metadata))->not->toContain('ghp_SECRET');
});
it('lets docker choose the Docker Hub key and runs the login through sudo for non-root users', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
$this->server->update(['user' => 'deploy']);
makeDockerRegistriesServerReachable($this->server);
$commands = [];
Process::fake(function ($process) use (&$commands) {
$commands[] = $process->command;
return Process::result(output: '');
});
Livewire::test(Login::class, ['server' => $this->server])
->set('registry', 'docker.io')
->set('username', 'someone')
->set('password', 'dckr_pat_SECRET')
->call('login')
->assertHasNoErrors();
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
expect($login)->toContain("echo '".base64_encode('dckr_pat_SECRET')."' | sudo base64 -d | sudo docker --config \"\$HOME/.docker\" login --username 'someone' --password-stdin\n");
});
it('shows the docker error without the token when the login fails', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer("ControlSocket /tmp/mux already exists, disabling multiplexing\nWARNING! Using --password via the CLI is insecure.\nError response from daemon: unauthorized: bad token SECRET_TOKEN");
Livewire::test(Login::class, ['server' => $this->server])
->set('registry', 'ghcr.io')
->set('username', 'octocat')
->set('password', 'SECRET_TOKEN')
->call('login')
->assertSet('password', '')
->assertSet('registry', 'ghcr.io')
->assertDispatched('error', 'Login failed.', 'Error response from daemon: unauthorized: bad token ***');
expect(AuditEvent::query()->where('event', 'ui.server.registry_login')->sole()->metadata['outcome'])->toBe('failed');
});
it('validates the registry host and username before running a command', function (string $registry, string $username, string $errorField) {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
$commands = fakeDockerRegistryServer();
Livewire::test(Login::class, ['server' => $this->server])
->set('registry', $registry)
->set('username', $username)
->set('password', 'token')
->call('login')
->assertHasErrors($errorField);
Process::assertDidntRun(fn ($process) => str_contains($process->command, ' login '));
})->with([
'shell characters in registry' => ["ghcr.io';reboot;'", 'user', 'registry'],
'space in username' => ['ghcr.io', 'my user', 'username'],
'empty registry' => ['', 'user', 'registry'],
]);
it('logs out from a registry', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->assertSee('Delete the login for ghcr.io?')
->assertSeeHtml('logout(ghcr.io)')
->call('logout', 'ghcr.io', '')
->assertDispatched('success');
Process::assertRan(fn ($process) => str_contains($process->command, "docker --config \"\$HOME/.docker\" logout 'ghcr.io'"));
expect(AuditEvent::query()->where('event', 'ui.server.registry_logout')->sole()->metadata['registry'])->toBe('ghcr.io');
});
it('does not let a user who lost the admin role log in or log out', function (string $action) {
$user = actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$component = $action === 'login'
? Livewire::test(Login::class, ['server' => $this->server])->set('registry', 'ghcr.io')->set('username', 'u')->set('password', 'p')
: Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server]);
$user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$user->refresh();
Cache::flush();
$action === 'login'
? $component->call('login')->assertForbidden()
: $component->call('logout', 'ghcr.io')->assertForbidden();
Process::assertDidntRun(fn ($process) => str_contains($process->command, ' login ') || str_contains($process->command, ' logout '));
})->with(['login', 'logout']);
it('does not render the registries page again for a user who lost the admin role', function () {
$user = actingAsDockerRegistriesRole($this->team, 'admin');
$component = Livewire::test(DockerRegistries::class)->assertOk();
$user->teams()->updateExistingPivot($this->team->id, ['role' => 'member']);
$user->refresh();
Cache::flush();
$component->call('$refresh')->assertForbidden();
});
it('does not open the login form for members', function () {
actingAsDockerRegistriesRole($this->team, 'member');
Livewire::test(Login::class, ['server' => $this->server])->assertForbidden();
});
it('reloads only the card of the server that got a new login', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$component = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->set('registries', []);
$component->dispatch('registryLoginsChanged', serverId: $this->server->id + 1000)
->assertSet('registries', []);
$component->dispatch('registryLoginsChanged', serverId: $this->server->id)
->assertSet('registries', fn (array $registries) => collect($registries)->pluck('registry')->all() === ['ghcr.io', 'registry.example.com']);
});
it('fills the registry and username from the chosen provider', function (string $provider, string $registry, string $username) {
actingAsDockerRegistriesRole($this->team, 'admin');
Livewire::test(Login::class, ['server' => $this->server])
->set('password', 'typed-before')
->set('provider', $provider)
->assertSet('registry', $registry)
->assertSet('username', $username)
->assertSet('password', '');
})->with([
'docker hub' => ['dockerhub', 'docker.io', ''],
'google' => ['google', '', '_json_key'],
'aws' => ['aws', '', 'AWS'],
'unknown value' => ['nope', '', ''],
]);
it('edits an existing login with the registry kept fixed', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
$commands = [];
Process::fake(function ($process) use (&$commands) {
$commands[] = $process->command;
return Process::result(output: '');
});
Livewire::test(Login::class, ['server' => $this->server, 'editRegistry' => 'ghcr.io', 'currentUsername' => 'octocat'])
->assertSet('provider', 'ghcr')
->assertSet('registry', 'ghcr.io')
->assertSet('username', 'octocat')
->assertSee('Update login')
->set('registry', 'evil.example.com')
->set('provider', 'dockerhub')
->assertSet('registry', 'ghcr.io')
->set('username', 'new-user')
->set('password', 'NEW_SECRET')
->call('login')
->assertHasNoErrors()
->assertSet('registry', 'ghcr.io')
->assertSet('username', 'new-user')
->assertSet('password', '')
->assertDispatched('success', 'Updated the login for ghcr.io.');
$login = collect($commands)->first(fn (string $command) => str_contains($command, ' login '));
expect($login)->toContain("login --username 'new-user' --password-stdin 'ghcr.io'")
->not->toContain('evil.example.com');
expect(AuditEvent::query()->where('event', 'ui.server.registry_login_updated')->sole()->metadata['registry'])->toBe('ghcr.io');
});
it('does not allow changing the edited registry from the browser', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
expect(fn () => Livewire::test(Login::class, ['server' => $this->server, 'editRegistry' => 'ghcr.io'])
->set('editRegistry', 'evil.example.com'))->toThrow(Exception::class);
});
it('shows an edit button only for logins stored in the Docker config', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
Process::fake(fn () => Process::result(output: json_encode([
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:SECRET_TOKEN')]],
'credHelpers' => ['123456789012.dkr.ecr.eu-west-1.amazonaws.com' => 'ecr-login'],
])));
$html = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])->html();
expect(substr_count($html, 'Edit login for'))->toBe(1)
->and($html)->toContain('Edit login for ghcr.io')
->and($html)->not->toContain('SECRET_TOKEN');
});
it('guesses the provider of an existing registry', function (string $registry, string $provider) {
expect(Login::providerFor($registry))->toBe($provider);
})->with([
['docker.io', 'dockerhub'],
['europe-west1-docker.pkg.dev', 'google'],
['ghcr.io', 'ghcr'],
['registry.gitlab.com', 'gitlab'],
['myregistry.azurecr.io', 'azure'],
['123456789012.dkr.ecr.eu-west-1.amazonaws.com', 'aws'],
['registry.example.com', 'custom'],
]);
it('always uses the fixed host of a provider, even if the browser sends another one', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
$commands = fakeDockerRegistryServer();
Livewire::test(Login::class, ['server' => $this->server])
->set('provider', 'ghcr')
->set('registry', 'evil.example.com')
->set('username', 'octocat')
->set('password', 'token')
->call('login')
->assertHasNoErrors()
->assertDispatched('success', 'Logged in to ghcr.io.');
Process::assertRan(fn ($process) => str_contains($process->command, "--password-stdin 'ghcr.io'"));
Process::assertDidntRun(fn ($process) => str_contains($process->command, 'evil.example.com'));
});
it('checks that the host belongs to the chosen cloud provider', function (string $provider, string $registry, bool $valid) {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$component = Livewire::test(Login::class, ['server' => $this->server])
->set('provider', $provider)
->set('registry', $registry)
->set('username', 'user')
->set('password', 'token')
->call('login');
$valid ? $component->assertHasNoErrors() : $component->assertHasErrors('registry');
})->with([
'google artifact registry' => ['google', 'europe-west1-docker.pkg.dev', true],
'google container registry' => ['google', 'eu.gcr.io', true],
'google with other host' => ['google', 'ghcr.io', false],
'azure' => ['azure', 'myregistry.azurecr.io', true],
'azure with other host' => ['azure', 'azurecr.io.example.com', false],
'aws' => ['aws', '123456789012.dkr.ecr.eu-west-1.amazonaws.com', true],
'aws china' => ['aws', '123456789012.dkr.ecr.cn-north-1.amazonaws.com.cn', true],
'aws with other host' => ['aws', 'dkr.ecr.eu-west-1.amazonaws.com', false],
'other accepts any host' => ['custom', 'registry.example.com:5000', true],
]);
it('returns 403 on the registries pages for members', function () {
actingAsDockerRegistriesRole($this->team, 'member');
$this->get(route('registries.index'))->assertForbidden();
$this->get(route('server.registries', ['server_uuid' => $this->server->uuid]))->assertForbidden();
});
it('does not open the registries page of another team server', function () {
actingAsDockerRegistriesRole(Team::factory()->create(), 'owner');
$response = $this->get(route('server.registries', ['server_uuid' => $this->server->uuid]));
expect($response->status())->toBeIn([403, 404])
->and($response->getContent())->not->toContain('registry.example.com');
Livewire::test(Login::class, ['server' => $this->server])->assertForbidden();
});
it('does not let the browser swap the server of a card or of the login form', function (string $component) {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$otherServer = Server::factory()->create([
'team_id' => Team::factory()->create()->id,
'private_key_id' => PrivateKey::factory()->create()->id,
]);
$test = $component === 'card'
? Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
: Livewire::test(Login::class, ['server' => $this->server]);
expect(fn () => $test->set('server', $otherServer->id))->toThrow(CannotUpdateLockedPropertyException::class);
})->with(['card', 'login form']);
it('sends only registry names and usernames to the browser, never secrets from the Docker config', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
$this->server->update(['ip' => '203.0.113.77']);
makeDockerRegistriesServerReachable($this->server);
Process::fake(fn () => Process::result(output: json_encode([
'auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:SECRET_TOKEN'), 'identitytoken' => 'ID_TOKEN_SECRET']],
'credsStore' => 'pass',
'proxies' => ['default' => ['httpProxy' => 'http://proxyuser:PROXY_SECRET@proxy']],
])));
$card = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server]);
$form = Livewire::test(Login::class, ['server' => $this->server, 'editRegistry' => 'ghcr.io', 'currentUsername' => 'octocat']);
expect(array_keys($card->snapshot['data']))->toBe(['server', 'registries', 'error', 'loginChecks'])
->and(array_keys($form->snapshot['data']))->toBe(['server', 'selectedServers', 'provider', 'editRegistry', 'registry', 'username', 'password'])
->and($form->snapshot['data']['password'])->toBe('');
foreach ([$card, $form] as $component) {
$payload = json_encode($component->snapshot).$component->html();
expect($payload)
->not->toContain('SECRET_TOKEN')
->not->toContain(base64_encode('octocat:SECRET_TOKEN'))
->not->toContain('ID_TOKEN_SECRET')
->not->toContain('PROXY_SECRET')
->not->toContain('203.0.113.77');
}
});
it('lists databases and service containers that use a registry, and each service only once', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$placement = [
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
];
$database = StandalonePostgresql::create([
'name' => 'private-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('password'),
'postgres_db' => 'app',
'image' => 'ghcr.io/acme/postgres:16',
] + $placement);
StandalonePostgresql::create([
'name' => 'coolify-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('password'),
'postgres_db' => 'coolify',
'image' => 'ghcr.io/acme/internal:1',
] + $placement);
$service = Service::factory()->create(['name' => 'private-stack', 'server_id' => $this->server->id] + $placement);
foreach (['web' => 'ghcr.io/acme/web:1', 'worker' => 'ghcr.io/acme/worker:1', 'cache' => 'redis:7'] as $name => $image) {
ServiceApplication::create(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => $name, 'image' => $image]);
}
ServiceDatabase::create(['uuid' => (string) Str::uuid(), 'service_id' => $service->id, 'name' => 'db', 'image' => 'ghcr.io/acme/db:1']);
$registries = collect(Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->assertSee('private-db')
->assertSee('private-stack')
->get('registries'))->keyBy('registry');
expect($registries['ghcr.io']['used_by'])->toBe([
['type' => 'Database', 'name' => 'private-db', 'link' => $database->link()],
['type' => 'Service', 'name' => 'private-stack', 'link' => $service->link()],
])
->and($registries['docker.io']['used_by'])->toBe([
['type' => 'Service', 'name' => 'private-stack', 'link' => $service->link()],
])
->and($registries['docker.io']['logged_in'])->toBeFalse()
->and($registries['registry.example.com']['used_by'][0]['type'])->toBe('Application');
});
it('shows one user by name and many users as a summary with a grouped list', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$placement = [
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
];
foreach (['second-app', 'third-app'] as $name) {
Application::factory()->create(['name' => $name, 'docker_registry_image_name' => 'registry.example.com/team/'.$name] + $placement);
}
StandalonePostgresql::create([
'name' => 'hub-db',
'postgres_user' => 'postgres',
'postgres_password' => encrypt('password'),
'postgres_db' => 'app',
'image' => 'postgres:16',
] + $placement);
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->assertSee('1 resource on this server uses')
->assertSee('Docker Hub images. Public images need no login.')
->assertSee('3 applications')
->assertSee('Applications (3)')
->assertSee('second-app')
->assertSee('third-app');
});
it('shows applications that build on a build server on that build server', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$buildServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id]);
$buildServer->settings->update(['server_role' => 'build', 'is_reachable' => true, 'is_usable' => true, 'force_disabled' => false]);
$buildServer->refresh();
$this->privateApp->settings->update(['is_build_server_enabled' => true]);
$placement = [
'environment_id' => $this->environment->id,
'destination_id' => $this->destination->id,
'destination_type' => $this->destination->getMorphClass(),
];
Application::factory()->create(['name' => 'local-build', 'docker_registry_image_name' => 'registry.example.com/team/local'] + $placement);
$otherTeamServer = Server::factory()->create(['team_id' => Team::factory()->create()->id, 'private_key_id' => PrivateKey::factory()->create()->id]);
$otherTeamServer->settings->update(['server_role' => 'build']);
$users = collect(Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $buildServer])->get('registries'))
->keyBy('registry');
expect($users['registry.example.com']['used_by'])->toBe([
['type' => 'Build', 'name' => 'private-app', 'link' => $this->privateApp->link()],
]);
expect(DockerRegistryLogins::usersOf($otherTeamServer->refresh()))->toBeEmpty();
});
it('offers a login with the registry filled in on a row that is not logged in', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->assertSee('Log in to registry.example.com');
Livewire::test(Login::class, ['server' => $this->server, 'presetRegistry' => '123456789012.dkr.ecr.eu-west-1.amazonaws.com'])
->assertSet('provider', 'aws')
->assertSet('registry', '123456789012.dkr.ecr.eu-west-1.amazonaws.com')
->assertSet('username', 'AWS');
});
it('tests a saved login and shows the result on the row', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
$checkFails = false;
Process::fake(function ($process) use (&$checkFails) {
if (str_contains($process->command, '</dev/null')) {
return $checkFails
? Process::result(errorOutput: "Authenticating with existing credentials... [Username: octocat]\nLogin did not succeed, error: Error response from daemon: unauthorized\nerror: cannot perform an interactive login from a non-TTY device", exitCode: 1)
: Process::result(output: 'Login Succeeded');
}
return Process::result(output: json_encode(['auths' => ['ghcr.io' => ['auth' => base64_encode('octocat:x')]]]));
});
$component = Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->call('testLogin', 'ghcr.io')
->assertSet('loginChecks', ['ghcr.io' => ['ok' => true, 'message' => 'The login works.']])
->assertDispatched('success', 'The login for ghcr.io works.');
Process::assertRan(fn ($process) => str_contains($process->command, "docker --config \"\$HOME/.docker\" login 'ghcr.io' </dev/null"));
$checkFails = true;
$component->call('testLogin', 'ghcr.io')
->assertSet('loginChecks', ['ghcr.io' => ['ok' => false, 'message' => 'Error response from daemon: unauthorized']])
->assertSee('Login failed')
->call('loadRegistries')
->assertSet('loginChecks', []);
});
it('does not test registries that are not logged in', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
Livewire::withoutLazyLoading()->test(ServerRegistries::class, ['server' => $this->server])
->call('testLogin', 'registry.example.com')
->assertSet('loginChecks', []);
Process::assertDidntRun(fn ($process) => str_contains($process->command, '</dev/null'));
});
it('logs in to several servers at once from the overview page', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
$secondServer = Server::factory()->create(['team_id' => $this->team->id, 'private_key_id' => $this->server->private_key_id]);
makeDockerRegistriesServerReachable($secondServer);
fakeDockerRegistryServer();
Livewire::test(Login::class)
->assertSee($this->server->name)
->assertSee($secondServer->name)
->set('registry', 'registry.example.com')
->set('username', 'robot')
->set('password', 'token')
->call('login')
->assertHasErrors('selectedServers')
->set('password', 'token')
->set('selectedServers', [$this->server->uuid, $secondServer->uuid])
->call('login')
->assertHasNoErrors()
->assertDispatched('registryLoginsChanged', serverId: $this->server->id)
->assertDispatched('registryLoginsChanged', serverId: $secondServer->id)
->assertDispatched('success', 'Logged in to registry.example.com.')
->assertSet('selectedServers', []);
expect(AuditEvent::query()->where('event', 'ui.server.registry_login')->count())->toBe(2);
});
it('reports the servers where a multi-server login failed', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer('Error response from daemon: unauthorized');
Livewire::test(Login::class)
->set('selectedServers', [$this->server->uuid])
->set('registry', 'registry.example.com')
->set('username', 'robot')
->set('password', 'token')
->call('login')
->assertDispatched('error', 'Login failed.', 'Error response from daemon: unauthorized')
->assertNotDispatched('registryLoginsChanged');
});
it('refuses a multi-server login to a server of another team', function () {
actingAsDockerRegistriesRole($this->team, 'admin');
makeDockerRegistriesServerReachable($this->server);
fakeDockerRegistryServer();
$foreignServer = Server::factory()->create(['team_id' => Team::factory()->create()->id, 'private_key_id' => PrivateKey::factory()->create()->id]);
Livewire::test(Login::class)
->assertDontSee($foreignServer->name)
->set('selectedServers', [$this->server->uuid, $foreignServer->uuid])
->set('registry', 'registry.example.com')
->set('username', 'robot')
->set('password', 'token')
->call('login')
->assertForbidden();
Process::assertDidntRun(fn ($process) => str_contains($process->command, ' login '));
});
it('does not open the overview login form for members', function () {
actingAsDockerRegistriesRole($this->team, 'member');
Livewire::test(Login::class)->assertForbidden();
});
it('explains a missing login and hides SSH connection noise in docker errors', function () {
makeDockerRegistriesServerReachable($this->server);
$sshNoise = "Control socket connect(/var/www/html/storage/app/ssh/mux/mux_x): Permission denied\nControlSocket /var/www/html/storage/app/ssh/mux/mux_x already exists, disabling multiplexing\nWarning: Permanently added '203.0.113.7' (ED25519) to the list of known hosts.";
Process::fake(fn () => Process::result(errorOutput: $sshNoise."\nerror: cannot perform an interactive login from a non-TTY device", exitCode: 1));
expect(fn () => DockerRegistryLogins::checkLogin($this->server, 'ghcr.io'))
->toThrow(RuntimeException::class, 'There is no saved login for ghcr.io on this server.');
Process::fake(fn () => Process::result(errorOutput: $sshNoise."\nError response from daemon: unauthorized", exitCode: 1));
expect(fn () => DockerRegistryLogins::login($this->server, 'ghcr.io', 'octocat', 'token'))
->toThrow(fn (RuntimeException $exception) => expect($exception->getMessage())->toBe('Error response from daemon: unauthorized'));
});
+57
View File
@@ -0,0 +1,57 @@
<?php
use App\Services\DockerRegistryLogins;
it('reads registry names from auths and credHelpers without credentials', function () {
$config = json_encode([
'auths' => [
'https://index.docker.io/v1/' => ['auth' => base64_encode('user:SECRET_TOKEN')],
'ghcr.io' => ['auth' => base64_encode('user:SECRET_TOKEN')],
'registry.example.com:5000' => [],
],
'credHelpers' => [
'123456789.dkr.ecr.eu-west-1.amazonaws.com' => 'ecr-login',
'ghcr.io' => 'gh',
],
]);
$registries = DockerRegistryLogins::parseConfig($config);
expect($registries)->toBe([
'123456789.dkr.ecr.eu-west-1.amazonaws.com' => ['source' => 'credHelpers', 'username' => null],
'docker.io' => ['source' => 'auths', 'username' => 'user'],
'ghcr.io' => ['source' => 'auths', 'username' => 'user'],
'registry.example.com:5000' => ['source' => 'auths', 'username' => null],
])->and(json_encode($registries))->not->toContain('SECRET_TOKEN');
});
it('returns no registries for a missing or invalid config', function (?string $config) {
expect(DockerRegistryLogins::parseConfig($config))->toBe([]);
})->with([null, '', 'not json', '{"auths": "broken"}', '{}']);
it('finds the registry of an image reference', function (string $image, string $registry) {
expect(DockerRegistryLogins::registryFromImage($image))->toBe($registry);
})->with([
['nginx', 'docker.io'],
['library/nginx:latest', 'docker.io'],
['coollabsio/coolify', 'docker.io'],
['docker.io/coollabsio/coolify', 'docker.io'],
['ghcr.io/coollabsio/coolify:4', 'ghcr.io'],
['GHCR.IO/Owner/App', 'ghcr.io'],
['registry.example.com:5000/team/app', 'registry.example.com:5000'],
['localhost/app', 'localhost'],
['localhost:5000/app', 'localhost:5000'],
]);
it('reads the username from the auth value without keeping the password', function (array $entry, ?string $username) {
$registries = DockerRegistryLogins::parseConfig(json_encode(['auths' => ['ghcr.io' => $entry]]));
expect($registries['ghcr.io']['username'])->toBe($username)
->and(json_encode($registries))->not->toContain('pa:ss');
})->with([
'password with colon' => [['auth' => base64_encode('octocat:pa:ss')], 'octocat'],
'explicit username' => [['username' => 'robot$ci', 'auth' => base64_encode('ignored:pa:ss')], 'robot$ci'],
'invalid base64' => [['auth' => '%%%'], null],
'no colon' => [['auth' => base64_encode('pa:ss')], 'pa'],
'empty entry' => [[], null],
]);