mirror of
https://github.com/appwrite/appwrite.git
synced 2026-05-26 13:51:13 +00:00
Refactored SSL genration
This commit is contained in:
@@ -5,6 +5,9 @@ require_once __DIR__.'/controllers/general.php';
|
||||
use Utopia\App;
|
||||
use Utopia\CLI\CLI;
|
||||
use Utopia\CLI\Console;
|
||||
use Utopia\Database\Validator\Authorization;
|
||||
|
||||
Authorization::disable();
|
||||
|
||||
$cli = new CLI();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
use Appwrite\Auth\Auth;
|
||||
use Appwrite\Auth\Validator\Password;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Network\Validator\CNAME;
|
||||
use Appwrite\Network\Validator\Domain as DomainValidator;
|
||||
use Appwrite\Network\Validator\Origin;
|
||||
@@ -1389,8 +1390,7 @@ App::patch('/v1/projects/:projectId/domains/:domainId/verification')
|
||||
$dbForConsole->deleteCachedDocument('projects', $project->getId());
|
||||
|
||||
// Issue a TLS certificate when domain is verified
|
||||
Resque::enqueue('v1-certificates', 'CertificatesV1', [
|
||||
'document' => $domain->getArrayCopy(),
|
||||
Resque::enqueue(Event::CERTIFICATES_QUEUE_NAME, Event::CERTIFICATES_CLASS_NAME, [
|
||||
'domain' => $domain->getAttribute('domain'),
|
||||
]);
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ use Appwrite\Extend\Exception;
|
||||
use Utopia\Config\Config;
|
||||
use Utopia\Domains\Domain;
|
||||
use Appwrite\Auth\Auth;
|
||||
use Appwrite\Event\Event;
|
||||
use Appwrite\Network\Validator\Origin;
|
||||
use Appwrite\Utopia\Response\Filters\V11 as ResponseV11;
|
||||
use Appwrite\Utopia\Response\Filters\V12 as ResponseV12;
|
||||
@@ -90,11 +91,8 @@ App::init(function ($utopia, $request, $response, $console, $project, $dbForCons
|
||||
|
||||
Console::info('Issuing a TLS certificate for the master domain (' . $domain->get() . ') in a few seconds...');
|
||||
|
||||
Resque::enqueue('v1-certificates', 'CertificatesV1', [
|
||||
'document' => $domainDocument,
|
||||
'domain' => $domain->get(),
|
||||
'validateTarget' => false,
|
||||
'validateCNAME' => false,
|
||||
Resque::enqueue(Event::CERTIFICATES_QUEUE_NAME, Event::CERTIFICATES_CLASS_NAME, [
|
||||
'domain' => $domain->get()
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,58 @@
|
||||
<?php
|
||||
|
||||
global $cli;
|
||||
global $register;
|
||||
|
||||
use Appwrite\Event\Event;
|
||||
use Utopia\App;
|
||||
use Utopia\Cache\Cache;
|
||||
use Utopia\CLI\Console;
|
||||
use Utopia\Database\Adapter\MariaDB;
|
||||
use Utopia\Database\Database;
|
||||
use Utopia\Registry\Registry;
|
||||
use Utopia\Cache\Adapter\Redis as RedisCache;
|
||||
use Utopia\Database\Query;
|
||||
|
||||
function getDatabase(Registry &$register)
|
||||
{
|
||||
$attempts = 0;
|
||||
|
||||
do {
|
||||
try {
|
||||
$attempts++;
|
||||
|
||||
$db = $register->get('dbPool')->get();
|
||||
$redis = $register->get('redisPool')->get();
|
||||
|
||||
$cache = new Cache(new RedisCache($redis));
|
||||
$database = new Database(new MariaDB($db), $cache);
|
||||
$database->setDefaultDatabase(App::getEnv('_APP_DB_SCHEMA', 'appwrite'));
|
||||
$database->setNamespace('_console');
|
||||
|
||||
break; // leave loop if successful
|
||||
} catch(\Exception $e) {
|
||||
Console::warning("Database not ready. Retrying connection ({$attempts})...");
|
||||
if ($attempts >= DATABASE_RECONNECT_MAX_ATTEMPTS) {
|
||||
throw new \Exception('Failed to connect to database: '. $e->getMessage());
|
||||
}
|
||||
sleep(DATABASE_RECONNECT_SLEEP);
|
||||
}
|
||||
} while ($attempts < DATABASE_RECONNECT_MAX_ATTEMPTS);
|
||||
|
||||
return [
|
||||
$database,
|
||||
function () use ($register, $db, $redis) {
|
||||
$register->get('dbPool')->put($db);
|
||||
$register->get('redisPool')->put($redis);
|
||||
}
|
||||
];
|
||||
|
||||
};
|
||||
|
||||
$cli
|
||||
->task('maintenance')
|
||||
->desc('Schedules maintenance tasks and publishes them to resque')
|
||||
->action(function () {
|
||||
->action(function () use ($register) {
|
||||
Console::title('Maintenance V1');
|
||||
Console::success(APP_NAME.' maintenance process v1 has started');
|
||||
|
||||
@@ -54,6 +97,29 @@ $cli
|
||||
]);
|
||||
}
|
||||
|
||||
function renewCertificates($dbForConsole)
|
||||
{
|
||||
$time = date('d-m-Y H:i:s', time());
|
||||
/** @var Utopia\Database\Database $dbForConsole */
|
||||
|
||||
$certificates = $dbForConsole->find('certificates', [
|
||||
new Query('attempts', Query::TYPE_LESSER, [5]), // Maximum 5 attempts
|
||||
new Query('renewDate', Query::TYPE_LESSEREQUAL, [\time()]) // includes 60 days cooldown (we have 30 days to renew)
|
||||
], 300); // Limit 300 comes from LetsEncrypt (orders per 3 hours)
|
||||
|
||||
if(\count($certificates) > 0) {
|
||||
Console::info("[{$time}] Found " . \count($certificates) . " certificates for renewal, scheduling jobs.");
|
||||
|
||||
foreach ($certificates as $certificate) {
|
||||
Resque::enqueue(Event::CERTIFICATES_QUEUE_NAME, Event::CERTIFICATES_CLASS_NAME, [
|
||||
'domain' => $certificate->getAttribute('domain'),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
Console::info("[{$time}] No certificates for renewal.");
|
||||
}
|
||||
}
|
||||
|
||||
// # of days in seconds (1 day = 86400s)
|
||||
$interval = (int) App::getEnv('_APP_MAINTENANCE_INTERVAL', '86400');
|
||||
$executionLogsRetention = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_EXECUTION', '1209600');
|
||||
@@ -62,13 +128,25 @@ $cli
|
||||
$usageStatsRetention30m = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_USAGE_30M', '129600');//36 hours
|
||||
$usageStatsRetention1d = (int) App::getEnv('_APP_MAINTENANCE_RETENTION_USAGE_1D', '8640000'); // 100 days
|
||||
|
||||
Console::loop(function() use ($interval, $executionLogsRetention, $abuseLogsRetention, $auditLogRetention, $usageStatsRetention30m, $usageStatsRetention1d) {
|
||||
$time = date('d-m-Y H:i:s', time());
|
||||
Console::info("[{$time}] Notifying deletes workers every {$interval} seconds");
|
||||
notifyDeleteExecutionLogs($executionLogsRetention);
|
||||
notifyDeleteAbuseLogs($abuseLogsRetention);
|
||||
notifyDeleteAuditLogs($auditLogRetention);
|
||||
notifyDeleteUsageStats($usageStatsRetention30m, $usageStatsRetention1d);
|
||||
notifyDeleteConnections();
|
||||
Console::loop(function() use ($register, $interval, $executionLogsRetention, $abuseLogsRetention, $auditLogRetention, $usageStatsRetention30m, $usageStatsRetention1d) {
|
||||
go(function () use ($register, $interval, $executionLogsRetention, $abuseLogsRetention, $auditLogRetention, $usageStatsRetention30m, $usageStatsRetention1d) {
|
||||
try {
|
||||
[$database, $returnDatabase] = getDatabase($register, '_console');
|
||||
|
||||
$time = date('d-m-Y H:i:s', time());
|
||||
Console::info("[{$time}] Notifying deletes workers every {$interval} seconds");
|
||||
notifyDeleteExecutionLogs($executionLogsRetention);
|
||||
notifyDeleteAbuseLogs($abuseLogsRetention);
|
||||
notifyDeleteAuditLogs($auditLogRetention);
|
||||
notifyDeleteUsageStats($usageStatsRetention30m, $usageStatsRetention1d);
|
||||
notifyDeleteConnections();
|
||||
|
||||
renewCertificates($database);
|
||||
} catch (\Throwable $th) {
|
||||
throw $th;
|
||||
} finally {
|
||||
call_user_func($returnDatabase);
|
||||
}
|
||||
});
|
||||
}, $interval);
|
||||
});
|
||||
+7
-5
@@ -11,13 +11,15 @@ $cli
|
||||
->action(function () {
|
||||
$domain = App::getEnv('_APP_DOMAIN', '');
|
||||
|
||||
Console::log('Issue a TLS certificate for master domain ('.$domain.') in 30 seconds.
|
||||
// TODO: Instead of waiting, let's ping Traefik. If responds, we can schedule instantly
|
||||
// TODO: Add support for argument (domain)
|
||||
|
||||
Console::log('Issue a TLS certificate for master domain ('.$domain.') in 2 seconds.
|
||||
Make sure your domain points to your server or restart to try again.');
|
||||
|
||||
ResqueScheduler::enqueueAt(\time() + 30, 'v1-certificates', 'CertificatesV1', [
|
||||
'document' => [],
|
||||
// Const for types not available here
|
||||
ResqueScheduler::enqueueAt(\time() + 2, 'v1-certificates', 'CertificatesV1', [
|
||||
'domain' => $domain,
|
||||
'validateTarget' => false,
|
||||
'validateCNAME' => false,
|
||||
'skipRenewCheck' => true // TODO: Discuss this behabiour. true? false? parameter? How do we document it?
|
||||
]);
|
||||
});
|
||||
@@ -449,6 +449,9 @@ services:
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_OPENSSL_KEY_V1
|
||||
- _APP_DOMAIN
|
||||
- _APP_DOMAIN_TARGET
|
||||
- _APP_SYSTEM_SECURITY_EMAIL_ADDRESS
|
||||
- _APP_REDIS_HOST
|
||||
- _APP_REDIS_PORT
|
||||
- _APP_REDIS_USER
|
||||
|
||||
+204
-147
@@ -1,7 +1,9 @@
|
||||
<?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;
|
||||
@@ -26,169 +28,224 @@ class CertificatesV1 extends Worker
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$dbForConsole = $this->getConsoleDB();
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
|
||||
Authorization::disable();
|
||||
|
||||
// Args
|
||||
$document = $this->args['document'];
|
||||
$domain = $this->args['domain'];
|
||||
$dbForConsole = $this->getConsoleDB();
|
||||
|
||||
// Validation Args
|
||||
$validateTarget = $this->args['validateTarget'] ?? true;
|
||||
$validateCNAME = $this->args['validateCNAME'] ?? true;
|
||||
$certificate = new Document();
|
||||
|
||||
// Options
|
||||
$domain = new Domain((!empty($domain)) ? $domain : '');
|
||||
$expiry = 60 * 60 * 24 * 30 * 2; // 60 days
|
||||
$safety = 60 * 60; // 1 hour
|
||||
$renew = (\time() + $expiry);
|
||||
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 : '');
|
||||
|
||||
if (empty($domain->get())) {
|
||||
throw new Exception('Missing domain');
|
||||
}
|
||||
|
||||
if (!$domain->isKnown() || $domain->isTest()) {
|
||||
throw new Exception('Unknown public suffix for domain');
|
||||
}
|
||||
|
||||
if ($validateTarget) {
|
||||
$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.');
|
||||
$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 ($validateCNAME) {
|
||||
$validator = new CNAME($target->get()); // Verify Domain with DNS records
|
||||
|
||||
if(!$validator->isValid($domain->get())) {
|
||||
throw new Exception('Failed to verify domain DNS records');
|
||||
|
||||
// If not main domain, we will check CNAME record
|
||||
$validateCNAME = false;
|
||||
if ($domain->get() !== $mainDomain) {
|
||||
$validateCNAME = true;
|
||||
}
|
||||
}
|
||||
|
||||
$certificate = $dbForConsole->findOne('certificates', [
|
||||
new Query('domain', QUERY::TYPE_EQUAL, [$domain->get()])
|
||||
]);
|
||||
|
||||
// $condition = ($certificate
|
||||
// && $certificate instanceof Document
|
||||
// && isset($certificate['issueDate'])
|
||||
// && (($certificate['issueDate'] + ($expiry)) > time())) ? 'true' : 'false';
|
||||
|
||||
// throw new Exception('cert issued at'.date('d.m.Y H:i', $certificate['issueDate']).' | renew date is: '.date('d.m.Y H:i', ($certificate['issueDate'] + ($expiry))).' | condition is '.$condition);
|
||||
|
||||
$certificate = (!empty($certificate) && $certificate instanceof $certificate) ? $certificate->getArrayCopy() : [];
|
||||
|
||||
if (
|
||||
!empty($certificate)
|
||||
&& isset($certificate['issueDate'])
|
||||
&& (($certificate['issueDate'] + ($expiry)) > \time())
|
||||
) { // Check last issue time
|
||||
throw new Exception('Renew isn\'t required');
|
||||
}
|
||||
|
||||
$staging = (App::isProduction()) ? '' : ' --dry-run';
|
||||
$email = App::getEnv('_APP_SYSTEM_SECURITY_EMAIL_ADDRESS');
|
||||
|
||||
if (empty($email)) {
|
||||
throw new Exception('You must set a valid security email address (_APP_SYSTEM_SECURITY_EMAIL_ADDRESS) to issue an SSL certificate');
|
||||
}
|
||||
|
||||
$stdout = '';
|
||||
$stderr = '';
|
||||
|
||||
$exit = Console::execute("certbot certonly --webroot --noninteractive --agree-tos{$staging}"
|
||||
. " --email " . $email
|
||||
. " -w " . APP_STORAGE_CERTIFICATES
|
||||
. " -d {$domain->get()}", '', $stdout, $stderr);
|
||||
|
||||
if ($exit !== 0) {
|
||||
throw new Exception('Failed to issue a certificate with message: ' . $stderr);
|
||||
}
|
||||
|
||||
$path = APP_STORAGE_CERTIFICATES . '/' . $domain->get();
|
||||
|
||||
if (!\is_readable($path)) {
|
||||
if (!\mkdir($path, 0755, true)) {
|
||||
throw new Exception('Failed to create path...');
|
||||
|
||||
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(!@\rename('/etc/letsencrypt/live/'.$domain->get().'/cert.pem', APP_STORAGE_CERTIFICATES.'/'.$domain->get().'/cert.pem')) {
|
||||
throw new Exception('Failed to rename certificate cert.pem: '.\json_encode($stdout));
|
||||
}
|
||||
// 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;
|
||||
|
||||
if (!@\rename('/etc/letsencrypt/live/' . $domain->get() . '/chain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/chain.pem')) {
|
||||
throw new Exception('Failed to rename certificate chain.pem: ' . \json_encode($stdout));
|
||||
}
|
||||
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).');
|
||||
}
|
||||
|
||||
if (!@\rename('/etc/letsencrypt/live/' . $domain->get() . '/fullchain.pem', APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/fullchain.pem')) {
|
||||
throw new Exception('Failed to rename certificate fullchain.pem: ' . \json_encode($stdout));
|
||||
}
|
||||
// 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.');
|
||||
}
|
||||
|
||||
if (!@\rename('/etc/letsencrypt/live/' . $domain->get() . '/privkey.pem', APP_STORAGE_CERTIFICATES . '/' . $domain->get() . '/privkey.pem')) {
|
||||
throw new Exception('Failed to rename certificate privkey.pem: ' . \json_encode($stdout));
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
|
||||
$certificate = new Document(\array_merge($certificate, [
|
||||
'domain' => $domain->get(),
|
||||
'issueDate' => \time(),
|
||||
'renewDate' => $renew,
|
||||
'attempts' => 0,
|
||||
'log' => \json_encode($stdout),
|
||||
]));
|
||||
|
||||
$certificate = $dbForConsole->createDocument('certificates', $certificate);
|
||||
|
||||
if (!$certificate) {
|
||||
throw new Exception('Failed saving certificate to DB');
|
||||
}
|
||||
|
||||
if(!empty($document)) {
|
||||
$certificate = new Document(\array_merge($document, [
|
||||
'updated' => \time(),
|
||||
'certificateId' => $certificate->getId(),
|
||||
// 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,
|
||||
]));
|
||||
|
||||
$certificate = $dbForConsole->updateDocument('domains', $certificate->getId(), $certificate);
|
||||
|
||||
if(!$certificate) {
|
||||
throw new Exception('Failed saving domain to DB');
|
||||
|
||||
// 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();
|
||||
}
|
||||
|
||||
$config =
|
||||
"tls:
|
||||
certificates:
|
||||
- certFile: /storage/certificates/{$domain->get()}/fullchain.pem
|
||||
keyFile: /storage/certificates/{$domain->get()}/privkey.pem";
|
||||
|
||||
if (!\file_put_contents(APP_STORAGE_CONFIG . '/' . $domain->get() . '.yml', $config)) {
|
||||
throw new Exception('Failed to save SSL configuration');
|
||||
}
|
||||
|
||||
ResqueScheduler::enqueueAt($renew + $safety, 'v1-certificates', 'CertificatesV1', [
|
||||
'document' => [],
|
||||
'domain' => $domain->get(),
|
||||
'validateTarget' => $validateTarget,
|
||||
'validateCNAME' => $validateCNAME,
|
||||
]); // Async task rescheduale
|
||||
|
||||
Authorization::reset();
|
||||
}
|
||||
|
||||
public function shutdown(): void
|
||||
|
||||
Generated
+59
-56
@@ -300,20 +300,23 @@
|
||||
},
|
||||
{
|
||||
"name": "colinmollenhour/credis",
|
||||
"version": "v1.12.1",
|
||||
"version": "v1.13.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/colinmollenhour/credis.git",
|
||||
"reference": "c27faa11724229986335c23f4b6d0f1d8d6547fb"
|
||||
"reference": "afec8e58ec93d2291c127fa19709a048f28641e5"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/colinmollenhour/credis/zipball/c27faa11724229986335c23f4b6d0f1d8d6547fb",
|
||||
"reference": "c27faa11724229986335c23f4b6d0f1d8d6547fb",
|
||||
"url": "https://api.github.com/repos/colinmollenhour/credis/zipball/afec8e58ec93d2291c127fa19709a048f28641e5",
|
||||
"reference": "afec8e58ec93d2291c127fa19709a048f28641e5",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.4.0"
|
||||
"php": ">=5.6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-redis": "Improved performance for communicating with redis"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
@@ -338,9 +341,9 @@
|
||||
"homepage": "https://github.com/colinmollenhour/credis",
|
||||
"support": {
|
||||
"issues": "https://github.com/colinmollenhour/credis/issues",
|
||||
"source": "https://github.com/colinmollenhour/credis/tree/v1.12.1"
|
||||
"source": "https://github.com/colinmollenhour/credis/tree/v1.13.0"
|
||||
},
|
||||
"time": "2020-11-06T16:09:14+00:00"
|
||||
"time": "2022-04-07T14:57:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/package-versions-deprecated",
|
||||
@@ -1580,16 +1583,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/deprecation-contracts",
|
||||
"version": "v3.0.0",
|
||||
"version": "v3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/deprecation-contracts.git",
|
||||
"reference": "c726b64c1ccfe2896cb7df2e1331c357ad1c8ced"
|
||||
"reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/c726b64c1ccfe2896cb7df2e1331c357ad1c8ced",
|
||||
"reference": "c726b64c1ccfe2896cb7df2e1331c357ad1c8ced",
|
||||
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/26954b3d62a6c5fd0ea8a2a00c0353a14978d05c",
|
||||
"reference": "26954b3d62a6c5fd0ea8a2a00c0353a14978d05c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1627,7 +1630,7 @@
|
||||
"description": "A generic function and convention to trigger deprecation notices",
|
||||
"homepage": "https://symfony.com",
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.0"
|
||||
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -1643,7 +1646,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2021-11-01T23:48:49+00:00"
|
||||
"time": "2022-01-02T09:55:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-ctype",
|
||||
@@ -3192,16 +3195,16 @@
|
||||
},
|
||||
{
|
||||
"name": "composer/semver",
|
||||
"version": "3.3.1",
|
||||
"version": "3.3.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/semver.git",
|
||||
"reference": "5d8e574bb0e69188786b8ef77d43341222a41a71"
|
||||
"reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/semver/zipball/5d8e574bb0e69188786b8ef77d43341222a41a71",
|
||||
"reference": "5d8e574bb0e69188786b8ef77d43341222a41a71",
|
||||
"url": "https://api.github.com/repos/composer/semver/zipball/3953f23262f2bff1919fc82183ad9acb13ff62c9",
|
||||
"reference": "3953f23262f2bff1919fc82183ad9acb13ff62c9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3253,7 +3256,7 @@
|
||||
"support": {
|
||||
"irc": "irc://irc.freenode.org/composer",
|
||||
"issues": "https://github.com/composer/semver/issues",
|
||||
"source": "https://github.com/composer/semver/tree/3.3.1"
|
||||
"source": "https://github.com/composer/semver/tree/3.3.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -3269,7 +3272,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2022-03-16T11:22:07+00:00"
|
||||
"time": "2022-04-01T19:23:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/xdebug-handler",
|
||||
@@ -3491,16 +3494,16 @@
|
||||
},
|
||||
{
|
||||
"name": "felixfbecker/language-server-protocol",
|
||||
"version": "1.5.1",
|
||||
"version": "v1.5.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/felixfbecker/php-language-server-protocol.git",
|
||||
"reference": "9d846d1f5cf101deee7a61c8ba7caa0a975cd730"
|
||||
"reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/9d846d1f5cf101deee7a61c8ba7caa0a975cd730",
|
||||
"reference": "9d846d1f5cf101deee7a61c8ba7caa0a975cd730",
|
||||
"url": "https://api.github.com/repos/felixfbecker/php-language-server-protocol/zipball/6e82196ffd7c62f7794d778ca52b69feec9f2842",
|
||||
"reference": "6e82196ffd7c62f7794d778ca52b69feec9f2842",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -3541,9 +3544,9 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/felixfbecker/php-language-server-protocol/issues",
|
||||
"source": "https://github.com/felixfbecker/php-language-server-protocol/tree/1.5.1"
|
||||
"source": "https://github.com/felixfbecker/php-language-server-protocol/tree/v1.5.2"
|
||||
},
|
||||
"time": "2021-02-22T14:02:09+00:00"
|
||||
"time": "2022-03-02T22:36:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "matthiasmullie/minify",
|
||||
@@ -4118,16 +4121,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/type-resolver",
|
||||
"version": "1.6.0",
|
||||
"version": "1.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpDocumentor/TypeResolver.git",
|
||||
"reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706"
|
||||
"reference": "77a32518733312af16a44300404e945338981de3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/93ebd0014cab80c4ea9f5e297ea48672f1b87706",
|
||||
"reference": "93ebd0014cab80c4ea9f5e297ea48672f1b87706",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/77a32518733312af16a44300404e945338981de3",
|
||||
"reference": "77a32518733312af16a44300404e945338981de3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -4162,9 +4165,9 @@
|
||||
"description": "A PSR-5 based resolver of Class names, Types and Structural Element Names",
|
||||
"support": {
|
||||
"issues": "https://github.com/phpDocumentor/TypeResolver/issues",
|
||||
"source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.0"
|
||||
"source": "https://github.com/phpDocumentor/TypeResolver/tree/1.6.1"
|
||||
},
|
||||
"time": "2022-01-04T19:58:01+00:00"
|
||||
"time": "2022-03-15T21:29:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpspec/prophecy",
|
||||
@@ -5073,16 +5076,16 @@
|
||||
},
|
||||
{
|
||||
"name": "sebastian/environment",
|
||||
"version": "5.1.3",
|
||||
"version": "5.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/environment.git",
|
||||
"reference": "388b6ced16caa751030f6a69e588299fa09200ac"
|
||||
"reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/388b6ced16caa751030f6a69e588299fa09200ac",
|
||||
"reference": "388b6ced16caa751030f6a69e588299fa09200ac",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/1b5dff7bb151a4db11d49d90e5408e4e938270f7",
|
||||
"reference": "1b5dff7bb151a4db11d49d90e5408e4e938270f7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5124,7 +5127,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/sebastianbergmann/environment/issues",
|
||||
"source": "https://github.com/sebastianbergmann/environment/tree/5.1.3"
|
||||
"source": "https://github.com/sebastianbergmann/environment/tree/5.1.4"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5132,7 +5135,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2020-09-28T05:52:38+00:00"
|
||||
"time": "2022-04-03T09:37:03+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/exporter",
|
||||
@@ -5715,16 +5718,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/console",
|
||||
"version": "v6.0.5",
|
||||
"version": "v6.0.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/console.git",
|
||||
"reference": "3bebf4108b9e07492a2a4057d207aa5a77d146b1"
|
||||
"reference": "70dcf7b2ca2ea08ad6ebcc475f104a024fb5632e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/3bebf4108b9e07492a2a4057d207aa5a77d146b1",
|
||||
"reference": "3bebf4108b9e07492a2a4057d207aa5a77d146b1",
|
||||
"url": "https://api.github.com/repos/symfony/console/zipball/70dcf7b2ca2ea08ad6ebcc475f104a024fb5632e",
|
||||
"reference": "70dcf7b2ca2ea08ad6ebcc475f104a024fb5632e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -5790,7 +5793,7 @@
|
||||
"terminal"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/console/tree/v6.0.5"
|
||||
"source": "https://github.com/symfony/console/tree/v6.0.7"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -5806,7 +5809,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2022-02-25T10:48:52+00:00"
|
||||
"time": "2022-03-31T17:18:25+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/polyfill-intl-grapheme",
|
||||
@@ -6058,16 +6061,16 @@
|
||||
},
|
||||
{
|
||||
"name": "symfony/service-contracts",
|
||||
"version": "v3.0.0",
|
||||
"version": "v3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/service-contracts.git",
|
||||
"reference": "36715ebf9fb9db73db0cb24263c79077c6fe8603"
|
||||
"reference": "e517458f278c2131ca9f262f8fbaf01410f2c65c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/36715ebf9fb9db73db0cb24263c79077c6fe8603",
|
||||
"reference": "36715ebf9fb9db73db0cb24263c79077c6fe8603",
|
||||
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/e517458f278c2131ca9f262f8fbaf01410f2c65c",
|
||||
"reference": "e517458f278c2131ca9f262f8fbaf01410f2c65c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6120,7 +6123,7 @@
|
||||
"standards"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/symfony/service-contracts/tree/v3.0.0"
|
||||
"source": "https://github.com/symfony/service-contracts/tree/v3.0.1"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6136,7 +6139,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2021-11-04T17:53:12+00:00"
|
||||
"time": "2022-03-13T20:10:05+00:00"
|
||||
},
|
||||
{
|
||||
"name": "symfony/string",
|
||||
@@ -6324,16 +6327,16 @@
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.3.8",
|
||||
"version": "v3.3.10",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/Twig.git",
|
||||
"reference": "972d8604a92b7054828b539f2febb0211dd5945c"
|
||||
"reference": "8442df056c51b706793adf80a9fd363406dd3674"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/972d8604a92b7054828b539f2febb0211dd5945c",
|
||||
"reference": "972d8604a92b7054828b539f2febb0211dd5945c",
|
||||
"url": "https://api.github.com/repos/twigphp/Twig/zipball/8442df056c51b706793adf80a9fd363406dd3674",
|
||||
"reference": "8442df056c51b706793adf80a9fd363406dd3674",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -6384,7 +6387,7 @@
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/twigphp/Twig/issues",
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.3.8"
|
||||
"source": "https://github.com/twigphp/Twig/tree/v3.3.10"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -6396,7 +6399,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2022-02-04T06:59:48+00:00"
|
||||
"time": "2022-04-06T06:47:41+00:00"
|
||||
},
|
||||
{
|
||||
"name": "vimeo/psalm",
|
||||
@@ -6580,5 +6583,5 @@
|
||||
"platform-overrides": {
|
||||
"php": "8.0"
|
||||
},
|
||||
"plugin-api-version": "2.2.0"
|
||||
"plugin-api-version": "2.3.0"
|
||||
}
|
||||
|
||||
@@ -370,6 +370,7 @@ services:
|
||||
environment:
|
||||
- _APP_ENV
|
||||
- _APP_OPENSSL_KEY_V1
|
||||
- _APP_DOMAIN
|
||||
- _APP_DOMAIN_TARGET
|
||||
- _APP_SYSTEM_SECURITY_EMAIL_ADDRESS
|
||||
- _APP_REDIS_HOST
|
||||
@@ -519,6 +520,11 @@ services:
|
||||
- _APP_REDIS_PORT
|
||||
- _APP_REDIS_USER
|
||||
- _APP_REDIS_PASS
|
||||
- _APP_DB_HOST
|
||||
- _APP_DB_PORT
|
||||
- _APP_DB_SCHEMA
|
||||
- _APP_DB_USER
|
||||
- _APP_DB_PASS
|
||||
- _APP_MAINTENANCE_INTERVAL
|
||||
- _APP_MAINTENANCE_RETENTION_EXECUTION
|
||||
- _APP_MAINTENANCE_RETENTION_ABUSE
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Appwrite\Exception;
|
||||
|
||||
class Certificate extends \Exception
|
||||
{
|
||||
private $incrementAttempts = false;
|
||||
|
||||
// Only set incrementAttempts to TRUE if exception occured AFTER certbot command execution
|
||||
public function __construct(string $message, bool $incrementAttempts = false)
|
||||
{
|
||||
$this->incrementAttempts = $incrementAttempts;
|
||||
|
||||
parent::__construct($message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value if attempts should be incremented.
|
||||
*
|
||||
* @return boolean
|
||||
*/
|
||||
public function getIncrementAttempts(): bool
|
||||
{
|
||||
return $this->incrementAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set if attempts should be incremented
|
||||
*
|
||||
* @param boolean $value
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function setIncrementAttempts(bool $value): void
|
||||
{
|
||||
$this->incrementAttempts = $value;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user