From 882b694e06acd389dd74f7a7d9b70ada0fd218d5 Mon Sep 17 00:00:00 2001 From: Simon Rettberg Date: Tue, 8 Oct 2024 16:22:17 +0200 Subject: [webinterface] Add support for ACME, add option to redirect to cert domain --- modules-available/webinterface/hooks/cron.inc.php | 5 + .../webinterface/hooks/main-warning.inc.php | 8 + modules-available/webinterface/inc/acme.inc.php | 164 ++++++++++++++++++ .../webinterface/inc/webinterface.inc.php | 65 ++++++++ .../webinterface/lang/de/messages.json | 8 +- .../webinterface/lang/de/template-tags.json | 19 +++ .../webinterface/lang/en/messages.json | 8 +- .../webinterface/lang/en/template-tags.json | 19 +++ modules-available/webinterface/page.inc.php | 184 ++++++++++++++++----- .../webinterface/templates/acme-error.html | 5 + .../webinterface/templates/heading.html | 5 + .../webinterface/templates/httpd-restart.html | 4 +- .../webinterface/templates/https.html | 150 ++++++++++++++--- 13 files changed, 572 insertions(+), 72 deletions(-) create mode 100644 modules-available/webinterface/hooks/cron.inc.php create mode 100644 modules-available/webinterface/hooks/main-warning.inc.php create mode 100644 modules-available/webinterface/inc/acme.inc.php create mode 100644 modules-available/webinterface/inc/webinterface.inc.php create mode 100644 modules-available/webinterface/templates/acme-error.html (limited to 'modules-available/webinterface') diff --git a/modules-available/webinterface/hooks/cron.inc.php b/modules-available/webinterface/hooks/cron.inc.php new file mode 100644 index 00000000..cc30ed05 --- /dev/null +++ b/modules-available/webinterface/hooks/cron.inc.php @@ -0,0 +1,5 @@ + "Let's Encrypt", + 'zerossl' => 'ZeroSSL.com', + 'buypass' => 'BuyPass.com', + 'geant/sectigo' => 'GEANT via Sectigo', + ]; + + const PROVIDER_ALIASES = [ + 'geant/sectigo' => 'https://acme.sectigo.com/v2/GEANTOV', + ]; + + public static function getLastError(): ?string + { + return Property::get(self::PROP_ERROR, null); + } + + public static function getProvider(): ?string + { + return Property::get(self::PROP_PROVIDER, null); + } + + public static function getKeyId(): ?string + { + return Property::get(self::PROP_KEY_ID, null); + } + + public static function getHmacKey(): ?string + { + return Property::get(self::PROP_HMAC_KEY, null); + } + + /** + * @return string[] list of [id] => friendly name + */ + public static function getProviders(): array + { + return self::VALID_PROVIDERS; + } + + public static function getMail(): ?string + { + return Property::get(self::PROP_MAIL, null); + } + + public static function getDomains(): array + { + return explode(' ', Property::get(self::PROP_DOMAINS)); + } + + public static function setConfig(string $provider, string $mail, ?string $keyId = null, ?string $hmacKey = null): bool + { + if (!isset(self::VALID_PROVIDERS[$provider])) { + Message::addError('webinterface.acme-invalid-provider', $provider); + return false; + } + Property::set(self::PROP_PROVIDER, $provider); + Property::set(self::PROP_MAIL, $mail); + Property::set(self::PROP_KEY_ID, $keyId); + Property::set(self::PROP_HMAC_KEY, $hmacKey); + return true; + } + + public static function setDomains(array $list): void + { + Property::set(self::PROP_DOMAINS, implode(' ', $list)); + } + + private static function handleErrorAsync($task): void + { + if (!is_array($task) || !Taskmanager::isTask($task)) + return; + $task = Taskmanager::waitComplete($task, 250); + if (Taskmanager::isFinished($task)) { + self::callbackErrorCheck($task); + } else { + Property::set(self::PROP_ERROR, false); + TaskmanagerCallback::addCallback($task, 'acmeErrors'); + } + } + + public static function callbackErrorCheck(array $task): void + { + if (!Taskmanager::isFinished($task)) + return; + if (Taskmanager::isFailed($task)) { + Property::set(self::PROP_ERROR, $task['data']['error'] ?? 'Unknown error'); + } else { + Property::set(self::PROP_ERROR, false); + } + } + + public static function issueNewCertificate(bool $wipeAll = false): ?string + { + $provider = self::getProvider(); + if ($provider === null) { + Message::addError('webinterface.acme-no-provider'); + return null; + } + $mail = self::getMail(); + if (empty($mail)) { + Message::addError('webinterface.acme-no-mail'); + return null; + } + $domains = self::getDomains(); + if (empty($domains)) { + Message::addError('webinterface.acme-no-domains'); + return null; + } + $redirect = Property::get(WebInterface::PROP_REDIRECT); + $task = Taskmanager::submit('LighttpdHttps', [ + 'redirect' => $redirect, + 'acmeMode' => 'issue', + 'acmeMail' => $mail, + 'acmeDomains' => $domains, + 'acmeProvider' => self::PROVIDER_ALIASES[$provider] ?? $provider, + 'acmeKeyId' => self::getKeyId(), + 'acmeHmacKey' => self::getHmacKey(), + 'acmeWipeAll' => $wipeAll, + ]); + self::handleErrorAsync($task); + return $task['id'] ?? null; + } + + public static function renew(): ?string + { + error_log("Renew called"); + $domains = self::getDomains(); + if (empty($domains)) { + Message::addError('webinterface.acme-no-domains'); + return null; + } + $redirect = Property::get(WebInterface::PROP_REDIRECT); + $task = Taskmanager::submit('LighttpdHttps', [ + 'redirect' => $redirect, + 'acmeMode' => 'renew', + 'acmeDomains' => $domains, + ]); + self::handleErrorAsync($task); + return $task['id'] ?? null; + } + + public static function tryEnable(): bool + { + $redirect = Property::get(WebInterface::PROP_REDIRECT); + $task = Taskmanager::submit('LighttpdHttps', [ + 'redirect' => $redirect, + 'acmeMode' => 'try-enable', + ]); + $task = Taskmanager::waitComplete($task, 10000); + return Taskmanager::isFinished($task) && !Taskmanager::isFailed($task); + } + +} \ No newline at end of file diff --git a/modules-available/webinterface/inc/webinterface.inc.php b/modules-available/webinterface/inc/webinterface.inc.php new file mode 100644 index 00000000..6dfd924f --- /dev/null +++ b/modules-available/webinterface/inc/webinterface.inc.php @@ -0,0 +1,65 @@ +actionCustomization(); break; + default: } if (Request::isPost()) { Util::redirect('?do=webinterface'); @@ -38,24 +35,28 @@ class Page_WebInterface extends Page { $mode = Request::post('mode'); switch ($mode) { - case 'off': - $task = $this->setHttpsOff(); - break; - case 'random': - $task = $this->setHttpsRandomCert(); - break; - case 'custom': - $task = $this->setHttpsCustomCert(); - break; - default: - $task = $this->setRedirectMode(); - break; + case 'off': + $taskId = $this->setHttpsOff(); + break; + case 'random': + $taskId = $this->setHttpsRandomCert(); + break; + case 'custom': + $taskId = $this->setHttpsCustomCert(); + break; + case 'acme': + $taskId = $this->setAcmeMode(); + break; + default: + $taskId = $this->setRedirectMode(); + break; } if ($mode !== 'off') { - Property::set(self::PROP_HSTS, Request::post('usehsts', false, 'string') === 'on' ? 'True' : 'False'); + Property::set(WebInterface::PROP_HSTS, Request::post('usehsts', false, 'string') === 'on' ? 'True' : 'False'); + WebInterface::setDomainRedirect(Request::post('redirdomain', false, 'string') === 'on'); } - if (isset($task['id'])) { - Session::set('https-id', $task['id']); + if ($taskId !== null) { + Session::set('https-id', $taskId, 1); Util::redirect('?do=WebInterface&show=httpsupdate'); } Util::redirect('?do=WebInterface'); @@ -70,7 +71,7 @@ class Page_WebInterface extends Page private function actionCustomization() { $prefix = Request::post('prefix', '', 'string'); - if (!empty($prefix) && !preg_match('/[\]\)\}\-_\s\&\$\!\/\+\*\^\>]$/', $prefix)) { + if (!empty($prefix) && !preg_match('/[)}\]\-_\s&$!\/+*^>]$/', $prefix)) { $prefix .= ' '; } Property::set('page-title-prefix', $prefix); @@ -87,17 +88,25 @@ class Page_WebInterface extends Page if (Request::get('show') === 'httpsupdate') { Render::addTemplate('httpd-restart', array('taskid' => Session::get('https-id'))); } - $type = Property::get(self::PROP_TYPE); - $force = Property::get(self::PROP_REDIRECT) === 'True'; - $hsts = Property::get(self::PROP_HSTS) === 'True'; + $type = Property::get(WebInterface::PROP_TYPE); + $force = Property::get(WebInterface::PROP_REDIRECT) === 'True'; + $hsts = Property::get(WebInterface::PROP_HSTS) === 'True'; + $redirdomain = WebInterface::getDomainRedirect(); $https = !empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off'; $exists = file_exists('/etc/lighttpd/server.pem'); $data = array( 'httpsUsed' => $https, 'redirect_checked' => ($force ? 'checked' : ''), - 'hsts_checked' => ($hsts ? 'checked' : '') + 'hsts_checked' => ($hsts ? 'checked' : ''), + 'redirdomain_checked' => ($redirdomain ? 'checked' : ''), ); - // Type should be 'off', 'generated', 'supplied' + // Type should be 'off', 'generated', 'supplied' or 'acme' + if ($type === 'acme') { + $err = Acme::getLastError(); + if (!empty($err)) { + Render::addTemplate('acme-error', ['error' => $err]); + } + } if ($type === 'off') { if ($exists) { // HTTPS is set to off, but a certificate exists @@ -113,7 +122,7 @@ class Page_WebInterface extends Page // Admin might have modified web server config in another way Message::addWarning('https-used-without-cert'); } - } elseif ($type === 'generated' || $type === 'supplied') { + } elseif ($type === 'generated' || $type === 'supplied' || $type === 'acme') { $data['httpsEnabled'] = true; if ($force && !$https) { Message::addWarning('https-want-redirect-is-plain'); @@ -125,21 +134,59 @@ class Page_WebInterface extends Page // Unknown config - maybe upgraded old install that doesn't keep track if ($exists || $https) { $type = 'unknown'; // Legacy fallback + $data['httpsEnabled'] = true; } else { $type = 'off'; } } + $domains = implode("\n", Acme::getDomains()); + if (empty($domains)) { + $domains = $_SERVER['HTTP_HOST']; + } + $data['acmeProviders'] = []; + foreach (Acme::getProviders() as $id => $name) { + $data['acmeProviders'][] = [ + 'id' => $id, + 'name' => $name, + 'selected' => $id === Acme::getProvider() ? 'selected' : '', + ]; + } + $data['acmeMail'] = Acme::getMail(); + $data['acmeDomains'] = $domains; + $data['acmeKeyId'] = Acme::getKeyId(); + $data['acmeHmacKey'] = Acme::getHmacKey(); + // $type might have changed in above block $data[$type . 'Selected'] = true; + // Show cert info if possible + if ($type !== 'off') { + $data['certDomains'] = []; + $exp = 0; + $iss = ''; + if (WebInterface::extractCurrentCertData($data['certDomains'], $exp, $iss)) { + $data['certExpire'] = Util::prettyTime($exp); + $data['certIssuer'] = $iss; + $diff = $exp - time(); + $class = []; + if ($diff < 86400 * 3) { + $class[] = 'text-danger'; + } + if ($diff < 86400 * 10) { + $class[] = 'slx-bold'; + } + $data['certExpireClass'] = implode(' ', $class); + } + } Permission::addGlobalTags($data['perms'], null, ['edit.https']); Render::addTemplate('https', $data); // // Password fields // $data = array(); - if (Property::getPasswordFieldType() === 'text') + if (Property::getPasswordFieldType() === 'text') { $data['selected_show'] = 'checked'; - else + } else { $data['selected_hide'] = 'checked'; + } Permission::addGlobalTags($data['perms'], null, ['edit.password']); Render::addTemplate('passwords', $data); // @@ -164,52 +211,99 @@ class Page_WebInterface extends Page Render::addTemplate('customization', $data); } - private function setHttpsOff() + private function setHttpsOff(): ?string { - Property::set(self::PROP_TYPE, 'off'); - Property::set(self::PROP_HSTS, 'off'); + Property::set(WebInterface::PROP_TYPE, 'off'); + Property::set(WebInterface::PROP_HSTS, 'off'); Header('Strict-Transport-Security: max-age=0', true); Session::deleteCookie(); - return Taskmanager::submit('LighttpdHttps', array()); + $task = Taskmanager::submit('LighttpdHttps', array()); + return $task['id'] ?? null; } - private function setHttpsRandomCert() + private function setHttpsRandomCert(): ?string { $force = Request::post('httpsredirect', false, 'string') === 'on'; - Property::set(self::PROP_TYPE, 'generated'); - Property::set(self::PROP_REDIRECT, $force ? 'True' : 'False'); - return Taskmanager::submit('LighttpdHttps', array( + Property::set(WebInterface::PROP_TYPE, 'generated'); + Property::set(WebInterface::PROP_REDIRECT, $force ? 'True' : 'False'); + $task = Taskmanager::submit('LighttpdHttps', array( 'proxyip' => Property::getServerIp(), 'redirect' => $force, )); + return $task['id'] ?? null; } - private function setHttpsCustomCert() + private function setHttpsCustomCert(): ?string { $force = Request::post('httpsredirect', false, 'string') === 'on'; - Property::set(self::PROP_TYPE, 'supplied'); - Property::set(self::PROP_REDIRECT, $force ? 'True' : 'False'); - return Taskmanager::submit('LighttpdHttps', array( + Property::set(WebInterface::PROP_TYPE, 'supplied'); + Property::set(WebInterface::PROP_REDIRECT, $force ? 'True' : 'False'); + $task = Taskmanager::submit('LighttpdHttps', array( 'importcert' => Request::post('certificate', 'bla'), 'importkey' => Request::post('privatekey', 'bla'), 'importchain' => Request::post('cachain', ''), 'redirect' => $force, )); + return $task['id'] ?? null; + } + + private function setAcmeMode(): ?string + { + $force = Request::post('httpsredirect', false, 'string') === 'on'; + Property::set(WebInterface::PROP_TYPE, 'acme'); + Property::set(WebInterface::PROP_REDIRECT, $force ? 'True' : 'False'); + $wipeAll = Request::post('acme-wipe-all', false, 'bool'); + // Get params + $provider = Request::post('acme-provider', Request::REQUIRED, 'string'); + $mail = Request::post('acme-mail', Request::REQUIRED, 'string'); + $domains = Request::post('acme-domains', Request::REQUIRED, 'string'); + $kid = Request::post('acme-kid', null, 'string'); + $hmac = Request::post('acme-hmac-key', null, 'string'); + // Check domains + $domains = preg_split('/[\r\n\s]+/', strtolower($domains), 0, PREG_SPLIT_NO_EMPTY); + $err = false; + foreach ($domains as $domain) { + if (!preg_match('/^[a-z0-9_.-]+$/', $domain)) { + Message::addError('invalid-domain', $domain); + $err = true; + } + } + unset($domain); + if ($err) + return null; + // First, try to revive existing config/certs if parameters didn't change + if (!$wipeAll + && $provider === Acme::getProvider() + && $mail === Acme::getMail() + && $kid === Acme::getKeyId() + && $hmac === Acme::getHmacKey() + && count($domains) === count(Acme::getDomains()) + && empty(array_diff($domains, Acme::getDomains()))) { + if (Acme::tryEnable()) + return null; // Nothing to do, old setup works + error_log('FUUUU'); + return Acme::renew(); // Hope for the best, otherwise user needs to check "force reissue" + } + if (!Acme::setConfig($provider, $mail, $kid, $hmac)) + return null; // Will generate error messages in this case + Acme::setDomains($domains); + return Acme::issueNewCertificate($wipeAll); } - private function setRedirectMode() + private function setRedirectMode(): ?string { $force = Request::post('httpsredirect', false, 'string') === 'on'; - Property::set(self::PROP_REDIRECT, $force ? 'True' : 'False'); - if (Property::get(self::PROP_TYPE) === 'off') { + Property::set(WebInterface::PROP_REDIRECT, $force ? 'True' : 'False'); + if (Property::get(WebInterface::PROP_TYPE) === 'off') { // Don't bother running the task if https isn't enabled - just // update the state in DB - return false; + return null; } - return Taskmanager::submit('LighttpdHttps', array( + $task = Taskmanager::submit('LighttpdHttps', array( 'redirectOnly' => true, 'redirect' => $force, )); + return $task['id'] ?? null; } } diff --git a/modules-available/webinterface/templates/acme-error.html b/modules-available/webinterface/templates/acme-error.html new file mode 100644 index 00000000..facbbad2 --- /dev/null +++ b/modules-available/webinterface/templates/acme-error.html @@ -0,0 +1,5 @@ +
{{error}}
+{{lang_httpsDescription}}
{{^httpsUsed}} - {{lang_youreNotUsingHttps}} +{{lang_youreNotUsingHttps}}
{{/httpsUsed}} {{#httpsUsed}} - {{lang_youreUsingHttps}} +{{lang_youreUsingHttps}}
{{/httpsUsed}}{{lang_suppliedSelected}}
{{/suppliedSelected}} + {{#acmeSelected}} +{{lang_acmeSelected}}
+ {{/acmeSelected}}| {{lang_certIssuer}}: | {{.}} |
| {{lang_certExpireTime}}: | {{.}} |
| {{lang_currentCertDomains}}: | +
+ {{#certDomains}}
+ {{.}}
+ {{/certDomains}}
+ |
+
{{lang_acmeCreateNewHint}}
+