mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
310 lines
13 KiB
PHP
310 lines
13 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Helpers\SshMultiplexingHelper;
|
|
use App\Models\Application;
|
|
use App\Models\Server;
|
|
use App\Models\Service;
|
|
use Illuminate\Process\Exceptions\ProcessTimedOutException;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Facades\Process;
|
|
use RuntimeException;
|
|
use Symfony\Component\Process\Exception\ProcessTimedOutException as SymfonyProcessTimedOutException;
|
|
|
|
class DockerRegistryLogins
|
|
{
|
|
public const DOCKER_HUB = 'docker.io';
|
|
|
|
/**
|
|
* A registry host with an optional port. An IPv6 host is bracketed ([2a01:4f8::1]:5000) and holds only
|
|
* hex digits, ':' and '.', so the value stays safe in shell commands.
|
|
*/
|
|
public const REGISTRY_PATTERN = '/^(?:[a-z0-9]([a-z0-9.-]*[a-z0-9])?|\[[0-9a-f:.]+\])(:[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 script goes to the server's shell on ssh stdin, and the shell builtin echo passes the secret to docker
|
|
* on stdin, so it is not a process argument on the Coolify host or on the server. 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];
|
|
try {
|
|
$process = Process::timeout(60)
|
|
->input(SshMultiplexingHelper::sshBatchInput(implode("\n", $commands)))
|
|
->run(SshMultiplexingHelper::generateSshBatchCommand($server));
|
|
} catch (ProcessTimedOutException|SymfonyProcessTimedOutException) {
|
|
// The timeout message contains the full SSH command, including the registry password.
|
|
throw new RuntimeException('The command on the server timed out after 60 seconds.', 124);
|
|
}
|
|
|
|
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()}."));
|
|
}
|
|
}
|