diff --git a/lib/classes/ssl/class.lescript.php b/lib/classes/ssl/class.lescript.php index 9923d5b0..c1745be7 100644 --- a/lib/classes/ssl/class.lescript.php +++ b/lib/classes/ssl/class.lescript.php @@ -30,7 +30,6 @@ class lescript { // https://letsencrypt.org/repository/ - public $license; private $logger; @@ -111,19 +110,7 @@ class lescript } $accountUrl=$this->client->getLastLocation(); - $this->log('Accepting lets encrypt Terms of Service'); - - $this->license = $this->client->getAgreementURL(); - - // Terms of Service are optional according to ACME specs; if no ToS are presented, no need to update registration - if (!empty($this->license)) { - $response = $this->postRegAgreement(parse_url($accountUrl, PHP_URL_PATH)); - if ($this->client->getLastCode() != 202) { - throw new \RuntimeException("Terms of Service not accepted. Whole response: " . json_encode($response)); - } - } - - $leregistered=1; + $leregistered = 1; $this->setLeRegisteredState($leregistered); // Account registered $this->log('Lets encrypt Terms of Service accepted'); } @@ -373,21 +360,16 @@ class lescript private function postNewReg() { + $this->log('Getting last terms of service URL'); + $directory = $this->client->get('/directory'); + if (!isset($directory['meta']) || !isset($directory['meta']['terms-of-service'])) { + throw new \RuntimeException("No terms of service link available!"); + } $this->log('Sending registration to letsencrypt server'); return $this->signedRequest('/acme/new-reg', array( 'resource' => 'new-reg', - 'agreement' => $this->license - )); - } - - private function postRegAgreement($uri) - { - $this->log('Accepting agreement at URL: ' . $this->license); - - return $this->signedRequest($uri, array( - 'resource' => 'reg', - 'agreement' => $this->license + 'agreement' => $directory['meta']['terms-of-service'] )); } @@ -592,49 +574,6 @@ class Client preg_match_all('~Link: <(.+)>;rel="up"~', $this->lastHeader, $matches); return $matches[1]; } - - public function getAgreementURLFromLastResponse() - { - if (preg_match_all('~Link: <(.+)>;rel="terms-of-service"~', $this->lastHeader, $matches)) { - return $matches[1][0]; - } - return ""; - } - public function getAgreementURLFromDirectory() - { - // FIXME: Current license should be found in /directory but LE does not implement this yet - // $this->curl('GET', '/directory'); - return ""; - } - public function getAgreementURLFromTermsUrl() - { - $this->curl('GET', '/terms'); - if (preg_match_all('~Location: (.+)~', $this->lastHeader, $matches)) { - return trim($matches[1][0]); - } - return ""; - } - - public function getAgreementURL() - { - // 1. check the header of the last response - $license=$this->getAgreementURLFromLastResponse(); - if (!empty($license)) return $license; - - // 2. query directory for license - $license=$this->getAgreementURLFromDirectory(); - if (!empty($license)) return $license; - - // 3. query /terms endpoint (not ACME standard but implemented by let's enrypt) - $license=$this->getAgreementURLFromTermsUrl(); - if (!empty($license)) return $license; - - // Fallback: use latest known license. This is only valid for let's encrypt and should be removed as soon as there is an official - // ACME-endpoint to get the current ToS - return "https://letsencrypt.org/documents/LE-SA-v1.1.1-August-1-2016.pdf"; - // return ""; - } - } class Base64UrlSafeEncoder diff --git a/lib/classes/ssl/class.lescript_v2.php b/lib/classes/ssl/class.lescript_v2.php new file mode 100644 index 00000000..074def62 --- /dev/null +++ b/lib/classes/ssl/class.lescript_v2.php @@ -0,0 +1,599 @@ + +// All rights reserved. +// +// Redistribution and use in source and binary forms, with or without +// modification, are permitted provided that the following conditions are met: +// * Redistributions of source code must retain the above copyright +// notice, this list of conditions and the following disclaimer. +// * Redistributions in binary form must reproduce the above copyright +// notice, this list of conditions and the following disclaimer in the +// documentation and/or other materials provided with the distribution. +// * Neither the name of the nor the +// names of its contributors may be used to endorse or promote products +// derived from this software without specific prior written permission. +// +// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +// ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +// WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +// DISCLAIMED. IN NO EVENT SHALL BE LIABLE FOR ANY +// DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +// (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +// LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +// ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +// SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +// This file is copied from https://github.com/analogic/lescript +// and modified to work without files and integrate in Froxlor +class lescript_v2 +{ + + // https://letsencrypt.org/repository/ + private $logger; + + private $client; + + private $accountKey; + + private $customerid; + + private $isFroxlorVhost; + + private $isLeProduction; + + private $version; + + private $_req_uris = array(); + + private $_acc_location = null; + + public function __construct($logger, $version = '2') + { + $this->logger = $logger; + $this->version = $version; + if (Settings::Get('system.letsencryptca') == 'production') { + $ca = 'https://acme-v02.api.letsencrypt.org'; + } else { + $ca = 'https://acme-staging-v02.api.letsencrypt.org'; + } + $this->client = new Client($ca); + $this->log("Using '$ca' to generate certificate"); + + // get request-uris from /directory + $response = $this->client->get('/directory'); + $this->_req_uris['newAccount'] = $response['newAccount']; + $this->_req_uris['newOrder'] = $response['newOrder']; + $this->_req_uris['newNonce'] = $response['newNonce']; + $this->_req_uris['revokeCert'] = $response['revokeCert']; + } + + public function initAccount($certrow, $isFroxlorVhost = false) + { + // Let's see if we have the private accountkey + $this->accountKey = $certrow['leprivatekey']; + $this->customerId = (! $isFroxlorVhost ? $certrow['customerid'] : null); + $this->isFroxlorVhost = $isFroxlorVhost; + $this->isLeProduction = (Settings::Get('system.letsencryptca') == 'production'); + + $leregistered = $certrow['leregistered']; + + if (! $this->accountKey || $this->accountKey == 'unset' || ! $this->isLeProduction) { + + // generate and save new private key for account + // --------------------------------------------- + + $this->log('Creating new account key'); + $keys = $this->generateKey(); + // Only store the accountkey in production, in staging always generate a new key + if ($this->isLeProduction) { + if ($isFroxlorVhost) { + Settings::Set('system.lepublickey', $keys['public']); + Settings::Set('system.leprivatekey', $keys['private']); + Settings::Set('system.leregistered', 0); // key is not registered + } else { + $upd_stmt = Database::prepare("UPDATE `" . TABLE_PANEL_CUSTOMERS . "` SET `lepublickey` = :public, `leprivatekey` = :private, `leregistered` = :registered " . "WHERE `customerid` = :customerid;"); + Database::pexecute($upd_stmt, array( + 'public' => $keys['public'], + 'private' => $keys['private'], + 'registered' => 0, + 'customerid' => $this->customerId + )); + } + } + $leregistered = 0; + $this->accountKey = $keys['private']; + } else { + $this->log('Using existing account key'); + } + + if ($leregistered == 0) { // Account not registered + + $this->log('Starting new account registration'); + $response = $this->postNewReg(); + if ($this->client->getLastCode() == 409) { + $this->log('The key was already registered. Using existing account.'); + } else if ($this->client->getLastCode() == 201) { + $this->log('New account registered.'); + } else { + throw new \RuntimeException("Account not initialized, probably due to rate limiting. Whole response: " . json_encode($response)); + } + $this->_acc_location = $this->client->getLastLocation(); + + $leregistered = 1; + $this->setLeRegisteredState($leregistered); + } + } + + /** + * + * @param array $domains + * @param string $domainkey + * @param string $csr + * optional, same behavior as $reuseCsr from the original class, but we're passing the content of the csr already + * + * @throws \RuntimeException + * @return string[] + */ + public function signDomains(array $domains, $domainkey = null, $csr = null) + { + if (! $this->accountKey) { + throw new \RuntimeException("Account not initialized"); + } + + $this->log('Starting certificate generation process for domains'); + + $privateAccountKey = openssl_pkey_get_private($this->accountKey); + $accountKeyDetails = openssl_pkey_get_details($privateAccountKey); + + // start domains authentication + // ---------------------------- + + foreach ($domains as $domain) { + + // 1. getting available authentication options + // ------------------------------------------- + + $this->log("Requesting challenge for $domain"); + + $response = $this->signedRequest($this->_req_uris['newOrder'], array( + "identifiers" => array( + array( + "type" => "dns", + "value" => $domain + ) + ) + ), false); + + if ($this->client->getLastCode() == 403) { + $this->log("Got status 403 - setting LE status to unregistered."); + $this->setLeRegisteredState(0); + throw new RuntimeException("Got 'unauthorized' response - we need to re-register at next run. Whole response: " . json_encode($response)); + } + + // if response is not an array but a string, it's most likely a server-error, e.g. + // ErrorAn error occurred while processing your request. + //

Reference #179.d8be1402.1458059103.3613c4db + if (! is_array($response)) { + throw new RuntimeException("Invalid response from LE for domain $domain. Whole response: " . json_encode($response)); + } + + if (! array_key_exists('authorizations', $response)) { + throw new RuntimeException("No authorizations received for $domain. Whole response: " . json_encode($response)); + } + + // get authorization + $auth_response = $this->client->get($response['authorizations'][0]); + + if (! array_key_exists('challenges', $auth_response)) { + throw new RuntimeException("No challenges received for $domain. Whole response: " . json_encode($auth_response)); + } + + // choose http-01 challenge only + $challenge = array_reduce($auth_response['challenges'], function ($v, $w) { + return $v ? $v : ($w['type'] == 'http-01' ? $w : false); + }); + + if (! $challenge) { + throw new RuntimeException("HTTP Challenge for $domain is not available. Whole response: " . json_encode($response)); + } + + $this->log("Got challenge token for $domain"); + $location = $challenge['url']; + $finalizeLink = $response['finalize']; + + // 2. saving authentication token for web verification + // --------------------------------------------------- + + $directory = Settings::Get('system.letsencryptchallengepath') . '/.well-known/acme-challenge'; + $tokenPath = $directory . '/' . $challenge['token']; + + if (! file_exists($directory) && ! @mkdir($directory, 0755, true)) { + throw new \RuntimeException("Couldn't create directory to expose challenge: ${tokenPath}"); + } + + $header = array( + // need to be in precise order! + "e" => Base64UrlSafeEncoder::encode($accountKeyDetails["rsa"]["e"]), + "kty" => "RSA", + "n" => Base64UrlSafeEncoder::encode($accountKeyDetails["rsa"]["n"]) + ); + $payload = $challenge['token'] . '.' . Base64UrlSafeEncoder::encode(hash('sha256', json_encode($header), true)); + + file_put_contents($tokenPath, $payload); + chmod($tokenPath, 0644); + + // 3. verification process itself + // ------------------------------- + + $uri = "http://${domain}/.well-known/acme-challenge/${challenge['token']}"; + + $this->log("Token for $domain saved at $tokenPath and should be available at $uri"); + + // simple self check + if (Settings::Get('system.disable_le_selfcheck') == '0') { + $selfcheckContextOptions = array( + 'http' => array( + 'header' => "User-Agent: Froxlor/" . $this->version + ) + ); + $selfcheckContext = stream_context_create($selfcheckContextOptions); + if ($payload !== trim(@file_get_contents($uri, false, $selfcheckContext))) { + $errmsg = json_encode(error_get_last()); + if ($errmsg != "null") { + $errmsg = "; PHP error: " . $errmsg; + } else { + $errmsg = ""; + } + $this->logger->logAction(CRON_ACTION, LOG_WARNING, "[Lets Encrypt self-check] Please check $uri - token seems to be not available. This is just a simple self-check, it might be wrong but consider using this information when Let's Encrypt fails to issue a certificate" . $errmsg); + } + } + + $this->log("Sending request to challenge"); + + // send request to challenge + $result = $this->signedRequest($challenge['url'], array( + "type" => "http-01", + "keyAuthorization" => $payload, + "token" => $challenge['token'] + ), false); + + // waiting loop + // we wait for a maximum of 30 seconds to avoid endless loops + $count = 0; + do { + if (empty($result['status']) || $result['status'] == "invalid") { + @unlink($tokenPath); + throw new \RuntimeException("Verification ended with error: " . json_encode($result)); + } + $ended = ! ($result['status'] === "pending" || $result['status'] === "processing"); + + if (! $ended) { + $this->log("Verification " . $result['status'] . ", sleeping 1s"); + sleep(1); + $count ++; + } + + $result = $this->client->get($location); + } while (! $ended && $count < 30); + + $this->log("Verification ended with status: ${result['status']}"); + @unlink($tokenPath); + } + + // requesting certificate + // ---------------------- + + // generate private key for domain if not exist + if (empty($domainkey) || Settings::Get('system.letsencryptreuseold') == 0) { + $keys = $this->generateKey(); + $domainkey = $keys['private']; + } + + // load domain key + $privateDomainKey = openssl_pkey_get_private($domainkey); + + if (empty($csr)) { + $csr = $this->generateCSR($privateDomainKey, $domains); + } + + // request certificates creation + $result = $this->signedRequest($finalizeLink, array( + 'csr' => $csr + ), false); + if ($this->client->getLastCode() !== 200) { + throw new \RuntimeException("Invalid response code: " . $this->client->getLastCode() . ", " . json_encode($result)); + } + if (! isset($result['certificate'])) { + throw new \RuntimeException("No certificate URL specified in result"); + } + + $certificates = array(); + $certdata = $this->client->get($result['certificate']); + $this->log("Got certificate! YAY!"); + $certificates[] = $certdata; + foreach ($this->client->getLastLinks() as $link) { + $this->log("Requesting chained cert at $link"); + $result = $this->client->get($link); + $certificates[] = $result; + } + + if (empty($certificates)) + throw new \RuntimeException('No certificates generated'); + + $fullchain = implode("\n", $certificates); + $crt = array_shift($certificates); + $chain = implode("\n", $certificates); + + $this->log("Done, returning new certificates and key"); + return array( + 'fullchain' => $fullchain, + 'crt' => $crt, + 'chain' => $chain, + 'key' => $domainkey, + 'csr' => $csr + ); + } + + private function setLeRegisteredState($state) + { + if ($this->isLeProduction) { + if ($this->isFroxlorVhost) { + Settings::Set('system.leregistered', $state); + } else { + $upd_stmt = Database::prepare("UPDATE `" . TABLE_PANEL_CUSTOMERS . "` SET `leregistered` = :registered " . "WHERE `customerid` = :customerid;"); + Database::pexecute($upd_stmt, array( + 'registered' => $state, + 'customerid' => $this->customerId + )); + } + } + } + + private function parsePemFromBody($body) + { + $pem = chunk_split(base64_encode($body), 64, "\n"); + return "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n"; + } + + private function postNewReg() + { + $this->log('Getting last terms of service URL'); + $directory = $this->client->get('/directory'); + if (! isset($directory['meta']) || ! isset($directory['meta']['termsOfService'])) { + throw new \RuntimeException("No terms of service link available!"); + } + $this->log('Sending registration to letsencrypt server'); + + return $this->signedRequest($this->_req_uris['newAccount'], array( + 'termsOfServiceAgreed' => true + )); + } + + private function generateCSR($privateKey, array $domains) + { + $domain = reset($domains); + $san = implode(",", array_map(function ($dns) { + return "DNS:" . $dns; + }, $domains)); + $tmpConf = tmpfile(); + $tmpConfMeta = stream_get_meta_data($tmpConf); + $tmpConfPath = $tmpConfMeta["uri"]; + + // workaround to get SAN working + fwrite($tmpConf, 'HOME = . +RANDFILE = $ENV::HOME/.rnd +[ req ] +default_bits = ' . Settings::Get('system.letsencryptkeysize') . ' +default_keyfile = privkey.pem +distinguished_name = req_distinguished_name +req_extensions = v3_req +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +[ v3_req ] +basicConstraints = CA:FALSE +subjectAltName = ' . $san . ' +keyUsage = nonRepudiation, digitalSignature, keyEncipherment'); + + $csr = openssl_csr_new(array( + "CN" => $domain, + "ST" => Settings::Get('system.letsencryptstate'), + "C" => Settings::Get('system.letsencryptcountrycode'), + "O" => "Unknown" + ), $privateKey, array( + "config" => $tmpConfPath, + "digest_alg" => "sha256" + )); + + if (! $csr) + throw new \RuntimeException("CSR couldn't be generated! " . openssl_error_string()); + + openssl_csr_export($csr, $csr); + fclose($tmpConf); + + preg_match('~REQUEST-----(.*)-----END~s', $csr, $matches); + + return trim(Base64UrlSafeEncoder::encode(base64_decode($matches[1]))); + } + + private function generateKey() + { + $res = openssl_pkey_new(array( + "private_key_type" => OPENSSL_KEYTYPE_RSA, + "private_key_bits" => (int) Settings::Get('system.letsencryptkeysize') + )); + + if (! openssl_pkey_export($res, $privateKey)) { + throw new \RuntimeException("Key export failed!"); + } + + $details = openssl_pkey_get_details($res); + + return array( + 'private' => $privateKey, + 'public' => $details['key'] + ); + } + + private function signedRequest($uri, array $payload, $needs_jwk = true) + { + $privateKey = openssl_pkey_get_private($this->accountKey); + $details = openssl_pkey_get_details($privateKey); + + $header = array( + "alg" => "RS256" + ); + + if ($needs_jwk) { + $header["jwk"] = array( + "kty" => "RSA", + "n" => Base64UrlSafeEncoder::encode($details["rsa"]["n"]), + "e" => Base64UrlSafeEncoder::encode($details["rsa"]["e"]) + ); + } else { + // need account-url + $header["kid"] = $this->_acc_location; + } + + $protected = $header; + $protected["nonce"] = $this->client->getLastNonce(); + $protected["url"] = $uri; + + $payload64 = Base64UrlSafeEncoder::encode(json_encode($payload, JSON_UNESCAPED_SLASHES)); + $protected64 = Base64UrlSafeEncoder::encode(json_encode($protected)); + + openssl_sign($protected64 . '.' . $payload64, $signed, $privateKey, "SHA256"); + + $signed64 = Base64UrlSafeEncoder::encode($signed); + + $data = array( + 'protected' => $protected64, + 'payload' => $payload64, + 'signature' => $signed64 + ); + + $this->log("Sending signed request to $uri"); + return $this->client->post($uri, json_encode($data)); + } + + protected function log($message) + { + $this->logger->logAction(CRON_ACTION, LOG_INFO, "letsencrypt-v2 " . $message); + } +} + +class Client +{ + + private $lastCode; + + public $lastHeader; + + private $base; + + public function __construct($base) + { + $this->base = $base; + } + + private function curl($method, $url, $data = null) + { + $headers = array( + 'Accept: application/json', + 'Content-Type: application/json' + ); + $handle = curl_init(); + curl_setopt($handle, CURLOPT_URL, preg_match('~^http~', $url) ? $url : $this->base . $url); + curl_setopt($handle, CURLOPT_HTTPHEADER, $headers); + curl_setopt($handle, CURLOPT_RETURNTRANSFER, true); + curl_setopt($handle, CURLOPT_HEADER, true); + + // DO NOT DO THAT! + // curl_setopt($handle, CURLOPT_SSL_VERIFYHOST, false); + // curl_setopt($handle, CURLOPT_SSL_VERIFYPEER, false); + + switch ($method) { + case 'GET': + break; + case 'POST': + curl_setopt($handle, CURLOPT_POST, true); + curl_setopt($handle, CURLOPT_POSTFIELDS, $data); + break; + } + $response = curl_exec($handle); + + if (curl_errno($handle)) { + throw new \RuntimeException('Curl: ' . curl_error($handle)); + } + + $header_size = curl_getinfo($handle, CURLINFO_HEADER_SIZE); + + $header = substr($response, 0, $header_size); + $body = substr($response, $header_size); + + $this->lastHeader = $header; + $this->lastCode = curl_getinfo($handle, CURLINFO_HTTP_CODE); + + $data = json_decode($body, true); + return $data === null ? $body : $data; + } + + public function post($url, $data) + { + return $this->curl('POST', $url, $data); + } + + public function get($url) + { + return $this->curl('GET', $url); + } + + public function getLastNonce() + { + if (preg_match('~Replay\-Nonce: (.+)~i', $this->lastHeader, $matches)) { + return trim($matches[1]); + } + + $this->curl('GET', '/directory'); + return $this->getLastNonce(); + } + + public function getLastLocation() + { + if (preg_match('~Location: (.+)~i', $this->lastHeader, $matches)) { + return trim($matches[1]); + } + return null; + } + + public function getLastCode() + { + return $this->lastCode; + } + + public function getLastLinks() + { + preg_match_all('~Link: <(.+)>;rel="up"~', $this->lastHeader, $matches); + return $matches[1]; + } +} + +class Base64UrlSafeEncoder +{ + + public static function encode($input) + { + return str_replace('=', '', strtr(base64_encode($input), '+/', '-_')); + } + + public static function decode($input) + { + $remainder = strlen($input) % 4; + if ($remainder) { + $padlen = 4 - $remainder; + $input .= str_repeat('=', $padlen); + } + return base64_decode(strtr($input, '-_', '+/')); + } +} diff --git a/scripts/jobs/cron_letsencrypt_v2.php b/scripts/jobs/cron_letsencrypt_v2.php new file mode 100644 index 00000000..b08d3679 --- /dev/null +++ b/scripts/jobs/cron_letsencrypt_v2.php @@ -0,0 +1,292 @@ + + * @author Froxlor team (2016-) + * @license GPLv2 http://files.froxlor.org/misc/COPYING.txt + * @package Cron + * + * @since 0.9.35 + * + */ + +$cronlog->logAction(CRON_ACTION, LOG_INFO, "Updating Let's Encrypt certificates"); + +if (! extension_loaded('curl')) { + $cronlog->logAction(CRON_ACTION, LOG_ERR, "Let's Encrypt requires the php cURL extension to be installed."); + exit(); +} + +$certificates_stmt = Database::query(" + SELECT + domssl.`id`, + domssl.`domainid`, + domssl.expirationdate, + domssl.`ssl_cert_file`, + domssl.`ssl_key_file`, + domssl.`ssl_ca_file`, + domssl.`ssl_csr_file`, + dom.`domain`, + dom.`wwwserveralias`, + dom.`iswildcarddomain`, + dom.`documentroot`, + dom.`id` AS 'domainid', + dom.`ssl_redirect`, + cust.`leprivatekey`, + cust.`lepublickey`, + cust.`leregistered`, + cust.`customerid`, + cust.`loginname` + FROM + `" . TABLE_PANEL_CUSTOMERS . "` AS cust, + `" . TABLE_PANEL_DOMAINS . "` AS dom + LEFT JOIN + `" . TABLE_PANEL_DOMAIN_SSL_SETTINGS . "` AS domssl ON + dom.`id` = domssl.`domainid` + WHERE + dom.`customerid` = cust.`customerid` + AND dom.`letsencrypt` = 1 + AND dom.`aliasdomain` IS NULL + AND ( + domssl.`expirationdate` < DATE_ADD(NOW(), INTERVAL 30 DAY) + OR domssl.`expirationdate` IS NULL + ) + "); + +$aliasdomains_stmt = Database::prepare(" + SELECT + dom.`id` as domainid, + dom.`domain`, + dom.`wwwserveralias`, + dom.`iswildcarddomain` + FROM `" . TABLE_PANEL_DOMAINS . "` AS dom + WHERE + dom.`aliasdomain` = :id + AND dom.`letsencrypt` = 1 + "); + +$updcert_stmt = Database::prepare(" + REPLACE INTO + `" . TABLE_PANEL_DOMAIN_SSL_SETTINGS . "` + SET + `id` = :id, + `domainid` = :domainid, + `ssl_cert_file` = :crt, + `ssl_key_file` = :key, + `ssl_ca_file` = :ca, + `ssl_cert_chainfile` = :chain, + `ssl_csr_file` = :csr, + `expirationdate` = :expirationdate + "); + +$upddom_stmt = Database::prepare("UPDATE `" . TABLE_PANEL_DOMAINS . "` SET `ssl_redirect` = '1' WHERE `id` = :domainid"); + +// flag for re-generation of vhost files +$changedetected = 0; + +// first - generate LE for system-vhost if enabled +if (Settings::Get('system.le_froxlor_enabled') == '1') { + + $certrow = array( + 'loginname' => 'froxlor.panel', + 'domain' => Settings::Get('system.hostname'), + 'domainid' => 0, + 'documentroot' => FROXLOR_INSTALL_DIR, + 'leprivatekey' => Settings::Get('system.leprivatekey'), + 'lepublickey' => Settings::Get('system.lepublickey'), + 'leregistered' => Settings::Get('system.leregistered'), + 'ssl_redirect' => Settings::Get('system.le_froxlor_redirect'), + 'expirationdate' => null, + 'ssl_cert_file' => null, + 'ssl_key_file' => null, + 'ssl_ca_file' => null, + 'ssl_csr_file' => null, + 'id' => null + ); + + $froxlor_ssl_settings_stmt = Database::prepare(" + SELECT * FROM `" . TABLE_PANEL_DOMAIN_SSL_SETTINGS . "` + WHERE `domainid` = '0' AND + (`expirationdate` < DATE_ADD(NOW(), INTERVAL 30 DAY) OR `expirationdate` IS NULL) + "); + $froxlor_ssl = Database::pexecute_first($froxlor_ssl_settings_stmt); + + $insert_or_update_required = true; + if ($froxlor_ssl) { + $certrow['id'] = $froxlor_ssl['id']; + $certrow['expirationdate'] = $froxlor_ssl['expirationdate']; + $certrow['ssl_cert_file'] = $froxlor_ssl['ssl_cert_file']; + $certrow['ssl_key_file'] = $froxlor_ssl['ssl_key_file']; + $certrow['ssl_ca_file'] = $froxlor_ssl['ssl_ca_file']; + $certrow['ssl_csr_file'] = $froxlor_ssl['ssl_csr_file']; + } else { + // check whether we have an entry with valid certificates which just does not need + // updating yet, so we need to skip this here + $froxlor_ssl_settings_stmt = Database::prepare(" + SELECT * FROM `" . TABLE_PANEL_DOMAIN_SSL_SETTINGS . "` WHERE `domainid` = '0' + "); + $froxlor_ssl = Database::pexecute_first($froxlor_ssl_settings_stmt); + if ($froxlor_ssl && ! empty($froxlor_ssl['ssl_cert_file'])) { + $insert_or_update_required = false; + } + } + + if ($insert_or_update_required) { + $domains = array( + $certrow['domain'] + ); + + // Only renew let's encrypt certificate if no broken ssl_redirect is enabled + // - this temp. deactivation of the ssl-redirect is handled by the webserver-cronjob + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Updating " . $certrow['domain']); + + $cronlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => $certrow['loginname'] + )); + + try { + // Initialize Lescript with documentroot + $le = new lescript_v2($cronlog, $version); + + // Initialize Lescript + $le->initAccount($certrow, true); + + // Request the new certificate (old key may be used) + $return = $le->signDomains($domains, $certrow['ssl_key_file']); + + // We are interessted in the expirationdate + $newcert = openssl_x509_parse($return['crt']); + + // Store the new data + Database::pexecute($updcert_stmt, array( + 'id' => $certrow['id'], + 'domainid' => $certrow['domainid'], + 'crt' => $return['crt'], + 'key' => $return['key'], + 'ca' => $return['chain'], + 'chain' => $return['chain'], + 'csr' => $return['csr'], + 'expirationdate' => date('Y-m-d H:i:s', $newcert['validTo_time_t']) + )); + + if ($certrow['ssl_redirect'] == 3) { + Settings::Set('system.le_froxlor_redirect', '1'); + } + + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Updated Let's Encrypt certificate for " . $certrow['domain']); + + $changedetected = 1; + } catch (Exception $e) { + $cronlog->logAction(CRON_ACTION, LOG_ERR, "Could not get Let's Encrypt certificate for " . $certrow['domain'] . ": " . $e->getMessage()); + } + } +} + +// customer domains +$certrows = $certificates_stmt->fetchAll(PDO::FETCH_ASSOC); +foreach ($certrows as $certrow) { + + // set logger to corresponding loginname for the log to appear in the users system-log + $cronlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => $certrow['loginname'] + )); + + // Only renew let's encrypt certificate if no broken ssl_redirect is enabled + if ($certrow['ssl_redirect'] != 2) { + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Updating " . $certrow['domain']); + + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Adding SAN entry: " . $certrow['domain']); + $domains = array( + $certrow['domain'] + ); + if ($certrow['iswildcarddomain'] == 1) { + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Adding SAN entry: *." . $certrow['domain']); + $domains[] = '*.' . $certrow['domain']; + } + elseif ($certrow['wwwserveralias'] == 1) { + // add www. to SAN list + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Adding SAN entry: www." . $certrow['domain']); + $domains[] = 'www.' . $certrow['domain']; + } + + // add alias domains (and possibly www.) to SAN list + Database::pexecute($aliasdomains_stmt, array( + 'id' => $certrow['domainid'] + )); + $aliasdomains = $aliasdomains_stmt->fetchAll(PDO::FETCH_ASSOC); + foreach ($aliasdomains as $aliasdomain) { + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Adding SAN entry: " . $aliasdomain['domain']); + $domains[] = $aliasdomain['domain']; + if ($aliasdomain['iswildcarddomain'] == 1) { + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Adding SAN entry: *." . $aliasdomain['domain']); + $domains[] = '*.' . $aliasdomain['domain']; + } + elseif ($aliasdomain['wwwserveralias'] == 1) { + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Adding SAN entry: www." . $aliasdomain['domain']); + $domains[] = 'www.' . $aliasdomain['domain']; + } + } + + try { + // Initialize Lescript with documentroot + $le = new lescript_v2($cronlog, $version); + + // Initialize Lescript + $le->initAccount($certrow); + + // Request the new certificate (old key may be used) + $return = $le->signDomains($domains, $certrow['ssl_key_file']); + + // We are interessted in the expirationdate + $newcert = openssl_x509_parse($return['crt']); + + // Store the new data + Database::pexecute($updcert_stmt, array( + 'id' => $certrow['id'], + 'domainid' => $certrow['domainid'], + 'crt' => $return['crt'], + 'key' => $return['key'], + 'ca' => $return['chain'], + 'chain' => $return['chain'], + 'csr' => $return['csr'], + 'expirationdate' => date('Y-m-d H:i:s', $newcert['validTo_time_t']) + )); + + if ($certrow['ssl_redirect'] == 3) { + Database::pexecute($upddom_stmt, array( + 'domainid' => $certrow['domainid'] + )); + } + + $cronlog->logAction(CRON_ACTION, LOG_INFO, "Updated Let's Encrypt certificate for " . $certrow['domain']); + + $changedetected = 1; + } catch (Exception $e) { + $cronlog->logAction(CRON_ACTION, LOG_ERR, "Could not get Let's Encrypt certificate for " . $certrow['domain'] . ": " . $e->getMessage()); + } + } else { + $cronlog->logAction(CRON_ACTION, LOG_WARNING, "Skipping Let's Encrypt generation for " . $certrow['domain'] . " due to an enabled ssl_redirect"); + } +} + +// If we have a change in a certificate, we need to update the webserver - configs +// This is easiest done by just creating a new task ;) +if ($changedetected) { + inserttask(1); +} + +// reset logger +$cronlog = FroxlorLogger::getInstanceOf(array( + 'loginname' => 'cronjob' +)); +$cronlog->logAction(CRON_ACTION, LOG_INFO, "Let's Encrypt certificates have been updated");