1
0
Fork 0
mirror of synced 2024-06-29 11:40:45 +12:00
appwrite/app/workers/certificates.php
2022-04-10 09:38:22 +00:00

255 lines
11 KiB
PHP

<?php
use Appwrite\Event\Event;
use Appwrite\Network\Validator\CNAME;
use Appwrite\Resque\Worker;
use Appwrite\Exception\Certificate as ExceptionCertificate;
use Utopia\App;
use Utopia\CLI\Console;
use Utopia\Database\Document;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Domains\Domain;
require_once __DIR__.'/../init.php';
Console::title('Certificates V1 Worker');
Console::success(APP_NAME . ' certificates worker v1 has started');
class CertificatesV1 extends Worker
{
public function getName(): string {
return "certificates";
}
public function init(): void
{
}
public function run(): void
{
Authorization::disable();
$dbForConsole = $this->getConsoleDB();
$certificate = new Document();
try {
/**
* TODO: Update
* 1. Get new domain document - DONE
* 1.1. Validate domain is valid, public suffix is known and CNAME records are verified - DONE
* 2. Check if a certificate already exists - DONE
* 3. Check if certificate is about to expire, if not - skip it
* 3.1. Create / renew certificate
* 3.2. Update loadblancer
* 3.3. Update database (domains, change date, expiry)
* 3.4. Set retry on failure
* 3.5. Schedule to renew certificate in 60 days
*/
// Get attributes
$domain = $this->args['domain']; // String of domain (hostname)
$domain = new Domain((!empty($domain)) ? $domain : '');
$certificate->setAttribute('domain', $domain->get());
$skipRenewCheck = $this->args['skipRenewCheck'] ?? false; // If true, we won't double-check expiry from cert file
$mainDomain = null; // ENV or first ever visited domain
if (!empty(App::getEnv('_APP_DOMAIN', ''))) {
$mainDomain = App::getEnv('_APP_DOMAIN', '');
} else {
$domainDocument = $dbForConsole->findOne('domains', [], 0, ['_id'], ['ASC']);
$mainDomain = $domainDocument ? $domainDocument->getAttribute('domain') : $domain->get();
}
// If not main domain, we will check CNAME record
$validateCNAME = false;
if ($domain->get() !== $mainDomain) {
$validateCNAME = true;
}
if (empty($domain->get())) {
throw new ExceptionCertificate('Missing certificate domain.');
}
if (!$domain->isKnown() || $domain->isTest()) {
throw new ExceptionCertificate('Unknown public suffix for domain.');
}
if ($validateCNAME) {
// 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 ExceptionCertificate('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 ExceptionCertificate('Failed to verify domain DNS records.');
}
} else {
// Main domain validation
// TODO: Would be awesome to check A/AAAA record here. Maybe dry run?
}
// If certificate exists already, double-check expiry date
// If asked to skip, we won't
$certPath = APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/cert.pem';
if (!$skipRenewCheck && \file_exists($certPath)) {
$validTo = null;
try {
$certData = openssl_x509_parse(file_get_contents($certPath));
$validTo = $certData['validTo_time_t'];
if (empty($validTo)) {
throw new Exception('Invalid expiry date.');
}
} catch(\Throwable $th) {
throw new ExceptionCertificate('Unable to read certificate file (cert.pem).');
}
// LetsEncrypt allows renewal 30 days before expiry
$expiryInAdvance = (60*60*24*30);
if ($validTo - $expiryInAdvance > \time()) {
$validToVerbose = date('d-m-Y H:i:s', $validTo);
throw new ExceptionCertificate('Renew isn\'t required. Next renew at ' . $validToVerbose);
}
}
// Email for alerts is required by LetsEncrypt
$email = App::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS');
if (empty($email)) {
throw new ExceptionCertificate('You must set a valid security email address (_APP_SYSTEM_SECURITY_EMAIL_ADDRESS) to issue an SSL certificate.');
}
// LetsEncrypt communication to issue certificate (using certbot CLI)
$stdout = '';
$stderr = '';
$staging = (App::isProduction()) ? '' : ' --dry-run';
$exit = Console::execute("certbot certonly --webroot --noninteractive --agree-tos{$staging}"
. " --email " . $email
. " -w " . APP_STORAGE_CERTIFICATES
. " -d {$domain->get()}", '', $stdout, $stderr);
// All exceptions from now on will be marked to increment attempts count. This allows us to only limit attempts for domains that failed on LectEncrypt side.
// Such attempts count allows us to prevent API limit abuse with always failing domains
// Unexpected error, usually 5XX, API limits, ...
if ($exit !== 0) {
throw new ExceptionCertificate('Failed to issue a certificate with message: ' . $stderr, true);
}
// Command succeeded, store all data into document
// We store stderr too, because it may include warnings
// This is only stored if everytng below passes too. Otherwise, it will be overwritten by error message
$certificate->setAttribute('log', \json_encode([
'stdout' => $stdout,
'stderr' => $stderr,
]));
// Prepare folder in storage for domain
$path = APP_STORAGE_CERTIFICATES . '/' . $domain->get();
if (!\is_readable($path)) {
if (!\mkdir($path, 0755, true)) {
throw new ExceptionCertificate('Failed to create path for certificate.', true);
}
}
// Move generated files from certbot into our storage
if(!@\rename('/etc/letsencrypt/live/'.$domain->get().'/cert.pem', APP_STORAGE_CERTIFICATES.'/'.$domain->get().'/cert.pem')) {
throw new ExceptionCertificate('Failed to rename certificate cert.pem: '.\json_encode($stdout), true);
}
if (!@\rename('/etc/letsencrypt/live/' . $domain->get() . '/chain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/chain.pem')) {
throw new ExceptionCertificate('Failed to rename certificate chain.pem: ' . \json_encode($stdout), true);
}
if (!@\rename('/etc/letsencrypt/live/' . $domain->get() . '/fullchain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/fullchain.pem')) {
throw new ExceptionCertificate('Failed to rename certificate fullchain.pem: ' . \json_encode($stdout), true);
}
if (!@\rename('/etc/letsencrypt/live/' . $domain->get() . '/privkey.pem', APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/privkey.pem')) {
throw new ExceptionCertificate('Failed to rename certificate privkey.pem: ' . \json_encode($stdout), true);
}
// This multi-line syntax helps IDE
$config =
"tls:" .
" certificates:" .
" - certFile: /storage/certificates/{$domain->get()}/fullchain.pem" .
" keyFile: /storage/certificates/{$domain->get()}/privkey.pem";
// Save configuration into Traefik using our new cert files
if (!\file_put_contents(APP_STORAGE_CONFIG . '/' . $domain->get() . '.yml', $config)) {
throw new ExceptionCertificate('Failed to save Traefik configuration.', true);
}
// Read new renew date from cert file
// TODO: This might not be required, we could calculate it. But this feels safer
$certPath = APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/cert.pem';
$certData = openssl_x509_parse(file_get_contents($certPath));
$validTo = $certData['validTo_time_t'];
$expiryInAdvance = (60*60*24*30);
$certificate->setAttribute('renewDate', $validTo - $expiryInAdvance);
// All went well at this point 🥳
// Reset attempts count for next renwal
$certificate->setAttribute('attempts', 0);
// Mark issue date
$certificate->setAttribute('issueDate', \time());
} catch(ExceptionCertificate $e) {
// These exceptions are expected if renew shouldn't or can't happen
// Add exception as log into certificate
$certificate->setAttribute('log', $e->getMessage());
$attempt = $certificate->getAttribute('attempts', 0);
$attempt++;
// Save increased attempts count if requested by exception
if($e->getIncrementAttempts()) {
$certificate->setAttribute('attempts', $attempt);
}
Console::warning('Cannot renew domain (' . $domain->get() . ') on attempt no. ' . $attempt . ' certificate: ' . $e->getMessage());
} finally {
// All actions result in new updatedAt date
$certificate->setAttribute('updated', \time());
// Save certificate data into database
// Check if update or insert required
$certificateDocument = $dbForConsole->findOne('certificates', [ new Query('domain', Query::TYPE_EQUAL, [$domain->get()]) ]);
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);
}
// Update domains with new certificate ID
$certificateId = $certificate->getId();
// TODO: Add logic for updating domains
Authorization::reset();
}
}
public function shutdown(): void
{
}
}