From 6039b8275ef08e2c6ad815c0ddc8fc2fe0e00c05 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Tue, 23 Jul 2024 23:16:51 +0200 Subject: [PATCH 01/17] feat: add automatic garbage collection for Let's Encrypt certificates. When enabled (enabled by default for new installs) ISPConfig will automatically remove certbot/acme.sh issued certificates that do not get used anymore. You can specify a list of domains that should never be automatically deleted. If you use certbot/acme.sh on your server outside ISPConfig you should list all these domains in the denylist otherwise ISPConfig will automatically remove the certificates. Fixes #5226 --- install/tpl/server.ini.master | 2 + .../web/admin/form/server_config.tform.php | 23 +- .../web/admin/lib/lang/en_server_config.lng | 3 + .../templates/server_config_web_edit.htm | 11 + server/cli/modules/letsencrypt.inc.php | 95 +++++ .../cron.d/800-letsencrypt_cleanup.inc.php | 142 +++++++ server/lib/classes/letsencrypt.inc.php | 363 ++++++++++++++---- 7 files changed, 549 insertions(+), 90 deletions(-) create mode 100644 server/cli/modules/letsencrypt.inc.php create mode 100644 server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php diff --git a/install/tpl/server.ini.master b/install/tpl/server.ini.master index 49971f80f6..eb262623b5 100644 --- a/install/tpl/server.ini.master +++ b/install/tpl/server.ini.master @@ -142,6 +142,8 @@ vhost_proxy_protocol_enabled=n vhost_proxy_protocol_protocols=ipv4 vhost_proxy_protocol_http_port=880 vhost_proxy_protocol_https_port=8443 +le_auto_cleanup=y +le_auto_cleanup_denylist=[server_name] [dns] bind_user=root diff --git a/interface/web/admin/form/server_config.tform.php b/interface/web/admin/form/server_config.tform.php index 5dd1a6d7f9..656880b787 100644 --- a/interface/web/admin/form/server_config.tform.php +++ b/interface/web/admin/form/server_config.tform.php @@ -574,15 +574,6 @@ $form["tabs"]['mail'] = array( 'default' => '2048', 'value' => array('1024' => 'weak (1024)', '2048' => 'normal (2048)', '4096' => 'strong (4096)') ), - 'relayhost_password' => array( - 'datatype' => 'VARCHAR', - 'formtype' => 'TEXT', - 'default' => '', - 'value' => '', - 'width' => '40', - 'maxlength' => '255' - ), - 'pop3_imap_daemon' => array( 'datatype' => 'VARCHAR', 'formtype' => 'SELECT', @@ -1642,6 +1633,20 @@ $form["tabs"]['web'] = array( 'width' => '40', 'maxlength' => '255' ), + 'le_auto_cleanup' => array( + 'datatype' => 'VARCHAR', + 'formtype' => 'CHECKBOX', + 'default' => 'y', + 'value' => array(0 => 'n', 1 => 'y') + ), + 'le_auto_cleanup_denylist' => array( + 'datatype' => 'VARCHAR', + 'formtype' => 'TEXT', + 'default' => '[server_name]', + 'value' => '', + 'width' => '40', + 'maxlength' => '255' + ), //################################# // END Datatable fields //################################# diff --git a/interface/web/admin/lib/lang/en_server_config.lng b/interface/web/admin/lib/lang/en_server_config.lng index b14271c319..15c0917c67 100644 --- a/interface/web/admin/lib/lang/en_server_config.lng +++ b/interface/web/admin/lib/lang/en_server_config.lng @@ -370,3 +370,6 @@ $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_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:'; diff --git a/interface/web/admin/templates/server_config_web_edit.htm b/interface/web/admin/templates/server_config_web_edit.htm index 54124bd9ac..44fc534491 100644 --- a/interface/web/admin/templates/server_config_web_edit.htm +++ b/interface/web/admin/templates/server_config_web_edit.htm @@ -248,6 +248,17 @@
+
+ +
+
+
+ +
+ +
{tmpl_var name='le_auto_cleanup_denylist_note_txt'} [server_name] +
+
diff --git a/server/cli/modules/letsencrypt.inc.php b/server/cli/modules/letsencrypt.inc.php new file mode 100644 index 0000000000..a674d513fc --- /dev/null +++ b/server/cli/modules/letsencrypt.inc.php @@ -0,0 +1,95 @@ +addCmdOpt($cmd_opt); + } + + public function list($arg) + { + global $app; + $app->uses('letsencrypt'); + $certificates = $app->letsencrypt->get_certificate_list(); + foreach ($certificates as $certificate) { + print_r($certificate); + } + } + + public function cleanupExpired($arg) + { + global $app; + $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; + } + } + } + if ($removals) { + $this->swriteln("Removed $removals expired certificates"); + } else { + $this->swriteln("No certificates were removed"); + } + if ($hasErrors) { + exit(1); + } + } + + public function showHelp($arg) + { + global $conf; + + $this->swriteln("---------------------------------"); + $this->swriteln("- Available commandline option -"); + $this->swriteln("---------------------------------"); + $this->swriteln("ispc letsencrypt list - lists all known certificates"); + $this->swriteln("ispc letsencrypt cleanup-expired - Cleanup all expired certificates."); + $this->swriteln("---------------------------------"); + $this->swriteln(); + } + +} + diff --git a/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php b/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php new file mode 100644 index 0000000000..6a2afba776 --- /dev/null +++ b/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php @@ -0,0 +1,142 @@ +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"; + } + + return false; + } + + $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"; + } + + return false; + } + + 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; + } + + $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; + } + $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 = ?", + $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"; + } + } else { + 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"; + } + } + } + + $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"; + } + 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"; + } + continue 2; + } + } + if ($app->letsencrypt->remove_certificate($certificate)) { + print 'Removed unused certificate '.$certificate['id']."\n"; + } else { + print 'Error removing certificate '.$certificate['id']."\n"; + } + } + + parent::onRunJob(); + } +} diff --git a/server/lib/classes/letsencrypt.inc.php b/server/lib/classes/letsencrypt.inc.php index fe6766e600..6e5adb93b1 100644 --- a/server/lib/classes/letsencrypt.inc.php +++ b/server/lib/classes/letsencrypt.inc.php @@ -30,19 +30,9 @@ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class letsencrypt { - /** - * Construct for this class - * - * @return system - */ - private $base_path = '/etc/letsencrypt'; private $renew_config_path = '/etc/letsencrypt/renewal'; private $certbot_use_certcommand = false; - public function __construct(){ - - } - 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 = reset($acme); @@ -123,6 +113,17 @@ class letsencrypt { return $cmd; } + private function get_certbot_version($certbot_script) { + $matches = array(); + $ret = null; + $val = 0; + $letsencrypt_version = exec($certbot_script . ' --version 2>&1', $ret, $val); + if(preg_match('/^(\S+|\w+)\s+(\d+(\.\d+)+)$/', $letsencrypt_version, $matches)) { + $letsencrypt_version = $matches[2]; + } + return $letsencrypt_version; + } + public function get_certbot_command($domains) { global $app; @@ -139,14 +140,8 @@ class letsencrypt { } $primary_domain = $domains[0]; - $matches = array(); - $ret = null; - $val = 0; - $letsencrypt_version = exec($letsencrypt . ' --version 2>&1', $ret, $val); - if(preg_match('/^(\S+|\w+)\s+(\d+(\.\d+)+)$/', $letsencrypt_version, $matches)) { - $letsencrypt_version = $matches[2]; - } + $letsencrypt_version = $this->get_certbot_version($letsencrypt); if (version_compare($letsencrypt_version, '0.22', '>=')) { $acme_version = 'https://acme-v02.api.letsencrypt.org/directory'; } else { @@ -181,87 +176,42 @@ class letsencrypt { } if(empty($domains)) return false; - if(!is_dir($this->renew_config_path)) return false; - - $dir = opendir($this->renew_config_path); - if(!$dir) return false; - $path_scores = array(); + $all_certificates = $this->get_certificate_list(); + if (empty($all_certificates)) { + return false; + } $main_domain = reset($domains); sort($domains); $min_diff = false; - 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; - - $fp = fopen($file_path, 'r'); - if(!$fp) continue; - - $path_scores[$file_path] = array( - 'domains' => array(), - 'diff' => 0, - 'has_main_domain' => false, - 'cert_paths' => array( - 'cert' => '', - 'privkey' => '', - 'chain' => '', - 'fullchain' => '' - ) - ); - $in_list = false; - while(!feof($fp) && $line = fgets($fp)) { - $line = trim($line); - if($line === '') continue; - elseif(!$in_list) { - if($line == '[[webroot_map]]') $in_list = true; - - $tmp = explode('=', $line, 2); - if(count($tmp) != 2) continue; - $key = trim($tmp[0]); - if($key == 'cert' || $key == 'privkey' || $key == 'chain' || $key == 'fullchain') { - $path_scores[$file_path]['cert_paths'][$key] = trim($tmp[1]); - } - - continue; - } - - $tmp = explode('=', $line, 2); - if(count($tmp) != 2) continue; - - $domain = trim($tmp[0]); - if($domain == $main_domain) $path_scores[$file_path]['has_main_domain'] = true; - $path_scores[$file_path]['domains'][] = $domain; - } - fclose($fp); - - sort($path_scores[$file_path]['domains']); - if(count(array_intersect($domains, $path_scores[$file_path]['domains'])) < 1) { - $path_scores[$file_path]['diff'] = false; + foreach ($all_certificates as $certificate) { + $certificate['has_main_domain'] = in_array($main_domain, $certificate['domains']); + $sorted_cert_domains = $certificate['domains']; + sort($sorted_cert_domains); + if(count(array_intersect($domains, $sorted_cert_domains)) < 1) { + $certificate['diff'] = false; } else { // give higher diff value to missing domains than to those that are too much in there - $path_scores[$file_path]['diff'] = (count(array_diff($domains, $path_scores[$file_path]['domains'])) * 1.5) + count(array_diff($path_scores[$file_path]['domains'], $domains)); + $certificate['diff'] = (count(array_diff($domains, $sorted_cert_domains)) * 1.5) + count(array_diff($sorted_cert_domains, $domains)); } - - if($min_diff === false || $path_scores[$file_path]['diff'] < $min_diff) $min_diff = $path_scores[$file_path]['diff']; + if($min_diff === false || ($certificate['diff'] !== false && $certificate['diff'] < $min_diff)) $min_diff = $certificate['diff']; } - closedir($dir); if($min_diff === false) return false; $cert_paths = false; - $used_path = false; - foreach($path_scores as $path => $data) { - if($data['diff'] === $min_diff) { - $used_path = $path; - $cert_paths = $data['cert_paths']; - if($data['has_main_domain'] == true) break; + $used_id = false; + foreach ($all_certificates as $certificate) { + if($certificate['diff'] === $min_diff) { + $used_id = $certificate['id']; + $cert_paths = $certificate['cert_paths']; + if($certificate['has_main_domain']) break; } } - $app->log("Let's Encrypt Cert config path is: " . ($used_path ? $used_path : "not found") . ".", LOGLEVEL_DEBUG); + $app->log("Let's Encrypt Cert config path is: " . ($used_id ? $used_id : "not found") . ".", LOGLEVEL_DEBUG); return $cert_paths; } @@ -567,4 +517,255 @@ class letsencrypt { return false; } } + + /** + * Gets a list of all installed certificates on this server. + * + * @return array + */ + public function get_certificate_list() { + global $app; + + $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); + return []; + } + + $certs = []; + if ($use_acme) { + $info = $app->system->system_safe("$shell_script --info"); + // 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->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); + return []; + } + $dir = opendir($cert_dir); + if(!$dir) { + $app->log("get_certificate_list: could not open certificate home $cert_dir", LOGLEVEL_ERROR); + return []; + } + while($path = readdir($dir)) { + // valid conf dirs have a . in them + if($path === '.' || $path === '..' || strpos($path, '.') === false) { + continue; + } + $full_path = $cert_dir.'/'.$path; + if (!is_dir($full_path)) { + continue; + } + $domain = $path; + if (preg_match('/_ecc$/', $path)) { + $domain = substr($path, 0, -4); + } + if (!is_file("$full_path/$domain.conf")) { + continue; + } + $certs[] = [ + 'type' => 'acme.sh', + 'id' => $path, + 'conf' => $full_path, + 'cert_paths' => [ + 'cert' => "$full_path/$domain.cer", + 'privkey' => "$full_path/$domain.key", + '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); + return []; + } + $dir = opendir($this->renew_config_path); + if(!$dir) { + $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; + + $fp = fopen($file_path, 'r'); + if(!$fp) continue; + $certificate = [ + 'type' => 'certbot', + 'id' => substr($file, 0, -5), + 'conf' => $file_path, + 'cert_paths' => [ + 'cert' => '', + 'privkey' => '', + 'chain' => '', + 'fullchain' => '' + ] + ]; + while(!feof($fp) && $line = fgets($fp)) { + $line = trim($line); + if($line === '') continue; + if($line == '[[webroot_map]]') break; + $tmp = explode('=', $line, 2); + if(count($tmp) != 2) continue; + $key = trim($tmp[0]); + if($key == 'cert' || $key == 'privkey' || $key == 'chain' || $key == 'fullchain') { + $certificate['cert_paths'][$key] = trim($tmp[1]); + } + } + fclose($fp); + $certs[] = $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); + } + } + } + return $certificates; + } + + /** + * @param array $certificate the certificate (from get_certificate_list()) + * + * @return bool whether the certificate could be removed + */ + public function remove_certificate($certificate) { + global $app; + + if ($certificate['type'] == 'certbot') { + $certbot_script = $this->get_certbot_script(); + 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); + } 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); + } + } + 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); + return false; + } + } + } + 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) { + global $app; + 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); + return false; + } + 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 = array_filter(array_merge($domains, array_map(function($i) { + $parts = explode(':', $i, 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; + } + return $maybe_domain; + }, explode(',', $info['extensions']['subjectAltName'])))); + $domains = array_values(array_unique($domains)); + } + if (empty($domains)) { + return false; + } + $valid_from = new DateTime('@' . $info['validFrom_time_t']); + $valid_to = new DateTime('@' . $info['validTo_time_t']); + $now = new DateTime(); + return [ + 'serialNumber' => $info['serialNumber'], + 'signatureType' => $info['signatureTypeLN'] ?? '?', + 'subject' => $info['subject'], + 'issuer' => $info['issuer'], + 'domains' => $domains, + 'is_valid' => $valid_from <= $now && $now <= $valid_to, // TODO: add revokation check (OCSP and/or CRL) + 'valid_from' => $valid_from, + 'valid_to' => $valid_to, + ]; + } + + private function parse_env_file($lines) { + $variables = []; + 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) == '#') { + continue; + } + $parts = explode('=', $line, 2); + if (count($parts) < 2) { + continue; + } + $key = trim($parts[0]); + $value = trim($parts[1]); + if (preg_match('/^"(.*)"$/', $value, $matches)) { + $value = $matches[1]; + } elseif (preg_match("/^'(.*)'$/", $value, $matches)) { + $value = $matches[1]; + } + $variables[$key] = $value; + } + return $variables; + } } -- GitLab From bc7d1280c00233756e744d4c24385d463b6383ef Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 04:21:51 +0200 Subject: [PATCH 02/17] enforce usage of tabs for indentation #5226 #6563 --- server/lib/classes/cli.inc.php | 128 ++++++++++++++++----------------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/server/lib/classes/cli.inc.php b/server/lib/classes/cli.inc.php index 29914a3eec..a7e0f0aa7c 100644 --- a/server/lib/classes/cli.inc.php +++ b/server/lib/classes/cli.inc.php @@ -30,52 +30,52 @@ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class cli { - private $cmd_opt = array(); - - // Add commandline option map - protected function addCmdOpt($cmd_opt) { - $this->cmd_opt = $cmd_opt; - } - - // Get commandline option map - protected function getCmdOpt() { - return $this->cmd_opt; - } - - // Run command module - public function process($arg) { - $function = ''; - $opt_string = ''; - $last_arg = 1; - for($n = 1; $n < count($arg); $n++) { - $a = ($n > 1)?$a.':'.$arg[$n]:$arg[$n]; - if(isset($this->cmd_opt[$a])) { - $function = $this->cmd_opt[$a]; - $last_arg = $n + 1; - } - } + private $cmd_opt = array(); + + // Add commandline option map + protected function addCmdOpt($cmd_opt) { + $this->cmd_opt = $cmd_opt; + } + + // Get commandline option map + protected function getCmdOpt() { + return $this->cmd_opt; + } + + // Run command module + public function process($arg) { + $function = ''; + $opt_string = ''; + $last_arg = 1; + for($n = 1; $n < count($arg); $n++) { + $a = ($n > 1) ? $a . ':' . $arg[$n] : $arg[$n]; + if(isset($this->cmd_opt[$a])) { + $function = $this->cmd_opt[$a]; + $last_arg = $n + 1; + } + } // Check function name - if(!preg_match("/[a-z0-9\-]{0,20}/",$function)) die("Invalid commandline option\n"); - - // Build new arg array of the remaining arguments - $new_arg = []; - if($last_arg < count($arg)) { - for($n = $last_arg; $n < count($arg); $n++) { - $new_arg[] = $arg[$n]; - } - } - - if($function != '') { - $this->$function($new_arg); - } else { + if(!preg_match("/[a-z0-9\-]{0,20}/", $function)) die("Invalid commandline option\n"); + + // Build new arg array of the remaining arguments + $new_arg = []; + if($last_arg < count($arg)) { + for($n = $last_arg; $n < count($arg); $n++) { + $new_arg[] = $arg[$n]; + } + } + + if($function != '') { + $this->$function($new_arg); + } else { $this->showHelp($new_arg); - //$this->error("Invalid option"); - } - } + //$this->error("Invalid option"); + } + } - // Query function - public function simple_query($query, $answers, $default, $name = '') { + // Query function + public function simple_query($query, $answers, $default, $name = '') { global $autoinstall, $autoupdate; $finished = false; do { @@ -93,7 +93,7 @@ class cli { } } else { $answers_str = implode(',', $answers); - $this->swrite($this->lng($query).' ('.$answers_str.') ['.$default.']: '); + $this->swrite($this->lng($query) . ' (' . $answers_str . ') [' . $default . ']: '); $input = $this->sread(); } @@ -115,7 +115,7 @@ class cli { $finished = true; } - } while ($finished == false); + } while($finished == false); $this->swriteln(); return $answer; } @@ -135,7 +135,7 @@ class cli { $input = $autoupdate[$name]; } } else { - $this->swrite($this->lng($query).' ['.$default.']: '); + $this->swrite($this->lng($query) . ' [' . $default . ']: '); $input = $this->sread(); } @@ -145,31 +145,31 @@ class cli { die(); } - $answer = ($input == '') ? $default : $input; + $answer = ($input == '') ? $default : $input; $this->swriteln(); return $answer; } - public function lng($text) { + public function lng($text) { return $text; } - public function sread() { - $input = fgets(STDIN); - return rtrim($input); - } - - public function swrite($text = '') { - echo $text; - } - - public function swriteln($text = '') { - echo $text."\n"; - } - - public function error($msg) { - $this->swriteln($msg); - die(); - } + public function sread() { + $input = fgets(STDIN); + return rtrim($input); + } + + public function swrite($text = '') { + echo $text; + } + + public function swriteln($text = '') { + echo $text . "\n"; + } + + public function error($msg) { + $this->swriteln($msg); + die(); + } } -- GitLab From c4b06abbb97de191d2f86910caecac708ae69bd4 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 04:23:14 +0200 Subject: [PATCH 03/17] add PHP doc comments to app for better Intellisense #5226 #6563 --- server/lib/app.inc.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/server/lib/app.inc.php b/server/lib/app.inc.php index 59a7111ac9..963d751417 100644 --- a/server/lib/app.inc.php +++ b/server/lib/app.inc.php @@ -49,6 +49,14 @@ if(DEVSYSTEM !== true) { * @author Till Brehm * @license bsd-3-clause * @link empty + * + * @property-read functions $functions + * @property-read getconf $getconf + * @property-read letsencrypt $letsencrypt + * @property-read modules $modules + * @property-read plugins $plugins + * @property-read services $services + * @property-read system $system **/ class app extends stdClass { /** @var array List of modules that have been loaded. */ @@ -57,8 +65,10 @@ class app extends stdClass { var $loaded_plugins = []; /** @var callable Script calling this. */ var $_calling_script = ''; - /** @var resource? Database used for ISPConfig3. */ + /** @var db|false Database used for ISPConfig3. */ public $db; + /** @var db|false */ + public $dbmaster; /** * Class constructor, which depends on the global configuration stored in $conf. -- GitLab From aae193322b12b0f51f8fdfff55322f51bda810ce Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 04:24:26 +0200 Subject: [PATCH 04/17] add some utility functions to CLI class for outputting on a terminal #5226 #6563 --- server/lib/classes/cli.inc.php | 278 +++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) diff --git a/server/lib/classes/cli.inc.php b/server/lib/classes/cli.inc.php index a7e0f0aa7c..946ab4403f 100644 --- a/server/lib/classes/cli.inc.php +++ b/server/lib/classes/cli.inc.php @@ -172,4 +172,282 @@ class cli { die(); } + /** + * @return bool true when STDOUT is not redirected to a file + */ + public function isStdOutATty() { + return defined('STDOUT') && function_exists('posix_isatty') && posix_isatty(constant('STDOUT')); + } + + /** + * Return the width of the string $text (not counting ANSI escape codes). + * + * @param string $text + * @return int + */ + public function stringWidth($text) { + return mb_strwidth(preg_replace("/\033\[.*?m/", '', $text), 'utf8'); + } + + /** + * Formats text so that it is not longer than $width. + * + * ANSI escape codes do not count to the line length and when a text needs to be wrapped, the currently active + * ANSI escape codes get reset before inserting the newline character. This is needed to wrap text in multiple columns. + * + * @param string $text unwrapped text. May already contain newlines that get preserved. + * @param int $width maximum line width + * @return string + */ + public function wrapText($text, $width = 80) { + $characters = []; + $cur_width = 0; + $in_ansi = false; + $prev_ansi = ''; + $current_ansi = ''; + $reset_ansi = "\033[0m"; + $input = preg_split('//u', $text, -1, PREG_SPLIT_NO_EMPTY); + foreach($input as $index => $char) { + if($char == "\n") { + $prev_ansi = ''; + $cur_width = 0; + if($current_ansi) { + $characters[] = $reset_ansi . "\n" . $current_ansi; + } else { + $characters[] = "\n"; + } + } elseif($in_ansi || $char == "\033") { + $characters[] = $char; + if($char == "\033") { + $prev_ansi = $prev_ansi . $current_ansi; + $current_ansi = ''; + } + $current_ansi .= $char; + $in_ansi = $char != 'm'; + if(!$in_ansi) { + if($current_ansi == $reset_ansi) { + $current_ansi = ''; + $prev_ansi = ''; + } else { + $current_ansi = $prev_ansi . $current_ansi; + } + } + } elseif($char == ' ') { + // look ahead $width characters and search for a newline or another space + $prev_ansi = ''; + $lookahead_length = 0; + $has_better_break = false; + $lookup_reached_end = false; + $lookahead_in_ansi = false; + for($i = $index + 1; $i < count($input); $i++) { + $next_char = $input[$i]; + $lookup_reached_end = $i == count($input) - 1; + if($next_char == "\n") { + $has_better_break = $cur_width + $lookahead_length < $width; + break; + } elseif($lookahead_in_ansi || $next_char == "\033") { + $lookahead_in_ansi = $char != 'm'; + } elseif($next_char == ' ' && $cur_width + $lookahead_length < $width) { + $has_better_break = true; + break; + } else { + $lookahead_length += mb_strwidth($next_char, 'utf8'); + } + if($cur_width + $lookahead_length > $width) { + break; + } + } + if(!$lookup_reached_end && !$has_better_break) { + $cur_width = 0; + if($current_ansi) { + $characters[] = $reset_ansi . "\n" . $current_ansi; + } else { + $characters[] = "\n"; + } + } else { + $characters[] = $char; + $cur_width += 1; + } + } else { + $prev_ansi = ''; + $char_width = mb_strwidth($char, 'utf8'); + if($cur_width + $char_width > $width) { + $cur_width = 0; + if($current_ansi) { + $characters[] = $reset_ansi . "\n" . $current_ansi; + } else { + $characters[] = "\n"; + } + } + $characters[] = $char; + $cur_width += $char_width; + } + } + return join('', $characters); + } + + /** + * Divides $value up into $count parts. Returns an array with $count integers. + * The sum of all returned integers is always $value (Even when rounding happens). + * + * @param int $value + * @param int $count + * @return int[] + */ + private function getDiscreetDistribution($value, $count) { + $per_item = intval(floor($value / $count)); + $distribution = array_fill(0, $count, $per_item); + // add 1 to the columns until the column sum is equal to $value + $sum = $per_item * $count; + for($i = 0; $i < $count && $sum < $value; $i++) { + $distribution[$i] += 1; + $sum += 1; + } + return $distribution; + } + + /** + * Outputs an array of arrays as table to STDOUT. All cells should already be strings. You can use ANSI escape code sequences in the cells. + * + * Simple table: + * + * $this->outputTable([['one', 'two'], ['1', '2']]); + * + * + * You can define minimum column lengths: + * + * $this->outputTable([['one', 'two'], ['1', '2']], ['min_lengths' => [10, 10]]); + * + * + * The table tries to size itself to fit in the terminal window width. As default all column width 5 or wider will shrink, + * but you can also specify which column(s) should shrink: + * + * $this->outputTable([ + * ['variable', 'fixed', 'variable'], + * [str_repeat('this is a long column ', 40), '2', str_repeat('another long column ', 60)] + * ], ['variable_columns' => [0,2]]); + * + * + * Small tables do not fill the available terminal window width by default. + * You can change this with the `expand` $option: + * + * $this->outputTable([['one', 'two'], ['1', '2']], ['expand' => true]); + * + * + * @param array $table array of arrays (rows -> columns) to display as table. + * @param array{min_lengths: array|null, variable_columns: array|string|int|null, expand: bool}|null $options optional formatting options + * @return void + */ + public function outputTable($table, $options = null) { + if(empty($table)) { + $this->swriteln('No data to display'); + return; + } + if(!is_array($options)) { + $options = []; + } + if(!is_array($options['min_lengths'])) { + $options['min_lengths'] = []; + } + + // process input $table + $columns = []; + $rows = []; + $num_columns = false; + foreach($table as $row) { + $c = count($row); + if(!$c || $num_columns !== false && $c != $num_columns) { + $this->error("every input row of outputTable input needs to the same size (not null)"); + } + $num_columns = $c; + $r = []; + $index = 0; + foreach($row as $value) { + $value_as_string = (string)$value; + $max = 0; + foreach(explode("\n", $value_as_string) as $line) { + $len = $this->stringWidth($line); + $max = max($max, max($options['min_lengths'][$index] ?: 0, $len)); + } + if(!isset($columns[$index])) { + $columns[$index] = ['length' => 0, 'first' => $index == 0, 'last' => $index == $num_columns - 1]; + } + if($columns[$index]['length'] < $max) { + $columns[$index]['length'] = $max; + } + $r[$index] = $value_as_string; + $index += 1; + } + $rows[] = $r; + } + + // fit table to terminal + if($this->isStdOutATty()) { + if(!isset($options['variable_columns']) || $options['variable_columns'] == 'all') { + $options['variable_columns'] = range(0, $num_columns - 1); + } elseif(!is_array($options['variable_columns'])) { + $options['variable_columns'] = explode(',', (string)$options['variable_columns']); + } + $minimum_variable_column_width = 5; + $shrink_variable_columns = array_values(array_filter($options['variable_columns'], function($index) use ($minimum_variable_column_width, $columns, $num_columns) { + return $index < $num_columns && $columns[$index]['length'] >= $minimum_variable_column_width; + })); + $expand_variable_columns = array_values(array_filter($options['variable_columns'], function($index) use ($num_columns) { + return $index < $num_columns; + })); + $terminal_width = intval(trim(exec("tput cols") ?: '')) ?: 80; + $table_width = array_reduce($columns, function($sum, $column) { + return $sum + $column['length'] + 3; + }, 1); + if(count($shrink_variable_columns) > 0 && $table_width > $terminal_width) { + $diff = $table_width - $terminal_width; + $diff_per_column = $this->getDiscreetDistribution($diff, count($shrink_variable_columns)); + foreach($shrink_variable_columns as $i => $index) { + $new_length = $columns[$index]['length'] - $diff_per_column[$i]; + $columns[$index]['length'] = max($minimum_variable_column_width, $new_length); + } + } elseif(count($expand_variable_columns) > 0 && $options['expand'] && $table_width < $terminal_width) { + $diff = $terminal_width - $table_width; + $diff_per_column = $this->getDiscreetDistribution($diff, count($expand_variable_columns)); + foreach($expand_variable_columns as $i => $index) { + $new_length = $columns[$index]['length'] + $diff_per_column[$i]; + $columns[$index]['length'] = $new_length; + } + } + } + + // output table + $separator = function($first_start, $mid_start, $mid, $mid_end, $last_end) use ($columns) { + $this->swriteln(array_reduce($columns, function($line, $column) use ($first_start, $mid_start, $mid, $mid_end, $last_end) { + return $line . ($column['first'] ? $first_start : $mid_start) . str_repeat($mid, $column['length']) . ($column['last'] ? $last_end : $mid_end); + }, '')); + }; + foreach($rows as $row_index => $row) { + if($row_index == 0) { + $separator('╔═', '╤═', '═', '═', '═╗'); + } + // first pass -> re-wrap lines and get max number of lines of this row + $height = 1; + $lines = []; + foreach($columns as $index => $column) { + $lines[$index] = explode("\n", $this->wrapText($row[$index], $column['length'])); + $height = max($height, count($lines[$index])); + } + // second pass -> output row, line by line + for($inner = 0; $inner < $height; $inner++) { + $line = ""; + foreach($columns as $index => $column) { + $value = $lines[$index][$inner] ?: ''; + $value .= str_repeat(' ', $column['length'] - $this->stringWidth($value)); + $line .= ($column['first'] ? '║ ' : '│ ') . $value . ($column['last'] ? ' ║' : ' '); + } + $this->swriteln($line); + } + if($row_index < count($rows) - 1) { + $separator('╟─', '┼─', '─', '─', '─╢'); + } else { + $separator('╚═', '╧═', '═', '═', '═╝'); + } + } + } } -- GitLab From bbd807441b51c9c39cf1cfe8abff3e77361b791a Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 04:29:26 +0200 Subject: [PATCH 05/17] refactor Let's encrypt issuing and introduce ECC cert handling #5226 #6563 --- install/tpl/server.ini.master | 1 + interface/lib/classes/validate_domain.inc.php | 25 + .../web/admin/form/server_config.tform.php | 16 + .../web/admin/lib/lang/en_server_config.lng | 6 +- .../templates/server_config_web_edit.htm | 9 +- server/cli/modules/letsencrypt.inc.php | 211 ++++- .../cron.d/800-letsencrypt_cleanup.inc.php | 153 ++-- server/lib/classes/letsencrypt.inc.php | 785 +++++++++--------- 8 files changed, 720 insertions(+), 486 deletions(-) diff --git a/install/tpl/server.ini.master b/install/tpl/server.ini.master index eb262623b5..13615f651b 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 3555135eae..53d8856368 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 656880b787..1f641166a8 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 15c0917c67..e33895369b 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.
E.g. mail.*, externally-managed.example.com
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 44fc534491..834d962692 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 @@
+ +
+ +
+
@@ -255,7 +262,7 @@
- +
{tmpl_var name='le_auto_cleanup_denylist_note_txt'} [server_name]
diff --git a/server/cli/modules/letsencrypt.inc.php b/server/cli/modules/letsencrypt.inc.php index a674d513fc..6557f250c4 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 - 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 6a2afba776..e379cadf81 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 6e5adb93b1..c8f6f31d7c 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; -- GitLab From 7b555929076e58895e989655d898b488800c3650 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 04:31:52 +0200 Subject: [PATCH 06/17] PHP 5.4 compatibility #5226 #6563 --- server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php b/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php index 33acf5fa5c..1b416a9db6 100644 --- a/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php +++ b/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php @@ -94,14 +94,14 @@ class cronjob_mailbox_stats_hourly extends cronjob { $line = strtok($log_lines, PHP_EOL); while ($line !== FALSE) { $matches = []; - // Match pop3/imap logings, or alternately smtp logins. + // Match pop3/imap logins, or alternately smtp logins. if (preg_match('/(.*) (imap|pop3)-login: Login: user=\<([\w\.@-]+)\>/', $line, $matches) || preg_match('/(.*) sasl_method=PLAIN, sasl_username=([\w\.@-]+)/', $line, $matches)) { $user = isset($matches[3]) ? $matches[3] : $matches[2]; $updatedUsers[] = $user; } // get the next line - $line = strtok(PHP_EOL); + $line = strtok($log_lines, PHP_EOL); } $uniqueUsers = array_unique($updatedUsers); -- GitLab From cafdd67b148de4d3f8e5ba60afe42e3a4b448c2c Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 04:32:36 +0200 Subject: [PATCH 07/17] fix undefined variable errors #5226 #6563 --- .../classes/cron.d/500-backup_mail.inc.php | 22 +++++++++---------- .../cron.d/500-clean_mailboxes.inc.php | 8 +++---- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/server/lib/classes/cron.d/500-backup_mail.inc.php b/server/lib/classes/cron.d/500-backup_mail.inc.php index 92051fa4f2..77c47e7e84 100644 --- a/server/lib/classes/cron.d/500-backup_mail.inc.php +++ b/server/lib/classes/cron.d/500-backup_mail.inc.php @@ -58,7 +58,7 @@ class cronjob_backup_mail extends cronjob { $server_config = $app->getconf->get_server_config($conf['server_id'], 'server'); $mail_config = $app->getconf->get_server_config($conf['server_id'], 'mail'); $global_config = $app->getconf->get_global_config('sites'); - + $backup_dir = trim($server_config['backup_dir']); $backup_dir_permissions =0750; @@ -92,7 +92,7 @@ class cronjob_backup_mail extends cronjob { $domain_rec=$app->db->queryOneRecord("SELECT * FROM mail_domain WHERE domain = ?", $domain); if($rec['backup_interval'] == 'daily' or ($rec['backup_interval'] == 'weekly' && date('w') == 0) or ($rec['backup_interval'] == 'monthly' && date('d') == '01')) { - + $backupusername = 'root'; $backupgroup = 'root'; if ($global_config['backups_include_into_web_quota'] == 'y') { @@ -112,7 +112,7 @@ class cronjob_backup_mail extends cronjob { $backupusername = $webdomain['system_user']; $backupgroup = $webdomain['system_group']; } - } + } $mail_backup_dir = $backup_dir.'/mail'.$domain_rec['domain_id']; if(!is_dir($mail_backup_dir)) mkdir($mail_backup_dir, 0750); @@ -127,7 +127,7 @@ class cronjob_backup_mail extends cronjob { if (empty($this->tmp_backup_dir)) $this->tmp_backup_dir = $rec['maildir']; // Create temporary backup-mailbox $app->system->exec_safe("su -c ?", 'dsync -o plugin/acl= -o plugin/quota= backup -u "'.$rec["email"].'" mdbox:' . $this->tmp_backup_dir . '/backup'); - + if($backup_mode == 'userzip') { $mail_backup_file.='.zip'; $app->system->exec_safe('cd ? && zip ? -b ? -r backup > /dev/null && rm -rf backup', $this->tmp_backup_dir, $mail_backup_dir.'/'.$mail_backup_file, $backup_tmp); @@ -142,7 +142,7 @@ class cronjob_backup_mail extends cronjob { $retval = $app->system->last_exec_retcode(); } } - + if ($retval != 0) { // Cleanup if(file_exists($this->tmp_backup_dir . '/backup')) { @@ -154,11 +154,11 @@ class cronjob_backup_mail extends cronjob { $domain_dir=explode('/',$rec['maildir']); $_temp=array_pop($domain_dir);unset($_temp); $domain_dir=implode('/',$domain_dir); - + $parts=explode('/',$rec['maildir']); $source_dir=array_pop($parts); unset($parts); - + //* create archives if($backup_mode == 'userzip') { $mail_backup_file.='.zip'; @@ -175,15 +175,15 @@ class cronjob_backup_mail extends cronjob { $retval = $app->system->last_exec_retcode(); } } - - if($retval == 0 || ($backup_mode != 'userzip' && $retval == 1) || ($backup_mode == 'userzip' && $retval == 12)){// tar can return 1, zip can return 12(due to harmless warings) and still create valid backups + + if($retval == 0 || ($backup_mode != 'userzip' && $retval == 1) || ($backup_mode == 'userzip' && $retval == 12)){// tar can return 1, zip can return 12(due to harmless warnings) and still create valid backups chown($mail_backup_dir.'/'.$mail_backup_file, $backupusername); chgrp($mail_backup_dir.'/'.$mail_backup_file, $backupgroup); chmod($mail_backup_dir.'/'.$mail_backup_file, 0640); /* Insert mail backup record in database */ $filesize = filesize($mail_backup_dir.'/'.$mail_backup_file); $sql = "INSERT INTO mail_backup (server_id, parent_domain_id, mailuser_id, backup_mode, tstamp, filename, filesize) VALUES (?, ?, ?, ?, ?, ?, ?)"; - $app->db->query($sql, $conf['server_id'], $domain_rec['domain_id'], $rec['mailuser_id'], $backup_mode, time(), $mail_backup_file, $filesize); + $app->db->query($sql, $conf['server_id'], $domain_rec['domain_id'], $rec['mailuser_id'], $backup_mode, time(), $mail_backup_file, $filesize); if($app->running_on_slaveserver()) $app->dbmaster->query($sql, $conf['server_id'], $domain_rec['domain_id'], $rec['mailuser_id'], $backup_mode, time(), $mail_backup_file, $filesize); unset($filesize); } else { @@ -195,7 +195,7 @@ class cronjob_backup_mail extends cronjob { $app->system->exec_safe('rm -rf ?', $rec['maildir'] . '/backup'); } } - $app->log($mail_backup_file.' NOK:'.implode('',$tmp_output), LOGLEVEL_WARN); + $app->log($mail_backup_file.' NOK:'.implode('',$app->system->last_exec_out()), LOGLEVEL_WARN); } /* Remove old backups */ $backup_copies = intval($rec['backup_copies']); diff --git a/server/lib/classes/cron.d/500-clean_mailboxes.inc.php b/server/lib/classes/cron.d/500-clean_mailboxes.inc.php index a1a053fcca..93a47246fa 100755 --- a/server/lib/classes/cron.d/500-clean_mailboxes.inc.php +++ b/server/lib/classes/cron.d/500-clean_mailboxes.inc.php @@ -31,8 +31,8 @@ EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. class cronjob_clean_mailboxes extends cronjob { // should run before quota notify and backup - // quota notify and backup is both '0 0 * * *' - + // quota notify and backup is both '0 0 * * *' + // job schedule protected $_schedule = '00 22 * * *'; @@ -77,7 +77,7 @@ class cronjob_clean_mailboxes extends cronjob { WHERE maildir_format = 'maildir' AND disableimap = 'n' AND server_id = ? AND (purge_trash_days > 0 OR purge_junk_days > 0)", $server_id); - + if(is_array($records) && !empty($records)) { foreach($records as $email) { @@ -147,7 +147,7 @@ class cronjob_clean_mailboxes extends cronjob { global $app, $conf; $sql = "SELECT email FROM mail_user WHERE maildir_format = 'mdbox' AND server_id = ?"; - $records = $app->db->queryAllRecords($sql, $server_id); + $records = $app->db->queryAllRecords($sql, $conf['server_id']); if(is_array($records)) { foreach($records as $rec) { -- GitLab From 2fc38b9f92c4cc7b9ddad6b75b6a2a607cddc21f Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 20:37:01 +0200 Subject: [PATCH 08/17] make PHP 8.3 compatible (no nested ternary expressions without braces) #5226 #6563 --- server/cli/modules/letsencrypt.inc.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/server/cli/modules/letsencrypt.inc.php b/server/cli/modules/letsencrypt.inc.php index 6557f250c4..c4e9b8d5d5 100644 --- a/server/cli/modules/letsencrypt.inc.php +++ b/server/cli/modules/letsencrypt.inc.php @@ -99,7 +99,11 @@ class letsencrypt_cli extends cli { ['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)], + ['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'])], -- GitLab From 8ca4b160715ac7986aac7b71a6557f07a44f2f6f Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 20:43:29 +0200 Subject: [PATCH 09/17] Fixes for acme.sh support #5226 #6563 --- server/lib/classes/letsencrypt.inc.php | 107 +++++++++++++------------ 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/server/lib/classes/letsencrypt.inc.php b/server/lib/classes/letsencrypt.inc.php index c8f6f31d7c..2c00b2f5fd 100644 --- a/server/lib/classes/letsencrypt.inc.php +++ b/server/lib/classes/letsencrypt.inc.php @@ -87,8 +87,8 @@ class letsencrypt { '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)), + 'if [ $R -eq 0 ] || [ $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 ]', @@ -521,41 +521,71 @@ class letsencrypt { $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 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); - return []; - } - $info = $this->parse_env_file($info); - $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); + // Use an inline shell script to get the configured acme.sh certificate home. + // We use a shell script because acme.sh config file is a shell script itself - to support even dynamic configs, we will evaluate the config file. + // The used --info command was not always there, so we try to auto-upgrade acme.sh when the command fails + $home_extract_cmd = join(' ; ', [ + '_info() { :', + ' _info_stdout=$(' . escapeshellarg($shell_script) . ' --info 2>/dev/null)', + ' _info_ret=$?', + '}', + '_echo_home() { :', + ' eval "$_info_stdout"', + ' _info_ret=$?', + ' if [ $_info_ret -eq 0 ]; then :', + ' if [ -z "$CERT_HOME" ]', + ' then echo "$LE_CONFIG_HOME"', + ' else echo "$CERT_HOME"', + ' fi', + ' else :', + ' echo "Error eval-ing --info output (exit code $_info_ret). stdout was: $_info_stdout"', + ' exit 1', + ' fi', + '}', + '_info', + 'if [ $_info_ret -eq 0 ]; then :', + ' _echo_home', + 'else :', + ' if ' . escapeshellarg($shell_script) . ' --upgrade 2>&1; then :', + ' _info', + ' if [ $_info_ret -eq 0 ]; then :', + ' _echo_home', + ' else :', + ' echo "--info failed (exit code $_info_ret). stdout was: $_info_stdout"', + ' exit 1', + ' fi', + ' else :', + ' echo "--info failed (exit code $_info_ret) and auto-upgrade failed, too. Initial info stdout was: $_info_stdout"', + ' exit 1', + ' fi', + 'fi', + ]); + $ret = 0; + $cert_home = []; + exec($home_extract_cmd, $cert_home, $ret); + $cert_home = trim(implode("\n", $cert_home)); + if($ret != 0 || empty($cert_home) || !is_dir($cert_home)) { + $app->log('get_certificate_list: could not find certificate home. Error: ' . $cert_home . '. Command used: ' . $home_extract_cmd, LOGLEVEL_ERROR); return []; } - $dir = opendir($cert_dir); + $app->log('get_certificate_list: discovered cert home as ' . $cert_home . '. Command used: ' . $home_extract_cmd, LOGLEVEL_DEBUG); + $dir = opendir($cert_home); 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_home, LOGLEVEL_ERROR); return []; } while($path = readdir($dir)) { + $full_path = $cert_home . '/' . $path; // valid conf dirs have a . in them - if($path === '.' || $path === '..' || strpos($path, '.') === false) { - continue; - } - $full_path = $cert_dir . '/' . $path; - if(!is_dir($full_path)) { + if($path === '.' || $path === '..' || strpos($path, '.') === false || !is_dir($full_path)) { continue; } $domain = $path; if(preg_match('/_ecc$/', $path)) { $domain = substr($path, 0, -4); } - if(!$this->is_readable_link_or_file("$full_path/$domain.conf")) { + if(!$this->is_readable_link_or_file($full_path . '/' . $domain . '.conf')) { + $app->log('get_certificate_list: skip ' . $full_path . '/' . $domain . '.conf because it is not readable', LOGLEVEL_DEBUG); continue; } $candidates[] = [ @@ -666,7 +696,7 @@ class letsencrypt { } } else { if(is_dir($certificate['conf'])) { - if(!$app->system->rmdir($certificate['conf'], false)) { + if(!$app->system->rmdir($certificate['conf'], true)) { $app->log('remove_certificate: could not delete config folder ' . $certificate['conf'], LOGLEVEL_WARN); return false; } @@ -738,7 +768,7 @@ class letsencrypt { $signature_type = 'ECDSA'; } return [ - 'serial_number' => $info['serialNumber'], + 'serial_number' => $info['serialNumberHex'] ?: $info['serialNumber'], 'signature_type' => $signature_type, 'subject' => $info['subject'], 'issuer' => $info['issuer'], @@ -762,29 +792,4 @@ class letsencrypt { 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) { - $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) == '#') { - continue; - } - $parts = explode('=', $line, 2); - if(count($parts) < 2) { - continue; - } - $key = trim($parts[0]); - $value = trim($parts[1]); - if(preg_match('/^"(.*)"$/', $value, $matches)) { - $value = $matches[1]; - } elseif(preg_match("/^'(.*)'$/", $value, $matches)) { - $value = $matches[1]; - } - $variables[$key] = $value; - } - return $variables; - } } -- GitLab From f4eac0b9b3658442967e420af36e404d6ffabcba Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 22:55:24 +0200 Subject: [PATCH 10/17] Better domain glob validation #5226 #6563 allows globs like `web?.*`, `web[0-9].*`, `web[!34].*` and rejects globs like `invalid..domain` or `this is not a domain name` --- interface/lib/classes/validate_domain.inc.php | 95 ++++++++++++++++++- .../web/admin/form/server_config.tform.php | 1 + 2 files changed, 91 insertions(+), 5 deletions(-) diff --git a/interface/lib/classes/validate_domain.inc.php b/interface/lib/classes/validate_domain.inc.php index 53d8856368..0378b2ee20 100644 --- a/interface/lib/classes/validate_domain.inc.php +++ b/interface/lib/classes/validate_domain.inc.php @@ -278,26 +278,111 @@ class validate_domain { return true; // admin may always add wildcard domain } + /** + * Parses $expression to check if it is a valid shell glob pattern. + * Does not support extended glob matching syntax. + * + * @see https://www.gnu.org/software/bash/manual/html_node/Pattern-Matching.html + * @param string $expression + * @param string $allowed_chars regexp of allowed characters in expression. ? and * are always allowed + * @param string|null $allowed_brace_chars regexp of allowed characters in brace ([...]). Dash is always allowed. If empty, then $allowed_chars will be used + * @return bool + */ + private function validate_glob($expression, $allowed_chars = '/^.$/u', $allowed_brace_chars = null) { + $escaping = false; + $in_brace = false; + $brace_content = []; + $chars = preg_split('//u', $expression, -1, PREG_SPLIT_NO_EMPTY); + foreach($chars as $i => $c) { + if($in_brace) { + // the first char after brace start can be a ]. + if(($c == ']' && empty($brace_content)) || $c != ']') { + $brace_content[] = $c; + } else { + $in_brace = false; + $last_is_dash = false; + foreach($brace_content as $bi => $bc) { + // dashes are always allowed + if($bc == '-') { + // ... but we consider consecutive dashes as invalid + if($last_is_dash) { + return false; + } + // ... and need to validate it as allowed char when it is first or last + if(($bi == 0 || $bi == count($brace_content) - 1) && !preg_match($allowed_brace_chars ?: $allowed_chars, '-')) { + return false; + } + $last_is_dash = true; + } else { + $last_is_dash = false; + // negate chars are always allowed + if($bi == 0 && ($bc == '^' || $bc == '!') && count($brace_content) > 1) { + continue; + } + if(!preg_match($allowed_brace_chars ?: $allowed_chars, $bc)) { + return false; + } + } + } + } + } else { + $peek = $i == count($chars) - 1 ? '' : $chars[$i + 1]; + if($c == '\\' && in_array($peek, ['[', ']', '*', '?'])) { + $escaping = true; + continue; + } elseif($c == '[' && !$escaping) { + $in_brace = true; + $brace_content = []; + } elseif($escaping || ($c != '?' && $c != '*')) { + if(!preg_match($allowed_chars, $c)) { + return false; + } + } + $escaping = false; + } + } + return !$in_brace && !$escaping; + } + /** * Validates that input is a comma separated list of domain globs. + * Can be used for fnmatch() as input. */ function domain_glob_list($field_name, $field_value, $validator) { global $app; $allowempty = $validator['allowempty'] ?: 'n'; $exceptions = $validator['exceptions'] ?: []; - if (!$field_value) { - if ($allowempty == 'y') { + $allow_exception_as_substring = $validator['allow_exception_as_substring'] ?: 'y'; + if(!$field_value) { + if($allowempty == 'y') { return ''; } return $this->get_error($validator['errmsg']); } $parts = explode(',', $field_value); - foreach ($parts as $part) { + foreach($parts as $part) { $part = trim($part); - if (in_array($part, $exceptions, true)) { + // an empty part means there is a stray comma + if(empty($part)) { + return $this->get_error($validator['errmsg']); + } + // allow list placeholders that you will replace with real values at evaluation + if(in_array($part, $exceptions, true)) { continue; } - if (!preg_match("/^[a-z0-9*._-]+$/i", $part) || !filter_var($part, FILTER_VALIDATE_DOMAIN)) { + // optionally do not allow placeholders to be part of an expression + if($allow_exception_as_substring == 'n') { + foreach($exceptions as $exception) { + if(strpos($part, $exception) !== false) { + return $this->get_error($validator['errmsg']); + } + } + } + // A domain glob needs to: + // * be a valid glob with only a-z0-9._- as characters + // * have at least one dot in it + // * not have two consecutive dots + if(!$this->validate_glob($part, '/^[a-z0-9._-]$/ui') || strpos($part, '.') === false || strpos($part, '..') !== false) { return $this->get_error($validator['errmsg']); } } diff --git a/interface/web/admin/form/server_config.tform.php b/interface/web/admin/form/server_config.tform.php index 1f641166a8..a3b673bfd8 100644 --- a/interface/web/admin/form/server_config.tform.php +++ b/interface/web/admin/form/server_config.tform.php @@ -1653,6 +1653,7 @@ $form["tabs"]['web'] = array( 'function' => 'domain_glob_list', 'allowempty' => 'y', 'exceptions' => array('[server_name]'), + 'allow_exception_as_substring' => 'n', 'errmsg'=> 'le_auto_cleanup_denylist_error_custom' ), ), -- GitLab From 8fd4e620379dc65e05b4364d85d3ea02632c2fa9 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Sat, 27 Jul 2024 23:26:30 +0200 Subject: [PATCH 11/17] Update *_server_config.lng languages workbooks #5226 #6563 Following files had inconsistency, that I fixed, too: * tr_server_config.lng: key `monitor_system_updates_txt` two time, missing keys * it_server_config.lng: missing key * cz_server_config.lng: value in rescue_description_txt had unnecessary escape for " * cn_server_config.lng: multiple keys two times, missing keys --- .../web/admin/lib/lang/ar_server_config.lng | 5 +++++ .../web/admin/lib/lang/bg_server_config.lng | 5 +++++ .../web/admin/lib/lang/br_server_config.lng | 5 +++++ .../web/admin/lib/lang/ca_server_config.lng | 5 +++++ .../web/admin/lib/lang/cn_server_config.lng | 19 ++++++++++++------- .../web/admin/lib/lang/cz_server_config.lng | 7 ++++++- .../web/admin/lib/lang/de_server_config.lng | 5 +++++ .../web/admin/lib/lang/dk_server_config.lng | 5 +++++ .../web/admin/lib/lang/el_server_config.lng | 5 +++++ .../web/admin/lib/lang/es_server_config.lng | 5 +++++ .../web/admin/lib/lang/fi_server_config.lng | 5 +++++ .../web/admin/lib/lang/fr_server_config.lng | 5 +++++ .../web/admin/lib/lang/hr_server_config.lng | 5 +++++ .../web/admin/lib/lang/hu_server_config.lng | 5 +++++ .../web/admin/lib/lang/id_server_config.lng | 5 +++++ .../web/admin/lib/lang/it_server_config.lng | 6 ++++++ .../web/admin/lib/lang/ja_server_config.lng | 5 +++++ .../web/admin/lib/lang/nl_server_config.lng | 5 +++++ .../web/admin/lib/lang/pl_server_config.lng | 7 ++++++- .../web/admin/lib/lang/pt_server_config.lng | 5 +++++ .../web/admin/lib/lang/ro_server_config.lng | 5 +++++ .../web/admin/lib/lang/ru_server_config.lng | 5 +++++ .../web/admin/lib/lang/se_server_config.lng | 5 +++++ .../web/admin/lib/lang/sk_server_config.lng | 5 +++++ .../web/admin/lib/lang/tr_server_config.lng | 12 ++++++++++-- 25 files changed, 140 insertions(+), 11 deletions(-) diff --git a/interface/web/admin/lib/lang/ar_server_config.lng b/interface/web/admin/lib/lang/ar_server_config.lng index 4a0d25ff05..cd6ab9869b 100644 --- a/interface/web/admin/lib/lang/ar_server_config.lng +++ b/interface/web/admin/lib/lang/ar_server_config.lng @@ -363,3 +363,8 @@ $wb['sysbackup_copies_txt'] = 'Número de copias de seguridad del sistema'; $wb['sysbackup_copies_error_empty'] = 'El número de copias de seguridad del sistema no debe estar vacío'; $wb['sysbackup_copies_error_regex'] = 'El número de copias de seguridad del sistema debe ser un número entre 1 y 3'; $wb['sysbackup_copies_note_txt'] = '(0 = desactivado)'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/bg_server_config.lng b/interface/web/admin/lib/lang/bg_server_config.lng index 2eae0f276d..772b3c32c0 100644 --- a/interface/web/admin/lib/lang/bg_server_config.lng +++ b/interface/web/admin/lib/lang/bg_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/br_server_config.lng b/interface/web/admin/lib/lang/br_server_config.lng index 6ead4c174f..66a6963691 100644 --- a/interface/web/admin/lib/lang/br_server_config.lng +++ b/interface/web/admin/lib/lang/br_server_config.lng @@ -364,3 +364,8 @@ $wb['sysbackup_copies_error_empty'] = 'O número de copias de seguridad do siste $wb['sysbackup_copies_error_regex'] = 'O número de copias de seguridad do sistema deve ser um número entre 1 e 3'; $wb['sysbackup_copies_note_txt'] = '(0 = desativado)'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/ca_server_config.lng b/interface/web/admin/lib/lang/ca_server_config.lng index 972a624a13..d1c9fd1c17 100644 --- a/interface/web/admin/lib/lang/ca_server_config.lng +++ b/interface/web/admin/lib/lang/ca_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/cn_server_config.lng b/interface/web/admin/lib/lang/cn_server_config.lng index 7f3f69b16c..db9b473283 100644 --- a/interface/web/admin/lib/lang/cn_server_config.lng +++ b/interface/web/admin/lib/lang/cn_server_config.lng @@ -52,13 +52,8 @@ $wb['relayhost_txt'] = '中继主机'; $wb['relayhost_user_txt'] = '中继主机用户名'; $wb['relayhost_password_txt'] = '中继主机密码'; $wb['reject_sender_login_mismatch_txt'] = '拒绝发件人和登录用户不匹配'; -$wb['reject_unknown_txt'] = '拒绝未知主机名'; -$wb['tooltip_reject_unknown_txt'] = '需要主机名通过DNS检查。对经过身份验证的用户不进行检查。'; $wb['reject_unknown_txt'] = '拒绝未知的helo主机名'; $wb['tooltip_reject_unknown_txt'] = '需要主机名通过DNS检测。对已认证用户不进行检查。'; -$wb['reject_unknown_helo_txt'] = '拒绝未知的 helo 主机名'; -$wb['reject_unknown_client_txt'] = '拒绝未知的客户端主机名'; -$wb['reject_unknown_client_helo_txt'] = '拒绝未知的helo和客户端主机名'; $wb['reject_unknown_helo_txt'] = '拒绝未知的helo主机名'; $wb['reject_unknown_client_txt'] = '拒绝未知的客户端主机名'; $wb['reject_unknown_client_helo_txt'] = '拒绝未知的helo和客户端主机名'; @@ -241,13 +236,12 @@ $wb['overquota_db_notify_threshold_txt'] = 'DB配额警告使用水平'; $wb['overquota_db_notify_admin_txt'] = '向管理员发送DB配额警告'; $wb['overquota_db_notify_reseller_txt'] = '向经销商发送DB配额警告'; $wb['overquota_db_notify_client_txt'] = '向客户发送DB配额警告'; -$wb['monitor_system_updates_txt'] = '检查Linux更新'; +$wb['monitor_system_updates_txt'] = '检查 Linux 更新'; $wb['php_handler_txt'] = '默认的 PHP 处理器'; $wb['php_fpm_default_chroot_txt'] = '默认的 PHP-FPM chroot'; $wb['php_fpm_incron_reload_txt'] = '安装 incron 触发器文件以重新加载 PHP-FPM'; $wb['disabled_txt'] = '已禁用'; $wb['dkim_strength_txt'] = 'DKIM 强度'; -$wb['monitor_system_updates_txt'] = '检查 Linux 更新'; $wb['invalid_apache_user_txt'] = '无效的 Apache 用户。'; $wb['invalid_apache_group_txt'] = '无效的 Apache 组。'; $wb['backup_dir_error_regex'] = '无效的备份目录。'; @@ -363,3 +357,14 @@ $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['dkim_path_txt'] = 'DKIM Path'; +$wb['bind_zonefiles_masterprefix_txt'] = 'BIND master zonefiles prefix'; +$wb['bind_zonefiles_slaveprefix_txt'] = 'BIND slave zonefiles prefix'; +$wb['bind_zonefiles_masterprefix_error_regex'] = 'Invalid BIND zonefiles master prefix.'; +$wb['bind_zonefiles_slaveprefix_error_regex'] = 'Invalid BIND zonefiles slave prefix.'; +$wb['vhost_proxy_protocol_protocols_txt'] = 'Use PROXY Protocol on'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/cz_server_config.lng b/interface/web/admin/lib/lang/cz_server_config.lng index e89c37d206..ebc082e860 100644 --- a/interface/web/admin/lib/lang/cz_server_config.lng +++ b/interface/web/admin/lib/lang/cz_server_config.lng @@ -150,7 +150,7 @@ $wb['php_fpm_socket_dir_error_empty'] = 'PHP-FPM adresář pro socket je prázdn $wb['try_rescue_txt'] = 'Povolit monitorování služeb a restartovat při selhání'; $wb['do_not_try_rescue_mysql_txt'] = 'Zakázat MySQL monitorování'; $wb['do_not_try_rescue_mail_txt'] = 'Zakázat E-mail monitorování'; -$wb['rescue_description_txt'] = 'Informace: Pokud chcete např. vypnout MySQL službu zatrhněte políčko \"Zakázat MySQL monitorování\" změna se provede do 2-3 minut.
Pokud nepočkáte 2-3 minuty, monitorování nastartuje službu MySQL automaticky znovu !'; +$wb['rescue_description_txt'] = 'Informace: Pokud chcete např. vypnout MySQL službu zatrhněte políčko "Zakázat MySQL monitorování" změna se provede do 2-3 minut.
Pokud nepočkáte 2-3 minuty, monitorování nastartuje službu MySQL automaticky znovu !'; $wb['enable_sni_txt'] = 'Aktivovat SNI (Server Name Indication)'; $wb['do_not_try_rescue_httpd_txt'] = 'Zakázat HTTPD monitorování'; $wb['set_folder_permissions_on_update_txt'] = 'Nastavení oprávnění složky při aktualizaci'; @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/de_server_config.lng b/interface/web/admin/lib/lang/de_server_config.lng index f847714453..557835f492 100644 --- a/interface/web/admin/lib/lang/de_server_config.lng +++ b/interface/web/admin/lib/lang/de_server_config.lng @@ -363,3 +363,8 @@ $wb['sysbackup_copies_txt'] = 'Anzahl der ISPConfig Sicherungen (0 = aus)'; $wb['sysbackup_copies_error_empty'] = 'Anzahl der ISPConfig Sicherungen darf nicht leer sein'; $wb['sysbackup_copies_error_regex'] = 'Anzahl der ISPConfig Sicherungen muss eine Zahl zwischen 1 und 3 sein'; $wb['sysbackup_copies_note_txt'] = '(0 = aus)'; +$wb['le_signature_type_txt'] = 'Zertifikat Signaturtyp'; +$wb['le_auto_cleanup_txt'] = 'Entferne unbenutzte Let\'s Encrypt Zertifikate automatisch'; +$wb['le_auto_cleanup_denylist_txt'] = 'Domains die nie entfernt werden sollen'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Komma-separierte Liste von Domain-Globs, die niemals entfernt werden sollen.
Z.B. mail.*, externally-managed.example.com
Platzhalter:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Domain-Glob Liste ungültig'; diff --git a/interface/web/admin/lib/lang/dk_server_config.lng b/interface/web/admin/lib/lang/dk_server_config.lng index 965a1d897f..c6304352b4 100644 --- a/interface/web/admin/lib/lang/dk_server_config.lng +++ b/interface/web/admin/lib/lang/dk_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/el_server_config.lng b/interface/web/admin/lib/lang/el_server_config.lng index ae8b948c76..4669efd5f6 100644 --- a/interface/web/admin/lib/lang/el_server_config.lng +++ b/interface/web/admin/lib/lang/el_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/es_server_config.lng b/interface/web/admin/lib/lang/es_server_config.lng index e42fb1c992..91dc60dff7 100644 --- a/interface/web/admin/lib/lang/es_server_config.lng +++ b/interface/web/admin/lib/lang/es_server_config.lng @@ -363,3 +363,8 @@ $wb['sysbackup_copies_txt'] = 'Número de copias de seguridad del sistema'; $wb['sysbackup_copies_error_empty'] = 'El número de copias de seguridad del sistema no debe estar vacío'; $wb['sysbackup_copies_error_regex'] = 'El número de copias de seguridad del sistema debe ser un número entre 1 y 3'; $wb['sysbackup_copies_note_txt'] = '(0 = desactivado)'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/fi_server_config.lng b/interface/web/admin/lib/lang/fi_server_config.lng index 1eba458c5f..fb6a6846b5 100644 --- a/interface/web/admin/lib/lang/fi_server_config.lng +++ b/interface/web/admin/lib/lang/fi_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/fr_server_config.lng b/interface/web/admin/lib/lang/fr_server_config.lng index 60508917ee..a112d05608 100644 --- a/interface/web/admin/lib/lang/fr_server_config.lng +++ b/interface/web/admin/lib/lang/fr_server_config.lng @@ -363,3 +363,8 @@ $wb['sysbackup_copies_txt'] = 'Nombre de sauvegardes système'; $wb['sysbackup_copies_error_empty'] = 'Le nombre de sauvegardes système ne doit pas être vide'; $wb['sysbackup_copies_error_regex'] = 'Le nombre de sauvegardes système doit être un nombre entre 1 et 3'; $wb['sysbackup_copies_note_txt'] = '(0 = désactivé)'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/hr_server_config.lng b/interface/web/admin/lib/lang/hr_server_config.lng index 2966dfd01c..c5e5c74e91 100644 --- a/interface/web/admin/lib/lang/hr_server_config.lng +++ b/interface/web/admin/lib/lang/hr_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/hu_server_config.lng b/interface/web/admin/lib/lang/hu_server_config.lng index 722e33d697..76989ff834 100644 --- a/interface/web/admin/lib/lang/hu_server_config.lng +++ b/interface/web/admin/lib/lang/hu_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/id_server_config.lng b/interface/web/admin/lib/lang/id_server_config.lng index ce1d7af21e..fdf084e63a 100644 --- a/interface/web/admin/lib/lang/id_server_config.lng +++ b/interface/web/admin/lib/lang/id_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/it_server_config.lng b/interface/web/admin/lib/lang/it_server_config.lng index 53b694087c..db2c51780a 100644 --- a/interface/web/admin/lib/lang/it_server_config.lng +++ b/interface/web/admin/lib/lang/it_server_config.lng @@ -362,3 +362,9 @@ $wb['sysbackup_copies_txt'] = 'Numero di backup di sistema'; $wb['sysbackup_copies_error_empty'] = 'Il numero di backup di sistema non deve essere vuoto'; $wb['sysbackup_copies_error_regex'] = 'Il numero di backup di sistema deve essere un numero compreso tra 1 e 3'; $wb['sysbackup_copies_note_txt'] = '(0 = disattivato)'; +$wb['overquota_db_notify_threshold_txt'] = 'DB quota warning usage level'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/ja_server_config.lng b/interface/web/admin/lib/lang/ja_server_config.lng index 5cb1e4dd0c..17eb9644aa 100644 --- a/interface/web/admin/lib/lang/ja_server_config.lng +++ b/interface/web/admin/lib/lang/ja_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/nl_server_config.lng b/interface/web/admin/lib/lang/nl_server_config.lng index 3378f37bc2..7bf308f259 100644 --- a/interface/web/admin/lib/lang/nl_server_config.lng +++ b/interface/web/admin/lib/lang/nl_server_config.lng @@ -363,3 +363,8 @@ $wb['sysbackup_copies_txt'] = 'Aantal systeemback-ups'; $wb['sysbackup_copies_error_empty'] = 'Aantal systeemback-ups mag niet leeg zijn'; $wb['sysbackup_copies_error_regex'] = 'Aantal systeemback-ups moet een getal tussen 1 en 3 zijn'; $wb['sysbackup_copies_note_txt'] = '(0 = uit)'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/pl_server_config.lng b/interface/web/admin/lib/lang/pl_server_config.lng index 2d7b4bf239..66d18aed6f 100644 --- a/interface/web/admin/lib/lang/pl_server_config.lng +++ b/interface/web/admin/lib/lang/pl_server_config.lng @@ -362,4 +362,9 @@ $wb['soft_delete_keep_365_txt'] = 'Purge after 365 days'; $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)'; \ No newline at end of file +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/pt_server_config.lng b/interface/web/admin/lib/lang/pt_server_config.lng index 2ec1c9be08..3683a69025 100644 --- a/interface/web/admin/lib/lang/pt_server_config.lng +++ b/interface/web/admin/lib/lang/pt_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/ro_server_config.lng b/interface/web/admin/lib/lang/ro_server_config.lng index b0869c4793..7481a3e8f8 100644 --- a/interface/web/admin/lib/lang/ro_server_config.lng +++ b/interface/web/admin/lib/lang/ro_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/ru_server_config.lng b/interface/web/admin/lib/lang/ru_server_config.lng index 1246a93d7f..12d2b97d07 100644 --- a/interface/web/admin/lib/lang/ru_server_config.lng +++ b/interface/web/admin/lib/lang/ru_server_config.lng @@ -363,3 +363,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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/se_server_config.lng b/interface/web/admin/lib/lang/se_server_config.lng index 08addb72c7..89e7c03e7f 100644 --- a/interface/web/admin/lib/lang/se_server_config.lng +++ b/interface/web/admin/lib/lang/se_server_config.lng @@ -359,3 +359,8 @@ $wb['soft_delete_keep_7_txt'] = 'Purge after 7 days'; $wb['soft_delete_keep_30_txt'] = 'Purge after 30 days'; $wb['soft_delete_keep_90_txt'] = 'Purge after 90 days'; $wb['soft_delete_keep_365_txt'] = 'Purge after 365 days'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/sk_server_config.lng b/interface/web/admin/lib/lang/sk_server_config.lng index ae5c4c7ae5..f84701bdd9 100644 --- a/interface/web/admin/lib/lang/sk_server_config.lng +++ b/interface/web/admin/lib/lang/sk_server_config.lng @@ -359,3 +359,8 @@ $wb['soft_delete_keep_7_txt'] = 'Purge after 7 days'; $wb['soft_delete_keep_30_txt'] = 'Purge after 30 days'; $wb['soft_delete_keep_90_txt'] = 'Purge after 90 days'; $wb['soft_delete_keep_365_txt'] = 'Purge after 365 days'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; diff --git a/interface/web/admin/lib/lang/tr_server_config.lng b/interface/web/admin/lib/lang/tr_server_config.lng index 0c5bfdfc7f..dc3ca92fe5 100644 --- a/interface/web/admin/lib/lang/tr_server_config.lng +++ b/interface/web/admin/lib/lang/tr_server_config.lng @@ -234,12 +234,11 @@ $wb['overquota_db_notify_threshold_txt'] = 'DB quota warning usage level'; $wb['overquota_db_notify_admin_txt'] = 'Veritabanı Kotası Bildirimleri Yöneticiye Gönderilsin'; $wb['overquota_db_notify_reseller_txt'] = 'Send DB quota warnings to reseller'; $wb['overquota_db_notify_client_txt'] = 'Veritabanı Kotası Bildirimleri Müşteriye Gönderilsin'; -$wb['monitor_system_updates_txt'] = 'Linux Güncellemeleri Denetlensin'; +$wb['monitor_system_updates_txt'] = 'Linux Güncelleme Denetimi'; $wb['php_handler_txt'] = 'Varsayılan PHP İşleyici'; $wb['php_fpm_default_chroot_txt'] = 'Default chrooted PHP-FPM'; $wb['disabled_txt'] = 'Devre Dışı'; $wb['dkim_strength_txt'] = 'DKIM zorluğu'; -$wb['monitor_system_updates_txt'] = 'Linux Güncelleme Denetimi'; $wb['invalid_apache_user_txt'] = 'Apache kullanıcısı geçersiz.'; $wb['invalid_apache_group_txt'] = 'Apache grubu geçersiz.'; $wb['backup_dir_error_regex'] = 'Yedek klasörü geçersiz.'; @@ -356,3 +355,12 @@ $wb['soft_delete_keep_7_txt'] = 'Purge after 7 days'; $wb['soft_delete_keep_30_txt'] = 'Purge after 30 days'; $wb['soft_delete_keep_90_txt'] = 'Purge after 90 days'; $wb['soft_delete_keep_365_txt'] = 'Purge after 365 days'; +$wb['php_fpm_incron_reload_txt'] = 'Install incron trigger file to reload PHP-FPM'; +$wb['error_mailbox_message_size_txt'] = 'Mailbox size must be larger or equal to message size'; +$wb['php_fpm_reload_mode_txt'] = 'PHP-FPM reload mode'; +$wb['content_filter_txt'] = 'Content Filter'; +$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'] = 'Domains that should never be purged'; +$wb['le_auto_cleanup_denylist_note_txt'] = 'Comma separated list of domain globs that should not be purged.
E.g. mail.*, externally-managed.example.com
Placeholders:'; +$wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; -- GitLab From 9af874690b35c6e46811d3543903c10d490dc0e1 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Mon, 29 Jul 2024 22:54:07 +0200 Subject: [PATCH 12/17] LE: add "Revoke before delete" and "Delete on Site delete" options #5226 #6563 --- install/tpl/server.ini.master | 2 + .../web/admin/form/server_config.tform.php | 12 ++ .../web/admin/lib/lang/ar_server_config.lng | 2 + .../web/admin/lib/lang/bg_server_config.lng | 2 + .../web/admin/lib/lang/br_server_config.lng | 2 + .../web/admin/lib/lang/ca_server_config.lng | 2 + .../web/admin/lib/lang/cn_server_config.lng | 2 + .../web/admin/lib/lang/cz_server_config.lng | 2 + .../web/admin/lib/lang/de_server_config.lng | 2 + .../web/admin/lib/lang/dk_server_config.lng | 2 + .../web/admin/lib/lang/el_server_config.lng | 2 + .../web/admin/lib/lang/en_server_config.lng | 2 + .../web/admin/lib/lang/es_server_config.lng | 2 + .../web/admin/lib/lang/fi_server_config.lng | 2 + .../web/admin/lib/lang/fr_server_config.lng | 2 + .../web/admin/lib/lang/hr_server_config.lng | 2 + .../web/admin/lib/lang/hu_server_config.lng | 2 + .../web/admin/lib/lang/id_server_config.lng | 2 + .../web/admin/lib/lang/it_server_config.lng | 2 + .../web/admin/lib/lang/ja_server_config.lng | 2 + .../web/admin/lib/lang/nl_server_config.lng | 2 + .../web/admin/lib/lang/pl_server_config.lng | 2 + .../web/admin/lib/lang/pt_server_config.lng | 2 + .../web/admin/lib/lang/ro_server_config.lng | 2 + .../web/admin/lib/lang/ru_server_config.lng | 2 + .../web/admin/lib/lang/se_server_config.lng | 2 + .../web/admin/lib/lang/sk_server_config.lng | 2 + .../web/admin/lib/lang/tr_server_config.lng | 2 + .../templates/server_config_web_edit.htm | 16 ++- .../cron.d/800-letsencrypt_cleanup.inc.php | 33 +---- server/lib/classes/letsencrypt.inc.php | 121 ++++++++++++++++-- .../plugins-available/apache2_plugin.inc.php | 24 ++-- server/plugins-available/nginx_plugin.inc.php | 24 ++-- 33 files changed, 226 insertions(+), 58 deletions(-) diff --git a/install/tpl/server.ini.master b/install/tpl/server.ini.master index 13615f651b..a82a486c27 100644 --- a/install/tpl/server.ini.master +++ b/install/tpl/server.ini.master @@ -143,7 +143,9 @@ vhost_proxy_protocol_protocols=ipv4 vhost_proxy_protocol_http_port=880 vhost_proxy_protocol_https_port=8443 le_signature_type=ECDSA +le_delete_on_site_remove=y le_auto_cleanup=y +le_revoke_before_delete=y le_auto_cleanup_denylist=[server_name] [dns] diff --git a/interface/web/admin/form/server_config.tform.php b/interface/web/admin/form/server_config.tform.php index a3b673bfd8..b6f21239d2 100644 --- a/interface/web/admin/form/server_config.tform.php +++ b/interface/web/admin/form/server_config.tform.php @@ -1639,6 +1639,12 @@ $form["tabs"]['web'] = array( 'default' => 'ECDSA', 'value' => array('RSA' => 'RSA (RSA encryption with SHA-256)', 'ECDSA' => 'ECDSA (Elliptic Curve Digital Signature Algorithm)') ), + 'le_delete_on_site_remove' => array( + 'datatype' => 'VARCHAR', + 'formtype' => 'CHECKBOX', + 'default' => 'y', + 'value' => array(0 => 'n', 1 => 'y') + ), 'le_auto_cleanup' => array( 'datatype' => 'VARCHAR', 'formtype' => 'CHECKBOX', @@ -1664,6 +1670,12 @@ $form["tabs"]['web'] = array( 'width' => '40', 'maxlength' => '255' ), + 'le_revoke_before_delete' => array( + 'datatype' => 'VARCHAR', + 'formtype' => 'CHECKBOX', + 'default' => 'y', + 'value' => array(0 => 'n', 1 => 'y') + ), //################################# // END Datatable fields //################################# diff --git a/interface/web/admin/lib/lang/ar_server_config.lng b/interface/web/admin/lib/lang/ar_server_config.lng index cd6ab9869b..f9f79b2e6d 100644 --- a/interface/web/admin/lib/lang/ar_server_config.lng +++ b/interface/web/admin/lib/lang/ar_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/bg_server_config.lng b/interface/web/admin/lib/lang/bg_server_config.lng index 772b3c32c0..18c23d7147 100644 --- a/interface/web/admin/lib/lang/bg_server_config.lng +++ b/interface/web/admin/lib/lang/bg_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/br_server_config.lng b/interface/web/admin/lib/lang/br_server_config.lng index 66a6963691..e8140025ad 100644 --- a/interface/web/admin/lib/lang/br_server_config.lng +++ b/interface/web/admin/lib/lang/br_server_config.lng @@ -369,3 +369,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/ca_server_config.lng b/interface/web/admin/lib/lang/ca_server_config.lng index d1c9fd1c17..1c75cae586 100644 --- a/interface/web/admin/lib/lang/ca_server_config.lng +++ b/interface/web/admin/lib/lang/ca_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/cn_server_config.lng b/interface/web/admin/lib/lang/cn_server_config.lng index db9b473283..b49a6e399a 100644 --- a/interface/web/admin/lib/lang/cn_server_config.lng +++ b/interface/web/admin/lib/lang/cn_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/cz_server_config.lng b/interface/web/admin/lib/lang/cz_server_config.lng index ebc082e860..14d217721d 100644 --- a/interface/web/admin/lib/lang/cz_server_config.lng +++ b/interface/web/admin/lib/lang/cz_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/de_server_config.lng b/interface/web/admin/lib/lang/de_server_config.lng index 557835f492..09c16f1d7c 100644 --- a/interface/web/admin/lib/lang/de_server_config.lng +++ b/interface/web/admin/lib/lang/de_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Entferne unbenutzte Let\'s Encrypt Zertifikate aut $wb['le_auto_cleanup_denylist_txt'] = 'Domains die nie entfernt werden sollen'; $wb['le_auto_cleanup_denylist_note_txt'] = 'Komma-separierte Liste von Domain-Globs, die niemals entfernt werden sollen.
Z.B. mail.*, externally-managed.example.com
Platzhalter:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Domain-Glob Liste ungültig'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/dk_server_config.lng b/interface/web/admin/lib/lang/dk_server_config.lng index c6304352b4..fe60600b64 100644 --- a/interface/web/admin/lib/lang/dk_server_config.lng +++ b/interface/web/admin/lib/lang/dk_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/el_server_config.lng b/interface/web/admin/lib/lang/el_server_config.lng index 4669efd5f6..830ff7e7bd 100644 --- a/interface/web/admin/lib/lang/el_server_config.lng +++ b/interface/web/admin/lib/lang/el_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/en_server_config.lng b/interface/web/admin/lib/lang/en_server_config.lng index e33895369b..7511ab258b 100644 --- a/interface/web/admin/lib/lang/en_server_config.lng +++ b/interface/web/admin/lib/lang/en_server_config.lng @@ -375,3 +375,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/es_server_config.lng b/interface/web/admin/lib/lang/es_server_config.lng index 91dc60dff7..62be233b8e 100644 --- a/interface/web/admin/lib/lang/es_server_config.lng +++ b/interface/web/admin/lib/lang/es_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/fi_server_config.lng b/interface/web/admin/lib/lang/fi_server_config.lng index fb6a6846b5..1c9032be94 100644 --- a/interface/web/admin/lib/lang/fi_server_config.lng +++ b/interface/web/admin/lib/lang/fi_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/fr_server_config.lng b/interface/web/admin/lib/lang/fr_server_config.lng index a112d05608..e83bf96b8d 100644 --- a/interface/web/admin/lib/lang/fr_server_config.lng +++ b/interface/web/admin/lib/lang/fr_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/hr_server_config.lng b/interface/web/admin/lib/lang/hr_server_config.lng index c5e5c74e91..84efd8307c 100644 --- a/interface/web/admin/lib/lang/hr_server_config.lng +++ b/interface/web/admin/lib/lang/hr_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/hu_server_config.lng b/interface/web/admin/lib/lang/hu_server_config.lng index 76989ff834..ef12a7fa72 100644 --- a/interface/web/admin/lib/lang/hu_server_config.lng +++ b/interface/web/admin/lib/lang/hu_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/id_server_config.lng b/interface/web/admin/lib/lang/id_server_config.lng index fdf084e63a..42686637b4 100644 --- a/interface/web/admin/lib/lang/id_server_config.lng +++ b/interface/web/admin/lib/lang/id_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/it_server_config.lng b/interface/web/admin/lib/lang/it_server_config.lng index db2c51780a..49d66acf87 100644 --- a/interface/web/admin/lib/lang/it_server_config.lng +++ b/interface/web/admin/lib/lang/it_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/ja_server_config.lng b/interface/web/admin/lib/lang/ja_server_config.lng index 17eb9644aa..e2fee94a79 100644 --- a/interface/web/admin/lib/lang/ja_server_config.lng +++ b/interface/web/admin/lib/lang/ja_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/nl_server_config.lng b/interface/web/admin/lib/lang/nl_server_config.lng index 7bf308f259..4b9e934f84 100644 --- a/interface/web/admin/lib/lang/nl_server_config.lng +++ b/interface/web/admin/lib/lang/nl_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/pl_server_config.lng b/interface/web/admin/lib/lang/pl_server_config.lng index 66d18aed6f..b64350b8a9 100644 --- a/interface/web/admin/lib/lang/pl_server_config.lng +++ b/interface/web/admin/lib/lang/pl_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/pt_server_config.lng b/interface/web/admin/lib/lang/pt_server_config.lng index 3683a69025..b09ad7d5dc 100644 --- a/interface/web/admin/lib/lang/pt_server_config.lng +++ b/interface/web/admin/lib/lang/pt_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/ro_server_config.lng b/interface/web/admin/lib/lang/ro_server_config.lng index 7481a3e8f8..18d9b7e42a 100644 --- a/interface/web/admin/lib/lang/ro_server_config.lng +++ b/interface/web/admin/lib/lang/ro_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/ru_server_config.lng b/interface/web/admin/lib/lang/ru_server_config.lng index 12d2b97d07..2a8b668f0e 100644 --- a/interface/web/admin/lib/lang/ru_server_config.lng +++ b/interface/web/admin/lib/lang/ru_server_config.lng @@ -368,3 +368,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/se_server_config.lng b/interface/web/admin/lib/lang/se_server_config.lng index 89e7c03e7f..faefe9d365 100644 --- a/interface/web/admin/lib/lang/se_server_config.lng +++ b/interface/web/admin/lib/lang/se_server_config.lng @@ -364,3 +364,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/sk_server_config.lng b/interface/web/admin/lib/lang/sk_server_config.lng index f84701bdd9..6278aa8603 100644 --- a/interface/web/admin/lib/lang/sk_server_config.lng +++ b/interface/web/admin/lib/lang/sk_server_config.lng @@ -364,3 +364,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/lib/lang/tr_server_config.lng b/interface/web/admin/lib/lang/tr_server_config.lng index dc3ca92fe5..a0f11a4e01 100644 --- a/interface/web/admin/lib/lang/tr_server_config.lng +++ b/interface/web/admin/lib/lang/tr_server_config.lng @@ -364,3 +364,5 @@ $wb['le_auto_cleanup_txt'] = 'Automatically purge unused Let\'s Encrypt certific $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.
E.g. mail.*, externally-managed.example.com
Placeholders:'; $wb['le_auto_cleanup_denylist_error_custom'] = 'Invalid list of domain globs'; +$wb['le_delete_on_site_remove_txt'] = 'Delete Let\'s Encrypt certificate on website removal'; +$wb['le_revoke_before_delete_txt'] = 'Revoke a certificate before deleting it (prevents Let\'s Encrypt renewal warnings)'; diff --git a/interface/web/admin/templates/server_config_web_edit.htm b/interface/web/admin/templates/server_config_web_edit.htm index 834d962692..956fd3039a 100644 --- a/interface/web/admin/templates/server_config_web_edit.htm +++ b/interface/web/admin/templates/server_config_web_edit.htm @@ -248,22 +248,26 @@
- -
+
-
+
+
+
+
+
+
+
- -
+
-
{tmpl_var name='le_auto_cleanup_denylist_note_txt'} [server_name] +
{tmpl_var name='le_auto_cleanup_denylist_note_txt'} [server_name]
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 e379cadf81..eb13c0379c 100644 --- a/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php +++ b/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php @@ -36,6 +36,7 @@ class cronjob_letsencrypt_cleanup extends cronjob { public function onRunJob() { global $app, $conf; $app->uses('letsencrypt,ini_parser,getconf'); + $conf['log_priority'] = LOGLEVEL_DEBUG; $server_db_record = $app->db->queryOneRecord("SELECT * FROM server WHERE server_id = ?", $conf['server_id']); if(!$server_db_record || !$server_db_record['web_server']) { @@ -115,16 +116,6 @@ class cronjob_letsencrypt_cleanup extends cronjob { } } - - $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['serial_number'], $used_serials)) { @@ -133,24 +124,14 @@ class cronjob_letsencrypt_cleanup extends cronjob { } continue; } - 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; + $on_deny_list = $app->letsencrypt->check_deny_list($certificate); + if(!empty($on_deny_list)) { + if($conf['log_priority'] <= LOGLEVEL_DEBUG) { + print 'Skip ' . $certificate['id'] . ' because one of its domains is on deny list or a wildcard domain (' . join(', ', $on_deny_list) . ')' . "\n"; } + continue; } - if($app->letsencrypt->remove_certificate($certificate)) { + if($app->letsencrypt->remove_certificate($certificate, null, false)) { print 'Removed unused certificate ' . $certificate['id'] . "\n"; } else { $app->log('Error removing certificate ' . $certificate['id'], LOGLEVEL_WARN); diff --git a/server/lib/classes/letsencrypt.inc.php b/server/lib/classes/letsencrypt.inc.php index 2c00b2f5fd..47a70a529b 100644 --- a/server/lib/classes/letsencrypt.inc.php +++ b/server/lib/classes/letsencrypt.inc.php @@ -666,13 +666,75 @@ class letsencrypt { return $certificates; } + private $_deny_list = null; + + private function get_deny_list() { + global $app, $conf; + + if(is_null($this->_deny_list)) { + $server_db_record = $app->db->queryOneRecord("SELECT * FROM server WHERE server_id = ?", $conf['server_id']); + $app->uses('getconf'); + $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); + + $this->_deny_list = empty($web_config['le_auto_cleanup_denylist']) ? [] : array_filter(array_map(function($pattern) use ($server_db_record) { + $pattern = trim($pattern); + if($server_db_record && $pattern == '[server_name]') { + return $server_db_record['server_name']; + } + + return $pattern; + }, explode(',', $web_config['le_auto_cleanup_denylist']))); + } + return $this->_deny_list; + } + /** - * @param array $certificate the certificate (from get_certificate_list()) + * Checks if $certificate is on the deny list or has a wildcard domain. + * Returns an array of the deny list patterns that matched the certificate. + * An empty array means that the $certificate is not on the deny list. * + * @param array $certificate + * @return array + */ + public function check_deny_list($certificate) { + $deny_list = $this->get_deny_list(); + $on_deny_list = []; + foreach($certificate['domains'] as $cert_domain) { + if(substr($cert_domain, 0, 2) == '*.') { + // wildcard domains are always on the deny list + $on_deny_list[] = $cert_domain; + } else { + $on_deny_list = array_merge($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); + })); + } + } + return array_values(array_unique($on_deny_list)); + } + + /** + * Remove and maybe revoke a certificate. + * @param array $certificate the certificate (from get_certificate_list()) + * @param null|bool $revoke_before_delete try to revoke certificate before deletion. when `null` the configured default is used. + * @param bool $check_deny_list refuse to delete certificate when it is on the servers purge deny list. * @return bool whether the certificate could be removed */ - public function remove_certificate($certificate) { - global $app; + public function remove_certificate($certificate, $revoke_before_delete = null, $check_deny_list = true) { + global $app, $conf; + + if(is_null($revoke_before_delete)) { + $app->uses('getconf'); + $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); + $revoke_before_delete = !empty($web_config['le_revoke_before_delete']) && $web_config['le_revoke_before_delete'] == 'y'; + } + + if($check_deny_list) { + $on_deny_list = $this->check_deny_list($certificate); + if(!empty($on_deny_list)) { + $app->log('remove_certificate: did not remove ' . $certificate['id'] . ' because one of its domains is on deny list or a wildcard domain (' . join(', ', $on_deny_list) . ')', LOGLEVEL_DEBUG); + return false; + } + } if($certificate['source'] == 'certbot') { $certbot_script = $this->get_certbot_script(); @@ -681,12 +743,30 @@ class letsencrypt { return false; } $version = $this->get_certbot_version($certbot_script); - if(version_compare($version, '0.30.0', '<')) { - $app->log('remove_certificate: certbot is very old. Please update for proper certificate deletion.', LOGLEVEL_WARN); + if($revoke_before_delete && $this->is_readable_link_or_file($certificate['cert_paths']['cert'])) { + if(version_compare($version, '0.22', '>=')) { + $server = 'https://acme-v02.api.letsencrypt.org/directory'; + } else { + $server = 'https://acme-v01.api.letsencrypt.org/directory'; + } + $app->system->exec_safe($certbot_script . ' revoke -n --server ? --cert-path ? --reason cessationofoperation 2>&1', $server, $certificate['cert_paths']['cert']); + if($app->system->last_exec_retcode() == 0) { + $app->log('remove_certificate: certbot revoked ' . $certificate['id'] . ' before deletion', LOGLEVEL_DEBUG); + } else { + $app->log('remove_certificate: certbot revoke ' . $certificate['id'] . ' before deletion failed: ' . $app->system->last_exec_out(), LOGLEVEL_WARN); + } } else { - $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); + $app->log('remove_certificate: certbot skip revoke ' . $certificate['id'] . ' before deletion', LOGLEVEL_DEBUG); + } + // the revoke command above might already have done the delete + if(is_file($certificate['conf'])) { + 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->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: ' . $app->system->last_exec_out(), LOGLEVEL_WARN); + } } } // if the conf file is still lingering around, we move it out of the way @@ -696,6 +776,26 @@ class letsencrypt { } } else { if(is_dir($certificate['conf'])) { + if($revoke_before_delete) { + $acme_script = $this->get_acme_script(); + if($acme_script) { + $cert_selection = ''; + $domain = $certificate['id']; + if(substr($domain, -4) == '_ecc') { + $cert_selection = '--ecc'; + $domain = substr($domain, 0, -4); + } + // 5 = cessationOfOperation, see https://github.com/acmesh-official/acme.sh/wiki/revokecert + $app->system->exec_safe($acme_script . ' --revoke --revoke-reason 5 -d ? ' . $cert_selection . ' 2>&1', $domain); + if($app->system->last_exec_retcode() == 0) { + $app->log('remove_certificate: acme.sh revoked ' . $certificate['id'] . ' before deletion', LOGLEVEL_DEBUG); + } else { + $app->log('remove_certificate: acme.sh revoke ' . $certificate['id'] . ' before deletion failed: ' . $app->system->last_exec_out(), LOGLEVEL_WARN); + } + } + } else { + $app->log('remove_certificate: acme.sh skip revoke ' . $certificate['id'] . ' before deletion', LOGLEVEL_DEBUG); + } if(!$app->system->rmdir($certificate['conf'], true)) { $app->log('remove_certificate: could not delete config folder ' . $certificate['conf'], LOGLEVEL_WARN); return false; @@ -717,9 +817,10 @@ class letsencrypt { return false; } if(empty($info['subject']['CN']) || !$this->is_domain_name_or_wildcard($info['subject']['CN'])) { - return false; + $domains = []; + } else { + $domains = [$app->functions->idn_encode($info['subject']['CN'])]; } - $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; diff --git a/server/plugins-available/apache2_plugin.inc.php b/server/plugins-available/apache2_plugin.inc.php index 59182185e7..b7e1de3654 100644 --- a/server/plugins-available/apache2_plugin.inc.php +++ b/server/plugins-available/apache2_plugin.inc.php @@ -2197,16 +2197,24 @@ class apache2_plugin { } else { $app->system->exec_safe('umount -l ? 2>/dev/null', $data['old']['document_root'].'/'.$log_folder); } + } - // remove letsencrypt if it exists (renew will always fail otherwise) - - $old_domain = $data['old']['domain']; - if(substr($old_domain, 0, 2) === '*.') { - // wildcard domain not yet supported by letsencrypt! - $old_domain = substr($old_domain, 2); + // remove (and maybe revoke) Let's Encrypt certificate (renew will always fail otherwise) + if(!empty($web_config['le_delete_on_site_remove']) && $web_config['le_delete_on_site_remove'] == 'y' + && !empty($data['old']['document_root']) + && $data['old']['ssl_letsencrypt'] == 'y' && $data['old']['ssl'] == 'y') { + $app->uses('letsencrypt'); + $paths = $app->letsencrypt->get_website_certificate_paths(['new' => $data['old']]); + $info = $app->letsencrypt->extract_x509($paths['crt']); + if($info) { + $certificates = $app->letsencrypt->get_certificate_list(); + foreach($certificates as $certificate) { + if($certificate['serial_number'] == $info['serial_number']) { + $app->letsencrypt->remove_certificate($certificate); + break; + } + } } - $le_conf_file = '/etc/letsencrypt/renewal/' . $old_domain . '.conf'; - @rename('/etc/letsencrypt/renewal/' . $old_domain . '.conf', '/etc/letsencrypt/renewal/' . $old_domain . '.conf~backup'); } //* remove mountpoint from fstab diff --git a/server/plugins-available/nginx_plugin.inc.php b/server/plugins-available/nginx_plugin.inc.php index b36012843c..63bc139c92 100644 --- a/server/plugins-available/nginx_plugin.inc.php +++ b/server/plugins-available/nginx_plugin.inc.php @@ -2183,16 +2183,24 @@ class nginx_plugin { } else { $app->system->exec_safe('umount ? 2>/dev/null', $data['old']['document_root'].'/'.$log_folder); } + } - // remove letsencrypt if it exists (renew will always fail otherwise) - - $old_domain = $data['old']['domain']; - if(substr($old_domain, 0, 2) === '*.') { - // wildcard domain not yet supported by letsencrypt! - $old_domain = substr($old_domain, 2); + // remove (and maybe revoke) Let's Encrypt certificate (renew will always fail otherwise) + if(!empty($web_config['le_delete_on_site_remove']) && $web_config['le_delete_on_site_remove'] == 'y' + && !empty($data['old']['document_root']) + && $data['old']['ssl_letsencrypt'] == 'y' && $data['old']['ssl'] == 'y') { + $app->uses('letsencrypt'); + $paths = $app->letsencrypt->get_website_certificate_paths(['new' => $data['old']]); + $info = $app->letsencrypt->extract_x509($paths['crt']); + if($info) { + $certificates = $app->letsencrypt->get_certificate_list(); + foreach($certificates as $certificate) { + if($certificate['serial_number'] == $info['serial_number']) { + $app->letsencrypt->remove_certificate($certificate); + break; + } + } } - $le_conf_file = '/etc/letsencrypt/renewal/' . $old_domain . '.conf'; - @rename('/etc/letsencrypt/renewal/' . $old_domain . '.conf', '/etc/letsencrypt/renewal/' . $old_domain . '.conf~backup'); } //* remove mountpoint from fstab -- GitLab From 5a4ec5c736209b6be88067b444042169360ad0fc Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Tue, 30 Jul 2024 00:20:24 +0200 Subject: [PATCH 13/17] LE: revert part of "PHP 5.4 compatibility" 25002450cacb7247f71af986cb0f89f95708e00d #5226 #6563 strtok usage was wrong --- server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php b/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php index 1b416a9db6..690c627216 100644 --- a/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php +++ b/server/lib/classes/cron.d/100-mailbox_stats_hourly.inc.php @@ -101,7 +101,7 @@ class cronjob_mailbox_stats_hourly extends cronjob { } // get the next line - $line = strtok($log_lines, PHP_EOL); + $line = strtok(PHP_EOL); } $uniqueUsers = array_unique($updatedUsers); -- GitLab From 2a5fdc9d1c5c60198dab5577741ee882388981f4 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Tue, 30 Jul 2024 03:32:31 +0200 Subject: [PATCH 14/17] LE: automatically add certificates of other services (ISPConfig interface, Postfix, Pure-FTPd) to the deny list #5226 #6563 --- server/cli/modules/letsencrypt.inc.php | 4 +- server/lib/classes/letsencrypt.inc.php | 75 +++++++++++++++++++++----- 2 files changed, 65 insertions(+), 14 deletions(-) diff --git a/server/cli/modules/letsencrypt.inc.php b/server/cli/modules/letsencrypt.inc.php index c4e9b8d5d5..9b59f879d5 100644 --- a/server/cli/modules/letsencrypt.inc.php +++ b/server/cli/modules/letsencrypt.inc.php @@ -65,9 +65,7 @@ class letsencrypt_cli extends cli { public function outputCertificate($args) { global $app; - if(empty($args)) { - } if(empty($args)) { $this->swriteln('error: ID of the certificate is missing'); $this->showHelp($args); @@ -93,6 +91,7 @@ class letsencrypt_cli extends cli { $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); + $on_deny_list = $app->letsencrypt->check_deny_list($certificate); $table = [ ['key', 'value'], ['id', $certificate['id']], @@ -110,6 +109,7 @@ class letsencrypt_cli extends cli { ['source', $certificate['source']], ['conf', $certificate['conf']], ['files', $this->getAssocArray($certificate['cert_paths'])], + ['deny_list', empty($on_deny_list) ? ($bold_green . 'no' . $ansi_reset) : $bold_red . 'yes' . $ansi_reset . "\n" . $this->getList($on_deny_list)], ]; $this->outputTable($table, ['min_lengths' => [10], 'variable_columns' => '1', 'expand' => true]); } else { diff --git a/server/lib/classes/letsencrypt.inc.php b/server/lib/classes/letsencrypt.inc.php index 47a70a529b..0fff2c10b1 100644 --- a/server/lib/classes/letsencrypt.inc.php +++ b/server/lib/classes/letsencrypt.inc.php @@ -666,17 +666,20 @@ class letsencrypt { return $certificates; } - private $_deny_list = null; + /** @var array|null */ + private $_deny_list_domains = null; + /** @var array|null */ + private $_deny_list_serials = null; private function get_deny_list() { global $app, $conf; - if(is_null($this->_deny_list)) { + if(is_null($this->_deny_list_domains)) { $server_db_record = $app->db->queryOneRecord("SELECT * FROM server WHERE server_id = ?", $conf['server_id']); $app->uses('getconf'); $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); - $this->_deny_list = empty($web_config['le_auto_cleanup_denylist']) ? [] : array_filter(array_map(function($pattern) use ($server_db_record) { + $this->_deny_list_domains = empty($web_config['le_auto_cleanup_denylist']) ? [] : array_filter(array_map(function($pattern) use ($server_db_record) { $pattern = trim($pattern); if($server_db_record && $pattern == '[server_name]') { return $server_db_record['server_name']; @@ -684,32 +687,55 @@ class letsencrypt { return $pattern; }, explode(',', $web_config['le_auto_cleanup_denylist']))); + + $this->_deny_list_domains = array_values(array_unique($this->_deny_list_domains)); + + // search certificates the installer creates and automatically add their serial numbers to deny list + $this->_deny_list_serials = []; + foreach([ + '/usr/local/ispconfig/interface/ssl/ispserver.crt', + '/etc/postfix/smtpd.cert', + '/etc/ssl/private/pure-ftpd.pem' + ] as $possible_cert_file) { + $cert = $this->extract_first_certificate($possible_cert_file); + if($cert) { + $info = $this->extract_x509($cert); + if($info) { + $app->log('add serial number ' . $info['serial_number'] . ' from ' . $possible_cert_file . ' to deny list', LOGLEVEL_DEBUG); + $this->_deny_list_serials[] = $info['serial_number']; + } + } + } + $this->_deny_list_serials = array_values(array_unique($this->_deny_list_serials)); } - return $this->_deny_list; + return [$this->_deny_list_domains, $this->_deny_list_serials]; } /** * Checks if $certificate is on the deny list or has a wildcard domain. - * Returns an array of the deny list patterns that matched the certificate. + * Returns an array of the deny list patterns and serials numbers that matched the certificate. * An empty array means that the $certificate is not on the deny list. * * @param array $certificate * @return array */ public function check_deny_list($certificate) { - $deny_list = $this->get_deny_list(); + list($deny_list_domains, $deny_list_serials) = $this->get_deny_list(); $on_deny_list = []; foreach($certificate['domains'] as $cert_domain) { if(substr($cert_domain, 0, 2) == '*.') { // wildcard domains are always on the deny list $on_deny_list[] = $cert_domain; } else { - $on_deny_list = array_merge($on_deny_list, array_filter($deny_list, function($deny_pattern) use ($cert_domain) { + $on_deny_list = array_merge($on_deny_list, array_filter($deny_list_domains, function($deny_pattern) use ($cert_domain) { return mb_strtolower($deny_pattern) == mb_strtolower($cert_domain) || fnmatch($deny_pattern, $cert_domain, FNM_CASEFOLD); })); } } - return array_values(array_unique($on_deny_list)); + if(in_array($certificate['serial_number'], $deny_list_serials, true)) { + $on_deny_list[] = $certificate['serial_number']; + } + return $on_deny_list; } /** @@ -728,6 +754,11 @@ class letsencrypt { $revoke_before_delete = !empty($web_config['le_revoke_before_delete']) && $web_config['le_revoke_before_delete'] == 'y'; } + if($certificate['is_revoked'] && $revoke_before_delete) { + $revoke_before_delete = false; + $app->log('remove_certificate: skip revokation of ' . $certificate['id'] . ' because it already is revoked', LOGLEVEL_DEBUG); + } + if($check_deny_list) { $on_deny_list = $this->check_deny_list($certificate); if(!empty($on_deny_list)) { @@ -805,15 +836,20 @@ class letsencrypt { return true; } - public function extract_x509($cert_file, $chain_file = null) { + public function extract_x509($cert_file_or_contents, $chain_file = null) { global $app; 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); + $cert_file = false; + if(strpos($cert_file_or_contents, '-----BEGIN CERTIFICATE-----') === false) { + $cert_file = $cert_file_or_contents; + $cert_file_or_contents = file_get_contents($cert_file_or_contents); + } + $info = openssl_x509_parse($cert_file_or_contents, true); if(!$info) { - $app->log('extract_x509: ' . $cert_file . ' could not be parsed', LOGLEVEL_ERROR); + $app->log('extract_x509: ' . ($cert_file ?: 'inline certificate') . ' could not be parsed', LOGLEVEL_ERROR); return false; } if(empty($info['subject']['CN']) || !$this->is_domain_name_or_wildcard($info['subject']['CN'])) { @@ -848,7 +884,7 @@ class letsencrypt { $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)) { + if($is_valid && $cert_file && $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) { @@ -881,6 +917,21 @@ class letsencrypt { ]; } + private function extract_first_certificate($file) { + if(!$this->is_readable_link_or_file($file)) { + return false; + } + $contents = file_get_contents($file); + if(!$contents) { + return false; + } + $matches = []; + if(!preg_match('/-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----/ms', $contents, $matches)) { + return false; + } + return $matches[0]; + } + private function is_domain_name_or_wildcard($input) { $input = filter_var($input, FILTER_VALIDATE_DOMAIN); if(!$input) { -- GitLab From 50080ea0678dd0b9b7518299689faded9feedf98 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Tue, 30 Jul 2024 17:14:03 +0200 Subject: [PATCH 15/17] LE: installer's make_ispconfig_ssl_cert now issues ECDSA certs when possible #6563 #6746 --- install/lib/installer_base.lib.php | 398 ++++++++++++++++------------- 1 file changed, 217 insertions(+), 181 deletions(-) diff --git a/install/lib/installer_base.lib.php b/install/lib/installer_base.lib.php index fe2e5415a7..84d634fa10 100644 --- a/install/lib/installer_base.lib.php +++ b/install/lib/installer_base.lib.php @@ -3006,33 +3006,33 @@ class installer_base extends stdClass { } } $dns_ips = array(); - if (checkdnsrr($hostname, 'A')) { - $dnsa=dns_get_record($hostname, DNS_A); + if(checkdnsrr($hostname, 'A')) { + $dnsa = dns_get_record($hostname, DNS_A); if($dnsa) { - foreach ($dnsa as $rec) { + foreach($dnsa as $rec) { if(is_array($rec) && isset($rec['ip'])) $dns_ips[] = $rec['ip']; } } } - if (checkdnsrr($hostname, 'AAAA')) { - $dnsaaaa=dns_get_record($hostname, DNS_AAAA); + if(checkdnsrr($hostname, 'AAAA')) { + $dnsaaaa = dns_get_record($hostname, DNS_AAAA); if($dnsaaaa) { - foreach ($dnsaaaa as $rec) { + foreach($dnsaaaa as $rec) { if(is_array($rec) && isset($rec['ip'])) $dns_ips[] = $rec['ip']; } } } //* Define and check ISPConfig SSL folder */ - $ssl_dir = $conf['ispconfig_install_dir'].'/interface/ssl'; + $ssl_dir = $conf['ispconfig_install_dir'] . '/interface/ssl'; if(!@is_dir($ssl_dir)) { mkdir($ssl_dir, 0755, true); } - $ssl_crt_file = $ssl_dir.'/ispserver.crt'; - $ssl_csr_file = $ssl_dir.'/ispserver.csr'; - $ssl_key_file = $ssl_dir.'/ispserver.key'; - $ssl_pem_file = $ssl_dir.'/ispserver.pem'; + $ssl_crt_file = $ssl_dir . '/ispserver.crt'; + $ssl_csr_file = $ssl_dir . '/ispserver.csr'; + $ssl_key_file = $ssl_dir . '/ispserver.key'; + $ssl_pem_file = $ssl_dir . '/ispserver.pem'; $date = new DateTime(); @@ -3040,17 +3040,100 @@ class installer_base extends stdClass { swriteln('Checking / creating certificate for ' . $hostname); - $acme_cert_dir = '/usr/local/ispconfig/server/scripts/' . $hostname; - $check_acme_file = $acme_cert_dir . '/' . $hostname . '.cer'; - if(!@is_dir($acme_cert_dir)) { - $acme_cert_dir = '/root/.acme.sh/' . $hostname; - $check_acme_file = $acme_cert_dir . '/' . $hostname . '.cer'; - if(!@is_dir($acme_cert_dir)) { + // Get the default LE client name and version + $which_certbot = shell_exec('which certbot /root/.local/share/letsencrypt/bin/letsencrypt /opt/eff.org/certbot/venv/bin/certbot letsencrypt'); + $certbot = explode("\n", $which_certbot ?: ''); + $certbot = reset($certbot); + $certbot_version = '0.0.0-unknown'; + if($certbot) { + $matches = []; + $output = shell_exec($certbot . ' --version 2>&1'); + if(preg_match('/^(\S+|\w+)\s+(\d+(\.\d+)+)$/', $output, $matches)) { + $certbot_version = $matches[2]; + swriteln('Discovered certbot version ' . $certbot_version . ' with certificate home /etc/letsencrypt'); + } else { + $certbot = ''; + } + } + + // Check for Neilpang acme.sh as well and install it when we did not find certbot + $which_acme = shell_exec('which acme.sh /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh'); + $acme = explode("\n", $which_acme ? $which_acme : ''); + $acme = reset($acme); + $acme_version = '0.0.0-unknown'; + + if(!$certbot && !$acme) { + $this->install_acme(); + $which_acme = shell_exec('which acme.sh /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh'); + $acme = explode("\n", $which_acme ?: ''); + $acme = reset($acme); + } + if($acme) { + // always update acme.sh + $this->update_acme(); + $matches = []; + $output = shell_exec($acme . ' --version 2>&1') ?: ''; + if(preg_match('/^v(\d+(\.\d+)+)$/m', $output, $matches)) { + $acme_version = $matches[1]; + } else { + $acme = ''; + } + if($acme) { + $ret = 0; + $acme_cert_home = []; + exec(join(' ; ', [ + '_info() { :', + ' _info_stdout=$(' . escapeshellarg($acme) . ' --info 2>/dev/null)', + ' _info_ret=$?', + '}', + '_echo_home() { :', + ' eval "$_info_stdout"', + ' _info_ret=$?', + ' if [ $_info_ret -eq 0 ]; then :', + ' if [ -z "$CERT_HOME" ]', + ' then echo "$LE_CONFIG_HOME"', + ' else echo "$CERT_HOME"', + ' fi', + ' else :', + ' echo "Error eval-ing --info output (exit code $_info_ret). stdout was: $_info_stdout"', + ' exit 1', + ' fi', + '}', + '_info', + 'if [ $_info_ret -eq 0 ]; then :', + ' _echo_home', + 'else :', + ' echo "--info failed. stdout was: $_info_stdout"', + ' exit 1', + 'fi', + ]), $acme_cert_home, $ret); + $acme_cert_home = trim(implode("\n", $acme_cert_home)); + if($ret != 0 || empty($acme_cert_home) || !is_dir($acme_cert_home)) { + swriteln('Cannot find acme.sh certificate home: ' . $acme_cert_home); + $acme = ''; + } else { + swriteln('Discovered acme.sh version ' . $acme_version . ' with certificate home ' . $acme_cert_home); + } + } + } + + $acme_cert_dir = 'not found'; + $check_acme_file = ''; + if($certbot) { + if(version_compare($certbot_version, '2.0', '>=')) { + $acme_cert_dir = '/etc/letsencrypt/live/' . $hostname . '_ecc'; + } else { $acme_cert_dir = '/etc/letsencrypt/live/' . $hostname; - $check_acme_file = $acme_cert_dir . '/cert.pem'; } + $check_acme_file = $acme_cert_dir . '/cert.pem'; + swriteln('Using certificate path ' . $acme_cert_dir . ' / ' . $check_acme_file); + } elseif($acme) { + $acme_cert_dir = $acme_cert_home . '/' . $hostname . '_ecc'; // always use ECC since we updated acme.sh + $check_acme_file = $acme_cert_dir . '/' . $hostname . '.cer'; + swriteln('Using certificate path ' . $acme_cert_dir . ' / ' . $check_acme_file); + } else { + swriteln('Failed discovering certbot or acme.sh and installing acme.sh. Will not be able to issue certificate during install.'); } - swriteln('Using certificate path ' . $acme_cert_dir); if(!is_dir($conf['ispconfig_log_dir'])) { mkdir($conf['ispconfig_log_dir'], 0755, true); @@ -3059,8 +3142,8 @@ class installer_base extends stdClass { $ip_address_match = false; if(!(($svr_ip4 && in_array($svr_ip4, $dns_ips)) || ($svr_ip6 && in_array($svr_ip6, $dns_ips)))) { - swriteln('Server\'s public ip(s) (' . $svr_ip4 . ($svr_ip6 ? ', ' . $svr_ip6 : '') . ') not found in A/AAAA records for ' . $hostname . ': ' . implode(', ', $dns_ips)); - if(strtolower($this->simple_query('Ignore DNS check and continue to request certificate?', array('y', 'n') , 'n','ignore_hostname_dns')) == 'y') { + swriteln('Server\'s public ip(s) (' . implode(', ', array_filter([$svr_ip4, $svr_ip6])) . ') not found in A/AAAA records for ' . $hostname . ': ' . implode(', ', $dns_ips)); + if(strtolower($this->simple_query('Ignore DNS check and continue to request certificate?', array('y', 'n'), 'n', 'ignore_hostname_dns')) == 'y') { $ip_address_match = true; } } else { @@ -3068,14 +3151,31 @@ class installer_base extends stdClass { } // Get subject and issuer of ispserver.crt to check if it is self-signed cert - if (file_exists($ssl_crt_file)) { - $crt_subject = exec("openssl x509 -in ".escapeshellarg($ssl_crt_file)." -inform PEM -noout -subject"); - $crt_issuer = exec("openssl x509 -in ".escapeshellarg($ssl_crt_file)." -inform PEM -noout -issuer"); + $self_signed = false; + if(file_exists($ssl_crt_file)) { + $crt_subject = exec("openssl x509 -in " . escapeshellarg($ssl_crt_file) . " -inform PEM -noout -subject"); + $crt_issuer = exec("openssl x509 -in " . escapeshellarg($ssl_crt_file) . " -inform PEM -noout -issuer"); + // strip the subject= and issuer= prefix to check for equality + if(is_string($crt_subject) && strpos($crt_subject, 'subject=') !== false) { + $crt_subject = explode('=', $crt_subject, 2)[1]; + } + if(is_string($crt_issuer) && strpos($crt_issuer, 'issuer=') !== false) { + $crt_issuer = explode('=', $crt_issuer, 2)[1]; + } + $self_signed = $crt_subject == $crt_issuer; + if ($self_signed) { + swriteln('ISPConfig currently is using a self-signed certificate.'); + } } $issued_successfully = false; - if ((@file_exists($ssl_crt_file) && ($crt_subject == $crt_issuer)) || (!@is_dir($acme_cert_dir) || !@file_exists($check_acme_file) || !@file_exists($ssl_crt_file) || md5_file($check_acme_file) != md5_file($ssl_crt_file)) && $ip_address_match == true) { + // if we have certbot or acme.sh, the required DNS records and our desired certificate is not the current one, try to get it + if( + ($acme || $certbot) && $ip_address_match + && ($self_signed || + (!@is_dir($acme_cert_dir) || !@file_exists($check_acme_file) || !@file_exists($ssl_crt_file) || md5_file($check_acme_file) != md5_file($ssl_crt_file))) + ) { // This script is needed earlier to check and open http port 80 or standalone might fail // Make executable and temporary symlink latest letsencrypt pre, post and renew hook script before install @@ -3104,48 +3204,19 @@ class installer_base extends stdClass { chmod('/usr/local/bin/letsencrypt_renew_hook.sh', 0700); } - // Check http port 80 status as it cannot be determined at post hook stage - $port80_status=exec('true &>/dev/null 0) print "open"; else print "close";}\''); - // Set pre-, post- and renew hook - $pre_hook = "--pre-hook \"letsencrypt_pre_hook.sh\""; - $renew_hook = " --renew-hook \"letsencrypt_renew_hook.sh\""; + // Set pre-, post- and renew hook (acme.sh and certbot use the same arguments) + $pre_hook = '--pre-hook "letsencrypt_pre_hook.sh"'; + $renew_hook = ' --renew-hook "letsencrypt_renew_hook.sh"'; if($port80_status == 'close') { - $post_hook = " --post-hook \"letsencrypt_post_hook.sh\""; + $post_hook = ' --post-hook "letsencrypt_post_hook.sh"'; $hook = $pre_hook . $post_hook . $renew_hook; } else { $hook = $pre_hook . $renew_hook; } - $which_certbot = shell_exec('which certbot /root/.local/share/letsencrypt/bin/letsencrypt /opt/eff.org/certbot/venv/bin/certbot letsencrypt'); - - // Get the default LE client name and version - $le_client = explode("\n", $which_certbot ? $which_certbot : ''); - $le_client = reset($le_client); - - $which_acme = shell_exec('which acme.sh /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh'); - // Check for Neilpang acme.sh as well - $acme = explode("\n", $which_acme ? $which_acme : ''); - $acme = reset($acme); - - if((!$acme || !is_executable($acme)) && (!$le_client || !is_executable($le_client))) { - $success = $this->install_acme(); - if(!$success) { - swriteln('Failed installing acme.sh. Will not be able to issue certificate during install.'); - } else { - $acme = explode("\n", shell_exec('which acme.sh /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh')); - $acme = reset($acme); - if($acme && is_executable($acme)) { - swriteln('Installed acme.sh and using it for certificate creation during install.'); - - // we do this even on install to enable automatic updates - $this->update_acme(); - } else { - swriteln('Failed installing acme.sh. Will not be able to issue certificate during install.'); - } - } - } - $restore_conf_symlink = false; // we only need this for apache, so use fixed conf index @@ -3158,18 +3229,18 @@ class installer_base extends stdClass { $server = 'nginx'; } elseif($conf['apache']['installed'] == true) { swriteln('Using apache for certificate validation'); - if($this->is_update == false && @is_link($vhost_conf_enabled_dir.'/000-ispconfig.conf')) { + if($this->is_update == false && @is_link($vhost_conf_enabled_dir . '/000-ispconfig.conf')) { $restore_conf_symlink = true; - unlink($vhost_conf_enabled_dir.'/000-ispconfig.conf'); + unlink($vhost_conf_enabled_dir . '/000-ispconfig.conf'); } $server = 'apache'; } if($conf[$server]['installed'] == true && $conf[$server]['init_script'] != '') { if($this->is_update) { - system($this->getinitcommand($conf[$server]['init_script'], 'force-reload').' &> /dev/null || ' . $this->getinitcommand($conf[$server]['init_script'], 'restart').' &> /dev/null'); + system($this->getinitcommand($conf[$server]['init_script'], 'force-reload') . ' &> /dev/null || ' . $this->getinitcommand($conf[$server]['init_script'], 'restart') . ' &> /dev/null'); } else { - system($this->getinitcommand($conf[$server]['init_script'], 'restart').' &> /dev/null'); + system($this->getinitcommand($conf[$server]['init_script'], 'restart') . ' &> /dev/null'); } } @@ -3188,24 +3259,20 @@ class installer_base extends stdClass { // - actual file copied to tmp name. // if cert request is successful, rename tmp copy to perm rename; // if cert request fails, delete tmp copy - $cert_files = array( $ssl_crt_file, $ssl_key_file, $ssl_pem_file ); - foreach ($cert_files as $f) { - if (is_link($f) && ! file_exists($f)) { - rename($f, $f.'-'.$date->format('YmdHis').'.bak'); - } elseif (is_link($f)) { - rename($f, $f.'-temporary.bak'); - copy($f.'-temporary.bak', $f); + $cert_files = array($ssl_crt_file, $ssl_key_file, $ssl_pem_file); + foreach($cert_files as $f) { + if(is_link($f) && !file_exists($f)) { + rename($f, $f . '-' . $date->format('YmdHis') . '.bak'); + } elseif(is_link($f)) { + rename($f, $f . '-temporary.bak'); + copy($f . '-temporary.bak', $f); } elseif(file_exists($f)) { - copy($f, $f.'-temporary.bak'); + copy($f, $f . '-temporary.bak'); } } // Attempt to use Neilpang acme.sh first, as it is now the preferred LE client - if (is_executable($acme)) { - $acme_cert_dir = dirname($acme) . '/' . $hostname; - - swriteln('acme.sh is installed, overriding certificate path to use ' . $acme_cert_dir); - + if($acme) { # acme.sh does not set umask, resulting in incorrect permissions (ispconfig issue #6015) $old_umask = umask(0022); @@ -3215,13 +3282,11 @@ class installer_base extends stdClass { $out = null; $ret = null; if($conf['nginx']['installed'] == true || $conf['apache']['installed'] == true) { - exec("$acme --issue --keylength 4096 --log $acme_log -w /usr/local/ispconfig/interface/acme -d " . escapeshellarg($hostname) . " $renew_hook", $out, $ret); - } - // Else, it is not webserver, so we use standalone - else { - exec("$acme --issue --keylength 4096 --log $acme_log --standalone -d " . escapeshellarg($hostname) . " $hook", $out, $ret); + exec("$acme --issue --keylength ec-256 --ecc --log $acme_log -w /usr/local/ispconfig/interface/acme -d " . escapeshellarg($hostname) . " $renew_hook", $out, $ret); + } else { // Else, it is not webserver, so we use standalone + exec("$acme --issue --keylength ec-256 --ecc --log $acme_log --standalone -d " . escapeshellarg($hostname) . " $hook", $out, $ret); } - + umask($old_umask); if($ret == 0 || ($ret == 2 && file_exists($check_acme_file))) { // acme.sh returns with 2 on issue for already existing certificate @@ -3231,117 +3296,89 @@ class installer_base extends stdClass { //$acme_cert = "--cert-file $acme_cert_dir/cert.pem"; $acme_key = "--key-file " . escapeshellarg($ssl_key_file); $acme_chain = "--fullchain-file " . escapeshellarg($ssl_crt_file); - exec("$acme --install-cert --log $acme_log -d " . escapeshellarg($hostname) . " $acme_key $acme_chain"); + exec("$acme --install-cert --log $acme_log -d " . escapeshellarg($hostname) . " --ecc $acme_key $acme_chain"); $issued_successfully = true; - umask($old_umask); - - // Make temporary backup of self-signed certs permanent - foreach ($cert_files as $f) { - if (is_link($f.'-temporary.bak')) { - unlink($f.'-temporary.bak'); - } elseif(file_exists($f.'-temporary.bak')) { - rename($f.'-temporary.bak', $f.'-'.$date->format('YmdHis').'.bak'); - } - } - } else { swriteln('Issuing certificate via acme.sh failed. Please check that your hostname can be verified by letsencrypt'); - - umask($old_umask); - - // Restore/cleanup temporary backup of self-signed certs - foreach ($cert_files as $f) { - if (is_link($f.'-temporary.bak')) { - @unlink($f); - rename($f.'-temporary.bak', $f); - } elseif(file_exists($f.'-temporary.bak')) { - unlink($f.'-temporary.bak'); - } - } } - // Else, we attempt to use the official LE certbot client certbot - } else { - - // But only if it is otherwise available - if(is_executable($le_client)) { - $out = null; - $ret = null; - // Get its version info due to be used for webroot arguement issues - $le_info = exec($le_client . ' --version 2>&1', $ret, $val); - if(preg_match('/^(\S+|\w+)\s+(\d+(\.\d+)+)$/', $le_info, $matches)) { - $le_version = $matches[2]; - } + // Else, we attempt to use the official LE certbot client certbot + } else { + $out = null; + $ret = null; - // Define certbot commands - $acme_version = '--server https://acme-v0' . (($le_version >=0.22) ? '2' : '1') . '.api.letsencrypt.org/directory'; + if(version_compare($certbot_version, '0.22', '>=')) { + $acme_version = '--server https://acme-v02.api.letsencrypt.org/directory'; + } else { + $acme_version = '--server https://acme-v01.api.letsencrypt.org/directory'; + } + if(version_compare($certbot_version, '2.0', '>=')) { + $certonly = 'certonly --agree-tos --non-interactive --expand --cert-name ' . escapeshellarg($hostname . '_ecc') . ' --elliptic-curve secp256r1'; + } elseif(version_compare($certbot_version, '0.30', '>=')) { + $certonly = 'certonly --agree-tos --non-interactive --expand --cert-name ' . escapeshellarg($hostname) . ' --rsa-key-size 4096'; + } else { $certonly = 'certonly --agree-tos --non-interactive --expand --rsa-key-size 4096'; + } - // If this is a webserver - if($conf['nginx']['installed'] == true || $conf['apache']['installed'] == true) { - exec("$le_client $certonly $acme_version --authenticator webroot --webroot-path /usr/local/ispconfig/interface/acme --email " . escapeshellarg('postmaster@' . $hostname) . " -d " . escapeshellarg($hostname) . " $renew_hook", $out, $ret); - } - // Else, it is not webserver, so we use standalone - else { - exec("$le_client $certonly $acme_version --standalone --email " . escapeshellarg('postmaster@' . $hostname) . " -d " . escapeshellarg($hostname) . " $hook", $out, $ret); - } - - if($ret == 0) { - // certbot returns with 0 on issue for already existing certificate - - $acme_cert_dir = '/etc/letsencrypt/live/' . $hostname; - foreach (array( $ssl_crt_file, $ssl_key_file) as $f) { - if (file_exists($f) && ! is_link($f)) { - unlink($f); - } - } - symlink($acme_cert_dir . '/fullchain.pem', $ssl_crt_file); - symlink($acme_cert_dir . '/privkey.pem', $ssl_key_file); - - $issued_successfully = true; + // If this is a webserver + if($conf['nginx']['installed'] == true || $conf['apache']['installed'] == true) { + exec("$certbot $certonly $acme_version --authenticator webroot --webroot-path /usr/local/ispconfig/interface/acme --email " . escapeshellarg('postmaster@' . $hostname) . " -d " . escapeshellarg($hostname) . " $renew_hook", $out, $ret); + } else { // Else, it is not webserver, so we use standalone + exec("$certbot $certonly $acme_version --standalone --email " . escapeshellarg('postmaster@' . $hostname) . " -d " . escapeshellarg($hostname) . " $hook", $out, $ret); + } - // Make temporary backup of self-signed certs permanent - foreach ($cert_files as $f) { - if (is_link($f.'-temporary.bak')) { - unlink($f.'-temporary.bak'); - } elseif(file_exists($f.'-temporary.bak')) { - rename($f.'-temporary.bak', $f.'-'.$date->format('YmdHis').'.bak'); - } - } + if($ret == 0 && is_dir($acme_cert_dir)) { + // certbot returns with 0 on issue for already existing certificate - } else { - swriteln('Issuing certificate via certbot failed. Please check log files and make sure that your hostname can be verified by letsencrypt'); - - // Restore/cleanup temporary backup of self-signed certs - foreach ($cert_files as $f) { - if (is_link($f.'-temporary.bak')) { - @unlink($f); - rename($f.'-temporary.bak', $f); - } elseif(file_exists($f.'-temporary.bak')) { - unlink($f.'-temporary.bak'); - } + foreach(array($ssl_crt_file, $ssl_key_file) as $f) { + if(file_exists($f) && !is_link($f)) { + unlink($f); } - } + symlink($acme_cert_dir . '/fullchain.pem', $ssl_crt_file); + symlink($acme_cert_dir . '/privkey.pem', $ssl_key_file); + + $issued_successfully = true; } else { - swriteln('Did not find any valid acme client (acme.sh or certbot)'); + swriteln('Issuing certificate via certbot failed. Please check log files and make sure that your hostname can be verified by letsencrypt'); } } if($restore_conf_symlink) { - if(!@is_link($vhost_conf_enabled_dir.'/000-ispconfig.conf')) { - symlink($vhost_conf_dir.'/ispconfig.conf', $vhost_conf_enabled_dir.'/000-ispconfig.conf'); + if(!@is_link($vhost_conf_enabled_dir . '/000-ispconfig.conf')) { + symlink($vhost_conf_dir . '/ispconfig.conf', $vhost_conf_enabled_dir . '/000-ispconfig.conf'); + } + } + + if($issued_successfully) { + // Make temporary backup of self-signed certs permanent + foreach($cert_files as $f) { + if(is_link($f . '-temporary.bak')) { + unlink($f . '-temporary.bak'); + } elseif(file_exists($f . '-temporary.bak')) { + rename($f . '-temporary.bak', $f . '-' . $date->format('YmdHis') . '.bak'); + } + } + } else { + // Restore/cleanup temporary backup of self-signed certs + foreach($cert_files as $f) { + if(is_link($f . '-temporary.bak')) { + @unlink($f); + rename($f . '-temporary.bak', $f); + } elseif(file_exists($f . '-temporary.bak')) { + unlink($f . '-temporary.bak'); + } } } } else { if($ip_address_match) { - // the directory already exists so we have to assume that it was created previously + // the directory already exists, so we have to assume that it was created previously $issued_successfully = true; } } // If the LE SSL certs for this hostname exists - if(!is_dir($acme_cert_dir) || !file_exists($check_acme_file) || !isset($issued_successfully) || !$issued_successfully) { + if(!is_dir($acme_cert_dir) || !file_exists($check_acme_file) || !$issued_successfully) { if(!$issued_successfully) { swriteln('Could not issue letsencrypt certificate, falling back to self-signed.'); } else { @@ -3350,7 +3387,7 @@ class installer_base extends stdClass { // We can still use the old self-signed method $openssl_cmd = 'openssl req -nodes -newkey rsa:4096 -x509 -days 3650 -keyout ' . escapeshellarg($ssl_key_file) . ' -out ' . escapeshellarg($ssl_crt_file); - if(AUTOINSTALL){ + if(AUTOINSTALL) { $openssl_cmd .= ' -subj ' . escapeshellarg('/C=' . $autoinstall['ssl_cert_country'] . '/ST=' . $autoinstall['ssl_cert_state'] . '/L=' . $autoinstall['ssl_cert_locality'] . '/O=' . $autoinstall['ssl_cert_organisation'] . '/OU=' . $autoinstall['ssl_cert_organisation_unit'] . '/CN=' . $autoinstall['ssl_cert_common_name']); } exec($openssl_cmd); @@ -3361,18 +3398,18 @@ class installer_base extends stdClass { exec("cat $ssl_key_file $ssl_crt_file > $ssl_pem_file; chmod 600 $ssl_pem_file"); // Extend LE SSL certs to postfix - if ($conf['postfix']['installed'] == true && strtolower($this->simple_query('Symlink ISPConfig SSL certs to Postfix?', array('y', 'n'), 'y','ispconfig_postfix_ssl_symlink')) == 'y') { + if($conf['postfix']['installed'] == true && strtolower($this->simple_query('Symlink ISPConfig SSL certs to Postfix?', array('y', 'n'), 'y', 'ispconfig_postfix_ssl_symlink')) == 'y') { // Define folder, file(s) $cf = $conf['postfix']; $postfix_dir = $cf['config_dir']; if(!is_dir($postfix_dir)) $this->error("The Postfix configuration directory '$postfix_dir' does not exist."); - $smtpd_crt = $postfix_dir.'/smtpd.cert'; - $smtpd_key = $postfix_dir.'/smtpd.key'; + $smtpd_crt = $postfix_dir . '/smtpd.cert'; + $smtpd_key = $postfix_dir . '/smtpd.key'; // Backup existing postfix ssl files - if (file_exists($smtpd_crt)) rename($smtpd_crt, $smtpd_crt . '-' .$date->format('YmdHis') . '.bak'); - if (file_exists($smtpd_key)) rename($smtpd_key, $smtpd_key . '-' .$date->format('YmdHis') . '.bak'); + if(file_exists($smtpd_crt)) rename($smtpd_crt, $smtpd_crt . '-' . $date->format('YmdHis') . '.bak'); + if(file_exists($smtpd_key)) rename($smtpd_key, $smtpd_key . '-' . $date->format('YmdHis') . '.bak'); // Create symlink to ISPConfig SSL files symlink($ssl_crt_file, $smtpd_crt); @@ -3380,26 +3417,25 @@ class installer_base extends stdClass { } // Extend LE SSL certs to pureftpd - if ($conf['pureftpd']['installed'] == true && strtolower($this->simple_query('Symlink ISPConfig SSL certs to Pure-FTPd? Creating dhparam file may take some time.', array('y', 'n'), 'y','ispconfig_pureftpd_ssl_symlink')) == 'y') { + if($conf['pureftpd']['installed'] == true && strtolower($this->simple_query('Symlink ISPConfig SSL certs to Pure-FTPd? Creating dhparam file may take some time.', array('y', 'n'), 'y', 'ispconfig_pureftpd_ssl_symlink')) == 'y') { // Define folder, file(s) $pureftpd_dir = '/etc/ssl/private'; if(!is_dir($pureftpd_dir)) mkdir($pureftpd_dir, 0755, true); - $pureftpd_pem = $pureftpd_dir.'/pure-ftpd.pem'; + $pureftpd_pem = $pureftpd_dir . '/pure-ftpd.pem'; // Backup existing pureftpd ssl files - if (file_exists($pureftpd_pem)) rename($pureftpd_pem, $pureftpd_pem . '-' .$date->format('YmdHis') . '.bak'); + if(file_exists($pureftpd_pem)) rename($pureftpd_pem, $pureftpd_pem . '-' . $date->format('YmdHis') . '.bak'); // Create symlink to ISPConfig SSL files symlink($ssl_pem_file, $pureftpd_pem); - if (!file_exists("$pureftpd_dir/pure-ftpd-dhparams.pem")) - symlink('/usr/local/ispconfig/interface/ssl/dhparam4096.pem', $pureftpd_dir.'/pure-ftpd-dhparams.pem'); - //exec("cd $pureftpd_dir; openssl dhparam -out dhparam2048.pem 2048; ln -sf dhparam2048.pem pure-ftpd-dhparams.pem"); + if(!file_exists("$pureftpd_dir/pure-ftpd-dhparams.pem")) + symlink('/usr/local/ispconfig/interface/ssl/dhparam4096.pem', $pureftpd_dir . '/pure-ftpd-dhparams.pem'); + //exec("cd $pureftpd_dir; openssl dhparam -out dhparam2048.pem 2048; ln -sf dhparam2048.pem pure-ftpd-dhparams.pem"); } } exec("chown -R root:root $ssl_dir"); - } public function install_ispconfig() { -- GitLab From 9e9681e93a44cb2c499153e1a775a96a4dea3559 Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Tue, 30 Jul 2024 17:35:15 +0200 Subject: [PATCH 16/17] LE: fix hooks (were broken for rpm-based OSes) & make them compatible with ECDSA certificates #6563 --- server/scripts/letsencrypt_post_hook.sh | 60 +++++-------- server/scripts/letsencrypt_pre_hook.sh | 56 ++++-------- server/scripts/letsencrypt_renew_hook.sh | 106 +++++++++++++---------- 3 files changed, 101 insertions(+), 121 deletions(-) diff --git a/server/scripts/letsencrypt_post_hook.sh b/server/scripts/letsencrypt_post_hook.sh index caef1e2d21..01a0955cce 100644 --- a/server/scripts/letsencrypt_post_hook.sh +++ b/server/scripts/letsencrypt_post_hook.sh @@ -1,14 +1,7 @@ #!/bin/bash -### BEGIN INIT INFO -# Provides: LETSENCRYPT POST HOOK SCRIPT -# Required-Start: $local_fs $network -# Required-Stop: $local_fs -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 # Short-Description: LETSENCRYPT POST HOOK SCRIPT # Description: To force close http port 80 if it is by default closed, to be used by letsencrypt client standlone command -### END INIT INFO ## If you need a custom hook file, create a file with the same name in ## /usr/local/ispconfig/server/conf-custom/scripts/ @@ -17,44 +10,35 @@ ## ## Eg. you can override the ispc_letsencrypt_firewall_disable() function then 'return 124' ## to customize the firewall setup. -if [ -e "/usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_post_hook.sh" ] ; then - . /usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_post_hook.sh - ret=$? - if [ $ret != 124 ]; then exit $ret; fi +if [ -e "/usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_post_hook.sh" ]; then + . /usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_post_hook.sh + ret=$? + if [ $ret != 124 ]; then exit $ret; fi fi declare -F ispc_letsencrypt_firewall_disable &>/dev/null || ispc_letsencrypt_firewall_disable() { - # delete 'ispc-letsencrypt' chain - iptables -D INPUT -p tcp --dport 80 -j ispc-letsencrypt - iptables -F ispc-letsencrypt - iptables -X ispc-letsencrypt + # delete 'ispc-letsencrypt' chain + iptables -D INPUT -p tcp --dport 80 -j ispc-letsencrypt + iptables -F ispc-letsencrypt + iptables -X ispc-letsencrypt } ispc_letsencrypt_firewall_disable - # For RHEL, Centos or derivatives -if which yum &> /dev/null 2>&1 ; then - # Check if web server software is installed, start it if any - if [ rpm -q nginx ]; then service nginx start - elif [ rpm -q httpd ]; then service httpd start -# # If using firewalld -# elif [ rpm -q firewalld ] && [ `firewall-cmd --state` = running ]; then -# firewall-cmd --zone=public --permanent --remove-service=http -# firewall-cmd --reload -# # If using UFW -# elif [ rpm -q ufw ]; then ufw --force enable && ufw deny http - fi +if which yum &>/dev/null 2>&1; then + # Check if web server software is installed, start it if any + if rpm -q nginx; then + service nginx start + elif rpm -q httpd; then + service httpd start + fi # For Debian, Ubuntu or derivatives -elif apt-get -v >/dev/null 2>&1 ; then - # Check if web server software is installed, stop it if any - if [ $(dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service nginx start - elif [ $(dpkg-query -W -f='${Status}' apache2 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service apache2 start -# # If using UFW -# elif [ $(dpkg-query -W -f='${Status}' ufw 2>/dev/null | grep -c "ok installed") -eq 1 ]; then ufw --force enable && ufw deny http - fi -## Try iptables as a final attempt -#else -# iptables -D INPUT -p tcp --dport 80 -j ACCEPT -# service iptables save +elif apt-get -v >/dev/null 2>&1; then + # Check if web server software is installed, stop it if any + if [ "$(dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then + service nginx start + elif [ "$(dpkg-query -W -f='${Status}' apache2 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then + service apache2 start + fi fi diff --git a/server/scripts/letsencrypt_pre_hook.sh b/server/scripts/letsencrypt_pre_hook.sh index 41aa018430..6466b6623c 100644 --- a/server/scripts/letsencrypt_pre_hook.sh +++ b/server/scripts/letsencrypt_pre_hook.sh @@ -1,14 +1,7 @@ #!/bin/bash -### BEGIN INIT INFO -# Provides: LETSENCRYPT PRE HOOK SCRIPT -# Required-Start: $local_fs $network -# Required-Stop: $local_fs -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 # Short-Description: LETSENCRYPT PRE HOOK SCRIPT # Description: To force open http port 80 to be used by letsencrypt client standlone command -### END INIT INFO ## If you need a custom hook file, create a file with the same name in ## /usr/local/ispconfig/server/conf-custom/scripts/ @@ -17,45 +10,30 @@ ## ## Eg. you can override the ispc_letsencrypt_firewall_enable() function then 'return 124' ## to customize the firewall setup. -if [ -e "/usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_pre_hook.sh" ] ; then - . /usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_pre_hook.sh - ret=$? - if [ $ret != 124 ]; then exit $ret; fi +if [ -e "/usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_pre_hook.sh" ]; then + . /usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_pre_hook.sh + ret=$? + if [ $ret != 124 ]; then exit $ret; fi fi declare -F ispc_letsencrypt_firewall_enable &>/dev/null || ispc_letsencrypt_firewall_enable() { - # create 'ispc-letsencrypt' chain with ACCEPT policy and send port 80 there - iptables -N ispc-letsencrypt - iptables -I ispc-letsencrypt -p tcp --dport 80 -j ACCEPT - iptables -A ispc-letsencrypt -j RETURN - iptables -I INPUT -p tcp --dport 80 -j ispc-letsencrypt + # create 'ispc-letsencrypt' chain with ACCEPT policy and send port 80 there + iptables -N ispc-letsencrypt + iptables -I ispc-letsencrypt -p tcp --dport 80 -j ACCEPT + iptables -A ispc-letsencrypt -j RETURN + iptables -I INPUT -p tcp --dport 80 -j ispc-letsencrypt } ispc_letsencrypt_firewall_enable # For RHEL, Centos or derivatives -if which yum &> /dev/null 2>&1 ; then - # Check if web server software is installed, stop it if any - if [ rpm -q nginx ]; then service nginx stop; fi - if [ rpm -q httpd ]; then service httpd stop; fi -# # If using firewalld -# if [ rpm -q firewalld ] && [ `firewall-cmd --state` = running ]; then -# firewall-cmd --zone=public --permanent --add-service=http -# firewall-cmd --reload -# fi -# # If using UFW -# if [ rpm -q ufw ]; then ufw --force enable && ufw allow http; fi - +if which yum &>/dev/null 2>&1; then + # Check if web server software is installed, stop it if any + if rpm -q nginx; then service nginx stop; fi + if rpm -q httpd; then service httpd stop; fi # For Debian, Ubuntu or derivatives -elif apt-get -v >/dev/null 2>&1 ; then - # Check if web server software is installed, stop it if any - if [ $(dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service nginx stop; fi - if [ $(dpkg-query -W -f='${Status}' apache2 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service apache2 stop; fi -# # If using UFW -# if [ $(dpkg-query -W -f='${Status}' ufw 2>/dev/null | grep -c "ok installed") -eq 1 ]; then ufw --force enable && ufw allow http; fi - -## Try iptables as a final attempt -#else -# iptables -I INPUT -p tcp --dport 80 -j ACCEPT -# service iptables save +elif apt-get -v >/dev/null 2>&1; then + # Check if web server software is installed, stop it if any + if [ "$(dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service nginx stop; fi + if [ "$(dpkg-query -W -f='${Status}' apache2 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service apache2 stop; fi fi diff --git a/server/scripts/letsencrypt_renew_hook.sh b/server/scripts/letsencrypt_renew_hook.sh index 5ec9912f59..3bc70652ef 100644 --- a/server/scripts/letsencrypt_renew_hook.sh +++ b/server/scripts/letsencrypt_renew_hook.sh @@ -1,59 +1,77 @@ #!/bin/bash -### BEGIN INIT INFO -# Provides: LETSENCRYPT RENEW HOOK SCRIPT -# Required-Start: $local_fs $network -# Required-Stop: $local_fs -# Default-Start: 2 3 4 5 -# Default-Stop: 0 1 6 # Short-Description: LETSENCRYPT RENEW HOOK SCRIPT # Description: Taken from LE4ISPC code. To be used to update ispserver.pem automatically after ISPConfig LE SSL certs are renewed and to reload / restart important ISPConfig server services -### END INIT INFO ## If you need a custom hook file, create a file with the same name in ## /usr/local/ispconfig/server/conf-custom/scripts/ ## ## End the file with 'return 124' to signal that this script should not terminate. -if [ -e "/usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_renew_hook.sh" ] ; then - . /usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_renew_hook.sh - ret=$? - if [ $ret != 124 ]; then exit $ret; fi +if [ -e "/usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_renew_hook.sh" ]; then + . /usr/local/ispconfig/server/conf-custom/scripts/letsencrypt_renew_hook.sh + ret=$? + if [ $ret != 124 ]; then exit $ret; fi fi hostname=$(hostname -f) -if [ -d "/usr/local/ispconfig/server/scripts/${hostname}" ] ; then - lelive="/usr/local/ispconfig/server/scripts/${hostname}" ; -elif [ -d "/root/.acme.sh/${hostname}" ] ; then - lelive="/root/.acme.sh/${hostname}" ; -else - lelive="/etc/letsencrypt/live/${hostname}" ; + +# If you want to manually execute letsencrypt_renew_hook.sh, call it with the SUCCESS environment variable set. +# E.g. like this: "SUCCESS=1 letsencrypt_renew_hook.sh" +# Then we assume that the certificate is there and do the post-processing. +SUCCESS=${SUCCESS:-} + +# acme.sh defines/exports the environment variables +# CERT_PATH, CERT_KEY_PATH, CA_CERT_PATH, CERT_FULLCHAIN_PATH and Le_Domain (main cert domain) +# for all hooks +if [ -f "$CERT_KEY_PATH" ] && [[ "${Le_Domain:-}" == "$hostname" ]]; then + SUCCESS=acme.sh + echo "$(/bin/date)" "Reconfigure and reload services after $hostname certificate issuing/renewal via acme.sh" >>/var/log/ispconfig/ispconfig.log +# certbot defines/exports the environment variables +# RENEWED_DOMAINS (all cert domains space separated) and RENEWED_LINEAGE (directory in /etc/letsencrypt/live) +# for the renew/deploy hook +elif [ -d "$RENEWED_LINEAGE" ] && [[ "$RENEWED_DOMAINS " == "$hostname "* ]]; then + SUCCESS=certbot + echo "$(/bin/date)" "Reconfigure and reload services after $hostname certificate issuing/renewal via certbot" >>/var/log/ispconfig/ispconfig.log fi -if [ -d "$lelive" ]; then - cd /usr/local/ispconfig/interface/ssl; ibak=ispserver.*.bak; ipem=ispserver.pem; icrt=ispserver.crt; ikey=ispserver.key - if ls $ibak 1> /dev/null 2>&1; then rm $ibak; fi - if [ -e "$ipem" ]; then mv $ipem $ipem-$(date +"%y%m%d%H%M%S").bak; cat $ikey $icrt > $ipem; chmod 600 $ipem; fi - pureftpdpem=/etc/ssl/private/pure-ftpd.pem; if [ -e "$pureftpdpem" ]; then chmod 600 $pureftpdpem; fi - # For Red Hat, Centos or derivatives - if which yum &> /dev/null 2>&1 ; then - if ( rpm -q pure-ftpd ); then service pure-ftpd restart; fi - if ( rpm -q monit ); then service monit restart; fi - if ( rpm -q postfix ); then service postfix restart; fi - if ( rpm -q dovecot ); then service dovecot restart; fi - if ( rpm -q mysql-server ); then service mysqld restart; fi - if ( rpm -q mariadb-server ); then service mariadb restart; fi - if ( rpm -q MariaDB-server ); then service mysql restart; fi - if ( rpm -q nginx ); then service nginx restart; fi - if ( rpm -q httpd ); then service httpd restart; fi - # For Debian, Ubuntu or derivatives - elif apt-get -v >/dev/null 2>&1 ; then - if [ $(dpkg-query -W -f='${Status}' pure-ftpd-mysql 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service pure-ftpd-mysql restart; fi - if [ $(dpkg-query -W -f='${Status}' monit 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service monit restart; fi - if [ $(dpkg-query -W -f='${Status}' postfix 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service postfix restart; fi - if [ $(dpkg-query -W -f='${Status}' dovecot-imapd 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service dovecot restart; fi - if [ $(dpkg-query -W -f='${Status}' mysql 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service mysql restart; fi - if [ $(dpkg-query -W -f='${Status}' mariadb 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service mysql restart; fi - if [ $(dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service nginx restart; fi - if [ $(dpkg-query -W -f='${Status}' apache2 2>/dev/null | grep -c "ok installed") -eq 1 ]; then service apache2 restart; fi +if [ -n "$SUCCESS" ]; then + if cd /usr/local/ispconfig/interface/ssl; then + ipem=ispserver.pem + icrt=ispserver.crt + ikey=ispserver.key + if ls ispserver.*.bak &>/dev/null; then + rm ispserver.*.bak fi -else echo `/bin/date` "Your Lets Encrypt SSL certs path for your ISPConfig server FQDN is missing.$line" >> /var/log/ispconfig/ispconfig.log; fi + if [ -e "$ipem" ]; then + mv $ipem "$ipem-$(date +"%y%m%d%H%M%S").bak" + cat $ikey $icrt >$ipem + chmod 600 $ipem + fi + fi + pureftpdpem=/etc/ssl/private/pure-ftpd.pem + if [ -e "$pureftpdpem" ]; then chmod 600 $pureftpdpem; fi + # For Red Hat, Centos or derivatives + if which yum &>/dev/null 2>&1; then + if rpm -q pure-ftpd; then service pure-ftpd restart; fi + if rpm -q monit; then service monit restart; fi + if rpm -q postfix; then service postfix restart; fi + if rpm -q dovecot; then service dovecot restart; fi + if rpm -q mysql-server; then service mysqld restart; fi + if rpm -q mariadb-server; then service mariadb restart; fi + if rpm -q MariaDB-server; then service mysql restart; fi + if rpm -q nginx; then service nginx restart; fi + if rpm -q httpd; then service httpd restart; fi + # For Debian, Ubuntu or derivatives + elif apt-get -v >/dev/null 2>&1; then + if [ "$(dpkg-query -W -f='${Status}' pure-ftpd-mysql 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service pure-ftpd-mysql restart; fi + if [ "$(dpkg-query -W -f='${Status}' monit 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service monit restart; fi + if [ "$(dpkg-query -W -f='${Status}' postfix 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service postfix restart; fi + if [ "$(dpkg-query -W -f='${Status}' dovecot-imapd 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service dovecot restart; fi + if [ "$(dpkg-query -W -f='${Status}' mysql 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service mysql restart; fi + if [ "$(dpkg-query -W -f='${Status}' mariadb 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service mysql restart; fi + if [ "$(dpkg-query -W -f='${Status}' nginx 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service nginx restart; fi + if [ "$(dpkg-query -W -f='${Status}' apache2 2>/dev/null | grep -c "ok installed")" -eq 1 ]; then service apache2 restart; fi + fi +else + echo "$(/bin/date)" "Your Lets Encrypt SSL certs path for your ISPConfig server FQDN is missing." >>/var/log/ispconfig/ispconfig.log +fi -- GitLab From 1452159e20bbd957dbd470f9202274c8ea53a19d Mon Sep 17 00:00:00 2001 From: Daniel Jagszent Date: Tue, 30 Jul 2024 19:56:42 +0200 Subject: [PATCH 17/17] increase log priority to debug in cron_debug.php --- server/cron_debug.php | 3 +++ server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/server/cron_debug.php b/server/cron_debug.php index 568cfd6413..ce3af89645 100644 --- a/server/cron_debug.php +++ b/server/cron_debug.php @@ -55,6 +55,9 @@ if(isset($cmd_opt['cronjob']) && is_file($path.'/'.$cmd_opt['cronjob'])) { die('Usage example: php cron_debug.php --cronjob=100-mailbox_stats.inc.php'."\n"); } +// increase log priority to debug - we are debugging a cronjob after all +$conf['log_priority'] = LOGLEVEL_DEBUG; + // Load and run the cronjob $name = substr($cronjob_file, 0, strpos($cronjob_file, '.')); if(preg_match('/^\d+\-(.*)$/', $name, $match)) $name = $match[1]; // strip numerical prefix from file name 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 eb13c0379c..8ed1f702be 100644 --- a/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php +++ b/server/lib/classes/cron.d/800-letsencrypt_cleanup.inc.php @@ -36,7 +36,6 @@ class cronjob_letsencrypt_cleanup extends cronjob { public function onRunJob() { global $app, $conf; $app->uses('letsencrypt,ini_parser,getconf'); - $conf['log_priority'] = LOGLEVEL_DEBUG; $server_db_record = $app->db->queryOneRecord("SELECT * FROM server WHERE server_id = ?", $conf['server_id']); if(!$server_db_record || !$server_db_record['web_server']) { -- GitLab