/dev/null') ?: ''); $acme = reset($acme); if(is_executable($acme)) { return $acme; } else { return false; } } public function get_acme_command($domains, $key_file, $bundle_file, $cert_file, $server_type = 'apache', &$cert_type = 'RSA') { global $app, $conf; if(empty($domains)) { return false; } $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', '>=')) { $files_to_install .= ' --fullchain-file ' . escapeshellarg($cert_file); } else { $files_to_install .= ' --fullchain-file ' . escapeshellarg($bundle_file) . ' --cert-file ' . escapeshellarg($cert_file); } // 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 { $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 ] || [ $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() { $install_cmd = 'wget -O - https://get.acme.sh | sh'; $ret = null; $val = 0; exec($install_cmd . ' 2>&1', $ret, $val); return $val == 0; } 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; } 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) { $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, &$cert_type = 'RSA') { global $app; 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', '>=')) { $acme_version = 'https://acme-v02.api.letsencrypt.org/directory'; } else { $acme_version = 'https://acme-v01.api.letsencrypt.org/directory'; } 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$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"; } 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; } private function use_acme(&$script = null) { global $app; $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)) { return false; } $primary_domain = reset($domains); $sorted_domains = $domains; sort($sorted_domains); $min_diff = false; $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($sorted_domains, $sorted_cert_domains)) < 1) { continue; } else { // 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'] < $min_diff)) $min_diff = $certificate['diff']; $possible_certificates[] = $certificate; } if($min_diff === false) return false; $cert_paths = false; $used_id = false; foreach($possible_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_id ?: "not found") . ".", LOGLEVEL_DEBUG); return $cert_paths; } private function get_ssl_domain($data) { global $app; $domain = $data['new']['ssl_domain']; if(!$domain) { $domain = $data['new']['domain']; } if($data['new']['ssl'] == 'y' && $data['new']['ssl_letsencrypt'] == 'y') { $domain = $data['new']['domain']; if(substr($domain, 0, 2) === '*.') { // wildcard domain not yet supported by letsencrypt! $app->log('Wildcard domains not yet supported by letsencrypt, so changing ' . $domain . ' to ' . substr($domain, 2), LOGLEVEL_WARN); $domain = substr($domain, 2); } } return $domain; } public function get_website_certificate_paths($data) { $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' ); 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', 'csr' => '', # Not used for LE. 'crt' => $ssl_dir . '/' . $domain . '-le.crt', 'bundle' => $ssl_dir . '/' . $domain . '-le.bundle' ); } return $cert_paths; } private function assemble_domains_to_request($data, $main_domain, $do_check) { global $app, $conf; $certificate_domains = array($main_domain); //* be sure to have good 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 = ? AND active = 'y' AND type = 'subdomain' AND ssl_letsencrypt_exclude != 'y'", intval($data['new']['domain_id'])); if(is_array($subdomains)) { foreach($subdomains as $subdomain) { $certificate_domains[] = $subdomain['domain']; } } //* then, add alias domain if we have $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 $certificate_domains = array_values(array_unique($certificate_domains)); // --- ISPConfig-Hook: Start --- // Run pre-challenge script before ISPConfig's own domain check. $run_ispconfig_check = $do_check; if($do_check) { if(!$this->prepare_cert_create()) { $app->log("pre_achme_challenge.sh failed, skipping ISPConfig check and aborting.", LOGLEVEL_WARN); // finalize_cert_create() was already called by prepare_cert_create() on failure. $run_ispconfig_check = false; // Prevent ISPConfig check from running return []; // Return empty array to abort the certificate request } } // check if domains are reachable to avoid let's encrypt verification errors if($run_ispconfig_check) { // Only run if $do_check was true AND pre-script succeeded $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); $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) { $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 " . $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); // ISPConfig-Hook: If the ISPConfig check failed (no domains left), run post-script. if(empty($certificate_domains)) { $app->log("ISPConfig domain verification failed (empty domain list), running post-script.", LOGLEVEL_WARN); $this->finalize_cert_create(); } } // --- ISPConfig-Hook: End --- $le_domain_count = count($certificate_domains); if($le_domain_count > 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); } return $certificate_domains; } private function link_file($target, $source) { global $app; $needs_link = true; if(@is_link($target)) { $existing_source = readlink($target); if($existing_source == $source) { $needs_link = false; } else { $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($needs_link) { $app->system->exec_safe("ln -s ? ?", $source, $target); } } public function request_certificates($data, $server_type = 'apache', $desired_signature_type = '') { 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'); if(!in_array($desired_signature_type, ['RSA', 'ECDSA'])) { $desired_signature_type = $web_config['le_signature_type'] ?: 'RSA'; } $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; // Ensure variables exist for the finally block $certificate_domains = []; $run_ispconfig_check = true; // ########################################################################## // # PRE-HOOK if(!$this->prepare_cert_create()) { $app->log("pre_achme_challenge.sh failed, skipping ISPConfig check and aborting.", LOGLEVEL_WARN); // finalize_cert_create() was already called by prepare_cert_create() on failure. $run_ispconfig_check = false; // Prevent ISPConfig check from running return false; // Abort the certificate request } // ########################################################################## try { // This function now calls assemble_domains_to_request() which may perform ISPConfig checks. $certificate_domains = $this->assemble_domains_to_request($data, $main_domain, $do_check && $run_ispconfig_check); if(empty($certificate_domains)) { // assemble_domains_to_request() or the PRE-HOOK caused an empty list. // finalize_cert_create() will be called in the finally block if needed. $app->log("Domain list for $main_domain is empty, skipping LE request.", LOGLEVEL_DEBUG); return false; } if($migration_mode) { $app->log("Migration mode active, skipping Let's Encrypt SSL Cert creation for: $main_domain", LOGLEVEL_DEBUG); } $use_acme = $this->use_acme(); if($use_acme === null) { return false; } 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); // Note: finalize_cert_create() will be called in finally if needed for empty domain list. if(!$migration_mode) { $this->finalize_cert_create(); } 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; } } // 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); // Note: finalize_cert_create() will be called in finally if needed for empty domain list. if(!$migration_mode) { $this->finalize_cert_create(); } 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; } } $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']); } return true; } } finally { // ########################################################################## // # POST-HOOK // ISPConfig-Hook: If the ISPConfig check failed (no domains left), run post-script. if(empty($certificate_domains)) { $app->log("ISPConfig domain verification failed (empty domain list), running post-script.", LOGLEVEL_WARN); $this->finalize_cert_create(); } // ########################################################################## } } // --- ISPConfig-Hook: Custom Challenge Scripts --- /** * Runs the pre-challenge script. * @return bool true on success, false on failure */ public function prepare_cert_create() { global $app; $script = '/usr/local/bin/pre_achme_challenge.sh'; $param = 'pre'; $app->log("Running pre-challenge script: $script $param", LOGLEVEL_DEBUG); $app->system->exec_safe("? ?", $script, $param); if($app->system->last_exec_retcode() == 0) { $app->log("Pre-challenge script successful.", LOGLEVEL_DEBUG); return true; } else { $app->log("Pre-challenge script failed with code: " . $app->system->last_exec_retcode() . ". Output: " . $app->system->last_exec_out(), LOGLEVEL_WARN); // As requested: run post-script if pre-script fails $this->finalize_cert_create(); return false; } } /** * Runs the post-challenge script. */ public function finalize_cert_create() { global $app; $script = '/usr/local/bin/post_achme_challenge.sh'; $param = 'post'; $app->log("Running post-challenge script: $script $param", LOGLEVEL_DEBUG); $app->system->exec_safe("? ?", $script, $param); if($app->system->last_exec_retcode() != 0) { $app->log("Post-challenge script failed with code: " . $app->system->last_exec_retcode() . ". Output: " . $app->system->last_exec_out(), LOGLEVEL_WARN); } } // --- End ISPConfig-Hook --- /** * Gets a list of all installed certificates on this server. * * @return array */ public function get_certificate_list() { global $app, $conf; $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 []; } $candidates = []; if($use_acme) { // 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 []; } $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_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 || !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')) { $app->log('get_certificate_list: skip ' . $full_path . '/' . $domain . '.conf because it is not readable', LOGLEVEL_DEBUG); continue; } $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 { 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)) { $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 = [ 'source' => '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); $candidates[] = $certificate; } closedir($dir); } $certificates = []; 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; } /** @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_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_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']; } 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_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 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) { 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_domains, function($deny_pattern) use ($cert_domain) { return mb_strtolower($deny_pattern) == mb_strtolower($cert_domain) || fnmatch($deny_pattern, $cert_domain, FNM_CASEFOLD); })); } } if(in_array($certificate['serial_number'], $deny_list_serials, true)) { $on_deny_list[] = $certificate['serial_number']; } return $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, $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($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)) { $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(); 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($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->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 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($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; } } } return true; } 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; } $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 ?: 'inline certificate') . ' could not be parsed', LOGLEVEL_ERROR); return false; } if(empty($info['subject']['CN']) || !$this->is_domain_name_or_wildcard($info['subject']['CN'])) { $domains = []; } else { $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) { return false; } $maybe_domain = trim($parts[1]); 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 false; }, 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(); $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 && $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) { $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 [ 'serial_number' => $info['serialNumberHex'] ?: $info['serialNumber'], 'signature_type' => $signature_type, 'subject' => $info['subject'], 'issuer' => $info['issuer'], 'domains' => $domains, 'is_valid' => $is_valid, 'is_revoked' => $is_revoked, 'valid_from' => $valid_from, 'valid_to' => $valid_to, ]; } 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) { 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); } }