diff --git a/README.md b/README.md index ada5c51a..62054376 100644 --- a/README.md +++ b/README.md @@ -58,3 +58,19 @@ http://files.froxlor.org/releases/froxlor-latest.tar.gz [MD5](http://files.froxl [HowTo](http://redmine.froxlor.org/projects/froxlor/wiki/Installationgentoo) http://files.froxlor.org/gentoo/repositories.xml + +## Let's Encrypt support + +This version of Froxlor contains a test implementation of support for [Let's Encrypt](https://letsencrypt.org). This is (as Let's Encrypt is in itself) +still a beta version and may break your system. The way it currently works is by creating a (sub-)domain with the default system - certificate, +after which the Let's Encrypt cronjob orders the certificate for this (sub-)domain and inserts the certificates in the database. With the next run +of the default cronjob, the certificates will be updated on the disk and the webserver reloaded. + +This has 2 known side-effects at the moment: +* The basic ip/port combinations don't work with the Froxlor - integration of Let's Encrypt, since it needs a certificate for the very first creation +* After creating a domain, it will have the default certificate for a short time (by default 5 minutes until the cronjob runs the next time) + +It may be possible to fix these issues, but they are not a priority at the moment + +**By default the testing environment of Let's Encrypt is used**. This issues certificates which will not be signed by a known certificate authority. +To activate the production system, change the `$ca` in `lib/classes/ssl/class.lescript.php` to `https://acme-v01.api.letsencrypt.org`. \ No newline at end of file diff --git a/actions/admin/settings/131.ssl.php b/actions/admin/settings/131.ssl.php index 016de801..e64ea768 100644 --- a/actions/admin/settings/131.ssl.php +++ b/actions/admin/settings/131.ssl.php @@ -79,7 +79,35 @@ return array( 'string_emptyallowed' => true, 'default' => '', 'save_method' => 'storeSettingField', - ) + ), + 'system_letsencryptca' => array( + 'label' => $lng['serversettings']['letsencryptca'], + 'settinggroup' => 'system', + 'varname' => 'letsencryptca', + 'type' => 'option', + 'default' => 'testing', + 'option_mode' => 'one', + 'option_options' => array('testing' => 'https://acme-staging.api.letsencrypt.org (Test)', 'production' => 'https://acme-v01.api.letsencrypt.org (Live)'), + 'save_method' => 'storeSettingField', + ), + 'system_letsencryptcountrycode' => array( + 'label' => $lng['serversettings']['letsencryptcountrycode'], + 'settinggroup' => 'system', + 'varname' => 'letsencryptcountrycode', + 'type' => 'string', + 'string_emptyallowed' => false, + 'default' => 'DE', + 'save_method' => 'storeSettingField', + ), + 'system_letsencryptstate' => array( + 'label' => $lng['serversettings']['letsencryptstate'], + 'settinggroup' => 'system', + 'varname' => 'letsencryptstate', + 'type' => 'string', + 'string_emptyallowed' => false, + 'default' => 'Germany', + 'save_method' => 'storeSettingField', + ), ) ) ) diff --git a/admin_domains.php b/admin_domains.php index e3c2a17f..8bec3e96 100644 --- a/admin_domains.php +++ b/admin_domains.php @@ -516,6 +516,11 @@ if ($page == 'domains' $ssl_redirect = (int)$_POST['ssl_redirect']; } + $letsencrypt = 0; + if (isset($_POST['letsencrypt'])) { + $letsencrypt = (int)$_POST['letsencrypt']; + } + $ssl_ipandports = array(); if (isset($_POST['ssl_ipandport']) && !is_array($_POST['ssl_ipandport'])) { $_POST['ssl_ipandport'] = unserialize($_POST['ssl_ipandport']); @@ -547,17 +552,24 @@ if ($page == 'domains' } } else { $ssl_redirect = 0; + $letsencrypt = 0; // we need this for the serialize // if ssl is disabled or no ssl-ip/port exists $ssl_ipandports[] = -1; } } else { $ssl_redirect = 0; + $letsencrypt = 0; // we need this for the serialize // if ssl is disabled or no ssl-ip/port exists $ssl_ipandports[] = -1; } + // We can't enable let's encrypt for wildcard - domains + if ($serveraliasoption == '0') { + $letsencrypt = 0; + } + if (!preg_match('/^https?\:\/\//', $documentroot)) { if (strstr($documentroot, ":") !== false) { standard_error('pathmaynotcontaincolon'); @@ -702,7 +714,8 @@ if ($page == 'domains' 'mod_fcgid_maxrequests' => $mod_fcgid_maxrequests, 'specialsettings' => $specialsettings, 'registration_date' => $registration_date, - 'issubof' => $issubof + 'issubof' => $issubof, + 'letsencrypt' => $letsencrypt ); $security_questions = array( @@ -751,7 +764,8 @@ if ($page == 'domains' 'phpsettingid' => $phpsettingid, 'mod_fcgid_starter' => $mod_fcgid_starter, 'mod_fcgid_maxrequests' => $mod_fcgid_maxrequests, - 'ismainbutsubto' => $issubof + 'ismainbutsubto' => $issubof, + 'letsencrypt' => $letsencrypt ); $ins_stmt = Database::prepare(" @@ -782,7 +796,8 @@ if ($page == 'domains' `phpsettingid` = :phpsettingid, `mod_fcgid_starter` = :mod_fcgid_starter, `mod_fcgid_maxrequests` = :mod_fcgid_maxrequests, - `ismainbutsubto` = :ismainbutsubto + `ismainbutsubto` = :ismainbutsubto, + `letsencrypt` = :letsencrypt "); Database::pexecute($ins_stmt, $ins_data); $domainid = Database::lastInsertId(); @@ -1288,6 +1303,11 @@ if ($page == 'domains' $ssl_redirect = (int)$_POST['ssl_redirect']; } + $letsencrypt = 0; + if (isset($_POST['letsencrypt'])) { + $letsencrypt = (int)$_POST['letsencrypt']; + } + $ssl_ipandports = array(); if (isset($_POST['ssl_ipandport']) && !is_array($_POST['ssl_ipandport'])) { $_POST['ssl_ipandport'] = unserialize($_POST['ssl_ipandport']); @@ -1314,17 +1334,24 @@ if ($page == 'domains' } } else { $ssl_redirect = 0; + $letsencrypt = 0; // we need this for the serialize // if ssl is disabled or no ssl-ip/port exists $ssl_ipandports[] = -1; } } else { $ssl_redirect = 0; + $letsencrypt = 0; // we need this for the serialize // if ssl is disabled or no ssl-ip/port exists $ssl_ipandports[] = -1; } + // We can't enable let's encrypt for wildcard domains + if ($serveraliasoption == '0') { + $letsencrypt = '0'; + } + if (!preg_match('/^https?\:\/\//', $documentroot)) { $documentroot = makeCorrectDir($documentroot); } @@ -1443,7 +1470,8 @@ if ($page == 'domains' 'speciallogfile' => $speciallogfile, 'speciallogverified' => $speciallogverified, 'ipandport' => serialize($ipandports), - 'ssl_ipandport' => serialize($ssl_ipandports) + 'ssl_ipandport' => serialize($ssl_ipandports), + 'letsencrypt' => $letsencrypt ); $security_questions = array( @@ -1478,6 +1506,7 @@ if ($page == 'domains' || $issubof != $result['ismainbutsubto'] || $email_only != $result['email_only'] || ($speciallogfile != $result['speciallogfile'] && $speciallogverified == '1') + || $letsencrypt != $result['letsencrypt'] ) { inserttask('1'); } @@ -1613,6 +1642,7 @@ if ($page == 'domains' $update_data['specialsettings'] = $specialsettings; $update_data['registration_date'] = $registration_date; $update_data['ismainbutsubto'] = $issubof; + $update_data['letsencrypt'] = $letsencrypt; $update_data['id'] = $id; $update_stmt = Database::prepare(" @@ -1638,7 +1668,8 @@ if ($page == 'domains' `mod_fcgid_maxrequests` = :mod_fcgid_maxrequests, `specialsettings` = :specialsettings, `registration_date` = :registration_date, - `ismainbutsubto` = :ismainbutsubto + `ismainbutsubto` = :ismainbutsubto, + `letsencrypt` = :letsencrypt WHERE `id` = :id "); Database::pexecute($update_stmt, $update_data); @@ -1653,9 +1684,10 @@ if ($page == 'domains' // if we have no more ssl-ip's for this domain, // all its subdomains must have "ssl-redirect = 0" + // and disable let's encrypt $update_sslredirect = ''; if (count($ssl_ipandports) == 1 && $ssl_ipandports[0] == -1) { - $update_sslredirect = ", `ssl_redirect` = '0' "; + $update_sslredirect = ", `ssl_redirect` = '0', `letsencrypt` = '0' "; } $_update_stmt = Database::prepare(" @@ -1867,6 +1899,7 @@ if ($page == 'domains' $_value = '2'; if ($result['iswildcarddomain'] == '1') { $_value = '0'; + $letsencrypt = 0; } elseif ($result['wwwserveralias'] == '1') { $_value = '1'; } diff --git a/customer_domains.php b/customer_domains.php index 01311329..c8ec610b 100644 --- a/customer_domains.php +++ b/customer_domains.php @@ -36,7 +36,7 @@ if ($page == 'overview') { 'd.domain' => $lng['domains']['domainname'] ); $paging = new paging($userinfo, TABLE_PANEL_DOMAINS, $fields); - $domains_stmt = Database::prepare("SELECT `d`.`id`, `d`.`customerid`, `d`.`domain`, `d`.`documentroot`, `d`.`isemaildomain`, `d`.`caneditdomain`, `d`.`iswildcarddomain`, `d`.`parentdomainid`, `ad`.`id` AS `aliasdomainid`, `ad`.`domain` AS `aliasdomain`, `da`.`id` AS `domainaliasid`, `da`.`domain` AS `domainalias` FROM `" . TABLE_PANEL_DOMAINS . "` `d` + $domains_stmt = Database::prepare("SELECT `d`.`id`, `d`.`customerid`, `d`.`domain`, `d`.`documentroot`, `d`.`isemaildomain`, `d`.`caneditdomain`, `d`.`iswildcarddomain`, `d`.`parentdomainid`, `d`.`letsencrypt`, `ad`.`id` AS `aliasdomainid`, `ad`.`domain` AS `aliasdomain`, `da`.`id` AS `domainaliasid`, `da`.`domain` AS `domainalias` FROM `" . TABLE_PANEL_DOMAINS . "` `d` LEFT JOIN `" . TABLE_PANEL_DOMAINS . "` `ad` ON `d`.`aliasdomain`=`ad`.`id` LEFT JOIN `" . TABLE_PANEL_DOMAINS . "` `da` ON `da`.`aliasdomain`=`d`.`id` WHERE `d`.`customerid`= :customerid @@ -146,7 +146,7 @@ if ($page == 'overview') { // get ssl-ips if activated $show_ssledit = false; - if (Settings::Get('system.use_ssl') == '1' && domainHasSslIpPort($row['id']) && $row['caneditdomain'] == '1') { + if (Settings::Get('system.use_ssl') == '1' && domainHasSslIpPort($row['id']) && $row['caneditdomain'] == '1' && $row['letsencrypt'] == 0) { $show_ssledit = true; } $row = htmlentities_array($row); @@ -303,7 +303,7 @@ if ($page == 'overview') { $ssl_redirect = '0'; if (isset($_POST['ssl_redirect']) && $_POST['ssl_redirect'] == '1') { - // a ssl-redirect only works of there actually is a + // a ssl-redirect only works if there actually is a // ssl ip/port assigned to the domain if (domainHasSslIpPort($domain_check['id']) == true) { $ssl_redirect = '1'; @@ -313,6 +313,17 @@ if ($page == 'overview') { } } + $letsencrypt = '0'; + if (isset($_POST['letsencrypt']) && $_POST['letsencrypt'] == '1') { + // let's encrypt only works if there actually is a + // ssl ip/port assigned to the domain + if (domainHasSslIpPort($domain_check['id']) == true) { + $letsencrypt = '1'; + } else { + standard_error('letsencryptonlypossiblewithsslipport'); + } + } + if ($path == '') { standard_error('patherror'); } elseif ($subdomain == '') { @@ -354,7 +365,8 @@ if ($page == 'overview') { `speciallogfile` = :speciallogfile, `specialsettings` = :specialsettings, `ssl_redirect` = :ssl_redirect, - `phpsettingid` = :phpsettingid" + `phpsettingid` = :phpsettingid, + `letsencrypt` = :letsencrypt" ); $params = array( "customerid" => $userinfo['customerid'], @@ -370,7 +382,8 @@ if ($page == 'overview') { "speciallogfile" => $domain_check['speciallogfile'], "specialsettings" => $domain_check['specialsettings'], "ssl_redirect" => $ssl_redirect, - "phpsettingid" => $phpsid_result['phpsettingid'] + "phpsettingid" => $phpsid_result['phpsettingid'], + "letsencrypt" => $letsencrypt ); Database::pexecute($stmt, $params); @@ -403,7 +416,7 @@ if ($page == 'overview') { redirectTo($filename, array('page' => $page, 's' => $s)); } } else { - $stmt = Database::prepare("SELECT `id`, `domain`, `documentroot`, `ssl_redirect`,`isemaildomain` FROM `" . TABLE_PANEL_DOMAINS . "` + $stmt = Database::prepare("SELECT `id`, `domain`, `documentroot`, `ssl_redirect`,`isemaildomain`,`letsencrypt` FROM `" . TABLE_PANEL_DOMAINS . "` WHERE `customerid` = :customerid AND `parentdomainid` = '0' AND `email_only` = '0' @@ -465,7 +478,7 @@ if ($page == 'overview') { } elseif ($action == 'edit' && $id != 0) { $stmt = Database::prepare("SELECT `d`.`id`, `d`.`customerid`, `d`.`domain`, `d`.`documentroot`, `d`.`isemaildomain`, `d`.`wwwserveralias`, `d`.`iswildcarddomain`, - `d`.`parentdomainid`, `d`.`ssl_redirect`, `d`.`aliasdomain`, `d`.`openbasedir`, `d`.`openbasedir_path`, `pd`.`subcanemaildomain` + `d`.`parentdomainid`, `d`.`ssl_redirect`, `d`.`aliasdomain`, `d`.`openbasedir`, `d`.`openbasedir_path`, `d`.`letsencrypt`, `pd`.`subcanemaildomain` FROM `" . TABLE_PANEL_DOMAINS . "` `d`, `" . TABLE_PANEL_DOMAINS . "` `pd` WHERE `d`.`customerid` = :customerid AND `d`.`id` = :id @@ -545,7 +558,7 @@ if ($page == 'overview') { } if (isset($_POST['ssl_redirect']) && $_POST['ssl_redirect'] == '1') { - // a ssl-redirect only works of there actually is a + // a ssl-redirect only works if there actually is a // ssl ip/port assigned to the domain if (domainHasSslIpPort($id) == true) { $ssl_redirect = '1'; @@ -557,6 +570,18 @@ if ($page == 'overview') { $ssl_redirect = '0'; } + if (isset($_POST['letsencrypt']) && $_POST['letsencrypt'] == '1') { + // let's encrypt only works if there actually is a + // ssl ip/port assigned to the domain + if (domainHasSslIpPort($id) == true) { + $letsencrypt = '1'; + } else { + standard_error('letsencryptonlypossiblewithsslipport'); + } + } else { + $letsencrypt = '0'; + } + if ($path == '') { standard_error('patherror'); } else { @@ -580,7 +605,8 @@ if ($page == 'overview') { || $iswildcarddomain != $result['iswildcarddomain'] || $aliasdomain != $result['aliasdomain'] || $openbasedir_path != $result['openbasedir_path'] - || $ssl_redirect != $result['ssl_redirect']) { + || $ssl_redirect != $result['ssl_redirect'] + || $letsencrypt != $result['letsencrypt']) { $log->logAction(USR_ACTION, LOG_INFO, "edited domain '" . $idna_convert->decode($result['domain']) . "'"); $stmt = Database::prepare("UPDATE `" . TABLE_PANEL_DOMAINS . "` SET @@ -590,7 +616,8 @@ if ($page == 'overview') { `iswildcarddomain`= :iswildcarddomain, `aliasdomain`= :aliasdomain, `openbasedir_path`= :openbasedir_path, - `ssl_redirect`= :ssl_redirect + `ssl_redirect`= :ssl_redirect, + `letsencrypt`= :letsencrypt WHERE `customerid`= :customerid AND `id`= :id" ); @@ -602,6 +629,7 @@ if ($page == 'overview') { "aliasdomain" => ($aliasdomain != 0 && $alias_check == 0) ? $aliasdomain : null, "openbasedir_path" => $openbasedir_path, "ssl_redirect" => $ssl_redirect, + "letsencrypt" => $letsencrypt, "customerid" => $userinfo['customerid'], "id" => $id ); diff --git a/install/froxlor.sql b/install/froxlor.sql index eaf55166..29c66917 100644 --- a/install/froxlor.sql +++ b/install/froxlor.sql @@ -194,6 +194,8 @@ CREATE TABLE `panel_customers` ( `theme` varchar(255) NOT NULL default 'Sparkle', `custom_notes` text, `custom_notes_show` tinyint(1) NOT NULL default '0', + `lepublickey` mediumtext DEFAULT NULL, + `leprivatekey` mediumtext DEFAULT NULL, PRIMARY KEY (`customerid`), UNIQUE KEY `loginname` (`loginname`) ) ENGINE=MyISAM CHARSET=utf8 COLLATE=utf8_general_ci; @@ -247,6 +249,7 @@ CREATE TABLE `panel_domains` ( `mod_fcgid_starter` int(4) default '-1', `mod_fcgid_maxrequests` int(4) default '-1', `ismainbutsubto` int(11) unsigned NOT NULL default '0', + `letsencrypt` tinyint(1) NOT NULL default '0', PRIMARY KEY (`id`), KEY `customerid` (`customerid`), KEY `parentdomain` (`parentdomainid`), @@ -509,6 +512,11 @@ 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'), + ('system', 'letsencryptca', 'testing'), + ('system', 'letsencryptcountrycode', 'DE'), + ('system', 'letsencryptstate', 'Germany'), ('panel', 'decimal_places', '4'), ('panel', 'adminmail', 'admin@SERVERNAME'), ('panel', 'phpmyadmin_url', ''), @@ -539,7 +547,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-dev2'); DROP TABLE IF EXISTS `panel_tasks`; @@ -747,7 +755,8 @@ INSERT INTO `cronjobs_run` (`id`, `module`, `cronfile`, `interval`, `isactive`, (3, 'froxlor/ticket', 'used_tickets_reset', '1 DAY', '1', 'cron_ticketsreset'), (4, 'froxlor/ticket', 'ticketarchive', '1 MONTH', '1', 'cron_ticketarchive'), (5, 'froxlor/reports', 'usage_report', '1 DAY', '1', 'cron_usage_report'), - (6, 'froxlor/core', 'mailboxsize', '6 HOUR', '1', 'cron_mailboxsize'); + (6, 'froxlor/core', 'mailboxsize', '6 HOUR', '1', 'cron_mailboxsize'), + (7, 'froxlor/letsencrypt', 'letsencrypt', '5 MINUTE', '1', 'cron_letsencrypt'); @@ -822,6 +831,7 @@ CREATE TABLE IF NOT EXISTS `domain_ssl_settings` ( `ssl_key_file` mediumtext NOT NULL, `ssl_ca_file` mediumtext, `ssl_cert_chainfile` mediumtext, + `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..47e722f8 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,44 @@ 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 `expirationdate` DATETIME NULL AFTER `ssl_cert_chainfile`;"); + Database::query("ALTER TABLE `".TABLE_PANEL_CUSTOMERS."` ADD `lepublickey` MEDIUMTEXT DEFAULT NULL AFTER `custom_notes_show`"); + Database::query("ALTER TABLE `".TABLE_PANEL_CUSTOMERS."` ADD `leprivatekey` MEDIUMTEXT DEFAULT NULL AFTER `lepublickey`;"); + Database::query("ALTER TABLE `".TABLE_PANEL_DOMAINS."` ADD `letsencrypt` TINYINT(1) NOT NULL DEFAULT '0' AFTER `ismainbutsubto`;"); + Settings::AddNew("system.leprivatekey", 'unset'); + Settings::AddNew("system.lepublickey", 'unset'); + showUpdateStep("Adding new cron-module for Let's encrypt"); + $stmt = Database::prepare(" + INSERT INTO `" . TABLE_PANEL_CRONRUNS . "` SET + `module` = 'froxlor/letsencrypt', + `cronfile` = 'letsencrypt', + `interval` = '5 MINUTE', + `desc_lng_key` = 'cron_letsencrypt', + `lastrun` = NOW(), + `isactive` = 1" + ); + Database::pexecute($stmt); + lastStepStatus(0); + + updateToVersion('0.9.35-dev1'); +} + +if (isFroxlorVersion('0.9.35-dev1')) { + + showUpdateStep("Updating from 0.9.35-dev1 to 0.9.35-dev2"); + lastStepStatus(0); + showUpdateStep("Adding Let's Encrypt - settings"); + Settings::AddNew("system.letsencryptca", 'testing'); + Settings::AddNew("system.letsencryptcountrycode", 'DE'); + Settings::AddNew("system.letsencryptstate", 'Germany'); + lastStepStatus(0); + + updateToVersion('0.9.35-dev2'); +} + diff --git a/lib/classes/integrity/class.IntegrityCheck.php b/lib/classes/integrity/class.IntegrityCheck.php index bf330760..6d82a594 100644 --- a/lib/classes/integrity/class.IntegrityCheck.php +++ b/lib/classes/integrity/class.IntegrityCheck.php @@ -267,6 +267,76 @@ class IntegrityCheck { } } + /** + * Check if all subdomain have letsencrypt = 0 if domain has no ssl-port + * @param $fix Fix everything found directly + */ + public function SubdomainLetsencrypt($fix = false) { + $ips = array(); + $parentdomains = array(); + $subdomains = array(); + + if ($fix) { + // Prepare update statement for the fixes + $upd_stmt = Database::prepare(" + UPDATE `" . TABLE_PANEL_DOMAINS . "` + SET `letsencrypt` = 0 WHERE `parentdomainid` = :domainid" + ); + } + + // Cache all ssl ip/port - combinations + $result_stmt = Database::prepare("SELECT `id`, `ip`, `port` FROM `" . TABLE_PANEL_IPSANDPORTS . "` WHERE `ssl` = 1 ORDER BY `id` ASC"); + Database::pexecute($result_stmt); + while ($row = $result_stmt->fetch(PDO::FETCH_ASSOC)) { + $ips[$row['id']] = $row['ip'] . ':' . $row['port']; + } + + // Cache all configured domains + $result_stmt = Database::prepare("SELECT `id`, `parentdomainid`, `letsencrypt` FROM `" . TABLE_PANEL_DOMAINS . "` ORDER BY `id` ASC"); + $ip_stmt = Database::prepare("SELECT `id_domain`, `id_ipandports` FROM `" . TABLE_DOMAINTOIP . "` WHERE `id_domain` = :domainid"); + Database::pexecute($result_stmt); + while ($row = $result_stmt->fetch(PDO::FETCH_ASSOC)) { + if ($row['parentdomainid'] == 0) { + // All parentdomains by default have no ssl - ip/port + $parentdomains[$row['id']] = false; + Database::pexecute($ip_stmt, array('domainid' => $row['id'])); + while ($iprow = $ip_stmt->fetch(PDO::FETCH_ASSOC)) { + // If the parentdomain has an ip/port assigned which we know is SSL enabled, set the parentdomain to "true" + if (array_key_exists($iprow['id_ipandports'], $ips)) { $parentdomains[$row['id']] = true; } + } + } elseif ($row['letsencrypt'] == 1) { + // All subdomains with enabled letsencrypt enabled are stored + if (!isset($subdomains[$row['parentdomainid']])) { $subdomains[$row['parentdomainid']] = array(); } + $subdomains[$row['parentdomainid']][] = $row['id']; + } + } + + // Check if every parentdomain with enabled letsencrypt as SSL enabled + foreach ($parentdomains as $id => $sslavailable) { + // This parentdomain has no subdomains + if (!isset($subdomains[$id])) { continue; } + // This parentdomain has SSL enabled, doesn't matter what status the subdomains have + if ($sslavailable) { continue; } + + // At this point only parentdomains reside which have letsencrypt enabled subdomains + if ($fix) { + // We make a blanket update to all subdomains of this parentdomain, doesn't matter which one is wrong, all have to be disabled + Database::pexecute($upd_stmt, array('domainid' => $id)); + $this->_log->logAction(ADM_ACTION, LOG_WARNING, "found a subdomain with letsencrypt=1 but parent-domain has ssl=0, integrity check fixed this"); + } else { + // It's just the check, let the function fail + $this->_log->logAction(ADM_ACTION, LOG_NOTICE, "found a subdomain with letsencrypt=1 but parent-domain has ssl=0, integrity check can fix this"); + return false; + } + } + + if ($fix) { + return $this->SubdomainLetsencrypt(); + } else { + return true; + } + } + /** * check whether the webserveruser is in * the customers groups when fcgid / php-fpm is used diff --git a/lib/classes/ssl/class.lescript.php b/lib/classes/ssl/class.lescript.php new file mode 100644 index 00000000..f36acd25 --- /dev/null +++ b/lib/classes/ssl/class.lescript.php @@ -0,0 +1,482 @@ + +// 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 $license = 'https://letsencrypt.org/documents/LE-SA-v1.0.1-July-27-2015.pdf'; + + private $webRootDir; + + private $debugHandler; + private $client; + private $accountKey; + + public function __construct($webRootDir, $debugHandler) + { + $this->webRootDir = $webRootDir; + $this->debugHandler = $debugHandler; + if (Settings::Get('system.letsencryptca') == 'production') { + $ca = 'https://acme-v01.api.letsencrypt.org'; + } else { + $ca = 'https://acme-staging.api.letsencrypt.org'; + } + $this->client = new Client($ca); + } + + public function initAccount($certrow) + { + // Let's see if we have the private accountkey + $this->accountKey = $certrow['leprivatekey']; + if (!$this->accountKey || $this->accountKey == 'unset') { + + // generate and save new private key for account + // --------------------------------------------- + + $this->log('Starting new account registration'); + $keys = $this->generateKey(); + $upd_stmt = Database::prepare(" + UPDATE `".TABLE_PANEL_CUSTOMERS."` SET `lepublickey` = :public, `leprivatekey` = :private WHERE `customerid` = :customerid; + "); + Database::pexecute($upd_stmt, array('public' => $keys['public'], 'private' => $keys['private'], 'customerid' => $certrow['customerid'])); + $this->accountKey = $keys['private']; + $this->postNewReg(); + $this->log('New account certificate registered'); + + } else { + + $this->log('Account already registered. Continuing.'); + + } + } + + public function signDomains(array $domains, $domainkey = null) + { + + if (!$this->accountKey) { + throw new \RuntimeException("Account not initiated"); + } + + $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( + "/acme/new-authz", + array("resource" => "new-authz", "identifier" => array("type" => "dns", "value" => $domain)) + ); + + if (!array_key_exists('challenges', $response)) { + throw new RuntimeException("No challenges received for $domain. Whole response: ".json_encode($response)); + } + + // 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 + // we wait for a maximum of 30 seconds to avoid endless loops + $count = 0; + 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); + $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)) { + $keys = $this->generateKey(); + $domainkey = $keys['private']; + } + + // 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'); + + $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); + } + + 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('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" => 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" => 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($this->accountKey); + $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 . "\n"); + } +} + +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/lib/formfields/admin/domains/formfield.domains_add.php b/lib/formfields/admin/domains/formfield.domains_add.php index 12dbae4a..f39c9754 100644 --- a/lib/formfields/admin/domains/formfield.domains_add.php +++ b/lib/formfields/admin/domains/formfield.domains_add.php @@ -113,6 +113,16 @@ return array( ), 'value' => array() ), + 'letsencrypt' => array( + 'visible' => (Settings::Get('system.use_ssl') == '1' ? ($ssl_ipsandports != '' ? true : false) : false), + 'label' => $lng['admin']['letsencrypt']['title'], + 'desc' => $lng['admin']['letsencrypt']['description'], + 'type' => 'checkbox', + 'values' => array( + array ('label' => $lng['panel']['yes'], 'value' => '1') + ), + 'value' => array() + ), 'no_ssl_available_info' => array( 'visible' => (Settings::Get('system.use_ssl') == '1' ? ($ssl_ipsandports == '' ? true : false) : false), 'label' => 'SSL', diff --git a/lib/formfields/admin/domains/formfield.domains_edit.php b/lib/formfields/admin/domains/formfield.domains_edit.php index d4d79698..2c68f208 100644 --- a/lib/formfields/admin/domains/formfield.domains_edit.php +++ b/lib/formfields/admin/domains/formfield.domains_edit.php @@ -124,6 +124,16 @@ return array( ), 'value' => array($result['ssl_redirect']) ), + 'letsencrypt' => array( + 'visible' => (Settings::Get('system.use_ssl') == '1' ? ($ssl_ipsandports != '' ? true : false) : false), + 'label' => $lng['admin']['letsencrypt']['title'], + 'desc' => $lng['admin']['letsencrypt']['description'], + 'type' => 'checkbox', + 'values' => array( + array ('label' => $lng['panel']['yes'], 'value' => '1') + ), + 'value' => array($result['letsencrypt']) + ), 'no_ssl_available_info' => array( 'visible' => (Settings::Get('system.use_ssl') == '1' ? ($ssl_ipsandports == '' ? true : false) : false), 'label' => 'SSL', diff --git a/lib/formfields/customer/domains/formfield.domains_add.php b/lib/formfields/customer/domains/formfield.domains_add.php index 55a1a67c..753903ea 100644 --- a/lib/formfields/customer/domains/formfield.domains_add.php +++ b/lib/formfields/customer/domains/formfield.domains_add.php @@ -70,6 +70,16 @@ return array( ), 'value' => array() ), + 'letsencrypt' => array( + 'visible' => (Settings::Get('system.use_ssl') == '1' ? ($ssl_ipsandports != '' ? true : false) : false), + 'label' => $lng['customer']['letsencrypt']['title'], + 'desc' => $lng['customer']['letsencrypt']['description'], + 'type' => 'checkbox', + 'values' => array( + array ('label' => $lng['panel']['yes'], 'value' => '1') + ), + 'value' => array() + ), 'openbasedir_path' => array( 'label' => $lng['domain']['openbasedirpath'], 'type' => 'select', diff --git a/lib/formfields/customer/domains/formfield.domains_edit.php b/lib/formfields/customer/domains/formfield.domains_edit.php index 7d809532..abd4905a 100644 --- a/lib/formfields/customer/domains/formfield.domains_edit.php +++ b/lib/formfields/customer/domains/formfield.domains_edit.php @@ -86,6 +86,16 @@ return array( ), 'value' => array($result['ssl_redirect']) ), + 'letsencrypt' => array( + 'visible' => (Settings::Get('system.use_ssl') == '1' ? ($ssl_ipsandports != '' ? (domainHasSslIpPort($result['id']) ? true : false) : false) : false), + 'label' => $lng['customer']['letsencrypt']['title'], + 'desc' => $lng['customer']['letsencrypt']['description'], + 'type' => 'checkbox', + 'values' => array( + array ('label' => $lng['panel']['yes'], 'value' => '1') + ), + 'value' => array($result['letsencrypt']) + ), 'openbasedir_path' => array( 'visible' => ($result['openbasedir'] == '1') ? true : false, 'label' => $lng['domain']['openbasedirpath'], diff --git a/lib/version.inc.php b/lib/version.inc.php index bc147d13..7bf5b39d 100644 --- a/lib/version.inc.php +++ b/lib/version.inc.php @@ -16,7 +16,7 @@ */ // Main version variable -$version = '0.9.34.2'; +$version = '0.9.35-dev2'; // Database version (unused, old stuff from SysCP) $dbversion = '2'; diff --git a/lng/english.lng.php b/lng/english.lng.php index d369cb3b..c780385c 100644 --- a/lng/english.lng.php +++ b/lng/english.lng.php @@ -1924,3 +1924,19 @@ $lng['opcacheinfo']['blacklist'] = 'Blacklist'; $lng['opcacheinfo']['novalue'] = 'no value'; $lng['opcacheinfo']['true'] = 'true'; $lng['opcacheinfo']['false'] = 'false'; + +// Added for let's encrypt +$lng['admin']['letsencrypt']['title'] = 'Use Let\'s Encrypt'; +$lng['admin']['letsencrypt']['description'] = 'Get a free certificate from Let\'s Encrypt. The certificate will be created and renewed automatically.
ATTENTION:If wildcards are enabled, this option will automatically be disabled. This feature is still in beta.'; +$lng['customer']['letsencrypt']['title'] = 'Use Let\'s Encrypt'; +$lng['customer']['letsencrypt']['description'] = 'Get a free certificate from Let\'s Encrypt. The certificate will be created and renewed automatically.
ATTENTION:"This feature is still in beta.'; +$lng['error']['sslredirectonlypossiblewithsslipport'] = 'Using Let\'s Encrypt is only possible when the domain has at least one ssl-enabled IP/port combination assigned.'; +$lng['panel']['letsencrypt'] = 'Using Let\'s encrypt'; +$lng['crondesc']['cron_letsencrypt'] = 'updating Let\'s Encrypt certificates'; +$lng['serversettings']['letsencryptca']['title'] = "Let's Encrypt environment"; +$lng['serversettings']['letsencryptca']['description'] = "Environment to be used for Let's Encrypt certificates.
ATTENTION:Let's Encrypt is still in beta"; +$lng['serversettings']['letsencryptcountrycode']['title'] = "Let's Encrypt country code"; +$lng['serversettings']['letsencryptcountrycode']['description'] = "2 letter country code used to generate Let's Encrypt certificates.
ATTENTION:Let's Encrypt is still in beta"; +$lng['serversettings']['letsencryptstate']['title'] = "Let's Encrypt state"; +$lng['serversettings']['letsencryptstate']['description'] = "State used to generate Let's Encrypt certificates.
ATTENTION:Let's Encrypt is still in beta"; + diff --git a/lng/german.lng.php b/lng/german.lng.php index 43213ad3..e2613afd 100644 --- a/lng/german.lng.php +++ b/lng/german.lng.php @@ -1579,3 +1579,19 @@ $lng['admin']['specialsettings_replacements'] = "Die folgenden Variablen können $lng['serversettings']['default_vhostconf']['description'] = 'Der Inhalt dieses Feldes wird direkt in den IP/Port-vHost-Container übernommen. '.$lng['admin']['specialsettings_replacements'].'
ACHTUNG: Der Code wird nicht auf Fehler geprüft. Etwaige Fehler werden also auch übernommen. Der Webserver könnte nicht mehr starten!'; $lng['serversettings']['default_vhostconf_domain']['description'] = 'Der Inhalt dieses Feldes wird direkt in jeden Domain-vHost-Container übernommen. '. $lng['admin']['specialsettings_replacements'].'ACHTUNG: Der Code wird nicht auf Fehler geprüft. Etwaige Fehler werden also auch übernommen. Der Webserver könnte nicht mehr starten!'; $lng['admin']['mod_fcgid_umask']['title'] = 'Umask (Standard: 022)'; + +// Added for let's encrypt +$lng['admin']['letsencrypt']['title'] = 'Benutze Let\'s Encrypt'; +$lng['admin']['letsencrypt']['description'] = 'Holt ein kostenloses Zertifikat von Let\'s Encrypt. Das Zertifikat wird automatisch erstellt und verlänger.
ACHTUNG:Wenn Wildcards aktiviert sind, wird diese Option automatisch deaktiviert. Dieses Feature befindet sich noch im Test.'; +$lng['customer']['letsencrypt']['title'] = 'Benutze Let\'s Encrypt'; +$lng['customer']['letsencrypt']['description'] = 'Holt ein kostenloses Zertifikat von Let\'s Encrypt. Das Zertifikat wird automatisch erstellt und verlängert.
ACHTUNG:Dieses Feature befindet sich noch im Test.'; +$lng['error']['sslredirectonlypossiblewithsslipport'] = 'Die Nutzung von Let\'s Encrypt ist nur möglich, wenn die Domain mindestens eine IP/Port - Kombination mit aktiviertem SSL zugewiesen hat.'; +$lng['panel']['letsencrypt'] = 'Benutzt Let\'s encrypt'; +$lng['crondesc']['cron_letsencrypt'] = 'aktualisiert Let\'s Encrypt Zertifikate'; +$lng['serversettings']['letsencryptca']['title'] = "Let's Encrypt Umgebung"; +$lng['serversettings']['letsencryptca']['description'] = "Let's Encrypt - Umgebung, welche genutzt wird um Zertifikate zu bestellen.
ATTENTION:Let's Encrypt befindet sich noch im Test"; +$lng['serversettings']['letsencryptcountrycode']['title'] = "Let's Encrypt Ländercode"; +$lng['serversettings']['letsencryptcountrycode']['description'] = "2 - stelliger Ländercode, welcher benutzt wird um Let's Encrypt - Zertifikate zu bestellen.
ATTENTION:Let's Encrypt befindet sich noch im Test"; +$lng['serversettings']['letsencryptstate']['title'] = "Let's Encrypt Bundesland"; +$lng['serversettings']['letsencryptstate']['description'] = "Bundesland, welches benutzt wird um Let's Encrypt - Zertifikate zu bestellen.
ATTENTION:Let's Encrypt befindet sich noch im Test"; + diff --git a/scripts/jobs/cron_letsencrypt.php b/scripts/jobs/cron_letsencrypt.php new file mode 100644 index 00000000..046d03e7 --- /dev/null +++ b/scripts/jobs/cron_letsencrypt.php @@ -0,0 +1,106 @@ + + * @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.`domainid`, domssl.expirationdate, domssl.`ssl_cert_file`, domssl.`ssl_key_file`, domssl.`ssl_ca_file`, dom.`domain`, dom.`iswildcarddomain`, dom.`wwwserveralias`, + dom.`documentroot`, dom.`id` as 'domainid', cust.`leprivatekey`, cust.`lepublickey`, cust.customerid + FROM `".TABLE_PANEL_CUSTOMERS."` as cust, `".TABLE_PANEL_DOMAINS."` dom LEFT JOIN `".TABLE_PANEL_DOMAIN_SSL_SETTINGS."` domssl ON (dom.id = domssl.domainid) + WHERE dom.customerid = cust.customerid AND dom.letsencrypt = 1 AND (domssl.expirationdate < DATE_ADD(NOW(), INTERVAL 30 DAY) OR domssl.expirationdate IS NULL) +"); + +$upd_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` = :fullchain, expirationdate = :expirationdate +"); + +$changedetected = 0; +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"); + + if ($certrow['ssl_cert_file']) { + fwrite($debugHandler, "letsencrypt using old key / SAN for " . $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); + } + } else { + fwrite($debugHandler, "letsencrypt generating new key / SAN for " . $certrow['domain'] . "\n"); + $domains = array($certrow['domain']); + // Add www. for SAN + if ($certrow['wwwserveralias'] == 1) { + $domains[] = 'www.' . $certrow['domain']; + } + } + + try { + // Initialize Lescript with documentroot + $le = new lescript($certrow['documentroot'], $debugHandler); + + // 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($upd_stmt, array( + 'id' => $certrow['id'], + 'domainid' => $certrow['domainid'], + 'crt' => $return['crt'], + 'key' => $return['key'], + 'ca' => $return['chain'], + 'fullchain' => $return['fullchain'], + 'expirationdate' => date('Y-m-d H:i:s', $newcert['validTo_time_t']) + ) + ); + + $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()); + fwrite($debugHandler, 'letsencrypt exception: ' . $e->getMessage() . "\n"); + } + } else { + fwrite($debugHandler, 'letsencrypt skipped because documentroot ' . $certrow['documentroot'] . ' does not exist' . "\n"); + } +} + +// 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); +} diff --git a/scripts/jobs/cron_tasks.php b/scripts/jobs/cron_tasks.php index e45ee1c3..8d5abaf7 100644 --- a/scripts/jobs/cron_tasks.php +++ b/scripts/jobs/cron_tasks.php @@ -99,7 +99,7 @@ while ($row = $result_tasks_stmt->fetch(PDO::FETCH_ASSOC)) { ) { // webserver has no access, add it if (isFreeBSD()) { - safe_exec('pw user mod '.escapeshellarg(Settings::Get('system.httpuser')).' -G '.escapeshellarg(Settings::Get('phpfpm.vhost_httpgroup'))); + safe_exec('pw usermod '.escapeshellarg(Settings::Get('system.httpuser')).' -G '.escapeshellarg(Settings::Get('phpfpm.vhost_httpgroup'))); } else { safe_exec('usermod -a -G ' . escapeshellarg(Settings::Get('phpfpm.vhost_httpgroup')).' '.escapeshellarg(Settings::Get('system.httpuser'))); } diff --git a/templates/Sparkle/assets/img/icons/ssl_letsencrypt.png b/templates/Sparkle/assets/img/icons/ssl_letsencrypt.png new file mode 100644 index 00000000..ef0d8f64 Binary files /dev/null and b/templates/Sparkle/assets/img/icons/ssl_letsencrypt.png differ diff --git a/templates/Sparkle/customer/domains/domains_domain.tpl b/templates/Sparkle/customer/domains/domains_domain.tpl index 681e1487..a85e74e0 100644 --- a/templates/Sparkle/customer/domains/domains_domain.tpl +++ b/templates/Sparkle/customer/domains/domains_domain.tpl @@ -20,6 +20,9 @@ {$lng['panel']['ssleditor']}   + + {$lng['panel']['letsencrypt']} + ({$lng['domains']['isassigneddomain']})