1
0
Fork 0
mirror of synced 2024-06-26 18:20:43 +12:00
appwrite/app/workers/certificates.php

413 lines
16 KiB
PHP
Raw Normal View History

2020-02-23 21:58:11 +13:00
<?php
2022-04-10 21:38:22 +12:00
use Appwrite\Event\Event;
2020-06-12 07:36:10 +12:00
use Appwrite\Network\Validator\CNAME;
use Appwrite\Resque\Worker;
use Utopia\App;
use Utopia\CLI\Console;
2022-05-12 05:05:43 +12:00
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Domains\Domain;
2020-02-23 21:58:11 +13:00
require_once __DIR__ . '/../init.php';
2020-02-23 21:58:11 +13:00
2021-01-15 19:02:48 +13:00
Console::title('Certificates V1 Worker');
2021-09-01 21:13:23 +12:00
Console::success(APP_NAME . ' certificates worker v1 has started');
2020-02-23 21:58:11 +13:00
class CertificatesV1 extends Worker
2020-02-23 21:58:11 +13:00
{
2022-05-12 01:11:58 +12:00
/**
* Database connection shared across all methods of this file
2022-05-24 02:54:50 +12:00
*
2022-05-12 01:11:58 +12:00
* @var Database
*/
2022-05-12 02:09:30 +12:00
private Database $dbForConsole;
2022-04-27 21:02:31 +12:00
public function getName(): string
{
return "certificates";
}
public function init(): void
2020-02-23 21:58:11 +13:00
{
}
public function run(): void
2020-02-23 21:58:11 +13:00
{
/**
2022-04-11 19:56:58 +12:00
* 1. Read arguments and validate domain
* 2. Get main domain
* 3. Validate CNAME DNS if parameter is not main domain (meaning it's custom domain)
2022-05-12 01:11:58 +12:00
* 4. Validate security email. Cannot be empty, required by LetsEncrypt
* 5. Validate renew date with certificate file, unless requested to skip by parameter
2022-04-11 19:56:58 +12:00
* 6. Issue a certificate using certbot CLI
* 7. Update 'log' attribute on certificate document with Certbot message
* 8. Create storage folder for certificate, if not ready already
* 9. Move certificates from Certbot location to our Storage
* 10. Create/Update our Storage with new Traefik config with new certificate paths
* 11. Read certificate file and update 'renewDate' on certificate document
* 12. Update 'issueDate' and 'attempts' on certificate
2022-05-24 02:54:50 +12:00
*
2022-04-11 19:56:58 +12:00
* If at any point unexpected error occurs, program stops without applying changes to document, and error is thrown into worker
2022-05-24 02:54:50 +12:00
*
2022-04-11 19:56:58 +12:00
* If code stops with expected error:
* 1. 'log' attribute on document is updated with error message
* 2. 'attempts' amount is increased
* 3. Console log is shown
* 4. Email is sent to security email
2022-05-24 02:54:50 +12:00
*
2022-04-11 19:56:58 +12:00
* Unless unexpected error occurs, at the end, we:
* 1. Update 'updated' attribute on document
* 2. Save document to database
* 3. Update all domains documents with current certificate ID
2022-05-24 02:54:50 +12:00
*
2022-04-11 19:56:58 +12:00
* Note: Renewals are checked and scheduled from maintenence worker
2020-02-23 21:58:11 +13:00
*/
2022-05-17 02:37:56 +12:00
$this->dbForConsole = $this->getConsoleDB();
2021-09-01 21:13:23 +12:00
2022-05-17 02:37:56 +12:00
$skipCheck = $this->args['skipRenewCheck'] ?? false; // If true, we won't double-check expiry from cert file
2022-05-17 04:28:50 +12:00
$document = new Document($this->args['domain'] ?? []);
2022-05-17 02:37:56 +12:00
$domain = new Domain($document->getAttribute('domain', ''));
2020-02-23 21:58:11 +13:00
2022-05-17 02:37:56 +12:00
// Get current certificate
$certificate = $this->dbForConsole->findOne('certificates', [new Query('domain', Query::TYPE_EQUAL, [$domain->get()])]);
2022-05-12 01:11:58 +12:00
2022-05-17 02:37:56 +12:00
// If we don't have certificate for domain yet, let's create new document. At the end we save it
if (!$certificate) {
$certificate = new Document();
$certificate->setAttribute('domain', $domain->get());
}
2022-05-12 01:11:58 +12:00
2022-05-17 02:37:56 +12:00
try {
2022-04-10 21:38:22 +12:00
// Email for alerts is required by LetsEncrypt
$email = App::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS');
if (empty($email)) {
2022-04-27 21:42:15 +12:00
throw new Exception('You must set a valid security email address (_APP_SYSTEM_SECURITY_EMAIL_ADDRESS) to issue an SSL certificate.');
2022-04-10 21:38:22 +12:00
}
2022-05-13 00:08:50 +12:00
// Validate domain and DNS records. Skip if job is forced
if (!$skipCheck) {
2022-05-13 00:08:50 +12:00
$mainDomain = $this->getMainDomain();
$isMainDomain = !isset($mainDomain) || $domain->get() === $mainDomain;
$this->validateDomain($domain, $isMainDomain);
}
2022-05-12 01:11:58 +12:00
2022-05-13 00:08:50 +12:00
// If certificate exists already, double-check expiry date. Skip if job is forced
if (!$skipCheck && !$this->isRenewRequired($domain->get())) {
2022-05-12 01:11:58 +12:00
throw new Exception('Renew isn\'t required.');
2022-04-10 21:38:22 +12:00
}
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
// Generate certificate files using Let's Encrypt
2022-05-12 02:09:30 +12:00
$letsEncryptData = $this->issueCertificate($domain->get(), $email);
2022-05-12 01:11:58 +12:00
2022-04-10 21:38:22 +12:00
// Command succeeded, store all data into document
// We store stderr too, because it may include warnings
2022-05-12 01:11:58 +12:00
$certificate->setAttribute('log', \json_encode([
'stdout' => $letsEncryptData['stdout'],
'stderr' => $letsEncryptData['stderr'],
2021-07-20 04:46:34 +12:00
]));
2022-05-12 01:11:58 +12:00
// Give certificates to Traefik
2022-05-13 00:08:50 +12:00
$this->applyCertificateFiles($domain->get(), $letsEncryptData);
2022-05-12 01:11:58 +12:00
// Update certificate info stored in database
$certificate->setAttribute('renewDate', $this->getRenewDate($domain->get()));
$certificate->setAttribute('attempts', 0);
$certificate->setAttribute('issueDate', \time());
} catch (Throwable $e) {
2022-05-12 01:11:58 +12:00
// Set exception as log in certificate document
$certificate->setAttribute('log', $e->getMessage());
// Increase attempts count
$attempts = $certificate->getAttribute('attempts', 0) + 1;
$certificate->setAttribute('attempts', $attempts);
// Send email to security email
$this->notifyError($domain->get(), $e->getMessage(), $attempts);
} finally {
// All actions result in new updatedAt date
$certificate->setAttribute('updated', \time());
// Save all changes we made to certificate document into database
$this->saveCertificateDocument($domain->get(), $certificate);
2020-02-23 21:58:11 +13:00
}
2022-05-12 01:11:58 +12:00
}
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
public function shutdown(): void
{
}
/**
* Save certificate data into database.
*
* @param string $domain Domain name that certificate is for
* @param Document $certificate Certificate document that we need to save
2022-05-24 02:54:50 +12:00
*
2022-05-12 01:11:58 +12:00
* @return void
*/
private function saveCertificateDocument(string $domain, Document $certificate): void
{
2022-05-12 01:11:58 +12:00
// Check if update or insert required
$certificateDocument = $this->dbForConsole->findOne('certificates', [new Query('domain', Query::TYPE_EQUAL, [$domain])]);
2022-05-12 01:11:58 +12:00
if (!empty($certificateDocument) && !$certificateDocument->isEmpty()) {
// Merge new data with current data
$certificate = new Document(\array_merge($certificateDocument->getArrayCopy(), $certificate->getArrayCopy()));
2022-05-12 01:11:58 +12:00
$certificate = $this->dbForConsole->updateDocument('certificates', $certificate->getId(), $certificate);
} else {
$certificate = $this->dbForConsole->createDocument('certificates', $certificate);
2020-02-23 21:58:11 +13:00
}
2022-05-12 01:11:58 +12:00
$certificateId = $certificate->getId();
$this->updateDomainDocuments($certificateId, $domain);
}
2021-09-01 21:13:23 +12:00
2022-05-12 01:11:58 +12:00
/**
* 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
{
2022-05-18 20:31:54 +12:00
$envDomain = App::getEnv('_APP_DOMAIN', '');
if (!empty($envDomain) && $envDomain !== 'localhost') {
return $envDomain;
2022-05-12 01:11:58 +12:00
} else {
$domainDocument = $this->dbForConsole->findOne('domains', [], 0, ['_id'], ['ASC']);
if ($domainDocument) {
2022-05-12 02:09:30 +12:00
return $domainDocument->getAttribute('domain');
2020-03-01 19:33:19 +13:00
}
2020-02-23 21:58:11 +13:00
}
2022-05-12 02:09:30 +12:00
return null;
2022-05-12 01:11:58 +12:00
}
2021-09-01 21:13:23 +12:00
2022-05-12 01:11:58 +12:00
/**
* 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
{
2022-05-12 01:11:58 +12:00
if (empty($domain->get())) {
throw new Exception('Missing certificate domain.');
2020-02-23 21:58:11 +13:00
}
2022-05-12 01:11:58 +12:00
if (!$domain->isKnown() || $domain->isTest()) {
throw new Exception('Unknown public suffix for domain.');
}
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
if (!$isMainDomain) {
// TODO: Would be awesome to also support A/AAAA records here. Maybe dry run?
2020-03-02 01:04:29 +13:00
2022-05-12 01:11:58 +12:00
// Validate if domain target is properly configured
$target = new Domain(App::getEnv('_APP_DOMAIN_TARGET', ''));
2020-03-02 00:20:39 +13:00
2022-05-12 01:11:58 +12:00
if (!$target->isKnown() || $target->isTest()) {
throw new Exception('Unreachable CNAME target (' . $target->get() . '), please use a domain with a public suffix.');
2022-04-10 21:38:22 +12:00
}
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
// Verify domain with DNS records
$validator = new CNAME($target->get());
if (!$validator->isValid($domain->get())) {
throw new Exception('Failed to verify domain DNS records.');
2020-02-29 19:24:46 +13:00
}
2022-05-12 01:11:58 +12:00
} else {
// Main domain validation
// TODO: Would be awesome to check A/AAAA record here. Maybe dry run?
2020-02-23 21:58:11 +13:00
}
2022-05-12 01:11:58 +12:00
}
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
/**
* 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
{
2022-05-12 01:11:58 +12:00
$certPath = APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem';
if (\file_exists($certPath)) {
$validTo = null;
2021-09-01 21:13:23 +12:00
2022-04-10 21:38:22 +12:00
$certData = openssl_x509_parse(file_get_contents($certPath));
2022-04-11 19:56:58 +12:00
$validTo = $certData['validTo_time_t'] ?? 0;
2022-04-10 21:38:22 +12:00
2022-05-12 01:11:58 +12:00
if (empty($validTo)) {
throw new Exception('Unable to read certificate file (cert.pem).');
}
2020-02-24 06:45:51 +13:00
2022-05-12 01:11:58 +12:00
// LetsEncrypt allows renewal 30 days before expiry
$expiryInAdvance = (60 * 60 * 24 * 30);
2022-05-12 01:11:58 +12:00
if ($validTo - $expiryInAdvance > \time()) {
return false;
}
2021-02-19 05:48:11 +13:00
}
2022-05-12 01:11:58 +12:00
return true;
}
2022-04-10 21:38:22 +12:00
2022-05-12 01:11:58 +12:00
/**
* LetsEncrypt communication to issue certificate (using certbot CLI)
*
* @param string $domain Domain to generate certificate for
*
* @return array Named array with keys 'stdout' and 'stderr', both string
*/
private function issueCertificate(string $domain, string $email): array
{
2021-02-19 05:48:11 +13:00
$stdout = '';
$stderr = '';
2022-05-12 01:11:58 +12:00
$staging = (App::isProduction()) ? '' : ' --dry-run';
2021-02-19 05:48:11 +13:00
$exit = Console::execute("certbot certonly --webroot --noninteractive --agree-tos{$staging}"
2021-09-01 21:13:23 +12:00
. " --email " . $email
. " -w " . APP_STORAGE_CERTIFICATES
2022-05-12 01:11:58 +12:00
. " -d {$domain}", '', $stdout, $stderr);
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
// Unexpected error, usually 5XX, API limits, ...
2021-09-01 21:13:23 +12:00
if ($exit !== 0) {
throw new Exception('Failed to issue a certificate with message: ' . $stderr);
2020-02-23 21:58:11 +13:00
}
2020-02-25 22:21:56 +13:00
2022-05-12 01:11:58 +12:00
return [
'stdout' => $stdout,
'stderr' => $stderr
];
}
2020-02-25 22:21:56 +13:00
2022-05-12 01:11:58 +12:00
/**
* Read new renew date from certificate file generated by Let's Encrypt
*
* @param string $domain Domain which certificate was generated for
*
* @return int
*/
private function getRenewDate(string $domain): int
{
2022-05-12 01:11:58 +12:00
$certPath = APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem';
$certData = openssl_x509_parse(file_get_contents($certPath));
$validTo = $certData['validTo_time_t'] ?? 0;
$expiryInAdvance = (60 * 60 * 24 * 30); // 30 days
2022-05-17 04:28:50 +12:00
2022-05-12 01:11:58 +12:00
return $validTo - $expiryInAdvance;
}
/**
* Method to take files from Let's Encrypt, and put it into Traefik.
*
* @param string $domain Domain which certificate was generated for
2022-05-13 00:08:50 +12:00
* @param array $letsEncryptData Let's Encrypt logs to use for additional info when throwing error
2022-05-12 01:11:58 +12:00
*
* @return void
*/
private function applyCertificateFiles(string $domain, array $letsEncryptData): void
{
2022-05-12 01:11:58 +12:00
// Prepare folder in storage for domain
$path = APP_STORAGE_CERTIFICATES . '/' . $domain;
2021-09-01 21:13:23 +12:00
if (!\is_readable($path)) {
2020-06-20 23:20:49 +12:00
if (!\mkdir($path, 0755, true)) {
2022-05-12 01:11:58 +12:00
throw new Exception('Failed to create path for certificate.');
2020-02-25 22:21:56 +13:00
}
}
2021-09-01 21:13:23 +12:00
2022-05-12 01:11:58 +12:00
// Move generated files from certbot into our storage
if (!@\rename('/etc/letsencrypt/live/' . $domain . '/cert.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/cert.pem')) {
2022-05-13 00:14:59 +12:00
throw new Exception('Failed to rename certificate cert.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']);
2020-02-24 06:05:42 +13:00
}
2020-02-23 21:58:11 +13:00
2022-05-12 01:11:58 +12:00
if (!@\rename('/etc/letsencrypt/live/' . $domain . '/chain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/chain.pem')) {
2022-05-13 00:14:59 +12:00
throw new Exception('Failed to rename certificate chain.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']);
2020-02-25 06:02:23 +13:00
}
2020-02-25 05:47:45 +13:00
2022-05-12 01:11:58 +12:00
if (!@\rename('/etc/letsencrypt/live/' . $domain . '/fullchain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/fullchain.pem')) {
2022-05-13 00:14:59 +12:00
throw new Exception('Failed to rename certificate fullchain.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']);
2020-02-25 06:02:23 +13:00
}
2020-02-25 05:47:45 +13:00
2022-05-12 01:11:58 +12:00
if (!@\rename('/etc/letsencrypt/live/' . $domain . '/privkey.pem', APP_STORAGE_CERTIFICATES . '/' . $domain . '/privkey.pem')) {
2022-05-13 00:14:59 +12:00
throw new Exception('Failed to rename certificate privkey.pem. Let\'s Encrypt log: ' . $letsEncryptData['stderr'] . ' ; ' . $letsEncryptData['stdout']);
2020-02-23 21:58:11 +13:00
}
2021-09-01 21:13:23 +12:00
2022-05-12 23:50:56 +12:00
$config = \implode(PHP_EOL, [
2022-05-12 23:40:32 +12:00
"tls:",
" certificates:",
" - certFile: /storage/certificates/{$domain}/fullchain.pem",
" keyFile: /storage/certificates/{$domain}/privkey.pem"
]);
2022-05-12 01:11:58 +12:00
// 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.');
2020-02-25 23:04:12 +13:00
}
2020-02-23 21:58:11 +13:00
}
2022-05-12 01:11:58 +12:00
/**
* 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
{
2022-05-12 01:11:58 +12:00
// Log error into console
Console::warning('Cannot renew domain (' . $domain . ') on attempt no. ' . $attempt . ' certificate: ' . $errorMessage);
// Send mail to administratore mail
Resque::enqueue(Event::MAILS_QUEUE_NAME, Event::MAILS_CLASS_NAME, [
'from' => 'console',
'project' => 'console',
'name' => 'Appwrite Administrator',
'recipient' => App::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS'),
'url' => 'https://' . $domain,
'locale' => App::getEnv('_APP_LOCALE', 'en'),
'type' => MAIL_TYPE_CERTIFICATE,
'domain' => $domain,
'error' => $errorMessage,
'attempt' => $attempt
]);
2020-02-23 21:58:11 +13:00
}
2022-05-12 01:11:58 +12:00
/**
* 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
2020-02-23 21:58:11 +13:00
{
2022-05-12 01:11:58 +12:00
$domains = $this->dbForConsole->find('domains', [
new Query('domain', Query::TYPE_EQUAL, [$domain])
], 1000);
foreach ($domains as $domainDocument) {
$domainDocument->setAttribute('updated', \time());
$domainDocument->setAttribute('certificateId', $certificateId);
$this->dbForConsole->updateDocument('domains', $domainDocument->getId(), $domainDocument);
2022-05-12 22:38:48 +12:00
if ($domainDocument->getAttribute('projectId')) {
2022-05-12 22:38:48 +12:00
$this->dbForConsole->deleteCachedDocument('projects', $domainDocument->getAttribute('projectId'));
}
}
2020-02-23 21:58:11 +13:00
}
2021-09-01 21:13:23 +12:00
}