mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
v4.7 (#12478)
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:
co-authored by
Claude Opus 5.5
peaklabs-dev
Aditya Tripathi
parent
b7dc2e435b
commit
033f0c84da
@@ -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');
|
||||
|
||||
@@ -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' : '');
|
||||
|
||||
@@ -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'),
|
||||
];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user