diff --git a/install/froxlor.sql b/install/froxlor.sql index eaf55166..0e9819b2 100644 --- a/install/froxlor.sql +++ b/install/froxlor.sql @@ -509,6 +509,8 @@ INSERT INTO `panel_settings` (`settinggroup`, `varname`, `value`) VALUES ('system', 'dns_createhostnameentry', '0'), ('system', 'send_cron_errors', '0'), ('system', 'apacheitksupport', '0'), + ('system', 'leprivatekey', 'unset'), + ('system', 'lepublickey', 'unset'), ('panel', 'decimal_places', '4'), ('panel', 'adminmail', 'admin@SERVERNAME'), ('panel', 'phpmyadmin_url', ''), @@ -539,7 +541,7 @@ INSERT INTO `panel_settings` (`settinggroup`, `varname`, `value`) VALUES ('panel', 'password_numeric', '0'), ('panel', 'password_special_char_required', '0'), ('panel', 'password_special_char', '!?<>§$%+#=@'), - ('panel', 'version', '0.9.34.2'); + ('panel', 'version', '0.9.35-dev1'); DROP TABLE IF EXISTS `panel_tasks`; @@ -822,6 +824,8 @@ CREATE TABLE IF NOT EXISTS `domain_ssl_settings` ( `ssl_key_file` mediumtext NOT NULL, `ssl_ca_file` mediumtext, `ssl_cert_chainfile` mediumtext, + `letsencrypt` int(11) NOT NULL DEFAULT '0', + `expirationdate` datetime DEFAULT NULL PRIMARY KEY (`id`) ) ENGINE=MyISAM CHARSET=utf8 COLLATE=utf8_general_ci; diff --git a/install/updates/froxlor/0.9/update_0.9.inc.php b/install/updates/froxlor/0.9/update_0.9.inc.php index 3a4f870c..f57c9e4d 100644 --- a/install/updates/froxlor/0.9/update_0.9.inc.php +++ b/install/updates/froxlor/0.9/update_0.9.inc.php @@ -3020,3 +3020,18 @@ if (isFroxlorVersion('0.9.34.1')) { updateToVersion('0.9.34.2'); } + +if (isFroxlorVersion('0.9.34.2')) { + + showUpdateStep("Updating from 0.9.34.2 to 0.9.35-dev1"); + lastStepStatus(0); + showUpdateStep("Adding Let's encrypt - certificate fields"); + Database::query("ALTER TABLE `".TABLE_PANEL_DOMAIN_SSL_SETTINGS."` ADD `letsencrypt` INT NOT NULL DEFAULT '0' AFTER `ssl_cert_chainfile`"); + Database::query("ALTER TABLE `".TABLE_PANEL_DOMAIN_SSL_SETTINGS."` ADD `expirationdate` DATETIME NULL AFTER `letsencrypt`;"); + Settings::AddNew("system.leprivatekey", 'unset'); + Settings::AddNew("system.lepublickey", 'unset'); + lastStepStatus(0); + + updateToVersion('0.9.35-dev1'); +} + diff --git a/lib/classes/ssl/class.lescript.php b/lib/classes/ssl/class.lescript.php new file mode 100644 index 00000000..d7252cbe --- /dev/null +++ b/lib/classes/ssl/class.lescript.php @@ -0,0 +1,477 @@ + +# 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 +{ + #public $ca = 'https://acme-v01.api.letsencrypt.org'; + public $ca = 'https://acme-staging.api.letsencrypt.org'; // testing + public $license = 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf'; + public $countryCode = 'DE'; + public $state = "Germany"; + + private $certificatesDir; + private $webRootDir; + + private $debugHandler; + private $client; + private $accountKeyPath; + + public function __construct($certificatesDir, $webRootDir, $debugHandler) + { + $this->certificatesDir = $certificatesDir; + $this->webRootDir = $webRootDir; + $this->debugHandler = $debugHandler; + $this->client = new Client($this->ca); + } + + public function initAccount() + { + + $private = Settings::Get('system.leprivatekey'); + if (!$private || $private == 'unset') { + + // generate and save new private key for account + // --------------------------------------------- + + $this->log('Starting new account registration'); + list($private, $public) = $this->generateKey(); + Settings::Set('system.leprivatekey', $private); + Settings::Set('system.lepublickey', $public); + $this->postNewReg(); + $this->log('New account certificate registered'); + + } else { + + $this->log('Account already registered. Continuing.'); + + } + } + + public function signDomains(array $domains, $domainkey = null) + { + $this->log('Starting certificate generation process for domains'); + + $privateAccountKey = openssl_pkey_get_private(Settings::Get('system.leprivatekey')); + $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( + "/acme/new-authz", + array("resource" => "new-authz", "identifier" => array("type" => "dns", "value" => $domain)) + ); + + // choose http-01 challange only + $challenge = array_reduce($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 = $this->client->getLastLocation(); + + + // 2. saving authentication token for web verification + // --------------------------------------------------- + + $directory = $this->webRootDir.'/.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($payload !== trim(@file_get_contents($uri))) { + throw new \RuntimeException("Please check $uri - token not available"); + } + + $this->log("Sending request to challenge"); + + // send request to challenge + $result = $this->signedRequest( + $challenge['uri'], + array( + "resource" => "challenge", + "type" => "http-01", + "keyAuthorization" => $payload, + "token" => $challenge['token'] + ) + ); + + // waiting loop + do { + if(empty($result['status']) || $result['status'] == "invalid") { + throw new \RuntimeException("Verification ended with error: ".json_encode($result)); + } + $ended = !($result['status'] === "pending"); + + if(!$ended) { + $this->log("Verification pending, sleeping 1s"); + sleep(1); + } + + $result = $this->client->get($location); + + } while (!$ended); + + $this->log("Verification ended with status: ${result['status']}"); + @unlink($tokenPath); + } + + // requesting certificate + // ---------------------- + + // generate private key for domain if not exist + if(!is_null($domainkey)) { + list($domainkey, $public) = $this->generateKey(); + } + + // load domain key + $privateDomainKey = openssl_pkey_get_private($domainkey); + + $this->client->getLastLinks(); + + // request certificates creation + $result = $this->signedRequest( + "/acme/new-cert", + array('resource' => 'new-cert', 'csr' => $this->generateCSR($privateDomainKey, $domains)) + ); + if ($this->client->getLastCode() !== 201) { + throw new \RuntimeException("Invalid response code: ".$this->client->getLastCode().", ".json_encode($result)); + } + $location = $this->client->getLastLocation(); + + // waiting loop + $certificates = array(); + while(1) { + $this->client->getLastLinks(); + + $result = $this->client->get($location); + + if($this->client->getLastCode() == 202) { + + $this->log("Certificate generation pending, sleeping 1s"); + sleep(1); + + } else if ($this->client->getLastCode() == 200) { + + $this->log("Got certificate! YAY!"); + $certificates[] = $this->parsePemFromBody($result); + + + foreach($this->client->getLastLinks() as $link) { + $this->log("Requesting chained cert at $link"); + $result = $this->client->get($link); + $certificates[] = $this->parsePemFromBody($result); + } + + break; + } else { + + throw new \RuntimeException("Can't get certificate: HTTP code ".$this->client->getLastCode()); + + } + } + + if(empty($certificates)) throw new \RuntimeException('No certificates generated'); + + $this->log("Saving fullchain.pem"); + $fullchain = implode("\n", $certificates); + + $this->log("Saving cert.pem"); + $crt = array_shift($certificates); + + $this->log("Saving chain.pem"); + $chain = implode("\n", $certificates); + + $this->log("Done !!§§!"); + return array('fullchain' => $fullchain, 'crt' => $crt, 'chain' => $chain, 'key' => $privateDomainKey); + } + + private function parsePemFromBody($body) + { + $pem = chunk_split(base64_encode($body), 64, "\n"); + return "-----BEGIN CERTIFICATE-----\n" . $pem . "-----END CERTIFICATE-----\n"; + } + + private function getDomainPath($domain) + { + return $this->certificatesDir.'/'.$domain.'/'; + } + + private function postNewReg() + { + $this->log('Sending registration to letsencrypt server'); + + return $this->signedRequest( + '/acme/new-reg', + array('resource' => 'new-reg', 'agreement' => $this->license) + ); + } + + 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 = 2048 +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" => $this->state, + "C" => $this->countryCode, + "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" => 4096, + )); + + 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) + { + $privateKey = openssl_pkey_get_private(Settings::Get('system.leprivatekey')); + $details = openssl_pkey_get_details($privateKey); + + $header = array( + "alg" => "RS256", + "jwk" => array( + "kty" => "RSA", + "n" => Base64UrlSafeEncoder::encode($details["rsa"]["n"]), + "e" => Base64UrlSafeEncoder::encode($details["rsa"]["e"]), + ) + ); + + $protected = $header; + $protected["nonce"] = $this->client->getLastNonce(); + + + $payload64 = Base64UrlSafeEncoder::encode(str_replace('\\/', '/', json_encode($payload))); + $protected64 = Base64UrlSafeEncoder::encode(json_encode($protected)); + + openssl_sign($protected64.'.'.$payload64, $signed, $privateKey, "SHA256"); + + $signed64 = Base64UrlSafeEncoder::encode($signed); + + $data = array( + 'header' => $header, + '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) + { + fwrite($this->debugHandler, 'letsencrypt ' . $message); + } +} + +class Client +{ + private $lastCode; + private $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.php b/scripts/jobs/cron_letsencrypt.php new file mode 100644 index 00000000..c92d04be --- /dev/null +++ b/scripts/jobs/cron_letsencrypt.php @@ -0,0 +1,73 @@ + + * @author Froxlor team (2016-) + * @license GPLv2 http://files.froxlor.org/misc/COPYING.txt + * @package Cron + * + * @since 0.9.35 + * + */ + +fwrite($debugHandler, "updating let's encrypt certificates\n"); + +$certificates_stmt = Database::query(" + SELECT domssl.`id`, domssl.`ssl_cert_file`, domssl.`ssl_key_file`, domssl.`ssl_ca_file`, dom.`domain`, dom.`iswildcarddomain`, dom.`wwwserveralias`, dom.`documentroot` + FROM `" . TABLE_PANEL_DOMAIN_SSL_SETTINGS . "` as domssl, `" . TABLE_PANEL_DOMAINS . "` as dom WHERE domssl.domainid = dom.id AND domssl.letsencrypt = 1 +"); + +$upd_stmt = Database::prepare(" + UPDATE `".TABLE_PANEL_DOMAIN_SSL_SETTINGS."` SET `ssl_cert_file` = :crt, `ssl_key_file` = :key, `ssl_ca_file` = :ca, expirationdate = :expirationdate WHERE `id` = :id +"); + +while ($certrow = $certificates_stmt->fetch(PDO::FETCH_ASSOC)) { + + # Only renew let's encrypt certificate for domains where a documentroot + # already exists + if (file_exists($certrow['documentroot']) + && is_dir($certrow['documentroot']) + ) { + fwrite($debugHandler, "updating " . $certrow['domain'] . "\n"); + # Parse the old certificate + $x509data = openssl_x509_parse($certrow['ssl_cert_file']); + + # We are interessted in the old SAN - data + $san = explode(', ', $x509data['extensions']['subjectAltName']); + $domains = array(); + foreach($san as $dnsname) { + $domains[] = substr($dnsname, 4); + } + + try { + # Initialize Lescript with documentroot + $le = new lescript($certrow['documentroot'], $debugHandler); + # 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($upd_stmt, array( + 'crt' => $return['crt'], + 'key' => $return['key'], + 'ca' => $return['fullchain'], + 'expirationdate' => date('Y-m-d H:i:s', $newcert['validTo_time_t']), + 'id' => $certrow['id']) + ); + } catch (\Exception $e) { + fwrite($debugHandler, 'letsencrypt exception: ' . $e->getMessage()); + } + } else { + fwrite($debugHandler, 'documentroot ' . $certrow['documentroot'] . ' does not exist' . "\n"); + } +}