diff --git a/install/lib/installer_base.lib.php b/install/lib/installer_base.lib.php index 696a7042bde3d19a036f2c74f932ca96ee63610d..217d8b5a51cb80ee32866812f60a05cca5e8d178 100644 --- a/install/lib/installer_base.lib.php +++ b/install/lib/installer_base.lib.php @@ -692,7 +692,7 @@ class installer_base { $this->warning('Unable to set rights of user in master database: '.$value['db']."\n Query: ".$query."\n Error: ".$this->dbmaster->errorMessage); } - $query = "GRANT SELECT, UPDATE (`ssl`, `ssl_letsencrypt`, `ssl_request`, `ssl_cert`, `ssl_action`, `ssl_key`) ON ?? TO ?@?"; + $query = "GRANT SELECT, UPDATE (`ssl`, `ssl_valid_until`, `ssl_letsencrypt`, `ssl_request`, `ssl_cert`, `ssl_action`, `ssl_key`) ON ?? TO ?@?"; if ($verbose){ echo $query ."\n"; } diff --git a/install/sql/incremental/upd_dev_collection.sql b/install/sql/incremental/upd_dev_collection.sql index 7d1ec4381568ba79c6812a569985778030585cf3..81b6c155fb0c6ebd6664556edcee8420e7bb20ca 100644 --- a/install/sql/incremental/upd_dev_collection.sql +++ b/install/sql/incremental/upd_dev_collection.sql @@ -19,3 +19,7 @@ DROP TABLE 'software_update_inst'; -- Brexit UPDATE `country` SET `eu` = 'n' WHERE `iso` = 'GB'; + +-- add field for enhanced SSL handling +ALTER TABLE `web_domain` ADD `ssl_valid_until` timestamp NULL DEFAULT NULL AFTER `ssl`; +-- end of fixes \ No newline at end of file diff --git a/install/sql/ispconfig3.sql b/install/sql/ispconfig3.sql index 04082fbeb7d371e871c89a34072c1b6e8bcf1919..8e83b1fc8f3428eadde50cbb6b27d7f0397dbada 100644 --- a/install/sql/ispconfig3.sql +++ b/install/sql/ispconfig3.sql @@ -1957,6 +1957,7 @@ CREATE TABLE `web_domain` ( `seo_redirect` varchar(255) default NULL, `rewrite_to_https` ENUM('y','n') NOT NULL DEFAULT 'n', `ssl` enum('n','y') NOT NULL default 'n', + `ssl_valid_until` timestamp NULL DEFAULT NULL, `ssl_letsencrypt` enum('n','y') NOT NULL DEFAULT 'n', `ssl_letsencrypt_exclude` enum('n','y') NOT NULL DEFAULT 'n', `ssl_state` varchar(255) NULL, diff --git a/server/lib/app.inc.php b/server/lib/app.inc.php index e0e8c85db2882eff0178e19995aa093f5d9d43a3..4a7e0bea6583af867690142b4709b33082547f80 100644 --- a/server/lib/app.inc.php +++ b/server/lib/app.inc.php @@ -74,7 +74,7 @@ class app { } public function __get($name) { - $valid_names = array('functions', 'getconf', 'letsencrypt', 'modules', 'plugins', 'services', 'system'); + $valid_names = array('functions', 'getconf', 'letsencrypt', 'openssl', 'modules', 'plugins', 'services', 'system'); if(!in_array($name, $valid_names)) { trigger_error('Undefined property ' . $name . ' of class app', E_USER_WARNING); } diff --git a/server/lib/classes/cron.d/900-letsencrypt.inc.php b/server/lib/classes/cron.d/900-letsencrypt.inc.php index b0f6f39c51a96ae205d151539fa7fd0f1c011620..762c3d1dbfa8d83ede6a8a57eefd31f9e81d3bab 100644 --- a/server/lib/classes/cron.d/900-letsencrypt.inc.php +++ b/server/lib/classes/cron.d/900-letsencrypt.inc.php @@ -48,7 +48,10 @@ class cronjob_letsencrypt extends cronjob { public function onRunJob() { global $app, $conf; - + + //TODO: Certbot: If this is a mirror server, do not run + //TODO: Certbot: On mirror server, check for new cert (validity in master-db vs local db) and re-install + $server_config = $app->getconf->get_server_config($conf['server_id'], 'server'); if(!isset($server_config['migration_mode']) || $server_config['migration_mode'] != 'y') { $acme = $app->letsencrypt->get_acme_script(); diff --git a/server/lib/classes/letsencrypt.inc.php b/server/lib/classes/letsencrypt.inc.php index a2e6a5c380144ecc864259b5dc149169eff4d10d..1893a0524d098e3b6026519077088d6472230af5 100644 --- a/server/lib/classes/letsencrypt.inc.php +++ b/server/lib/classes/letsencrypt.inc.php @@ -37,12 +37,22 @@ class letsencrypt { */ private $base_path = '/etc/letsencrypt'; private $renew_config_path = '/etc/letsencrypt/renewal'; - private $certbot_use_certcommand = false; + private $COMMAND_TYPE_CHECK = "CHECK"; + private $COMMAND_TYPE_REQUEST = "REQUEST"; + private $COMMAND_TYPE_INSTALL = "INSTALL"; public function __construct(){ } + /** + * acme.sh + * Searches for the acme.sh client scripts in known locations + * Returns false if no acme.sh executable is found + * Returns the path the the acme.sh script if found + * + * @return false|string + */ public function get_acme_script() { $acme = explode("\n", shell_exec('which /usr/local/ispconfig/server/scripts/acme.sh /root/.acme.sh/acme.sh')); $acme = reset($acme); @@ -53,42 +63,60 @@ class letsencrypt { } } - public function get_acme_command($domains, $key_file, $bundle_file, $cert_file, $server_type = 'apache') { + /** + * acme.sh + * Generates the shell commands to be used when acme.sh is the LetsEncrypt client + * + * @param COMMAND_TYPE $command_type One of the ENUMs telling which type of command should be generated + * @param array $domains Array of domains relevant for the certification + * @param string $key_file Path to the certificate key file + * @param string $bundle_file Path to the certificate bundle file + * @param string $cert_file Path to the certificate file + * @param string $server_type apache|nginx + * + * @return false|string + */ + public function get_acme_command($command_type, $domains, $key_file, $bundle_file, $cert_file, $server_type = 'apache') { global $app, $conf; $letsencrypt = $this->get_acme_script(); - $cmd = ''; + $domains_arg = ''; // generate cli format foreach($domains as $domain) { - $cmd .= (string) " -d " . $domain; + $domains_arg .= (string) " -d " . $domain; } - if($cmd == '') { + if($domains_arg == '') { return false; } + $log_arg = "--log " . escapeshellarg($conf['ispconfig_log_dir'].'/acme.log'). ""; + $reload_arg = "--reloadcmd " . escapeshellarg($this->get_reload_command()) . ""; + if($server_type != 'apache' || version_compare($app->system->getapacheversion(true), '2.4.8', '>=')) { $cert_arg = '--fullchain-file ' . escapeshellarg($cert_file); } else { $cert_arg = '--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 || $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() { - $letsencrypt = explode("\n", shell_exec('which letsencrypt certbot /root/.local/share/letsencrypt/bin/letsencrypt /opt/eff.org/certbot/venv/bin/certbot')); - $letsencrypt = reset($letsencrypt); - if(is_executable($letsencrypt)) { - return $letsencrypt; + if ( $this->COMMAND_TYPE_REQUEST == $command_type) { + return "{$letsencrypt} --issue {$domains_arg} -w /usr/local/ispconfig/interface/acme --always-force-new-domain-key --keylength 4096 {$log_arg}"; + } else if ( $this->COMMAND_TYPE_INSTALL == $command_type) { + return "{$letsencrypt} --install-cert {$domains_arg} --key-file ". escapeshellarg($key_file) ." {$cert_arg} {$reload_arg} {$log_arg}"; } else { - return false; + return ""; } + } + /** + * acme.sh + * + * Installs the acme.sh script locally + * + * @return bool true for successful install, false if failed + */ private function install_acme() { $install_cmd = 'wget -O - https://get.acme.sh | sh'; $ret = null; @@ -98,12 +126,37 @@ class letsencrypt { return ($val == 0 ? true : false); } + /** + * certbot + * + * Searches for the certbot client script in known locations + * Returns false if no certbot executable is found + * Returns the path the the certbot script if found + * + * @return false|string + */ + public function get_certbot_script() { + $letsencrypt = explode("\n", shell_exec('which letsencrypt certbot /root/.local/share/letsencrypt/bin/letsencrypt /opt/eff.org/certbot/venv/bin/certbot')); + $letsencrypt = reset($letsencrypt); + if(is_executable($letsencrypt)) { + return $letsencrypt; + } else { + return false; + } + } + + /** + * certbot | acme.sh + * + * Returns the reload command for the used http server + * + * @return string the reload command + */ 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']; @@ -122,52 +175,105 @@ class letsencrypt { return $cmd; } - public function get_certbot_command($domains) { + /** + * certbot + * + * Generates the shell commands to be used when certbot is the LetsEncrypt client + * + * @param COMMAND_TYPE $command_type One of the ENUMs telling which type of command should be generated + * @param array $domains Array of domains relevant for the certification + * + * @return string the shell command (empty in case of errors or missing parameters) + */ + public function get_certbot_command($command_type, $domains) { global $app; $letsencrypt = $this->get_certbot_script(); - $cmd = ''; - // generate cli format + // Map the domain array to a string containing the cli args + $domain_arg = ''; foreach($domains as $domain) { - $cmd .= (string) " --domains " . $domain; + $domain_arg .= (string) " --domains " . $domain; } - if($cmd == '') { - return false; + // Domains are required + if($domain_arg == '') { + return ''; } - $matches = array(); - $ret = null; - $val = 0; + $primary_domain = $domains[0]; + $certbot_can_use_certcommand = false; - $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(); + $app->log("LE version is " . $letsencrypt_version, LOGLEVEL_DEBUG); if (version_compare($letsencrypt_version, '0.22', '>=')) { $acme_version = 'https://acme-v02.api.letsencrypt.org/directory'; } else { + $app->log("You are using an outdated Let's Encrypt client which is not able to use the acme V02 protocol. Please update! The old acme V01 protocol will be end of life in mid 2021. See https://community.letsencrypt.org/t/end-of-life-plan-for-acmev1/88430", LOGLEVEL_ERROR); $acme_version = 'https://acme-v01.api.letsencrypt.org/directory'; } + // Modern versions of certbot allow us for some more fancy options if (version_compare($letsencrypt_version, '0.30', '>=')) { - $app->log("LE version is " . $letsencrypt_version . ", so using certificates command", LOGLEVEL_DEBUG); - $this->certbot_use_certcommand = true; + $app->log("using certificates command and --webroot-map", LOGLEVEL_DEBUG); + $certbot_can_use_certcommand = true; $webroot_map = array(); for($i = 0; $i < count($domains); $i++) { $webroot_map[$domains[$i]] = '/usr/local/ispconfig/interface/acme'; } $webroot_args = "--webroot-map " . escapeshellarg(str_replace(array("\r", "\n"), '', json_encode($webroot_map))); + // Domain list is not required with json webroot map, the domains will be implicitly used from the json + $domain_arg = ""; + $cert_selection_command = "--cert-name $primary_domain"; } else { - $webroot_args = "$cmd --webroot-path /usr/local/ispconfig/interface/acme"; + $webroot_args = "--webroot-path /usr/local/ispconfig/interface/acme"; + $cert_selection_command = "--expand"; } - $cmd = $letsencrypt . " certonly -n --text --agree-tos --expand --authenticator webroot --server $acme_version --rsa-key-size 4096 --email postmaster@$domain $webroot_args"; + // Generate the required command based on the $command_type passed in + if ( $this->COMMAND_TYPE_REQUEST == $command_type) { + return $letsencrypt . " certonly -n --text --agree-tos {$cert_selection_command} --authenticator webroot --server {$acme_version} --rsa-key-size 4096 --email postmaster@{$primary_domain} {$webroot_args} {$domain_arg}"; + } else if ( $this->COMMAND_TYPE_CHECK == $command_type && $certbot_can_use_certcommand) { + return $letsencrypt . " certificates {$domain_arg}"; + } else { + return ''; + } - return $cmd; } - public function get_letsencrypt_certificate_paths($domains = array()) { + /** + * certbot + * + * Returns the certbot version + * + * @return string The certbot version + */ + public function get_certbot_version() { + $letsencrypt = $this->get_certbot_script(); + + $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)) { + return $matches[2]; + } + return $letsencrypt_version; + } + + /** + * certbot + * + * Searches the letsencrypt directory for the best matching existing certificates for all given domains. + * This is done searching and scoring the renewal config file and the containing domains based on a given domain list + * Returns false if none is found + * Returns an array of certificate paths if a matching cert is found + * + * @param array $domains The target domains to find a matching certificate for + * + * @return false|array False if none found. Array of the certificate paths if a matching cert is found + */ + public function find_matching_certificate_on_filesystem($domains = array()) { global $app; if($this->get_acme_script()) { @@ -186,6 +292,7 @@ class letsencrypt { sort($domains); $min_diff = false; + // Iterate over all renewal config files and create a score for each file while($file = readdir($dir)) { if($file === '.' || $file === '..' || substr($file, -5) !== '.conf') continue; $file_path = $this->renew_config_path . '/' . $file; @@ -243,10 +350,12 @@ class letsencrypt { } closedir($dir); + // We didn't find any matching certificate if($min_diff === false) return false; $cert_paths = false; $used_path = false; + // Select the config with the best matching score foreach($path_scores as $path => $data) { if($data['diff'] === $min_diff) { $used_path = $path; @@ -260,6 +369,14 @@ class letsencrypt { return $cert_paths; } + /** + * certbot | acme.sh + * Returns the cleaned SSL Domain. Removes invalid parts and maps empty ssl_domain configs + * + * @param $data + * + * @return string + */ private function get_ssl_domain($data) { global $app; @@ -280,6 +397,15 @@ class letsencrypt { return $domain; } + /** + * certbot | acme.sh + * + * Calculates the Paths, where the certificate files should be found within the webroot + * + * @param $data The website config array + * + * @return array The path array + */ public function get_website_certificate_paths($data) { $ssl_dir = $data['new']['document_root'].'/ssl'; $domain = $this->get_ssl_domain($data); @@ -306,37 +432,46 @@ class letsencrypt { 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'); + /** + * acme.sh + * + * Check if acme.sh is installed and use it prefered + * If neither acme.sh nor certbot are there, install acme.sh + * + * @return bool + */ + public function can_use_acmesh() { + global $app; - $use_acme = false; if($this->get_acme_script()) { - $use_acme = true; + return 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; + return true; } + $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']; + /** + * certbot | acme.sh + * + * Returns an array of all domains required for the vhost including subdomains and alias domains + * + * @param $data The website config array + * + * @return array The domain list for the given website + */ + public function get_domains_for_certificate($data) { + global $app; + $domain = $this->get_ssl_domain($data); // default values $temp_domains = array($domain); - $cli_domain_arg = ''; $subdomains = null; $aliasdomains = null; @@ -365,92 +500,31 @@ class letsencrypt { } // prevent duplicate - $temp_domains = array_unique($temp_domains); - - // check if domains are reachable to avoid letsencrypt verification errors - $le_rnd_file = uniqid('le-') . '.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)); - if($le_hash_check == $le_rnd_hash) { - $le_domains[] = $temp_domain; - $app->log("Verified domain " . $temp_domain . " should be reachable for letsencrypt.", LOGLEVEL_DEBUG); - } else { - $app->log("Could not verify domain " . $temp_domain . ", so excluding it from letsencrypt request.", LOGLEVEL_WARN); - } - } - } - $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); - if($le_domain_count > 100) { - $temp_domains = array_slice($temp_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); - } - - // 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); - } else { - $letsencrypt_cmd = $this->get_certbot_command($temp_domains); - umask($old_umask); - } - - $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); - - $success = $app->system->_exec($letsencrypt_cmd, $allow_return_codes); - } else { - $app->log("Migration mode active, skipping Let's Encrypt SSL Cert creation for: $domain", LOGLEVEL_DEBUG); - $success = true; - } - } + return array_unique($temp_domains); + } - 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; - } - } + /** + * certbot + * + * Checks whether a certificate already exists and returns it using certbot certificate command + * Returns false if none is found + * Returns an array of the paths to the certificates if found + * + * @param $domains the domain list + * + * @return false|array False if none found. An array of the paths if a certificate is found + */ + public function get_existing_certificate_from_certbot($domains) { + global $app; - $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; - } + // There is no way to check this using acme.sh so always return false + if (!$this->can_use_acmesh()) { + $app->log("LE Certbot - Checking for existing certificates using the 'certificate' command", LOGLEVEL_DEBUG); - $letsencrypt_cmd = $this->get_certbot_script() . " certificates " . $cli_domain_arg; - $output = explode("\n", shell_exec($letsencrypt_cmd . " 2>/dev/null | grep -v '^\$'")); + $output = explode("\n", shell_exec($this->get_certbot_command($this->COMMAND_TYPE_CHECK, $domains) . " 2>/dev/null | grep -v '^\$'")); $le_path = ''; + $le_valid_until = 0; $skip_to_next = true; $matches = null; foreach($output as $outline) { @@ -462,12 +536,21 @@ class letsencrypt { } $skip_to_next = false; + // Check if the certificate is expired ("VALID: EXPIRED"). + // Skip all other checks 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; } + // Get validity information + if(preg_match('/^\s*Expiry Date:\s?(.*)\s?\(VALID:\s+\d+/', $outline)) { + $app->log("Certificate valid until: " . $matches[1], LOGLEVEL_DEBUG); + $le_valid_until = strtotime(trim($matches[1])); + continue; + } + if(preg_match('/^\s*Certificate Path:\s*(\/.*?)\s*$/', $outline, $matches)) { $app->log("Found LE path: " . $matches[1], LOGLEVEL_DEBUG); $le_path = dirname($matches[1]); @@ -480,76 +563,246 @@ class letsencrypt { } if($le_path) { - $le_files = array( + return array( 'privkey' => $le_path . '/privkey.pem', 'chain' => $le_path . '/chain.pem', 'cert' => $le_path . '/cert.pem', - 'fullchain' => $le_path . '/fullchain.pem' + 'fullchain' => $le_path . '/fullchain.pem', + 'valid_until' => $le_valid_until ); } } - if(empty($le_files)) { - $le_files = $this->get_letsencrypt_certificate_paths($temp_domains); + return false; + } + + /** + * certbot + * + * Searches for an existing certbot certificate + * + * @param $domains the domains list for certification + * @return false|array false if none found, else an array with the paths to the certificate + */ + public function get_existing_certificate($domain) { + + $domains = $this->get_domains_for_certificate($domain); + + if($this->can_use_acmesh()) { + // TODO: Use the --install-cert Command of ACME and check the return code (1 = error | 0 = found) + + return false; + } else { + $letsencrypt_version = $this->get_certbot_version(); + + // Use Certbot certificate command + if (version_compare($letsencrypt_version, '0.30', '>=')) { + return $this->get_existing_certificate_from_certbot($domains); + } else { + // On older certbot versions, search on filesystem (legacy fallback) + return $this->find_matching_certificate_on_filesystem($domains); + } } - unset($temp_domains); + } - if($server_type != 'apache' || version_compare($app->system->getapacheversion(true), '2.4.8', '>=')) { - $crt_tmp_file = $le_files['fullchain']; + /** + * certbot | acme.sh + * + * Request a new certificate using one of the available LE backends. + * Returns the domains of the certificate or false in case of failure + * + * @param $data The website config + * @param string $server_type The http server type (apache | nginx) + * + * @return array|false False on failure. Else the array of domains the cert was requested for + */ + public function fetch_certificate_from_le($data, $server_type = 'apache') { + + global $app, $conf; + + $app->log("Trying to fetch new certificate from Letsencrypt", LOGLEVEL_DEBUG); + + $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 = $this->can_use_acmesh(); + + $tmp = $this->get_website_certificate_paths($data); + $domain = $tmp['domain']; + $key_file = $tmp['key']; + $crt_file = $tmp['crt']; + $bundle_file = $tmp['bundle']; + // Create the list of all wanted domains + $temp_domains = $this->get_domains_for_certificate($data); + // Validate all the domains and only return the validated ones + $temp_domains = $this->validate_certificate_domains($temp_domains, $web_config, $server_config); + + // LE only accepts 100 domains per single certificate, so cut overflow off and war about it + $le_domain_count = count($temp_domains); + if($le_domain_count > 100) { + $temp_domains = array_slice($temp_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); + } + + // Prepare the LE backend to use, get the command + $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($this->COMMAND_TYPE_REQUEST, $temp_domains, $key_file, $bundle_file, $crt_file, $server_type); + $allow_return_codes = array(2); } else { - $crt_tmp_file = $le_files['cert']; + $letsencrypt_cmd = $this->get_certbot_command($this->COMMAND_TYPE_REQUEST, $temp_domains); + umask($old_umask); } - $key_tmp_file = $le_files['privkey']; - $bundle_tmp_file = $le_files['chain']; + // Execute the LE Backend call and obtain the certificate + $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: ". implode(" ", $temp_domains), LOGLEVEL_DEBUG); + + $success = $app->system->_exec($letsencrypt_cmd, $allow_return_codes); + } else { + $app->log("Migration mode active, skipping Let's Encrypt SSL Cert creation for: $domain", LOGLEVEL_DEBUG); + $success = true; + } + } 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); + return false; + } else { + $app->log('Let\'s Encrypt SSL Cert for: ' . $domain . ' successfully issued.', LOGLEVEL_DEBUG); + return $temp_domains; + } - // if cert already exists, dont remove it. Ex. expired/misstyped/noDnsYet alias domain, api down... - if(!file_exists($crt_tmp_file)) { - return false; + } + + /** + * certbot | acme.sh + * + * Checks whether a list of domains can be reached from the outside to prevalidate the Let's Encrypt requests + * + * @param $domains_to_validate List of domains to validate + * + * @return array The list of validated domains which can be accessed from the outside + */ + private function validate_certificate_domains($domains_to_validate, $web_config, $server_config) { + global $app; + // check if domains are reachable to avoid letsencrypt verification errors + $le_rnd_file = uniqid('le-') . '.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($domains_to_validate 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)); + if($le_hash_check == $le_rnd_hash) { + $le_domains[] = $temp_domain; + $app->log("Verified domain " . $temp_domain . " should be reachable for letsencrypt.", LOGLEVEL_DEBUG); + } else { + $app->log("Could not verify domain " . $temp_domain . ", so excluding it from letsencrypt request.", LOGLEVEL_WARN); + } } } + @unlink('/usr/local/ispconfig/interface/acme/.well-known/acme-challenge/' . $le_rnd_file); - //* 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"); + return $le_domains; + } - //* 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); - } + /** + * certbot | acme.sh + * + * Setup all required links, files and stuff on the server + * + * @param $data + * @param $domains + * @param string $server_type + * + * @return false | array False on setup failure. Else an array with the validity of the new certificate + */ + public function setup_certificate($data, $domains, $server_type = 'apache') { + global $app; - 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); + // Target paths + $tmp = $this->get_website_certificate_paths($data); + $domain = $tmp['domain']; + $key_file = $tmp['key']; + $crt_file = $tmp['crt']; + $bundle_file = $tmp['bundle']; - 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); - } + if ($this->can_use_acmesh()) { + // Install the certificate using acme.sh client + $cmd = $this->get_acme_command($this->COMMAND_TYPE_INSTALL, $domains, $key_file, $bundle_file, $crt_file, $server_type ); + $app->system->exec_safe($cmd); + // Return the validity of the setup certificate + return $app->openssl->get_cert_validity($crt_file); + } else { + // Certbot requires manually created symlinks - 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); + // Get the existing certificates + $le_files = $this->get_existing_certificate($domains); + if (!$le_files) { + return false; + } - 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); + // Chose the required files from let's encrypt to setup + // Apache requires different certificate types (cert only VS full chain) based on the Apache version + 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']; } + $key_tmp_file = $le_files['privkey']; + $bundle_tmp_file = $le_files['chain']; + + //* check is been correctly created + if(file_exists($crt_tmp_file)) { + $app->log("Let's Encrypt Cert file: $crt_tmp_file exists.", LOGLEVEL_DEBUG); + + // 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'); + $app->system->chmod($key_file.'.old', 0400); + $app->system->unlink($key_file); + } - 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); + 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); - return true; - } else { - $app->log("Let's Encrypt Cert file: $crt_tmp_file does not exist.", LOGLEVEL_DEBUG); - return false; + if(is_file($crt_file)) { + $app->system->copy($crt_file, $crt_file.'.old'); + $app->system->chmod($crt_file.'.old', 0400); + $app->system->unlink($crt_file); + } + + 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'); + $app->system->chmod($bundle_file.'.old', 0400); + $app->system->unlink($bundle_file); + } + + 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); + + // All done, return validity of the setup cert file + return $app->openssl->get_cert_validity($crt_file); + } else { + $app->log("Let's Encrypt Cert file: $crt_tmp_file does not exist.", LOGLEVEL_DEBUG); + return false; + } } } + } diff --git a/server/lib/classes/openssl.inc.php b/server/lib/classes/openssl.inc.php new file mode 100644 index 0000000000000000000000000000000000000000..6e9ddda9896a6f1c36b15017278553c8173e7b7b --- /dev/null +++ b/server/lib/classes/openssl.inc.php @@ -0,0 +1,78 @@ +system->exec_safe("{$this->openssl_command} x509 -dates -noout -in ?", $crt_file) . " 2>/dev/null | grep -v '^\$'"); + $matches = null; + $validity = array(); + foreach($output as $outline) { + $outline = trim( $outline ); + $app->log( "CERT Validity output: " . $outline, LOGLEVEL_DEBUG ); + + if ( preg_match( '/^notBefore=(.*)$/', $outline, $matches ) ) { + $app->log( "Certificate valid from: " . $matches[1], LOGLEVEL_DEBUG ); + $validity['from'] = strtotime( $matches[1] ); + continue; + } + + if ( preg_match( '/^notAfter=(.*)$/', $outline, $matches ) ) { + $app->log( "Certificate valid until: " . $matches[1], LOGLEVEL_DEBUG ); + $validity['until'] = strtotime( $matches[1] ); + continue; + } + } + + return $validity; + + } + +} diff --git a/server/plugins-available/nginx_plugin.inc.php b/server/plugins-available/nginx_plugin.inc.php index bec59fddc40f38525cb7f6d35ab982f8c4b7a053..f3aaf1d4c92f948fb7c958deb4b3fdc3b0510014 100644 --- a/server/plugins-available/nginx_plugin.inc.php +++ b/server/plugins-available/nginx_plugin.inc.php @@ -100,6 +100,8 @@ class nginx_plugin { // load the server configuration options $app->uses('getconf'); + // load openssl functions + $app->uses('openssl'); $web_config = $app->getconf->get_server_config($conf['server_id'], 'web'); if ($web_config['CA_path']!='' && !file_exists($web_config['CA_path'].'/openssl.cnf')) $app->log("CA path error, file does not exist:".$web_config['CA_path'].'/openssl.cnf', LOGLEVEL_ERROR); @@ -179,21 +181,24 @@ class nginx_plugin { $ssl_ext_file = $ssl_dir.'/v3.ext'; $app->system->file_put_contents($ssl_ext_file, $ext_cnf); - $rand_file = $rand_file; - $key_file2 = $key_file2; $openssl_cmd_key_file2 = $key_file2; - if(substr($domain, 0, 2) == '*.' && strpos($key_file2, '/ssl/\*.') !== false) $key_file2 = str_replace('/ssl/\*.', '/ssl/*.', $key_file2); // wildcard certificate - $key_file = $key_file; + if (substr($domain, 0, 2) == '*.' && strpos($key_file2, '/ssl/\*.') !== false) { + $key_file2 = str_replace('/ssl/\*.', '/ssl/*.', $key_file2); // wildcard certificate + } $openssl_cmd_key_file = $key_file; - if(substr($domain, 0, 2) == '*.' && strpos($key_file, '/ssl/\*.') !== false) $key_file = str_replace('/ssl/\*.', '/ssl/*.', $key_file); // wildcard certificate + if (substr($domain, 0, 2) == '*.' && strpos($key_file, '/ssl/\*.') !== false) { + $key_file = str_replace('/ssl/\*.', '/ssl/*.', $key_file); // wildcard certificate + } $ssl_days = 3650; - $csr_file = $csr_file; $openssl_cmd_csr_file = $csr_file; - if(substr($domain, 0, 2) == '*.' && strpos($csr_file, '/ssl/\*.') !== false) $csr_file = str_replace('/ssl/\*.', '/ssl/*.', $csr_file); // wildcard certificate + if (substr($domain, 0, 2) == '*.' && strpos($csr_file, '/ssl/\*.') !== false) { + $csr_file = str_replace('/ssl/\*.', '/ssl/*.', $csr_file); // wildcard certificate + } $config_file = $ssl_cnf_file; - $crt_file = $crt_file; $openssl_cmd_crt_file = $crt_file; - if(substr($domain, 0, 2) == '*.' && strpos($crt_file, '/ssl/\*.') !== false) $crt_file = str_replace('/ssl/\*.', '/ssl/*.', $crt_file); // wildcard certificate + if (substr($domain, 0, 2) == '*.' && strpos($crt_file, '/ssl/\*.') !== false) { + $crt_file = str_replace('/ssl/\*.', '/ssl/*.', $crt_file); // wildcard certificate + } if(is_file($ssl_cnf_file) && !is_link($ssl_cnf_file)) { @@ -206,11 +211,11 @@ class nginx_plugin { $app->system->exec_safe("openssl ca -batch -out ? -config ? -passin pass:? -in ? -extfile ?", $openssl_cmd_crt_file, $web_config['CA_path']."/openssl.cnf", $web_config['CA_pass'], $openssl_cmd_csr_file, $ssl_ext_file); $app->log("Creating CA-signed SSL Cert for: $domain", LOGLEVEL_DEBUG); if (filesize($crt_file)==0 || !file_exists($crt_file)) $app->log("CA-Certificate signing failed. openssl ca -out $openssl_cmd_crt_file -config ".$web_config['CA_path']."/openssl.cnf -passin pass:".$web_config['CA_pass']." -in $openssl_cmd_csr_file -extfile $ssl_ext_file", LOGLEVEL_ERROR); - }; + } if (@filesize($crt_file)==0 || !file_exists($crt_file)){ $app->system->exec_safe("openssl req -x509 -passin pass:? -passout pass:? -key ? -in ? -out ? -days ? -config ?", $ssl_password, $ssl_password, $openssl_cmd_key_file2, $openssl_cmd_csr_file, $openssl_cmd_crt_file, $ssl_days, $config_file); $app->log("Creating self-signed SSL Cert for: $domain", LOGLEVEL_DEBUG); - }; + } } @@ -222,12 +227,18 @@ class nginx_plugin { $ssl_request = $app->system->file_get_contents($csr_file); $ssl_cert = $app->system->file_get_contents($crt_file); $ssl_key = $app->system->file_get_contents($key_file); + $ssl_cert_validity = $app->openssl->get_cert_validity($crt_file); /* Update the DB of the (local) Server */ $app->db->query("UPDATE web_domain SET ssl_request = ?, ssl_cert = ?, ssl_key = ? WHERE domain = ?", $ssl_request, $ssl_cert, $ssl_key, $data['new']['domain']); $app->db->query("UPDATE web_domain SET ssl_action = '' WHERE domain = ?", $data['new']['domain']); /* Update also the master-DB of the Server-Farm */ $app->dbmaster->query("UPDATE web_domain SET ssl_request = ?, ssl_cert = ?, ssl_key = ? WHERE domain = ?", $ssl_request, $ssl_cert, $ssl_key, $data['new']['domain']); $app->dbmaster->query("UPDATE web_domain SET ssl_action = '' WHERE domain = ?", $data['new']['domain']); + /* Update the validity of the certificate */ + if (!empty($ssl_cert_validity) && $ssl_cert_validity['until'] != -1) { + $app->db->query("UPDATE web_domain SET ssl_valid_until = FROM_UNIXTIME(?) WHERE domain = ?", $ssl_cert_validity['until'], $data['new']['domain']); + $app->dbmaster->query("UPDATE web_domain SET ssl_valid_until = FROM_UNIXTIME(?) WHERE domain = ?", $ssl_cert_validity['until'], $data['new']['domain']); + } } //* Check that the SSL key is not password protected @@ -1356,7 +1367,7 @@ class nginx_plugin { $trans = array( '{DOCROOT}' => $vhost_data['web_document_root_www'], '{DOCROOT_CLIENT}' => $vhost_data['web_document_root'], - '{DOMAIN}' => $vhost_data['domain'], + '{DOMAIN}' => $vhost_data['domain'], '{FASTCGIPASS}' => 'fastcgi_pass '.($data['new']['php_fpm_use_socket'] == 'y'? 'unix:'.$fpm_socket : '127.0.0.1:'.$vhost_data['fpm_port']).';' ); foreach($nginx_directive_lines as $nginx_directive_line){ @@ -1383,30 +1394,47 @@ class nginx_plugin { $vhost_data['ssl_bundle_file'] = $bundle_file; //* Generate Let's Encrypt SSL certificat - if($data['new']['ssl'] == 'y' && $data['new']['ssl_letsencrypt'] == 'y' && $conf['mirror_server_id'] == 0 && ( // ssl and let's encrypt is active and no mirror server + if($data['new']['ssl'] == 'y' && $data['new']['ssl_letsencrypt'] == 'y' && ( // ssl and let's encrypt is active ($data['old']['ssl'] == 'n' || $data['old']['ssl_letsencrypt'] == 'n') // we have new let's encrypt configuration || ($data['old']['domain'] != $data['new']['domain']) // we have domain update || ($data['old']['subdomain'] != $data['new']['subdomain']) // we have new or update on "auto" subdomain || $this->update_letsencrypt == true )) { - $success = $app->letsencrypt->request_certificates($data, 'nginx'); - if($success) { - /* we don't need to store it. - /* Update the DB of the (local) Server */ - $app->db->query("UPDATE web_domain SET ssl_request = '', ssl_cert = '', ssl_key = '' WHERE domain = ?", $data['new']['domain']); - $app->db->query("UPDATE web_domain SET ssl_action = '' WHERE domain = ?", $data['new']['domain']); - /* Update also the master-DB of the Server-Farm */ - $app->dbmaster->query("UPDATE web_domain SET ssl_request = '', ssl_cert = '', ssl_key = '' WHERE domain = ?", $data['new']['domain']); - $app->dbmaster->query("UPDATE web_domain SET ssl_action = '' WHERE domain = ?", $data['new']['domain']); - } else { - $data['new']['ssl_letsencrypt'] = 'n'; - if($data['old']['ssl'] == 'n') $data['new']['ssl'] = 'n'; - /* Update the DB of the (local) Server */ - $app->db->query("UPDATE web_domain SET `ssl` = ?, `ssl_letsencrypt` = ? WHERE `domain` = ? AND `server_id` = ?", $data['new']['ssl'], 'n', $data['new']['domain'], $conf['server_id']); - /* Update also the master-DB of the Server-Farm */ - $app->dbmaster->query("UPDATE web_domain SET `ssl` = ?, `ssl_letsencrypt` = ? WHERE `domain` = ?", $data['new']['ssl'], 'n', $data['new']['domain']); + $found_or_got_cert = $app->letsencrypt->get_existing_certificate($data['new']['ssl_domain']); + + // On mirror servers no certs should be requested. They should be only setup. We assume that the required folder (/etc/letsencrypt/ or /root/.acme.sh/) are live network shares + if ($conf['mirror_server_id'] == 0) { // Request certs only on the mirrored server + + // Didn't find an existing cert, fetch from LE + if(false === $found_or_got_cert) { + $found_or_got_cert = $app->letsencrypt->fetch_certificate_from_le( $data, 'nginx' ); + } + + if(false !== $found_or_got_cert) { + $cert_valid_until = $found_or_got_cert['until']; + /* we don't need to store it. + /* Update the DB of the (local) Server */ + $app->db->query("UPDATE web_domain SET ssl_request = '', ssl_cert = '', ssl_key = '', ssl_valid_until = ? WHERE domain = ?", $cert_valid_until, $data['new']['domain']); + $app->db->query("UPDATE web_domain SET ssl_action = '' WHERE domain = ?", $data['new']['domain']); + /* Update also the master-DB of the Server-Farm */ + $app->dbmaster->query("UPDATE web_domain SET ssl_request = '', ssl_cert = '', ssl_key = '', ssl_valid_until = ? WHERE domain = ?", $cert_valid_until, $data['new']['domain']); + $app->dbmaster->query("UPDATE web_domain SET ssl_action = '' WHERE domain = ?", $data['new']['domain']); + } else { + $data['new']['ssl_letsencrypt'] = 'n'; + if($data['old']['ssl'] == 'n') $data['new']['ssl'] = 'n'; + /* Update the DB of the (local) Server */ + $app->db->query("UPDATE web_domain SET `ssl` = ?, `ssl_letsencrypt` = ? WHERE `domain` = ? AND `server_id` = ?", $data['new']['ssl'], 'n', $data['new']['domain'], $conf['server_id']); + /* Update also the master-DB of the Server-Farm */ + $app->dbmaster->query("UPDATE web_domain SET `ssl` = ?, `ssl_letsencrypt` = ? WHERE `domain` = ?", $data['new']['ssl'], 'n', $data['new']['domain']); + } + } + + if (false !== $found_or_got_cert) { + //TODO: Configure certificates + } + } if($domain!='' && $data['new']['ssl'] == 'y' && @is_file($crt_file) && @is_file($key_file) && (@filesize($crt_file)>0) && (@filesize($key_file)>0)) {