diff options
17 files changed, 669 insertions, 89 deletions
diff --git a/inc/taskmanagercallback.inc.php b/inc/taskmanagercallback.inc.php index c6b447c9..9f276020 100644 --- a/inc/taskmanagercallback.inc.php +++ b/inc/taskmanagercallback.inc.php @@ -240,4 +240,13 @@ class TaskmanagerCallback SystemStatus::setUpgradableData($task); } + public static function acmeErrors(array $task): void + { + $mod = Module::get('webinterface'); + if ($mod === false) + return; + $mod->activate(1, false); + Acme::callbackErrorCheck($task); + } + } diff --git a/inc/util.inc.php b/inc/util.inc.php index 0ff6fdad..ed0b40ae 100644 --- a/inc/util.inc.php +++ b/inc/util.inc.php @@ -471,4 +471,44 @@ class Util } } + public static function shouldRedirectDomain(): ?string + { + if ($_SERVER['HTTP_HOST'] === 'satellite.bwlehrpool') + return null; + if (!Property::get('webinterface.redirect-domain') || empty(Property::get('webinterface.https-domains'))) + return null; // Disabled, or unknown domain + $curDomain = $_SERVER['HTTP_HOST']; + if ($curDomain[-1] === '.') { + $curDomain = substr($curDomain, 0, -1); + } + $domains = explode(' ', Property::get('webinterface.https-domains')); + foreach ($domains as $domain) { + if ($domain === $curDomain) + return null; // MATCH + if (substr_compare($domain, '*.', 0, 2) === 0) { + if (substr($domain, 2) === $curDomain) + return null; // MATCH + $len = strlen($domain) - 1; + if (substr($curDomain, -$len) === substr($domain, 1)) + return null; // MATCH + } + } + // No match + return $domains[0]; + /* + $str = '//' . $domains[0] . '/' . $_SERVER['REQUEST_URI']; + if (!empty($_SERVER['QUERY_STRING'])) { + $str .= '?' . $_SERVER['QUERY_STRING']; + } + Header('Location: ' . $str); + exit; + */ + } + + public static function osUptime(): int + { + $a = file_get_contents('/proc/uptime'); + return (int)preg_replace('/[^0-9].*$/s', '', $a); + } + } @@ -19,6 +19,33 @@ require_once('inc/user.inc.php'); $global_start = microtime(true); +// Set variable if this is an ajax request +if ((isset($_REQUEST['async'])) || (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')) { + define('AJAX', true); +} else { + define('AJAX', false); +} +define('API', false); + +// Autoload classes from ./inc which adhere to naming scheme <lowercasename>.inc.php +spl_autoload_register(function ($class) { + $file = 'inc/' . preg_replace('/[^a-z0-9]/', '', mb_strtolower($class)) . '.inc.php'; + if (!file_exists($file)) + return; + require_once $file; +}); + +if (($_GET['do'] ?? '') === '_https_magic') { + Header('Access-Control-Allow-Origin: *'); + Header('Content-Type: application/json'); + $ut = floor(Util::osUptime() / 3); + $str = Property::getServerIp() . serialize(Property::getVmStoreConfig()); + die(json_encode([ + 'a' => md5($ut . $str), + 'b' => md5(($ut - 1) . $str), + ])); +} + /** * Page class which all module's pages must be extending from */ @@ -90,23 +117,6 @@ abstract class Page } -// Set variable if this is an ajax request -if ((isset($_REQUEST['async'])) || (!empty($_SERVER['HTTP_X_REQUESTED_WITH']) && strtolower($_SERVER['HTTP_X_REQUESTED_WITH']) === 'xmlhttprequest')) { - define('AJAX', true); -} else { - define('AJAX', false); -} -define('API', false); - -// Autoload classes from ./inc which adhere to naming scheme <lowercasename>.inc.php -spl_autoload_register(function ($class) { - $file = 'inc/' . preg_replace('/[^a-z0-9]/', '', mb_strtolower($class)) . '.inc.php'; - if (!file_exists($file)) - return; - require_once $file; -}); - - if (defined('CONFIG_DEBUG') && CONFIG_DEBUG) { set_error_handler(function ($errno, $errstr, $errfile, $errline) { if (preg_match('/^\[skip:\s*(\d+)\]\s*(.*)/is', $errstr, $out)) { @@ -237,5 +247,13 @@ if (CONFIG_DEBUG) { ), 'main'); } +// Redirect if not accessed via proper domain +if ($_SERVER['REQUEST_METHOD'] === 'GET' && ($host = Util::shouldRedirectDomain()) !== null) { + Render::addTemplate('domain-redirect-check', [ + 'magic' => md5((string)floor(Util::osUptime() / 3) . Property::getServerIp() . serialize(Property::getVmStoreConfig())), + 'host' => $host, + ], 'main'); +} + // Send page to client. Render::output(); diff --git a/modules-available/main/templates/domain-redirect-check.html b/modules-available/main/templates/domain-redirect-check.html new file mode 100644 index 00000000..6019c045 --- /dev/null +++ b/modules-available/main/templates/domain-redirect-check.html @@ -0,0 +1,13 @@ +<script> + document.addEventListener('DOMContentLoaded', function() { + var url = window.location.protocol + '//{{host}}' + window.location.pathname + '?do=_https_magic'; + var magic = "{{magic}}"; + $.post(url, function (data) { + if (!data) return; + if (data.a === magic || data.b === magic) { + // Seems safe to redirect + window.location.host = '{{host}}'; + } + }, 'json'); + }); +</script>
\ No newline at end of file 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 @@ +<?php + +if (mt_rand(1, 36) === 1 && Property::get(WebInterface::PROP_TYPE) === 'acme') { + Acme::renew(); +}
\ No newline at end of file diff --git a/modules-available/webinterface/hooks/main-warning.inc.php b/modules-available/webinterface/hooks/main-warning.inc.php new file mode 100644 index 00000000..88abc002 --- /dev/null +++ b/modules-available/webinterface/hooks/main-warning.inc.php @@ -0,0 +1,8 @@ +<?php + +if (Property::get(WebInterface::PROP_TYPE) === 'acme') { + $err = Acme::getLastError(); + if (!empty($err)) { + Message::addError('webinterface.mw-acme-errors', true); + } +}
\ No newline at end of file diff --git a/modules-available/webinterface/inc/acme.inc.php b/modules-available/webinterface/inc/acme.inc.php new file mode 100644 index 00000000..c23578cc --- /dev/null +++ b/modules-available/webinterface/inc/acme.inc.php @@ -0,0 +1,164 @@ +<?php + +class Acme +{ + + const PROP_ERROR = 'acme.error-string'; + const PROP_PROVIDER = 'acme.provider'; + const PROP_KEY_ID = 'acme.key-id'; + const PROP_HMAC_KEY = 'acme.hmac-key'; + const PROP_DOMAINS = 'acme.domains'; + const PROP_MAIL = 'acme.mail'; + const VALID_PROVIDERS = [ + 'letsencrypt' => "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 @@ +<?php + +class WebInterface +{ + public const PROP_TYPE = 'webinterface.https-type'; + public const PROP_HSTS = 'webinterface.https-hsts'; + public const PROP_REDIRECT = 'webinterface.https-redirect'; + public const PROP_CURRENT_CERT_DOMAINS = 'webinterface.https-domains'; + + public const PROP_REDIRECT_DOMAIN = 'webinterface.redirect-domain'; + + /** + * Read data all handled domains from current certificate. + * SAN takes precedence, if empty, we fall back to CN. + * @param string[] $certDomains + * @return bool success reading? + */ + public static function extractCurrentCertData(array &$certDomains, int &$expireTimestamp, string &$issuer): bool + { + if (!is_readable('/etc/lighttpd/pub-cert.pem')) + return false; + $cert = openssl_x509_parse(file_get_contents('/etc/lighttpd/pub-cert.pem')); + if ($cert === false) + return false; + // Domains + $certDomains = []; + if (isset($cert['extensions']['subjectAltName'])) { + $doms = preg_split('/[,\s]+/', $cert['extensions']['subjectAltName'], -1, PREG_SPLIT_NO_EMPTY); + foreach ($doms as $d) { + if (substr_compare($d, 'DNS:', 0, 4, true) !== 0) + continue; + $d = substr($d, 4); + if (preg_match('/^([a-z0-9_-]|\*\.)[a-z0-9_.-]+$/', $d) && !in_array($d, $certDomains)) { + $certDomains[] = $d; + } + } + } + if (empty($certDomains) && isset($cert['subject']['CN']) + && preg_match('/^([a-z0-9_-]|\*\.)[a-z0-9_.-]+$/', $cert['subject']['CN'])) { + $certDomains[] = $cert['subject']['CN']; + } + foreach ($certDomains as &$d) { + if ($d[-1] === '.') { + $d = substr($d, 0, -1); + } + } + Property::set(self::PROP_CURRENT_CERT_DOMAINS, implode(' ', $certDomains)); + // Expire time + $expireTimestamp = $cert['validTo_time_t'] ?? 0; + // Issuer + $issuer = $cert['issuer']['CN'] ?? 'Unknown'; + return true; + } + + public static function setDomainRedirect(bool $enable): void + { + Property::set(self::PROP_REDIRECT_DOMAIN, $enable ? '1' : false); + } + + public static function getDomainRedirect(): bool + { + return !empty(Property::get(self::PROP_REDIRECT_DOMAIN, false)); + } + +}
\ No newline at end of file diff --git a/modules-available/webinterface/lang/de/messages.json b/modules-available/webinterface/lang/de/messages.json index 24ca7d5f..3f1d5ba4 100644 --- a/modules-available/webinterface/lang/de/messages.json +++ b/modules-available/webinterface/lang/de/messages.json @@ -1,6 +1,12 @@ { + "acme-invalid-provider": "Ung\u00fcltiger ACME-Anbieter: {{0}}", + "acme-no-domains": "Keine Domains angegeben", + "acme-no-mail": "Keine administrative Mailadresse angegeben", + "acme-no-provider": "Kein ACME-Anbieter ausgew\u00e4hlt", "https-on-cert-missing": "HTTPS ist aktiviert, das Zertifikat ist jedoch nicht vorhanden. Bitte nehmen Sie die HTTPS-Konfiguration erneut vor.", "https-used-without-cert": "HTTPS wird gerade verwendet, obwohl kein Zertifikat installiert ist. Falls Sie die Webserver-Konfiguration manuell angepasst haben, um HTTPS zu aktivieren beachten Sie bitte, dass die Konfiguration bei einem zuk\u00fcnftigen Server-Update ohne Nachfrage \u00fcberschrieben werden k\u00f6nnte.", "https-want-off-is-used": "HTTPS wird gerade verwendet, obwohl es laut Einstellungen deaktiviert ist. Merkw\u00fcrdig.", - "https-want-redirect-is-plain": "Weiterleitung von HTTP auf HTTPS ist aktiviert, trotzdem scheint die Verbindung Ihres Browsers mit dem Server unverschl\u00fcsselt zu sein. Nehmen Sie die Konfiguration erneut vor und wenden Sie sich an den Support, wenn das Problem weiterhin besteht." + "https-want-redirect-is-plain": "Weiterleitung von HTTP auf HTTPS ist aktiviert, trotzdem scheint die Verbindung Ihres Browsers mit dem Server unverschl\u00fcsselt zu sein. Nehmen Sie die Konfiguration erneut vor und wenden Sie sich an den Support, wenn das Problem weiterhin besteht.", + "invalid-domain": "Ung\u00fcltige Domain: {{0}}", + "mw-acme-errors": "Fehler beim erneuern\/abrufen des Zertifikats via ACME" }
\ No newline at end of file diff --git a/modules-available/webinterface/lang/de/template-tags.json b/modules-available/webinterface/lang/de/template-tags.json index d00b92e4..e5a149b4 100644 --- a/modules-available/webinterface/lang/de/template-tags.json +++ b/modules-available/webinterface/lang/de/template-tags.json @@ -1,25 +1,44 @@ { + "lang_acmeCreateNewHint": "Hier k\u00f6nnen Sie ein Zertifikat via ACMEv2 erzeugen lassen. Daf\u00fcr ist es erforderlich, dass der Satellitenserver auf Port 80 erreichbar ist, genauer gesagt der Pfad \"\/.well-known\/acme-challenge\". Eine Ausnahme bietet hier GEANT\/Sectigo, welches durch die Verwendung eines Accounts an bestimmte Domains gebunden werden kann, und dann keine HTTP-Verifizierung erfordert.", + "lang_acmeDomains": "Anzufordernde Domains (eine pro Zeile)", + "lang_acmeHmacKey": "HMAC-Key (optional)", + "lang_acmeKeyId": "Key ID (optional)", + "lang_acmeKidKeyHint": "Sofern der Anbieter die Verwendung eines Kontos (\"external account binding\") erfordert (GEANT), geben Sie hier die Daten in Form der Key ID und des HMAC-Keys ein.", + "lang_acmeMail": "Die E-Mail-Adresse des Zust\u00e4ndigen", + "lang_acmeProvider": "Zu verwendender Anbieter", + "lang_acmeSelected": "Das aktuelle Zertifikat wurde via ACME erstellt.", + "lang_acmeWipeAll": "Alle hinterlegten Daten l\u00f6schen und Account etc. von neuem anfordern", + "lang_acmeWipeAllHint": "W\u00e4hlen Sie diese Option aus, wenn das Zertifikat nicht verl\u00e4ndert werden kann, oder es Probleme beim \u00c4ndern und Speichern der Daten gibt. Beachten Sie, dass einige Anbieter ein Ratelimit haben, Sie also nicht zu oft\/schnell hintereinander ein neues Zertifikat anfordern sollten.", "lang_applyingSettings": "Anwenden der Einstellungen", "lang_caChain": "Optional k\u00f6nnen Sie hier die zum Zertifikat geh\u00f6rende Zertifikatkette (CA-Chain) einf\u00fcgen. Dies wird ben\u00f6tigt, wenn das Zertifikat nicht direkt von einer der in Browsern mitgeliferten CAs signiert wurde. Die Datei enth\u00e4lt ein oder meherere Zertifikatsbl\u00f6cke, im gleichen Format wie das oben gezeigte Zertifikat.", + "lang_certExpireTime": "Zertifikat l\u00e4uft ab", + "lang_certIssuer": "Aussteller", "lang_certificate": "Bitte f\u00fcgen Sie hier das Zertifikat ein. Das Zertifikat wird im Base64-codierten x509-Format erwartet (manchmal pem genannt). Es sieht in etwa wie folgt aus:", + "lang_changeCertificateOption": "Zertifikat wechseln", + "lang_currentCertDomains": "Im aktuell verwendeten Zertifikat hinterlegte Domains (SAN\/CN)", "lang_customCert": "Eigenes Zertifikat verwenden", "lang_customization": "Designanpassung", "lang_customizationDesc": "Hier k\u00f6nnen Sie kleine optische Anpassungen an der Weboberfl\u00e4che vornehmen. Dies ist hilfreich, wenn Sie z.B. ein Produktiv- und ein Testsystem betreiben und verhindern m\u00f6chten, dass Sie versehentlich in der falschen Weboberfl\u00e4che kritische Einstellungen ver\u00e4ndern.", + "lang_generalHttpsOptions": "Allgemeine Optionen", "lang_generatedSelected": "Der Server verwendet zur Zeit ein automatisch generiertes, selbst signiertes Zertifikat.", "lang_hidePasswords": "Passw\u00f6rter maskieren", "lang_httpsDescription": "Hier k\u00f6nnen Sie festlegen, ob das Web-Interface auch per HTTPS erreichbar sein soll, und welches Zertifikat daf\u00fcr verwendet werden soll.", + "lang_httpsOptionNoChange": "Nichts \u00e4ndern", "lang_httpsRedirect": "Anfragen per HTTP immer auf HTTPS umleiten (sofern aktiviert)", "lang_httpsSettings": "HTTPS-Konfiguration", "lang_installAndRestart": "Zertifikat installieren und Webserver neustarten", "lang_logoBackground": "Hintergrundfarbe des Logos", "lang_moduleHeading": "Web-Schnittstelle", + "lang_msgAcmeFailed": "ACME-Abruf fehlgeschlagen", "lang_noHttps": "HTTPS wieder deaktivieren, aktuelles Zertifikat l\u00f6schen", "lang_offSelected": "HTTPS ist derzeit deaktiviert.", + "lang_optionAcme": "ACMEv2", "lang_pageTitlePrefix": "Pr\u00e4fix f\u00fcr den Seitentitel", "lang_passwordFields": "Passwortfelder", "lang_passwordsDescription": "Legen Sie fest, ob Passwortfelder in der Web-Schnittstelle maskiert werden, oder ob Ihr Inhalt sichtbar sein soll. Wenn Sie die Schnittstelle in einer sicheren Umgebung nutzen (keine neugierigen Augen), kann dies den Komfort erh\u00f6hen. Das Passwortfeld der Anmeldemaske ist von dieser Einstellung ausgenommen.", "lang_privateKey": "Bitte f\u00fcgen Sie hier den privaten Schl\u00fcssel ein, der zum obigen Zertifikat geh\u00f6rt. Er muss ebenfalls im \"pem\"-Format vorliegen, und sieht wie folgt aus:", "lang_randomCert": "Neues selbstsigniertes Zertifikat generieren", + "lang_redirectDomain": "Bei Browserzugriff \u00fcber eine andere Domain oder per IP auf die Prim\u00e4rdomain des Zertifikats umleiten", "lang_showPasswords": "Passw\u00f6rter anzeigen", "lang_suppliedSelected": "Der Server verwendet zur Zeit ein \u00fcber die Option \"Eigenes Zertifikat\" hochgeladenes Zertifikat.", "lang_unknownSelected": "Unbekanntes oder ung\u00fcltiges Zertifikat vorhanden. Wahrscheinlich wurde der Server von einer alten Version aktualisiert. Um diese Meldung zu entfernen, die HTTPS-Konfiguration erneut vornehmen.", diff --git a/modules-available/webinterface/lang/en/messages.json b/modules-available/webinterface/lang/en/messages.json index 803dc73f..edccbd47 100644 --- a/modules-available/webinterface/lang/en/messages.json +++ b/modules-available/webinterface/lang/en/messages.json @@ -1,6 +1,12 @@ { + "acme-invalid-provider": "Invalid ACME provider: {{0}}", + "acme-no-domains": "No domains specified", + "acme-no-mail": "No technical mail contact specified", + "acme-no-provider": "No ACME provider selected", "https-on-cert-missing": "HTTPS is enabled, but the certificate is missing. Please redo the configuration steps.", "https-used-without-cert": "HTTPS is currently used, but there is no certificate installed. If you tweaked the web server's configuration manually to enable HTTPS bear in mind that a future server update might overwrite your modified configuration without asking.", "https-want-off-is-used": "HTTPS is currently in use although it is disabled in the settings. Very weird indeed.", - "https-want-redirect-is-plain": "HTTP to HTTPS redirects are enabled, but the connection from your browser appears to be unencrypted. Please redo the HTTPS configuration and contact support if the problem persists." + "https-want-redirect-is-plain": "HTTP to HTTPS redirects are enabled, but the connection from your browser appears to be unencrypted. Please redo the HTTPS configuration and contact support if the problem persists.", + "invalid-domain": "Invalid domain: {{0}}", + "mw-acme-errors": "Error renewing\/requesting certificate via ACME" }
\ No newline at end of file diff --git a/modules-available/webinterface/lang/en/template-tags.json b/modules-available/webinterface/lang/en/template-tags.json index 2378261b..a1cbff7f 100644 --- a/modules-available/webinterface/lang/en/template-tags.json +++ b/modules-available/webinterface/lang/en/template-tags.json @@ -1,25 +1,44 @@ { + "lang_acmeCreateNewHint": "Here you can create a certificate using an ACMEv2 provider. This requires making this server accessible on port 80, more specifically the path \"\/.well-known\/acme-challenge\" needs to be accessible via HTTP. An exeption is GEANT\/Sectigo, which uses accounts that are verified for certain domains and don't require any verification via HTTP.", + "lang_acmeDomains": "Domains to request (one per line)", + "lang_acmeHmacKey": "HMAC-KEY (options)", + "lang_acmeKeyId": "Key ID (optional)", + "lang_acmeKidKeyHint": "If the provider requires an account (\"external account binding\"), i.e. GEANT, please specify it here.", + "lang_acmeMail": "Technical contact e-mail address", + "lang_acmeProvider": "Provider to use", + "lang_acmeSelected": "Current certificate was generated via ACME.", + "lang_acmeWipeAll": "Wipe all existing data and request everything anew", + "lang_acmeWipeAllHint": "Select this option if you experience trouble renewing an existing certificate, or if a previous registration attempt left stale data. Please be aware that rate limits apply with some providers, so you shouldn't issue too many requests over a short period of time.", "lang_applyingSettings": "Applying settings", "lang_caChain": "Here you can paste an optional certificate chain. It should only be required if you have a certificate that was not directly signed by a certificate authority known by the browsers. It should contain one or more certificate blocks, looking just like the certificate above.", + "lang_certExpireTime": "Certificate expires", + "lang_certIssuer": "Issuer", "lang_certificate": "Please paste your certificate below. It has to be in base64 encoded x509 format (sometimes called pem). It should look something like this:", + "lang_changeCertificateOption": "Change certificate", + "lang_currentCertDomains": "Domains listed in current certificate (SAN\/CN)", "lang_customCert": "Supply own certificate", "lang_customization": "Design customization", "lang_customizationDesc": "Here you can make small changes to the design of the web interface. This might help to prevent accidents if you run multiple satellite servers and have an open tab for all of them at the same time.", + "lang_generalHttpsOptions": "General options", "lang_generatedSelected": "The server is currently using an automatically generated, self-signed certificate.", "lang_hidePasswords": "Mask passwords", "lang_httpsDescription": "Here you can set whether the web interface should be accessible via https. You can choose if you want to use a random self signed certificate, or supply your own.", + "lang_httpsOptionNoChange": "Don't change", "lang_httpsRedirect": "Redirect incoming HTTP requests to HTTPS (if enabled).", "lang_httpsSettings": "HTTPS settings", "lang_installAndRestart": "Installing certificate and restarting web server", "lang_logoBackground": "Logo background color", "lang_moduleHeading": "Web Interface", + "lang_msgAcmeFailed": "ACME failed", "lang_noHttps": "Disable HTTPS, delete current certificate", "lang_offSelected": "HTTPS is currently disabled.", + "lang_optionAcme": "ACMEv2", "lang_pageTitlePrefix": "Page title prefix", "lang_passwordFields": "Password fields", "lang_passwordsDescription": "Set whether password fields should be masked or not. The password field of the login page to the web interface is always masked.", "lang_privateKey": "Please paste the private key belonging to the certificate here. It has to be in \"pem\" format too, which should look like this:", "lang_randomCert": "Generate new self-signed certificate", + "lang_redirectDomain": "Redirect browser to certificate's primary domain if accessed via unknown domain name or IP address", "lang_showPasswords": "Show passwords", "lang_suppliedSelected": "The server is currently using a certificate supplied using the \"Supply own certificate\" option.", "lang_unknownSelected": "Unknown or invalid certificate in use. The server was probably updated from an old version while HTTPS was already enabled. Redo the HTTPS configuration steps to get rid of this message.", diff --git a/modules-available/webinterface/page.inc.php b/modules-available/webinterface/page.inc.php index ca52c2ab..35f21b38 100644 --- a/modules-available/webinterface/page.inc.php +++ b/modules-available/webinterface/page.inc.php @@ -3,10 +3,6 @@ class Page_WebInterface extends Page { - const PROP_REDIRECT = 'webinterface.https-redirect'; - const PROP_TYPE = 'webinterface.https-type'; - const PROP_HSTS = 'webinterface.https-hsts'; - protected function doPreprocess() { User::load(); @@ -27,6 +23,7 @@ class Page_WebInterface extends Page User::assertPermission("edit.design"); $this->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 @@ +<div class="alert alert-danger"> + {{lang_msgAcmeFailed}} +</div> +<pre>{{error}}</pre> +<hr>
\ No newline at end of file diff --git a/modules-available/webinterface/templates/heading.html b/modules-available/webinterface/templates/heading.html index 59a8cf6b..b759eea9 100644 --- a/modules-available/webinterface/templates/heading.html +++ b/modules-available/webinterface/templates/heading.html @@ -4,6 +4,7 @@ <script><!-- document.addEventListener('DOMContentLoaded', function () { var $boxCustom = $('#wcustom'); + var $boxAcme = $('#wacme'); $('.row-select').click(function (e) { if (e.target.tagName === 'INPUT') return; @@ -11,9 +12,13 @@ document.addEventListener('DOMContentLoaded', function () { }); $('#cert-options').find('input').change(function(e) { $boxCustom.toggle(e.target.id === 'mcustom'); + $boxAcme.toggle(e.target.id === 'macme'); }); if ($('#mcustom').is(':checked')) { $boxCustom.show(); } + if ($('#macme').is(':checked')) { + $boxAcme.show(); + } }); //--></script>
\ No newline at end of file diff --git a/modules-available/webinterface/templates/httpd-restart.html b/modules-available/webinterface/templates/httpd-restart.html index ac4e726b..75d86ad3 100644 --- a/modules-available/webinterface/templates/httpd-restart.html +++ b/modules-available/webinterface/templates/httpd-restart.html @@ -19,7 +19,9 @@ function slxRestartCb(task) { } else { console.log('Disabling because ' + task.statusCode); clearInterval(slxRedirTimer); - window.location.replace(window.location.href.replace('&show=httpsupdate', '')); + if (task.statusCode === 'TASK_FINISHED' && (!task.data || !task.data.error || !task.data.error.length)) { + window.location.replace(window.location.href.replace('&show=httpsupdate', '')); + } } } diff --git a/modules-available/webinterface/templates/https.html b/modules-available/webinterface/templates/https.html index ad36e9e5..5198c299 100644 --- a/modules-available/webinterface/templates/https.html +++ b/modules-available/webinterface/templates/https.html @@ -6,10 +6,10 @@ <div class="panel-body"> <p>{{lang_httpsDescription}}</p> {{^httpsUsed}} - {{lang_youreNotUsingHttps}} + <p>{{lang_youreNotUsingHttps}}</p> {{/httpsUsed}} {{#httpsUsed}} - {{lang_youreUsingHttps}} + <p>{{lang_youreUsingHttps}}</p> {{/httpsUsed}} <div class="text-info slx-bold"> {{#offSelected}} @@ -24,8 +24,80 @@ {{#suppliedSelected}} <p>{{lang_suppliedSelected}}</p> {{/suppliedSelected}} + {{#acmeSelected}} + <p>{{lang_acmeSelected}}</p> + {{/acmeSelected}} </div> + <table class="slx-table"> + {{#certIssuer}} + <tr><td>{{lang_certIssuer}}:</td><td>{{.}}</td></tr> + {{/certIssuer}} + {{#certExpire}} + <tr><td>{{lang_certExpireTime}}:</td><td class="{{certExpireClass}}">{{.}}</td></tr> + {{/certExpire}} + {{#certDomains.0}} + <tr> + <td style="vertical-align:top">{{lang_currentCertDomains}}:</td> + <td> + {{#certDomains}} + <div>{{.}}</div> + {{/certDomains}} + </td> + </tr> + {{/certDomains.0}} + </table> + + <label>{{lang_generalHttpsOptions}}</label> + <div class="input-group row-select"> + <span class="input-group-addon"> + <span class="checkbox"> + <input id="httpsredirect" type="checkbox" name="httpsredirect" value="on" {{redirect_checked}} {{perms.edit.https.disabled}}> + <label></label> + </span> + </span> + <span class="form-control"> + {{lang_httpsRedirect}} + </span> + </div> + <div class="input-group row-select"> + <span class="input-group-addon"> + <span class="checkbox"> + <input id="usehsts" type="checkbox" name="usehsts" value="on" {{hsts_checked}} {{perms.edit.https.disabled}}> + <label></label> + </span> + </span> + <span class="form-control"> + {{lang_useHsts}} + </span> + </div> + <div class="input-group row-select"> + <span class="input-group-addon"> + <span class="checkbox"> + <input id="redirdomain" type="checkbox" name="redirdomain" value="on" {{redirdomain_checked}} {{perms.edit.https.disabled}}> + <label></label> + </span> + </span> + <span class="form-control"> + {{lang_redirectDomain}} + </span> + </div> + <br> + + <label>{{lang_changeCertificateOption}}</label> <div id="cert-options"> + + <div class="input-group row-select"> + <span class="input-group-addon"> + <span class="radio"> + <input id="https-do-nothing" type="radio" name="mode" value="noop" {{perms.edit.https.disabled}} checked> + <label></label> + </span> + </span> + <span class="form-control"> + {{lang_httpsOptionNoChange}} + </span> + </div> + {{#httpsEnabled}} <div class="input-group row-select"> <span class="input-group-addon"> @@ -39,6 +111,7 @@ </span> </div> {{/httpsEnabled}} + <div class="input-group row-select"> <span class="input-group-addon"> <span class="radio"> @@ -50,6 +123,7 @@ {{lang_randomCert}} </span> </div> + <div class="input-group row-select"> <span class="input-group-addon"> <span class="radio"> @@ -61,6 +135,18 @@ {{lang_customCert}} </span> </div> + + <div class="input-group row-select"> + <span class="input-group-addon"> + <span class="radio"> + <input id="macme" type="radio" name="mode" value="acme" {{perms.edit.https.disabled}}> + <label></label> + </span> + </span> + <span class="form-control"> + {{lang_optionAcme}} + </span> + </div> </div> <div class="well well-sm collapse" id="wcustom"> @@ -85,29 +171,45 @@ MIIFfTCCA... <hr> </div> - <br> - <div class="input-group row-select"> - <span class="input-group-addon"> - <span class="checkbox"> - <input id="httpsredirect" type="checkbox" name="httpsredirect" value="on" {{redirect_checked}} {{perms.edit.https.disabled}}> - <label></label> - </span> - </span> - <span class="form-control"> - {{lang_httpsRedirect}} - </span> - </div> - <div class="input-group row-select"> - <span class="input-group-addon"> - <span class="checkbox"> - <input id="usehsts" type="checkbox" name="usehsts" value="on" {{hsts_checked}} {{perms.edit.https.disabled}}> - <label></label> - </span> - </span> - <span class="form-control"> - {{lang_useHsts}} - </span> + <div class="well well-sm collapse" id="wacme"> + <p>{{lang_acmeCreateNewHint}}</p> + <div class="form-group"> + <label for="acme-provider">{{lang_acmeProvider}}</label> + <select class="form-control" name="acme-provider" id="acme-provider"> + {{#acmeProviders}} + <option value="{{id}}" {{selected}}>{{name}}</option> + {{/acmeProviders}} + </select> + </div> + <div class="form-group"> + <label for="acme-mail">{{lang_acmeMail}}</label> + <input class="form-control" name="acme-mail" id="acme-mail" value="{{acmeMail}}"> + </div> + <div class="form-group"> + <label for="acme-domains">{{lang_acmeDomains}}</label> + <textarea rows="4" class="form-control" name="acme-domains" id="acme-domains">{{acmeDomains}}</textarea> + </div> + <hr> + <div class="slx-space"> + {{lang_acmeKidKeyHint}} + </div> + <div class="form-group"> + <label for="acme-kid">{{lang_acmeKeyId}}</label> + <input class="form-control" type="text" id="acme-kid" name="acme-kid" value="{{acmeKeyId}}"> + </div> + <div class="form-group"> + <label for="acme-hmac-key">{{lang_acmeHmacKey}}</label> + <input class="form-control" type="text" id="acme-hmac-key" name="acme-hmac-key" + value="{{acmeHmacKey}}"> + </div> + <div class="slx-space"></div> + <div class="checkbox"> + <input type="checkbox" name="acme-wipe-all" value="1" id="acme-wipe-all"> + <label for="acme-wipe-all">{{lang_acmeWipeAll}}</label> + </div> + <i>{{lang_acmeWipeAllHint}}</i> </div> + <br> <div class="pull-right"> |