diff --git a/install/tpl/server.ini.master b/install/tpl/server.ini.master index eb262623b504634c078c0771d72b11a94e27ee07..13615f651bd60315d647298d3a0cf8638d0b2744 100644 --- a/install/tpl/server.ini.master +++ b/install/tpl/server.ini.master @@ -142,6 +142,7 @@ vhost_proxy_protocol_enabled=n vhost_proxy_protocol_protocols=ipv4 vhost_proxy_protocol_http_port=880 vhost_proxy_protocol_https_port=8443 +le_signature_type=ECDSA le_auto_cleanup=y le_auto_cleanup_denylist=[server_name] diff --git a/interface/lib/classes/validate_domain.inc.php b/interface/lib/classes/validate_domain.inc.php index 3555135eae028efd7163769ff41c6e6808500da9..53d8856368234942c6fd22d26ee789d320c8d375 100644 --- a/interface/lib/classes/validate_domain.inc.php +++ b/interface/lib/classes/validate_domain.inc.php @@ -278,5 +278,30 @@ class validate_domain { return true; // admin may always add wildcard domain } + /** + * Validates that input is a comma separated list of domain globs. + */ + function domain_glob_list($field_name, $field_value, $validator) { + global $app; + $allowempty = $validator['allowempty'] ?: 'n'; + $exceptions = $validator['exceptions'] ?: []; + if (!$field_value) { + if ($allowempty == 'y') { + return ''; + } + return $this->get_error($validator['errmsg']); + } + $parts = explode(',', $field_value); + foreach ($parts as $part) { + $part = trim($part); + if (in_array($part, $exceptions, true)) { + continue; + } + if (!preg_match("/^[a-z0-9*._-]+$/i", $part) || !filter_var($part, FILTER_VALIDATE_DOMAIN)) { + return $this->get_error($validator['errmsg']); + } + } + return ''; + } } diff --git a/interface/web/admin/form/server_config.tform.php b/interface/web/admin/form/server_config.tform.php index 656880b7877e7386d9d4e0f19bcb68d893090843..1f641166a836c408427b809eb2fb8375e2d7995e 100644 --- a/interface/web/admin/form/server_config.tform.php +++ b/interface/web/admin/form/server_config.tform.php @@ -1633,6 +1633,12 @@ $form["tabs"]['web'] = array( 'width' => '40', 'maxlength' => '255' ), + 'le_signature_type' => array( + 'datatype' => 'VARCHAR', + 'formtype' => 'SELECT', + 'default' => 'ECDSA', + 'value' => array('RSA' => 'RSA (RSA encryption with SHA-256)', 'ECDSA' => 'ECDSA (Elliptic Curve Digital Signature Algorithm)') + ), 'le_auto_cleanup' => array( 'datatype' => 'VARCHAR', 'formtype' => 'CHECKBOX', @@ -1640,6 +1646,16 @@ $form["tabs"]['web'] = array( 'value' => array(0 => 'n', 1 => 'y') ), 'le_auto_cleanup_denylist' => array( + 'validators' => array( + array ( + 'type' => 'CUSTOM', + 'class' => 'validate_domain', + 'function' => 'domain_glob_list', + 'allowempty' => 'y', + 'exceptions' => array('[server_name]'), + 'errmsg'=> 'le_auto_cleanup_denylist_error_custom' + ), + ), 'datatype' => 'VARCHAR', 'formtype' => 'TEXT', 'default' => '[server_name]', diff --git a/interface/web/admin/lib/lang/en_server_config.lng b/interface/web/admin/lib/lang/en_server_config.lng index 15c0917c6765823d635ce4b29bfeb567f4a86c36..e33895369b86e03bd2984c11c39cc14871eb7db1 100644 --- a/interface/web/admin/lib/lang/en_server_config.lng +++ b/interface/web/admin/lib/lang/en_server_config.lng @@ -370,6 +370,8 @@ $wb['sysbackup_copies_txt'] = 'Number of ISPConfig backups'; $wb['sysbackup_copies_error_empty'] = 'Number of ISPConfig backups must not be empty'; $wb['sysbackup_copies_error_regex'] = 'Number of ISPConfig backups must be a number between 1 and 3'; $wb['sysbackup_copies_note_txt'] = '(0 = off)'; +$wb['le_signature_type_txt'] = 'Certificate signature type'; $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certificates'; -$wb['le_auto_cleanup_denylist_txt'] = 'Comma seperated list of domains that should never be purged.'; -$wb['le_auto_cleanup_denylist_note_txt'] = 'Placeholders:'; +$wb['le_auto_cleanup_denylist_txt'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged. <br>E.g. <code>mail.*, externally-managed.example.com</code> <br>Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/templates/server_config_web_edit.htm b/interface/web/admin/templates/server_config_web_edit.htm index 44fc534491d8fa7782871831dfdbc41665e18c4b..834d96269264f2199e8c51c0107817322476ea51 100644 --- a/interface/web/admin/templates/server_config_web_edit.htm +++ b/interface/web/admin/templates/server_config_web_edit.htm @@ -248,6 +248,13 @@ <label class="col-sm-3 control-label"><tmpl_var name="skip_le_check_txt"></label> <div class="col-sm-9"><tmpl_var name="skip_le_check"></div> </div> + + <div class="form-group"> + <label class="col-sm-3 control-label">{tmpl_var name='le_signature_type_txt'}</label> + <div class="col-sm-9"><select name="le_signature_type" id="le_signature_type" class="form-control"> + {tmpl_var name='le_signature_type'} + </select></div> + </div> <div class="form-group"> <label class="col-sm-3 control-label"><tmpl_var name="le_auto_cleanup_txt"></label> <div class="col-sm-9"><tmpl_var name="le_auto_cleanup"></div> @@ -255,7 +262,7 @@ <div class="form-group"> <label for="le_auto_cleanup_denylist" class="col-sm-3 control-label">{tmpl_var name='le_auto_cleanup_denylist_txt'}</label> <div class="col-sm-9"> - <input type="text" name="CA_path" id="le_auto_cleanup_denylist" value="{tmpl_var name='le_auto_cleanup_denylist'}" class="form-control" /> + <input type="text" name="le_auto_cleanup_denylist" id="le_auto_cleanup_denylist" value="{tmpl_var name='le_auto_cleanup_denylist'}" class="form-control" /> <br>{tmpl_var name='le_auto_cleanup_denylist_note_txt'} <a href="javascript:void(0);" class="addPlaceholder">[server_name]</a> </div> </div> diff --git a/server/cli/modules/letsencrypt.inc.php b/server/cli/modules/letsencrypt.inc.php index a674d513fc51af3a1186831ffa4ccbc1e97ab45c..6557f250c44d79a08d28893894f3aadc4279d7c7 100644 --- a/server/cli/modules/letsencrypt.inc.php +++ b/server/cli/modules/letsencrypt.inc.php @@ -28,68 +28,211 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -class letsencrypt_cli extends cli -{ - - function __construct() - { - $cmd_opt = []; - $cmd_opt['letsencrypt'] = 'showHelp'; - $cmd_opt['letsencrypt:list'] = 'list'; +class letsencrypt_cli extends cli { + + function __construct() { + $cmd_opt = []; + $cmd_opt['letsencrypt'] = 'showHelp'; + $cmd_opt['letsencrypt:list'] = 'listCertificates'; + $cmd_opt['letsencrypt:info'] = 'outputCertificate'; $cmd_opt['letsencrypt:cleanup-expired'] = 'cleanupExpired'; $this->addCmdOpt($cmd_opt); } - public function list($arg) - { + public function listCertificates($arg) { global $app; $app->uses('letsencrypt'); - $certificates = $app->letsencrypt->get_certificate_list(); - foreach ($certificates as $certificate) { - print_r($certificate); + $certificates = $this->getCertificates(); + if(empty($certificates)) { + return; + } + $table = [['type', 'id', 'valid info', 'serial', 'domains']]; + $ansi_reset = "\033[0m"; + $bold_red = "\033[1m\033[31m"; + $bold_green = "\033[1m\033[32m"; + foreach($certificates as $certificate) { + $valid = ($certificate['is_valid'] ? ($bold_green . 'yes' . $ansi_reset) : ($bold_red . 'no ' . $ansi_reset)) . ' ' . $this->getValidInfo($certificate); + $table[] = [ + $certificate['signature_type'], + $certificate['id'], + $valid, + $certificate['serial_number'], + $this->getList($certificate['domains']), + ]; } + $this->outputTable($table, ['variable_columns' => '1,4', 'expand' => true]); } - public function cleanupExpired($arg) - { + public function outputCertificate($args) { global $app; + if(empty($args)) { + + } + if(empty($args)) { + $this->swriteln('error: ID of the certificate is missing'); + $this->showHelp($args); + exit(1); + } $app->uses('letsencrypt'); - $removals = 0; - $hasErrors = false; - $certificates = $app->letsencrypt->get_certificate_list(); - foreach ($certificates as $certificate) { - if (! $certificate['is_valid']) { - $this->swriteln("Removing ".join(', ', $certificate['domains'])." expired certificate..."); - if ($app->letsencrypt->remove_certificate($certificate)) { - $removals += 1; - } else { - $this->swriteln("Could not remove ".print_r($certificate, true)); - $hasErrors = true; + $certificates = $this->getCertificates(); + if(empty($certificates)) { + return; + } + foreach($args as $id) { + $certificate = false; + foreach($certificates as $c) { + if($c['id'] == $id) { + $certificate = $c; + break; } } + if($certificate) { + $ansi_reset = "\033[0m"; + $bold_red = "\033[1m\033[31m"; + $bold_green = "\033[1m\033[32m"; + $bold_yellow = "\033[1m\033[33m"; + $gray = "\033[38;5;7m"; + $valid = ($certificate['is_valid'] ? ($bold_green . 'yes' . $ansi_reset) : ($bold_red . 'no ' . $ansi_reset)) . ' ' . $this->getValidInfo($certificate); + $table = [ + ['key', 'value'], + ['id', $certificate['id']], + ['serial', $certificate['serial_number']], + ['type', $certificate['signature_type']], + ['valid', $valid . "\n" . $gray . 'from ' . $ansi_reset . $certificate['valid_from']->format('Y-m-d H:i:s') . "\n" . $gray . 'to ' . $ansi_reset . $certificate['valid_to']->format('Y-m-d H:i:s')], + ['revokation', $certificate['is_revoked'] === null ? ($bold_yellow . 'not checked' . $ansi_reset) : $certificate['is_revoked'] ? ($bold_red . 'REVOKED' . $ansi_reset) : ($bold_green . 'not revoked' . $ansi_reset)], + ['domains', $this->getList($certificate['domains'])], + ['subject', $this->getAssocArray($certificate['subject'])], + ['issuer', $this->getAssocArray($certificate['issuer'])], + ['source', $certificate['source']], + ['conf', $certificate['conf']], + ['files', $this->getAssocArray($certificate['cert_paths'])], + ]; + $this->outputTable($table, ['min_lengths' => [10], 'variable_columns' => '1', 'expand' => true]); + } else { + $this->swriteln("\n" . 'Certificate not found: ' . $id . "\n"); + } } - if ($removals) { - $this->swriteln("Removed $removals expired certificates"); - } else { - $this->swriteln("No certificates were removed"); + + } + + public function cleanupExpired($arg) { + global $app; + $app->uses('letsencrypt'); + $removals = 0; + $hasErrors = false; + $certificates = $this->getCertificates(); + if(empty($certificates)) { + return; + } + $certificates_to_remove = []; + foreach($certificates as $certificate) { + if(!$certificate['is_valid']) { + $certificates_to_remove[] = $certificate; + } + } + if(empty($certificates_to_remove)) { + $this->swriteln('No expired certificates found'); + return; } - if ($hasErrors) { + $ansi_reset = "\033[0m"; + $bold_red = "\033[1m\033[31m"; + $table = [['type', 'id', 'valid info', 'serial', 'domains']]; + foreach($certificates_to_remove as $certificate) { + $valid = $this->getValidInfo($certificate); + $table[] = [ + $certificate['signature_type'], + $certificate['id'], + $valid, + $certificate['serial_number'], + $this->getList($certificate['domains']), + ]; + } + $this->outputTable($table, ['variable_columns' => '1,4', 'expand' => true]); + $this->swriteln(''); + if($this->simple_query($bold_red . 'Do you want to delete the certificates?' . $ansi_reset, ['yes', 'no'], 'no') == 'no') { + $this->swriteln('No certificates were removed'); + return; + } + foreach($certificates_to_remove as $certificate) { + $this->swriteln('Removing expired certificate ' . $certificate['id']); + if($app->letsencrypt->remove_certificate($certificate)) { + $removals += 1; + } else { + $this->swriteln("Could not remove " . print_r($certificate, true)); + $hasErrors = true; + } + } + $this->swriteln('Removed ' . $removals . ' expired certificates'); + if($hasErrors) { exit(1); } } - public function showHelp($arg) - { + public function showHelp($arg) { global $conf; $this->swriteln("---------------------------------"); - $this->swriteln("- Available commandline option -"); + $this->swriteln("- Available commandline options -"); $this->swriteln("---------------------------------"); $this->swriteln("ispc letsencrypt list - lists all known certificates"); + $this->swriteln("ispc letsencrypt info <id> - outputs all information of one certificate"); $this->swriteln("ispc letsencrypt cleanup-expired - Cleanup all expired certificates."); $this->swriteln("---------------------------------"); $this->swriteln(); } + + private function getList($array) { + return join("\n", array_map(function($line) { + $ansi_reset = "\033[0m"; + $gray = "\033[38;5;7m"; + return $gray . '• ' . $ansi_reset . $line; + }, $array)); + } + + private function getAssocArray($array) { + return join("\n", array_map(function($key, $value) { + $ansi_reset = "\033[0m"; + $gray = "\033[38;5;7m"; + return $gray . $key . '=' . $ansi_reset . $value; + }, array_keys($array), array_values($array))); + } + + private function getValidInfo($certificate) { + $ansi_reset = "\033[0m"; + $bold_red = "\033[1m\033[31m"; + $bold_green = "\033[1m\033[32m"; + $bold_yellow = "\033[1m\033[33m"; + $gray = "\033[38;5;7m"; + $now = new DateTime('now'); + $diff = $now->diff($certificate['valid_to'])->format('%r%a'); + if($diff > 0) { + if($diff <= 7) { + $info = $bold_yellow . $diff . ' day' . ($diff > 1 ? 's' : '') . ' valid' . $ansi_reset; + } else { + $info = $bold_green . $diff . ' days valid' . $ansi_reset; + } + } else { + $diff = abs($diff); + $info = $bold_red . $diff . ' day' . ($diff != 1 ? 's' : '') . ' expired' . $ansi_reset; + } + // $info .= $gray . $certificate['valid_to']->format('Y-m-d H:i:s') . $ansi_reset; + if($certificate['is_revoked'] === null) { + $info .= $gray . ' (no OCSP)' . $ansi_reset; + } elseif($certificate['is_revoked']) { + $info .= $bold_red . ' REVOKED' . $ansi_reset; + } + return $info; + } + + private function getCertificates() { + global $app; + $this->swriteln('Getting all certificates…'); + $certificates = $app->letsencrypt->get_certificate_list(); + if(empty($certificates)) { + $this->swriteln('No certificates found'); + } + return $certificates; + } } diff --git a/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php b/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php index 6a2afba776036c422b30cfa78e0a01c2fdceb02e..e379cadf81a8272ed69b06194f6b2c1063bae2b2 100644 --- a/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php +++ b/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php @@ -28,112 +28,133 @@ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -class cronjob_letsencrypt_cleanup extends cronjob -{ +class cronjob_letsencrypt_cleanup extends cronjob { // job schedule protected $_schedule = '@weekly'; - private $denylist = []; - - protected function onBeforeRun() - { + public function onRunJob() { global $app, $conf; $app->uses('letsencrypt,ini_parser,getconf'); $server_db_record = $app->db->queryOneRecord("SELECT * FROM server WHERE server_id = ?", $conf['server_id']); - if (! $server_db_record || ! $server_db_record['web_server']) { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'Webserver not active, not running Let\'s Encrypt cleanup.'."\n"; + if(!$server_db_record || !$server_db_record['web_server']) { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'Webserver not active, not running Let\'s Encrypt cleanup.' . "\n"; } - - return false; + parent::onRunJob(); + return; } $server_config = $app->getconf->get_server_config($conf['server_id'], 'server'); - if (($server_config['migration_mode'] ?? 'n') != 'y') { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'Migration mode active, not running Let\'s Encrypt cleanup.'."\n"; + if((isset($server_config['migration_mode']) ? $server_config['migration_mode'] : 'n') == 'y') { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'Migration mode active, not running Let\'s Encrypt cleanup.' . "\n"; } - - return false; + parent::onRunJob(); + return; } - if (! $app->letsencrypt->get_acme_script() && ! $app->letsencrypt->get_certbot_script()) { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'No Let\'s Encrypt client found, not running Let\'s Encrypt cleanup.'."\n"; + if(!$app->letsencrypt->get_acme_script() && !$app->letsencrypt->get_certbot_script()) { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'No Let\'s Encrypt client found, not running Let\'s Encrypt cleanup.' . "\n"; } - - return false; + parent::onRunJob(); + return; } - $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); - $le_auto_cleanup = $web_config['le_auto_cleanup'] ?? 'n'; - if ($le_auto_cleanup == 'n') { - return false; + $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); + $le_auto_cleanup = empty($web_config['le_auto_cleanup']) ? 'n' : $web_config['le_auto_cleanup']; + if($le_auto_cleanup == 'n') { + parent::onRunJob(); + return; } - $this->denylist = array_filter(array_map(function ($domain) use ($server_db_record) { - $domain = trim($domain); - if ($domain == '[server_name]') { - return $server_db_record['server_name']; - } - - return $domain; - }, explode(',', $web_config['le_auto_cleanup_denylist'] ?? ''))); - - return parent::onBeforeRun(); - } - - public function onRunJob() - { - global $app, $conf; - $used_serials = []; - $active_ssl_records = $app->db->queryAllRecords( - "SELECT * FROM web_domain WHERE active = 'y' AND ssl_letsencrypt = 'y' AND `ssl` = 'y' AND document_root IS NOT NULL AND server_id = ?", + $used_serials = []; + $all_letsencrypt_websites = $app->db->queryAllRecords( + "SELECT * FROM web_domain WHERE ssl_letsencrypt = 'y' AND `ssl` = 'y' AND document_root IS NOT NULL AND server_id = ?", $conf['server_id'] ); - foreach ($active_ssl_records as $active_ssl_record) { - $cert_paths = $app->letsencrypt->get_website_certificate_paths(['new' => $active_ssl_record]); - if (is_readable($cert_paths['crt'])) { - $info = $app->letsencrypt->extract_x509($cert_paths['crt']); - if ($info) { - $used_serials[] = $info['serialNumber']; - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'mark serial number '.$info['serialNumber'].' as used from '.$cert_paths['crt']."\n"; + foreach($all_letsencrypt_websites as $record) { + $cert_paths = $app->letsencrypt->get_website_certificate_paths(['new' => $record]); + if(is_readable($cert_paths['crt'])) { + $info = $app->letsencrypt->extract_x509($cert_paths['crt'], $cert_paths['bundle']); + if($info) { + if($record['active'] == 'y') { + // when website is active we unconditionally deem the certificate as used (even when it is not valid anymore) + $used = true; + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'active website ' . $record['domain_id'] . '/' . $record['domain'] . ' is using ' . ($info['is_valid'] ? 'valid' : 'invalid') . ' certificate ' . $cert_paths['crt'] . "\n"; + } + } else { + // when website is inactive, we only consider its certificates used when the certificate is still valid + $used = $info['is_valid']; + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'inactive website ' . $record['domain_id'] . '/' . $record['domain'] . ' is using ' . ($info['is_valid'] ? 'valid' : 'invalid') . ' certificate ' . $cert_paths['crt'] . ($used ? '' : ' but we consider it as unused') . "\n"; + } + } + if($used) { + $used_serials[] = $info['serial_number']; + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'mark serial number ' . $info['serial_number'] . ' as used from ' . $cert_paths['crt'] . "\n"; + } + } else { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'serial number ' . $info['serial_number'] . ' is referenced but we deem it as unused ' . $cert_paths['crt'] . "\n"; + } } } else { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'cannot extract x509 information from '.$cert_paths['crt']."\n"; + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'cannot extract X509 information from ' . $cert_paths['crt'] . "\n"; } } } else { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print $cert_paths['crt'].' is not readable'."\n"; + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print $cert_paths['crt'] . ' is not readable' . "\n"; } } } + + $deny_list = empty($web_config['le_auto_cleanup_denylist']) ? [] : array_filter(array_map(function($domain) use ($server_db_record) { + $domain = trim($domain); + if($domain == '[server_name]') { + return $server_db_record['server_name']; + } + + return $domain; + }, explode(',', $web_config['le_auto_cleanup_denylist']))); + $certificates = $app->letsencrypt->get_certificate_list(); - foreach ($certificates as $certificate) { - if (in_array($certificate['serialNumber'], $used_serials)) { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'Skip '.$certificate['id'] . ' because it still gets used by ISPConfig' . "\n"; + foreach($certificates as $certificate) { + if(in_array($certificate['serial_number'], $used_serials)) { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'Skip ' . $certificate['id'] . ' because it still gets used by ISPConfig' . "\n"; } continue; } - foreach ($this->denylist as $domain) { - if (in_array($domain, $certificate['domains'])) { - if ($conf['log_priority'] <= LOGLEVEL_DEBUG) { - print 'Skip '.$certificate['id'] . ' because it is on denylist' . "\n"; + foreach($certificate['domains'] as $cert_domain) { + if(substr($cert_domain, 0, 2) == '*.') { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'Skip ' . $certificate['id'] . ' because it is a wildcard certificate' . "\n"; + } + continue 2; + } + $on_deny_list = array_filter($deny_list, function($deny_pattern) use ($cert_domain) { + return mb_strtolower($deny_pattern) == mb_strtolower($cert_domain) || fnmatch($deny_pattern, $cert_domain, FNM_CASEFOLD); + }); + if(!empty($on_deny_list)) { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'Skip ' . $certificate['id'] . ' because its domain ' . $cert_domain . ' is on deny list (' . join(', ', $on_deny_list) . ')' . "\n"; } continue 2; } } - if ($app->letsencrypt->remove_certificate($certificate)) { - print 'Removed unused certificate '.$certificate['id']."\n"; + if($app->letsencrypt->remove_certificate($certificate)) { + print 'Removed unused certificate ' . $certificate['id'] . "\n"; } else { - print 'Error removing certificate '.$certificate['id']."\n"; + $app->log('Error removing certificate ' . $certificate['id'], LOGLEVEL_WARN); + print 'Error removing certificate ' . $certificate['id'] . "\n"; } } diff --git a/server/lib/classes/letsencrypt.inc.php b/server/lib/classes/letsencrypt.inc.php index 6e5adb93b12b52405dbde173da68362e405ef357..c8f6f31d7c06792c4ad8e691cc16de84321ba423 100644 --- a/server/lib/classes/letsencrypt.inc.php +++ b/server/lib/classes/letsencrypt.inc.php @@ -31,10 +31,9 @@ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class letsencrypt { private $renew_config_path = '/etc/letsencrypt/renewal'; - private $certbot_use_certcommand = false; public function get_acme_script() { - $acme = explode("\n", shell_exec('which acme.sh /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh 2> /dev/null') ?? ''); + $acme = explode("\n", shell_exec('which acme.sh /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh 2> /dev/null') ?: ''); $acme = reset($acme); if(is_executable($acme)) { return $acme; @@ -43,41 +42,62 @@ class letsencrypt { } } - public function get_acme_command($domains, $key_file, $bundle_file, $cert_file, $server_type = 'apache') { + public function get_acme_command($domains, $key_file, $bundle_file, $cert_file, $server_type = 'apache', &$cert_type = 'RSA') { global $app, $conf; - $letsencrypt = $this->get_acme_script(); - - $cmd = ''; - // generate cli format - foreach($domains as $domain) { - $cmd .= (string) " -d " . $domain; + if(empty($domains)) { + return false; } - if($cmd == '') { + $acme_sh = ''; + $use_acme = $this->use_acme($acme_sh); + if(!$use_acme || !$acme_sh) { + return false; + } + $version = $this->get_acme_version($acme_sh); + if(empty($version)) { return false; } + $acme_sh .= ' --log ' . escapeshellarg($conf['ispconfig_log_dir'] . '/acme.log'); + + $domain_args = ' -d ' . join(' -d ', array_map('escapeshellarg', $domains)); + $files_to_install = ' --key-file ' . escapeshellarg($key_file); if($server_type != 'apache' || version_compare($app->system->getapacheversion(true), '2.4.8', '>=')) { - $cert_arg = '--fullchain-file ' . escapeshellarg($cert_file); + $files_to_install .= ' --fullchain-file ' . escapeshellarg($cert_file); } else { - $cert_arg = '--fullchain-file ' . escapeshellarg($bundle_file) . ' --cert-file ' . escapeshellarg($cert_file); + $files_to_install .= ' --fullchain-file ' . escapeshellarg($bundle_file) . ' --cert-file ' . escapeshellarg($cert_file); } - $cmd = 'R=0 ; C=0 ; ' . $letsencrypt . ' --issue ' . $cmd . ' -w /usr/local/ispconfig/interface/acme --always-force-new-domain-key --keylength 4096; R=$? ; if [ $R -eq 0 -o $R -eq 2 ] ; then ' . $letsencrypt . ' --install-cert ' . $cmd . ' --key-file ' . escapeshellarg($key_file) . ' ' . $cert_arg . ' --reloadcmd ' . escapeshellarg($this->get_reload_command()) . ' --log ' . escapeshellarg($conf['ispconfig_log_dir'].'/acme.log') . '; C=$? ; fi ; if [ $C -eq 0 ] ; then exit $R ; else exit $C ; fi'; - - return $cmd; - } - - public function get_certbot_script() { - $which_certbot = shell_exec('which certbot /root/.local/share/letsencrypt/bin/letsencrypt /opt/eff.org/certbot/venv/bin/certbot letsencrypt'); - $letsencrypt = explode("\n", $which_certbot ? $which_certbot : ''); - $letsencrypt = reset($letsencrypt); - if(is_executable($letsencrypt)) { - return $letsencrypt; + // the minimum acme.sh version for ECDSA might be lower, but this version should work OK + if($cert_type == 'ECDSA' && version_compare($version, '2.6.4', '>=')) { + $app->log('acme.sh version is ' . $version . ', so using --keylength ec-256 instead of --keylength 4096', LOGLEVEL_DEBUG); + $certificate_type_arg = ' --keylength ec-256'; + $conf_selection_arg = ' --ecc'; } else { - return false; - } + $certificate_type_arg = ' --keylength 4096'; + $conf_selection_arg = ''; + if($cert_type != 'RSA') { + $cert_type = 'RSA'; + $app->log($cert_type . ' was requested by we use RSA because acme.sh version is ' . $version, LOGLEVEL_DEBUG); + } + } + + $commands = [ + 'R=0 ; C=0', + $acme_sh . ' --issue ' . $domain_args . ' -w /usr/local/ispconfig/interface/acme --always-force-new-domain-key ' . $conf_selection_arg . $certificate_type_arg, + 'R=$?', + 'if [ $R -eq 0 -o $R -eq 2 ]', + ' then ' . $acme_sh . ' --install-cert ' . $domain_args . $conf_selection_arg . $files_to_install . ' --reloadcmd ' . escapeshellarg($this->get_reload_command($server_type)), + ' C=$?', + 'fi', + 'if [ $C -eq 0 ]', + ' then exit $R', + ' else exit $C', + 'fi' + ]; + + return join(' ; ', $commands); } private function install_acme() { @@ -86,31 +106,27 @@ class letsencrypt { $val = 0; exec($install_cmd . ' 2>&1', $ret, $val); - return ($val == 0 ? true : false); + return $val == 0; } - private function get_reload_command() { - global $app, $conf; - - $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); - - $daemon = ''; - switch ($web_config['server_type']) { - case 'nginx': - $daemon = $web_config['server_type']; - break; - default: - if(is_file($conf['init_scripts'] . '/' . 'httpd24-httpd') || is_dir('/opt/rh/httpd24/root/etc/httpd')) { - $daemon = 'httpd24-httpd'; - } elseif(is_file($conf['init_scripts'] . '/' . 'httpd') || is_dir('/etc/httpd')) { - $daemon = 'httpd'; - } else { - $daemon = 'apache2'; - } + private function get_acme_version($acme_script) { + $matches = array(); + $output = shell_exec($acme_script . ' --version 2>&1') ?: ''; + if(preg_match('/^v(\d+(\.\d+)+)$/m', $output, $matches)) { + return $matches[1]; } + return false; + } - $cmd = $app->system->getinitcommand($daemon, 'force-reload'); - return $cmd; + public function get_certbot_script() { + $which_certbot = shell_exec('which certbot /root/.local/share/letsencrypt/bin/letsencrypt /opt/eff.org/certbot/venv/bin/certbot letsencrypt'); + $letsencrypt = explode("\n", $which_certbot ? $which_certbot : ''); + $letsencrypt = reset($letsencrypt); + if(is_executable($letsencrypt)) { + return $letsencrypt; + } else { + return false; + } } private function get_certbot_version($certbot_script) { @@ -124,86 +140,144 @@ class letsencrypt { return $letsencrypt_version; } - public function get_certbot_command($domains) { + public function get_certbot_command($domains, &$cert_type = 'RSA') { global $app; - $letsencrypt = $this->get_certbot_script(); - - $cmd = ''; - // generate cli format - foreach($domains as $domain) { - $cmd .= (string) " --domains " . $domain; - } - - if($cmd == '') { + if(empty($domains)) { return false; } + $letsencrypt = $this->get_certbot_script(); + $primary_domain = $domains[0]; $letsencrypt_version = $this->get_certbot_version($letsencrypt); - if (version_compare($letsencrypt_version, '0.22', '>=')) { + + if(version_compare($letsencrypt_version, '0.22', '>=')) { $acme_version = 'https://acme-v02.api.letsencrypt.org/directory'; } else { $acme_version = 'https://acme-v01.api.letsencrypt.org/directory'; } - if (version_compare($letsencrypt_version, '0.30', '>=')) { - $app->log("LE version is " . $letsencrypt_version . ", so using certificates command and --cert-name instead of --expand", LOGLEVEL_DEBUG); - $this->certbot_use_certcommand = true; - $webroot_map = array(); - for($i = 0; $i < count($domains); $i++) { - $webroot_map[$domains[$i]] = '/usr/local/ispconfig/interface/acme'; + + if($cert_type == 'ECDSA' && version_compare($letsencrypt_version, '2.0', '>=')) { + $app->log('LE version is ' . $letsencrypt_version . ', so using --elliptic-curve secp256r1 instead of --rsa-key-size 4096', LOGLEVEL_DEBUG); + $certificate_type_arg = "--elliptic-curve secp256r1"; + $name_suffix = '_ecc'; + } else { + $certificate_type_arg = "--rsa-key-size 4096"; + $name_suffix = ''; + if($cert_type != 'RSA') { + $cert_type = 'RSA'; + $app->log($cert_type . ' was requested by we use RSA because certbot version is ' . $letsencrypt_version, LOGLEVEL_DEBUG); + } + } + + if(version_compare($letsencrypt_version, '0.30', '>=')) { + $app->log('LE version is ' . $letsencrypt_version . ', so using --cert-name instead of --expand', LOGLEVEL_DEBUG); + $webroot_map = []; + foreach($domains as $domain) { + $webroot_map[$domain] = '/usr/local/ispconfig/interface/acme'; } $webroot_args = "--webroot-map " . escapeshellarg(str_replace(array("\r", "\n"), '', json_encode($webroot_map))); // --cert-name might be working with earlier versions of certbot, but there is no exact version documented // So for safety reasons we add it to the 0.30 version check as it is documented to work as expected in this version - $cert_selection_command = "--cert-name $primary_domain"; + $cert_selection_command = "--cert-name $primary_domain$name_suffix"; } else { + $cmd = ' --domains ' . join(' --domains ', array_map('escapeshellarg', $domains)); $webroot_args = "$cmd --webroot-path /usr/local/ispconfig/interface/acme"; $cert_selection_command = "--expand"; } - $cmd = $letsencrypt . " certonly -n --text --agree-tos $cert_selection_command --authenticator webroot --server $acme_version --rsa-key-size 4096 --email webmaster@$primary_domain $webroot_args"; + return $letsencrypt . " certonly -n --text --agree-tos $cert_selection_command --authenticator webroot --server $acme_version $certificate_type_arg --email webmaster@$primary_domain $webroot_args"; + } + + private function get_reload_command($server_type) { + global $app, $conf; + + $daemon = ''; + switch($server_type) { + case 'nginx': + $daemon = 'nginx'; + break; + default: + if(is_file($conf['init_scripts'] . '/' . 'httpd24-httpd') || is_dir('/opt/rh/httpd24/root/etc/httpd')) { + $daemon = 'httpd24-httpd'; + } elseif(is_file($conf['init_scripts'] . '/' . 'httpd') || is_dir('/etc/httpd')) { + $daemon = 'httpd'; + } else { + $daemon = 'apache2'; + } + } + $cmd = $app->system->getinitcommand($daemon, 'force-reload'); return $cmd; } - public function get_letsencrypt_certificate_paths($domains = array()) { + private function use_acme(&$script = null) { global $app; - if($this->get_acme_script()) { - return false; + $script = $this->get_acme_script(); + if($script) { + return true; + } + $script = $this->get_certbot_script(); + if(!$script) { + $app->log("Unable to find Let's Encrypt client, installing acme.sh.", LOGLEVEL_DEBUG); + // acme and le missing + $this->install_acme(); + $script = $this->get_acme_script(); + if($script) { + return true; + } else { + $app->log("Unable to install acme.sh. Cannot proceed, no Let's Encrypt client found.", LOGLEVEL_WARN); + return null; + } } + return false; + } + + public function get_letsencrypt_certificate_paths($domains = [], $cert_type = 'RSA') { + global $app; if(empty($domains)) return false; $all_certificates = $this->get_certificate_list(); - if (empty($all_certificates)) { + if(empty($all_certificates)) { return false; } - $main_domain = reset($domains); - sort($domains); + $primary_domain = reset($domains); + $sorted_domains = $domains; + sort($sorted_domains); $min_diff = false; - - foreach ($all_certificates as $certificate) { - $certificate['has_main_domain'] = in_array($main_domain, $certificate['domains']); + $possible_certificates = []; + foreach($all_certificates as $certificate) { + if($certificate['signature_type'] != $cert_type) { + continue; + } $sorted_cert_domains = $certificate['domains']; sort($sorted_cert_domains); - if(count(array_intersect($domains, $sorted_cert_domains)) < 1) { - $certificate['diff'] = false; + if(count(array_intersect($sorted_domains, $sorted_cert_domains)) < 1) { + continue; } else { - // give higher diff value to missing domains than to those that are too much in there - $certificate['diff'] = (count(array_diff($domains, $sorted_cert_domains)) * 1.5) + count(array_diff($sorted_cert_domains, $domains)); + // if the domains are exactly the same (including order) consider this better than a certificate that has all domains but in a different order + if($domains === $certificate['domains']) { + $certificate['diff'] = -1; + } else { + // give higher diff value to missing domains than to those that are too much in there + $certificate['diff'] = (count(array_diff($sorted_domains, $sorted_cert_domains)) * 1.5) + count(array_diff($sorted_cert_domains, $sorted_domains)); + } + $certificate['has_main_domain'] = in_array($primary_domain, $certificate['domains']); } - if($min_diff === false || ($certificate['diff'] !== false && $certificate['diff'] < $min_diff)) $min_diff = $certificate['diff']; + if($min_diff === false || ($certificate['diff'] < $min_diff)) $min_diff = $certificate['diff']; + $possible_certificates[] = $certificate; } if($min_diff === false) return false; $cert_paths = false; $used_id = false; - foreach ($all_certificates as $certificate) { + foreach($possible_certificates as $certificate) { if($certificate['diff'] === $min_diff) { $used_id = $certificate['id']; $cert_paths = $certificate['cert_paths']; @@ -211,7 +285,7 @@ class letsencrypt { } } - $app->log("Let's Encrypt Cert config path is: " . ($used_id ? $used_id : "not found") . ".", LOGLEVEL_DEBUG); + $app->log("Let's Encrypt Cert config path is: " . ($used_id ?: "not found") . ".", LOGLEVEL_DEBUG); return $cert_paths; } @@ -237,284 +311,196 @@ class letsencrypt { } public function get_website_certificate_paths($data) { - $ssl_dir = $data['new']['document_root'].'/ssl'; + $ssl_dir = $data['new']['document_root'] . '/ssl'; $domain = $this->get_ssl_domain($data); $cert_paths = array( 'domain' => $domain, - 'key' => $ssl_dir.'/'.$domain.'.key', - 'key2' => $ssl_dir.'/'.$domain.'.key.org', - 'csr' => $ssl_dir.'/'.$domain.'.csr', - 'crt' => $ssl_dir.'/'.$domain.'.crt', - 'bundle' => $ssl_dir.'/'.$domain.'.bundle' + 'key' => $ssl_dir . '/' . $domain . '.key', + 'key2' => $ssl_dir . '/' . $domain . '.key.org', + 'csr' => $ssl_dir . '/' . $domain . '.csr', + 'crt' => $ssl_dir . '/' . $domain . '.crt', + 'bundle' => $ssl_dir . '/' . $domain . '.bundle' ); if($data['new']['ssl'] == 'y' && $data['new']['ssl_letsencrypt'] == 'y') { $cert_paths = array( 'domain' => $domain, - 'key' => $ssl_dir.'/'.$domain.'-le.key', - 'key2' => $ssl_dir.'/'.$domain.'-le.key.org', + 'key' => $ssl_dir . '/' . $domain . '-le.key', + 'key2' => $ssl_dir . '/' . $domain . '-le.key.org', 'csr' => '', # Not used for LE. - 'crt' => $ssl_dir.'/'.$domain.'-le.crt', - 'bundle' => $ssl_dir.'/'.$domain.'-le.bundle' + 'crt' => $ssl_dir . '/' . $domain . '-le.crt', + 'bundle' => $ssl_dir . '/' . $domain . '-le.bundle' ); } return $cert_paths; } - public function request_certificates($data, $server_type = 'apache') { - global $app, $conf; - - $app->uses('getconf'); - $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); - $server_config = $app->getconf->get_server_config($conf['server_id'], 'server'); - - $use_acme = false; - if($this->get_acme_script()) { - $use_acme = true; - } elseif(!$this->get_certbot_script()) { - $app->log("Unable to find Let's Encrypt client, installing acme.sh.", LOGLEVEL_DEBUG); - // acme and le missing - $this->install_acme(); - if($this->get_acme_script()) { - $use_acme = true; - } else { - $app->log("Unable to install acme.sh. Cannot proceed, no Let's Encrypt client found.", LOGLEVEL_WARN); - return false; - } - } - $tmp = $app->letsencrypt->get_website_certificate_paths($data); - $domain = $tmp['domain']; - $key_file = $tmp['key']; - $crt_file = $tmp['crt']; - $bundle_file = $tmp['bundle']; + private function assemble_domains_to_request($data, $main_domain, $do_check) { + global $app, $conf; - // default values - $temp_domains = array($domain); - $cli_domain_arg = ''; - $subdomains = null; - $aliasdomains = null; + $certificate_domains = array($main_domain); //* be sure to have good domain - if(substr($domain,0,4) != 'www.' && ($data['new']['subdomain'] == "www" || $data['new']['subdomain'] == "*")) { - $temp_domains[] = "www." . $domain; + if(substr($main_domain, 0, 4) != 'www.' && ($data['new']['subdomain'] == "www" || $data['new']['subdomain'] == "*")) { + $certificate_domains[] = "www." . $main_domain; } //* then, add subdomain if we have - $subdomains = $app->db->queryAllRecords('SELECT domain FROM web_domain WHERE parent_domain_id = '.intval($data['new']['domain_id'])." AND active = 'y' AND type = 'subdomain' AND ssl_letsencrypt_exclude != 'y'"); + $subdomains = $app->db->queryAllRecords("SELECT domain FROM web_domain WHERE parent_domain_id = ? AND active = 'y' AND type = 'subdomain' AND ssl_letsencrypt_exclude != 'y'", intval($data['new']['domain_id'])); if(is_array($subdomains)) { foreach($subdomains as $subdomain) { - $temp_domains[] = $subdomain['domain']; + $certificate_domains[] = $subdomain['domain']; } } //* then, add alias domain if we have - $aliasdomains = $app->db->queryAllRecords('SELECT domain,subdomain FROM web_domain WHERE parent_domain_id = '.intval($data['new']['domain_id'])." AND active = 'y' AND type = 'alias' AND ssl_letsencrypt_exclude != 'y'"); - if(is_array($aliasdomains)) { - foreach($aliasdomains as $aliasdomain) { - $temp_domains[] = $aliasdomain['domain']; - if(isset($aliasdomain['subdomain']) && substr($aliasdomain['domain'],0,4) != 'www.' && ($aliasdomain['subdomain'] == "www" OR $aliasdomain['subdomain'] == "*")) { - $temp_domains[] = "www." . $aliasdomain['domain']; + $alias_domains = $app->db->queryAllRecords("SELECT domain,subdomain FROM web_domain WHERE parent_domain_id = ? AND active = 'y' AND type = 'alias' AND ssl_letsencrypt_exclude != 'y'", intval($data['new']['domain_id'])); + if(is_array($alias_domains)) { + foreach($alias_domains as $alias_domain) { + $certificate_domains[] = $alias_domain['domain']; + if(isset($alias_domain['subdomain']) && substr($alias_domain['domain'], 0, 4) != 'www.' && ($alias_domain['subdomain'] == "www" or $alias_domain['subdomain'] == "*")) { + $certificate_domains[] = "www." . $alias_domain['domain']; } } } // prevent duplicate - $temp_domains = array_unique($temp_domains); + $certificate_domains = array_values(array_unique($certificate_domains)); - // check if domains are reachable to avoid letsencrypt verification errors - $le_rnd_file = uniqid('le-', true) . '.txt'; - $le_rnd_hash = md5(uniqid('le-', true)); - if(!is_dir('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/')) { - $app->system->mkdir('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/', false, 0755, true); - } - file_put_contents('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/' . $le_rnd_file, $le_rnd_hash); + // check if domains are reachable to avoid let's encrypt verification errors + if($do_check) { + $le_rnd_file = uniqid('le-', true) . '.txt'; + $le_rnd_hash = md5(uniqid('le-', true)); + if(!is_dir('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/')) { + $app->system->mkdir('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/', false, 0755, true); + } + file_put_contents('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/' . $le_rnd_file, $le_rnd_hash); - $le_domains = array(); - foreach($temp_domains as $temp_domain) { - if((isset($web_config['skip_le_check']) && $web_config['skip_le_check'] == 'y') || (isset($server_config['migration_mode']) && $server_config['migration_mode'] == 'y')) { - $le_domains[] = $temp_domain; - } else { - $le_hash_check = trim(@file_get_contents('http://' . $temp_domain . '/.well-known/acme-challenge/' . $le_rnd_file)); + $checked_domains = []; + foreach($certificate_domains as $domain_to_check) { + $le_hash_check = trim(@file_get_contents('http://' . $domain_to_check . '/.well-known/acme-challenge/' . $le_rnd_file)); if($le_hash_check == $le_rnd_hash) { - $le_domains[] = $temp_domain; - $app->log("Verified domain " . $temp_domain . " should be reachable for letsencrypt.", LOGLEVEL_DEBUG); + $checked_domains[] = $domain_to_check; + $app->log("Verified domain " . $domain_to_check . " should be reachable for let's encrypt.", LOGLEVEL_DEBUG); } else { - $app->log("Could not verify domain " . $temp_domain . ", so excluding it from letsencrypt request.", LOGLEVEL_WARN); + $app->log("Could not verify domain " . $domain_to_check . ", so excluding it from let's encrypt request.", LOGLEVEL_WARN); } } + $certificate_domains = $checked_domains; + @unlink('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/' . $le_rnd_file); } - $temp_domains = $le_domains; - unset($le_domains); - @unlink('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/' . $le_rnd_file); - $le_domain_count = count($temp_domains); + $le_domain_count = count($certificate_domains); if($le_domain_count > 100) { - $temp_domains = array_slice($temp_domains, 0, 100); + $certificate_domains = array_slice($certificate_domains, 0, 100); $app->log("There were " . $le_domain_count . " domains in the domain list. LE only supports 100, so we strip the rest.", LOGLEVEL_WARN); } - if ($le_domain_count == 0) { - return false; - } - - // unset useless data - unset($subdomains); - unset($aliasdomains); - - $this->certbot_use_certcommand = false; - $letsencrypt_cmd = ''; - $allow_return_codes = null; - $old_umask = umask(0022); # work around acme.sh permission bug, see #6015 - if($use_acme) { - $letsencrypt_cmd = $this->get_acme_command($temp_domains, $key_file, $bundle_file, $crt_file, $server_type); - $allow_return_codes = array(2); - // Cleanup ssl cert symlinks, if exists - if(@is_link($key_file)) unlink($key_file); - if(@is_link($bundle_file)) unlink($bundle_file); - if(@is_link($crt_file)) unlink($crt_file); - } else { - $letsencrypt_cmd = $this->get_certbot_command($temp_domains); - umask($old_umask); - } + return $certificate_domains; + } - $success = false; - if($letsencrypt_cmd) { - if(!isset($server_config['migration_mode']) || $server_config['migration_mode'] != 'y') { - $app->log("Create Let's Encrypt SSL Cert for: $domain", LOGLEVEL_DEBUG); - $app->log("Let's Encrypt SSL Cert domains: $cli_domain_arg", LOGLEVEL_DEBUG); + private function link_file($target, $source) { + global $app; - $success = $app->system->_exec($letsencrypt_cmd, $allow_return_codes); + $needs_link = true; + if(@is_link($target)) { + $existing_source = readlink($target); + if($existing_source == $source) { + $needs_link = false; } else { - $app->log("Migration mode active, skipping Let's Encrypt SSL Cert creation for: $domain", LOGLEVEL_DEBUG); - $success = true; + $app->system->unlink($target); } + } elseif(is_file($target)) { + $suffix = '.old.' . date('YmdHis'); + $app->system->copy($target, $target . $suffix); + $app->system->chmod($target . $suffix, 0400); + $app->system->unlink($target); } - - if($use_acme === true) { - umask($old_umask); - if(!$success) { - $app->log('Let\'s Encrypt SSL Cert for: ' . $domain . ' could not be issued.', LOGLEVEL_WARN); - $app->log($letsencrypt_cmd, LOGLEVEL_WARN); - return false; - } else { - return true; - } + if($needs_link) { + $app->system->exec_safe("ln -s ? ?", $source, $target); } + } - $le_files = array(); - if($this->certbot_use_certcommand === true && $letsencrypt_cmd) { - $cli_domain_arg = ''; - // generate cli format - foreach($temp_domains as $temp_domain) { - $cli_domain_arg .= (string) " --domains " . $temp_domain; - } - - - $letsencrypt_cmd = $this->get_certbot_script() . " certificates " . $cli_domain_arg; - $output = explode("\n", shell_exec($letsencrypt_cmd . " 2>/dev/null | grep -v '^\$'") ?? ''); - $le_path = ''; - $skip_to_next = true; - $matches = null; - foreach($output as $outline) { - $outline = trim($outline); - $app->log("LE CERT OUTPUT: " . $outline, LOGLEVEL_DEBUG); + public function request_certificates($data, $server_type = 'apache', $desired_signature_type = '') { + global $app, $conf; - if($skip_to_next === true && !preg_match('/^\s*Certificate Name/', $outline)) { - continue; - } - $skip_to_next = false; + $app->uses('getconf'); + $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); + $server_config = $app->getconf->get_server_config($conf['server_id'], 'server'); - if(preg_match('/^\s*Expiry.*?VALID:\s+\D/', $outline)) { - $app->log("Found LE path is expired or invalid: " . $matches[1], LOGLEVEL_DEBUG); - $skip_to_next = true; - continue; - } + if(!in_array($desired_signature_type, ['RSA', 'ECDSA'])) { + $desired_signature_type = $web_config['le_signature_type'] ?: 'RSA'; + } - if(preg_match('/^\s*Certificate Path:\s*(\/.*?)\s*$/', $outline, $matches)) { - $app->log("Found LE path: " . $matches[1], LOGLEVEL_DEBUG); - $le_path = dirname($matches[1]); - if(is_dir($le_path)) { - break; - } else { - $le_path = false; - } - } - } + $certificate_paths = $this->get_website_certificate_paths($data); + $key_file = $certificate_paths['key']; + $crt_file = $certificate_paths['crt']; + $bundle_file = $certificate_paths['bundle']; + $main_domain = $certificate_paths['domain']; + $migration_mode = isset($server_config['migration_mode']) && $server_config['migration_mode'] == 'y'; + $do_check = (empty($web_config['skip_le_check']) || $web_config['skip_le_check'] == 'n') && !$migration_mode; + $certificate_domains = $this->assemble_domains_to_request($data, $main_domain, $do_check); - if($le_path) { - $le_files = array( - 'privkey' => $le_path . '/privkey.pem', - 'chain' => $le_path . '/chain.pem', - 'cert' => $le_path . '/cert.pem', - 'fullchain' => $le_path . '/fullchain.pem' - ); - } - } - if(empty($le_files)) { - $le_files = $this->get_letsencrypt_certificate_paths($temp_domains); + if(empty($certificate_domains)) { + return false; } - unset($temp_domains); - if($server_type != 'apache' || version_compare($app->system->getapacheversion(true), '2.4.8', '>=')) { - $crt_tmp_file = $le_files['fullchain']; - } else { - $crt_tmp_file = $le_files['cert']; + if($migration_mode) { + $app->log("Migration mode active, skipping Let's Encrypt SSL Cert creation for: $main_domain", LOGLEVEL_DEBUG); } - $key_tmp_file = $le_files['privkey']; - $bundle_tmp_file = $le_files['chain']; - - if(!$success) { - // error issuing cert - $app->log('Let\'s Encrypt SSL Cert for: ' . $domain . ' could not be issued.', LOGLEVEL_WARN); - $app->log($letsencrypt_cmd, LOGLEVEL_WARN); - - // if cert already exists, dont remove it. Ex. expired/misstyped/noDnsYet alias domain, api down... - if(!file_exists($crt_tmp_file)) { - return false; - } + $use_acme = $this->use_acme(); + if($use_acme === null) { + return false; } - //* check is been correctly created - if(file_exists($crt_tmp_file)) { - $app->log("Let's Encrypt Cert file: $crt_tmp_file exists.", LOGLEVEL_DEBUG); - $date = date("YmdHis"); - - //* TODO: check if is a symlink, if target same keep it, either remove it - if(is_file($key_file)) { - $app->system->copy($key_file, $key_file.'.old.'.$date); - $app->system->chmod($key_file.'.old.'.$date, 0400); - $app->system->unlink($key_file); + if($use_acme) { + if(!$migration_mode) { + $letsencrypt_cmd = $this->get_acme_command($certificate_domains, $key_file, $bundle_file, $crt_file, $server_type, $desired_signature_type); + // Cleanup ssl cert symlinks, if exists so that amcme.sh can install copies of its files to the target location + if(@is_link($key_file)) unlink($key_file); + if(@is_link($bundle_file)) unlink($bundle_file); + if(@is_link($crt_file)) unlink($crt_file); + + $app->log("Create Let's Encrypt SSL Cert for " . $main_domain . ' (' . $desired_signature_type . ') via acme.sh, domains to include: ' . join(', ', $certificate_domains), LOGLEVEL_DEBUG); + $old_umask = umask(0022); # work around acme.sh permission bug, see #6015 + $success = $letsencrypt_cmd && $app->system->_exec($letsencrypt_cmd, [2]); + umask($old_umask); + if(!$success) { + $app->log("Let's Encrypt SSL Cert for " . $main_domain . ' via acme.sh could not be issued. Used command: ' . $letsencrypt_cmd, LOGLEVEL_WARN); + return false; + } } - - if(@is_link($key_file)) $app->system->unlink($key_file); - if(@file_exists($key_tmp_file)) $app->system->exec_safe("ln -s ? ?", $key_tmp_file, $key_file); - - if(is_file($crt_file)) { - $app->system->copy($crt_file, $crt_file.'.old.'.$date); - $app->system->chmod($crt_file.'.old.'.$date, 0400); - $app->system->unlink($crt_file); + // acme.sh directly installs a copy of the certificate at the place we expect them to be, so we are done here + return true; + } else { + if(!$migration_mode) { + $letsencrypt_cmd = $this->get_certbot_command($certificate_domains, $desired_signature_type); + // get_certbot_command sets $this->certbot_use_certcommand + $app->log("Create Let's Encrypt SSL Cert for " . $main_domain . ' (' . $desired_signature_type . ') via certbot, domains to include: ' . join(', ', $certificate_domains), LOGLEVEL_DEBUG); + $success = $letsencrypt_cmd && $app->system->_exec($letsencrypt_cmd); + if(!$success) { + $app->log("Let's Encrypt SSL Cert for " . $main_domain . ' via certbot could not be issued. Used command: ' . $letsencrypt_cmd, LOGLEVEL_WARN); + return false; + } } - if(@is_link($crt_file)) $app->system->unlink($crt_file); - if(@file_exists($crt_tmp_file))$app->system->exec_safe("ln -s ? ?", $crt_tmp_file, $crt_file); - - if(is_file($bundle_file)) { - $app->system->copy($bundle_file, $bundle_file.'.old.'.$date); - $app->system->chmod($bundle_file.'.old.'.$date, 0400); - $app->system->unlink($bundle_file); + $discovered_paths = $this->get_letsencrypt_certificate_paths($certificate_domains, $desired_signature_type); + if(empty($discovered_paths)) { + $app->log("Let's Encrypt Cert file: could not find the issued certificate", LOGLEVEL_WARN); + return false; + } + $this->link_file($key_file, $discovered_paths['privkey']); + $this->link_file($bundle_file, $discovered_paths['chain']); + if($server_type != 'apache' || version_compare($app->system->getapacheversion(true), '2.4.8', '>=')) { + $this->link_file($crt_file, $discovered_paths['fullchain']); + } else { + $this->link_file($crt_file, $discovered_paths['cert']); } - - if(@is_link($bundle_file)) $app->system->unlink($bundle_file); - if(@file_exists($bundle_tmp_file)) $app->system->exec_safe("ln -s ? ?", $bundle_tmp_file, $bundle_file); - return true; - } else { - $app->log("Let's Encrypt Cert file: $crt_tmp_file does not exist.", LOGLEVEL_DEBUG); - return false; } } @@ -524,41 +510,36 @@ class letsencrypt { * @return array */ public function get_certificate_list() { - global $app; + global $app, $conf; - $use_acme = false; - $shell_script = $this->get_acme_script(); - if ($shell_script) { - $use_acme = true; - } else { - $shell_script = $this->get_certbot_script(); - } - if (!$shell_script) { - $app->log("get_certificate_list: did not find acme.sh nor certbot", LOGLEVEL_ERROR); + $shell_script = ''; + $use_acme = $this->use_acme($shell_script); + if($use_acme === null || !$shell_script) { + $app->log('get_certificate_list: did not find acme.sh nor certbot', LOGLEVEL_WARN); return []; } - $certs = []; - if ($use_acme) { - $info = $app->system->system_safe("$shell_script --info"); + $candidates = []; + if($use_acme) { + $info = $app->system->system_safe($shell_script . ' --info 2>/dev/null'); // try to auto-upgrade acme.sh when --info command is not there - if ($app->system->last_exec_retcode() != 0) { - $app->system->system_safe("$shell_script --upgrade"); - $info = $app->system->system_safe("$shell_script --info"); + if($app->system->last_exec_retcode() != 0) { + $app->system->system_safe($shell_script . ' --upgrade 2>&1'); + $info = $app->system->system_safe($shell_script . ' --info 2>/dev/null'); } - if ($app->system->last_exec_retcode() != 0) { - $app->log("get_certificate_list: acme.sh --info failed", LOGLEVEL_ERROR); + if($app->system->last_exec_retcode() != 0) { + $app->log('get_certificate_list: acme.sh --info failed', LOGLEVEL_ERROR); return []; } $info = $this->parse_env_file($info); - $cert_dir = $info['CERT_HOME'] ?? $info['LE_CONFIG_HOME']; - if (!is_dir($cert_dir)) { - $app->log("get_certificate_list: could not find certificate home $cert_dir", LOGLEVEL_ERROR); + $cert_dir = !empty($info['CERT_HOME']) ? $info['CERT_HOME'] : $info['LE_CONFIG_HOME']; + if(empty($cert_dir) || !is_dir($cert_dir)) { + $app->log('get_certificate_list: could not find certificate home ' . $cert_dir, LOGLEVEL_ERROR); return []; } $dir = opendir($cert_dir); if(!$dir) { - $app->log("get_certificate_list: could not open certificate home $cert_dir", LOGLEVEL_ERROR); + $app->log('get_certificate_list: could not open certificate home ' . $cert_dir, LOGLEVEL_ERROR); return []; } while($path = readdir($dir)) { @@ -566,52 +547,48 @@ class letsencrypt { if($path === '.' || $path === '..' || strpos($path, '.') === false) { continue; } - $full_path = $cert_dir.'/'.$path; - if (!is_dir($full_path)) { + $full_path = $cert_dir . '/' . $path; + if(!is_dir($full_path)) { continue; } $domain = $path; - if (preg_match('/_ecc$/', $path)) { + if(preg_match('/_ecc$/', $path)) { $domain = substr($path, 0, -4); } - if (!is_file("$full_path/$domain.conf")) { + if(!$this->is_readable_link_or_file("$full_path/$domain.conf")) { continue; } - $certs[] = [ - 'type' => 'acme.sh', + $candidates[] = [ + 'source' => 'acme.sh', 'id' => $path, 'conf' => $full_path, 'cert_paths' => [ 'cert' => "$full_path/$domain.cer", 'privkey' => "$full_path/$domain.key", + 'chain' => "$full_path/ca.cer", 'fullchain' => "$full_path/fullchain.cer", ] ]; } } else { - $letsencrypt_version = $this->get_certbot_version($shell_script); - if (version_compare($letsencrypt_version, '0.10.0', '<')) { - $app->log("get_certificate_list: certbot version $letsencrypt_version not supported", LOGLEVEL_ERROR); - return []; - } if(!is_dir($this->renew_config_path)) { - $app->log("get_certificate_list: certbot renew dir not found: ".$this->renew_config_path, LOGLEVEL_ERROR); + $app->log('get_certificate_list: certbot renew dir not found: ' . $this->renew_config_path, LOGLEVEL_ERROR); return []; } $dir = opendir($this->renew_config_path); if(!$dir) { - $app->log("get_certificate_list: could not open certbot renew dir", LOGLEVEL_ERROR); + $app->log('get_certificate_list: could not open certbot renew dir', LOGLEVEL_ERROR); return []; } while($file = readdir($dir)) { - if($file === '.' || $file === '..' || substr($file, -5) !== '.conf') continue; - $file_path = $this->renew_config_path . '/' . $file; - if(!is_file($file_path) || !is_readable($file_path)) continue; - + $file_path = $this->renew_config_path . $conf['fs_div'] . $file; + if($file === '.' || $file === '..' || substr($file, -5) !== '.conf' || !$this->is_readable_link_or_file($file_path)) { + continue; + } $fp = fopen($file_path, 'r'); if(!$fp) continue; $certificate = [ - 'type' => 'certbot', + 'source' => 'certbot', 'id' => substr($file, 0, -5), 'conf' => $file_path, 'cert_paths' => [ @@ -633,18 +610,27 @@ class letsencrypt { } } fclose($fp); - $certs[] = $certificate; + $candidates[] = $certificate; } closedir($dir); } $certificates = []; - foreach ($certs as $certificate) { - if (!empty($certificate['cert_paths']['cert']) && !empty($certificate['cert_paths']['priv']) && is_file($certificate['cert_paths']['cert']) && is_file($certificate['cert_paths']['priv'])) { - $info = $this->extract_x509($certificate['cert_paths']['cert']); - if ($info) { - $certificates[] = array_merge($certificate, $info); + foreach($candidates as $certificate) { + if($this->is_readable_link_or_file($certificate['cert_paths']['cert']) + && $this->is_readable_link_or_file($certificate['cert_paths']['privkey']) + && $this->is_readable_link_or_file($certificate['cert_paths']['fullchain']) + && $this->is_readable_link_or_file($certificate['cert_paths']['chain'])) { + $info = $this->extract_x509($certificate['cert_paths']['cert'], $certificate['cert_paths']['chain']); + if($info) { + $certificate = array_merge($certificate, $info); + $certificates[] = $certificate; + $app->log('get_certificate_list found certificate ' . $certificate['conf'] . ' ' . $certificate['signature_type'] . ' ' . $certificate['serial_number'] . ($certificate['is_valid'] ? ' (valid) ' : ' (invalid) ') . join(', ', $certificate['domains']), LOGLEVEL_DEBUG); + } else { + $app->log('get_certificate_list certificate candidate ' . $certificate['conf'] . ' invalid because X509 extraction was unsuccessful', LOGLEVEL_DEBUG); } + } else { + $app->log('get_certificate_list certificate candidate ' . $certificate['conf'] . ' invalid because files are missing', LOGLEVEL_DEBUG); } } return $certificates; @@ -658,29 +644,30 @@ class letsencrypt { public function remove_certificate($certificate) { global $app; - if ($certificate['type'] == 'certbot') { + if($certificate['source'] == 'certbot') { $certbot_script = $this->get_certbot_script(); - if (!$certbot_script) { - $app->log("remove_certificate: certbot not found, cannot delete ". $certificate['id'], LOGLEVEL_WARN); + if(!$certbot_script) { + $app->log("remove_certificate: certbot not found, cannot delete " . $certificate['id'], LOGLEVEL_WARN); return false; } $version = $this->get_certbot_version($certbot_script); - if (version_compare($version, '0.10.0', '<')) { - $app->log("remove_certificate: certbot is very old. Please update for proper certificate deletion.", LOGLEVEL_WARN); + if(version_compare($version, '0.30.0', '<')) { + $app->log('remove_certificate: certbot is very old. Please update for proper certificate deletion.', LOGLEVEL_WARN); } else { - $app->system->safe_exec("$certbot_script delete --cert-name ?", $certificate['id']); - if ($app->system->last_exec_retcode() != 0) { - $app->log("remove_certificate: certbot delete --cert-name ". $certificate['id']. " failed.", LOGLEVEL_WARN); + $app->system->exec_safe($certbot_script . ' delete -n --cert-name ? 2>&1', $certificate['id']); + if($app->system->last_exec_retcode() != 0) { + $app->log('remove_certificate: certbot delete -n --cert-name ' . $certificate['id'] . ' failed.', LOGLEVEL_WARN); } } - if (is_file($certificate['conf'])) { - @rename($certificate['conf'], $certificate['conf'].'.removed'); - $app->log("remove_certificate: manually move renew conf ". $certificate['conf']. " out of the way.", LOGLEVEL_DEBUG); + // if the conf file is still lingering around, we move it out of the way + if(is_file($certificate['conf'])) { + @rename($certificate['conf'], $certificate['conf'] . '.removed'); + $app->log('remove_certificate: manually move renew conf ' . $certificate['conf'] . ' out of the way.', LOGLEVEL_DEBUG); } } else { - if (is_dir($certificate['conf'])) { - if (!$app->system->rmdir($certificate['conf'], false)) { - $app->log("remove_certificate: could not delete config folder ". $certificate['conf'], LOGLEVEL_WARN); + if(is_dir($certificate['conf'])) { + if(!$app->system->rmdir($certificate['conf'], false)) { + $app->log('remove_certificate: could not delete config folder ' . $certificate['conf'], LOGLEVEL_WARN); return false; } } @@ -688,80 +675,112 @@ class letsencrypt { return true; } - private function is_domain_name_or_wildcard($input) { - $input = filter_var($input, FILTER_VALIDATE_DOMAIN); - if (!$input) { - return false; - } - // $input can still be something like "some. invalid . domain % name", so we check with a simple regex that no unusual things are in domain name - return preg_match("/^(\*\.)?[\w\p{L}0-9._-]+$/u", $input); - } - - public function extract_x509($cert_file) { + public function extract_x509($cert_file, $chain_file = null) { global $app; - if (!function_exists('openssl_x509_parse')) { - $app->log("extract_x509: openssl extension missing", LOGLEVEL_ERROR); + if(!function_exists('openssl_x509_parse')) { + $app->log('extract_x509: openssl extension missing', LOGLEVEL_ERROR); return false; } $info = openssl_x509_parse(file_get_contents($cert_file), true); - if (!$info) { - $app->log("extract_x509: $cert_file could not be parsed", LOGLEVEL_ERROR); + if(!$info) { + $app->log('extract_x509: ' . $cert_file . ' could not be parsed', LOGLEVEL_ERROR); return false; } - if (empty($info['subject']['CN']) || !$this->is_domain_name_or_wildcard($info['subject']['CN'])) { + if(empty($info['subject']['CN']) || !$this->is_domain_name_or_wildcard($info['subject']['CN'])) { return false; } - $domains = [$info['subject']['CN']]; - if (!empty($info['extensions']) && !empty($info['extensions']['subjectAltName'])) { + $domains = [$app->functions->idn_encode($info['subject']['CN'])]; + if(!empty($info['extensions']) && !empty($info['extensions']['subjectAltName'])) { $domains = array_filter(array_merge($domains, array_map(function($i) { + global $app; $parts = explode(':', $i, 2); - if (count($parts) < 2) { + if(count($parts) < 2) { return false; } $maybe_domain = trim($parts[1]); - if (!$this->is_domain_name_or_wildcard($maybe_domain) && !filter_var($maybe_domain, FILTER_VALIDATE_IP)) { - return false; + if(filter_var($maybe_domain, FILTER_VALIDATE_IP)) { + return $maybe_domain; + } + if($this->is_domain_name_or_wildcard($maybe_domain)) { + return $app->functions->idn_encode($maybe_domain); } - return $maybe_domain; + return false; }, explode(',', $info['extensions']['subjectAltName'])))); $domains = array_values(array_unique($domains)); } - if (empty($domains)) { + if(empty($domains)) { return false; } - $valid_from = new DateTime('@' . $info['validFrom_time_t']); - $valid_to = new DateTime('@' . $info['validTo_time_t']); + $valid_from = new DateTime('@' . $info['validFrom_time_t']); + $valid_to = new DateTime('@' . $info['validTo_time_t']); $now = new DateTime(); + $is_valid = $valid_from <= $now && $now <= $valid_to; + $is_revoked = null; + // only do online revokation check when cert is valid and we got the required chain + if($is_valid && $this->is_readable_link_or_file($chain_file)) { + $ocsp_uri = $app->system->exec_safe('openssl x509 -noout -ocsp_uri -in ? 2>&1', $cert_file); + $ocsp_host = parse_url($ocsp_uri ?: '', PHP_URL_HOST); + if($ocsp_uri && $ocsp_host) { + $ocsp_response = $app->system->system_safe('openssl ocsp -issuer ? -cert ? -text -url ? -header HOST=? 2>&1', $chain_file, $cert_file, $ocsp_uri, $ocsp_host); + if($app->system->last_exec_retcode() == 0) { + $is_revoked = strpos($ocsp_response, 'Cert Status: good') === false; + if($is_revoked) { + $is_valid = false; + } + } else { + $app->log('extract_x509: ' . $cert_file . ' getting OCSP response from ' . $ocsp_uri . ' failed: ' . $ocsp_response, LOGLEVEL_WARN); + } + } + } + $signature_type = 'RSA'; + $long_type = strtolower(isset($info['signatureTypeLN']) ? $info['signatureTypeLN'] : '?'); + if(strpos($long_type, 'ecdsa') !== false) { + $signature_type = 'ECDSA'; + } return [ - 'serialNumber' => $info['serialNumber'], - 'signatureType' => $info['signatureTypeLN'] ?? '?', + 'serial_number' => $info['serialNumber'], + 'signature_type' => $signature_type, 'subject' => $info['subject'], 'issuer' => $info['issuer'], 'domains' => $domains, - 'is_valid' => $valid_from <= $now && $now <= $valid_to, // TODO: add revokation check (OCSP and/or CRL) + 'is_valid' => $is_valid, + 'is_revoked' => $is_revoked, 'valid_from' => $valid_from, 'valid_to' => $valid_to, ]; } + private function is_domain_name_or_wildcard($input) { + $input = filter_var($input, FILTER_VALIDATE_DOMAIN); + if(!$input) { + return false; + } + // $input can still be something like "some. invalid . domain % name", so we check with a simple regex that no unusual things are in domain name + return preg_match("/^(\*\.)?[\w\p{L}0-9._-]+$/u", $input); + } + + private function is_readable_link_or_file($path) { + return $path && (@is_link($path) || @is_file($path)) && @is_readable($path); + } + private function parse_env_file($lines) { $variables = []; - foreach ($lines as $line) { + foreach($lines as $line) { $line = trim($line); // does only handle comment-only lines. // lines like `KEY=Value # inline-comment` are not supported (and normally not used by acme.sh) - if (!$line || substr($line, 0, 1) == '#') { + if(!$line || substr($line, 0, 1) == '#') { continue; } $parts = explode('=', $line, 2); - if (count($parts) < 2) { + if(count($parts) < 2) { continue; } $key = trim($parts[0]); $value = trim($parts[1]); - if (preg_match('/^"(.*)"$/', $value, $matches)) { + if(preg_match('/^"(.*)"$/', $value, $matches)) { $value = $matches[1]; - } elseif (preg_match("/^'(.*)'$/", $value, $matches)) { + } elseif(preg_match("/^'(.*)'$/", $value, $matches)) { $value = $matches[1]; } $variables[$key] = $value;