Improve UX of dns flows

This commit is contained in:
Matej Bačo committed 2025-09-11 12:32:37 +02:00
1 parent d1918b7c24
commit 60dd501793
11 files changed
+426 -280

No files matched your search

+11
View File
@@ -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,
+63 -10
View File
@@ -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
];
}
}
+35 -1
View File
@@ -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) {
@@ -0,0 +1,131 @@
<?php
namespace Appwrite\Platform\Modules\Proxy;
use Appwrite\Extend\Exception;
use Appwrite\Network\Validator\DNS;
use Appwrite\Platform\Action;
use Utopia\Database\Document;
use Utopia\Domains\Domain;
use Utopia\Logger\Log;
use Utopia\System\System;
use Utopia\Validator\AnyOf;
use Utopia\Validator\IP;
class Base extends Action
{
/**
* Verify or re-verify a rule
*
* @param Document $rule Rule to verify
* @param Log|null $log Log instance to add timings to
* @param string|null $verificationDomainApi Override for expected API rule value during verification
* @param string|null $verificationDomainFunction Override for expected Function rule value during verification
* @return void
*/
public static function verifyRule(Document $rule, ?Log $log = null, ?string $verificationDomainApi = null, ?string $verificationDomainFunction = null): void
{
if (\is_null($verificationDomainApi)) {
$verificationDomainApi = System::getEnv('_APP_DOMAIN_TARGET_CNAME', '');
}
if (\is_null($verificationDomainFunction)) {
$verificationDomainFunction = System::getEnv('_APP_DOMAIN_FUNCTIONS', '');
}
$targetCNAME = null;
$ruleType = $rule->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);
}
}
}
}
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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) {
@@ -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
+71 -73
View File
@@ -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?
@@ -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.',