icingadb-web/application/forms/RedisConfigForm.php
jrauh01 3c7228686c
Print redis config file if saving failed (#1272)
If saving the Redis settings fails due to a write error, the desired
configuration file content is now displayed so that the user can deploy
it manually (Same behavior as with database configuration).

For that a catch block is introduced on the `NotWritableError` that is
added to `IniWriter::write()` in
https://github.com/Icinga/icingaweb2/pull/5404.

Refs https://github.com/Icinga/icingaweb2/pull/5404
Resolves #1269
2025-11-17 13:19:13 +01:00

719 lines
26 KiB
PHP

<?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\Application\Logger;
use Icinga\Exception\AlreadyExistsException;
use Icinga\Exception\IcingaException;
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 Throwable;
use Zend_Config_Exception;
use Zend_Form_Decorator_Abstract;
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);
}
$redisPortDescription = t(sprintf(
"Defaults to %d as the Redis® open source server provided by"
. ' the "icingadb-redis" package listens on that port.',
IcingaRedis::DEFAULT_PORT
));
$redisDatabaseDescription = t(sprintf(
"Numerical database identifier, defaults to %d. This only needs to be changed"
. ' if Icinga 2 is configured to write to another database index.',
IcingaRedis::DEFAULT_DATABASE
));
$redisUsernameDescription = t(
'Authentication username, requires a password being set as well. This field is necessary when'
. ' connecting to a redis instance that requires authentication beyond the default user.'
);
$redisPasswordDescription = t(
'Authentication password. May be used alone when logging in as the'
. ' default user or together with a username.'
);
$this->addElement('text', 'redis1_host', [
'description' => t('Redis Host'),
'label' => t('Redis Host'),
'required' => true
]);
$this->addElement('number', 'redis1_port', [
'description' => $redisPortDescription,
'label' => t('Redis Port'),
'placeholder' => IcingaRedis::DEFAULT_PORT
]);
$this->addElement('number', 'redis1_database', [
'description' => $redisDatabaseDescription,
'label' => t('Redis Database'),
'placeholder' => IcingaRedis::DEFAULT_DATABASE
]);
$this->addElement('text', 'redis1_username', [
'description' => $redisUsernameDescription,
'label' => t('Redis Username')
]);
$this->addElement('password', 'redis1_password', [
'description' => $redisPasswordDescription,
'label' => t('Redis Password'),
'renderPassword' => true,
'autocomplete' => 'new-password'
]);
$this->addDisplayGroup(
['redis1_host', 'redis1_port', 'redis1_database', 'redis1_username', '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' => $redisPortDescription,
'label' => t('Redis Port'),
'placeholder' => IcingaRedis::DEFAULT_PORT
]);
$this->addElement('number', 'redis2_database', [
'description' => $redisDatabaseDescription,
'label' => t('Redis Database'),
'placeholder' => IcingaRedis::DEFAULT_DATABASE
]);
$this->addElement('text', 'redis2_username', [
'description' => $redisUsernameDescription,
'label' => t('Redis Username')
]);
$this->addElement('password', 'redis2_password', [
'description' => $redisPasswordDescription,
'label' => t('Redis Password'),
'renderPassword' => true,
'autocomplete' => 'new-password'
]);
$this->addDisplayGroup(
['redis2_host', 'redis2_port', 'redis2_database', 'redis2_username', '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'),
'database' => $connectionConfig->get('redis1', 'database'),
'username' => $connectionConfig->get('redis1', 'username'),
'password' => $connectionConfig->get('redis1', 'password')
]);
$this->config->setSection('redis2', [
'host' => $connectionConfig->get('redis2', 'host'),
'port' => $connectionConfig->get('redis2', 'port'),
'database' => $connectionConfig->get('redis2', 'database'),
'username' => $connectionConfig->get('redis2', 'username'),
'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;
} catch (AlreadyExistsException $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');
$redis1Database = $this->getValue('redis1_database');
$redis1Username = $this->getValue('redis1_username');
$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($redis1Database)) {
$redis1Section['database'] = $redis1Database;
$this->getElement('redis1_database')->setValue(null);
} else {
$redis1Section['database'] = null;
}
if (! empty($redis1Username)) {
$redis1Section['username'] = $redis1Username;
$this->getElement('redis1_username')->setValue(null);
} else {
$redis1Section['username'] = 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');
$redis2Database = $this->getValue('redis2_database');
$redis2Username = $this->getValue('redis2_username');
$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($redis2Database)) {
$redis2Section['database'] = $redis2Database;
$this->getElement('redis2_database')->setValue(null);
} else {
$redis2Section['database'] = null;
}
if (! empty($redis2Username)) {
$redis2Section['username'] = $redis2Username;
$this->getElement('redis2_username')->setValue(null);
} else {
$redis2Section['username'] = 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');
}
try {
$connectionConfig->saveIni();
} catch (NotWritableError | Zend_Config_Exception $e) {
$this->addDecorator('ViewScript', array(
'viewModule' => 'default',
'viewScript' => 'showConfiguration.phtml',
'errorMessage' => $e->getMessage(),
'configString' => $connectionConfig,
'filePath' => $connectionConfig->getConfigFile(),
'placement' => Zend_Form_Decorator_Abstract::PREPEND
));
return false;
} catch (Throwable $e) {
$this->addError($e->getMessage());
Logger::error($e->getMessage());
Logger::debug(IcingaException::getConfidentialTraceAsString($e));
return false;
}
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;
}
]
];
}
}