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 ;
2021-06-12 02:20:18 +12:00
use Appwrite\Resque\Worker ;
2022-04-10 21:38:22 +12:00
use Appwrite\Exception\Certificate as ExceptionCertificate ;
2021-06-12 02:20:18 +12:00
use Utopia\App ;
use Utopia\CLI\Console ;
2021-07-16 09:14:52 +12:00
use Utopia\Database\Document ;
use Utopia\Database\Query ;
use Utopia\Database\Validator\Authorization ;
2021-06-12 02:20:18 +12:00
use Utopia\Domains\Domain ;
2020-02-23 21:58:11 +13:00
2021-08-13 20:39:46 +12: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
2021-06-12 02:20:18 +12:00
class CertificatesV1 extends Worker
2020-02-23 21:58:11 +13:00
{
2021-11-24 22:38:32 +13:00
public function getName () : string {
2021-11-24 03:24:25 +13:00
return " certificates " ;
}
2021-06-12 02:20:18 +12:00
public function init () : void
2020-02-23 21:58:11 +13:00
{
}
2021-06-12 02:20:18 +12:00
public function run () : void
2020-02-23 21:58:11 +13:00
{
Authorization :: disable ();
2022-04-10 21:38:22 +12:00
$dbForConsole = $this -> getConsoleDB ();
2021-09-01 21:13:23 +12:00
2022-04-10 21:38:22 +12:00
$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 ();
2020-03-01 19:33:19 +13:00
}
2022-04-10 21:38:22 +12:00
// If not main domain, we will check CNAME record
$validateCNAME = false ;
if ( $domain -> get () !== $mainDomain ) {
$validateCNAME = true ;
2020-03-01 19:33:19 +13:00
}
2022-04-10 21:38:22 +12:00
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?
2020-02-25 22:21:56 +13:00
}
2020-02-23 21:58:11 +13:00
2022-04-10 21:38:22 +12:00
// 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.' );
}
2020-02-23 21:58:11 +13:00
2022-04-10 21:38:22 +12:00
// 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 );
}
2020-02-23 21:58:11 +13: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
// 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 ,
2021-07-20 04:46:34 +12:00
]));
2022-04-10 21:38:22 +12:00
// 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 );
}
2021-09-01 21:13:23 +12:00
2022-04-10 21:38:22 +12:00
// 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 );
2020-02-29 19:24:46 +13:00
}
2021-09-01 21:13:23 +12:00
2022-04-10 21:38:22 +12:00
// 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 );
}
2020-02-25 23:04:12 +13:00
2022-04-10 21:38:22 +12:00
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 );
}
2020-02-23 21:58:11 +13:00
2022-04-10 21:38:22 +12:00
// Update domains with new certificate ID
$certificateId = $certificate -> getId ();
// TODO: Add logic for updating domains
2020-02-29 19:24:46 +13:00
2022-04-10 21:38:22 +12:00
Authorization :: reset ();
}
2020-02-23 21:58:11 +13:00
}
2021-06-12 02:20:18 +12:00
public function shutdown () : void
2020-02-23 21:58:11 +13:00
{
}
2021-09-01 21:13:23 +12:00
}