From 21afa4312749fe4fc5ff6ff3d6460fd2c38f8a6b Mon Sep 17 00:00:00 2001 From: Bradley Schofield Date: Tue, 13 Dec 2022 11:16:12 +0000 Subject: [PATCH] Port Certificates to new Queue system --- app/cli.php | 6 + app/controllers/api/projects.php | 6 +- app/controllers/general.php | 5 +- app/init.php | 4 + app/worker.php | 11 + app/workers/certificates.php | 590 ++++++++++---------- docker-compose.yml | 1 + src/Appwrite/Event/Certificate.php | 8 +- src/Appwrite/Platform/Tasks/Maintenance.php | 16 +- 9 files changed, 339 insertions(+), 308 deletions(-) diff --git a/app/cli.php b/app/cli.php index bfe7bfcefb..860a1ef946 100644 --- a/app/cli.php +++ b/app/cli.php @@ -3,6 +3,7 @@ require_once __DIR__ . '/init.php'; require_once __DIR__ . '/controllers/general.php'; +use Appwrite\Event\Certificate; use Appwrite\Event\Func; use Appwrite\Platform\Appwrite; use Utopia\CLI\CLI; @@ -144,6 +145,11 @@ CLI::setResource('queueForFunctions', function (Group $pools) { return new Func($pools->get('queue')->pop()->getResource()); }, ['pools']); +CLI::setResource('queueForCertificates', function (Group $pools) { + var_dump(json_encode($pools)); + return new Certificate($pools->get('queue')->pop()->getResource()); +}, ['pools']); + CLI::setResource('logError', function (Registry $register) { return function (Throwable $error, string $namespace, string $action) use ($register) { $logger = $register->get('logger'); diff --git a/app/controllers/api/projects.php b/app/controllers/api/projects.php index 5fef7eab78..87bba655c5 100644 --- a/app/controllers/api/projects.php +++ b/app/controllers/api/projects.php @@ -1314,7 +1314,8 @@ App::patch('/v1/projects/:projectId/domains/:domainId/verification') ->param('domainId', '', new UID(), 'Domain unique ID.') ->inject('response') ->inject('dbForConsole') - ->action(function (string $projectId, string $domainId, Response $response, Database $dbForConsole) { + ->inject('queueForCertificates') + ->action(function (string $projectId, string $domainId, Response $response, Database $dbForConsole, Certificate $queueForCertificates) { $project = $dbForConsole->getDocument('projects', $projectId); @@ -1352,8 +1353,7 @@ App::patch('/v1/projects/:projectId/domains/:domainId/verification') $dbForConsole->deleteCachedDocument('projects', $project->getId()); // Issue a TLS certificate when domain is verified - $event = new Certificate(); - $event + $queueForCertificates ->setDomain($domain) ->trigger(); diff --git a/app/controllers/general.php b/app/controllers/general.php index 0c10dd46e9..1e894d087a 100644 --- a/app/controllers/general.php +++ b/app/controllers/general.php @@ -51,7 +51,8 @@ App::init() ->inject('locale') ->inject('clients') ->inject('servers') - ->action(function (App $utopia, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $clients, array $servers) { + ->inject('queueForCertificates') + ->action(function (App $utopia, Request $request, Response $response, Document $console, Document $project, Database $dbForConsole, Document $user, Locale $locale, array $clients, array $servers, Certificate $queueForCertificates) { /* * Request format */ @@ -122,7 +123,7 @@ App::init() Console::info('Issuing a TLS certificate for the main domain (' . $domain->get() . ') in a few seconds...'); - (new Certificate()) + $queueForCertificates ->setDomain($domainDocument) ->trigger(); } diff --git a/app/init.php b/app/init.php index 2f71a60ce8..b606669770 100644 --- a/app/init.php +++ b/app/init.php @@ -69,6 +69,7 @@ use Utopia\Pools\Group; use Utopia\Pools\Pool; use Ahc\Jwt\JWT; use Ahc\Jwt\JWTException; +use Appwrite\Event\Certificate; use Appwrite\Event\Func; use MaxMind\Db\Reader; use PHPMailer\PHPMailer\PHPMailer; @@ -860,6 +861,9 @@ App::setResource('queue', function (Group $pools) { App::setResource('queueForFunctions', function (Connection $queue) { return new Func($queue); }, ['queue']); +App::setResource('queueForCertificates', function (Connection $queue) { + return new Certificate($queue); +}, ['queue']); App::setResource('usage', function ($register) { return new Stats($register->get('statsd')); }, ['register']); diff --git a/app/worker.php b/app/worker.php index 8151381d4a..cf6fa2e342 100644 --- a/app/worker.php +++ b/app/worker.php @@ -2,6 +2,7 @@ require_once __DIR__ . '/init.php'; +use Appwrite\Event\Certificate; use Appwrite\Event\Func; use Swoole\Runtime; use Utopia\App; @@ -85,6 +86,16 @@ Server::setResource('queueForFunctions', function (Registry $register) { ); }, ['register']); +Server::setResource('queueForCertificates', function (Registry $register) { + $pools = $register->get('pools'); + return new Certificate( + $pools + ->get('queue') + ->pop() + ->getResource() + ); +}, ['register']); + Server::setResource('logger', function ($register) { return $register->get('logger'); }, ['register']); diff --git a/app/workers/certificates.php b/app/workers/certificates.php index b4f0701c46..b5f6c52685 100644 --- a/app/workers/certificates.php +++ b/app/workers/certificates.php @@ -1,42 +1,33 @@ dbForConsole = $this->getConsoleDB(); - - $skipCheck = $this->args['skipRenewCheck'] ?? false; // If true, we won't double-check expiry from cert file - $document = new Document($this->args['domain'] ?? []); - $domain = new Domain($document->getAttribute('domain', '')); - // Get current certificate - $certificate = $this->dbForConsole->findOne('certificates', [Query::equal('domain', [$domain->get()])]); + $certificate = $dbForConsole->findOne('certificates', [Query::equal('domain', [$domain->get()])]); // If we don't have certificate for domain yet, let's create new document. At the end we save it if (!$certificate) { @@ -90,14 +75,14 @@ class CertificatesV1 extends Worker } // Validate domain and DNS records. Skip if job is forced - if (!$skipCheck) { - $mainDomain = $this->getMainDomain(); + if (!$skipRenewCheck) { + $mainDomain = getMainDomain($dbForConsole); $isMainDomain = !isset($mainDomain) || $domain->get() === $mainDomain; - $this->validateDomain($domain, $isMainDomain); + validateDomain($domain, $isMainDomain); } // If certificate exists already, double-check expiry date. Skip if job is forced - if (!$skipCheck && !$this->isRenewRequired($domain->get())) { + if (!$skipRenewCheck && !isRenewRequired($domain->get())) { throw new Exception('Renew isn\'t required.'); } @@ -105,7 +90,7 @@ class CertificatesV1 extends Worker $folder = ID::unique(); // Generate certificate files using Let's Encrypt - $letsEncryptData = $this->issueCertificate($folder, $domain->get(), $email); + $letsEncryptData = issueCertificate($folder, $domain->get(), $email); // Command succeeded, store all data into document // We store stderr too, because it may include warnings @@ -115,10 +100,10 @@ class CertificatesV1 extends Worker ])); // Give certificates to Traefik - $this->applyCertificateFiles($folder, $domain->get(), $letsEncryptData); + applyCertificateFiles($folder, $domain->get(), $letsEncryptData); // Update certificate info stored in database - $certificate->setAttribute('renewDate', $this->getRenewDate($domain->get())); + $certificate->setAttribute('renewDate', getRenewDate($domain->get())); $certificate->setAttribute('attempts', 0); $certificate->setAttribute('issueDate', DateTime::now()); } catch (Throwable $e) { @@ -133,290 +118,309 @@ class CertificatesV1 extends Worker $certificate->setAttribute('renewDate', DateTime::now()); // Send email to security email - $this->notifyError($domain->get(), $e->getMessage(), $attempts); + notifyError($domain->get(), $e->getMessage(), $attempts); } finally { // All actions result in new updatedAt date $certificate->setAttribute('updated', DateTime::now()); // Save all changes we made to certificate document into database - $this->saveCertificateDocument($domain->get(), $certificate); + saveCertificateDocument($domain->get(), $certificate, $dbForConsole); + } + }; +}); + + +/** + * Save certificate data into database. + * + * @param string $domain Domain name that certificate is for + * @param Document $certificate Certificate document that we need to save + * @param Database $dbForConsole Database connection for console + * + * @return void + */ +function saveCertificateDocument(string $domain, Document $certificate, Database $dbForConsole) : void +{ + // Check if update or insert required + $certificateDocument = $dbForConsole->findOne('certificates', [Query::equal('domain', [$domain])]); + if (!empty($certificateDocument) && !$certificateDocument->isEmpty()) { + // Merge new data with current data + $certificate = new Document(\array_merge($certificateDocument->getArrayCopy(), $certificate->getArrayCopy())); + + $certificate = $dbForConsole->updateDocument('certificates', $certificate->getId(), $certificate); + } else { + $certificate = $dbForConsole->createDocument('certificates', $certificate); + } + + $certificateId = $certificate->getId(); + updateDomainDocuments($certificateId, $domain, $dbForConsole); +} + +/** + * Get main domain. Needed as we do different checks for main and non-main domains. + * + * @return null|string Returns main domain. If null, there is no main domain yet. + */ +function getMainDomain($dbForConsole): ?string +{ + $envDomain = App::getEnv('_APP_DOMAIN', ''); + if (!empty($envDomain) && $envDomain !== 'localhost') { + return $envDomain; + } else { + $domainDocument = $dbForConsole->findOne('domains', [Query::orderAsc('_id')]); + if ($domainDocument) { + return $domainDocument->getAttribute('domain'); } } - public function shutdown(): void - { + return null; +} + +/** + * Internal domain validation functionality to prevent unnecessary attempts failed from Let's Encrypt side. We check: + * - Domain needs to be public and valid (prevents NFT domains that are not supported by Let's Encrypt) + * - Domain must have proper DNS record + * + * @param Domain $domain Domain which we validate + * @param bool $isMainDomain In case of master domain, we look for different DNS configurations + * + * @return void + */ +function validateDomain(Domain $domain, bool $isMainDomain): void +{ + if (empty($domain->get())) { + throw new Exception('Missing certificate domain.'); } - /** - * Save certificate data into database. - * - * @param string $domain Domain name that certificate is for - * @param Document $certificate Certificate document that we need to save - * - * @return void - */ - private function saveCertificateDocument(string $domain, Document $certificate): void - { - // Check if update or insert required - $certificateDocument = $this->dbForConsole->findOne('certificates', [Query::equal('domain', [$domain])]); - if (!empty($certificateDocument) && !$certificateDocument->isEmpty()) { - // Merge new data with current data - $certificate = new Document(\array_merge($certificateDocument->getArrayCopy(), $certificate->getArrayCopy())); - - $certificate = $this->dbForConsole->updateDocument('certificates', $certificate->getId(), $certificate); - } else { - $certificate = $this->dbForConsole->createDocument('certificates', $certificate); - } - - $certificateId = $certificate->getId(); - $this->updateDomainDocuments($certificateId, $domain); + if (!$domain->isKnown() || $domain->isTest()) { + throw new Exception('Unknown public suffix for domain.'); } - /** - * Get main domain. Needed as we do different checks for main and non-main domains. - * - * @return null|string Returns main domain. If null, there is no main domain yet. - */ - private function getMainDomain(): ?string - { - $envDomain = App::getEnv('_APP_DOMAIN', ''); - if (!empty($envDomain) && $envDomain !== 'localhost') { - return $envDomain; - } else { - $domainDocument = $this->dbForConsole->findOne('domains', [Query::orderAsc('_id')]); - if ($domainDocument) { - return $domainDocument->getAttribute('domain'); - } + 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(App::getEnv('_APP_DOMAIN_TARGET', '')); + + if (!$target->isKnown() || $target->isTest()) { + throw new Exception('Unreachable CNAME target (' . $target->get() . '), please use a domain with a public suffix.'); } - return null; + // Verify domain with DNS records + $validator = new CNAME($target->get()); + if (!$validator->isValid($domain->get())) { + throw new Exception('Failed to verify domain DNS records.'); + } + } else { + // Main domain validation + // TODO: Would be awesome to check A/AAAA record here. Maybe dry run? } +} - /** - * Internal domain validation functionality to prevent unnecessary attempts failed from Let's Encrypt side. We check: - * - Domain needs to be public and valid (prevents NFT domains that are not supported by Let's Encrypt) - * - Domain must have proper DNS record - * - * @param Domain $domain Domain which we validate - * @param bool $isMainDomain In case of master domain, we look for different DNS configurations - * - * @return void - */ - private function validateDomain(Domain $domain, bool $isMainDomain): void - { - if (empty($domain->get())) { - throw new Exception('Missing certificate domain.'); - } +/** + * Reads expiry date of certificate from file and decides if renewal is required or not. + * + * @param string $domain Domain for which we check certificate file + * + * @return bool True, if certificate needs to be renewed + */ +function isRenewRequired(string $domain): bool +{ + $certPath = APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem'; + if (\file_exists($certPath)) { + $validTo = null; - if (!$domain->isKnown() || $domain->isTest()) { - throw new Exception('Unknown public suffix for domain.'); - } - - 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(App::getEnv('_APP_DOMAIN_TARGET', '')); - - if (!$target->isKnown() || $target->isTest()) { - throw new Exception('Unreachable CNAME target (' . $target->get() . '), please use a domain with a public suffix.'); - } - - // Verify domain with DNS records - $validator = new CNAME($target->get()); - if (!$validator->isValid($domain->get())) { - throw new Exception('Failed to verify domain DNS records.'); - } - } else { - // Main domain validation - // TODO: Would be awesome to check A/AAAA record here. Maybe dry run? - } - } - - /** - * Reads expiry date of certificate from file and decides if renewal is required or not. - * - * @param string $domain Domain for which we check certificate file - * - * @return bool True, if certificate needs to be renewed - */ - private function isRenewRequired(string $domain): bool - { - $certPath = APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem'; - if (\file_exists($certPath)) { - $validTo = null; - - $certData = openssl_x509_parse(file_get_contents($certPath)); - $validTo = $certData['validTo_time_t'] ?? 0; - - if (empty($validTo)) { - throw new Exception('Unable to read certificate file (cert.pem).'); - } - - // LetsEncrypt allows renewal 30 days before expiry - $expiryInAdvance = (60 * 60 * 24 * 30); - if ($validTo - $expiryInAdvance > \time()) { - return false; - } - } - - return true; - } - - /** - * LetsEncrypt communication to issue certificate (using certbot CLI) - * - * @param string $folder Folder into which certificates should be generated - * @param string $domain Domain to generate certificate for - * - * @return array Named array with keys 'stdout' and 'stderr', both string - */ - private function issueCertificate(string $folder, string $domain, string $email): array - { - $stdout = ''; - $stderr = ''; - - $staging = (App::isProduction()) ? '' : ' --dry-run'; - $exit = Console::execute("certbot certonly --webroot --noninteractive --agree-tos{$staging}" - . " --email " . $email - . " --cert-name " . $folder - . " -w " . APP_STORAGE_CERTIFICATES - . " -d {$domain}", '', $stdout, $stderr); - - // Unexpected error, usually 5XX, API limits, ... - if ($exit !== 0) { - throw new Exception('Failed to issue a certificate with message: ' . $stderr); - } - - return [ - 'stdout' => $stdout, - 'stderr' => $stderr - ]; - } - - /** - * Read new renew date from certificate file generated by Let's Encrypt - * - * @param string $domain Domain which certificate was generated for - * - * @return string - */ - private function getRenewDate(string $domain): string - { - $certPath = APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem'; $certData = openssl_x509_parse(file_get_contents($certPath)); - $validTo = $certData['validTo_time_t'] ?? null; - $dt = (new \DateTime())->setTimestamp($validTo); - return DateTime::addSeconds($dt, -60 * 60 * 24 * 30); // -30 days - } + $validTo = $certData['validTo_time_t'] ?? 0; - /** - * Method to take files from Let's Encrypt, and put it into Traefik. - * - * @param string $domain Domain which certificate was generated for - * @param string $folder Folder in which certificates were generated - * @param array $letsEncryptData Let's Encrypt logs to use for additional info when throwing error - * - * @return void - */ - private function applyCertificateFiles(string $folder, string $domain, array $letsEncryptData): void - { - // Prepare folder in storage for domain - $path = APP_STORAGE_CERTIFICATES . '/' . $domain; - if (!\is_readable($path)) { - if (!\mkdir($path, 0755, true)) { - throw new Exception('Failed to create path for certificate.'); - } + if (empty($validTo)) { + throw new Exception('Unable to read certificate file (cert.pem).'); } - // Move generated files - if (!@\rename('/etc/letsencrypt/live/' . $folder . '/cert.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem')) { - throw new Exception('Failed to rename certificate cert.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); - } - - if (!@\rename('/etc/letsencrypt/live/' . $folder . '/chain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/chain.pem')) { - throw new Exception('Failed to rename certificate chain.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); - } - - if (!@\rename('/etc/letsencrypt/live/' . $folder . '/fullchain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/fullchain.pem')) { - throw new Exception('Failed to rename certificate fullchain.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); - } - - if (!@\rename('/etc/letsencrypt/live/' . $folder . '/privkey.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/privkey.pem')) { - throw new Exception('Failed to rename certificate privkey.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); - } - - $config = \implode(PHP_EOL, [ - "tls:", - " certificates:", - " - certFile: /storage/certificates/{$domain}/fullchain.pem", - " keyFile: /storage/certificates/{$domain}/privkey.pem" - ]); - - // Save configuration into Traefik using our new cert files - if (!\file_put_contents(APP_STORAGE_CONFIG . '/' . $domain . '.yml', $config)) { - throw new Exception('Failed to save Traefik configuration.'); + // LetsEncrypt allows renewal 30 days before expiry + $expiryInAdvance = (60 * 60 * 24 * 30); + if ($validTo - $expiryInAdvance > \time()) { + return false; } } - /** - * Method to make sure information about error is delivered to admnistrator. - * - * @param string $domain Domain that caused the error - * @param string $errorMessage Verbose error message - * @param int $attempt How many times it failed already - * - * @return void - */ - private function notifyError(string $domain, string $errorMessage, int $attempt): void - { - // Log error into console - Console::warning('Cannot renew domain (' . $domain . ') on attempt no. ' . $attempt . ' certificate: ' . $errorMessage); + return true; +} - // Send mail to administratore mail - $mail = new Mail(); - $mail - ->setType(MAIL_TYPE_CERTIFICATE) - ->setRecipient(App::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')) - ->setUrl('https://' . $domain) - ->setLocale(App::getEnv('_APP_LOCALE', 'en')) - ->setName('Appwrite Administrator') - ->setPayload([ - 'domain' => $domain, - 'error' => $errorMessage, - 'attempt' => $attempt - ]) - ->trigger(); +/** + * LetsEncrypt communication to issue certificate (using certbot CLI) + * + * @param string $folder Folder into which certificates should be generated + * @param string $domain Domain to generate certificate for + * + * @return array Named array with keys 'stdout' and 'stderr', both string + */ +function issueCertificate(string $folder, string $domain, string $email): array +{ + $stdout = ''; + $stderr = ''; + + $staging = (App::isProduction()) ? '' : ' --dry-run'; + $exit = Console::execute("certbot certonly --webroot --noninteractive --agree-tos{$staging}" + . " --email " . $email + . " --cert-name " . $folder + . " -w " . APP_STORAGE_CERTIFICATES + . " -d {$domain}", '', $stdout, $stderr); + + // Unexpected error, usually 5XX, API limits, ... + if ($exit !== 0) { + throw new Exception('Failed to issue a certificate with message: ' . $stderr); } - /** - * Update all existing domain documents so they have relation to correct certificate document. - * This solved issues: - * - when adding a domain for which there is already a certificate - * - when renew creates new document? It might? - * - overall makes it more reliable - * - * @param string $certificateId ID of a new or updated certificate document - * @param string $domain Domain that is affected by new certificate - * - * @return void - */ - private function updateDomainDocuments(string $certificateId, string $domain): void - { - $domains = $this->dbForConsole->find('domains', [ - Query::equal('domain', [$domain]), - Query::limit(1000), - ]); + return [ + 'stdout' => $stdout, + 'stderr' => $stderr + ]; +} - foreach ($domains as $domainDocument) { - $domainDocument->setAttribute('updated', DateTime::now()); - $domainDocument->setAttribute('certificateId', $certificateId); +/** + * Read new renew date from certificate file generated by Let's Encrypt + * + * @param string $domain Domain which certificate was generated for + * + * @return string + */ +function getRenewDate(string $domain): string +{ + $certPath = APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem'; + $certData = openssl_x509_parse(file_get_contents($certPath)); + $validTo = $certData['validTo_time_t'] ?? null; + $dt = (new \DateTime())->setTimestamp($validTo); + return DateTime::addSeconds($dt, -60 * 60 * 24 * 30); // -30 days +} - $this->dbForConsole->updateDocument('domains', $domainDocument->getId(), $domainDocument); +/** + * Method to take files from Let's Encrypt, and put it into Traefik. + * + * @param string $domain Domain which certificate was generated for + * @param string $folder Folder in which certificates were generated + * @param array $letsEncryptData Let's Encrypt logs to use for additional info when throwing error + * + * @return void + */ +function applyCertificateFiles(string $folder, string $domain, array $letsEncryptData): void +{ + // Prepare folder in storage for domain + $path = APP_STORAGE_CERTIFICATES . '/' . $domain; + if (!\is_readable($path)) { + if (!\mkdir($path, 0755, true)) { + throw new Exception('Failed to create path for certificate.'); + } + } - if ($domainDocument->getAttribute('projectId')) { - $this->dbForConsole->deleteCachedDocument('projects', $domainDocument->getAttribute('projectId')); - } + // Move generated files + if (!@\rename('/etc/letsencrypt/live/' . $folder . '/cert.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem')) { + throw new Exception('Failed to rename certificate cert.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); + } + + if (!@\rename('/etc/letsencrypt/live/' . $folder . '/chain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/chain.pem')) { + throw new Exception('Failed to rename certificate chain.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); + } + + if (!@\rename('/etc/letsencrypt/live/' . $folder . '/fullchain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/fullchain.pem')) { + throw new Exception('Failed to rename certificate fullchain.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); + } + + if (!@\rename('/etc/letsencrypt/live/' . $folder . '/privkey.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/privkey.pem')) { + throw new Exception('Failed to rename certificate privkey.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']); + } + + $config = \implode(PHP_EOL, [ + "tls:", + " certificates:", + " - certFile: /storage/certificates/{$domain}/fullchain.pem", + " keyFile: /storage/certificates/{$domain}/privkey.pem" + ]); + + // Save configuration into Traefik using our new cert files + if (!\file_put_contents(APP_STORAGE_CONFIG . '/' . $domain . '.yml', $config)) { + throw new Exception('Failed to save Traefik configuration.'); + } +} + +/** + * Method to make sure information about error is delivered to admnistrator. + * + * @param string $domain Domain that caused the error + * @param string $errorMessage Verbose error message + * @param int $attempt How many times it failed already + * + * @return void + */ +function notifyError(string $domain, string $errorMessage, int $attempt): void +{ + // Log error into console + Console::warning('Cannot renew domain (' . $domain . ') on attempt no. ' . $attempt . ' certificate: ' . $errorMessage); + + // Send mail to administratore mail + $mail = new Mail(); + $mail + ->setType(MAIL_TYPE_CERTIFICATE) + ->setRecipient(App::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS')) + ->setUrl('https://' . $domain) + ->setLocale(App::getEnv('_APP_LOCALE', 'en')) + ->setName('Appwrite Administrator') + ->setPayload([ + 'domain' => $domain, + 'error' => $errorMessage, + 'attempt' => $attempt + ]) + ->trigger(); +} + +/** + * Update all existing domain documents so they have relation to correct certificate document. + * This solved issues: + * - when adding a domain for which there is already a certificate + * - when renew creates new document? It might? + * - overall makes it more reliable + * + * @param string $certificateId ID of a new or updated certificate document + * @param string $domain Domain that is affected by new certificate + * @param Database $dbForConsole Database instance for console + * + * @return void + */ +function updateDomainDocuments(string $certificateId, string $domain, Database $dbForConsole): void +{ + $domains = $dbForConsole->find('domains', [ + Query::equal('domain', [$domain]), + Query::limit(1000), + ]); + + foreach ($domains as $domainDocument) { + $domainDocument->setAttribute('updated', DateTime::now()); + $domainDocument->setAttribute('certificateId', $certificateId); + + $dbForConsole->updateDocument('domains', $domainDocument->getId(), $domainDocument); + + if ($domainDocument->getAttribute('projectId')) { + $dbForConsole->deleteCachedDocument('projects', $domainDocument->getAttribute('projectId')); } } } + +$server->job() + ->inject('message') + ->inject('dbForConsole') + ->inject('execute') + ->action(function ($message, $dbForConsole, $execute) use ($server) { + $payload = $message->getPayload() ?? []; + + if (empty($payload)) { + throw new Exception('Missing payload'); + } + + $document = new Document($payload['domain'] ?? []); + $domain = new Domain($document->getAttribute('domain', '')); + $skipRenewCheck = $payload['skipRenewCheck'] ?? false; + + $execute($dbForConsole, $document, $domain, $skipRenewCheck); + }); + +$server->workerStart(); +$server->start(); diff --git a/docker-compose.yml b/docker-compose.yml index 6c040df4cc..0e002e8de7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -589,6 +589,7 @@ services: - _APP_MAINTENANCE_RETENTION_AUDIT - _APP_MAINTENANCE_RETENTION_USAGE_HOURLY - _APP_MAINTENANCE_RETENTION_SCHEDULES + - _APP_CONNECTIONS_QUEUE appwrite-usage: entrypoint: usage diff --git a/src/Appwrite/Event/Certificate.php b/src/Appwrite/Event/Certificate.php index d3d9091804..0f0cbaa73d 100644 --- a/src/Appwrite/Event/Certificate.php +++ b/src/Appwrite/Event/Certificate.php @@ -4,13 +4,15 @@ namespace Appwrite\Event; use Resque; use Utopia\Database\Document; +use Utopia\Queue\Client; +use Utopia\Queue\Connection; class Certificate extends Event { protected bool $skipRenewCheck = false; protected ?Document $domain = null; - public function __construct() + public function __construct(protected Connection $connection) { parent::__construct(Event::CERTIFICATES_QUEUE_NAME, Event::CERTIFICATES_CLASS_NAME); } @@ -69,7 +71,9 @@ class Certificate extends Event */ public function trigger(): string|bool { - return Resque::enqueue($this->queue, $this->class, [ + $client = new Client($this->queue, $this->connection); + + return $client->enqueue([ 'project' => $this->project, 'domain' => $this->domain, 'skipRenewCheck' => $this->skipRenewCheck diff --git a/src/Appwrite/Platform/Tasks/Maintenance.php b/src/Appwrite/Platform/Tasks/Maintenance.php index 0739923e34..87995b0e1e 100644 --- a/src/Appwrite/Platform/Tasks/Maintenance.php +++ b/src/Appwrite/Platform/Tasks/Maintenance.php @@ -25,10 +25,11 @@ class Maintenance extends Action $this ->desc('Schedules maintenance tasks and publishes them to resque') ->inject('dbForConsole') - ->callback(fn (Database $dbForConsole) => $this->action($dbForConsole)); + ->inject('queueForCertificates') + ->callback(fn (Database $dbForConsole, Certificate $queueForCertificates) => $this->action($dbForConsole, $queueForCertificates)); } - public function action(Database $dbForConsole): void + public function action(Database $dbForConsole, Certificate $queueForCertificates): void { Console::title('Maintenance V1'); Console::success(APP_NAME . ' maintenance process v1 has started'); @@ -80,7 +81,7 @@ class Maintenance extends Action ->trigger(); } - function renewCertificates($dbForConsole) + function renewCertificates($dbForConsole, $queueForCertificates) { $time = DateTime::now(); @@ -91,12 +92,11 @@ class Maintenance extends Action ]); - if (\count($certificates) > 0) { + if (\count($certificates) > 0 || true) { Console::info("[{$time}] Found " . \count($certificates) . " certificates for renewal, scheduling jobs."); - $event = new Certificate(); foreach ($certificates as $certificate) { - $event + $queueForCertificates ->setDomain(new Document([ 'domain' => $certificate->getAttribute('domain') ])) @@ -135,7 +135,7 @@ class Maintenance extends Action $cacheRetention = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_CACHE', '2592000'); // 30 days $schedulesDeletionRetention = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_SCHEDULES', '86400'); // 1 Day - Console::loop(function () use ($interval, $executionLogsRetention, $abuseLogsRetention, $auditLogRetention, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForConsole) { + Console::loop(function () use ($interval, $executionLogsRetention, $abuseLogsRetention, $auditLogRetention, $cacheRetention, $schedulesDeletionRetention, $usageStatsRetentionHourly, $dbForConsole, $queueForCertificates) { $time = DateTime::now(); Console::info("[{$time}] Notifying workers with maintenance tasks every {$interval} seconds"); @@ -145,7 +145,7 @@ class Maintenance extends Action notifyDeleteUsageStats($usageStatsRetentionHourly); notifyDeleteConnections(); notifyDeleteExpiredSessions(); - renewCertificates($dbForConsole); + renewCertificates($dbForConsole, $queueForCertificates); notifyDeleteCache($cacheRetention); notifyDeleteSchedules($schedulesDeletionRetention); }, $interval);