summaryrefslogtreecommitdiffstats
path: root/application/forms/RedisConfigForm.php
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--application/forms/RedisConfigForm.php606
1 files changed, 606 insertions, 0 deletions
diff --git a/application/forms/RedisConfigForm.php b/application/forms/RedisConfigForm.php
new file mode 100644
index 0000000..bd3db9c
--- /dev/null
+++ b/application/forms/RedisConfigForm.php
@@ -0,0 +1,606 @@
+<?php
+
+/* Icinga DB Web | (c) 2020 Icinga GmbH | GPLv2 */
+
+namespace Icinga\Module\Icingadb\Forms;
+
+use Closure;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Exception\NotWritableError;
+use Icinga\File\Storage\LocalFileStorage;
+use Icinga\File\Storage\TemporaryLocalFileStorage;
+use Icinga\Forms\ConfigForm;
+use Icinga\Module\Icingadb\Common\IcingaRedis;
+use Icinga\Web\Form;
+use ipl\Validator\PrivateKeyValidator;
+use ipl\Validator\X509CertValidator;
+use Zend_Validate_Callback;
+
+class RedisConfigForm extends ConfigForm
+{
+ public function init()
+ {
+ $this->setSubmitLabel(t('Save Changes'));
+ $this->setValidatePartial(true);
+ }
+
+ public function createElements(array $formData)
+ {
+ $this->addElement('checkbox', 'redis_tls', [
+ 'label' => t('Use TLS'),
+ 'description' => t('Encrypt connections to Redis via TLS'),
+ 'autosubmit' => true
+ ]);
+
+ $this->addElement('hidden', 'redis_ca');
+ $this->addElement('hidden', 'redis_cert');
+ $this->addElement('hidden', 'redis_key');
+ $this->addElement('hidden', 'clear_redis_ca', ['ignore' => true]);
+ $this->addElement('hidden', 'clear_redis_cert', ['ignore' => true]);
+ $this->addElement('hidden', 'clear_redis_key', ['ignore' => true]);
+
+ $useTls = isset($formData['redis_tls']) && $formData['redis_tls'];
+ if ($useTls) {
+ $this->addElement('textarea', 'redis_ca_pem', [
+ 'label' => t('Redis CA Certificate'),
+ 'description' => sprintf(
+ t('Verify the peer using this PEM-encoded CA certificate ("%s...")'),
+ '-----BEGIN CERTIFICATE-----'
+ ),
+ 'required' => true,
+ 'ignore' => true,
+ 'validators' => [$this->wrapIplValidator(X509CertValidator::class, 'redis_ca_pem')]
+ ]);
+
+ $this->addElement('textarea', 'redis_cert_pem', [
+ 'label' => t('Client Certificate'),
+ 'description' => sprintf(
+ t('Authenticate using this PEM-encoded client certificate ("%s...")'),
+ '-----BEGIN CERTIFICATE-----'
+ ),
+ 'ignore' => true,
+ 'allowEmpty' => false,
+ 'validators' => [
+ $this->wrapIplValidator(X509CertValidator::class, 'redis_cert_pem', function ($value) {
+ if (! $value && $this->getElement('redis_key_pem')->getValue()) {
+ $this->getElement('redis_cert_pem')->addError(t(
+ 'Either both a client certificate and its private key or none of them must be specified'
+ ));
+ }
+
+ return true;
+ })
+ ]
+ ]);
+
+ $this->addElement('textarea', 'redis_key_pem', [
+ 'label' => t('Client Key'),
+ 'description' => sprintf(
+ t('Authenticate using this PEM-encoded private key ("%s...")'),
+ '-----BEGIN PRIVATE KEY-----'
+ ),
+ 'ignore' => true,
+ 'allowEmpty' => false,
+ 'validators' => [
+ $this->wrapIplValidator(PrivateKeyValidator::class, 'redis_key_pem', function ($value) {
+ if (! $value && $this->getElement('redis_cert_pem')->getValue()) {
+ $this->getElement('redis_key_pem')->addError(t(
+ 'Either both a client certificate and its private key or none of them must be specified'
+ ));
+ }
+
+ return true;
+ })
+ ]
+ ]);
+ }
+
+ $this->addDisplayGroup(
+ ['redis_tls', 'redis_insecure', 'redis_ca_pem', 'redis_cert_pem', 'redis_key_pem'],
+ 'redis',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'Secure connections. If you are running a high availability zone'
+ . ' with two masters, the following applies to both of them.'
+ ),
+ 'legend' => t('General')
+ ]
+ );
+
+ if (isset($formData['skip_validation']) && $formData['skip_validation']) {
+ // In case another error occured and the checkbox was displayed before
+ static::addSkipValidationCheckbox($this);
+ }
+
+ if ($useTls && isset($formData['redis_insecure']) && $formData['redis_insecure']) {
+ // In case another error occured and the checkbox was displayed before
+ static::addInsecureCheckboxIfTls($this);
+ }
+
+ $this->addElement('text', 'redis1_host', [
+ 'description' => t('Redis Host'),
+ 'label' => t('Redis Host'),
+ 'required' => true
+ ]);
+
+ $this->addElement('number', 'redis1_port', [
+ 'description' => t('Redis Port'),
+ 'label' => t('Redis Port'),
+ 'placeholder' => 6380
+ ]);
+
+ $this->addElement('password', 'redis1_password', [
+ 'description' => t('Redis Password'),
+ 'label' => t('Redis Password'),
+ 'renderPassword' => true,
+ 'autocomplete' => 'new-password'
+ ]);
+
+ $this->addDisplayGroup(
+ ['redis1_host', 'redis1_port', 'redis1_password'],
+ 'redis1',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'Redis connection details of your Icinga host. If you are running a high'
+ . ' availability zone with two masters, this is your configuration master.'
+ ),
+ 'legend' => t('Primary Icinga Master')
+ ]
+ );
+
+ $this->addElement('text', 'redis2_host', [
+ 'description' => t('Redis Host'),
+ 'label' => t('Redis Host'),
+ ]);
+
+ $this->addElement('number', 'redis2_port', [
+ 'description' => t('Redis Port'),
+ 'label' => t('Redis Port'),
+ 'placeholder' => 6380
+ ]);
+
+ $this->addElement('password', 'redis2_password', [
+ 'description' => t('Redis Password'),
+ 'label' => t('Redis Password'),
+ 'renderPassword' => true,
+ 'autocomplete' => 'new-password'
+ ]);
+
+ $this->addDisplayGroup(
+ ['redis2_host', 'redis2_port', 'redis2_password'],
+ 'redis2',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div']],
+ [
+ 'Description',
+ ['tag' => 'span', 'class' => 'description', 'placement' => 'prepend']
+ ],
+ 'Fieldset'
+ ],
+ 'description' => t(
+ 'If you are running a high availability zone with two masters,'
+ . ' please provide the Redis connection details of the secondary master.'
+ ),
+ 'legend' => t('Secondary Icinga Master')
+ ]
+ );
+ }
+
+ public static function addSkipValidationCheckbox(Form $form)
+ {
+ $form->addElement(
+ 'checkbox',
+ 'skip_validation',
+ [
+ 'order' => 0,
+ 'ignore' => true,
+ 'label' => t('Skip Validation'),
+ 'description' => t(
+ 'Check this box to enforce changes without validating that Redis is available.'
+ )
+ ]
+ );
+ }
+
+ public static function addInsecureCheckboxIfTls(Form $form)
+ {
+ if ($form->getElement('redis_insecure') !== null) {
+ return;
+ }
+
+ $form->addElement(
+ 'checkbox',
+ 'redis_insecure',
+ [
+ 'order' => 1,
+ 'label' => t('Insecure'),
+ 'description' => t('Don\'t verify the peer')
+ ]
+ );
+
+ $displayGroup = $form->getDisplayGroup('redis');
+ $elements = $displayGroup->getElements();
+ $elements['redis_insecure'] = $form->getElement('redis_insecure');
+ $displayGroup->setElements($elements);
+ }
+
+ public function isValid($formData)
+ {
+ if (! parent::isValid($formData)) {
+ return false;
+ }
+
+ if (($el = $this->getElement('skip_validation')) === null || ! $el->isChecked()) {
+ if (! static::checkRedis($this)) {
+ if ($el === null) {
+ static::addSkipValidationCheckbox($this);
+
+ if ($this->getElement('redis_tls')->isChecked()) {
+ static::addInsecureCheckboxIfTls($this);
+ }
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function isValidPartial(array $formData)
+ {
+ if (! parent::isValidPartial($formData)) {
+ return false;
+ }
+
+ $useTls = $this->getElement('redis_tls')->isChecked();
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textareaName = 'redis_' . $name . '_pem';
+ $clearName = 'clear_redis_' . $name;
+
+ if ($useTls) {
+ $this->getElement($clearName)->setValue(null);
+
+ $pemPath = $this->getValue('redis_' . $name);
+ if ($pemPath && ! isset($formData[$textareaName]) && ! $formData[$clearName]) {
+ $this->getElement($textareaName)->setValue(@file_get_contents($pemPath));
+ }
+ }
+
+ if (isset($formData[$textareaName]) && ! $formData[$textareaName]) {
+ $this->getElement($clearName)->setValue(true);
+ }
+ }
+
+ if ($this->getElement('backend_validation')->isChecked()) {
+ if (! static::checkRedis($this)) {
+ if ($this->getElement('redis_tls')->isChecked()) {
+ static::addInsecureCheckboxIfTls($this);
+ }
+
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ public function onRequest()
+ {
+ $errors = [];
+
+ $redisConfig = $this->config->getSection('redis');
+ if ($redisConfig->get('tls', false)) {
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $path = $redisConfig->get($name);
+ if (file_exists($path)) {
+ try {
+ $redisConfig[$name . '_pem'] = file_get_contents($path);
+ } catch (Exception $e) {
+ $errors['redis_' . $name . '_pem'] = sprintf(
+ t('Failed to read file "%s": %s'),
+ $path,
+ $e->getMessage()
+ );
+ }
+ }
+ }
+ }
+
+ $connectionConfig = Config::fromIni(
+ join(DIRECTORY_SEPARATOR, [dirname($this->config->getConfigFile()), 'redis.ini'])
+ );
+ $this->config->setSection('redis1', [
+ 'host' => $connectionConfig->get('redis1', 'host'),
+ 'port' => $connectionConfig->get('redis1', 'port'),
+ 'password' => $connectionConfig->get('redis1', 'password')
+ ]);
+ $this->config->setSection('redis2', [
+ 'host' => $connectionConfig->get('redis2', 'host'),
+ 'port' => $connectionConfig->get('redis2', 'port'),
+ 'password' => $connectionConfig->get('redis2', 'password')
+ ]);
+
+ parent::onRequest();
+
+ foreach ($errors as $elementName => $message) {
+ $this->getElement($elementName)->addError($message);
+ }
+ }
+
+ public function onSuccess()
+ {
+ $storage = new LocalFileStorage(Icinga::app()->getStorageDir(
+ join(DIRECTORY_SEPARATOR, ['modules', 'icingadb', 'redis'])
+ ));
+
+ $useTls = $this->getElement('redis_tls')->isChecked();
+ $pem = null;
+ foreach (['ca', 'cert', 'key'] as $name) {
+ $textarea = $this->getElement('redis_' . $name . '_pem');
+ if ($useTls && $textarea !== null && ($pem = $textarea->getValue())) {
+ $pemFile = md5($pem) . '-' . $name . '.pem';
+ if (! $storage->has($pemFile)) {
+ try {
+ $storage->create($pemFile, $pem);
+ } catch (NotWritableError $e) {
+ $textarea->addError($e->getMessage());
+ return false;
+ }
+ }
+
+ $this->getElement('redis_' . $name)->setValue($storage->resolvePath($pemFile));
+ }
+
+ if ((! $useTls && $this->getElement('clear_redis_' . $name)->getValue()) || ($useTls && ! $pem)) {
+ $pemPath = $this->getValue('redis_' . $name);
+ if ($pemPath && $storage->has(($pemFile = basename($pemPath)))) {
+ try {
+ $storage->delete($pemFile);
+ $this->getElement('redis_' . $name)->setValue(null);
+ } catch (NotWritableError $e) {
+ $this->addError($e->getMessage());
+ return false;
+ }
+ }
+ }
+ }
+
+ $connectionConfig = Config::fromIni(
+ join(DIRECTORY_SEPARATOR, [dirname($this->config->getConfigFile()), 'redis.ini'])
+ );
+
+ $redis1Host = $this->getValue('redis1_host');
+ $redis1Port = $this->getValue('redis1_port');
+ $redis1Password = $this->getValue('redis1_password');
+ $redis1Section = $connectionConfig->getSection('redis1');
+ $redis1Section['host'] = $redis1Host;
+ $this->getElement('redis1_host')->setValue(null);
+ $connectionConfig->setSection('redis1', $redis1Section);
+ if (! empty($redis1Port)) {
+ $redis1Section['port'] = $redis1Port;
+ $this->getElement('redis1_port')->setValue(null);
+ } else {
+ $redis1Section['port'] = null;
+ }
+
+ if (! empty($redis1Password)) {
+ $redis1Section['password'] = $redis1Password;
+ $this->getElement('redis1_password')->setValue(null);
+ } else {
+ $redis1Section['password'] = null;
+ }
+
+ if (! array_filter($redis1Section->toArray())) {
+ $connectionConfig->removeSection('redis1');
+ }
+
+ $redis2Host = $this->getValue('redis2_host');
+ $redis2Port = $this->getValue('redis2_port');
+ $redis2Password = $this->getValue('redis2_password');
+ $redis2Section = $connectionConfig->getSection('redis2');
+ if (! empty($redis2Host)) {
+ $redis2Section['host'] = $redis2Host;
+ $this->getElement('redis2_host')->setValue(null);
+ $connectionConfig->setSection('redis2', $redis2Section);
+ } else {
+ $redis2Section['host'] = null;
+ }
+
+ if (! empty($redis2Port)) {
+ $redis2Section['port'] = $redis2Port;
+ $this->getElement('redis2_port')->setValue(null);
+ $connectionConfig->setSection('redis2', $redis2Section);
+ } else {
+ $redis2Section['port'] = null;
+ }
+
+ if (! empty($redis2Password)) {
+ $redis2Section['password'] = $redis2Password;
+ $this->getElement('redis2_password')->setValue(null);
+ } else {
+ $redis2Section['password'] = null;
+ }
+
+ if (! array_filter($redis2Section->toArray())) {
+ $connectionConfig->removeSection('redis2');
+ }
+
+ $connectionConfig->saveIni();
+
+ return parent::onSuccess();
+ }
+
+ public function addSubmitButton()
+ {
+ parent::addSubmitButton()
+ ->getElement('btn_submit')
+ ->setDecorators(['ViewHelper']);
+
+ $this->addElement(
+ 'submit',
+ 'backend_validation',
+ [
+ 'ignore' => true,
+ 'label' => t('Validate Configuration'),
+ 'data-progress-label' => t('Validation In Progress'),
+ 'decorators' => ['ViewHelper']
+ ]
+ );
+ $this->addDisplayGroup(
+ ['btn_submit', 'backend_validation'],
+ 'submit_validation',
+ [
+ 'decorators' => [
+ 'FormElements',
+ ['HtmlTag', ['tag' => 'div', 'class' => 'control-group form-controls']]
+ ]
+ ]
+ );
+
+ return $this;
+ }
+
+ public static function checkRedis(Form $form): bool
+ {
+ $sections = [];
+
+ $storage = new TemporaryLocalFileStorage();
+ foreach (ConfigForm::transformEmptyValuesToNull($form->getValues()) as $sectionAndPropertyName => $value) {
+ if ($value !== null) {
+ list($section, $property) = explode('_', $sectionAndPropertyName, 2);
+ if (in_array($property, ['ca', 'cert', 'key'])) {
+ $storage->create("$property.pem", $value);
+ $value = $storage->resolvePath("$property.pem");
+ }
+
+ $sections[$section][$property] = $value;
+ }
+ }
+
+ $ignoredTextAreas = [
+ 'ca' => 'redis_ca_pem',
+ 'cert' => 'redis_cert_pem',
+ 'key' => 'redis_key_pem'
+ ];
+ foreach ($ignoredTextAreas as $name => $textareaName) {
+ if (($textarea = $form->getElement($textareaName)) !== null) {
+ if (($pem = $textarea->getValue())) {
+ if ($storage->has("$name.pem")) {
+ $storage->update("$name.pem", $pem);
+ } else {
+ $storage->create("$name.pem", $pem);
+ $sections['redis'][$name] = $storage->resolvePath("$name.pem");
+ }
+ } elseif ($storage->has("$name.pem")) {
+ $storage->delete("$name.pem");
+ unset($sections['redis'][$name]);
+ }
+ }
+ }
+
+ $moduleConfig = new Config();
+ $moduleConfig->setSection('redis', $sections['redis']);
+ $redisConfig = new Config();
+ $redisConfig->setSection('redis1', $sections['redis1'] ?? []);
+ $redisConfig->setSection('redis2', $sections['redis2'] ?? []);
+
+ try {
+ $redis1 = IcingaRedis::getPrimaryRedis($moduleConfig, $redisConfig);
+ } catch (Exception $e) {
+ $form->warning(sprintf(
+ t('Failed to connect to primary Redis: %s'),
+ $e->getMessage()
+ ));
+ return false;
+ }
+
+ if (IcingaRedis::getLastIcingaHeartbeat($redis1) === null) {
+ $form->warning(t('Primary connection established but failed to verify Icinga is connected as well.'));
+ return false;
+ }
+
+ try {
+ $redis2 = IcingaRedis::getSecondaryRedis($moduleConfig, $redisConfig);
+ } catch (Exception $e) {
+ $form->warning(sprintf(t('Failed to connect to secondary Redis: %s'), $e->getMessage()));
+ return false;
+ }
+
+ if ($redis2 !== null && IcingaRedis::getLastIcingaHeartbeat($redis2) === null) {
+ $form->warning(t('Secondary connection established but failed to verify Icinga is connected as well.'));
+ return false;
+ }
+
+ $form->info(t('The configuration has been successfully validated.'));
+ return true;
+ }
+
+ /**
+ * Wraps the given IPL validator class into a callback validator
+ * for usage as the only validator of the element given by name.
+ *
+ * @param string $cls IPL validator class FQN
+ * @param string $element Form element name
+ * @param Closure $additionalValidator
+ *
+ * @return array Callback validator
+ */
+ private function wrapIplValidator(string $cls, string $element, Closure $additionalValidator = null): array
+ {
+ return [
+ 'Callback',
+ false,
+ [
+ 'callback' => function ($v) use ($cls, $element, $additionalValidator) {
+ if ($additionalValidator !== null) {
+ if (! $additionalValidator($v)) {
+ return false;
+ }
+ }
+
+ if (! $v) {
+ return true;
+ }
+
+ $validator = new $cls();
+ $valid = $validator->isValid($v);
+
+ if (! $valid) {
+ /** @var Zend_Validate_Callback $callbackValidator */
+ $callbackValidator = $this->getElement($element)->getValidator('Callback');
+
+ $callbackValidator->setMessage(
+ $validator->getMessages()[0],
+ Zend_Validate_Callback::INVALID_VALUE
+ );
+ }
+
+ return $valid;
+ }
+ ]
+ ];
+ }
+}