diff --git a/app/config/collections/platform.php b/app/config/collections/platform.php index b839e51622..6cfc0a7a79 100644 --- a/app/config/collections/platform.php +++ b/app/config/collections/platform.php @@ -1185,6 +1185,17 @@ return [ 'array' => false, 'filters' => [], ], + [ + '$id' => ID::custom('verificationLogs'), + 'type' => Database::VAR_STRING, + 'format' => '', + 'size' => 2048, + 'signed' => true, + 'required' => false, + 'default' => '', + 'array' => false, + 'filters' => [], + ], [ '$id' => ID::custom('redirectStatusCode'), 'type' => Database::VAR_INTEGER, diff --git a/src/Appwrite/Event/Certificate.php b/src/Appwrite/Event/Certificate.php index 00875c7a4a..aab8c1400b 100644 --- a/src/Appwrite/Event/Certificate.php +++ b/src/Appwrite/Event/Certificate.php @@ -7,9 +7,15 @@ use Utopia\Queue\Publisher; class Certificate extends Event { + public const ACTION_VERIFICATION = 'verification'; + public const ACTION_GENERATION = 'generation'; + public const ACTION_GENERATION_SYNC = 'generation_sync'; + protected bool $skipRenewCheck = false; protected ?Document $domain = null; - protected ?string $validationDomain = null; + protected ?string $verificationDomainFunction = null; // For example: fra.cloud.appwrite.io + protected ?string $verificationDomainApi = null; // For example: fra.appwrite.run + protected string $action = self::ACTION_GENERATION; public function __construct(protected Publisher $publisher) { @@ -58,26 +64,49 @@ class Certificate extends Event /** - * Set override for main domain used for validation + * Set verification domain function. * - * @param string|null $validationDomain + * @param ?string $verificationDomainFunction * @return self */ - public function setValidationDomain(?string $validationDomain): self + public function setVerificationDomainFunction(?string $verificationDomainFunction): self { - $this->validationDomain = $validationDomain; + $this->verificationDomainFunction = $verificationDomainFunction; return $this; } /** - * Get validation domain + * Get verification domain function. * - * @return string|null + * @return ?string */ - public function getValidationDomain(): ?string + public function getVerificationDomainFunction(): ?string { - return $this->validationDomain; + return $this->verificationDomainFunction; + } + + /** + * Set verification domain api. + * + * @param ?string $verificationDomainApi + * @return self + */ + public function setVerificationDomainApi(?string $verificationDomainApi): self + { + $this->verificationDomainApi = $verificationDomainApi; + + return $this; + } + + /** + * Get verification domain api. + * + * @return ?string + */ + public function getVerificationDomainApi(): ?string + { + return $this->verificationDomainApi; } /** @@ -90,6 +119,28 @@ class Certificate extends Event return $this->skipRenewCheck; } + /** + * Set action for this certificate event. + * + * @param string $action + * @return self + */ + public function setAction(string $action): self + { + $this->action = $action; + + return $this; + } + + /** + * Get action for this certificate event. + * + * @return string + */ + public function getAction(): string + { + return $this->action; + } /** * Prepare the payload for the event @@ -102,7 +153,9 @@ class Certificate extends Event 'project' => $this->project, 'domain' => $this->domain, 'skipRenewCheck' => $this->skipRenewCheck, - 'validationDomain' => $this->validationDomain + 'verificationDomainFunction' => $this->verificationDomainFunction, + 'verificationDomainApi' => $this->verificationDomainApi, + 'action' => $this->action ]; } } diff --git a/src/Appwrite/Network/Validator/DNS.php b/src/Appwrite/Network/Validator/DNS.php index f09bb42b02..6c6f5568c8 100644 --- a/src/Appwrite/Network/Validator/DNS.php +++ b/src/Appwrite/Network/Validator/DNS.php @@ -14,6 +14,10 @@ class DNS extends Validator public const RECORD_CNAME = 'CNAME'; public const RECORD_CAA = 'CAA'; // You can provide domain only (as $target) for CAA validation + protected const FAILURE_REASON_QUERY = 'DNS query failed.'; + protected const FAILURE_REASON_INTERNAL = 'Internal error occurred.'; + protected const FAILURE_REASON_UNKNOWN = ''; + /** * @var mixed */ @@ -24,6 +28,11 @@ class DNS extends Validator */ protected string $dnsServer; + protected string $domain = ''; + protected array $recordValues = []; + protected int $count = 0; + protected string $reason = ''; + /** * @param string $target */ @@ -41,7 +50,20 @@ class DNS extends Validator */ public function getDescription(): string { - return 'Invalid DNS record'; + if (!empty($this->reason)) { + return $this->reason; + } + + if ($this->count === 0) { + return 'Domain ' . $this->domain . ' does not have ' . $this->type . ' record.'; + } + + $record = $this->count === 1 ? 'record' : 'records'; + $value = $this->count === 1 ? 'value' : 'values'; + + $recordValuesVerbose = implode(', ', $this->recordValues); + + return "Domain {$this->domain} has {$this->count} {$this->type} {$record} with wrong {$value}: {$recordValuesVerbose}"; } /** @@ -60,7 +82,13 @@ class DNS extends Validator */ public function isValid($value): bool { + $this->count = 0; + $this->domain = $value; + $this->reason = self::FAILURE_REASON_UNKNOWN; + $this->recordValues = []; + if (!is_string($value)) { + $this->reason = self::FAILURE_REASON_INTERNAL; return false; } @@ -77,10 +105,13 @@ class DNS extends Validator $this->logs = $query; } catch (\Exception $e) { + $this->reason = self::FAILURE_REASON_QUERY; $this->logs = ['error' => $e->getMessage()]; return false; } + $this->count = \count($query); + if (empty($query)) { // CAA records inherit from parent (custom CAA behaviour) if ($this->type === self::RECORD_CAA) { @@ -109,9 +140,12 @@ class DNS extends Validator $rdata = \trim($rdata, '"'); // certainly.com;validationmethods=tls-alpn-01;retrytimeout=3600 $rdata = \explode(';', $rdata, 2)[0] ?? ''; // certainly.com + $this->recordValues[] = $rdata; if ($rdata === $this->target) { return true; } + } else { + $this->recordValues[] = $record->getRdata(); } if ($record->getRdata() === $this->target) { diff --git a/src/Appwrite/Platform/Modules/Proxy/Base.php b/src/Appwrite/Platform/Modules/Proxy/Base.php new file mode 100644 index 0000000000..e72a32a768 --- /dev/null +++ b/src/Appwrite/Platform/Modules/Proxy/Base.php @@ -0,0 +1,131 @@ +getAttribute('type', ''); + $resourceType = $rule->getAttribute('deploymentResourceType', ''); + + if ($resourceType === 'function') { + // For example: fra.appwrite.run + $targetCNAME = new Domain($verificationDomainFunction); + } elseif ($resourceType === 'site') { + // For example: appwrite.network + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_SITES', '')); + } elseif ($ruleType === 'api') { + // For example: fra.cloud.appwrite.io + $targetCNAME = new Domain($verificationDomainApi); + } elseif ($ruleType === 'redirect') { + // Shouldn't be needed, because redirect should always have resourceTyp too, but just in case we defailt to sites + // For example: appwrite.network + $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_SITES', '')); + } + + $validators = []; + $mainValidator = null; // Validator to use for error description + + if (!is_null($targetCNAME)) { + if ($targetCNAME->isKnown() && !$targetCNAME->isTest()) { + $validator = new DNS($targetCNAME->get(), DNS::RECORD_CNAME); + $validators[] = $validator; + + if (\is_null($mainValidator)) { + $mainValidator = $validator; + } + } + } + + if ((new IP(IP::V4))->isValid(System::getEnv('_APP_DOMAIN_TARGET_A', ''))) { + $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_A', ''), DNS::RECORD_A); + $validators[] = $validator; + + if (\is_null($mainValidator)) { + $mainValidator = $validator; + } + } + + if ((new IP(IP::V6))->isValid(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''))) { + $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_AAAA', ''), DNS::RECORD_AAAA); + $validators[] = $validator; + + if (\is_null($mainValidator)) { + $mainValidator = $validator; + } + } + + 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); + $domain = new Domain($rule->getAttribute('domain', '')); + + $validationStart = \microtime(true); + if (!$validator->isValid($domain->get())) { + if (!\is_null($log)) { + $log->addExtra('dnsTiming', \strval(\microtime(true) - $validationStart)); + $log->addTag('dnsDomain', $domain->get()); + + $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)); + } + + $errorVerbose = 'Verification of DNS records failed.' . $mainValidator->getDescription(); + throw new Exception(Exception::RULE_VERIFICATION_FAILED, $errorVerbose); + } + + // Ensure CAA won't block certificate issuance + if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) { + $validationStart = \microtime(true); + $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); + if (!$validator->isValid($domain->get())) { + if (!\is_null($log)) { + $log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart)); + $log->addTag('dnsDomain', $domain->get()); + $error = $validator->getDescription(); + $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); + } + + $errorVerbose = 'Verification of DNS records failed.' . $mainValidator->getDescription(); + throw new Exception(Exception::RULE_VERIFICATION_FAILED, $errorVerbose); + } + } + } +} 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 4cc8f48e7c..9114a6bdb9 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/API/Create.php @@ -5,7 +5,7 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\API; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\DNS; +use Appwrite\Platform\Modules\Proxy\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -15,14 +15,13 @@ use Utopia\Database\Document; use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Domains\Domain; +use Utopia\Logger\Log; 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 +class Create extends Base { use HTTP; @@ -66,11 +65,19 @@ class Create extends Action ->inject('queueForCertificates') ->inject('queueForEvents') ->inject('dbForPlatform') + ->inject('log') ->callback($this->action(...)); } - public function action(string $domain, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform) - { + public function action( + string $domain, + Response $response, + Document $project, + Certificate $queueForCertificates, + Event $queueForEvents, + Database $dbForPlatform, + Log $log + ) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -131,28 +138,6 @@ class Create extends Action if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { $status = 'verified'; } - if ($status === 'created') { - $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'; - } - } $owner = ''; if ( @@ -176,6 +161,15 @@ class Create extends Action 'region' => $project->getAttribute('region') ]); + if ($status === 'created') { + try { + self::verifyRule($rule, $log); + $status = 'verifying'; + } catch (Exception $err) { + $rule->setAttribute('verificationLogs', $err->getMessage()); + } + } + try { $rule = $dbForPlatform->createDocument('rules', $rule); } catch (Duplicate $e) { 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 5839e03e25..622e5e4b74 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Function/Create.php @@ -5,7 +5,7 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Function; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\DNS; +use Appwrite\Platform\Modules\Proxy\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,15 +16,14 @@ use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\UID; use Utopia\Domains\Domain; +use Utopia\Logger\Log; 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 +class Create extends Base { use HTTP; @@ -71,11 +70,22 @@ class Create extends Action ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('log') ->callback($this->action(...)); } - public function action(string $domain, string $functionId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) - { + public function action( + string $domain, + string $functionId, + string $branch, + Response $response, + Document $project, + Certificate $queueForCertificates, + Event $queueForEvents, + Database $dbForPlatform, + Database $dbForProject, + Log $log + ) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -143,28 +153,6 @@ class Create extends Action if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { $status = 'verified'; } - if ($status === 'created') { - $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'; - } - } $owner = ''; if ( @@ -194,6 +182,15 @@ class Create extends Action 'region' => $project->getAttribute('region') ]); + if ($status === 'created') { + try { + self::verifyRule($rule, $log); + $status = 'verifying'; + } catch (Exception $err) { + $rule->setAttribute('verificationLogs', $err->getMessage()); + } + } + try { $rule = $dbForPlatform->createDocument('rules', $rule); } catch (Duplicate $e) { 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 1dfef8bcc7..375d1d6c44 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Redirect/Create.php @@ -5,7 +5,7 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Redirect; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\DNS; +use Appwrite\Platform\Modules\Proxy\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,16 +16,15 @@ use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\UID; use Utopia\Domains\Domain; +use Utopia\Logger\Log; 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; -class Create extends Action +class Create extends Base { use HTTP; @@ -74,11 +73,24 @@ class Create extends Action ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('log') ->callback($this->action(...)); } - public function action(string $domain, string $url, int $statusCode, string $resourceId, string $resourceType, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) - { + public function action( + string $domain, + string $url, + int $statusCode, + string $resourceId, + string $resourceType, + Response $response, + Document $project, + Certificate $queueForCertificates, + Event $queueForEvents, + Database $dbForPlatform, + Database $dbForProject, + Log $log + ) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -148,28 +160,6 @@ class Create extends Action if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { $status = 'verified'; } - if ($status === 'created') { - $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'; - } - } $owner = ''; if ( @@ -198,6 +188,15 @@ class Create extends Action 'region' => $project->getAttribute('region') ]); + if ($status === 'created') { + try { + self::verifyRule($rule, $log); + $status = 'verifying'; + } catch (Exception $err) { + $rule->setAttribute('verificationLogs', $err->getMessage()); + } + } + try { $rule = $dbForPlatform->createDocument('rules', $rule); } catch (Duplicate $e) { 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 43cf09eaca..192c4d46d3 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Site/Create.php @@ -5,7 +5,7 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Site; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\DNS; +use Appwrite\Platform\Modules\Proxy\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -16,15 +16,14 @@ use Utopia\Database\Exception\Duplicate; use Utopia\Database\Helpers\ID; use Utopia\Database\Validator\UID; use Utopia\Domains\Domain; +use Utopia\Logger\Log; 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 +class Create extends Base { use HTTP; @@ -71,11 +70,22 @@ class Create extends Action ->inject('queueForEvents') ->inject('dbForPlatform') ->inject('dbForProject') + ->inject('log') ->callback($this->action(...)); } - public function action(string $domain, string $siteId, string $branch, Response $response, Document $project, Certificate $queueForCertificates, Event $queueForEvents, Database $dbForPlatform, Database $dbForProject) - { + public function action( + string $domain, + string $siteId, + string $branch, + Response $response, + Document $project, + Certificate $queueForCertificates, + Event $queueForEvents, + Database $dbForPlatform, + Database $dbForProject, + Log $log + ) { $sitesDomain = System::getEnv('_APP_DOMAIN_SITES', ''); $functionsDomain = System::getEnv('_APP_DOMAIN_FUNCTIONS', ''); @@ -143,28 +153,6 @@ class Create extends Action if (\str_ends_with($domain->get(), $functionsDomain) || \str_ends_with($domain->get(), $sitesDomain)) { $status = 'verified'; } - if ($status === 'created') { - $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'; - } - } $owner = ''; if ( @@ -194,6 +182,15 @@ class Create extends Action 'region' => $project->getAttribute('region') ]); + if ($status === 'created') { + try { + self::verifyRule($rule, $log); + $status = 'verifying'; + } catch (Exception $err) { + $rule->setAttribute('verificationLogs', $err->getMessage()); + } + } + try { $rule = $dbForPlatform->createDocument('rules', $rule); } catch (Duplicate $e) { diff --git a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php index 3d52d203c3..b09f456a8c 100644 --- a/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php +++ b/src/Appwrite/Platform/Modules/Proxy/Http/Rules/Verification/Update.php @@ -5,7 +5,7 @@ namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Verification; use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Extend\Exception; -use Appwrite\Network\Validator\DNS; +use Appwrite\Platform\Modules\Proxy\Base; use Appwrite\SDK\AuthType; use Appwrite\SDK\Method; use Appwrite\SDK\Response as SDKResponse; @@ -17,11 +17,8 @@ use Utopia\Domains\Domain; use Utopia\Logger\Log; use Utopia\Platform\Action; use Utopia\Platform\Scope\HTTP; -use Utopia\System\System; -use Utopia\Validator\AnyOf; -use Utopia\Validator\IP; -class Update extends Action +class Update extends Base { use HTTP; @@ -81,92 +78,21 @@ class Update extends Action throw new Exception(Exception::RULE_NOT_FOUND); } - $targetCNAME = null; - switch ($rule->getAttribute('type', '')) { - case 'api': - // For example: fra.cloud.appwrite.io - $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', '')); - break; - case 'redirect': - // For example: appwrite.network - $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_SITES', '')); - break; - case 'deployment': - switch ($rule->getAttribute('deploymentResourceType', '')) { - case 'function': - // For example: fra.appwrite.run - $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_FUNCTIONS', '')); - break; - case 'site': - // For example: appwrite.network - $targetCNAME = new Domain(System::getEnv('_APP_DOMAIN_SITES', '')); - break; - default: - break; - } - // no break - default: - break; - } - - $validators = []; - - if (!is_null($targetCNAME)) { - 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.'); - } - if ($rule->getAttribute('verification') === true) { return $response->dynamic($rule, Response::MODEL_PROXY_RULE); } - $validator = new AnyOf($validators, AnyOf::TYPE_STRING); - $domain = new Domain($rule->getAttribute('domain', '')); - - $validationStart = \microtime(true); - if (!$validator->isValid($domain->get())) { - $log->addExtra('dnsTiming', \strval(\microtime(true) - $validationStart)); - $log->addTag('dnsDomain', $domain->get()); - - $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); + try { + self::verifyRule($rule, $log); + $rule = $rule->setAttribute('verificationLogs', ''); + } catch (Exception $err) { + $rule = $rule->setAttribute('verificationLogs', $err->getMessage()); + throw $err; } - // Ensure CAA won't block certificate issuance - if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) { - $validationStart = \microtime(true); - $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); - if (!$validator->isValid($domain->get())) { - $log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart)); - $log->addTag('dnsDomain', $domain->get()); - $error = $validator->getDescription(); - $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); - throw new Exception(Exception::RULE_VERIFICATION_FAILED, 'Domain verification failed because CAA records do not allow Appwrite\'s certificate issuer.'); - } - } + $rule = $rule->setAttribute('status', 'verifying'); - $dbForPlatform->updateDocument('rules', $rule->getId(), $rule->setAttribute('status', 'verifying')); + $dbForPlatform->updateDocument('rules', $rule->getId(), $rule); // Issue a TLS certificate when domain is verified $queueForCertificates diff --git a/src/Appwrite/Platform/Workers/Certificates.php b/src/Appwrite/Platform/Workers/Certificates.php index 3a65dfed2b..b462b728a8 100644 --- a/src/Appwrite/Platform/Workers/Certificates.php +++ b/src/Appwrite/Platform/Workers/Certificates.php @@ -3,12 +3,13 @@ namespace Appwrite\Platform\Workers; use Appwrite\Certificates\Adapter as CertificatesAdapter; +use Appwrite\Event\Certificate; use Appwrite\Event\Event; use Appwrite\Event\Func; use Appwrite\Event\Mail; use Appwrite\Event\Realtime; use Appwrite\Event\Webhook; -use Appwrite\Network\Validator\DNS; +use Appwrite\Platform\Modules\Proxy\Base; use Appwrite\Template\Template; use Appwrite\Utopia\Response\Model\Rule; use Exception; @@ -22,14 +23,13 @@ use Utopia\Database\Exception\Conflict; use Utopia\Database\Exception\Structure; use Utopia\Database\Helpers\ID; use Utopia\Database\Query; +use Utopia\Database\Validator\Authorization as ValidatorAuthorization; use Utopia\Domains\Domain; use Utopia\Locale\Locale; 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 { @@ -85,21 +85,44 @@ class Certificates extends Action array $plan ): void { $payload = $message->getPayload() ?? []; - if (empty($payload)) { throw new Exception('Missing payload'); } $document = new Document($payload['domain'] ?? []); - $domain = new Domain($document->getAttribute('domain', '')); + $domain = new Domain($document->getAttribute('domain', '')); + $domainType = $document->getAttribute('domainType'); + $skipRenewCheck = $payload['skipRenewCheck'] ?? false; - $validationDomain = $payload['validationDomain'] ?? null; + $action = $payload['action'] ?? []; + $verificationDomainFunction = $payload['verificationDomainFunction'] ?? null; + $verificationDomainApi = $payload['verificationDomainApi'] ?? null; $log->addTag('domain', $domain->get()); - $domainType = $document->getAttribute('domainType'); + switch ($action) { + case Certificate::ACTION_GENERATION: + $this->executeGeneration($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $skipRenewCheck, $plan, $verificationDomainFunction, $verificationDomainApi); + break; + case Certificate::ACTION_VERIFICATION: + $this->executeVerification(); + break; + case Certificate::ACTION_GENERATION_SYNC: + $this->executeGenerationSync(); + break; + default: + throw new Exception('Invalid action . ' . $action); + } + } - $this->execute($domain, $domainType, $dbForPlatform, $queueForMails, $queueForEvents, $queueForWebhooks, $queueForFunctions, $queueForRealtime, $log, $certificates, $skipRenewCheck, $plan, $validationDomain); + private function executeGenerationSync(): void + { + throw new \Exception('To be implemented.'); + } + + private function executeVerification(): void + { + throw new \Exception('To be implemented.'); } /** @@ -113,12 +136,13 @@ class Certificates extends Action * @param CertificatesAdapter $certificates * @param bool $skipRenewCheck * @param array $plan - * @param string|null $validationDomain + * @param ?string $verificationDomainFunction + * @param ?string $verificationDomainApi * @return void * @throws Throwable * @throws \Utopia\Database\Exception */ - private function execute( + private function executeGeneration( Domain $domain, ?string $domainType, Database $dbForPlatform, @@ -131,7 +155,8 @@ class Certificates extends Action CertificatesAdapter $certificates, bool $skipRenewCheck = false, array $plan = [], - ?string $validationDomain = null + ?string $verificationDomainFunction = null, + ?string $verificationDomainApi = null ): void { /** * 1. Read arguments and validate domain @@ -176,9 +201,37 @@ class Certificates extends Action try { // Validate domain and DNS records. Skip if job is forced if (!$skipRenewCheck) { - $mainDomain = $validationDomain ?? $this->getMainDomain(); + $mainDomain = $this->getMainDomain(); $isMainDomain = !isset($mainDomain) || $domain->get() === $mainDomain; - $this->validateDomain($domain, $isMainDomain, $log); + + if (empty($domain->get())) { + throw new Exception('Missing certificate domain.'); + } + + if (!$domain->isKnown() || $domain->isTest()) { + throw new Exception('Unknown public suffix for domain.'); + } + + // TODO: @christyjacob remove once we migrate the rules in 1.7.x + if (System::getEnv('_APP_RULES_FORMAT') === 'md5') { + $rule = ValidatorAuthorization::skip(fn () => $dbForPlatform->getDocument('rules', md5($domain->get()))); + } else { + $rule = ValidatorAuthorization::skip( + fn () => $dbForPlatform->find('rules', [ + Query::equal('domain', [$domain->get()]), + Query::limit(1) + ]) + )[0] ?? new Document(); + } + + if ($rule->isEmpty()) { + $rule = new Document([ + 'domain' => $domain->get(), + 'type' => 'api' + ]); + } + + $this->validateDomain($rule, $isMainDomain, $log, $verificationDomainApi, $verificationDomainFunction); // If certificate exists already, double-check expiry date. Skip if job is forced if (!$certificates->isRenewRequired($domain->get(), $domainType, $log)) { @@ -286,73 +339,18 @@ class Certificates extends Action * - Domain needs to be public and valid (prevents NFT domains that are not supported) * - Domain must have proper DNS record * - * @param Domain $domain Domain which we validate + * @param Document $rule Rule which we validate * @param bool $isMainDomain In case of master domain, we look for different DNS configurations + * @param string|null $verificationDomainApi Function to verify domain + * @param string|null $verificationDomainFunction Function to verify domain * * @return void * @throws Exception */ - private function validateDomain(Domain $domain, bool $isMainDomain, Log $log): void + private function validateDomain(Document $rule, bool $isMainDomain, Log $log, ?string $verificationDomainApi = null, ?string $verificationDomainFunction = null): void { - if (empty($domain->get())) { - throw new Exception('Missing certificate domain.'); - } - - if (!$domain->isKnown() || $domain->isTest()) { - throw new Exception('Unknown public suffix for domain.'); - } - if (!$isMainDomain) { - $validationStart = \microtime(true); - - $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 - $validator = new AnyOf($validators, AnyOf::TYPE_STRING); - if (!$validator->isValid($domain->get())) { - $log->addExtra('dnsTiming', \strval(\microtime(true) - $validationStart)); - $log->addTag('dnsDomain', $domain->get()); - - $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.'); - } - - // Ensure CAA won't block certificate issuance - if (!empty(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''))) { - $validationStart = \microtime(true); - $validator = new DNS(System::getEnv('_APP_DOMAIN_TARGET_CAA', ''), DNS::RECORD_CAA); - if (!$validator->isValid($domain->get())) { - $log->addExtra('dnsTimingCaa', \strval(\microtime(true) - $validationStart)); - $log->addTag('dnsDomain', $domain->get()); - $error = $validator->getDescription(); - $log->addExtra('dnsResponse', \is_array($error) ? \json_encode($error) : \strval($error)); - throw new Exception('Failed to verify domain DNS records. CAA records do not allow Appwrite\'s certificate issuer.'); - } - } + Base::verifyRule($rule, $log, $verificationDomainApi, $verificationDomainFunction); } else { // Main domain validation // TODO: Would be awesome to check A/AAAA record here. Maybe dry run? diff --git a/src/Appwrite/Utopia/Response/Model/Rule.php b/src/Appwrite/Utopia/Response/Model/Rule.php index 12903b270e..b887b01297 100644 --- a/src/Appwrite/Utopia/Response/Model/Rule.php +++ b/src/Appwrite/Utopia/Response/Model/Rule.php @@ -88,6 +88,12 @@ class Rule extends Model 'default' => false, 'example' => 'verified', ]) + ->addRule('verificationLogs', [ + 'type' => self::TYPE_STRING, + 'description' => 'DNS verification logs. This contains error from last verification attempt.', + 'default' => '', + 'example' => 'Verification of DNS records failed. Domain stage.myapp.com does not have DNS record.', + ]) ->addRule('logs', [ 'type' => self::TYPE_STRING, 'description' => 'Certificate generation logs. This will return an empty string if generation did not run, or succeeded.',