diff --git a/.env b/.env index ac7e2a6836..1d46f6cad9 100644 --- a/.env +++ b/.env @@ -23,7 +23,9 @@ _APP_OPENSSL_KEY_V1=your-secret-key _APP_DOMAIN=traefik _APP_DOMAIN_FUNCTIONS=functions.localhost _APP_DOMAIN_SITES=sites.localhost -_APP_DOMAIN_TARGET=test.appwrite.io +_APP_DOMAIN_TARGET_CNAME=test.appwrite.io +_APP_DOMAIN_TARGET_A=127.0.0.1 +_APP_DOMAIN_TARGET_AAAA=::1 _APP_RULES_FORMAT=md5 _APP_REDIS_HOST=redis _APP_REDIS_PORT=6379 diff --git a/app/config/variables.php b/app/config/variables.php index 27463d2fee..d6516f7a54 100644 --- a/app/config/variables.php +++ b/app/config/variables.php @@ -90,13 +90,40 @@ return [ ], [ 'name' => '_APP_DOMAIN_TARGET', - 'description' => 'A DNS A record hostname to serve as a CNAME target for your Appwrite custom domains. You can use the same value as used for the Appwrite \'_APP_DOMAIN\' variable. The default value is \'localhost\'.', + 'description' => 'Deprecated since 1.7.0. A DNS A record hostname to serve as a CNAME target for your Appwrite custom domains. You can use the same value as used for the Appwrite \'_APP_DOMAIN\' variable. The default value is \'localhost\'.', 'introduction' => '', 'default' => 'localhost', 'required' => true, 'question' => 'Enter a DNS A record hostname to serve as a CNAME for your custom domains.' . PHP_EOL . 'You can use the same value as used for the Appwrite hostname.', 'filter' => 'domainTarget' ], + [ + 'name' => '_APP_DOMAIN_TARGET_CNAME', + 'description' => 'A domain that can be used as DNS CNAME record to point to instance of Appwrite server.', + 'introduction' => '', + 'default' => 'localhost', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DOMAIN_TARGET_AAAA', + 'description' => 'An IPv6 that can be used as DNS AAAA record to point to instance of Appwrite server.', + 'introduction' => '', + 'default' => '::1', + 'required' => false, + 'question' => '', + 'filter' => '' + ], + [ + 'name' => '_APP_DOMAIN_TARGET_A', + 'description' => 'An IPV4 that can be used as DNS A record to point to instance of Appwrite server.', + 'introduction' => '', + 'default' => '127.0.0.1', + 'required' => false, + 'question' => '', + 'filter' => '' + ], [ 'name' => '_APP_CONSOLE_WHITELIST_ROOT', 'description' => 'This option allows you to disable the creation of new users on the Appwrite console. When enabled only 1 user will be able to use the registration form. New users can be added by inviting them to your project. By default this option is enabled.', diff --git a/app/controllers/api/console.php b/app/controllers/api/console.php index 4cf00faf65..4dd5432d3a 100644 --- a/app/controllers/api/console.php +++ b/app/controllers/api/console.php @@ -8,7 +8,9 @@ use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; use Utopia\App; use Utopia\Database\Document; +use Utopia\Domains\Domain; use Utopia\System\System; +use Utopia\Validator\IP; use Utopia\Validator\Text; App::init() @@ -40,10 +42,21 @@ App::get('/v1/console/variables') )) ->inject('response') ->action(function (Response $response) { - $isDomainEnabled = !empty(System::getEnv('_APP_DOMAIN', '')) - && !empty(System::getEnv('_APP_DOMAIN_TARGET', '')) - && System::getEnv('_APP_DOMAIN', '') !== 'localhost' - && System::getEnv('_APP_DOMAIN_TARGET', '') !== 'localhost'; + $validator = new Domain(System::getEnv('_APP_DOMAIN')); + $isDomainValid = !empty(System::getEnv('_APP_DOMAIN', '')) && $validator->isKnown() && !$validator->isTest(); + + $validator = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME')); + $isCNAMEValid = !empty(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')) && $validator->isKnown() && !$validator->isTest(); + + $validator = new IP(IP::V4); + $isAValid = !empty(System::getEnv('_APP_DOMAIN_TARGET_A', '')) && ($validator->isValid(System::getEnv('_APP_DOMAIN_TARGET_A'))); + + $validator = new IP(IP::V6); + $isAAAAValid = !empty(System::getEnv('_APP_DOMAIN_TARGET_AAAA', '')) && $validator->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA')); + + $isDomainEnabled = $isDomainValid && ( + $isAAAAValid || $isAValid || $isCNAMEValid + ); $isVcsEnabled = !empty(System::getEnv('_APP_VCS_GITHUB_APP_NAME', '')) && !empty(System::getEnv('_APP_VCS_GITHUB_PRIVATE_KEY', '')) @@ -54,7 +67,9 @@ App::get('/v1/console/variables') $isAssistantEnabled = !empty(System::getEnv('_APP_ASSISTANT_OPENAI_API_KEY', '')); $variables = new Document([ - '_APP_DOMAIN_TARGET' => System::getEnv('_APP_DOMAIN_TARGET'), + '_APP_DOMAIN_TARGET_CNAME' => System::getEnv('_APP_DOMAIN_TARGET_CNAME'), + '_APP_DOMAIN_TARGET_AAAA' => System::getEnv('_APP_DOMAIN_TARGET_AAAA'), + '_APP_DOMAIN_TARGET_A' => System::getEnv('_APP_DOMAIN_TARGET_A'), '_APP_STORAGE_LIMIT' => +System::getEnv('_APP_STORAGE_LIMIT'), '_APP_COMPUTE_SIZE_LIMIT' => +System::getEnv('_APP_COMPUTE_SIZE_LIMIT'), '_APP_USAGE_STATS' => System::getEnv('_APP_USAGE_STATS'), diff --git a/app/controllers/api/proxy.php b/app/controllers/api/proxy.php index e7aa3acb6c..d8e637725a 100644 --- a/app/controllers/api/proxy.php +++ b/app/controllers/api/proxy.php @@ -4,7 +4,7 @@ use Appwrite\Event\Certificate; use Appwrite\Event\Delete; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\CNAME; +use Appwrite\Network\Validator\DNS; use Appwrite\SDK\AuthType; use Appwrite\SDK\ContentType; use Appwrite\SDK\Method; @@ -21,6 +21,8 @@ use Utopia\Database\Validator\UID; use Utopia\Domains\Domain; use Utopia\Logger\Log; use Utopia\System\System; +use Utopia\Validator\AnyOf; +use Utopia\Validator\IP; use Utopia\Validator\Text; App::get('/v1/proxy/rules') @@ -208,17 +210,27 @@ App::patch('/v1/proxy/rules/:ruleId/verification') throw new Exception(Exception::RULE_NOT_FOUND); } - $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); + $validators = []; + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); + if (!$targetCNAME->isKnown() || $targetCNAME->isTest()) { + $validators[] = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + } + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + } + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + } - if (!$target->isKnown() || $target->isTest()) { - throw new Exception(Exception::GENERAL_SERVER_ERROR, 'Domain target must be configured as environment variable.'); + if (empty($validators)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'At least one of domain targets environment variable must be configured.'); } if ($rule->getAttribute('verification') === true) { return $response->dynamic($rule, Response::MODEL_PROXY_RULE); } - $validator = new CNAME($target->get()); // Verify Domain with DNS records + $validator = new AnyOf($validators, AnyOf::TYPE_STRING); $domain = new Domain($rule->getAttribute('domain', '')); $validationStart = \microtime(true); @@ -226,7 +238,14 @@ App::patch('/v1/proxy/rules/:ruleId/verification') $log->addExtra('dnsTiming', \strval(\microtime(true) - $validationStart)); $log->addTag('dnsDomain', $domain->get()); - $error = $validator->getLogs(); + $errors = []; + foreach ($validators as $validator) { + if (!empty($validator->getLogs())) { + $errors[] = $validator->getLogs(); + } + } + + $error = \implode("\n", $errors); $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); throw new Exception(Exception::RULE_VERIFICATION_FAILED); diff --git a/composer.lock b/composer.lock index 6e25678d0c..32a98f418a 100644 --- a/composer.lock +++ b/composer.lock @@ -1365,16 +1365,16 @@ }, { "name": "open-telemetry/sdk", - "version": "1.2.2", + "version": "1.2.3", "source": { "type": "git", "url": "https://github.com/opentelemetry-php/sdk.git", - "reference": "37eec0fe47ddd627911f318f29b6cd48196be0c0" + "reference": "0e7804c176c4b09d95b7985400aa38ce544cb7fc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/37eec0fe47ddd627911f318f29b6cd48196be0c0", - "reference": "37eec0fe47ddd627911f318f29b6cd48196be0c0", + "url": "https://api.github.com/repos/opentelemetry-php/sdk/zipball/0e7804c176c4b09d95b7985400aa38ce544cb7fc", + "reference": "0e7804c176c4b09d95b7985400aa38ce544cb7fc", "shasum": "" }, "require": { @@ -1451,7 +1451,7 @@ "issues": "https://github.com/open-telemetry/opentelemetry-php/issues", "source": "https://github.com/open-telemetry/opentelemetry-php" }, - "time": "2025-01-29T21:40:28+00:00" + "time": "2025-04-08T09:55:41+00:00" }, { "name": "open-telemetry/sem-conv", @@ -3791,16 +3791,16 @@ }, { "name": "utopia-php/image", - "version": "0.8.1", + "version": "0.8.2", "source": { "type": "git", "url": "https://github.com/utopia-php/image.git", - "reference": "e8cc7dd14f423270a1b7570ec0dae88a66195b63" + "reference": "6c736965177f9a9e71311e22b80cfa88511768e9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/image/zipball/e8cc7dd14f423270a1b7570ec0dae88a66195b63", - "reference": "e8cc7dd14f423270a1b7570ec0dae88a66195b63", + "url": "https://api.github.com/repos/utopia-php/image/zipball/6c736965177f9a9e71311e22b80cfa88511768e9", + "reference": "6c736965177f9a9e71311e22b80cfa88511768e9", "shasum": "" }, "require": { @@ -3834,9 +3834,9 @@ ], "support": { "issues": "https://github.com/utopia-php/image/issues", - "source": "https://github.com/utopia-php/image/tree/0.8.1" + "source": "https://github.com/utopia-php/image/tree/0.8.2" }, - "time": "2025-04-04T18:55:20+00:00" + "time": "2025-04-08T11:31:45+00:00" }, { "name": "utopia-php/locale", @@ -3996,16 +3996,16 @@ }, { "name": "utopia-php/migration", - "version": "0.8.4", + "version": "0.8.5", "source": { "type": "git", "url": "https://github.com/utopia-php/migration.git", - "reference": "845fd04ccf5e0edb03c184b864e0596080a432b8" + "reference": "0dd95b148c581579ec05d2abbbdc13c2b4702331" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/utopia-php/migration/zipball/845fd04ccf5e0edb03c184b864e0596080a432b8", - "reference": "845fd04ccf5e0edb03c184b864e0596080a432b8", + "url": "https://api.github.com/repos/utopia-php/migration/zipball/0dd95b148c581579ec05d2abbbdc13c2b4702331", + "reference": "0dd95b148c581579ec05d2abbbdc13c2b4702331", "shasum": "" }, "require": { @@ -4046,9 +4046,9 @@ ], "support": { "issues": "https://github.com/utopia-php/migration/issues", - "source": "https://github.com/utopia-php/migration/tree/0.8.4" + "source": "https://github.com/utopia-php/migration/tree/0.8.5" }, - "time": "2025-03-28T02:08:22+00:00" + "time": "2025-04-09T05:21:09+00:00" }, { "name": "utopia-php/orchestration", @@ -5085,16 +5085,16 @@ }, { "name": "laravel/pint", - "version": "v1.21.2", + "version": "v1.22.0", "source": { "type": "git", "url": "https://github.com/laravel/pint.git", - "reference": "370772e7d9e9da087678a0edf2b11b6960e40558" + "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/pint/zipball/370772e7d9e9da087678a0edf2b11b6960e40558", - "reference": "370772e7d9e9da087678a0edf2b11b6960e40558", + "url": "https://api.github.com/repos/laravel/pint/zipball/7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", + "reference": "7ddfaa6523a675fae5c4123ee38fc6bfb8ee4f36", "shasum": "" }, "require": { @@ -5105,9 +5105,9 @@ "php": "^8.2.0" }, "require-dev": { - "friendsofphp/php-cs-fixer": "^3.72.0", + "friendsofphp/php-cs-fixer": "^3.75.0", "illuminate/view": "^11.44.2", - "larastan/larastan": "^3.2.0", + "larastan/larastan": "^3.3.1", "laravel-zero/framework": "^11.36.1", "mockery/mockery": "^1.6.12", "nunomaduro/termwind": "^2.3", @@ -5147,7 +5147,7 @@ "issues": "https://github.com/laravel/pint/issues", "source": "https://github.com/laravel/pint" }, - "time": "2025-03-14T22:31:42+00:00" + "time": "2025-04-08T22:11:45+00:00" }, { "name": "matthiasmullie/minify", diff --git a/docker-compose.yml b/docker-compose.yml index a2d7fc91dc..fe40e75363 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -115,7 +115,9 @@ services: - _APP_OPTIONS_COMPUTE_FORCE_HTTPS - _APP_OPENSSL_KEY_V1 - _APP_DOMAIN - - _APP_DOMAIN_TARGET + - _APP_DOMAIN_TARGET_CNAME + - _APP_DOMAIN_TARGET_AAAA + - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_FUNCTIONS - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -524,7 +526,9 @@ services: - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 - _APP_DOMAIN - - _APP_DOMAIN_TARGET + - _APP_DOMAIN_TARGET_CNAME + - _APP_DOMAIN_TARGET_AAAA + - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_FUNCTIONS - _APP_EMAIL_CERTIFICATES - _APP_REDIS_HOST @@ -690,7 +694,9 @@ services: - _APP_WORKER_PER_CORE - _APP_OPENSSL_KEY_V1 - _APP_DOMAIN - - _APP_DOMAIN_TARGET + - _APP_DOMAIN_TARGET_CNAME + - _APP_DOMAIN_TARGET_AAAA + - _APP_DOMAIN_TARGET_A - _APP_EMAIL_SECURITY - _APP_REDIS_HOST - _APP_REDIS_PORT @@ -722,7 +728,9 @@ services: - _APP_ENV - _APP_WORKER_PER_CORE - _APP_DOMAIN - - _APP_DOMAIN_TARGET + - _APP_DOMAIN_TARGET_CNAME + - _APP_DOMAIN_TARGET_AAAA + - _APP_DOMAIN_TARGET_A - _APP_DOMAIN_FUNCTIONS - _APP_OPENSSL_KEY_V1 - _APP_REDIS_HOST diff --git a/src/Appwrite/Network/Validator/CNAME.php b/src/Appwrite/Network/Validator/DNS.php similarity index 53% rename from src/Appwrite/Network/Validator/CNAME.php rename to src/Appwrite/Network/Validator/DNS.php index e1ae061c84..9fa78f360a 100644 --- a/src/Appwrite/Network/Validator/CNAME.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -4,24 +4,22 @@ namespace Appwrite\Network\Validator; use Utopia\Validator; -class CNAME extends Validator +class DNS extends Validator { + public const RECORD_A = 'a'; + public const RECORD_AAAA = 'aaaa'; + public const RECORD_CNAME = 'cname'; + /** * @var mixed */ protected mixed $logs; - /** - * @var string - */ - protected $target; - /** * @param string $target */ - public function __construct($target) + public function __construct(protected $target, protected string $type = self::RECORD_CNAME) { - $this->target = $target; } /** @@ -29,7 +27,7 @@ class CNAME extends Validator */ public function getDescription(): string { - return 'Invalid CNAME record'; + return 'Invalid DNS record'; } /** @@ -41,20 +39,34 @@ class CNAME extends Validator } /** - * Check if CNAME record target value matches selected target + * Check if DNS record value matches specific value * * @param mixed $domain * * @return bool */ - public function isValid($domain): bool + public function isValid($value): bool { - if (!is_string($domain)) { + $typeNative = match ($this->type) { + self::RECORD_A => DNS_A, + self::RECORD_AAAA => DNS_AAAA, + self::RECORD_CNAME => DNS_CNAME, + default => throw new \Exception('Record type not supported.') + }; + + $dnsKey = match ($this->type) { + self::RECORD_A => 'ip', + self::RECORD_AAAA => 'ipv6', + self::RECORD_CNAME => 'target', + default => throw new \Exception('Record type not supported.') + }; + + if (!is_string($value)) { return false; } try { - $records = \dns_get_record($domain, DNS_CNAME); + $records = \dns_get_record($value, $typeNative); $this->logs = $records; } catch (\Throwable $th) { return false; @@ -65,7 +77,7 @@ class CNAME extends Validator } foreach ($records as $record) { - if (isset($record['target']) && $record['target'] === $this->target) { + if (isset($record[$dnsKey]) && $record[$dnsKey] === $this->target) { return true; } } diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php index 50c56b7ea3..b6349c3aea 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -5,12 +5,11 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\CNAME; +use Appwrite\Network\Validator\DNS; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; -use Utopia\App; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; @@ -19,7 +18,9 @@ use Utopia\Domains\Domain; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\AnyOf; use Utopia\Validator\Domain as ValidatorDomain; +use Utopia\Validator\IP; class Create extends Action { @@ -95,13 +96,6 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); } - // Apex domain prevention due to CNAME limitations - if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { - if ($domain->get() === $domain->getRegisterable()) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); - } - } - // TODO: @christyjacob remove once we migrate the rules in 1.7.x $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); @@ -110,8 +104,23 @@ class Create extends Action $status = 'verified'; } if ($status === 'created') { - $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); - $validator = new CNAME($target->get()); + $validators = []; + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); + if (!$targetCNAME->isKnown() || $targetCNAME->isTest()) { + $validators[] = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + } + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + } + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + } + + if (empty($validators)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'At least one of domain targets environment variable must be configured.'); + } + + $validator = new AnyOf($validators, AnyOf::TYPE_STRING); if ($validator->isValid($domain->get())) { $status = 'verifying'; } diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php index 890eb00dec..a85a4fa063 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -5,12 +5,11 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\CNAME; +use Appwrite\Network\Validator\DNS; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; -use Utopia\App; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; @@ -20,7 +19,9 @@ use Utopia\Domains\Domain; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\AnyOf; use Utopia\Validator\Domain as ValidatorDomain; +use Utopia\Validator\IP; use Utopia\Validator\Text; class Create extends Action @@ -96,13 +97,6 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); } - // Apex domain prevention due to CNAME limitations - if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { - if ($domain->get() === $domain->getRegisterable()) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); - } - } - $function = $dbForProject->getDocument('functions', $functionId); if ($function->isEmpty()) { throw new Exception(Exception::RULE_RESOURCE_NOT_FOUND); @@ -118,8 +112,23 @@ class Create extends Action $status = 'verified'; } if ($status === 'created') { - $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); - $validator = new CNAME($target->get()); + $validators = []; + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); + if (!$targetCNAME->isKnown() || $targetCNAME->isTest()) { + $validators[] = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + } + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + } + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + } + + if (empty($validators)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'At least one of domain targets environment variable must be configured.'); + } + + $validator = new AnyOf($validators, AnyOf::TYPE_STRING); if ($validator->isValid($domain->get())) { $status = 'verifying'; } diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php index 5e6f69af4b..58271ef08e 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -5,12 +5,11 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\CNAME; +use Appwrite\Network\Validator\DNS; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; -use Utopia\App; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; @@ -19,7 +18,9 @@ use Utopia\Domains\Domain; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\AnyOf; use Utopia\Validator\Domain as ValidatorDomain; +use Utopia\Validator\IP; use Utopia\Validator\URL; use Utopia\Validator\WhiteList; @@ -95,13 +96,6 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); } - // Apex domain prevention due to CNAME limitations - if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { - if ($domain->get() === $domain->getRegisterable()) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); - } - } - // TODO: @christyjacob remove once we migrate the rules in 1.7.x $ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5($domain->get()) : ID::unique(); @@ -110,8 +104,23 @@ class Create extends Action $status = 'verified'; } if ($status === 'created') { - $dnsTarget = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); - $validator = new CNAME($dnsTarget->get()); + $validators = []; + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); + if (!$targetCNAME->isKnown() || $targetCNAME->isTest()) { + $validators[] = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + } + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + } + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + } + + if (empty($validators)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'At least one of domain targets environment variable must be configured.'); + } + + $validator = new AnyOf($validators, AnyOf::TYPE_STRING); if ($validator->isValid($domain->get())) { $status = 'verifying'; } diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php index 4506f10dce..1a0cbe7fdc 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -5,12 +5,11 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\CNAME; +use Appwrite\Network\Validator\DNS; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; use Appwrite\Utopia\Response; -use Utopia\App; use Utopia\Database\Database; use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; @@ -20,7 +19,9 @@ use Utopia\Domains\Domain; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; use Utopia\System\System; +use Utopia\Validator\AnyOf; use Utopia\Validator\Domain as ValidatorDomain; +use Utopia\Validator\IP; use Utopia\Validator\Text; class Create extends Action @@ -96,13 +97,6 @@ class Create extends Action throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'Domain may not start with http:// or https://.'); } - // Apex domain prevention due to CNAME limitations - if (empty(App::getEnv('_APP_DOMAINS_NAMESERVERS', ''))) { - if ($domain->get() === $domain->getRegisterable()) { - throw new Exception(Exception::GENERAL_ARGUMENT_INVALID, 'The instance does not allow root-level (apex) domains.'); - } - } - $site = $dbForProject->getDocument('sites', $siteId); if ($site->isEmpty()) { throw new Exception(Exception::RULE_RESOURCE_NOT_FOUND); @@ -118,8 +112,23 @@ class Create extends Action $status = 'verified'; } if ($status === 'created') { - $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); - $validator = new CNAME($target->get()); + $validators = []; + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); + if (!$targetCNAME->isKnown() || $targetCNAME->isTest()) { + $validators[] = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + } + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + } + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + } + + if (empty($validators)) { + throw new Exception(Exception::GENERAL_SERVER_ERROR, 'At least one of domain targets environment variable must be configured.'); + } + + $validator = new AnyOf($validators, AnyOf::TYPE_STRING); if ($validator->isValid($domain->get())) { $status = 'verifying'; } diff --git a/src/Appwrite/Platform/Tasks/Doctor.php b/src/Appwrite/Platform/Tasks/Doctor.php index a9d0a6220c..329248eae2 100644 --- a/src/Appwrite/Platform/Tasks/Doctor.php +++ b/src/Appwrite/Platform/Tasks/Doctor.php @@ -15,6 +15,7 @@ use Utopia\Registry\Registry; use Utopia\Storage\Device\Local; use Utopia\Storage\Storage; use Utopia\System\System; +use Utopia\Validator\IP; class Doctor extends Action { @@ -43,19 +44,31 @@ class Doctor extends Action Console::log('[Settings]'); $domain = new Domain(System::getEnv('_APP_DOMAIN')); - if (!$domain->isKnown() || $domain->isTest()) { - Console::log('🔴 Hostname has no public suffix (' . $domain->get() . ')'); + Console::log('🔴 Hostname is not valid (' . $domain->get() . ')'); } else { - Console::log('🟢 Hostname has a public suffix (' . $domain->get() . ')'); + Console::log('🟢 Hostname is valid (' . $domain->get() . ')'); } - $domain = new Domain(System::getEnv('_APP_DOMAIN_TARGET')); - + $domain = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME')); if (!$domain->isKnown() || $domain->isTest()) { - Console::log('🔴 CNAME target has no public suffix (' . $domain->get() . ')'); + Console::log('🔴 CNAME record target is not valid (' . $domain->get() . ')'); } else { - Console::log('🟢 CNAME target has a public suffix (' . $domain->get() . ')'); + Console::log('🟢 CNAME record target is valid (' . $domain->get() . ')'); + } + + $ipv4 = new IP(IP::V4); + if (!$ipv4->isValid(System::getEnv('_APP_DOMAIN_TARGET_A'))) { + Console::log('🔴 A record target is not valid (' . System::getEnv('_APP_DOMAIN_TARGET_A') . ')'); + } else { + Console::log('🟢 A record target is valid (' . System::getEnv('_APP_DOMAIN_TARGET_A') . ')'); + } + + $ipv6 = new IP(IP::V6); + if (!$ipv6->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA'))) { + Console::log('🔴 AAAA record target is not valid (' . System::getEnv('_APP_DOMAIN_TARGET_AAAA') . ')'); + } else { + Console::log('🟢 AAAA record target is valid (' . System::getEnv('_APP_DOMAIN_TARGET_AAAA') . ')'); } if (System::getEnv('_APP_OPENSSL_KEY_V1') === 'your-secret-key' || empty(System::getEnv('_APP_OPENSSL_KEY_V1'))) { diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 712196fc72..15f9645bb0 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -8,7 +8,7 @@ use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; -use Appwrite\Network\Validator\CNAME; +use Appwrite\Network\Validator\DNS; use Appwrite\Template\Template; use Appwrite\Utopia\Response\Model\Rule; use Exception; @@ -28,6 +28,8 @@ use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Queue\Message; use Utopia\System\System; +use Utopia\Validator\AnyOf; +use Utopia\Validator\IP; class Certificates extends Action { @@ -290,22 +292,39 @@ class Certificates extends Action } if (!$isMainDomain) { - // TODO: Would be awesome to also support A/AAAA records here. Maybe dry run? - // Validate if domain target is properly configured - $target = new Domain(System::getEnv('_APP_DOMAIN_TARGET', '')); + $validationStart = \microtime(true); - if (!$target->isKnown() || $target->isTest()) { - throw new Exception('Unreachable CNAME target (' . $target->get() . '), please use a domain with a public suffix.'); + $validators = []; + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); + if (!$targetCNAME->isKnown() || $targetCNAME->isTest()) { + $validators[] = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + } + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + } + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validators[] = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + } + + // Validate if domain target is properly configured + if (empty($validators)) { + throw new Exception('At least one of domain targets environment variable must be configured.'); } // Verify domain with DNS records - $validationStart = \microtime(true); - $validator = new CNAME($target->get()); + $validator = new AnyOf($validators, AnyOf::TYPE_STRING); if (!$validator->isValid($domain->get())) { $log->addExtra('dnsTiming', \strval(\microtime(true) - $validationStart)); $log->addTag('dnsDomain', $domain->get()); - $error = $validator->getLogs(); + $errors = []; + foreach ($validators as $validator) { + if (!empty($validator->getLogs())) { + $errors[] = $validator->getLogs(); + } + } + + $error = \implode("\n", $errors); $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); throw new Exception('Failed to verify domain DNS records.'); diff --git a/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php b/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php index 57f287fc78..97dae2efcd 100644 --- a/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php +++ b/src/Appwrite/Utopia/Response/Model/ConsoleVariables.php @@ -10,12 +10,24 @@ class ConsoleVariables extends Model public function __construct() { $this - ->addRule('_APP_DOMAIN_TARGET', [ - 'type' => self::TYPE_STRING, - 'description' => 'CNAME target for your Appwrite custom domains.', - 'default' => '', - 'example' => 'appwrite.io', - ]) + ->addRule('_APP_DOMAIN_TARGET_CNAME', [ + 'type' => self::TYPE_STRING, + 'description' => 'CNAME target for your Appwrite custom domains.', + 'default' => '', + 'example' => 'appwrite.io', + ]) + ->addRule('_APP_DOMAIN_TARGET_A', [ + 'type' => self::TYPE_STRING, + 'description' => 'A target for your Appwrite custom domains.', + 'default' => '', + 'example' => '127.0.0.1', + ]) + ->addRule('_APP_DOMAIN_TARGET_AAAA', [ + 'type' => self::TYPE_STRING, + 'description' => 'AAAA target for your Appwrite custom domains.', + 'default' => '', + 'example' => '::1', + ]) ->addRule('_APP_STORAGE_LIMIT', [ 'type' => self::TYPE_INTEGER, 'description' => 'Maximum file size allowed for file upload in bytes.', diff --git a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php index a24ec148bf..6059cb2000 100644 --- a/tests/e2e/Services/Console/ConsoleConsoleClientTest.php +++ b/tests/e2e/Services/Console/ConsoleConsoleClientTest.php @@ -24,11 +24,12 @@ class ConsoleConsoleClientTest extends Scope ], $this->getHeaders())); $this->assertEquals(200, $response['headers']['status-code']); - $this->assertCount(11, $response['body']); - $this->assertIsString($response['body']['_APP_DOMAIN_TARGET']); + $this->assertCount(13, $response['body']); + $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_CNAME']); + $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_A']); + $this->assertIsString($response['body']['_APP_DOMAIN_TARGET_AAAA']); $this->assertIsInt($response['body']['_APP_STORAGE_LIMIT']); $this->assertIsInt($response['body']['_APP_COMPUTE_SIZE_LIMIT']); - $this->assertIsString($response['body']['_APP_DOMAIN_TARGET']); $this->assertIsBool($response['body']['_APP_DOMAIN_ENABLED']); $this->assertIsBool($response['body']['_APP_VCS_ENABLED']); $this->assertIsBool($response['body']['_APP_ASSISTANT_ENABLED']); diff --git a/tests/e2e/Services/Proxy/ProxyCustomServerTest.php b/tests/e2e/Services/Proxy/ProxyCustomServerTest.php index 6312840095..0ac2d9c186 100644 --- a/tests/e2e/Services/Proxy/ProxyCustomServerTest.php +++ b/tests/e2e/Services/Proxy/ProxyCustomServerTest.php @@ -53,8 +53,10 @@ class ProxyCustomServerTest extends Scope public function testCreateRuleApex(): void { - $rule = $this->createAPIRule('myapp.com'); - $this->assertEquals(400, $rule['headers']['status-code']); + $domain = \uniqid() . '.com'; + $rule = $this->createAPIRule($domain); + $this->assertEquals(201, $rule['headers']['status-code']); + $this->assertEquals('created', $rule['body']['status']); } public function testCreateRuleVcs(): void @@ -337,7 +339,7 @@ class ProxyCustomServerTest extends Scope $this->cleanupRule($rule['body']['$id']); // Create + update - $domain = \uniqid() . '-cname-api.custom.localhost'; + $domain = \uniqid() . '-cname-api.custom.com'; $rule = $this->createAPIRule($domain); $this->assertEquals(201, $rule['headers']['status-code']); diff --git a/tests/unit/Network/Validators/CNAMETest.php b/tests/unit/Network/Validators/CNAMETest.php deleted file mode 100644 index cbb07f19b5..0000000000 --- a/tests/unit/Network/Validators/CNAMETest.php +++ /dev/null @@ -1,29 +0,0 @@ -object = new CNAME('appwrite.io'); - } - - public function tearDown(): void - { - } - - public function testValues(): void - { - $this->assertEquals($this->object->isValid(''), false); - $this->assertEquals($this->object->isValid(null), false); - $this->assertEquals($this->object->isValid(false), false); - $this->assertEquals($this->object->isValid('cname-unit-test.appwrite.org'), true); - $this->assertEquals($this->object->isValid('test1.appwrite.org'), false); - } -} diff --git a/tests/unit/Network/Validators/DNSTest.php b/tests/unit/Network/Validators/DNSTest.php new file mode 100644 index 0000000000..5e8652381a --- /dev/null +++ b/tests/unit/Network/Validators/DNSTest.php @@ -0,0 +1,50 @@ +assertEquals($validator->isValid(''), false); + $this->assertEquals($validator->isValid(null), false); + $this->assertEquals($validator->isValid(false), false); + $this->assertEquals($validator->isValid('cname-unit-test.appwrite.org'), true); + $this->assertEquals($validator->isValid('test1.appwrite.org'), false); + } + + public function testA(): void + { + // IPv4 for documentation purposes + $validator = new DNS('203.0.113.1', DNS::RECORD_A); + $this->assertEquals($validator->isValid(''), false); + $this->assertEquals($validator->isValid(null), false); + $this->assertEquals($validator->isValid(false), false); + $this->assertEquals($validator->isValid('a-unit-test.appwrite.org'), true); + $this->assertEquals($validator->isValid('test1.appwrite.org'), false); + } + + public function testAAAA(): void + { + // IPv6 for documentation purposes + $validator = new DNS('2001:db8::1', DNS::RECORD_AAAA); + $this->assertEquals($validator->isValid(''), false); + $this->assertEquals($validator->isValid(null), false); + $this->assertEquals($validator->isValid(false), false); + $this->assertEquals($validator->isValid('aaaa-unit-test.appwrite.org'), true); + $this->assertEquals($validator->isValid('test1.appwrite.org'), false); + } +}