Merge remote-tracking branch 'origin/next' into feat/noindex-domains

This commit is contained in:
Andras Bacsai
2026-08-08 11:26:20 +02:00
1274 changed files with 156179 additions and 25249 deletions
+402 -33
View File
@@ -535,6 +535,17 @@ function find_destination_for_current_team(?string $uuid): StandaloneDocker|Swar
?? SwarmDocker::ownedByCurrentTeam()->where('uuid', $uuid)->first();
}
function find_resource_destination_for_current_team(?string $uuid): StandaloneDocker|SwarmDocker|null
{
$destination = find_destination_for_current_team($uuid);
if (! $destination?->server?->canHostResources()) {
return null;
}
return $destination;
}
function showBoarding(): bool
{
if (isDev()) {
@@ -738,18 +749,71 @@ function base_ip(): string
return 'localhost';
}
function getFqdnWithoutPort(string $fqdn)
/**
* Parse a domain URL into scheme/host/path pieces used by COOLIFY_* and SERVICE_* env builders.
* Omits a bare "/" path so port re-append stays valid ("http://host:80" not "http://host/:80").
*
* @return array{scheme: string, host: string, path: string}|null
*/
function parseDomainUrlParts(string $fqdn): ?array
{
try {
$url = Url::fromString($fqdn);
$host = $url->getHost();
$scheme = $url->getScheme();
$path = $url->getPath();
if ($host === '') {
return null;
}
return "$scheme://$host$path";
$path = $url->getPath();
if ($path === '' || $path === '/') {
$path = '';
}
return [
'scheme' => $url->getScheme(),
'host' => $host,
'path' => $path,
];
} catch (Throwable) {
return null;
}
}
/**
* Absolute URL without port (and without a bare trailing slash).
* Used for COOLIFY_URL / SERVICE_URL base values.
*/
function getFqdnWithoutPort(string $fqdn): string
{
$parts = parseDomainUrlParts($fqdn);
if ($parts === null || $parts['scheme'] === '') {
// Spatie accepts bare hostnames (empty scheme). Do not invent "://host".
return $fqdn;
}
return $parts['scheme'].'://'.$parts['host'].$parts['path'];
}
/**
* Host (+ optional path) without scheme or port.
* Used for COOLIFY_FQDN / SERVICE_FQDN base values.
*/
function getHostWithoutPort(string $fqdn): string
{
$parts = parseDomainUrlParts($fqdn);
if ($parts === null) {
return $fqdn;
}
return $parts['host'].$parts['path'];
}
/**
* First entry from a comma-separated FQDN list (service apps may store multiple).
*/
function firstDomainFromList(?string $fqdns): string
{
return trim((string) str($fqdns ?? '')->explode(',')->first());
}
/**
* If fqdn is set, return it, otherwise return public ip.
@@ -1252,23 +1316,92 @@ function sslip(Server $server)
return "http://{$server->ip}.sslip.io";
}
function service_templates_cache_key(): string
{
return (string) config('constants.services.cache_key', 'coolify:service-templates-bundle');
}
function service_templates_path(): string
{
return base_path('templates/'.config('constants.services.file_name'));
}
/**
* Persist the CDN service-templates bundle to local disk and shared cache.
*
* The shared cache entry is what multi-node Cloud relies on: Horizon (or any
* single node) pulls once; every HTTP node reads the same Redis payload.
*/
function store_service_templates_bundle(string $json, ?string $fetchedAt = null): bool
{
$fetchedAt ??= now()->toIso8601String();
$path = service_templates_path();
$written = File::put($path, $json) !== false;
Cache::forever(service_templates_cache_key(), [
'fetched_at' => $fetchedAt,
'json' => $json,
]);
return $written;
}
function get_service_templates_fetched_at(): ?CarbonImmutable
{
$bundle = Cache::get(service_templates_cache_key());
if (is_array($bundle) && filled(data_get($bundle, 'fetched_at'))) {
try {
return CarbonImmutable::parse((string) data_get($bundle, 'fetched_at'));
} catch (Throwable) {
// fall through to local file mtime
}
}
$path = service_templates_path();
if (File::exists($path)) {
$mtime = filemtime($path);
if ($mtime !== false) {
return CarbonImmutable::createFromTimestamp($mtime);
}
}
return null;
}
function get_service_templates(bool $force = false): Collection
{
if ($force) {
try {
$response = Http::retry(3, 1000)->get(config('constants.services.official'));
$response = Http::retry(3, 1000, throw: false)
->timeout(60)
->connectTimeout(10)
->get(config('constants.services.official'));
if ($response->failed()) {
return collect([]);
}
$services = $response->json();
store_service_templates_bundle($response->body());
return collect($services);
return collect(json_decode($response->body()))->sortKeys();
} catch (Throwable) {
return get_service_templates();
}
}
$path = base_path('templates/'.config('constants.services.file_name'));
$bundle = Cache::get(service_templates_cache_key());
if (is_array($bundle) && is_string(data_get($bundle, 'json')) && data_get($bundle, 'json') !== '') {
$fetchedAt = (string) data_get($bundle, 'fetched_at', '0');
return Cache::remember("service-templates:shared:{$fetchedAt}", now()->addDay(), function () use ($bundle) {
return collect(json_decode((string) data_get($bundle, 'json')))->sortKeys();
});
}
$path = service_templates_path();
if (! File::exists($path)) {
return collect([]);
}
$mtime = filemtime($path) ?: 0;
return Cache::remember("service-templates:{$mtime}", now()->addDay(), function () use ($path) {
@@ -1735,11 +1868,189 @@ function getRealtime()
}
}
/**
* Resolve a server IP or hostname to an IP address for DNS comparison/display.
*
* When a server uses a hostname (e.g. coolify-testing-host) instead of a literal
* IP, A-record validation and UI copy need the actual IP the user should point DNS to.
*
* Successful hostname resolutions are cached briefly so Livewire mounts/domain state
* loads do not re-query DNS on every request.
*
* @return array{ip: ?string, configured: ?string, resolved_from_hostname: bool}
*/
function resolveServerIpAddress(?string $ipOrHost): array
{
$configured = filled($ipOrHost) ? trim((string) $ipOrHost) : null;
if ($configured === null || $configured === '') {
return [
'ip' => null,
'configured' => null,
'resolved_from_hostname' => false,
];
}
// Strip IPv6 brackets if present: [2001:db8::1]
$candidate = str($configured)->trim('[]')->toString();
if (filter_var($candidate, FILTER_VALIDATE_IP) !== false) {
return [
'ip' => $candidate,
'configured' => $configured,
'resolved_from_hostname' => false,
];
}
$cacheKey = 'server-ip-resolve:'.mb_strtolower($candidate);
$cachedIp = Cache::get($cacheKey);
if (is_string($cachedIp) && filter_var($cachedIp, FILTER_VALIDATE_IP) !== false) {
return [
'ip' => $cachedIp,
'configured' => $configured,
'resolved_from_hostname' => true,
];
}
$resolvedIp = null;
try {
$aRecords = @dns_get_record($candidate, DNS_A);
if (is_array($aRecords)) {
foreach ($aRecords as $record) {
$ip = $record['ip'] ?? null;
if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$resolvedIp = $ip;
break;
}
}
}
} catch (Throwable) {
}
if ($resolvedIp === null) {
try {
$aaaaRecords = @dns_get_record($candidate, DNS_AAAA);
if (is_array($aaaaRecords)) {
foreach ($aaaaRecords as $record) {
$ip = $record['ipv6'] ?? null;
if (is_string($ip) && filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$resolvedIp = $ip;
break;
}
}
}
} catch (Throwable) {
}
}
if ($resolvedIp === null) {
$resolved = @gethostbyname($candidate);
if (is_string($resolved) && $resolved !== $candidate && filter_var($resolved, FILTER_VALIDATE_IP) !== false) {
$resolvedIp = $resolved;
}
}
if ($resolvedIp !== null) {
Cache::put($cacheKey, $resolvedIp, now()->addSeconds(60));
}
return [
'ip' => $resolvedIp,
'configured' => $configured,
'resolved_from_hostname' => $resolvedIp !== null,
];
}
/**
* Preferred server address for DNS validation: public instance IPs for localhost,
* otherwise the server IP/hostname resolved to a real IP when possible.
*/
function serverDnsTargetIp(Server $server): ?string
{
$settings = instanceSettings();
if ($server->id === 0) {
$configured = data_get($settings, 'public_ipv4')
?: data_get($settings, 'public_ipv6')
?: $server->ip;
} else {
$configured = $server->ip;
}
$resolved = resolveServerIpAddress(is_string($configured) ? $configured : null);
// Prefer resolved IP; fall back to configured value so existing hostname-based
// comparisons still have something to show if DNS resolution fails.
return $resolved['ip'] ?? $resolved['configured'];
}
/**
* DNS record type users should create for a server address: A (IPv4) or AAAA (IPv6).
*/
function dnsRecordTypeForIp(?string $ipOrHost): string
{
if (! is_string($ipOrHost) || trim($ipOrHost) === '') {
return 'A';
}
// Accept labels like "2001:db8::1 (hostname)" or bracketed IPv6.
$candidate = trim($ipOrHost);
if (preg_match('/^(\S+)/', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$candidate = str($candidate)->trim('[]')->toString();
if (filter_var($candidate, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
return 'AAAA';
}
return 'A';
}
/**
* Bare address for DNS guidance (first token of a label, brackets stripped).
*/
function dnsGuidanceTargetAddress(?string $ipOrLabel): ?string
{
if (! is_string($ipOrLabel) || trim($ipOrLabel) === '') {
return null;
}
$candidate = trim($ipOrLabel);
if (preg_match('/^(\S+)/', $candidate, $matches) === 1) {
$candidate = $matches[1];
}
$candidate = str($candidate)->trim('[]')->toString();
return $candidate !== '' ? $candidate : null;
}
/**
* User-facing guidance when a hostname does not resolve to the server.
* Format: "Required DNS record type A pointing to 1.2.3.4"
* or "Required DNS record type AAAA pointing to 2001:db8::1".
*
* @param ?string $targetLabel Display target (IP, or "IP (hostname)") used as fallback.
* @param ?string $ipForRecordType Preferred IP for type + display (defaults to $targetLabel).
*/
function dnsMismatchGuidanceMessage(?string $targetLabel, ?string $ipForRecordType = null): string
{
$address = dnsGuidanceTargetAddress($ipForRecordType)
?? dnsGuidanceTargetAddress($targetLabel);
if ($address === null) {
return 'DNS validation failed. Check your DNS records.';
}
$recordType = dnsRecordTypeForIp($address);
return "Required DNS record type {$recordType} pointing to {$address}";
}
function validateDNSEntry(string $fqdn, Server $server)
{
// https://www.cloudflare.com/ips-v4/#
$cloudflare_ips = collect(['173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22', '141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20', '197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13', '172.64.0.0/13', '131.0.72.0/22']);
$url = Url::fromString($fqdn);
$host = $url->getHost();
if (str($host)->contains('sslip.io')) {
@@ -1752,13 +2063,9 @@ function validateDNSEntry(string $fqdn, Server $server)
}
$dns_servers = data_get($settings, 'custom_dns_servers');
$dns_servers = str($dns_servers)->explode(',');
if ($server->id === 0) {
$ip = data_get($settings, 'public_ipv4', data_get($settings, 'public_ipv6', $server->ip));
} else {
$ip = $server->ip;
}
$ip = serverDnsTargetIp($server);
$found_matching_ip = false;
$type = DNSTypes::NAME_A;
$type = dnsRecordTypeForIp($ip) === 'AAAA' ? DNSTypes::NAME_AAAA : DNSTypes::NAME_A;
foreach ($dns_servers as $dns_server) {
try {
$query = new DNSQuery($dns_server);
@@ -1767,11 +2074,11 @@ function validateDNSEntry(string $fqdn, Server $server)
} else {
foreach ($results as $result) {
if ($result->getType() == $type) {
if (ipMatch($result->getData(), $cloudflare_ips->toArray(), $match)) {
if (isCloudflareIp($result->getData())) {
$found_matching_ip = true;
break;
}
if ($result->getData() === $ip) {
if ($ip && $result->getData() === $ip) {
$found_matching_ip = true;
break;
}
@@ -1785,11 +2092,48 @@ function validateDNSEntry(string $fqdn, Server $server)
return $found_matching_ip;
}
function isCloudflareIp(string $ip): bool
{
// https://www.cloudflare.com/ips/
$cloudflareIps = [
'173.245.48.0/20', '103.21.244.0/22', '103.22.200.0/22', '103.31.4.0/22',
'141.101.64.0/18', '108.162.192.0/18', '190.93.240.0/20', '188.114.96.0/20',
'197.234.240.0/22', '198.41.128.0/17', '162.158.0.0/15', '104.16.0.0/13',
'172.64.0.0/13', '131.0.72.0/22', '2400:cb00::/32', '2606:4700::/32',
'2803:f800::/32', '2405:b500::/32', '2405:8100::/32', '2a06:98c0::/29',
'2c0f:f248::/32',
];
return ipMatch($ip, $cloudflareIps);
}
function ipMatch($ip, $cidrs, &$match = null)
{
foreach ((array) $cidrs as $cidr) {
[$subnet, $mask] = explode('/', $cidr);
if (((ip2long($ip) & ($mask = ~((1 << (32 - $mask)) - 1))) == (ip2long($subnet) & $mask))) {
[$subnet, $prefixLength] = explode('/', $cidr);
$packedIp = inet_pton($ip);
$packedSubnet = inet_pton($subnet);
if ($packedIp === false || $packedSubnet === false || strlen($packedIp) !== strlen($packedSubnet)) {
continue;
}
$addressBits = strlen($packedIp) * 8;
$prefixLength = (int) $prefixLength;
if ($prefixLength < 0 || $prefixLength > $addressBits) {
continue;
}
$fullBytes = intdiv($prefixLength, 8);
$remainingBits = $prefixLength % 8;
$matches = substr($packedIp, 0, $fullBytes) === substr($packedSubnet, 0, $fullBytes);
if ($matches && $remainingBits > 0) {
$mask = (0xFF << (8 - $remainingBits)) & 0xFF;
$matches = (ord($packedIp[$fullBytes]) & $mask) === (ord($packedSubnet[$fullBytes]) & $mask);
}
if ($matches) {
$match = $cidr;
return true;
@@ -2527,7 +2871,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($env) {
$env_url = Url::fromString($savedService->fqdn);
$env_port = $env_url->getPort();
if ($env_port !== $predefinedPort) {
if ((int) $env_port !== (int) $predefinedPort) {
$env_url = $env_url->withPort($predefinedPort);
$savedService->fqdn = $env_url->__toString();
$savedService->save();
@@ -2612,7 +2956,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($env) {
$env_url = Url::fromString($env->value);
$env_port = $env_url->getPort();
if ($env_port !== $predefinedPort) {
if ((int) $env_port !== (int) $predefinedPort) {
$env_url = $env_url->withPort($predefinedPort);
$savedService->fqdn = $env_url->__toString();
$savedService->save();
@@ -2697,6 +3041,9 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if (! $isDatabase && $fqdns->count() > 0) {
if ($fqdns) {
$shouldGenerateLabelsExactly = $resource->server->settings->generate_exact_labels;
$redirectDirection = in_array(data_get($savedService, 'redirect'), ['www', 'non-www', 'both'], true)
? data_get($savedService, 'redirect')
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($resource->server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -2709,7 +3056,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
case ProxyTypes::CADDY->value:
@@ -2723,7 +3071,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
break;
}
@@ -2737,7 +3086,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
$serviceLabels = $serviceLabels->merge(fqdnLabelsForCaddy(
network: $resource->destination->network,
@@ -2749,7 +3099,8 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_stripprefix_enabled: $savedService->isStripprefixEnabled(),
service_name: $serviceName,
image: data_get($service, 'image'),
noindex_domains: $noindexDomains
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection
));
}
}
@@ -3440,16 +3791,17 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
if ($resource->serviceType()) {
$fqdns = generateServiceSpecificFqdns($resource);
} else {
$domains = collect(json_decode($resource->docker_compose_domains)) ?? [];
$domains = json_decode($resource->docker_compose_domains ?: '[]', true) ?: [];
if ($domains) {
$fqdns = data_get($domains, "$serviceName.domain");
// Dual-read: original compose name or legacy underscore key.
$fqdns = getComposeServiceDomainString($domains, (string) $serviceName);
if ($fqdns) {
$fqdns = str($fqdns)->explode(',');
if ($pull_request_id !== 0) {
$preview = $resource->previews()->find($preview_id);
$docker_compose_domains = collect(json_decode(data_get($preview, 'docker_compose_domains')));
if ($docker_compose_domains->count() > 0) {
$found_fqdn = data_get($docker_compose_domains, "$serviceName.domain");
$docker_compose_domains = json_decode(data_get($preview, 'docker_compose_domains') ?: '[]', true) ?: [];
if (count($docker_compose_domains) > 0) {
$found_fqdn = getComposeServiceDomainString($docker_compose_domains, (string) $serviceName);
if ($found_fqdn) {
$fqdns = collect($found_fqdn);
} else {
@@ -3476,6 +3828,10 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
}
$noindexDomains = $pull_request_id !== 0 ? $fqdns : $resource->noindexDomains();
$shouldGenerateLabelsExactly = $server->settings->generate_exact_labels;
$composeRedirect = data_get($domains, "$serviceName.redirect");
$redirectDirection = in_array($composeRedirect, ['www', 'non-www', 'both'], true)
? $composeRedirect
: 'both';
if ($shouldGenerateLabelsExactly) {
switch ($server->proxyType()) {
case ProxyTypes::TRAEFIK->value:
@@ -3490,6 +3846,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
break;
@@ -3505,6 +3862,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
break;
@@ -3521,6 +3879,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
$serviceLabels = $serviceLabels->merge(
@@ -3534,6 +3893,7 @@ function parseDockerComposeFile(Service|Application $resource, bool $isNew = fal
is_gzip_enabled: $resource->isGzipEnabled(),
is_stripprefix_enabled: $resource->isStripprefixEnabled(),
noindex_domains: $noindexDomains,
redirect_direction: $redirectDirection,
)
);
}
@@ -3804,8 +4164,17 @@ function loggy($message = null, array $context = [])
return app('log')->debug($message, $context);
}
function sslipDomainWarning(string $domains)
/**
* Warn when any domain uses HTTPS with an sslip hostname.
*
* Empty/null domain lists are valid (domains removed) and produce no warning.
*/
function sslipDomainWarning(?string $domains): bool
{
if (blank($domains)) {
return false;
}
$domains = str($domains)->trim()->explode(',');
$showSslipHttpsWarning = false;
$domains->each(function ($domain) use (&$showSslipHttpsWarning) {