Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
This commit is contained in:
Andras Bacsai
2026-11-06 14:25:02 +01:00
committed by GitHub
co-authored by Claude Opus 5.5 peaklabs-dev Aditya Tripathi
parent b7dc2e435b
commit 033f0c84da
581 changed files with 58626 additions and 2456 deletions
+2
View File
@@ -264,6 +264,7 @@ function sharedDataApplications(?Application $application = null): array
}
}],
'is_consistent_container_name_enabled' => 'boolean',
'is_image_scan_enabled' => 'boolean',
'custom_internal_name' => ['nullable', ...ValidationPatterns::containerNameRules()],
'custom_container_name_prefix' => 'string|nullable|max:'.ApplicationSetting::MAX_CONTAINER_NAME_PREFIX_LENGTH,
'preview_url_template' => 'string',
@@ -551,6 +552,7 @@ function removeUnnecessaryFieldsFromRequest(Request $request)
$request->offsetUnset('gpu_device_ids');
$request->offsetUnset('gpu_options');
$request->offsetUnset('is_consistent_container_name_enabled');
$request->offsetUnset('is_image_scan_enabled');
$request->offsetUnset('custom_internal_name');
$request->offsetUnset('custom_container_name_prefix');
$request->offsetUnset('docker_compose_raw');
+297 -5
View File
@@ -5,6 +5,7 @@ use App\Enums\ApplicationDeploymentStatus;
use App\Enums\CancelPreviousDeployments;
use App\Jobs\ApplicationDeploymentJob;
use App\Jobs\CancelSupersededDeploymentJob;
use App\Jobs\SyncGithubDeploymentJob;
use App\Jobs\VolumeCloneJob;
use App\Models\Application;
use App\Models\ApplicationDeploymentQueue;
@@ -16,11 +17,13 @@ use App\Models\StandaloneDocker;
use App\Notifications\Application\DeploymentFailed;
use App\Services\Dns\ResourceDnsRecords;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Spatie\Url\Url;
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null, ?string $parent_deployment_uuid = null, bool $is_mcp = false, ?CancelPreviousDeployments $cancel_previous = null)
function queue_application_deployment(Application $application, string $deployment_uuid, ?int $pull_request_id = 0, ?string $commit = null, bool $force_rebuild = false, bool $is_webhook = false, bool $is_api = false, bool $restart_only = false, ?string $git_type = null, bool $no_questions_asked = false, ?Server $server = null, ?StandaloneDocker $destination = null, bool $only_this_server = false, bool $rollback = false, ?string $docker_registry_image_tag = null, ?string $parent_deployment_uuid = null, ?int $scan_gate_bypassed_by = null, ?string $scan_gate_pinned_image = null, bool $is_mcp = false, ?CancelPreviousDeployments $cancel_previous = null)
{
$commit = $commit ?: ($application->git_commit_sha ?: 'HEAD');
$commit = validateGitRef($commit, 'deployment commit');
@@ -43,7 +46,7 @@ function queue_application_deployment(Application $application, string $deployme
$is_mcp = $is_mcp || request()->attributes->get('mcp_rest_bridge') === true;
$is_api = $is_api && ! $is_mcp;
$admission = DB::transaction(function () use ($application, $application_id, $cancel_previous, $commit, $deployment_uuid, $deployment_url, $destination_id, $docker_registry_image_tag, $force_rebuild, $git_type, $is_api, $is_mcp, $is_webhook, $no_questions_asked, $only_this_server, $parent_deployment_uuid, $pull_request_id, $restart_only, $rollback, $server_id, $server_name) {
$admission = DB::transaction(function () use ($application, $application_id, $cancel_previous, $commit, $deployment_uuid, $deployment_url, $destination_id, $docker_registry_image_tag, $force_rebuild, $git_type, $is_api, $is_mcp, $is_webhook, $no_questions_asked, $only_this_server, $parent_deployment_uuid, $pull_request_id, $restart_only, $rollback, $scan_gate_bypassed_by, $scan_gate_pinned_image, $server_id, $server_name) {
// Lock stable rows because an empty deployment queue has no row to lock.
Application::query()->whereKey($application_id)->lockForUpdate()->firstOrFail();
$serverForQueueCheck = Server::query()->whereKey($server_id)->lockForUpdate()->firstOrFail();
@@ -95,6 +98,9 @@ function queue_application_deployment(Application $application, string $deployme
'git_type' => $git_type,
'only_this_server' => $only_this_server,
'parent_deployment_uuid' => $parent_deployment_uuid,
'scan_gate_bypassed_by' => $scan_gate_bypassed_by,
// "Deploy anyway" of a Docker image starts the blocked image by its digest.
'scan_gate_result' => $scan_gate_pinned_image !== null ? ['repo_digest' => $scan_gate_pinned_image] : null,
]);
$superseded = cancel_superseded_deployments($deployment, $application, $cancel_previous);
@@ -121,6 +127,12 @@ function queue_application_deployment(Application $application, string $deployme
foreach ($admission['superseded'] as $kind => $cancelled) {
foreach ($cancelled as $old) {
$old->addLogEntry("Deployment cancelled: the newer deployment {$deployment_uuid} (commit {$commit}) replaces it.", 'stderr');
// The cancel is a query update, which fires no model event.
try {
SyncGithubDeploymentJob::dispatchFor($old);
} catch (Throwable $e) {
Log::warning("Failed to queue the GitHub deployment sync of superseded deployment {$old->deployment_uuid}: {$e->getMessage()}");
}
if ($kind !== 'building') {
continue;
}
@@ -359,6 +371,12 @@ function fail_undispatchable_deployment(ApplicationDeploymentQueue $deployment,
Log::warning("Deployment {$deployment->deployment_uuid} could not be started: {$exception->getMessage()}");
$deployment->refresh();
// The query update above fires no model event.
try {
SyncGithubDeploymentJob::dispatchFor($deployment);
} catch (Throwable $e) {
Log::warning("Failed to queue the GitHub deployment sync of deployment {$deployment->deployment_uuid}: {$e->getMessage()}");
}
$deployment->addLogEntry('========================================', 'stderr');
$deployment->addLogEntry("Deployment could not be started: {$exception->getMessage()}", 'stderr');
$deployment->addLogEntry('========================================', 'stderr');
@@ -665,12 +683,286 @@ function clone_application(Application $source, $destination, array $overrides =
}
/**
* The SSH command that git uses to reach a repository.
* The published SSH host keys of the large git hosts, also for their SSH endpoints on port 443.
* ssh.github.com also serves the GitHub keys on port 22, where a `Port 443` in the SSH config ends because
* gitSshCommand() passes the port of the repository URL.
* Git over SSH to these hosts accepts only these keys. Update this list when a provider changes its host keys.
* GitLab.com does not publish its keys over HTTPS, so for gitlab.com an application also accepts the keys that are saved on its Source page.
*
* @see https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints
* @see https://api.github.com/meta (ssh_keys)
* @see https://docs.gitlab.com/user/gitlab_com/#ssh-known_hosts-entries
* @see https://bitbucket.org/site/ssh
* @see https://docs.github.com/en/authentication/troubleshooting-ssh/using-ssh-over-the-https-port
*/
const GIT_PINNED_HOST_KEYS = [
'github.com,ssh.github.com,[ssh.github.com]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl',
'github.com,ssh.github.com,[ssh.github.com]:443 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=',
'github.com,ssh.github.com,[ssh.github.com]:443 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=',
'gitlab.com,[altssh.gitlab.com]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf',
'gitlab.com,[altssh.gitlab.com]:443 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=',
'gitlab.com,[altssh.gitlab.com]:443 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9',
'bitbucket.org,[altssh.bitbucket.org]:443 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIazEu89wgQZ4bqs3d63QSMzYVa0MuJ2e2gKTKqu+UUO',
'bitbucket.org,[altssh.bitbucket.org]:443 ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBPIQmuzMBuKdWeF4+a2sjSSpBK0iqitSQ+5BM9KhpexuGt20JpTVM7u5BDZngncgrqDMbWdxMWWOGtZ9UgbqgZE=',
'bitbucket.org,[altssh.bitbucket.org]:443 ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDQeJzhupRu0u0cdegZIa8e86EG2qOCsIsD1Xw0xSeiPDlCr7kq97NLmMbpKTX6Esc30NuoqEEHCuc7yWtwp8dI76EEEB1VqY9QJq6vk+aySyboD5QF61I/1WeTwu+deCbgKMGbUijeXhtfbxSxm6JwGrXrhBdofTsbKRUsrN1WoNgUa8uqN1Vx6WAJw1JHPhglEGGHea6QICwJOAr/6mrui/oB7pkaWKHj3z7d1IC4KWLtY47elvjbaTlkN04Kc/5LFEirorGYVbt15kAUlqGM65pk6ZBxtaO3+30LVlORZkxOh+LKL/BvbZ/iRNhItLqNyieoQj/uh/7Iv4uyH/cV/0b4WDSd3DptigWq84lJubb9t/DnZlrJazxyDCulTmKdOR7vs9gMTo+uoIrPSb8ScTtvw65+odKAlBj59dhnVp9zd7QUojOpXlL62Aw56U4oO+FALuevvMjiWeavKhJqlR7i5n9srYcrNV7ttmDw7kf/97P5zauIhxcjX+xHv4M=',
];
/**
* The providers that publish their SSH host keys over HTTPS. "Fetch current host key" on the Source page reads
* them (fetchGitProviderHostKeys()), so deployments follow a key change without a Coolify release.
* GitLab publishes its keys only in its docs. The key is the host list of the provider's lines in GIT_PINNED_HOST_KEYS.
*/
const GIT_PROVIDER_HOST_KEY_SOURCES = [
'github.com,ssh.github.com,[ssh.github.com]:443' => 'https://api.github.com/meta',
'bitbucket.org,[altssh.bitbucket.org]:443' => 'https://bitbucket.org/site/ssh',
];
function gitProviderHostKeysCacheKey(string $hosts): string
{
return 'git-provider-host-keys:'.$hosts;
}
/**
* The key of GIT_PROVIDER_HOST_KEY_SOURCES whose host list contains $host, or null.
*/
function gitProviderHostKeySource(string $host): ?string
{
foreach (array_keys(GIT_PROVIDER_HOST_KEY_SOURCES) as $hosts) {
if (in_array(strtolower($host), gitKnownHostsLineHosts($hosts), true)) {
return $hosts;
}
}
return null;
}
/**
* Reads the host keys that a provider publishes over HTTPS and saves them for gitProviderHostKeys().
*
* @return list<string>
*
* @throws RuntimeException When the provider does not answer with valid keys. The saved keys do not change.
*/
function fetchGitProviderHostKeys(string $hosts): array
{
$url = GIT_PROVIDER_HOST_KEY_SOURCES[$hosts];
$response = Http::timeout(10)->get($url);
if (! $response->successful()) {
throw new RuntimeException("Could not read the published host keys from {$url} (HTTP {$response->status()}).");
}
$keys = $hosts === array_key_first(GIT_PROVIDER_HOST_KEY_SOURCES)
? (array) $response->json('ssh_keys')
: collect(preg_split('/\R/', $response->body()))
->map(fn (string $line) => preg_split('/\s+/', trim($line)))
->filter(fn (array $parts) => count($parts) >= 3 && $parts[0] === explode(',', $hosts)[0])
->map(fn (array $parts) => "{$parts[1]} {$parts[2]}")
->all();
$lines = collect($keys)
->filter(fn ($key) => is_string($key))
->map(fn (string $key) => $hosts.' '.trim($key))
->unique()
->values();
if ($lines->isEmpty() || $lines->contains(fn (string $line) => ! isValidGitKnownHostsLine($line))) {
throw new RuntimeException("The answer from {$url} has no valid host keys.");
}
Cache::forever(gitProviderHostKeysCacheKey($hosts), $lines->all());
return $lines->all();
}
/**
* GIT_PINNED_HOST_KEYS, with the lines of a provider replaced by the keys that it published last.
* A replaced list also drops a key that the provider withdrew.
*
* @return list<string>
*/
function gitProviderHostKeys(): array
{
$lines = GIT_PINNED_HOST_KEYS;
foreach (array_keys(GIT_PROVIDER_HOST_KEY_SOURCES) as $hosts) {
$published = Cache::get(gitProviderHostKeysCacheKey($hosts));
if (is_array($published) && $published !== []) {
$lines = [...array_filter($lines, fn (string $line) => explode(' ', $line, 2)[0] !== $hosts), ...$published];
}
}
return array_values($lines);
}
function gitSshConfigFile(string $uuid): string
{
return "/root/.ssh/coolify_git_{$uuid}.config";
}
function gitKnownHostsFile(string $uuid): string
{
return "/root/.ssh/coolify_git_{$uuid}.known_hosts";
}
/**
* A known_hosts line without markers, hashed host names, or comments: `host[,host] key-type key`.
* A host with a custom SSH port is written as `[host]:port`.
*/
function isValidGitKnownHostsLine(string $line): bool
{
$host = '(?:[A-Za-z0-9._:-]+|\[[A-Za-z0-9._:-]+\]:[0-9]{1,5})';
$keyType = '(?:ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)';
return preg_match("/\\A{$host}(?:,{$host})* {$keyType} [A-Za-z0-9+\/]+={0,2}\\z/", $line) === 1;
}
/**
* The host names of a known_hosts line, without brackets and ports.
*
* @return list<string>
*/
function gitKnownHostsLineHosts(string $line): array
{
return array_map(
fn (string $host) => strtolower(preg_replace('/\A\[(.+)\]:[0-9]+\z/', '$1', $host)),
explode(',', strtok($line, ' ')),
);
}
/**
* The host names in GIT_PINNED_HOST_KEYS.
*
* @return list<string>
*/
function gitPinnedHosts(): array
{
return array_values(array_unique(array_merge(...array_map(gitKnownHostsLineHosts(...), GIT_PINNED_HOST_KEYS))));
}
/**
* The host names of the providers in GIT_PROVIDER_HOST_KEY_SOURCES. Coolify reads their keys over HTTPS, so the
* keys that are saved for an application are not used for these hosts.
*
* @return list<string>
*/
function gitHostsWithPublishedKeys(): array
{
return array_values(array_unique(array_merge(...array_map(gitKnownHostsLineHosts(...), array_keys(GIT_PROVIDER_HOST_KEY_SOURCES)))));
}
/**
* Whether a known_hosts line names a host of gitHostsWithPublishedKeys(). Deployments ignore such lines.
*/
function gitKnownHostsLineHasHostWithPublishedKeys(string $line): bool
{
return array_intersect(gitKnownHostsLineHosts($line), gitHostsWithPublishedKeys()) !== [];
}
/**
* The valid known_hosts lines of a text, without blank lines, duplicates, and lines of gitHostsWithPublishedKeys().
*
* @return list<string>
*/
function gitKnownHostsLines(?string $text): array
{
return collect(preg_split('/\R/', (string) $text))
->map(fn (string $line) => preg_replace('/\s+/', ' ', trim($line)))
->filter(fn (string $line) => isValidGitKnownHostsLine($line) && ! gitKnownHostsLineHasHostWithPublishedKeys($line))
->unique()
->values()
->all();
}
/**
* The saved known_hosts lines, with the lines of the hosts in $fetchedLines replaced by $fetchedLines.
* A saved line belongs to a fetched host when one of its host entries has the same host and port.
*
* @param list<string> $fetchedLines
* @return list<string>
*/
function gitKnownHostsReplaceHostKeys(?string $saved, array $fetchedLines): array
{
$entries = fn (string $line) => array_map(
fn (string $host) => strtolower(preg_match('/\A\[(.+)\]:([0-9]+)\z/', $host, $match) ? "{$match[1]}:{$match[2]}" : "{$host}:22"),
explode(',', strtok($line, ' ')),
);
$fetchedEntries = array_merge(...array_map($entries, $fetchedLines));
$otherLines = array_filter(gitKnownHostsLines($saved), fn (string $line) => array_intersect($entries($line), $fetchedEntries) === []);
return [...array_values($otherLines), ...$fetchedLines];
}
/**
* The host, key type, and SHA256 fingerprint of a known_hosts line, in the format that `ssh-keygen -lf` shows.
*
* @return array{host: string, type: string, fingerprint: string}
*/
function gitKnownHostFingerprint(string $line): array
{
[$host, $type, $key] = explode(' ', $line);
return [
'host' => $host,
'type' => $type,
'fingerprint' => 'SHA256:'.rtrim(base64_encode(hash('sha256', (string) base64_decode($key), true)), '='),
];
}
/**
* A shell command that writes the SSH config and known_hosts file that gitSshCommand() uses.
* Run it in the place where git runs (the helper container or the server) before git uses SSH.
*
* The hosts in GIT_PINNED_HOST_KEYS must show one of their published keys (gitProviderHostKeys()), or for gitlab.com also
* a key in $knownHosts. Other hosts are trusted on
* first use: SSH adds the key of a new host to the known_hosts file and rejects a changed key of a known
* host. $knownHosts are the keys that earlier deployments saved (Application::$git_ssh_known_hosts).
* The config of the SSH user is read after these rules, so it cannot change StrictHostKeyChecking or
* UserKnownHostsFile. Root on the server can still add trusted keys (for example with KnownHostsCommand
* or GlobalKnownHostsFile), which is trusted by design. `~` in the Include is resolved from $HOME.
*
* With $onServer, the command first tests whether the ssh of the server supports accept-new (OpenSSH 7.6+,
* which also has Include). An older ssh rejects the whole config, so there every host is checked strictly
* against the pinned and saved keys, with no trust on first use, and the config of root and the system
* config are appended to the file instead of included.
*/
function gitSshSetupCommand(string $uuid, ?string $knownHosts = null, bool $onServer = false): string
{
$knownHostsFile = gitKnownHostsFile($uuid);
$pinnedHosts = implode(' ', gitPinnedHosts());
$config = implode("\n", [
"Host {$pinnedHosts}",
' StrictHostKeyChecking yes',
'Host *',
' StrictHostKeyChecking accept-new',
" UserKnownHostsFile {$knownHostsFile}",
' HashKnownHosts no',
' Include ~/.ssh/config /etc/ssh/ssh_config',
])."\n";
$knownHostsContent = implode("\n", [...gitProviderHostKeys(), ...gitKnownHostsLines($knownHosts)])."\n";
$write = fn (string $content, string $file) => "echo '".base64_encode($content)."' | base64 -d | tee {$file} > /dev/null";
$setup = 'mkdir -p /root/.ssh && '.$write($knownHostsContent, $knownHostsFile).' && ';
if (! $onServer) {
return $setup.$write($config, gitSshConfigFile($uuid));
}
$strictConfig = implode("\n", [
'Host *',
' StrictHostKeyChecking yes',
" UserKnownHostsFile {$knownHostsFile}",
' HashKnownHosts no',
])."\n";
return $setup.'if ssh -F /dev/null -G -o StrictHostKeyChecking=accept-new localhost > /dev/null 2>&1; then '.$write($config, gitSshConfigFile($uuid))
."; else { echo '".base64_encode($strictConfig)."' | base64 -d; cat /root/.ssh/config /etc/ssh/ssh_config 2> /dev/null; } | tee ".gitSshConfigFile($uuid).' > /dev/null; fi';
}
/**
* The SSH command that git uses to reach a repository. Run gitSshSetupCommand() with the same $uuid first.
* Without a port, SSH uses the port of the repository URL.
* The keepalive options stop a stalled connection after about one minute; without them git waits until the deployment timeout.
*/
function gitSshCommand(int|string $port, ?string $identityFile = null, bool $identitiesOnly = true): string
function gitSshCommand(string $uuid, int|string|null $port = null, ?string $identityFile = null, bool $identitiesOnly = true): string
{
$command = "ssh -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4 -p {$port} -o Port={$port} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null";
$command = 'ssh -F '.gitSshConfigFile($uuid).' -o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=4';
if ($port !== null) {
$command .= " -p {$port} -o Port={$port}";
}
$command .= ' -o LogLevel=ERROR';
if ($identityFile !== null) {
$command .= " -i {$identityFile}".($identitiesOnly ? ' -o IdentitiesOnly=yes' : '');
+2 -1
View File
@@ -8,7 +8,7 @@ use Illuminate\Support\Str;
if (! function_exists('auditLog')) {
/**
* Queue an audit event for persistence after the response.
* Record an audit event. The database row is written at once, or when the open transaction commits.
*
* @param string $event Dot-namespaced event name, e.g. `api.private_key.created`.
* @param array<string, mixed> $context Identifiers + outcome details.
@@ -182,6 +182,7 @@ if (! function_exists('auditLogWebhookFailure')) {
'event_header' => $request?->header('X-GitHub-Event')
?? $request?->header('X-Gitlab-Event')
?? $request?->header('X-Gitea-Event')
?? $request?->header('X-Forgejo-Event')
?? $request?->header('X-Event-Key'),
];
+55
View File
@@ -18,7 +18,10 @@ use App\Models\StandaloneRedis;
use App\Models\StandaloneSqlite;
use App\Models\SwarmDocker;
use App\Services\Access\AccessResolver;
use App\Support\DeletesEmptyDirectories;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\File\Exception\FileNotFoundException;
@@ -276,6 +279,12 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
$filenames = [$filenames];
}
if (! $s3->isS3()) {
deleteBackupsFromFileStorage($filenames, $s3);
return;
}
$disk = Storage::build([
'driver' => 's3',
'key' => $s3->key,
@@ -300,6 +309,52 @@ function deleteBackupsS3(string|array|null $filenames, S3Storage $s3): void
}
}
/**
* Deletes backup files from an SFTP, FTPS, or WebDAV storage. A file that is gone already counts as deleted, as on S3.
*
* @param array<int, string> $filenames
*/
function deleteBackupsFromFileStorage(array $filenames, S3Storage $storage): void
{
try {
$disk = $storage->filesystem();
foreach ($filenames as $filename) {
if ($disk->exists($filename) && ! $disk->delete($filename)) {
throw new RuntimeException("{$filename} could not be deleted.");
}
}
} catch (Throwable $exception) {
throw new RuntimeException('One or more backup files could not be deleted from the '.$storage->type->label().' storage.', previous: $exception);
}
deleteEmptyFileStorageDirectories($disk, $filenames);
}
/**
* Deletes the directories of deleted backup files that are empty now, and their empty parents, below the base path of
* the storage. The server refuses a directory with content, so a file that another backup writes now stays.
*
* @param array<int, string> $filenames
*/
function deleteEmptyFileStorageDirectories(FilesystemAdapter $disk, array $filenames): void
{
$adapter = $disk->getAdapter();
if (! $adapter instanceof DeletesEmptyDirectories) {
return;
}
$directories = collect($filenames)->map(fn (string $filename): string => dirname(trim($filename, '/')))->unique();
foreach ($directories as $directory) {
try {
while ($directory !== '.' && $directory !== '' && $adapter->deleteEmptyDirectory($directory)) {
$directory = dirname($directory);
}
} catch (Throwable $exception) {
Log::warning('Could not delete an empty backup directory: '.$exception->getMessage());
}
}
}
function deleteEmptyBackupFolder($folderPath, Server $server): void
{
$escapedPath = escapeshellarg($folderPath);
+1 -1
View File
@@ -13,7 +13,7 @@ const GITEA_PAGE_SIZE = 50;
function refreshGiteaToken(GiteaApp $source): void
{
if (! $source->refresh_token) {
throw new RuntimeException('Gitea source has no refresh token. Please reconnect the Gitea app.');
throw new RuntimeException('Gitea / Forgejo source has no refresh token. Please reconnect the Gitea / Forgejo App.');
}
$safetyMargin = 60;
+20 -8
View File
@@ -5,6 +5,7 @@ use App\Models\PrivateKey;
use Carbon\Carbon;
use Carbon\CarbonImmutable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Http\Client\RequestException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Cache;
@@ -199,7 +200,10 @@ function githubAppPrivateKey(GithubApp $source): ?PrivateKey
return $privateKey?->team_id === $source->team_id ? $privateKey : null;
}
function generateGithubToken(GithubApp $source, string $type)
/**
* @param int|null $timeout Seconds for each request to GitHub. Null keeps the HTTP client default.
*/
function generateGithubToken(GithubApp $source, string $type, ?int $timeout = null)
{
$privateKey = githubAppPrivateKey($source) ?? throw new RuntimeException('Private key not found for this GitHub App.');
@@ -218,8 +222,8 @@ function generateGithubToken(GithubApp $source, string $type)
return match ($type) {
'jwt' => $jwt,
'installation' => (function () use ($source, $jwt) {
$response = Http::GitSource($source->api_url)->withHeaders([
'installation' => (function () use ($source, $jwt, $timeout) {
$response = githubRequestTimeout(Http::GitSource($source->api_url), $timeout)->withHeaders([
'Authorization' => "Bearer $jwt",
'Accept' => 'application/vnd.github.machine-man-preview+json',
])->retry(3, fn (int $attempt) => $attempt * 1000, isTransientGithubFailure(...), throw: false)->post("{$source->api_url}/app/installations/{$source->installation_id}/access_tokens");
@@ -239,9 +243,17 @@ function generateGithubToken(GithubApp $source, string $type)
};
}
function generateGithubInstallationToken(GithubApp $source)
function generateGithubInstallationToken(GithubApp $source, ?int $timeout = null)
{
return $source->installationToken();
return $source->installationToken($timeout);
}
/**
* Limits how long a request waits for GitHub, both to connect and for the whole answer.
*/
function githubRequestTimeout(PendingRequest $request, ?int $timeout): PendingRequest
{
return $timeout === null ? $request : $request->connectTimeout($timeout)->timeout($timeout);
}
function generateGithubJwt(GithubApp $source)
@@ -249,7 +261,7 @@ function generateGithubJwt(GithubApp $source)
return generateGithubToken($source, 'jwt');
}
function githubApi(?GithubApp $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true)
function githubApi(?GithubApp $source, string $endpoint, string $method = 'get', ?array $data = null, bool $throwError = true, ?int $timeout = null)
{
if (is_null($source)) {
throw new Exception('Source is required for API calls');
@@ -259,9 +271,9 @@ function githubApi(?GithubApp $source, string $endpoint, string $method = 'get',
throw new InvalidArgumentException("Unsupported source type: {$source->getMorphClass()}");
}
$request = $source->is_public
$request = githubRequestTimeout($source->is_public
? Http::GitHub($source->api_url)
: Http::GitHub($source->api_url, generateGithubInstallationToken($source));
: Http::GitHub($source->api_url, generateGithubInstallationToken($source, $timeout)), $timeout);
// Only reads are retried: a repeated POST could create a second pull request comment.
if (strtolower($method) === 'get') {
+48 -17
View File
@@ -12,7 +12,35 @@ function is_transactional_emails_enabled(): bool
{
$settings = instanceSettings();
return $settings->smtp_enabled || $settings->resend_enabled;
return filled(enabled_email_provider($settings));
}
/**
* Labels of the email providers, in the order that the provider selectors show them.
*
* @return array<'none'|'smtp'|'resend', string>
*/
function email_provider_labels(): array
{
return [
'none' => 'None',
'smtp' => 'SMTP',
'resend' => 'Resend',
];
}
/**
* Get the email provider that is turned on. Only one provider can be on at a time.
*
* @return 'resend'|'smtp'|null
*/
function enabled_email_provider(?object $settings): ?string
{
return match (true) {
(bool) data_get($settings, 'resend_enabled') => 'resend',
(bool) data_get($settings, 'smtp_enabled') => 'smtp',
default => null,
};
}
/**
@@ -23,20 +51,30 @@ function is_transactional_emails_enabled(): bool
*/
function mail_from_identity(object $settings, bool $asNotification = false): array
{
if ($asNotification && filled($settings->notification_from_address ?? null)) {
$address = (string) $settings->notification_from_address;
$name = trim((string) ($settings->notification_from_name ?? ''));
// Each provider has its own sender, because each one sends from its own verified domain.
// Resend uses the SMTP sender when it has none, as before the Resend sender fields existed.
$usesResend = (bool) data_get($settings, 'resend_enabled');
$providerSender = static function (string $field) use ($settings, $usesResend): string {
$value = trim((string) ($usesResend ? data_get($settings, "resend_{$field}") : ''));
return $value !== '' ? $value : trim((string) data_get($settings, "smtp_{$field}"));
};
if ($asNotification && filled(data_get($settings, 'notification_from_address'))) {
$address = (string) data_get($settings, 'notification_from_address');
$name = trim((string) data_get($settings, 'notification_from_name', ''));
} else {
if (blank($settings->smtp_from_address ?? null)) {
$address = $providerSender('from_address');
if ($address === '') {
throw new InvalidArgumentException('Transactional email sender address is not configured.');
}
$address = (string) $settings->smtp_from_address;
$name = '';
}
if ($name === '') {
$name = trim((string) ($settings->smtp_from_name ?? ''));
$name = $providerSender('from_name');
}
return [
@@ -131,20 +169,13 @@ function set_transanctional_email_settings($settings = null)
if (! $settings) {
$settings = instanceSettings();
}
if (! data_get($settings, 'smtp_enabled') && ! data_get($settings, 'resend_enabled')) {
$provider = enabled_email_provider($settings);
if (blank($provider)) {
return null;
}
$configRepository = app('App\Services\ConfigurationRepository'::class);
$configRepository->updateMailConfig($settings);
if (data_get($settings, 'resend_enabled')) {
return 'resend';
}
if (data_get($settings, 'smtp_enabled')) {
return 'smtp';
}
return null;
return $provider;
}
+4
View File
@@ -2302,6 +2302,10 @@ function applicationParser(Application $resource, int $pull_request_id = 0, ?int
'restart' => $restart->value(),
'labels' => $serviceLabels,
]);
// A security update of this service, while the compose file still names the image it replaced.
if (($imageOverride = $resource->composeImageOverride((string) $serviceName, $image->isEmpty() ? null : $image->value())) !== null) {
$payload['image'] = $imageOverride;
}
if (! $use_network_mode) {
$payload['networks'] = $networks_temp;
}
+9
View File
@@ -91,6 +91,7 @@ function instant_scp(string $source, string $dest, Server $server, $throwError =
function () use ($source, $dest, $server) {
$scp_command = SshMultiplexingHelper::generateScpCommand($server, $source, $dest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
SshMultiplexingHelper::noticeHostKeyMismatch($server, $process);
$output = trim($process->output());
$exitCode = $process->exitCode();
@@ -120,6 +121,7 @@ function instant_scp_from_server(string $remoteSource, string $localDest, Server
function () use ($remoteSource, $localDest, $server) {
$scp_command = SshMultiplexingHelper::generateScpDownloadCommand($server, $remoteSource, $localDest);
$process = Process::timeout(config('constants.ssh.command_timeout'))->run($scp_command);
SshMultiplexingHelper::noticeHostKeyMismatch($server, $process);
$output = trim($process->output());
$exitCode = $process->exitCode();
@@ -168,6 +170,7 @@ function instant_remote_write_file(Server $server, string $path, string $content
function () use ($server, $remoteCommand, $content, $timeout) {
$sshCommand = SshMultiplexingHelper::generateSshStdinCommand($server, $remoteCommand, commandTimeout: $timeout);
$process = runSshProcess($sshCommand, $timeout, $content);
SshMultiplexingHelper::noticeHostKeyMismatch($server, $process);
if ($process->exitCode() !== 0) {
excludeCertainErrors($process->errorOutput(), $process->exitCode());
@@ -194,6 +197,7 @@ function instant_remote_process_with_timeout(Collection|array $command, Server $
function () use ($server, $command_string) {
$sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server);
$process = runSshProcess($sshCommand, 30, SshMultiplexingHelper::sshBatchInput($command_string));
SshMultiplexingHelper::noticeHostKeyMismatch($server, $process);
$output = trim($process->output());
$exitCode = $process->exitCode();
@@ -230,6 +234,7 @@ function instant_remote_process(Collection|array $command, Server $server, bool
function () use ($server, $command_string, $effectiveTimeout, $disableMultiplexing) {
$sshCommand = SshMultiplexingHelper::generateSshBatchCommand($server, $disableMultiplexing, (int) $effectiveTimeout);
$process = runSshProcess($sshCommand, (int) $effectiveTimeout, SshMultiplexingHelper::sshBatchInput($command_string));
SshMultiplexingHelper::noticeHostKeyMismatch($server, $process);
$output = trim($process->output());
$exitCode = $process->exitCode();
@@ -262,6 +267,10 @@ function excludeCertainErrors(string $errorOutput, ?int $exitCode = null): never
if (empty($errorMessage)) {
$errorMessage = "SSH command failed with exit code: $exitCode";
}
if ($exitCode === 255 && str_contains($errorMessage, 'Host key verification failed')) {
$errorMessage = SshMultiplexingHelper::HOST_KEY_CHANGED_MESSAGE;
}
$errorMessage = trim(SshMultiplexingHelper::stripHostKeyChangedWarning($errorMessage)) ?: "SSH command failed with exit code: $exitCode";
throw new RuntimeException($errorMessage, $exitCode);
}
+7 -8
View File
@@ -40,6 +40,8 @@ use App\Models\SwarmDocker;
use App\Models\Team;
use App\Models\User;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\Dns\DnsRecord;
use App\Services\Dns\DnsResolver;
use App\Services\Security\SensitiveDataRedactor;
use App\Support\DnsRecordHints;
use Carbon\CarbonImmutable;
@@ -73,9 +75,6 @@ use Nubs\RandomNameGenerator\Alliteration;
use phpseclib3\Crypt\EC;
use phpseclib3\Crypt\RSA;
use Poliander\Cron\CronExpression;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSResult;
use PurplePixie\PhpDns\DNSTypes;
use Spatie\Url\Url;
use Symfony\Component\Yaml\Yaml;
@@ -746,7 +745,7 @@ function generate_random_name(?string $cuid = null): string
/**
* RSA keys get 4096 bits: GitHub rejects new RSA SSH keys smaller than 3072 bits, and phpseclib makes 2048-bit keys by default.
*/
function generateSSHKey(string $type = 'rsa')
function generateSSHKey(string $type = 'ed25519')
{
if ($type === 'rsa') {
$key = RSA::createKey(4096);
@@ -2528,7 +2527,7 @@ function validateDNSEntry(string $fqdn, Server $server)
$dns_servers = str($dns_servers)->explode(',');
$ip = serverDnsTargetIp($server);
$found_matching_ip = false;
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DnsResolver::TYPE_AAAA : DnsResolver::TYPE_A;
foreach ($dns_servers as $dns_server) {
try {
$results = queryDnsServer($dns_server, $host, $type);
@@ -2553,9 +2552,9 @@ function validateDNSEntry(string $fqdn, Server $server)
return $found_matching_ip;
}
function createDnsQuery(string $dnsServer, int $timeout = 5, bool $udp = true): DNSQuery
function createDnsQuery(string $dnsServer, int $timeout = 5, bool $udp = true): DnsResolver
{
return app()->make(DNSQuery::class, [
return app()->make(DnsResolver::class, [
'server' => formatHostForUrl($dnsServer),
'port' => 53,
'timeout' => $timeout,
@@ -2568,7 +2567,7 @@ function createDnsQuery(string $dnsServer, int $timeout = 5, bool $udp = true):
* retry over TCP: resolvers such as 1.1.1.1 can truncate even small answers, and the
* records would otherwise be silently dropped.
*
* @return iterable<int, DNSResult>|false
* @return iterable<int, DnsRecord>|false
*/
function queryDnsServer(string $dnsServer, string $host, string $type, int $timeout = 5): iterable|false
{