Files
coolify/app/Support/DnsRecordHints.php
T
Andras Bacsai 9e7cb60b0d feat: add import restore options, OAuth confirmation, and fixes
- database import: add keep_owners (restore PostgreSQL owners and
  privileges) and sqlite_database (pick the target SQLite file) to the
  API, OpenAPI spec, and import form
- oauth: require password confirmation and support team selection on
  OAuth login
- remote process: send team broadcast events to the team of the server
  that ran the process instead of the causer's user id
- servers API: handle the legacy build server setting
- compose: support parent bind paths and volume driver options
- dns: improve record hints and provider management
- notifications: fix throttling and missing backup notifications
- ui: rework the confirmation modal
- add feature and unit tests for the new behavior
2026-09-30 13:35:22 +02:00

197 lines
5.8 KiB
PHP

<?php
namespace App\Support;
/**
* Provider-agnostic DNS records to point hostnames at a Coolify server.
*/
class DnsRecordHints
{
public const NO_PUBLIC_ADDRESS_MESSAGE = 'The server has no public IP address; add the DNS record manually.';
/**
* Whether an address may be published as a public A/AAAA record: private, reserved, loopback, link-local,
* unique local, CGNAT (100.64.0.0/10, used by Tailscale), and multicast addresses are rejected.
*/
public static function isPublicAddress(string $address): bool
{
$binary = @inet_pton($address);
if ($binary === false) {
return false;
}
if (strlen($binary) === 16 && str_starts_with($binary, str_repeat("\0", 10)."\xff\xff")) {
$address = (string) inet_ntop(substr($binary, 12));
$binary = substr($binary, 12);
}
if (filter_var($address, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) {
return false;
}
if (strlen($binary) === 4) {
$firstOctet = ord($binary[0]);
$isCgnat = $firstOctet === 100 && (ord($binary[1]) & 0xC0) === 64;
return ! $isCgnat && ($firstOctet & 0xF0) !== 224;
}
return ord($binary[0]) !== 0xFF;
}
/**
* Build A/AAAA entries for every hostname (deduped).
*
* @param array<int, string|null> $hostnames
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forHostnames(array $hostnames, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$seen = [];
foreach ($hostnames as $hostname) {
if (! is_string($hostname) || trim($hostname) === '') {
continue;
}
foreach (self::forTarget($hostname, $ipv4, $ipv6) as $record) {
$key = strtolower($record['type'].'|'.$record['name'].'|'.$record['value']);
if (isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$records[] = $record;
}
}
usort($records, function (array $a, array $b): int {
return [$a['name'], $a['type'], $a['value']] <=> [$b['name'], $b['type'], $b['value']];
});
return $records;
}
/**
* @return array<int, array{type: string, name: string, value: string}>
*/
public static function forTarget(?string $hostname, ?string $ipv4, ?string $ipv6 = null): array
{
$records = [];
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return [];
}
if (filled($ipv4) && filter_var($ipv4, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) {
$records[] = [
'type' => 'A',
'name' => $fqdn,
'value' => $ipv4,
];
}
if (filled($ipv6) && filter_var($ipv6, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) {
$records[] = [
'type' => 'AAAA',
'name' => $fqdn,
'value' => $ipv6,
];
}
return $records;
}
public static function normalizeHostname(?string $hostname): ?string
{
if (blank($hostname)) {
return null;
}
$hostname = strtolower(trim($hostname));
$hostname = preg_replace('#^https?://#i', '', $hostname) ?? $hostname;
$hostname = explode('/', $hostname)[0] ?? $hostname;
$hostname = explode(':', $hostname)[0] ?? $hostname;
$hostname = rtrim($hostname, '.');
if ($hostname === '' || $hostname === '@' || ! str_contains($hostname, '.')) {
return null;
}
// Strip path-like noise; host only.
if (filter_var($hostname, FILTER_VALIDATE_IP)) {
return null;
}
return $hostname;
}
/**
* Relative name for a zone (e.g. app for app.example.com, @ for example.com).
*/
public static function relativeName(?string $hostname): string
{
$fqdn = self::normalizeHostname($hostname);
if ($fqdn === null) {
return '@';
}
$labels = array_values(array_filter(explode('.', $fqdn), fn (string $p) => $p !== ''));
if (count($labels) <= 2) {
return '@';
}
return implode('.', array_slice($labels, 0, -2));
}
/**
* BIND-compatible zone snippet for clipboard (absolute names with trailing dots).
*
* Example:
* asd.hu. IN A 172.16.0.2
* www.asd.hu. IN A 172.16.0.2
*
* @param array<int, array{type: string, name: string, value: string}> $records
*/
public static function toCopyText(array $records): string
{
if ($records === []) {
return '';
}
$lines = [];
$nameWidth = 0;
foreach ($records as $record) {
$name = self::bindAbsoluteName((string) $record['name']);
$nameWidth = max($nameWidth, strlen($name));
}
foreach ($records as $record) {
$name = self::bindAbsoluteName((string) $record['name']);
$type = strtoupper((string) $record['type']);
$value = (string) $record['value'];
// AAAA values may be IPv6; leave as-is (no quotes needed for A/AAAA).
$format = '%-'.$nameWidth.'s IN %-5s %s';
$lines[] = sprintf($format, $name, $type, $value);
}
return implode("\n", $lines)."\n";
}
/**
* Absolute BIND name (trailing dot). Leaves @ as-is.
*/
public static function bindAbsoluteName(string $name): string
{
$name = trim($name);
if ($name === '' || $name === '@') {
return '@';
}
$name = rtrim($name, '.');
return $name.'.';
}
}