diff options
Diffstat (limited to 'modules/setup')
53 files changed, 7482 insertions, 0 deletions
diff --git a/modules/setup/application/clicommands/ConfigCommand.php b/modules/setup/application/clicommands/ConfigCommand.php new file mode 100644 index 0000000..e50333e --- /dev/null +++ b/modules/setup/application/clicommands/ConfigCommand.php @@ -0,0 +1,188 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Clicommands; + +use Icinga\Application\Logger; +use Icinga\Cli\Command; +use Icinga\Exception\IcingaException; +use Icinga\Exception\ProgrammingError; +use Icinga\Module\Setup\Webserver; + +class ConfigCommand extends Command +{ + /** + * Create Icinga Web 2's configuration directory + * + * USAGE: + * + * icingacli setup config directory [options] + * + * OPTIONS: + * + * --config=<directory> Path to Icinga Web 2's configuration files [/etc/icingaweb2] + * + * --mode=<mode> The access mode to use [2770] + * + * --group=<group> Owner group for the configuration directory [icingaweb2] + * + * EXAMPLES: + * + * icingacli setup config directory + * + * icingacli setup config directory --mode=2775 --config=/opt/icingaweb2/etc + */ + public function directoryAction() + { + $configDir = trim($this->params->get('config', $this->app->getConfigDir())); + if (strlen($configDir) === 0) { + $this->fail($this->translate( + 'The argument --config expects a path to Icinga Web 2\'s configuration files' + )); + } + + $group = trim($this->params->get('group', 'icingaweb2')); + if (strlen($group) === 0) { + $this->fail($this->translate( + 'The argument --group expects a owner group for the configuration directory' + )); + } + + $mode = trim($this->params->get('mode', '2770')); + if (strlen($mode) === 0) { + $this->fail($this->translate( + 'The argument --mode expects an access mode for the configuration directory' + )); + } + + if (! file_exists($configDir) && ! @mkdir($configDir, 0755, true)) { + $e = error_get_last(); + $this->fail(sprintf( + $this->translate('Can\'t create configuration directory %s: %s'), + $configDir, + $e['message'] + )); + } + + if (! @chmod($configDir, octdec($mode))) { + $e = error_get_last(); + $this->fail(sprintf( + $this->translate('Can\'t change the mode of the configuration directory to %s: %s'), + $mode, + $e['message'] + )); + } + + if (! @chgrp($configDir, $group)) { + $e = error_get_last(); + $this->fail(sprintf( + $this->translate('Can\'t change the group of %s to %s: %s'), + $configDir, + $group, + $e['message'] + )); + } + + printf($this->translate('Successfully created configuration directory %s') . PHP_EOL, $configDir); + } + + /** + * Create webserver configuration + * + * USAGE: + * + * icingacli setup config webserver <apache|nginx> [options] + * + * OPTIONS: + * + * --path=<urlpath> The URL path to Icinga Web 2 [/icingaweb2] + * + * --root|--document-root=<directory> The directory from which the webserver will serve files + * [/path/to/icingaweb2/public] + * + * --enable-fpm Enable FPM handler for Apache (Nginx is always enabled) + * + * --fpm-uri=<uri> Address or path where to pass requests to FPM [127.0.0.1:9000] + * + * --config=<directory> Path to Icinga Web 2's configuration files [/etc/icingaweb2] + * + * --file=<filename> Write configuration to file [stdout] + * + * EXAMPLES: + * + * icingacli setup config webserver apache + * + * icingacli setup config webserver apache \ + * --path=/icingaweb2 \ + * --document-root=/usr/share/icingaweb2/public \ + * --config=/etc/icingaweb2 + * + * icingacli setup config webserver apache \ + * --file=/etc/apache2/conf.d/icingaweb2.conf + * + * icingacli setup config webserver nginx \ + * --root=/usr/share/icingaweb2/public \ + * --fpm-uri=unix:/var/run/php5-fpm.sock + */ + public function webserverAction() + { + if (($type = $this->params->getStandalone()) === null) { + $this->fail($this->translate('Argument type is mandatory.')); + } + + $webserver = null; + try { + $webserver = Webserver::createInstance($type); + } catch (ProgrammingError $e) { + $this->fail($this->translate('Unknown type') . ': ' . $type); + } + $urlPath = trim($this->params->get('path', $webserver->getUrlPath())); + if (strlen($urlPath) === 0) { + $this->fail($this->translate('The argument --path expects a URL path')); + } + $documentRoot = trim( + $this->params->get('root', $this->params->get('document-root', $webserver->getDocumentRoot())) + ); + if (strlen($documentRoot) === 0) { + $this->fail($this->translate( + 'The argument --root/--document-root expects a directory from which the webserver will serve files' + )); + } + $configDir = trim($this->params->get('config', $webserver->getConfigDir())); + if (strlen($configDir) === 0) { + $this->fail($this->translate( + 'The argument --config expects a path to Icinga Web 2\'s configuration files' + )); + } + + $enableFpm = $this->params->shift('enable-fpm', $webserver->getEnableFpm()); + + $fpmUri = trim($this->params->get('fpm-uri', $webserver->getFpmUri())); + if (empty($fpmUri)) { + $this->fail($this->translate( + 'The argument --fpm-uri expects an address or path where to pass requests to FPM' + )); + } + $webserver + ->setDocumentRoot($documentRoot) + ->setConfigDir($configDir) + ->setUrlPath($urlPath) + ->setEnableFpm($enableFpm) + ->setFpmUri($fpmUri); + $config = $webserver->generate() . "\n"; + if (($file = $this->params->get('file')) !== null) { + if (file_exists($file) === true) { + $this->fail(sprintf($this->translate('File %s already exists. Please delete it first.'), $file)); + } + Logger::info($this->translate('Write %s configuration to file: %s'), $type, $file); + $re = file_put_contents($file, $config); + if ($re === false) { + $this->fail($this->translate('Could not write to file') . ': ' . $file); + } + Logger::info($this->translate('Successfully written %d bytes to file'), $re); + return true; + } + echo $config; + return true; + } +} diff --git a/modules/setup/application/clicommands/TokenCommand.php b/modules/setup/application/clicommands/TokenCommand.php new file mode 100644 index 0000000..f1c30d1 --- /dev/null +++ b/modules/setup/application/clicommands/TokenCommand.php @@ -0,0 +1,89 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Clicommands; + +use Icinga\Cli\Command; + +/** + * Maintain the setup wizard's authentication + * + * The token command allows you to display the current setup token or to create a new one. + * + * Usage: icingacli setup token <action> + */ +class TokenCommand extends Command +{ + /** + * Display the current setup token + * + * Shows you the current setup token used to authenticate when setting up Icinga Web 2 using the web-based wizard. + * + * USAGE: + * + * icingacli setup token show [options] + * + * OPTIONS: + * + * --config=<directory> Path to Icinga Web 2's configuration files [/etc/icingaweb2] + */ + public function showAction() + { + $configDir = $this->params->get('config', $this->app->getConfigDir()); + if (! is_string($configDir) || strlen(trim($configDir)) === 0) { + $this->fail($this->translate( + 'The argument --config expects a path to Icinga Web 2\'s configuration files' + )); + } + + $token = file_get_contents($configDir . '/setup.token'); + if (! $token) { + $this->fail( + $this->translate('Nothing to show. Please create a new setup token using the generateToken action.') + ); + } + + printf($this->translate("The current setup token is: %s\n"), $token); + } + + /** + * Create a new setup token + * + * Re-generates the setup token used to authenticate when setting up Icinga Web 2 using the web-based wizard. + * + * USAGE: + * + * icingacli setup token create [options] + * + * OPTIONS: + * + * --config=<directory> Path to Icinga Web 2's configuration files [/etc/icingaweb2] + */ + public function createAction() + { + $configDir = $this->params->get('config', $this->app->getConfigDir()); + if (! is_string($configDir) || strlen(trim($configDir)) === 0) { + $this->fail($this->translate( + 'The argument --config expects a path to Icinga Web 2\'s configuration files' + )); + } + + $file = $configDir . '/setup.token'; + + if (function_exists('openssl_random_pseudo_bytes')) { + $token = bin2hex(openssl_random_pseudo_bytes(8)); + } else { + $token = substr(md5(mt_rand()), 16); + } + + if (false === file_put_contents($file, $token)) { + $this->fail(sprintf($this->translate('Cannot write setup token "%s" to disk.'), $file)); + } + + if (! chmod($file, 0660)) { + $this->fail(sprintf($this->translate('Cannot change access mode of "%s" to %o.'), $file, 0660)); + } + + printf($this->translate("The newly generated setup token is: %s\n"), $token); + } +} diff --git a/modules/setup/application/controllers/IndexController.php b/modules/setup/application/controllers/IndexController.php new file mode 100644 index 0000000..b75643c --- /dev/null +++ b/modules/setup/application/controllers/IndexController.php @@ -0,0 +1,91 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Controllers; + +use Icinga\Module\Setup\WebWizard; +use Icinga\Web\Controller; +use Icinga\Web\Form; +use Icinga\Web\Url; + +class IndexController extends Controller +{ + /** + * Whether the controller requires the user to be authenticated + * + * FALSE as the wizard uses token authentication + * + * @var bool + */ + protected $requiresAuthentication = false; + + /** + * {@inheritdoc} + */ + protected $innerLayout = 'inline'; + + /** + * Show the web wizard and run the configuration once finished + */ + public function indexAction() + { + $wizard = new WebWizard(); + + if ($wizard->isFinished()) { + $setup = $wizard->getSetup(); + $success = $setup->run(); + if ($success) { + $wizard->clearSession(); + } else { + $wizard->setIsFinished(false); + } + + $this->view->success = $success; + $this->view->report = $setup->getReport(); + } else { + $wizard->handleRequest(); + + $restartForm = new Form(); + $restartForm->setUidDisabled(); + $restartForm->setName('setup_restart_form'); + $restartForm->setAction(Url::fromPath('setup/index/restart')); + $restartForm->setAttrib('class', 'restart-form'); + $restartForm->addElement( + 'button', + 'btn_submit', + array( + 'type' => 'submit', + 'value' => 'btn_submit', + 'escape' => false, + 'label' => $this->view->icon('reply-all'), + 'title' => $this->translate('Restart the setup'), + 'decorators' => array('ViewHelper') + ) + ); + + $this->view->restartForm = $restartForm; + } + + $this->view->wizard = $wizard; + $this->view->title = $this->translate('Setup') . ' :: ' . $this->view->defaultTitle; + } + + /** + * Reset session and restart the wizard + */ + public function restartAction() + { + $this->assertHttpMethod('POST'); + + $form = new Form(array( + 'onSuccess' => function () { + $wizard = new WebWizard(); + $wizard->clearSession(false); + } + )); + $form->setUidDisabled(); + $form->setRedirectUrl('setup'); + $form->setSubmitLabel('btn_submit'); + $form->handleRequest(); + } +} diff --git a/modules/setup/application/forms/AdminAccountPage.php b/modules/setup/application/forms/AdminAccountPage.php new file mode 100644 index 0000000..b33749e --- /dev/null +++ b/modules/setup/application/forms/AdminAccountPage.php @@ -0,0 +1,431 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Exception; +use Icinga\Application\Config; +use Icinga\Authentication\User\ExternalBackend; +use Icinga\Authentication\User\UserBackend; +use Icinga\Authentication\User\DbUserBackend; +use Icinga\Authentication\User\LdapUserBackend; +use Icinga\Authentication\UserGroup\UserGroupBackend; +use Icinga\Authentication\UserGroup\LdapUserGroupBackend; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Data\Selectable; +use Icinga\Exception\NotImplementedError; +use Icinga\Protocol\Ldap\LdapQuery; +use Icinga\Web\Form; + +/** + * Wizard page to define the initial administrative account + */ +class AdminAccountPage extends Form +{ + /** + * The resource configuration to use + * + * @var array + */ + protected $resourceConfig; + + /** + * The user backend configuration to use + * + * @var array + */ + protected $backendConfig; + + /** + * The user group backend configuration to use + * + * @var array + */ + protected $groupConfig; + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_admin_account'); + $this->setTitle($this->translate('Administration', 'setup.page.title')); + $this->addDescription($this->translate( + 'Now it\'s time to configure your first administrative account or group for Icinga Web 2.' + )); + } + + /** + * Set the resource configuration to use + * + * @param array $config + * + * @return $this + */ + public function setResourceConfig(array $config) + { + $this->resourceConfig = $config; + return $this; + } + + /** + * Set the user backend configuration to use + * + * @param array $config + * + * @return $this + */ + public function setBackendConfig(array $config) + { + $this->backendConfig = $config; + return $this; + } + + /** + * Set the user group backend configuration to use + * + * @param array $config + * + * @return $this + */ + public function setGroupConfig(array $config = null) + { + $this->groupConfig = $config; + return $this; + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $choices = array(); + $groups = []; + if ($this->backendConfig['backend'] !== 'db') { + $choices['by_name'] = $this->translate('By Name', 'setup.admin'); + $choice = isset($formData['user_type']) ? $formData['user_type'] : 'by_name'; + + if (in_array($this->backendConfig['backend'], array('ldap', 'msldap'))) { + $groups = $this->fetchGroups(); + if (! empty($groups)) { + $choices['user_group'] = $this->translate('User Group', 'setup.admin'); + } + } + } else { + $choices['new_user'] = $this->translate('New User', 'setup.admin'); + $choice = isset($formData['user_type']) ? $formData['user_type'] : 'new_user'; + } + + $users = []; + if (in_array($this->backendConfig['backend'], array('db', 'ldap', 'msldap'))) { + $users = $this->fetchUsers(); + if (! empty($users)) { + $choices['existing_user'] = $this->translate('Existing User', 'setup.admin'); + } + } + + if (count($choices) > 1) { + $this->addElement( + 'select', + 'user_type', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Type Of Definition'), + 'description' => $this->translate('Choose how to define the desired account.'), + 'multiOptions' => $choices, + 'value' => $choice + ) + ); + } else { + $this->addElement( + 'hidden', + 'user_type', + array( + 'required' => true, + 'value' => key($choices) + ) + ); + } + + if ($choice === 'by_name') { + $this->addElement( + 'text', + 'by_name', + array( + 'required' => true, + 'value' => $this->getUsername(), + 'label' => $this->translate('Username'), + 'description' => $this->translate( + 'Define the initial administrative account by providing a username that reflects' + . ' a user created later or one that is authenticated using external mechanisms.' + ) + ) + ); + } + + if ($choice === 'user_group') { + $this->addElement( + 'select', + 'user_group', + array( + 'required' => true, + 'label' => $this->translate('Group Name'), + 'description' => $this->translate( + 'Choose a user group reported by the LDAP backend' + . ' to permit its members administrative access.', + 'setup.admin' + ), + 'multiOptions' => array_combine($groups, $groups) + ) + ); + } + + if ($choice === 'existing_user') { + $this->addElement( + 'select', + 'existing_user', + array( + 'required' => true, + 'label' => $this->translate('Username'), + 'description' => sprintf( + $this->translate( + 'Choose a user reported by the %s backend as the initial administrative account.', + 'setup.admin' + ), + $this->backendConfig['backend'] === 'db' + ? $this->translate('database', 'setup.admin.authbackend') + : 'LDAP' + ), + 'multiOptions' => array_combine($users, $users) + ) + ); + } + + if ($choice === 'new_user') { + $this->addElement( + 'text', + 'new_user', + array( + 'required' => true, + 'label' => $this->translate('Username'), + 'description' => $this->translate( + 'Enter the username to be used when creating an initial administrative account.' + ) + ) + ); + $this->addElement( + 'password', + 'new_user_password', + array( + 'required' => true, + 'renderPassword' => true, + 'label' => $this->translate('Password'), + 'description' => $this->translate( + 'Enter the password to assign to the newly created account.' + ), + 'autocomplete' => 'new-password' + ) + ); + $this->addElement( + 'password', + 'new_user_2ndpass', + array( + 'required' => true, + 'renderPassword' => true, + 'label' => $this->translate('Repeat password'), + 'description' => $this->translate( + 'Please repeat the password given above to avoid typing errors.' + ), + 'validators' => array( + array('identical', false, array('new_user_password')) + ) + ) + ); + } + } + + /** + * Validate the given request data and ensure that any new user does not already exist + * + * @param array $data The request data to validate + * + * @return bool + */ + public function isValid($data) + { + if (false === parent::isValid($data)) { + return false; + } + + if ($data['user_type'] === 'new_user' && $this->hasUser($data['new_user'])) { + $this->getElement('new_user')->addError($this->translate('Username already exists.')); + return false; + } + + return true; + } + + /** + * Return the name of the externally authenticated user + * + * @return string + */ + protected function getUsername() + { + list($name, $_) = ExternalBackend::getRemoteUserInformation(); + if ($name === null) { + return ''; + } + + if (isset($this->backendConfig['strip_username_regexp']) && $this->backendConfig['strip_username_regexp']) { + // No need to silence or log anything here because the pattern has + // already been successfully compiled during backend configuration + $name = preg_replace($this->backendConfig['strip_username_regexp'], '', $name); + } + + return $name; + } + + /** + * Return the names of all users the user backend currently provides + * + * @return array + */ + protected function fetchUsers() + { + try { + $query = $this + ->createUserBackend() + ->select(array('user_name')) + ->order('user_name', 'asc', true); + if (in_array($this->backendConfig['backend'], array('ldap', 'msldap'))) { + /** @var LdapQuery $ldapQuery */ + $ldapQuery = $query->getQuery(); + $ldapQuery->setUsePagedResults(); + } + + return $query->fetchColumn(); + } catch (Exception $_) { + // No need to handle anything special here. Error means no users found. + return array(); + } + } + + /** + * Return whether the user backend provides a user with the given name + * + * @param string $username + * + * @return bool + */ + protected function hasUser($username) + { + try { + return $this + ->createUserBackend() + ->select() + ->where('user_name', $username) + ->count() > 1; + } catch (Exception $_) { + return false; + } + } + + /** + * Create and return the user backend + * + * @return DbUserBackend|LdapUserBackend + */ + protected function createUserBackend() + { + $resourceConfig = new Config(); + $resourceConfig->setSection($this->resourceConfig['name'], $this->resourceConfig); + ResourceFactory::setConfig($resourceConfig); + + $config = new ConfigObject($this->backendConfig); + $config->resource = $this->resourceConfig['name']; + return UserBackend::create(null, $config); + } + + /** + * Return the names of all user groups the user group backend currently provides + * + * @return array + */ + protected function fetchGroups() + { + try { + $query = $this + ->createUserGroupBackend() + ->select(array('group_name')); + if (in_array($this->backendConfig['backend'], array('ldap', 'msldap'))) { + /** @var LdapQuery $ldapQuery */ + $ldapQuery = $query->getQuery(); + $ldapQuery->setUsePagedResults(); + } + + return $query->fetchColumn(); + } catch (Exception $_) { + // No need to handle anything special here. Error means no groups found. + return array(); + } + } + + /** + * Return whether the user group backend provides a user group with the given name + * + * @param string $groupname + * + * @return bool + */ + protected function hasGroup($groupname) + { + try { + return $this + ->createUserGroupBackend() + ->select() + ->where('group_name', $groupname) + ->count() > 1; + } catch (Exception $_) { + return false; + } + } + + /** + * Create and return the user group backend + * + * @return LdapUserGroupBackend + */ + protected function createUserGroupBackend() + { + $resourceConfig = new Config(); + $resourceConfig->setSection($this->resourceConfig['name'], $this->resourceConfig); + ResourceFactory::setConfig($resourceConfig); + + $backendConfig = new Config(); + $backendConfig->setSection($this->backendConfig['name'], array_merge( + $this->backendConfig, + array('resource' => $this->resourceConfig['name']) + )); + UserBackend::setConfig($backendConfig); + + if (empty($this->groupConfig)) { + $groupConfig = new ConfigObject(array( + 'backend' => $this->backendConfig['backend'], // _Should_ be "db" or "msldap" + 'resource' => $this->resourceConfig['name'], + 'user_backend' => $this->backendConfig['name'] // Gets ignored if 'backend' is "db" + )); + } else { + $groupConfig = new ConfigObject($this->groupConfig); + } + + $backend = UserGroupBackend::create(null, $groupConfig); + if (! $backend instanceof Selectable) { + throw new NotImplementedError('Unsupported, until #9772 has been resolved'); + } + + return $backend; + } +} diff --git a/modules/setup/application/forms/AuthBackendPage.php b/modules/setup/application/forms/AuthBackendPage.php new file mode 100644 index 0000000..88c77e6 --- /dev/null +++ b/modules/setup/application/forms/AuthBackendPage.php @@ -0,0 +1,274 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Application\Config; +use Icinga\Data\ResourceFactory; +use Icinga\Forms\Config\UserBackendConfigForm; +use Icinga\Forms\Config\UserBackend\DbBackendForm; +use Icinga\Forms\Config\UserBackend\LdapBackendForm; +use Icinga\Forms\Config\UserBackend\ExternalBackendForm; +use Icinga\Web\Form; + +/** + * Wizard page to define authentication backend specific details + */ +class AuthBackendPage extends Form +{ + /** + * The resource configuration to use + * + * @var array + */ + protected $config; + + /** + * Default values for the subform's elements suggested by a previous step + * + * @var string[] + */ + protected $suggestions = array(); + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_authentication_backend'); + $this->setTitle($this->translate('Authentication Backend', 'setup.page.title')); + $this->setValidatePartial(true); + } + + /** + * Set the resource configuration to use + * + * @param array $config + * + * @return $this + */ + public function setResourceConfig(array $config) + { + $resourceConfig = new Config(); + $resourceConfig->setSection($config['name'], $config); + ResourceFactory::setConfig($resourceConfig); + + $this->config = $config; + return $this; + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + if (isset($formData['skip_validation']) && $formData['skip_validation']) { + $this->addSkipValidationCheckbox(); + } + + $backendForm = null; + if (! isset($this->config) || $this->config['type'] === 'external') { + $backendForm = new ExternalBackendForm(); + $backendForm->create($formData); + $this->addDescription($this->translate( + 'You\'ve chosen to authenticate using a web server\'s mechanism so it may be necessary' + . ' to adjust usernames before any permissions, restrictions, etc. are being applied.' + )); + } elseif ($this->config['type'] === 'db') { + $this->setRequiredCue(null); + $backendForm = new DbBackendForm(); + $backendForm->setRequiredCue(null); + $backendForm->create($formData)->removeElement('resource'); + $this->addDescription($this->translate( + 'As you\'ve chosen to use a database for authentication all you need ' + . 'to do now is defining a name for your first authentication backend.' + )); + } elseif ($this->config['type'] === 'ldap') { + $type = null; + if (! isset($formData['type'])) { + if (isset($formData['backend'])) { + $formData['type'] = $type = $formData['backend']; + } elseif (isset($this->suggestions['backend'])) { + $formData['type'] = $type = $this->suggestions['backend']; + } + } + + $backendForm = new LdapBackendForm(); + $backendForm->setSuggestions($this->suggestions); + $backendForm->setResources(array($this->config['name'])); + $backendForm->create($formData); + $backendForm->getElement('resource')->setIgnore(true); + $this->addDescription($this->translate( + 'Before you are able to authenticate using the LDAP connection defined earlier you need to' + . ' provide some more information so that Icinga Web 2 is able to locate account details.' + )); + $this->addElement( + 'select', + 'type', + array( + 'ignore' => true, + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Backend Type'), + 'description' => $this->translate( + 'The type of the resource being used for this authenticaton provider' + ), + 'multiOptions' => array( + 'ldap' => 'LDAP', + 'msldap' => 'ActiveDirectory' + ), + 'value' => $type + ) + ); + } + + $backendForm->getElement('name')->setValue('icingaweb2'); + $this->addSubForm($backendForm, 'backend_form'); + } + + /** + * Retrieve all form element values + * + * @param bool $suppressArrayNotation Ignored + * + * @return array + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues(); + $values = array_merge($values, $values['backend_form']); + unset($values['backend_form']); + return $values; + } + + /** + * Validate the given form data and check whether it's possible to authenticate using the configured backend + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (! parent::isValid($data)) { + return false; + } + + if (isset($this->config)) { + if ($this->config['type'] === 'ldap' && ( + ! isset($data['skip_validation']) || $data['skip_validation'] == 0) + ) { + $self = clone $this; + $self->getSubForm('backend_form')->getElement('resource')->setIgnore(false); + $inspection = UserBackendConfigForm::inspectUserBackend($self); + if ($inspection && $inspection->hasError()) { + $this->error($inspection->getError()); + $this->addSkipValidationCheckbox(); + return false; + } + } + } + + return true; + } + + /** + * Run the configured backend's inspection checks and show the result, if necessary + * + * This will only run any validation if the user pushed the 'backend_validation' button. + * + * @param array $formData + * + * @return bool + */ + public function isValidPartial(array $formData) + { + if (isset($formData['backend_validation']) && parent::isValid($formData)) { + $self = clone $this; + if (($resourceElement = $self->getSubForm('backend_form')->getElement('resource')) !== null) { + $resourceElement->setIgnore(false); + } + + $inspection = UserBackendConfigForm::inspectUserBackend($self); + if ($inspection !== null) { + $join = function ($e) use (&$join) { + return is_string($e) ? $e : join("\n", array_map($join, $e)); + }; + $this->addElement( + 'note', + 'inspection_output', + array( + 'order' => 0, + 'value' => '<strong>' . $this->translate('Validation Log') . "</strong>\n\n" + . join("\n", array_map($join, $inspection->toArray())), + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'pre', 'class' => 'log-output')), + ) + ) + ); + + if ($inspection->hasError()) { + $this->warning(sprintf( + $this->translate('Failed to successfully validate the configuration: %s'), + $inspection->getError() + )); + return false; + } + } + + $this->info($this->translate('The configuration has been successfully validated.')); + } elseif (isset($formData['discovery_btn']) || isset($formData['btn_discover_domain'])) { + return parent::isValidPartial($formData); + } elseif (! isset($formData['backend_validation'])) { + // This is usually done by isValid(Partial), but as we're not calling any of these... + $this->populate($formData); + } + + return true; + } + + /** + * Add a checkbox to this form by which the user can skip the authentication validation + */ + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + array( + 'order' => 0, + 'ignore' => true, + 'required' => true, + 'label' => $this->translate('Skip Validation'), + 'description' => $this->translate('Check this to not to validate authentication using this backend') + ) + ); + } + + /** + * Get default values for the subform's elements suggested by a previous step + * + * @return string[] + */ + public function getSuggestions() + { + return $this->suggestions; + } + + /** + * Set default values for the subform's elements suggested by a previous step + * + * @param string[] $suggestions + * + * @return $this + */ + public function setSuggestions(array $suggestions) + { + $this->suggestions = $suggestions; + + return $this; + } +} diff --git a/modules/setup/application/forms/AuthenticationPage.php b/modules/setup/application/forms/AuthenticationPage.php new file mode 100644 index 0000000..52e3c66 --- /dev/null +++ b/modules/setup/application/forms/AuthenticationPage.php @@ -0,0 +1,69 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Authentication\User\ExternalBackend; +use Icinga\Web\Form; +use Icinga\Application\Platform; + +/** + * Wizard page to choose an authentication backend + */ +class AuthenticationPage extends Form +{ + /** + * Initialize this page + */ + public function init() + { + $this->setRequiredCue(null); + $this->setName('setup_authentication_type'); + $this->setTitle($this->translate('Authentication', 'setup.page.title')); + $this->addDescription($this->translate( + 'Please choose how you want to authenticate when accessing Icinga Web 2.' + . ' Configuring backend specific details follows in a later step.' + )); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + if (isset($formData['type']) && $formData['type'] === 'external') { + list($username, $_) = ExternalBackend::getRemoteUserInformation(); + if ($username === null) { + $this->info( + $this->translate( + 'You\'re currently not authenticated using any of the web server\'s authentication ' + . 'mechanisms. Make sure you\'ll configure such, otherwise you\'ll not be able to ' + . 'log into Icinga Web 2.' + ), + false + ); + } + } + + $backendTypes = array(); + if (Platform::hasMysqlSupport() || Platform::hasPostgresqlSupport()) { + $backendTypes['db'] = $this->translate('Database'); + } + if (Platform::extensionLoaded('ldap')) { + $backendTypes['ldap'] = 'LDAP'; + } + $backendTypes['external'] = $this->translate('External'); + + $this->addElement( + 'select', + 'type', + array( + 'required' => true, + 'autosubmit' => true, + 'label' => $this->translate('Authentication Type'), + 'description' => $this->translate('The type of authentication to use when accessing Icinga Web 2'), + 'multiOptions' => $backendTypes + ) + ); + } +} diff --git a/modules/setup/application/forms/DatabaseCreationPage.php b/modules/setup/application/forms/DatabaseCreationPage.php new file mode 100644 index 0000000..f7092a1 --- /dev/null +++ b/modules/setup/application/forms/DatabaseCreationPage.php @@ -0,0 +1,209 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use PDOException; +use Icinga\Web\Form; +use Icinga\Module\Setup\Utils\DbTool; + +/** + * Wizard page to define a database user that is able to create databases and tables + */ +class DatabaseCreationPage extends Form +{ + /** + * The resource configuration to use + * + * @var array + */ + protected $config; + + /** + * The required privileges to setup the database + * + * @var array + */ + protected $databaseSetupPrivileges; + + /** + * The required privileges to operate the database + * + * @var array + */ + protected $databaseUsagePrivileges; + + /** + * Initialize this page + */ + public function init() + { + $this->setTitle($this->translate('Database Setup', 'setup.page.title')); + $this->addDescription($this->translate( + 'It seems that either the database you defined earlier does not yet exist and cannot be created' + . ' using the provided access credentials, the database does not have the required schema to be' + . ' operated by Icinga Web 2 or the provided access credentials do not have the sufficient ' + . 'permissions to access the database. Please provide appropriate access credentials to solve this.' + )); + } + + /** + * Set the resource configuration to use + * + * @param array $config + * + * @return $this + */ + public function setResourceConfig(array $config) + { + $this->config = $config; + return $this; + } + + /** + * Set the required privileges to setup the database + * + * @param array $privileges The privileges + * + * @return $this + */ + public function setDatabaseSetupPrivileges(array $privileges) + { + $this->databaseSetupPrivileges = $privileges; + return $this; + } + + /** + * Set the required privileges to operate the database + * + * @param array $privileges The privileges + * + * @return $this + */ + public function setDatabaseUsagePrivileges(array $privileges) + { + $this->databaseUsagePrivileges = $privileges; + return $this; + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $skipValidation = isset($formData['skip_validation']) && $formData['skip_validation']; + $this->addElement( + 'text', + 'username', + array( + 'required' => false === $skipValidation, + 'label' => $this->translate('Username'), + 'description' => $this->translate( + 'A user which is able to create databases and/or touch the database schema' + ) + ) + ); + $this->addElement( + 'password', + 'password', + array( + 'renderPassword' => true, + 'label' => $this->translate('Password'), + 'description' => $this->translate('The password for the database user defined above'), + 'autocomplete' => 'new-password' + ) + ); + + if ($skipValidation) { + $this->addSkipValidationCheckbox(); + } else { + $this->addElement( + 'hidden', + 'skip_validation', + array( + 'required' => true, + 'value' => 0 + ) + ); + } + } + + /** + * Validate the given form data and check whether the defined user has sufficient access rights + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (false === parent::isValid($data)) { + return false; + } + + if (isset($data['skip_validation']) && $data['skip_validation']) { + return true; + } + + $config = $this->config; + $config['username'] = $this->getValue('username'); + $config['password'] = $this->getValue('password'); + $db = new DbTool($config); + + try { + $db->connectToDb(); // Are we able to login on the database? + } catch (PDOException $_) { + try { + $db->connectToHost(); // Are we able to login on the server? + } catch (PDOException $e) { + // We are NOT able to login on the server.. + $this->error($e->getMessage()); + $this->addSkipValidationCheckbox(); + return false; + } + } + + // In case we are connected the credentials filled into this + // form need to be granted to create databases, users... + if (false === $db->checkPrivileges($this->databaseSetupPrivileges)) { + $this->error( + $this->translate('The provided credentials cannot be used to create the database and/or the user.') + ); + $this->addSkipValidationCheckbox(); + return false; + } + + // ...and to grant all required usage privileges to others + if (false === $db->isGrantable($this->databaseUsagePrivileges)) { + $this->error(sprintf( + $this->translate( + 'The provided credentials cannot be used to grant all required privileges to the login "%s".' + ), + $this->config['username'] + )); + $this->addSkipValidationCheckbox(); + return false; + } + + return true; + } + + /** + * Add a checkbox to the form by which the user can skip the login and privilege validation + */ + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + array( + 'order' => 0, + 'required' => true, + 'label' => $this->translate('Skip Validation'), + 'description' => $this->translate( + 'Check this to not to validate the ability to login and required privileges' + ) + ) + ); + } +} diff --git a/modules/setup/application/forms/DbResourcePage.php b/modules/setup/application/forms/DbResourcePage.php new file mode 100644 index 0000000..a417710 --- /dev/null +++ b/modules/setup/application/forms/DbResourcePage.php @@ -0,0 +1,183 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Exception; +use Icinga\Web\Form; +use Icinga\Forms\Config\Resource\DbResourceForm; +use Icinga\Module\Setup\Utils\DbTool; + +/** + * Wizard page to define connection details for a database resource + */ +class DbResourcePage extends Form +{ + /** + * Initialize this page + */ + public function init() + { + $this->setTitle($this->translate('Database Resource', 'setup.page.title')); + $this->setValidatePartial(true); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'hidden', + 'type', + array( + 'required' => true, + 'value' => 'db' + ) + ); + + if (isset($formData['skip_validation']) && $formData['skip_validation']) { + $this->addSkipValidationCheckbox(); + } else { + $this->addElement( + 'hidden', + 'skip_validation', + array( + 'required' => true, + 'value' => 0 + ) + ); + } + + $resourceForm = new DbResourceForm(); + $this->addElements($resourceForm->createElements($formData)->getElements()); + $this->getElement('name')->setValue('icingaweb_db'); + } + + /** + * Validate the given form data and check whether it's possible to connect to the database server + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (false === parent::isValid($data)) { + return false; + } + + if (false === isset($data['skip_validation']) || $data['skip_validation'] == 0) { + if (! $this->validateConfiguration()) { + $this->addSkipValidationCheckbox(); + return false; + } + } + + return true; + } + + /** + * Check whether it's possible to connect to the database server + * + * This will only run the check if the user pushed the 'backend_validation' button. + * + * @param array $formData + * + * @return bool + */ + public function isValidPartial(array $formData) + { + if (isset($formData['backend_validation']) && parent::isValid($formData)) { + if (! $this->validateConfiguration()) { + return false; + } + + $this->info($this->translate('The configuration has been successfully validated.')); + } elseif (! isset($formData['backend_validation'])) { + // This is usually done by isValid(Partial), but as we're not calling any of these... + $this->populate($formData); + } + + return true; + } + + /** + * Return whether the configuration is valid + * + * @return bool + */ + protected function validateConfiguration() + { + try { + $db = new DbTool($this->getValues()); + $db->checkConnectivity(); + } catch (Exception $e) { + $this->error(sprintf( + $this->translate('Failed to successfully validate the configuration: %s'), + $e->getMessage() + )); + return false; + } + + $state = true; + $connectionError = null; + + try { + $db->connectToDb(); + } catch (Exception $e) { + $connectionError = $e; + } + + if ($connectionError === null && array_search('icinga_instances', $db->listTables(), true) !== false) { + $this->warning($this->translate( + 'The database you\'ve configured to use for Icinga Web 2 seems to be the one of Icinga. Please be aware' + . ' that this database configuration is supposed to be used for Icinga Web 2\'s configuration and that' + . ' it is highly recommended to not mix different schemas in the same database. If this is intentional,' + . ' you can skip the validation and ignore this warning. If not, please provide a different database.' + )); + $state = false; + } + + if ($this->getValue('db') === 'pgsql') { + if ($connectionError !== null) { +// $this->warning(sprintf( +// $this->translate('Unable to check the server\'s version. This is usually not a critical error' +// . ' as there is probably only access to the database permitted which does not exist yet. If you are' +// . ' absolutely sure you are running PostgreSQL in a version equal to or newer than 9.1,' +// . ' you can skip the validation and safely proceed to the next step. The error was: %s'), +// $connectionError->getMessage() +// )); +// $state = false; + } else { + $version = $db->getServerVersion(); + if (version_compare($version, '9.1', '<')) { + $this->error(sprintf( + $this->translate('The server\'s version %s is too old. The minimum required version is %s.'), + $version, + '9.1' + )); + $state = false; + } + } + } + + return $state; + } + + /** + * Add a checkbox to the form by which the user can skip the configuration validation + */ + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + array( + 'required' => true, + 'label' => $this->translate('Skip Validation'), + 'description' => $this->translate('Check this to not to validate the configuration') + ) + ); + } +} diff --git a/modules/setup/application/forms/GeneralConfigPage.php b/modules/setup/application/forms/GeneralConfigPage.php new file mode 100644 index 0000000..5b9f011 --- /dev/null +++ b/modules/setup/application/forms/GeneralConfigPage.php @@ -0,0 +1,41 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Forms\Config\General\ApplicationConfigForm; +use Icinga\Forms\Config\General\LoggingConfigForm; +use Icinga\Web\Form; + +/** + * Wizard page to define the application and logging configuration + */ +class GeneralConfigPage extends Form +{ + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_general_config'); + $this->setTitle($this->translate('Application Configuration', 'setup.page.title')); + $this->addDescription($this->translate( + 'Now please adjust all application and logging related configuration options to fit your needs.' + )); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $appConfigForm = new ApplicationConfigForm(); + $appConfigForm->createElements($formData); + $appConfigForm->removeElement('global_module_path'); + $appConfigForm->removeElement('global_config_resource'); + $this->addElements($appConfigForm->getElements()); + + $loggingConfigForm = new LoggingConfigForm(); + $this->addElements($loggingConfigForm->createElements($formData)->getElements()); + } +} diff --git a/modules/setup/application/forms/LdapDiscoveryConfirmPage.php b/modules/setup/application/forms/LdapDiscoveryConfirmPage.php new file mode 100644 index 0000000..33bc907 --- /dev/null +++ b/modules/setup/application/forms/LdapDiscoveryConfirmPage.php @@ -0,0 +1,133 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Data\ConfigObject; +use Icinga\Web\Form; + +/** + * Wizard page to define the connection details for a LDAP resource + */ +class LdapDiscoveryConfirmPage extends Form +{ + const TYPE_AD = 'MS ActiveDirectory'; + const TYPE_MISC = 'LDAP'; + + private $infoTemplate = <<< 'EOT' +<table><tbody> + <tr><td><strong>Type:</strong></td><td>{type}</td></tr> + <tr><td><strong>Port:</strong></td><td>{port}</td></tr> + <tr><td><strong>Root DN:</strong></td><td>{root_dn}</td></tr> + <tr><td><strong>User Object Class:</strong></td><td>{user_class}</td></tr> + <tr><td><strong>User Name Attribute:</strong></td><td>{user_attribute}</td></tr> +</tbody></table> +EOT; + + /** + * The previous configuration + * + * @var array + */ + private $config; + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_ldap_discovery_confirm'); + $this->setTitle($this->translate('LDAP Discovery Results', 'setup.page.title')); + } + + /** + * Set the resource configuration to use + * + * @param array $config + * + * @return $this + */ + public function setResourceConfig(array $config) + { + $this->config = $config; + return $this; + } + + /** + * Return the resource configuration as Config object + * + * @return ConfigObject + */ + public function getResourceConfig() + { + return new ConfigObject($this->config); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $resource = $this->config['resource']; + $backend = $this->config['backend']; + $html = $this->infoTemplate; + $html = str_replace('{type}', $this->config['type'], $html); + $html = str_replace('{hostname}', $resource['hostname'], $html); + $html = str_replace('{port}', $resource['port'], $html); + $html = str_replace('{root_dn}', $resource['root_dn'], $html); + $html = str_replace('{user_attribute}', $backend['user_name_attribute'], $html); + $html = str_replace('{user_class}', $backend['user_class'], $html); + + $this->addDescription(sprintf( + $this->translate('The following directory service has been found on domain "%s".'), + $this->config['domain'] + )); + + $this->addElement( + 'note', + 'suggestion', + array( + 'value' => $html, + 'decorators' => array( + 'ViewHelper', + array( + 'HtmlTag', array('tag' => 'div') + ) + ) + ) + ); + + $this->addElement( + 'checkbox', + 'confirm', + array( + 'value' => '1', + 'label' => $this->translate('Use this configuration?') + ) + ); + } + + /** + * Validate the given form data and check whether a BIND-request is successful + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (false === parent::isValid($data)) { + return false; + } + return true; + } + + public function getValues($suppressArrayNotation = false) + { + if ($this->getValue('confirm') === '1') { + // use configuration + return $this->config; + } + return null; + } +} diff --git a/modules/setup/application/forms/LdapDiscoveryPage.php b/modules/setup/application/forms/LdapDiscoveryPage.php new file mode 100644 index 0000000..7b5de17 --- /dev/null +++ b/modules/setup/application/forms/LdapDiscoveryPage.php @@ -0,0 +1,115 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Exception; +use Zend_Validate_NotEmpty; +use Icinga\Exception\IcingaException; +use Icinga\Web\Form; +use Icinga\Web\Form\ErrorLabeller; +use Icinga\Forms\LdapDiscoveryForm; +use Icinga\Protocol\Ldap\Discovery; +use Icinga\Module\Setup\Forms\LdapDiscoveryConfirmPage; + +/** + * Wizard page to define the connection details for a LDAP resource + */ +class LdapDiscoveryPage extends Form +{ + /** + * @var Discovery + */ + private $discovery; + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_ldap_discovery'); + $this->setTitle($this->translate('LDAP Discovery', 'setup.page.title')); + $this->addDescription($this->translate( + 'You can use this page to discover LDAP or ActiveDirectory servers ' . + ' for authentication. If you don\'t want to execute a discovery, just skip this step.' + )); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $discoveryForm = new LdapDiscoveryForm(); + $this->addElements($discoveryForm->createElements($formData)->getElements()); + + $this->addElement( + 'checkbox', + 'skip_validation', + array( + 'label' => $this->translate('Skip'), + 'description' => $this->translate('Do not discover LDAP servers and enter all settings manually.') + ) + ); + } + + /** + * Validate the given form data and check whether a BIND-request is successful + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (false === parent::isValid($data)) { + return false; + } + if (isset($data['skip_validation']) && $data['skip_validation']) { + return true; + } + + if (isset($data['domain']) && $data['domain']) { + try { + $this->discovery = Discovery::discoverDomain($data['domain']); + if ($this->discovery->isSuccess()) { + return true; + } else { + $this->error($this->discovery->getError()->getMessage()); + } + } catch (Exception $e) { + $this->error(sprintf( + $this->translate('Could not find any LDAP servers on the domain "%s". An error occurred: %s'), + $data['domain'], + IcingaException::describe($e) + )); + } + } else { + $labeller = new ErrorLabeller(array('element' => $this->getElement('domain'))); + $this->getElement('domain')->addError($labeller->translate(Zend_Validate_NotEmpty::IS_EMPTY)); + } + + return false; + } + + /** + * Suggest settings based on the underlying discovery + * + * @param bool $suppressArrayNotation + * + * @return array + */ + public function getValues($suppressArrayNotation = false) + { + if (! isset($this->discovery) || ! $this->discovery->isSuccess()) { + return []; + } + $disc = $this->discovery; + return array( + 'domain' => $this->getValue('domain'), + 'type' => $disc->isAd() ? LdapDiscoveryConfirmPage::TYPE_AD : LdapDiscoveryConfirmPage::TYPE_MISC, + 'resource' => $disc->suggestResourceSettings(), + 'backend' => $disc->suggestBackendSettings() + ); + } +} diff --git a/modules/setup/application/forms/LdapResourcePage.php b/modules/setup/application/forms/LdapResourcePage.php new file mode 100644 index 0000000..7786407 --- /dev/null +++ b/modules/setup/application/forms/LdapResourcePage.php @@ -0,0 +1,152 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Web\Form; +use Icinga\Forms\Config\ResourceConfigForm; +use Icinga\Forms\Config\Resource\LdapResourceForm; + +/** + * Wizard page to define the connection details for a LDAP resource + */ +class LdapResourcePage extends Form +{ + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_ldap_resource'); + $this->setTitle($this->translate('LDAP Resource', 'setup.page.title')); + $this->addDescription($this->translate( + 'Now please configure your AD/LDAP resource. This will later ' + . 'be used to authenticate users logging in to Icinga Web 2.' + )); + $this->setValidatePartial(true); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'hidden', + 'type', + array( + 'required' => true, + 'value' => 'ldap' + ) + ); + + if (isset($formData['skip_validation']) && $formData['skip_validation']) { + $this->addSkipValidationCheckbox(); + } else { + $this->addElement( + 'hidden', + 'skip_validation', + array( + 'required' => true, + 'value' => 0 + ) + ); + } + + $resourceForm = new LdapResourceForm(); + $this->addElements($resourceForm->createElements($formData)->getElements()); + $this->getElement('name')->setValue('icingaweb_ldap'); + } + + /** + * Validate the given form data and check whether a BIND-request is successful + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (! parent::isValid($data)) { + return false; + } + + if (! isset($data['skip_validation']) || $data['skip_validation'] == 0) { + $inspection = ResourceConfigForm::inspectResource($this); + if ($inspection !== null && $inspection->hasError()) { + $this->error($inspection->getError()); + $this->addSkipValidationCheckbox(); + return false; + } + } + + return true; + } + + /** + * Run the configured backend's inspection checks and show the result, if necessary + * + * This will only run any validation if the user pushed the 'backend_validation' button. + * + * @param array $formData + * + * @return bool + */ + public function isValidPartial(array $formData) + { + if (isset($formData['backend_validation']) && parent::isValid($formData)) { + $inspection = ResourceConfigForm::inspectResource($this); + if ($inspection !== null) { + $join = function ($e) use (&$join) { + return is_string($e) ? $e : join("\n", array_map($join, $e)); + }; + $this->addElement( + 'note', + 'inspection_output', + array( + 'order' => 0, + 'value' => '<strong>' . $this->translate('Validation Log') . "</strong>\n\n" + . join("\n", array_map($join, $inspection->toArray())), + 'decorators' => array( + 'ViewHelper', + array('HtmlTag', array('tag' => 'pre', 'class' => 'log-output')), + ) + ) + ); + + if ($inspection->hasError()) { + $this->warning(sprintf( + $this->translate('Failed to successfully validate the configuration: %s'), + $inspection->getError() + )); + return false; + } + } + + $this->info($this->translate('The configuration has been successfully validated.')); + } elseif (! isset($formData['backend_validation'])) { + // This is usually done by isValid(Partial), but as we're not calling any of these... + $this->populate($formData); + } + + return true; + } + + /** + * Add a checkbox to the form by which the user can skip the connection validation + */ + protected function addSkipValidationCheckbox() + { + $this->addElement( + 'checkbox', + 'skip_validation', + array( + 'required' => true, + 'label' => $this->translate('Skip Validation'), + 'description' => $this->translate( + 'Check this to not to validate connectivity with the given directory service' + ) + ) + ); + } +} diff --git a/modules/setup/application/forms/ModulePage.php b/modules/setup/application/forms/ModulePage.php new file mode 100644 index 0000000..d62b5a9 --- /dev/null +++ b/modules/setup/application/forms/ModulePage.php @@ -0,0 +1,108 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Application\Icinga; +use Icinga\Application\Modules\Module; +use Icinga\Web\Form; + +class ModulePage extends Form +{ + protected $modules; + + protected $modulePaths; + + protected $foundIcingaDB = false; + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_modules'); + $this->setViewScript('form/setup-modules.phtml'); + + $this->modulePaths = array(); + if (($appModulePath = realpath(Icinga::app()->getApplicationDir() . '/../modules')) !== false) { + $this->modulePaths[] = $appModulePath; + } + } + + public function createElements(array $formData) + { + foreach ($this->getModules() as $module) { + $checked = false; + if ($module->getName() === 'monitoring') { + $checked = ! $this->foundIcingaDB; + } elseif ($this->foundIcingaDB && $module->getName() === 'icingadb') { + $checked = true; + } + + $this->addElement( + 'checkbox', + $module->getName(), + array( + 'description' => $module->getDescription(), + 'label' => ucfirst($module->getName()), + 'value' => (int) $checked, + 'decorators' => array('ViewHelper') + ) + ); + } + } + + /** + * @return Module[] + */ + protected function getModules() + { + if ($this->modules !== null) { + return $this->modules; + } else { + $this->modules = array(); + } + + $moduleManager = Icinga::app()->getModuleManager(); + $moduleManager->detectInstalledModules($this->modulePaths); + foreach ($moduleManager->listInstalledModules() as $moduleName) { + if ($moduleName !== 'setup') { + $this->modules[$moduleName] = $moduleManager->loadModule($moduleName)->getModule($moduleName); + } + + if ($moduleName === 'icingadb') { + $this->foundIcingaDB = true; + } + } + + return $this->modules; + } + + public function getCheckedModules() + { + $modules = $this->getModules(); + + $checked = array(); + foreach ($this->getElements() as $name => $element) { + if (array_key_exists($name, $modules) && $element->isChecked()) { + $checked[$name] = $modules[$name]; + } + } + + return $checked; + } + + public function getModuleWizards() + { + $checked = $this->getCheckedModules(); + + $wizards = array(); + foreach ($checked as $name => $module) { + if ($module->providesSetupWizard()) { + $wizards[$name] = $module->getSetupWizard(); + } + } + + return $wizards; + } +} diff --git a/modules/setup/application/forms/RequirementsPage.php b/modules/setup/application/forms/RequirementsPage.php new file mode 100644 index 0000000..d1fb70e --- /dev/null +++ b/modules/setup/application/forms/RequirementsPage.php @@ -0,0 +1,68 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Web\Form; +use Icinga\Module\Setup\SetupWizard; + +/** + * Wizard page to list setup requirements + */ +class RequirementsPage extends Form +{ + /** + * The wizard + * + * @var SetupWizard + */ + protected $wizard; + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_requirements'); + $this->setViewScript('form/setup-requirements.phtml'); + } + + /** + * Set the wizard + * + * @param SetupWizard $wizard + * + * @return $this + */ + public function setWizard(SetupWizard $wizard) + { + $this->wizard = $wizard; + return $this; + } + + /** + * Return the wizard + * + * @return SetupWizard + */ + public function getWizard() + { + return $this->wizard; + } + + /** + * Validate the given form data and check whether the wizard's requirements are fulfilled + * + * @param array $data The data to validate + * + * @return bool + */ + public function isValid($data) + { + if (false === parent::isValid($data)) { + return false; + } + + return $this->wizard->getRequirements()->fulfilled(); + } +} diff --git a/modules/setup/application/forms/SummaryPage.php b/modules/setup/application/forms/SummaryPage.php new file mode 100644 index 0000000..ab62d55 --- /dev/null +++ b/modules/setup/application/forms/SummaryPage.php @@ -0,0 +1,84 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use LogicException; +use Icinga\Web\Form; + +/** + * Wizard page that displays a summary of what is going to be "done" + */ +class SummaryPage extends Form +{ + /** + * The title of what is being set up + * + * @var string + */ + protected $title; + + /** + * The summary to show + * + * @var array + */ + protected $summary; + + /** + * Initialize this page + */ + public function init() + { + if ($this->getName() === $this->filterName(get_class($this))) { + throw new LogicException( + 'When utilizing ' . get_class($this) . ' it is required to set a unique name by using the form options' + ); + } + + $this->setViewScript('form/setup-summary.phtml'); + } + + /** + * Set the title of what is being set up + * + * @param string $title + */ + public function setSubjectTitle($title) + { + $this->title = $title; + } + + /** + * Return the title of what is being set up + * + * @return string + */ + public function getSubjectTitle() + { + return $this->title; + } + + /** + * Set the summary to show + * + * @param array $summary + * + * @return $this + */ + public function setSummary(array $summary) + { + $this->summary = $summary; + return $this; + } + + /** + * Return the summary to show + * + * @return array + */ + public function getSummary() + { + return $this->summary; + } +} diff --git a/modules/setup/application/forms/UserGroupBackendPage.php b/modules/setup/application/forms/UserGroupBackendPage.php new file mode 100644 index 0000000..751270f --- /dev/null +++ b/modules/setup/application/forms/UserGroupBackendPage.php @@ -0,0 +1,147 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Application\Config; +use Icinga\Authentication\User\UserBackend; +use Icinga\Data\ResourceFactory; +use Icinga\Forms\Config\UserGroup\LdapUserGroupBackendForm; +use Icinga\Web\Form; + +/** + * Wizard page to define user group backend specific details + */ +class UserGroupBackendPage extends Form +{ + /** + * The resource configuration to use + * + * @var array + */ + protected $resourceConfig; + + /** + * The user backend configuration to use + * + * @var array + */ + protected $backendConfig; + + /** + * Initialize this page + */ + public function init() + { + $this->setName('setup_usergroup_backend'); + $this->setTitle($this->translate('User Group Backend', 'setup.page.title')); + $this->addDescription($this->translate( + 'To allow Icinga Web 2 to associate users and groups, you\'ll need to provide some further information' + . ' about the LDAP Connection that is already going to be used to locate account details.' + )); + } + + /** + * Set the resource configuration to use + * + * @param array $config + * + * @return $this + */ + public function setResourceConfig(array $config) + { + $this->resourceConfig = $config; + return $this; + } + + /** + * Set the user backend configuration to use + * + * @param array $config + * + * @return $this + */ + public function setBackendConfig(array $config) + { + $this->backendConfig = $config; + return $this; + } + + /** + * Return the resource configuration as Config object + * + * @return Config + */ + protected function createResourceConfiguration() + { + $config = new Config(); + $config->setSection($this->resourceConfig['name'], $this->resourceConfig); + return $config; + } + + /** + * Return the user backend configuration as Config object + * + * @return Config + */ + protected function createBackendConfiguration() + { + $config = new Config(); + $backendConfig = $this->backendConfig; + $backendConfig['resource'] = $this->resourceConfig['name']; + $config->setSection($this->backendConfig['name'], $backendConfig); + return $config; + } + + /** + * Create and add elements to this form + * + * @param array $formData + */ + public function createElements(array $formData) + { + // LdapUserGroupBackendForm requires these factories to provide valid configurations + ResourceFactory::setConfig($this->createResourceConfiguration()); + UserBackend::setConfig($this->createBackendConfiguration()); + + $backendForm = new LdapUserGroupBackendForm(); + $formData['type'] = 'ldap'; + $backendForm->create($formData); + $backendForm->getElement('name')->setValue('icingaweb2'); + $this->addSubForm($backendForm, 'backend_form'); + + $backendForm->addElement( + 'hidden', + 'resource', + array( + 'required' => true, + 'value' => $this->resourceConfig['name'], + 'decorators' => array('ViewHelper') + ) + ); + $backendForm->addElement( + 'hidden', + 'user_backend', + array( + 'required' => true, + 'value' => $this->backendConfig['name'], + 'decorators' => array('ViewHelper') + ) + ); + } + + /** + * Retrieve all form element values + * + * @param bool $suppressArrayNotation Ignored + * + * @return array + */ + public function getValues($suppressArrayNotation = false) + { + $values = parent::getValues(); + $values = array_merge($values, $values['backend_form']); + unset($values['backend_form']); + return $values; + } +} diff --git a/modules/setup/application/forms/WelcomePage.php b/modules/setup/application/forms/WelcomePage.php new file mode 100644 index 0000000..124a31f --- /dev/null +++ b/modules/setup/application/forms/WelcomePage.php @@ -0,0 +1,45 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Forms; + +use Icinga\Application\Icinga; +use Icinga\Web\Form; +use Icinga\Module\Setup\Web\Form\Validator\TokenValidator; + +/** + * Wizard page to authenticate and welcome the user + */ +class WelcomePage extends Form +{ + /** + * Initialize this page + */ + public function init() + { + $this->setRequiredCue(null); + $this->setName('setup_welcome'); + $this->setViewScript('form/setup-welcome.phtml'); + } + + /** + * @see Form::createElements() + */ + public function createElements(array $formData) + { + $this->addElement( + 'text', + 'token', + array( + 'class' => 'autofocus', + 'required' => true, + 'label' => $this->translate('Setup Token'), + 'description' => $this->translate( + 'For security reasons we need to ensure that you are permitted to run this wizard.' + . ' Please provide a token by following the instructions below.' + ), + 'validators' => array(new TokenValidator(Icinga::app()->getConfigDir() . '/setup.token')) + ) + ); + } +} diff --git a/modules/setup/application/views/scripts/form/setup-modules.phtml b/modules/setup/application/views/scripts/form/setup-modules.phtml new file mode 100644 index 0000000..e57c7dc --- /dev/null +++ b/modules/setup/application/views/scripts/form/setup-modules.phtml @@ -0,0 +1,33 @@ +<?php + +use Icinga\Web\Wizard; + +?> +<form + id="<?= $this->escape($form->getName()); ?>" + name="<?= $this->escape($form->getName()); ?>" + enctype="<?= $this->escape($form->getEncType()); ?>" + method="<?= $this->escape($form->getMethod()); ?>" + action="<?= $this->escape($form->getAction()); ?>" + class="icinga-controls" + data-progress-element="<?= Wizard::PROGRESS_ELEMENT; ?>" +> +<h2><?= $this->translate('Modules', 'setup.page.title'); ?></h2> +<p><?= $this->translate('The following modules were found in your Icinga Web 2 installation. To enable and configure a module, just tick it and click "Next".'); ?></p> +<?php foreach ($form->getElements() as $element): ?> + <?php if (! in_array($element->getName(), array(Wizard::BTN_PREV, Wizard::BTN_NEXT, Wizard::PROGRESS_ELEMENT, $form->getTokenElementName(), $form->getUidElementName()))): ?> + <div class="module"> + <div class="header"> + <h3><label for="<?= $element->getId(); ?>"><strong><?= $element->getLabel(); ?></strong></label></h3> + <div class="element"> + <?= $element; ?> + </div> + </div> + <label class="description" for="<?= $element->getId(); ?>"><?= $element->getDescription(); ?></label> + </div> + <?php endif ?> +<?php endforeach ?> + <?= $form->getElement($form->getTokenElementName()); ?> + <?= $form->getElement($form->getUidElementName()); ?> + <?= $form->getDisplayGroup('buttons'); ?> +</form> diff --git a/modules/setup/application/views/scripts/form/setup-requirements.phtml b/modules/setup/application/views/scripts/form/setup-requirements.phtml new file mode 100644 index 0000000..544f284 --- /dev/null +++ b/modules/setup/application/views/scripts/form/setup-requirements.phtml @@ -0,0 +1,48 @@ +<?php + +use Icinga\Web\Wizard; + +if (! $form->getWizard()->getRequirements()->fulfilled()) { + $form->getElement(Wizard::BTN_NEXT)->setAttrib('disabled', 1); +} + +?> +<h1>Icinga Web 2</h1> +<?= $form->getWizard()->getRequirements(true); ?> +<?php foreach ($form->getWizard()->getPage('setup_modules')->getModuleWizards() as $moduleName => $wizard): ?> +<h1><?= ucwords($moduleName) . ' ' . $this->translate('Module'); ?></h1> +<?= $wizard->getRequirements(); ?> +<?php endforeach ?> +<form + id="<?= $this->escape($form->getName()); ?>" + name="<?= $this->escape($form->getName()); ?>" + enctype="<?= $this->escape($form->getEncType()); ?>" + method="<?= $this->escape($form->getMethod()); ?>" + action="<?= $this->escape($form->getAction()); ?>" + data-progress-element="<?= Wizard::PROGRESS_ELEMENT; ?>" +> + <?= $form->getElement($form->getTokenElementName()); ?> + <?= $form->getElement($form->getUidElementName()); ?> + <div class="buttons"> + <?php + $double = clone $form->getElement(Wizard::BTN_NEXT); + echo $double->setAttrib('class', 'double'); + ?> + <?= $form->getElement(Wizard::BTN_PREV); ?> + <?= $form->getElement(Wizard::BTN_NEXT); ?> + <?= $form->getElement(Wizard::PROGRESS_ELEMENT); ?> + <div class="requirements-refresh"> + <?php $title = $this->translate('You may also need to restart the web-server for the changes to take effect!'); ?> + <?= $this->qlink( + $this->translate('Refresh'), + null, + null, + array( + 'class' => 'button-link', + 'title' => $title, + 'aria-label' => sprintf($this->translate('Refresh the page; %s'), $title) + ) + ); ?> + </div> + </div> +</form>
\ No newline at end of file diff --git a/modules/setup/application/views/scripts/form/setup-summary.phtml b/modules/setup/application/views/scripts/form/setup-summary.phtml new file mode 100644 index 0000000..3ad0265 --- /dev/null +++ b/modules/setup/application/views/scripts/form/setup-summary.phtml @@ -0,0 +1,40 @@ +<?php + +use Icinga\Web\Wizard; + +$form->getElement(Wizard::BTN_NEXT)->setAttrib( + 'class', + $form->getElement(Wizard::BTN_NEXT)->getAttrib('class') . ' finish' +); + +?> +<p><?= sprintf( + $this->translate( + 'You\'ve configured %1$s successfully. You can review the changes supposed to be made before setting it up.' + . ' Make sure that everything is correct (Feel free to navigate back to make any corrections!) so' + . ' that you can start using %1$s right after it has successfully been set up.' + ), + $form->getSubjectTitle() +); ?></p> +<div class="summary"> +<?php foreach ($form->getSummary() as $pageHtml): ?> + <?php if ($pageHtml): ?> + <div class="page"> + <?= $pageHtml; ?> + </div> + <?php endif ?> +<?php endforeach ?> +</div> +<form + id="<?= $this->escape($form->getName()); ?>" + name="<?= $this->escape($form->getName()); ?>" + enctype="<?= $this->escape($form->getEncType()); ?>" + method="<?= $this->escape($form->getMethod()); ?>" + action="<?= $this->escape($form->getAction()); ?>" + data-progress-element="<?= Wizard::PROGRESS_ELEMENT; ?>" + class="summary" +> + <?= $form->getElement($form->getTokenElementName()); ?> + <?= $form->getElement($form->getUidElementName()); ?> + <?= $form->getDisplayGroup('buttons'); ?> +</form>
\ No newline at end of file diff --git a/modules/setup/application/views/scripts/form/setup-welcome.phtml b/modules/setup/application/views/scripts/form/setup-welcome.phtml new file mode 100644 index 0000000..1be68f3 --- /dev/null +++ b/modules/setup/application/views/scripts/form/setup-welcome.phtml @@ -0,0 +1,120 @@ +<?php + +use Icinga\Application\Icinga; +use Icinga\Application\Config; +use Icinga\Application\Platform; +use Icinga\Web\Wizard; + +$phpUser = Platform::getPhpUser(); +$configDir = Icinga::app()->getConfigDir(); +$setupTokenPath = rtrim($configDir, '/') . '/setup.token'; +$cliPath = realpath(Icinga::app()->getApplicationDir() . '/../bin/icingacli'); + +$groupadd = null; +$docker = getenv('ICINGAWEB_OFFICIAL_DOCKER_IMAGE'); + +if (! (false === ($distro = Platform::getLinuxDistro(1)) || $distro === 'linux')) { + foreach (array( + 'groupadd -r icingaweb2' => array( + 'redhat', 'rhel', 'centos', 'fedora', + 'suse', 'sles', 'sled', 'opensuse' + ), + 'addgroup --system icingaweb2' => array('debian', 'ubuntu') + ) as $groupadd_ => $distros) { + if (in_array($distro, $distros)) { + $groupadd = $groupadd_; + break; + } + } + + switch ($distro) { + case 'redhat': + case 'rhel': + case 'centos': + case 'fedora': + $usermod = 'usermod -a -G icingaweb2 %s'; + $webSrvUser = 'apache'; + break; + case 'suse': + case 'sles': + case 'sled': + case 'opensuse': + $usermod = 'usermod -A icingaweb2 %s'; + $webSrvUser = 'wwwrun'; + break; + case 'debian': + case 'ubuntu': + $usermod = 'usermod -a -G icingaweb2 %s'; + $webSrvUser = 'www-data'; + break; + default: + $usermod = $webSrvUser = null; + } +} +?> +<div class="welcome-page"> + <h2><?= $this->translate('Welcome to the configuration of Icinga Web 2!') ?></h2> + <?php if (false === file_exists($setupTokenPath) && file_exists(Config::resolvePath('config.ini'))): ?> + <p class="restart-warning"><?= $this->translate( + 'You\'ve already completed the configuration of Icinga Web 2. Note that most of your configuration' + . ' files will be overwritten in case you\'ll re-configure Icinga Web 2 using this wizard!' + ); ?></p> + <?php else: ?> + <p><?= $this->translate( + 'This wizard will guide you through the configuration of Icinga Web 2. Once completed and successfully' + . ' finished you are able to log in and to explore all the new and stunning features!' + ); ?></p> + <?php endif ?> + <form id="<?= $form->getName(); ?>" name="<?= $form->getName(); ?>" enctype="<?= $form->getEncType(); ?>" method="<?= $form->getMethod(); ?>" action="<?= $form->getAction(); ?>" class="icinga-controls"> + <?= $form->getElement('token'); ?> + <?= $form->getElement($form->getTokenElementName()); ?> + <?= $form->getElement($form->getUidElementName()); ?> + <div class="buttons"> + <?= $form->getElement(Wizard::BTN_NEXT); ?> + </div> + </form> + <div class="note"> + <h3><?= $this->translate('Generating a New Setup Token'); ?></h3> + <div> + <p><?= + $this->translate( + 'To run this wizard a user needs to authenticate using a token which is usually' + . ' provided to him by an administrator who\'d followed the instructions below.' + ); ?></p> + <?php if (! $docker): ?> + <p><?= $this->translate('In any case, make sure that all of the following applies to your environment:'); ?></p> + <ul> + <li><?= $this->translate('A system group called "icingaweb2" exists'); ?></li> + <?php if ($phpUser): ?> + <li><?= sprintf($this->translate('The user "%s" is a member of the system group "icingaweb2"'), $phpUser); ?></li> + <?php else: ?> + <li><?= $this->translate('Your webserver\'s user is a member of the system group "icingaweb2"'); ?></li> + <?php endif ?> + </ul> + <?php if (! ($groupadd === null || $usermod === null)) { ?> + <div class="code"> + <span><?= $this->escape($groupadd . ';') ?></span> + <span><?= $this->escape(sprintf($usermod, $phpUser ?: $webSrvUser) . ';') ?></span> + </div> + <?php } ?> + <p><?= $this->translate('If you\'ve got the IcingaCLI installed you can do the following:'); ?></p> + <?php endif; ?> + <div class="code"> + <?php if (! $docker): ?> + <span><?= $cliPath ? $cliPath : 'icingacli'; ?> setup config directory --group icingaweb2<?= $configDir !== '/etc/icingaweb2' ? ' --config ' . $configDir : ''; ?>;</span> + <?php endif; ?> + <span><?= $cliPath ? $cliPath : 'icingacli'; ?> setup token create;</span> + </div> + <?php if (! $docker): ?> + <p><?= $this->translate('In case the IcingaCLI is missing you can create the token manually:'); ?></p> + <div class="code"> + <span>su <?= $phpUser ?: $this->translate('<your-webserver-user>'); ?> -s /bin/sh -c "mkdir -m 2770 <?= dirname($setupTokenPath); ?>; chgrp icingaweb2 <?= dirname($setupTokenPath); ?>; head -c 12 /dev/urandom | base64 | tee <?= $setupTokenPath; ?>; chmod 0660 <?= $setupTokenPath; ?>;";</span> + </div> + <?php endif; ?> + <p><?= sprintf( + $this->translate('Please see the %s for an extensive description on how to access and use this wizard.'), + '<a href="http://docs.icinga.com/">' . $this->translate('Icinga Web 2 documentation') . '</a>' // TODO: Add link to iw2 docs which points to the installation topic + ); ?></p> + </div> + </div> +</div> diff --git a/modules/setup/application/views/scripts/index/index.phtml b/modules/setup/application/views/scripts/index/index.phtml new file mode 100644 index 0000000..32952e7 --- /dev/null +++ b/modules/setup/application/views/scripts/index/index.phtml @@ -0,0 +1,224 @@ +<?php + +use Icinga\Util\Csp; +use Icinga\Web\Notification; +use ipl\Web\Style; + +$pages = $wizard->getPages(); +$finished = isset($success); +$configPages = array_slice($pages, 3, count($pages) - 4, true); +$currentPos = array_search($wizard->getCurrentPage(), $pages, true); +list($configPagesLeft, $configPagesRight) = array_chunk($configPages, (int)(count($configPages) / 2), true); +$setupStyle = (new Style()) + ->setSelector('.setup-header > .progress-bar') + ->setNonce(Csp::getStyleNonce()); + +$visitedPages = array_keys($wizard->getPageData()); +$maxProgress = max(array_merge([0], array_keys(array_filter( + $pages, + function ($page) use ($visitedPages) { return in_array($page->getName(), $visitedPages); } +)))); + +$setupStyle->add( + '.width-percent-10', + ['width' => '10%'] +)->add( + '.width-percent-60', + ['width' => '60%'] +); +?> +<div id="setup-content-wrapper" data-base-target="layout"> + <div class="setup-header"> + <?= $this->img('img/icinga-logo-big.png'); ?> + <div class="progress-bar"> + <div class="step width-percent-10"> + <h1><?= $this->translate('Welcome', 'setup.progress'); ?></h1> + <?php $stateClass = $finished || $currentPos > 0 ? 'complete' : ( + $maxProgress > 0 ? 'visited' : 'active' + ); ?> + <table><tbody><tr> + <td class="left"></td> + <td class="middle"><div class="bubble <?= $stateClass; ?>"></div></td> + <td class="right"><div class="line right <?= $stateClass; ?>"></div></td> + </tr></tbody></table> + </div> + <div class="step width-percent-10"> + <h1><?= $this->translate('Modules', 'setup.progress'); ?></h1> + <?php $stateClass = $finished || $currentPos > 1 ? ' complete' : ( + $maxProgress > 1 ? ' visited' : ( + $currentPos === 1 ? ' active' : '' + ) + ); ?> + <table><tbody><tr> + <td class="left"><div class="line left<?= $stateClass; ?>"></div></td> + <td class="middle"><div class="bubble <?= $stateClass; ?>"></div></td> + <td class="right"><div class="line right <?= $stateClass; ?>"></div></td> + </tr></tbody></table> + <?php if (($maxProgress < $currentPos && $currentPos === 1) || ($maxProgress >= $currentPos && $maxProgress === 1)): ?> + <?= $this->restartForm ?> + <?php endif ?> + </div> + <div class="step width-percent-10"> + <h1><?= $this->translate('Requirements', 'setup.progress'); ?></h1> + <?php $stateClass = $finished || $currentPos > 2 ? ' complete' : ( + $maxProgress > 2 ? ' visited' : ( + $currentPos === 2 ? ' active' : '' + ) + ); ?> + <table><tbody><tr> + <td class="left"><div class="line left<?= $stateClass; ?>"></div></td> + <td class="middle"><div class="bubble<?= $stateClass; ?>"></div></td> + <td class="right"><div class="line right<?= $stateClass; ?>"></div></td> + </tr></tbody></table> + <?php if (($maxProgress < $currentPos && $currentPos === 2) || ($maxProgress >= $currentPos && $maxProgress === 2)): ?> + <?= $this->restartForm ?> + <?php endif ?> + </div> + <div class="step width-percent-60"> + <h1><?= $this->translate('Configuration', 'setup.progress'); ?></h1> + <table><tbody><tr> + <td class="left"> + <?php + $firstPage = current($configPagesLeft); + $lastPage = end($configPagesLeft); + $lineWidth = sprintf('%.2F', round(100 / count($configPagesLeft), 2, PHP_ROUND_HALF_DOWN)); + ?> + <?php foreach ($configPagesLeft as $pos => $page): ?> + <?php $stateClass = $finished || $pos < $currentPos ? ' complete' : ( + $pos < $maxProgress ? ' visited' : ($currentPos > 2 ? ' active' : '') + ); ?> + <?php if ($page === $firstPage): ?> + <?php + $setupStyle->add( + '.step .left-line-' . $pos, + [ + 'float' => 'left', + 'width' => sprintf( + '%.2F%%', + 100 - (count($configPagesLeft) - 1) * $lineWidth + ), + 'margin-right' => 0 + ] + ); + ?> + <div class="line left<?= $stateClass; ?> left-line-<?= $pos; ?>"></div> + <?php elseif ($page === $lastPage): ?> + <?php + $setupStyle->add( + '.step .left-line-' . $pos, + [ + 'float' => 'left', + 'width' => $lineWidth . '%', + 'margin-right' => '-0.1em' + ] + ); + ?> + <div class="line<?= $stateClass; ?> left-line-<?= $pos; ?>"></div> + <?php else: ?> + <?php + $setupStyle->add( + '.step .left-line-' . $pos, + [ + 'float' => 'left', + 'width' => $lineWidth . '%' + ] + ); + ?> + <div class="line<?= $stateClass; ?> left-line-<?= $pos; ?>"></div> + <?php endif ?> + <?php endforeach ?> + </td> + <td class="middle"> + <div class="bubble<?= array_key_exists($currentPos, $configPagesLeft) ? ( + key($configPagesRight) <= $maxProgress ? ' visited' : ' active') : ( + $finished || $currentPos > 2 ? ' complete' : ( + key($configPagesRight) < $maxProgress ? ' visited' : '' + ) + ); ?>"></div> + </td> + <td class="right"> + <?php + $firstPage = current($configPagesRight); + $lastPage = end($configPagesRight); + $lineWidth = sprintf('%.2F', round(100 / count($configPagesRight), 2, PHP_ROUND_HALF_DOWN)); + ?> + <?php foreach ($configPagesRight as $pos => $page): ?> + <?php $stateClass = $finished || $pos < $currentPos ? ' complete' : ( + $pos < $maxProgress ? ' visited' : ($currentPos > 2 ? ' active' : '') + ); ?> + <?php if ($page === $firstPage): ?> + <?php + $setupStyle->add( + '.step .right-line-' . $pos, + [ + 'float' => 'left', + 'width' => $lineWidth . '%', + 'margin-right' => '-0.1em' + ] + ); + ?> + <div class="line<?= $stateClass; ?> right-line-<?= $pos; ?>"></div> + <?php elseif ($page === $lastPage): ?> + <?php + $setupStyle->add( + '.step .right-line-' . $pos, + [ + 'float' => 'left', + 'width' => sprintf( + '%.2F%%', + 100 - (count($configPagesRight) - 1) * $lineWidth + ), + 'margin-right' => 0 + ] + ); + ?> + <div class="line right<?= $stateClass; ?> right-line-<?= $pos; ?>"></div> + <?php else: ?> + <?php + $setupStyle->add( + '.step .right-line-' . $pos, + [ + 'float' => 'left', + 'width' => $lineWidth . '%' + ] + ); + ?> + <div class="line<?= $stateClass; ?> right-line-<?= $pos; ?>"></div> + <?php endif ?> + <?php endforeach ?> + </td> + </tr></tbody></table> + <?php if ($maxProgress > 2 || $currentPos > 2): ?> + <?= $this->restartForm ?> + <?php endif ?> + </div> + <div class="step width-percent-10"> + <h1><?= $this->translate('Finish', 'setup.progress'); ?></h1> + <?php $stateClass = $finished ? ' complete' : ($pages[$currentPos] === end($pages) ? ' active' : ''); ?> + <table><tbody><tr> + <td class="left"><div class="line left<?= $stateClass; ?>"></div></td> + <td class="middle"><div class="bubble<?= $stateClass; ?>"></div></td> + <td class="right"></td> + </tr></tbody></table> + </div> + </div> + </div> + <div class="setup-content"> +<?php if ($finished): ?> + <?= $this->render('index/parts/finish.phtml'); ?> +<?php else: ?> + <?= $this->render('index/parts/wizard.phtml'); ?> +<?php endif ?> + </div> +</div> +<div id="footer"> + <ul role="alert" id="notifications"><?php + $notifications = Notification::getInstance(); + if ($notifications->hasMessages()) { + foreach ($notifications->popMessages() as $m) { + echo '<li class="' . $m->type . '">' . $this->escape($m->message) . '</li>'; + } + } + ?></ul> +</div> +<?= $setupStyle; ?> diff --git a/modules/setup/application/views/scripts/index/parts/finish.phtml b/modules/setup/application/views/scripts/index/parts/finish.phtml new file mode 100644 index 0000000..dcb34dc --- /dev/null +++ b/modules/setup/application/views/scripts/index/parts/finish.phtml @@ -0,0 +1,34 @@ +<div id="setup-finish"> + <?php if ($success): ?> + <h2 class="success"><?= $this->translate('Congratulations! Icinga Web 2 has been successfully set up.'); ?></h2> + <?php else: ?> + <h2 class="failure"><?= $this->translate('Sorry! Failed to set up Icinga Web 2 successfully.'); ?></h2> + <?php endif ?> + <div class="buttons pull-right"> + <?php if ($success): ?> + <?= $this->qlink( + $this->translate('Login to Icinga Web 2'), + 'authentication/login', + null, + array( + 'class' => 'button-link login', + 'data-no-icinga-ajax' => true, + 'title' => $this->translate('Show the login page of Icinga Web 2') + ) + ); ?> + <?php else: ?> + <?= $this->qlink( + $this->translate('Back'), + null, + null, + array( + 'class' => 'button-link', + 'title' => $this->translate('Show previous wizard-page') + ) + ); ?> + <?php endif ?> + </div> + <pre class="log-output"><?= join("\n\n", array_map(function($a) { + return join("\n", $a); + }, $report ?? [])); ?></pre> +</div> diff --git a/modules/setup/application/views/scripts/index/parts/wizard.phtml b/modules/setup/application/views/scripts/index/parts/wizard.phtml new file mode 100644 index 0000000..94891f9 --- /dev/null +++ b/modules/setup/application/views/scripts/index/parts/wizard.phtml @@ -0,0 +1 @@ +<?= $wizard->getForm()->render(); ?>
\ No newline at end of file diff --git a/modules/setup/library/Setup/Exception/SetupException.php b/modules/setup/library/Setup/Exception/SetupException.php new file mode 100644 index 0000000..c3ae591 --- /dev/null +++ b/modules/setup/library/Setup/Exception/SetupException.php @@ -0,0 +1,22 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Exception; + +use Icinga\Exception\IcingaException; + +/** + * Class SetupException + * + * Used to indicate that a setup should be aborted. + */ +class SetupException extends IcingaException +{ + /** + * {@inheritdoc} + */ + public function __construct() + { + parent::__construct('Setup abortion'); + } +} diff --git a/modules/setup/library/Setup/Requirement.php b/modules/setup/library/Setup/Requirement.php new file mode 100644 index 0000000..1df02ef --- /dev/null +++ b/modules/setup/library/Setup/Requirement.php @@ -0,0 +1,343 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +use LogicException; + +abstract class Requirement +{ + /** + * The state of this requirement + * + * @var bool + */ + protected $state; + + /** + * A descriptive text representing the current state of this requirement + * + * @var string + */ + protected $stateText; + + /** + * The descriptions of this requirement + * + * @var array + */ + protected $descriptions; + + /** + * The title of this requirement + * + * @var string + */ + protected $title; + + /** + * The condition of this requirement + * + * @var mixed + */ + protected $condition; + + /** + * Whether this requirement is optional + * + * @var bool + */ + protected $optional; + + /** + * The alias to display the condition with in a human readable way + * + * @var string + */ + protected $alias; + + /** + * The text to display if the given requirement is fulfilled + * + * @var string + */ + protected $textAvailable; + + /** + * The text to display if the given requirement is not fulfilled + * + * @var string + */ + protected $textMissing; + + /** + * Create a new requirement + * + * @param array $options + * + * @throws LogicException In case there exists no setter for an option's key + */ + public function __construct(array $options = array()) + { + $this->optional = false; + $this->descriptions = array(); + + foreach ($options as $key => $value) { + $setMethod = 'set' . ucfirst($key); + $addMethod = 'add' . ucfirst($key); + if (method_exists($this, $setMethod)) { + $this->$setMethod($value); + } elseif (method_exists($this, $addMethod)) { + $this->$addMethod($value); + } else { + throw new LogicException('No setter found for option key: ' . $key); + } + } + } + + /** + * Set the state of this requirement + * + * @param bool $state + * + * @return Requirement + */ + public function setState($state) + { + $this->state = (bool) $state; + return $this; + } + + /** + * Return the state of this requirement + * + * Evaluates the requirement in case there is no state set yet. + * + * @return int + */ + public function getState() + { + if ($this->state === null) { + $this->state = $this->evaluate(); + } + + return $this->state; + } + + /** + * Set a descriptive text for this requirement's current state + * + * @param string $text + * + * @return Requirement + */ + public function setStateText($text) + { + $this->stateText = $text; + return $this; + } + + /** + * Return a descriptive text for this requirement's current state + * + * @return string + */ + public function getStateText() + { + $state = $this->getState(); + if ($this->stateText === null) { + return $state ? $this->getTextAvailable() : $this->getTextMissing(); + } + return $this->stateText; + } + + /** + * Add a description for this requirement + * + * @param string $description + * + * @return Requirement + */ + public function addDescription($description) + { + $this->descriptions[] = $description; + return $this; + } + + /** + * Return the descriptions of this wizard + * + * @return array + */ + public function getDescriptions() + { + return $this->descriptions; + } + + /** + * Set the title for this requirement + * + * @param string $title + * + * @return Requirement + */ + public function setTitle($title) + { + $this->title = $title; + return $this; + } + + /** + * Return the title of this requirement + * + * In case there is no title set the alias is returned instead. + * + * @return string + */ + public function getTitle() + { + if ($this->title === null) { + return $this->getAlias(); + } + + return $this->title; + } + + /** + * Set the condition for this requirement + * + * @param mixed $condition + * + * @return Requirement + */ + public function setCondition($condition) + { + $this->condition = $condition; + return $this; + } + + /** + * Return the condition of this requirement + * + * @return mixed + */ + public function getCondition() + { + return $this->condition; + } + + /** + * Set whether this requirement is optional + * + * @param bool $state + * + * @return Requirement + */ + public function setOptional($state = true) + { + $this->optional = (bool) $state; + return $this; + } + + /** + * Return whether this requirement is optional + * + * @return bool + */ + public function isOptional() + { + return $this->optional; + } + + /** + * Set the alias to display the condition with in a human readable way + * + * @param string $alias + * + * @return Requirement + */ + public function setAlias($alias) + { + $this->alias = $alias; + return $this; + } + + /** + * Return the alias to display the condition with in a human readable way + * + * @return string + */ + public function getAlias() + { + return $this->alias; + } + + /** + * Set the text to display if the given requirement is fulfilled + * + * @param string $textAvailable + * + * @return Requirement + */ + public function setTextAvailable($textAvailable) + { + $this->textAvailable = $textAvailable; + return $this; + } + + /** + * Get the text to display if the given requirement is fulfilled + * + * @return string + */ + public function getTextAvailable() + { + return $this->textAvailable; + } + + /** + * Set the text to display if the given requirement is not fulfilled + * + * @param string $textMissing + * + * @return Requirement + */ + public function setTextMissing($textMissing) + { + $this->textMissing = $textMissing; + return $this; + } + + /** + * Get the text to display if the given requirement is not fulfilled + * + * @return string + */ + public function getTextMissing() + { + return $this->textMissing; + } + + /** + * Evaluate this requirement and return whether it is fulfilled + * + * @return bool + */ + abstract protected function evaluate(); + + /** + * Return whether the given requirement equals this one + * + * @param Requirement $requirement + * + * @return bool + */ + public function equals(Requirement $requirement) + { + if ($requirement instanceof static) { + return $this->getCondition() === $requirement->getCondition(); + } + + return false; + } +} diff --git a/modules/setup/library/Setup/Requirement/ClassRequirement.php b/modules/setup/library/Setup/Requirement/ClassRequirement.php new file mode 100644 index 0000000..d884c31 --- /dev/null +++ b/modules/setup/library/Setup/Requirement/ClassRequirement.php @@ -0,0 +1,48 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Platform; +use Icinga\Module\Setup\Requirement; + +class ClassRequirement extends Requirement +{ + protected function evaluate() + { + return Platform::classExists($this->getCondition()); + } + + /** + * {@inheritdoc} + */ + public function getStateText() + { + $stateText = parent::getStateText(); + if ($stateText === null) { + $alias = $this->getAlias(); + if ($this->getState()) { + $stateText = $alias === null + ? sprintf( + mt('setup', 'The %s class is available.', 'setup.requirement.class'), + $this->getCondition() + ) + : sprintf( + mt('setup', 'The %s is available.', 'setup.requirement.class'), + $alias + ); + } else { + $stateText = $alias === null + ? sprintf( + mt('setup', 'The %s class is missing.', 'setup.requirement.class'), + $this->getCondition() + ) + : sprintf( + mt('setup', 'The %s is missing.', 'setup.requirement.class'), + $alias + ); + } + } + return $stateText; + } +} diff --git a/modules/setup/library/Setup/Requirement/ConfigDirectoryRequirement.php b/modules/setup/library/Setup/Requirement/ConfigDirectoryRequirement.php new file mode 100644 index 0000000..7e9044c --- /dev/null +++ b/modules/setup/library/Setup/Requirement/ConfigDirectoryRequirement.php @@ -0,0 +1,42 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Module\Setup\Requirement; + +class ConfigDirectoryRequirement extends Requirement +{ + public function getTitle() + { + $title = parent::getTitle(); + if ($title === null) { + return mt('setup', 'Read- and writable configuration directory'); + } + + return $title; + } + + protected function evaluate() + { + $path = $this->getCondition(); + if (file_exists($path)) { + $readable = is_readable($path); + if ($readable && is_writable($path)) { + $this->setStateText(sprintf(mt('setup', 'The directory %s is read- and writable.'), $path)); + return true; + } else { + $this->setStateText(sprintf( + $readable + ? mt('setup', 'The directory %s is not writable.') + : mt('setup', 'The directory %s is not readable.'), + $path + )); + return false; + } + } else { + $this->setStateText(sprintf(mt('setup', 'The directory %s does not exist.'), $path)); + return false; + } + } +} diff --git a/modules/setup/library/Setup/Requirement/OSRequirement.php b/modules/setup/library/Setup/Requirement/OSRequirement.php new file mode 100644 index 0000000..760c97a --- /dev/null +++ b/modules/setup/library/Setup/Requirement/OSRequirement.php @@ -0,0 +1,27 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Platform; +use Icinga\Module\Setup\Requirement; + +class OSRequirement extends Requirement +{ + public function getTitle() + { + $title = parent::getTitle(); + if ($title === null) { + return sprintf(mt('setup', '%s Platform'), ucfirst($this->getCondition())); + } + + return $title; + } + + protected function evaluate() + { + $phpOS = Platform::getOperatingSystemName(); + $this->setStateText(sprintf(mt('setup', 'You are running PHP on a %s system.'), ucfirst($phpOS))); + return strtolower($phpOS) === strtolower($this->getCondition()); + } +} diff --git a/modules/setup/library/Setup/Requirement/PhpConfigRequirement.php b/modules/setup/library/Setup/Requirement/PhpConfigRequirement.php new file mode 100644 index 0000000..6c77af5 --- /dev/null +++ b/modules/setup/library/Setup/Requirement/PhpConfigRequirement.php @@ -0,0 +1,22 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Platform; +use Icinga\Module\Setup\Requirement; + +class PhpConfigRequirement extends Requirement +{ + protected function evaluate() + { + list($configDirective, $value) = $this->getCondition(); + $configValue = Platform::getPhpConfig($configDirective); + $this->setStateText( + $configValue + ? sprintf(mt('setup', 'The PHP config `%s\' is set to "%s".'), $configDirective, $configValue) + : sprintf(mt('setup', 'The PHP config `%s\' is not defined.'), $configDirective) + ); + return is_bool($value) ? $configValue == $value : $configValue === $value; + } +} diff --git a/modules/setup/library/Setup/Requirement/PhpModuleRequirement.php b/modules/setup/library/Setup/Requirement/PhpModuleRequirement.php new file mode 100644 index 0000000..f8ab129 --- /dev/null +++ b/modules/setup/library/Setup/Requirement/PhpModuleRequirement.php @@ -0,0 +1,42 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Platform; +use Icinga\Module\Setup\Requirement; + +class PhpModuleRequirement extends Requirement +{ + public function getTitle() + { + $title = parent::getTitle(); + if ($title === $this->getAlias()) { + if ($title === null) { + $title = $this->getCondition(); + } + + return sprintf(mt('setup', 'PHP Module: %s'), $title); + } + + return $title; + } + + protected function evaluate() + { + $moduleName = $this->getCondition(); + if (Platform::extensionLoaded($moduleName)) { + $this->setStateText(sprintf( + mt('setup', 'The PHP module %s is available.'), + $this->getAlias() ?: $moduleName + )); + return true; + } else { + $this->setStateText(sprintf( + mt('setup', 'The PHP module %s is missing.'), + $this->getAlias() ?: $moduleName + )); + return false; + } + } +} diff --git a/modules/setup/library/Setup/Requirement/PhpVersionRequirement.php b/modules/setup/library/Setup/Requirement/PhpVersionRequirement.php new file mode 100644 index 0000000..b811ca8 --- /dev/null +++ b/modules/setup/library/Setup/Requirement/PhpVersionRequirement.php @@ -0,0 +1,28 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Platform; +use Icinga\Module\Setup\Requirement; + +class PhpVersionRequirement extends Requirement +{ + public function getTitle() + { + $title = parent::getTitle(); + if ($title === null) { + return mt('setup', 'PHP Version'); + } + + return $title; + } + + protected function evaluate() + { + $phpVersion = Platform::getPhpVersion(); + $this->setStateText(sprintf(mt('setup', 'You are running PHP version %s.'), $phpVersion)); + list($operator, $requiredVersion) = $this->getCondition(); + return version_compare($phpVersion, $requiredVersion, $operator); + } +} diff --git a/modules/setup/library/Setup/Requirement/SetRequirement.php b/modules/setup/library/Setup/Requirement/SetRequirement.php new file mode 100644 index 0000000..77cbaf0 --- /dev/null +++ b/modules/setup/library/Setup/Requirement/SetRequirement.php @@ -0,0 +1,34 @@ +<?php +/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Module\Setup\Requirement; + +/** + * Add requirement field + * + * @package Icinga\Module\Setup\Requirement + */ +class SetRequirement extends Requirement +{ + protected function evaluate() + { + $condition = $this->getCondition(); + + if ($condition->getState()) { + $this->setStateText(sprintf( + mt('setup', '%s is available.'), + $this->getAlias() ?: $this->getTitle() + )); + return true; + } + + $this->setStateText(sprintf( + mt('setup', '%s is missing.'), + $this->getAlias() ?: $this->getTitle() + )); + + return false; + } +} diff --git a/modules/setup/library/Setup/Requirement/WebLibraryRequirement.php b/modules/setup/library/Setup/Requirement/WebLibraryRequirement.php new file mode 100644 index 0000000..bab587a --- /dev/null +++ b/modules/setup/library/Setup/Requirement/WebLibraryRequirement.php @@ -0,0 +1,24 @@ +<?php +/* Icinga Web 2 | (c) 2021 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Icinga; +use Icinga\Module\Setup\Requirement; + +class WebLibraryRequirement extends Requirement +{ + protected function evaluate() + { + list($name, $op, $version) = $this->getCondition(); + + $libs = Icinga::app()->getLibraries(); + if (! $libs->has($name)) { + $this->setStateText(sprintf(mt('setup', '%s is not installed'), $this->getAlias())); + return false; + } + + $this->setStateText(sprintf(mt('setup', '%s version: %s'), $this->getAlias(), $libs->get($name)->getVersion())); + return $libs->has($name, $op . $version); + } +} diff --git a/modules/setup/library/Setup/Requirement/WebModuleRequirement.php b/modules/setup/library/Setup/Requirement/WebModuleRequirement.php new file mode 100644 index 0000000..ad600e1 --- /dev/null +++ b/modules/setup/library/Setup/Requirement/WebModuleRequirement.php @@ -0,0 +1,31 @@ +<?php +/* Icinga Web 2 | (c) 2020 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Module\Setup\Requirement; + +use Icinga\Application\Icinga; +use Icinga\Module\Setup\Requirement; + +class WebModuleRequirement extends Requirement +{ + protected function evaluate() + { + list($name, $op, $version) = $this->getCondition(); + + $mm = Icinga::app()->getModuleManager(); + if (! $mm->hasInstalled($name)) { + $this->setStateText(sprintf(mt('setup', '%s is not installed'), $this->getAlias())); + return false; + } + + $module = $mm->getModule($name, false); + + $moduleVersion = $module->getVersion(); + if ($moduleVersion[0] === 'v') { + $moduleVersion = substr($moduleVersion, 1); + } + + $this->setStateText(sprintf(mt('setup', '%s version: %s'), $this->getAlias(), $moduleVersion)); + return version_compare($moduleVersion, $version, $op); + } +} diff --git a/modules/setup/library/Setup/RequirementSet.php b/modules/setup/library/Setup/RequirementSet.php new file mode 100644 index 0000000..0baf4c0 --- /dev/null +++ b/modules/setup/library/Setup/RequirementSet.php @@ -0,0 +1,335 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +use LogicException; +use RecursiveIterator; +use Traversable; + +/** + * Container to store and handle requirements + */ +class RequirementSet implements RecursiveIterator +{ + /** + * Mode AND (all requirements must be met) + */ + const MODE_AND = 0; + + /** + * Mode OR (at least one requirement must be met) + */ + const MODE_OR = 1; + + /** + * Whether all requirements meet their condition + * + * @var bool + */ + protected $state; + + /** + * Whether this set is optional + * + * @var bool + */ + protected $optional; + + /** + * The mode by which the requirements are evaluated + * + * @var string + */ + protected $mode; + + /** + * The registered requirements + * + * @var array + */ + protected $requirements; + + /** + * The raw state of this set's requirements + * + * @var bool + */ + private $forcedState; + + /** + * Initialize a new set of requirements + * + * @param bool $optional Whether this set is optional + * @param int $mode The mode by which to evaluate this set + */ + public function __construct($optional = false, $mode = null) + { + $this->optional = $optional; + $this->requirements = array(); + $this->setMode($mode ?: static::MODE_AND); + } + + /** + * Set the state of this set + * + * @param bool $state + * + * @return RequirementSet + */ + public function setState($state) + { + $this->state = (bool) $state; + return $this; + } + + /** + * Return the state of this set + * + * Alias for RequirementSet::fulfilled(true). + * + * @return bool + */ + public function getState() + { + return $this->fulfilled(true); + } + + /** + * Set whether this set of requirements should be optional + * + * @param bool $state + * + * @return RequirementSet + */ + public function setOptional($state = true) + { + $this->optional = (bool) $state; + return $this; + } + + /** + * Return whether this set of requirements is optional + * + * @return bool + */ + public function isOptional() + { + return $this->optional; + } + + /** + * Set the mode by which to evaluate the requirements + * + * @param int $mode + * + * @return RequirementSet + * + * @throws LogicException In case the given mode is invalid + */ + public function setMode($mode) + { + if ($mode !== static::MODE_AND && $mode !== static::MODE_OR) { + throw new LogicException(sprintf('Invalid mode %u given.', $mode)); + } + + $this->mode = $mode; + return $this; + } + + /** + * Return the mode by which the requirements are evaluated + * + * @return int + */ + public function getMode() + { + return $this->mode; + } + + /** + * Register a requirement + * + * @param Requirement $requirement The requirement to add + * + * @return RequirementSet + */ + public function add(Requirement $requirement) + { + $merged = false; + foreach ($this->requirements as $knownRequirement) { + if ($knownRequirement instanceof Requirement && $requirement->equals($knownRequirement)) { + $knownRequirement->setOptional($requirement->isOptional()); + foreach ($requirement->getDescriptions() as $description) { + $knownRequirement->addDescription($description); + } + + $merged = true; + break; + } + } + + if (! $merged) { + $this->requirements[] = $requirement; + } + + return $this; + } + + /** + * Return all registered requirements + * + * @return array + */ + public function getAll() + { + return $this->requirements; + } + + /** + * Register the given set of requirements + * + * @param RequirementSet $set The set to register + * + * @return RequirementSet + */ + public function merge(RequirementSet $set) + { + if ($this->getMode() === $set->getMode() && $this->isOptional() === $set->isOptional()) { + foreach ($set->getAll() as $requirement) { + if ($requirement instanceof static) { + $this->merge($requirement); + } else { + $this->add($requirement); + } + } + } else { + $this->requirements[] = $set; + } + + return $this; + } + + /** + * Return whether all requirements can successfully be evaluated based on the current mode + * + * In case this is a optional set of requirements (and $force is false), true is returned immediately. + * + * @param bool $force Whether to ignore the optionality of a set or single requirement + * + * @return bool + */ + public function fulfilled($force = false) + { + $state = $this->isOptional(); + if (! $force && $state) { + return true; + } + + if (! $force && $this->state !== null) { + return $this->state; + } elseif ($force && $this->forcedState !== null) { + return $this->forcedState; + } + + $self = $this->requirements; + foreach ($self as $requirement) { + if ($requirement->getState()) { + $state = true; + if ($this->getMode() === static::MODE_OR) { + break; + } + } elseif ($force || !$requirement->isOptional()) { + $state = false; + if ($this->getMode() === static::MODE_AND) { + break; + } + } + } + + if ($force) { + return $this->forcedState = $state; + } + + return $this->state = $state; + } + + /** + * Return whether the current element represents a nested set of requirements + * + * @return bool + */ + public function hasChildren(): bool + { + $current = $this->current(); + return $current instanceof static; + } + + /** + * Return a iterator for the current nested set of requirements + * + * @return ?RecursiveIterator + */ + public function getChildren(): ?RecursiveIterator + { + return $this->current(); + } + + /** + * Rewind the iterator to its first element + */ + public function rewind(): void + { + reset($this->requirements); + } + + /** + * Return whether the current iterator position is valid + * + * @return bool + */ + public function valid(): bool + { + return key($this->requirements) !== null; + } + + /** + * Return the current element in the iteration + * + * @return Requirement|RequirementSet + */ + #[\ReturnTypeWillChange] + public function current() + { + return current($this->requirements); + } + + /** + * Return the position of the current element in the iteration + * + * @return int + */ + public function key(): int + { + return key($this->requirements); + } + + /** + * Advance the iterator to the next element + */ + public function next(): void + { + next($this->requirements); + } + + /** + * Return this set of requirements rendered as HTML + * + * @return string + */ + public function __toString() + { + $renderer = new RequirementsRenderer($this); + return (string) $renderer; + } +} diff --git a/modules/setup/library/Setup/RequirementsRenderer.php b/modules/setup/library/Setup/RequirementsRenderer.php new file mode 100644 index 0000000..94f0f2b --- /dev/null +++ b/modules/setup/library/Setup/RequirementsRenderer.php @@ -0,0 +1,67 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +use RecursiveIteratorIterator; + +class RequirementsRenderer extends RecursiveIteratorIterator +{ + protected $tags; + + public function beginIteration(): void + { + $this->tags[] = '<ul class="requirements">'; + } + + public function endIteration(): void + { + $this->tags[] = '</ul>'; + } + + public function beginChildren(): void + { + $this->tags[] = '<li>'; + /** @var RequirementSet $currentSet */ + $currentSet = $this->getSubIterator(); + $state = $currentSet->getState() ? 'fulfilled' : ($currentSet->isOptional() ? 'not-available' : 'missing'); + $this->tags[] = '<ul class="set-state ' . $state . '">'; + } + + public function endChildren(): void + { + $this->tags[] = '</ul>'; + $this->tags[] = '</li>'; + } + + public function render() + { + foreach ($this as $requirement) { + $this->tags[] = '<li class="clearfix">'; + $this->tags[] = '<div class="title"><h2>' . $requirement->getTitle() . '</h2></div>'; + $this->tags[] = '<div class="description">'; + $descriptions = $requirement->getDescriptions(); + if (count($descriptions) > 1) { + $this->tags[] = '<ul>'; + foreach ($descriptions as $d) { + $this->tags[] = '<li>' . $d . '</li>'; + } + $this->tags[] = '</ul>'; + } elseif (! empty($descriptions)) { + $this->tags[] = $descriptions[0]; + } + $this->tags[] = '</div>'; + $this->tags[] = '<div class="state ' . ($requirement->getState() ? 'fulfilled' : ( + $requirement->isOptional() ? 'not-available' : 'missing' + )) . '">' . $requirement->getStateText() . '</div>'; + $this->tags[] = '</li>'; + } + + return implode("\n", $this->tags); + } + + public function __toString() + { + return $this->render(); + } +} diff --git a/modules/setup/library/Setup/Setup.php b/modules/setup/library/Setup/Setup.php new file mode 100644 index 0000000..7b0baed --- /dev/null +++ b/modules/setup/library/Setup/Setup.php @@ -0,0 +1,99 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +use ArrayIterator; +use IteratorAggregate; +use Icinga\Module\Setup\Exception\SetupException; +use Traversable; + +/** + * Container for multiple configuration steps + */ +class Setup implements IteratorAggregate +{ + protected $steps; + + protected $state; + + public function __construct() + { + $this->steps = array(); + } + + public function getIterator(): Traversable + { + return new ArrayIterator($this->getSteps()); + } + + public function addStep(Step $step) + { + $this->steps[] = $step; + } + + public function addSteps(array $steps) + { + foreach ($steps as $step) { + $this->addStep($step); + } + } + + public function getSteps() + { + return $this->steps; + } + + /** + * Run the configuration and return whether it succeeded + * + * @return bool + */ + public function run() + { + $this->state = true; + + try { + foreach ($this->steps as $step) { + $this->state &= $step->apply(); + } + } catch (SetupException $_) { + $this->state = false; + } + + return $this->state; + } + + /** + * Return a summary of all actions designated to run + * + * @return array An array of HTML strings + */ + public function getSummary() + { + $summaries = array(); + foreach ($this->steps as $step) { + $summaries[] = $step->getSummary(); + } + + return $summaries; + } + + /** + * Return a report of all actions that were run + * + * @return array An array of arrays of strings + */ + public function getReport() + { + $reports = array(); + foreach ($this->steps as $step) { + $report = $step->getReport(); + if (! empty($report)) { + $reports[] = $report; + } + } + + return $reports; + } +} diff --git a/modules/setup/library/Setup/SetupWizard.php b/modules/setup/library/Setup/SetupWizard.php new file mode 100644 index 0000000..c7ad0c3 --- /dev/null +++ b/modules/setup/library/Setup/SetupWizard.php @@ -0,0 +1,24 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +/** + * Interface for wizards providing a setup and requirements + */ +interface SetupWizard +{ + /** + * Return the setup for this wizard + * + * @return Setup + */ + public function getSetup(); + + /** + * Return the requirements of this wizard + * + * @return RequirementSet + */ + public function getRequirements(); +} diff --git a/modules/setup/library/Setup/Step.php b/modules/setup/library/Setup/Step.php new file mode 100644 index 0000000..4b9afcc --- /dev/null +++ b/modules/setup/library/Setup/Step.php @@ -0,0 +1,31 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +/** + * Class to implement functionality for a single setup step + */ +abstract class Step +{ + /** + * Apply this step's configuration changes + * + * @return bool + */ + abstract public function apply(); + + /** + * Return a HTML representation of this step's configuration changes supposed to be made + * + * @return string + */ + abstract public function getSummary(); + + /** + * Return a textual summary of all configuration changes made + * + * @return ?array + */ + abstract public function getReport(); +} diff --git a/modules/setup/library/Setup/Steps/AuthenticationStep.php b/modules/setup/library/Setup/Steps/AuthenticationStep.php new file mode 100644 index 0000000..3c6c64a --- /dev/null +++ b/modules/setup/library/Setup/Steps/AuthenticationStep.php @@ -0,0 +1,238 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Steps; + +use Exception; +use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\IcingaException; +use Icinga\Authentication\User\DbUserBackend; +use Icinga\Module\Setup\Step; + +class AuthenticationStep extends Step +{ + protected $data; + + protected $dbError; + + protected $authIniError; + + protected $permIniError; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $success = $this->createAuthenticationIni(); + if (isset($this->data['adminAccountData']['resourceConfig'])) { + $success &= $this->createAccount(); + } + + $success &= $this->createRolesIni(); + return $success; + } + + protected function createAuthenticationIni() + { + $config = array(); + $backendConfig = $this->data['backendConfig']; + $backendName = $backendConfig['name']; + unset($backendConfig['name']); + $config[$backendName] = $backendConfig; + if (isset($this->data['resourceName'])) { + $config[$backendName]['resource'] = $this->data['resourceName']; + } + + try { + Config::fromArray($config) + ->setConfigFile(Config::resolvePath('authentication.ini')) + ->saveIni(); + } catch (Exception $e) { + $this->authIniError = $e; + return false; + } + + $this->authIniError = false; + return true; + } + + protected function createRolesIni() + { + if (isset($this->data['adminAccountData']['username'])) { + $config = array( + 'users' => $this->data['adminAccountData']['username'], + 'permissions' => '*' + ); + + if ($this->data['backendConfig']['backend'] === 'db') { + $config['groups'] = mt('setup', 'Administrators', 'setup.role.name'); + } + } else { // isset($this->data['adminAccountData']['groupname']) + $config = array( + 'groups' => $this->data['adminAccountData']['groupname'], + 'permissions' => '*' + ); + } + + try { + Config::fromArray(array(mt('setup', 'Administrators', 'setup.role.name') => $config)) + ->setConfigFile(Config::resolvePath('roles.ini')) + ->saveIni(); + } catch (Exception $e) { + $this->permIniError = $e; + return false; + } + + $this->permIniError = false; + return true; + } + + protected function createAccount() + { + try { + $backend = new DbUserBackend( + ResourceFactory::createResource(new ConfigObject($this->data['adminAccountData']['resourceConfig'])) + ); + + if ($backend->select()->where('user_name', $this->data['adminAccountData']['username'])->count() === 0) { + $backend->insert('user', array( + 'user_name' => $this->data['adminAccountData']['username'], + 'password' => $this->data['adminAccountData']['password'], + 'is_active' => true + )); + $this->dbError = false; + } + } catch (Exception $e) { + $this->dbError = $e; + return false; + } + + return true; + } + + public function getSummary() + { + $pageTitle = '<h2>' . mt('setup', 'Authentication', 'setup.page.title') . '</h2>'; + $backendTitle = '<h3>' . mt('setup', 'Authentication Backend', 'setup.page.title') . '</h3>'; + $adminTitle = '<h3>' . mt('setup', 'Administration', 'setup.page.title') . '</h3>'; + + $authType = $this->data['backendConfig']['backend']; + $backendDesc = '<p>' . sprintf( + mt('setup', 'Users will authenticate using %s.', 'setup.summary.auth'), + $authType === 'db' ? mt('setup', 'a database', 'setup.summary.auth.type') : ( + $authType === 'ldap' || $authType === 'msldap' ? 'LDAP' : ( + mt('setup', 'webserver authentication', 'setup.summary.auth.type') + ) + ) + ) . '</p>'; + + $backendHtml = '' + . '<table>' + . '<tbody>' + . '<tr>' + . '<td><strong>' . t('Backend Name') . '</strong></td>' + . '<td>' . $this->data['backendConfig']['name'] . '</td>' + . '</tr>' + . ($authType === 'ldap' || $authType === 'msldap' ? ( + '<tr>' + . '<td><strong>' . mt('setup', 'User Object Class') . '</strong></td>' + . '<td>' . ($authType === 'msldap' ? 'user' : $this->data['backendConfig']['user_class']) . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . mt('setup', 'Custom Filter') . '</strong></td>' + . '<td>' . (trim($this->data['backendConfig']['filter']) ?: t('None', 'auth.ldap.filter')) . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . mt('setup', 'User Name Attribute') . '</strong></td>' + . '<td>' . ($authType === 'msldap' + ? 'sAMAccountName' + : $this->data['backendConfig']['user_name_attribute']) . '</td>' + . '</tr>' + ) : ($authType === 'external' ? ( + '<tr>' + . '<td><strong>' . t('Filter Pattern') . '</strong></td>' + . '<td>' . $this->data['backendConfig']['strip_username_regexp'] . '</td>' + . '</tr>' + ) : '')) + . '</tbody>' + . '</table>'; + + if (isset($this->data['adminAccountData']['username'])) { + $adminHtml = '<p>' . (isset($this->data['adminAccountData']['resourceConfig']) ? sprintf( + mt('setup', 'Administrative rights will initially be granted to a new account called "%s".'), + $this->data['adminAccountData']['username'] + ) : sprintf( + mt('setup', 'Administrative rights will initially be granted to an existing account called "%s".'), + $this->data['adminAccountData']['username'] + )) . '</p>'; + } else { // isset($this->data['adminAccountData']['groupname']) + $adminHtml = '<p>' . sprintf( + mt('setup', 'Administrative rights will initially be granted to members of the user group "%s".'), + $this->data['adminAccountData']['groupname'] + ) . '</p>'; + } + + return $pageTitle . '<div class="topic">' . $backendDesc . $backendTitle . $backendHtml . '</div>' + . '<div class="topic">' . $adminTitle . $adminHtml . '</div>'; + } + + public function getReport() + { + $report = array(); + + if ($this->authIniError === false) { + $report[] = sprintf( + mt('setup', 'Authentication configuration has been successfully written to: %s'), + Config::resolvePath('authentication.ini') + ); + } elseif ($this->authIniError !== null) { + $report[] = sprintf( + mt('setup', 'Authentication configuration could not be written to: %s. An error occured:'), + Config::resolvePath('authentication.ini') + ); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->authIniError)); + } + + if ($this->dbError === false) { + $report[] = sprintf( + mt('setup', 'Account "%s" has been successfully created.'), + $this->data['adminAccountData']['username'] + ); + } elseif ($this->dbError !== null) { + $report[] = sprintf( + mt('setup', 'Unable to create account "%s". An error occured:'), + $this->data['adminAccountData']['username'] + ); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->dbError)); + } + + if ($this->permIniError === false) { + $report[] = isset($this->data['adminAccountData']['username']) ? sprintf( + mt('setup', 'Account "%s" has been successfully defined as initial administrator.'), + $this->data['adminAccountData']['username'] + ) : sprintf( + mt('setup', 'The members of the user group "%s" were successfully defined as initial administrators.'), + $this->data['adminAccountData']['groupname'] + ); + } elseif ($this->permIniError !== null) { + $report[] = isset($this->data['adminAccountData']['username']) ? sprintf( + mt('setup', 'Unable to define account "%s" as initial administrator. An error occured:'), + $this->data['adminAccountData']['username'] + ) : sprintf( + mt( + 'setup', + 'Unable to define the members of the user group "%s" as initial administrators. An error occured:' + ), + $this->data['adminAccountData']['groupname'] + ); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->permIniError)); + } + + return $report; + } +} diff --git a/modules/setup/library/Setup/Steps/DatabaseStep.php b/modules/setup/library/Setup/Steps/DatabaseStep.php new file mode 100644 index 0000000..32b2d15 --- /dev/null +++ b/modules/setup/library/Setup/Steps/DatabaseStep.php @@ -0,0 +1,266 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Steps; + +use Exception; +use PDOException; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; +use Icinga\Module\Setup\Utils\DbTool; +use Icinga\Module\Setup\Exception\SetupException; + +class DatabaseStep extends Step +{ + protected $data; + + protected $error; + + protected $messages; + + public function __construct(array $data) + { + $this->data = $data; + $this->messages = array(); + } + + public function apply() + { + $resourceConfig = $this->data['resourceConfig']; + if (isset($this->data['adminName'])) { + $resourceConfig['username'] = $this->data['adminName']; + if (isset($this->data['adminPassword'])) { + $resourceConfig['password'] = $this->data['adminPassword']; + } + } + + $db = new DbTool($resourceConfig); + + try { + if ($resourceConfig['db'] === 'mysql') { + $this->setupMysqlDatabase($db); + } elseif ($resourceConfig['db'] === 'pgsql') { + $this->setupPgsqlDatabase($db); + } + } catch (Exception $e) { + $this->error = $e; + throw new SetupException(); + } + + $this->error = false; + return true; + } + + protected function setupMysqlDatabase(DbTool $db) + { + try { + $db->connectToDb(); + $this->log( + mt('setup', 'Successfully connected to existing database "%s"...'), + $this->data['resourceConfig']['dbname'] + ); + } catch (PDOException $_) { + $db->connectToHost(); + $this->log(mt('setup', 'Creating new database "%s"...'), $this->data['resourceConfig']['dbname']); + $db->exec('CREATE DATABASE ' . $db->quoteIdentifier($this->data['resourceConfig']['dbname'])); + $db->reconnect($this->data['resourceConfig']['dbname']); + } + + if (array_search(reset($this->data['tables']), $db->listTables(), true) !== false) { + $this->log(mt('setup', 'Database schema already exists...')); + } else { + $this->log(mt('setup', 'Creating database schema...')); + $db->import($this->data['schemaPath'] . '/mysql.schema.sql'); + } + + if ($db->hasLogin($this->data['resourceConfig']['username'])) { + $this->log(mt('setup', 'Login "%s" already exists...'), $this->data['resourceConfig']['username']); + } else { + $this->log(mt('setup', 'Creating login "%s"...'), $this->data['resourceConfig']['username']); + $db->addLogin($this->data['resourceConfig']['username'], $this->data['resourceConfig']['password']); + } + + $username = $this->data['resourceConfig']['username']; + if ($db->checkPrivileges($this->data['privileges'], $this->data['tables'], $username)) { + $this->log( + mt('setup', 'Required privileges were already granted to login "%s".'), + $this->data['resourceConfig']['username'] + ); + } else { + $this->log( + mt('setup', 'Granting required privileges to login "%s"...'), + $this->data['resourceConfig']['username'] + ); + $db->grantPrivileges( + $this->data['privileges'], + $this->data['tables'], + $this->data['resourceConfig']['username'] + ); + } + } + + protected function setupPgsqlDatabase(DbTool $db) + { + try { + $db->connectToDb(); + $this->log( + mt('setup', 'Successfully connected to existing database "%s"...'), + $this->data['resourceConfig']['dbname'] + ); + } catch (PDOException $_) { + $db->connectToHost(); + $this->log(mt('setup', 'Creating new database "%s"...'), $this->data['resourceConfig']['dbname']); + $db->exec(sprintf( + "CREATE DATABASE %s WITH ENCODING 'UTF-8'", + $db->quoteIdentifier($this->data['resourceConfig']['dbname']) + )); + $db->reconnect($this->data['resourceConfig']['dbname']); + } + + if (array_search(reset($this->data['tables']), $db->listTables(), true) !== false) { + $this->log(mt('setup', 'Database schema already exists...')); + } else { + $this->log(mt('setup', 'Creating database schema...')); + $db->import($this->data['schemaPath'] . '/pgsql.schema.sql'); + } + + if ($db->hasLogin($this->data['resourceConfig']['username'])) { + $this->log(mt('setup', 'Login "%s" already exists...'), $this->data['resourceConfig']['username']); + } else { + $this->log(mt('setup', 'Creating login "%s"...'), $this->data['resourceConfig']['username']); + $db->addLogin($this->data['resourceConfig']['username'], $this->data['resourceConfig']['password']); + } + + $username = $this->data['resourceConfig']['username']; + if ($db->checkPrivileges($this->data['privileges'], $this->data['tables'], $username)) { + $this->log( + mt('setup', 'Required privileges were already granted to login "%s".'), + $this->data['resourceConfig']['username'] + ); + } else { + $this->log( + mt('setup', 'Granting required privileges to login "%s"...'), + $this->data['resourceConfig']['username'] + ); + $db->grantPrivileges( + $this->data['privileges'], + $this->data['tables'], + $this->data['resourceConfig']['username'] + ); + } + } + + public function getSummary() + { + $resourceConfig = $this->data['resourceConfig']; + if (isset($this->data['adminName'])) { + $resourceConfig['username'] = $this->data['adminName']; + if (isset($this->data['adminPassword'])) { + $resourceConfig['password'] = $this->data['adminPassword']; + } + } + + $db = new DbTool($resourceConfig); + + try { + $db->connectToDb(); + if (array_search(reset($this->data['tables']), $db->listTables(), true) === false) { + if ($resourceConfig['username'] !== $this->data['resourceConfig']['username']) { + $message = sprintf( + mt( + 'setup', + 'The database user "%s" will be used to setup the missing schema required by Icinga' + . ' Web 2 in database "%s" and to grant access to it to a new login called "%s".' + ), + $resourceConfig['username'], + $resourceConfig['dbname'], + $this->data['resourceConfig']['username'] + ); + } else { + $message = sprintf( + mt( + 'setup', + 'The database user "%s" will be used to setup the missing' + . ' schema required by Icinga Web 2 in database "%s".' + ), + $resourceConfig['username'], + $resourceConfig['dbname'] + ); + } + } else { + $message = sprintf( + mt('setup', 'The database "%s" already seems to be fully set up. No action required.'), + $resourceConfig['dbname'] + ); + } + } catch (PDOException $_) { + try { + $db->connectToHost(); + if ($resourceConfig['username'] !== $this->data['resourceConfig']['username']) { + if ($db->hasLogin($this->data['resourceConfig']['username'])) { + $message = sprintf( + mt( + 'setup', + 'The database user "%s" will be used to create the missing database' + . ' "%s" with the schema required by Icinga Web 2 and to grant' + . ' access to it to an existing login called "%s".' + ), + $resourceConfig['username'], + $resourceConfig['dbname'], + $this->data['resourceConfig']['username'] + ); + } else { + $message = sprintf( + mt( + 'setup', + 'The database user "%s" will be used to create the missing database' + . ' "%s" with the schema required by Icinga Web 2 and to grant' + . ' access to it to a new login called "%s".' + ), + $resourceConfig['username'], + $resourceConfig['dbname'], + $this->data['resourceConfig']['username'] + ); + } + } else { + $message = sprintf( + mt( + 'setup', + 'The database user "%s" will be used to create the missing' + . ' database "%s" with the schema required by Icinga Web 2.' + ), + $resourceConfig['username'], + $resourceConfig['dbname'] + ); + } + } catch (Exception $_) { + $message = mt( + 'setup', + 'No connection to database host possible. You\'ll need to setup the' + . ' database with the schema required by Icinga Web 2 manually.' + ); + } + } + + return '<h2>' . mt('setup', 'Database Setup', 'setup.page.title') . '</h2><p>' . $message . '</p>'; + } + + public function getReport() + { + if ($this->error === false) { + $report = $this->messages; + $report[] = mt('setup', 'The database has been fully set up!'); + return $report; + } elseif ($this->error !== null) { + $report = $this->messages; + $report[] = mt('setup', 'Failed to fully setup the database. An error occured:'); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error)); + return $report; + } + } + + protected function log() + { + $this->messages[] = call_user_func_array('sprintf', func_get_args()); + } +} diff --git a/modules/setup/library/Setup/Steps/GeneralConfigStep.php b/modules/setup/library/Setup/Steps/GeneralConfigStep.php new file mode 100644 index 0000000..5deb18d --- /dev/null +++ b/modules/setup/library/Setup/Steps/GeneralConfigStep.php @@ -0,0 +1,133 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Steps; + +use Exception; +use Icinga\Application\Logger; +use Icinga\Application\Config; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; + +class GeneralConfigStep extends Step +{ + protected $data; + + protected $error; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $config = array(); + foreach ($this->data['generalConfig'] as $sectionAndPropertyName => $value) { + list($section, $property) = explode('_', $sectionAndPropertyName, 2); + $config[$section][$property] = $value; + } + + $config['global']['config_resource'] = $this->data['resourceName']; + + try { + Config::fromArray($config) + ->setConfigFile(Config::resolvePath('config.ini')) + ->saveIni(); + } catch (Exception $e) { + $this->error = $e; + return false; + } + + $this->error = false; + return true; + } + + public function getSummary() + { + $pageTitle = '<h2>' . mt('setup', 'Application Configuration', 'setup.page.title') . '</h2>'; + $generalTitle = '<h3>' . t('General', 'app.config') . '</h3>'; + $loggingTitle = '<h3>' . t('Logging', 'app.config') . '</h3>'; + + $generalHtml = '' + . '<ul>' + . '<li>' . ($this->data['generalConfig']['global_show_stacktraces'] + ? t('An exception\'s stacktrace is shown to every user by default.') + : t('An exception\'s stacktrace is hidden from every user by default.') + ) . '</li>' + . '<li>' . t('Preferences will be stored using a database.') . '</li>' + . '</ul>'; + + $type = $this->data['generalConfig']['logging_log']; + if ($type === 'none') { + $loggingHtml = '<p>' . mt('setup', 'Logging will be disabled.') . '</p>'; + } else { + $level = $this->data['generalConfig']['logging_level']; + + $typeDescription = null; + $typeSpecificHtml = null; + switch ($type) { + case 'php': + $typeDescription = t('Webserver Log', 'app.config.logging.type'); + $typeSpecificHtml = ''; + break; + + case 'syslog': + $typeDescription = 'Syslog'; + $typeSpecificHtml = '<td><strong>' . t('Application Prefix') . '</strong></td>' + . '<td>' . $this->data['generalConfig']['logging_application'] . '</td>'; + break; + + case 'file': + $typeDescription = t('File', 'app.config.logging.type'); + $typeSpecificHtml = '<td><strong>' . t('Filepath') . '</strong></td>' + . '<td>' . $this->data['generalConfig']['logging_file'] . '</td>'; + break; + } + + $loggingHtml = '' + . '<table>' + . '<tbody>' + . '<tr>' + . '<td><strong>' . t('Type', 'app.config.logging') . '</strong></td>' + . '<td>' . $typeDescription . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Level', 'app.config.logging') . '</strong></td>' + . '<td>' . ($level === Logger::$levels[Logger::ERROR] ? t('Error', 'app.config.logging.level') : ( + $level === Logger::$levels[Logger::WARNING] ? t('Warning', 'app.config.logging.level') : ( + $level === Logger::$levels[Logger::INFO] ? t('Information', 'app.config.logging.level') : ( + t('Debug', 'app.config.logging.level') + ) + ) + )) . '</td>' + . '</tr>' + . '<tr>' + . $typeSpecificHtml + . '</tr>' + . '</tbody>' + . '</table>'; + } + + return $pageTitle . '<div class="topic">' . $generalTitle . $generalHtml . '</div>' + . '<div class="topic">' . $loggingTitle . $loggingHtml . '</div>'; + } + + public function getReport() + { + if ($this->error === false) { + return array(sprintf( + mt('setup', 'General configuration has been successfully written to: %s'), + Config::resolvePath('config.ini') + )); + } elseif ($this->error !== null) { + return array( + sprintf( + mt('setup', 'General configuration could not be written to: %s. An error occured:'), + Config::resolvePath('config.ini') + ), + sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error)) + ); + } + } +} diff --git a/modules/setup/library/Setup/Steps/ResourceStep.php b/modules/setup/library/Setup/Steps/ResourceStep.php new file mode 100644 index 0000000..d69d325 --- /dev/null +++ b/modules/setup/library/Setup/Steps/ResourceStep.php @@ -0,0 +1,201 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Steps; + +use Exception; +use Icinga\Application\Config; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; + +class ResourceStep extends Step +{ + protected $data; + + protected $error; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $resourceConfig = array(); + if (isset($this->data['dbResourceConfig'])) { + $dbConfig = $this->data['dbResourceConfig']; + $resourceName = $dbConfig['name']; + unset($dbConfig['name']); + $resourceConfig[$resourceName] = $dbConfig; + } + + if (isset($this->data['ldapResourceConfig'])) { + $ldapConfig = $this->data['ldapResourceConfig']; + $resourceName = $ldapConfig['name']; + unset($ldapConfig['name']); + $resourceConfig[$resourceName] = $ldapConfig; + } + + try { + Config::fromArray($resourceConfig) + ->setConfigFile(Config::resolvePath('resources.ini')) + ->saveIni(); + } catch (Exception $e) { + $this->error = $e; + return false; + } + + $this->error = false; + return true; + } + + public function getSummary() + { + if (isset($this->data['dbResourceConfig']) && isset($this->data['ldapResourceConfig'])) { + $pageTitle = '<h2>' . mt('setup', 'Resources', 'setup.page.title') . '</h2>'; + } else { + $pageTitle = '<h2>' . mt('setup', 'Resource', 'setup.page.title') . '</h2>'; + } + + $dbHtml = null; + if (isset($this->data['dbResourceConfig'])) { + $dbTitle = '<h3>' . mt('setup', 'Database', 'setup.page.title') . '</h3>'; + $dbHtml = '' + . '<table>' + . '<tbody>' + . '<tr>' + . '<td><strong>' . t('Resource Name') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['name'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Database Type') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['db'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Host') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['host'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Port') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['port'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Database Name') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['dbname'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Username') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['username'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Password') . '</strong></td>' + . '<td>' . str_repeat('*', strlen($this->data['dbResourceConfig']['password'])) . '</td>' + . '</tr>'; + + if (defined('\PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && isset($this->data['resourceConfig']['ssl_do_not_verify_server_cert']) + && $this->data['resourceConfig']['ssl_do_not_verify_server_cert'] + ) { + $dbHtml .= '' + . '<tr>' + . '<td><strong>' . t('SSL Do Not Verify Server Certificate') . '</strong></td>' + . '<td>' . $this->data['resourceConfig']['ssl_do_not_verify_server_cert'] . '</td>' + . '</tr>'; + } + if (isset($this->data['dbResourceConfig']['ssl_key']) && $this->data['dbResourceConfig']['ssl_key']) { + $dbHtml .= '' + .'<tr>' + . '<td><strong>' . t('SSL Key') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['ssl_key'] . '</td>' + . '</tr>'; + } + if (isset($this->data['dbResourceConfig']['ssl_cert']) && $this->data['dbResourceConfig']['ssl_cert']) { + $dbHtml .= '' + . '<tr>' + . '<td><strong>' . t('SSL Cert') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['ssl_cert'] . '</td>' + . '</tr>'; + } + if (isset($this->data['dbResourceConfig']['ssl_ca']) && $this->data['dbResourceConfig']['ssl_ca']) { + $dbHtml .= '' + . '<tr>' + . '<td><strong>' . t('CA') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['ssl_ca'] . '</td>' + . '</tr>'; + } + if (isset($this->data['dbResourceConfig']['ssl_capath']) && $this->data['dbResourceConfig']['ssl_capath']) { + $dbHtml .= '' + . '<tr>' + . '<td><strong>' . t('CA Path') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['ssl_capath'] . '</td>' + . '</tr>'; + } + if (isset($this->data['dbResourceConfig']['ssl_cipher']) && $this->data['dbResourceConfig']['ssl_cipher']) { + $dbHtml .= '' + . '<tr>' + . '<td><strong>' . t('Cipher') . '</strong></td>' + . '<td>' . $this->data['dbResourceConfig']['ssl_cipher'] . '</td>' + . '</tr>'; + } + + $dbHtml .= '' + . '</tbody>' + . '</table>'; + } + + $ldapHtml = null; + if (isset($this->data['ldapResourceConfig'])) { + $ldapTitle = '<h3>LDAP</h3>'; + $ldapHtml = '' + . '<table>' + . '<tbody>' + . '<tr>' + . '<td><strong>' . t('Resource Name') . '</strong></td>' + . '<td>' . $this->data['ldapResourceConfig']['name'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Host') . '</strong></td>' + . '<td>' . $this->data['ldapResourceConfig']['hostname'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Port') . '</strong></td>' + . '<td>' . $this->data['ldapResourceConfig']['port'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Root DN') . '</strong></td>' + . '<td>' . $this->data['ldapResourceConfig']['root_dn'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Bind DN') . '</strong></td>' + . '<td>' . $this->data['ldapResourceConfig']['bind_dn'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . t('Bind Password') . '</strong></td>' + . '<td>' . str_repeat('*', strlen($this->data['ldapResourceConfig']['bind_pw'])) . '</td>' + . '</tr>' + . '</tbody>' + . '</table>'; + } + + return $pageTitle . (isset($dbTitle) ? '<div class="topic">' . $dbTitle . $dbHtml . '</div>' : '') + . (isset($ldapTitle) ? '<div class="topic">' . $ldapTitle . $ldapHtml . '</div>' : ''); + } + + public function getReport() + { + if ($this->error === false) { + return array(sprintf( + mt('setup', 'Resource configuration has been successfully written to: %s'), + Config::resolvePath('resources.ini') + )); + } elseif ($this->error !== null) { + return array( + sprintf( + mt('setup', 'Resource configuration could not be written to: %s. An error occured:'), + Config::resolvePath('resources.ini') + ), + sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->error)) + ); + } + } +} diff --git a/modules/setup/library/Setup/Steps/UserGroupStep.php b/modules/setup/library/Setup/Steps/UserGroupStep.php new file mode 100644 index 0000000..4aab676 --- /dev/null +++ b/modules/setup/library/Setup/Steps/UserGroupStep.php @@ -0,0 +1,213 @@ +<?php +/* Icinga Web 2 | (c) 2015 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Steps; + +use Exception; +use Icinga\Application\Config; +use Icinga\Authentication\UserGroup\DbUserGroupBackend; +use Icinga\Data\ConfigObject; +use Icinga\Data\ResourceFactory; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; + +class UserGroupStep extends Step +{ + protected $data; + + protected $groupError; + + protected $memberError; + + protected $groupIniError; + + public function __construct(array $data) + { + $this->data = $data; + } + + public function apply() + { + $success = $this->createGroupsIni(); + if (isset($this->data['resourceConfig'])) { + $success &= $this->createUserGroup(); + if ($success) { + $success &= $this->createMembership(); + } + } + + return $success; + } + + protected function createGroupsIni() + { + $config = array(); + if (isset($this->data['groupConfig'])) { + $backendConfig = $this->data['groupConfig']; + $backendName = $backendConfig['name']; + unset($backendConfig['name']); + $config[$backendName] = $backendConfig; + } else { + $backendConfig = array( + 'backend' => $this->data['backendConfig']['backend'], // "db" or "msldap" + 'resource' => $this->data['resourceName'] + ); + + if ($backendConfig['backend'] === 'msldap') { + $backendConfig['user_backend'] = $this->data['backendConfig']['name']; + } + + $config[$this->data['backendConfig']['name']] = $backendConfig; + } + + try { + Config::fromArray($config) + ->setConfigFile(Config::resolvePath('groups.ini')) + ->saveIni(); + } catch (Exception $e) { + $this->groupIniError = $e; + return false; + } + + $this->groupIniError = false; + return true; + } + + protected function createUserGroup() + { + try { + $backend = new DbUserGroupBackend( + ResourceFactory::createResource(new ConfigObject($this->data['resourceConfig'])) + ); + + $groupName = mt('setup', 'Administrators', 'setup.role.name'); + if ($backend->select()->where('group_name', $groupName)->count() === 0) { + $backend->insert('group', array( + 'group_name' => $groupName + )); + $this->groupError = false; + } + } catch (Exception $e) { + $this->groupError = $e; + return false; + } + + return true; + } + + protected function createMembership() + { + try { + $backend = new DbUserGroupBackend( + ResourceFactory::createResource(new ConfigObject($this->data['resourceConfig'])) + ); + + $groupName = mt('setup', 'Administrators', 'setup.role.name'); + $userName = $this->data['username']; + if ($backend + ->select() + ->from('group_membership') + ->where('group_name', $groupName) + ->where('user_name', $userName) + ->count() === 0 + ) { + $backend->insert('group_membership', array( + 'group_name' => $groupName, + 'user_name' => $userName + )); + $this->memberError = false; + } + } catch (Exception $e) { + $this->memberError = $e; + return false; + } + + return true; + } + + public function getSummary() + { + if (! isset($this->data['groupConfig'])) { + return; // It's not necessary to show the user something he didn't configure.. + } + + $pageTitle = '<h2>' . mt('setup', 'User Groups', 'setup.page.title') . '</h2>'; + $backendTitle = '<h3>' . mt('setup', 'User Group Backend', 'setup.page.title') . '</h3>'; + + $backendHtml = '' + . '<table>' + . '<tbody>' + . '<tr>' + . '<td><strong>' . t('Backend Name') . '</strong></td>' + . '<td>' . $this->data['groupConfig']['name'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . mt('setup', 'Group Object Class') . '</strong></td>' + . '<td>' . $this->data['groupConfig']['group_class'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . mt('setup', 'Custom Filter') . '</strong></td>' + . '<td>' . (trim($this->data['groupConfig']['group_filter']) ?: t('None', 'auth.ldap.filter')) . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . mt('setup', 'Group Name Attribute') . '</strong></td>' + . '<td>' . $this->data['groupConfig']['group_name_attribute'] . '</td>' + . '</tr>' + . '<tr>' + . '<td><strong>' . mt('setup', 'Group Member Attribute') . '</strong></td>' + . '<td>' . $this->data['groupConfig']['group_member_attribute'] . '</td>' + . '</tr>' + . '</tbody>' + . '</table>'; + + return $pageTitle . '<div class="topic">' . $backendTitle . $backendHtml . '</div>'; + } + + public function getReport() + { + $report = array(); + + if ($this->groupIniError === false) { + $report[] = sprintf( + mt('setup', 'User Group Backend configuration has been successfully written to: %s'), + Config::resolvePath('groups.ini') + ); + } elseif ($this->groupIniError !== null) { + $report[] = sprintf( + mt('setup', 'User Group Backend configuration could not be written to: %s. An error occured:'), + Config::resolvePath('groups.ini') + ); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->groupIniError)); + } + + if ($this->groupError === false) { + $report[] = sprintf( + mt('setup', 'User Group "%s" has been successfully created.'), + mt('setup', 'Administrators', 'setup.role.name') + ); + } elseif ($this->groupError !== null) { + $report[] = sprintf( + mt('setup', 'Unable to create user group "%s". An error occured:'), + mt('setup', 'Administrators', 'setup.role.name') + ); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->groupError)); + } + + if ($this->memberError === false) { + $report[] = sprintf( + mt('setup', 'Account "%s" has been successfully added as member to user group "%s".'), + $this->data['username'], + mt('setup', 'Administrators', 'setup.role.name') + ); + } elseif ($this->memberError !== null) { + $report[] = sprintf( + mt('setup', 'Unable to add account "%s" as member to user group "%s". An error occured:'), + $this->data['username'], + mt('setup', 'Administrators', 'setup.role.name') + ); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->memberError)); + } + + return $report; + } +} diff --git a/modules/setup/library/Setup/Utils/DbTool.php b/modules/setup/library/Setup/Utils/DbTool.php new file mode 100644 index 0000000..7578462 --- /dev/null +++ b/modules/setup/library/Setup/Utils/DbTool.php @@ -0,0 +1,950 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Utils; + +use PDO; +use PDOException; +use LogicException; +use Zend_Db_Adapter_Pdo_Abstract; +use Zend_Db_Adapter_Pdo_Mysql; +use Zend_Db_Adapter_Pdo_Pgsql; +use Icinga\Util\File; +use Icinga\Exception\ConfigurationError; + +/** + * Utility class to ease working with databases when setting up Icinga Web 2 or one of its modules + */ +class DbTool +{ + /** + * The PDO database connection + * + * @var PDO + */ + protected $pdoConn; + + /** + * The Zend database adapter + * + * @var Zend_Db_Adapter_Pdo_Abstract + */ + protected $zendConn; + + /** + * The resource configuration + * + * @var array + */ + protected $config; + + /** + * Whether we are connected to the database from the resource configuration + * + * @var bool + */ + protected $dbFromConfig = false; + + /** + * GRANT privilege level identifiers + */ + const GLOBAL_LEVEL = 1; + const PROCEDURE_LEVEL = 2; + const DATABASE_LEVEL = 4; + const TABLE_LEVEL = 8; + const COLUMN_LEVEL = 16; + const FUNCTION_LEVEL = 32; + + /** + * All MySQL GRANT privileges with their respective level identifiers + * + * @var array + */ + protected $mysqlGrantContexts = array( + 'ALL' => 31, + 'ALL PRIVILEGES' => 31, + 'ALTER' => 13, + 'ALTER ROUTINE' => 7, + 'CREATE' => 13, + 'CREATE ROUTINE' => 5, + 'CREATE TEMPORARY TABLES' => 5, + 'CREATE USER' => 1, + 'CREATE VIEW' => 13, + 'DELETE' => 13, + 'DROP' => 13, + 'EXECUTE' => 5, // MySQL reference states this also supports database level, 5.1.73 not though + 'FILE' => 1, + 'GRANT OPTION' => 15, + 'INDEX' => 13, + 'INSERT' => 29, + 'LOCK TABLES' => 5, + 'PROCESS' => 1, + 'REFERENCES' => 12, + 'RELOAD' => 1, + 'REPLICATION CLIENT' => 1, + 'REPLICATION SLAVE' => 1, + 'SELECT' => 29, + 'SHOW DATABASES' => 1, + 'SHOW VIEW' => 13, + 'SHUTDOWN' => 1, + 'SUPER' => 1, + 'UPDATE' => 29 + ); + + /** + * All PostgreSQL GRANT privileges with their respective level identifiers + * + * @var array + */ + protected $pgsqlGrantContexts = array( + 'ALL' => 63, + 'ALL PRIVILEGES' => 63, + 'CREATE' => 13, + 'CONNECT' => 4, + 'TEMPORARY' => 4, + 'TEMP' => 4, + 'EXECUTE' => 32, + 'USAGE' => 33, + 'CREATEROLE' => 1 + ); + + /** + * Create a new DbTool + * + * @param array $config The resource configuration to use + */ + public function __construct(array $config) + { + if (! isset($config['port'])) { + // TODO: This is not quite correct, but works as it previously did. Previously empty values were not + // transformed no NULL (now they are) so if the port is now null, it's been the empty string. + $config['port'] = ''; + } + + $this->config = $config; + } + + /** + * Connect to the server + * + * @return $this + */ + public function connectToHost() + { + $this->assertHostAccess(); + + if ($this->config['db'] == 'pgsql') { + // PostgreSQL requires us to specify a database on each connection and will use + // the current user name as default database in cases none is provided. If + // that database doesn't exist (which might be the case here) it will error. + // Therefore, we specify the maintenance database 'postgres' as database, which + // is most probably present and public. (http://stackoverflow.com/q/4483139) + $this->connect('postgres'); + } else { + $this->connect(); + } + + return $this; + } + + /** + * Connect to the database + * + * @return $this + */ + public function connectToDb() + { + $this->assertHostAccess(); + $this->assertDatabaseAccess(); + $this->connect($this->config['dbname']); + return $this; + } + + /** + * Assert that all configuration values exist that are required to connect to a server + * + * @throws ConfigurationError + */ + protected function assertHostAccess() + { + if (! isset($this->config['db'])) { + throw new ConfigurationError('Can\'t connect to database server of unknown type'); + } elseif (! isset($this->config['host'])) { + throw new ConfigurationError('Can\'t connect to database server without a hostname or address'); + } elseif (! isset($this->config['port'])) { + throw new ConfigurationError('Can\'t connect to database server without a port'); + } elseif (! isset($this->config['username'])) { + throw new ConfigurationError('Can\'t connect to database server without a username'); + } elseif (! isset($this->config['password'])) { + throw new ConfigurationError('Can\'t connect to database server without a password'); + } + } + + /** + * Assert that all configuration values exist that are required to connect to a database + * + * @throws ConfigurationError + */ + protected function assertDatabaseAccess() + { + if (! isset($this->config['dbname'])) { + throw new ConfigurationError('Can\'t connect to database without a valid database name'); + } + } + + /** + * Assert that a connection with a database has been established + * + * @throws LogicException + */ + protected function assertConnectedToDb() + { + if ($this->zendConn === null) { + throw new LogicException('Not connected to database'); + } + } + + /** + * Return whether a connection with the server has been established + * + * @return bool + */ + public function isConnected() + { + return $this->pdoConn !== null; + } + + /** + * Establish a connection with the database or just the server by omitting the database name + * + * @param string $dbname The name of the database to connect to + */ + public function connect($dbname = null) + { + $this->pdoConnect($dbname); + if ($dbname !== null) { + $this->zendConnect($dbname); + $this->dbFromConfig = $dbname === $this->config['dbname']; + } + } + + /** + * Reestablish a connection with the database or just the server by omitting the database name + * + * @param string $dbname The name of the database to connect to + */ + public function reconnect($dbname = null) + { + $this->pdoConn = null; + $this->zendConn = null; + $this->connect($dbname); + } + + /** + * Initialize Zend database adapter + * + * @param string $dbname The name of the database to connect with + * + * @throws ConfigurationError In case the resource type is not a supported PDO driver name + */ + private function zendConnect($dbname) + { + if ($this->zendConn !== null) { + return; + } + + $config = array( + 'dbname' => $dbname, + 'host' => $this->config['host'], + 'port' => $this->config['port'], + 'username' => $this->config['username'], + 'password' => $this->config['password'] + ); + + if ($this->config['db'] === 'mysql') { + if (isset($this->config['use_ssl']) && $this->config['use_ssl']) { + $this->config['driver_options'] = array(); + # The presence of these keys as empty strings or null cause non-ssl connections to fail + if ($this->config['ssl_key']) { + $config['driver_options'][PDO::MYSQL_ATTR_SSL_KEY] = $this->config['ssl_key']; + } + if ($this->config['ssl_cert']) { + $config['driver_options'][PDO::MYSQL_ATTR_SSL_CERT] = $this->config['ssl_cert']; + } + if ($this->config['ssl_ca']) { + $config['driver_options'][PDO::MYSQL_ATTR_SSL_CA] = $this->config['ssl_ca']; + } + if ($this->config['ssl_capath']) { + $config['driver_options'][PDO::MYSQL_ATTR_SSL_CAPATH] = $this->config['ssl_capath']; + } + if ($this->config['ssl_cipher']) { + $config['driver_options'][PDO::MYSQL_ATTR_SSL_CIPHER] = $this->config['ssl_cipher']; + } + if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && $this->config['ssl_do_not_verify_server_cert'] + ) { + $config['driver_options'][PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + $this->zendConn = new Zend_Db_Adapter_Pdo_Mysql($config); + } elseif ($this->config['db'] === 'pgsql') { + $this->zendConn = new Zend_Db_Adapter_Pdo_Pgsql($config); + } else { + throw new ConfigurationError( + 'Failed to connect to database. Unsupported PDO driver "%s"', + $this->config['db'] + ); + } + + $this->zendConn->getConnection(); // Force connection attempt + } + + /** + * Initialize PDO connection + * + * @param string $dbname The name of the database to connect with + */ + private function pdoConnect($dbname) + { + if ($this->pdoConn !== null) { + return; + } + + $driverOptions = array( + PDO::ATTR_TIMEOUT => 1, + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION + ); + + if ($this->config['db'] === 'mysql' + && isset($this->config['use_ssl']) + && $this->config['use_ssl'] + ) { + # The presence of these keys as empty strings or null cause non-ssl connections to fail + if ($this->config['ssl_key']) { + $driverOptions[PDO::MYSQL_ATTR_SSL_KEY] = $this->config['ssl_key']; + } + if ($this->config['ssl_cert']) { + $driverOptions[PDO::MYSQL_ATTR_SSL_CERT] = $this->config['ssl_cert']; + } + if ($this->config['ssl_ca']) { + $driverOptions[PDO::MYSQL_ATTR_SSL_CA] = $this->config['ssl_ca']; + } + if ($this->config['ssl_capath']) { + $driverOptions[PDO::MYSQL_ATTR_SSL_CAPATH] = $this->config['ssl_capath']; + } + if ($this->config['ssl_cipher']) { + $driverOptions[PDO::MYSQL_ATTR_SSL_CIPHER] = $this->config['ssl_cipher']; + } + if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') + && $this->config['ssl_do_not_verify_server_cert'] + ) { + $driverOptions[PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT] = false; + } + } + + $this->pdoConn = new PDO( + $this->buildDsn($this->config['db'], $dbname), + $this->config['username'], + $this->config['password'], + $driverOptions + ); + } + + /** + * Return a datasource name for the given database type and name + * + * @param string $dbtype + * @param string $dbname + * + * @return string + * + * @throws ConfigurationError In case the passed database type is not supported + */ + protected function buildDsn($dbtype, $dbname = null) + { + if ($dbtype === 'mysql') { + return 'mysql:host=' . $this->config['host'] . ';port=' . $this->config['port'] + . ($dbname !== null ? ';dbname=' . $dbname : ''); + } elseif ($dbtype === 'pgsql') { + return 'pgsql:host=' . $this->config['host'] . ';port=' . $this->config['port'] + . ($dbname !== null ? ';dbname=' . $dbname : ''); + } else { + throw new ConfigurationError( + 'Failed to build data source name. Unsupported PDO driver "%s"', + $dbtype + ); + } + } + + /** + * Try to connect to the server and throw an exception if this fails + * + * @throws PDOException In case an error occurs that does not indicate that authentication failed + */ + public function checkConnectivity() + { + try { + $this->connectToHost(); + } catch (PDOException $e) { + if ($this->config['db'] === 'mysql') { + $code = $e->getCode(); + /* + * 1040 .. Too many connections + * 1045 .. Access denied for user '%s'@'%s' (using password: %s) + * 1698 .. Access denied for user '%s'@'%s' + */ + if ($code !== 1040 && $code !== 1045 && $code !== 1698) { + throw $e; + } + } elseif ($this->config['db'] === 'pgsql') { + if (strpos($e->getMessage(), $this->config['username']) === false) { + throw $e; + } + } + } + } + + /** + * Return the given identifier escaped with backticks + * + * @param string $identifier The identifier to escape + * + * @return string + * + * @throws LogicException In case there is no behaviour implemented for the current PDO driver + */ + public function quoteIdentifier($identifier) + { + if ($this->config['db'] === 'mysql') { + return '`' . str_replace('`', '``', $identifier) . '`'; + } elseif ($this->config['db'] === 'pgsql') { + return '"' . str_replace('"', '""', $identifier) . '"'; + } else { + throw new LogicException('Unable to quote identifier.'); + } + } + + /** + * Return the given table name with all wildcards being escaped + * + * @param string $tableName + * + * @return string + * + * @throws LogicException In case there is no behaviour implemented for the current PDO driver + */ + public function escapeTableWildcards($tableName) + { + if ($this->config['db'] === 'mysql') { + return str_replace(array('_', '%'), array('\_', '\%'), $tableName); + } + + throw new LogicException('Unable to escape table wildcards.'); + } + + /** + * Return the given value escaped as string + * + * @param mixed $value The value to escape + * + * @return string + * + * @throws LogicException In case there is no behaviour implemented for the current PDO driver + */ + public function quote($value) + { + $quoted = $this->pdoConn->quote($value); + if ($quoted === false) { + throw new LogicException(sprintf('Unable to quote value: %s', $value)); + } + + return $quoted; + } + + /** + * Execute a SQL statement and return the affected row count + * + * Use $params to use a prepared statement. + * + * @param string $statement The statement to execute + * @param array $params The params to bind + * + * @return int + */ + public function exec($statement, $params = array()) + { + if (empty($params)) { + return $this->pdoConn->exec($statement); + } + + $stmt = $this->pdoConn->prepare($statement); + $stmt->execute($params); + return $stmt->rowCount(); + } + + /** + * Execute a SQL statement and return the result + * + * Use $params to use a prepared statement. + * + * @param string $statement The statement to execute + * @param array $params The params to bind + * + * @return mixed + */ + public function query($statement, $params = array()) + { + if ($this->zendConn !== null) { + return $this->zendConn->query($statement, $params); + } + + if (empty($params)) { + return $this->pdoConn->query($statement); + } + + $stmt = $this->pdoConn->prepare($statement); + $stmt->execute($params); + return $stmt; + } + + /** + * Return the version of the server currently connected to + * + * @return string|null + */ + public function getServerVersion() + { + if ($this->config['db'] === 'mysql') { + return $this->query('show variables like "version"')->fetchColumn(1) ?: null; + } elseif ($this->config['db'] === 'pgsql') { + return $this->query('show server_version')->fetchColumn() ?: null; + } else { + throw new LogicException( + sprintf('Unable to fetch the server\'s version. Unsupported PDO driver "%s"', $this->config['db']) + ); + } + } + + /** + * Import the given SQL file + * + * @param string $filepath The file to import + */ + public function import($filepath) + { + $file = new File($filepath); + $content = join(PHP_EOL, iterator_to_array($file)); // There is no fread() before PHP 5.5 :( + + foreach (preg_split('@;(?! \\\\)@', $content) as $statement) { + if (($statement = trim($statement)) !== '') { + $this->exec($statement); + } + } + } + + /** + * Return whether the given privileges were granted + * + * @param array $privileges An array of strings with the required privilege names + * @param array $context An array describing the context for which the given privileges need to apply. + * Only one or more table names are currently supported + * @param string $username The login name for which to check the privileges, + * if NULL the current login is used + * + * @return ?bool + */ + public function checkPrivileges(array $privileges, array $context = null, $username = null) + { + if ($this->config['db'] === 'mysql') { + return $this->checkMysqlPrivileges($privileges, false, $context, $username); + } elseif ($this->config['db'] === 'pgsql') { + return $this->checkPgsqlPrivileges($privileges, false, $context, $username); + } + } + + /** + * Return whether the given privileges are grantable to other users + * + * @param array $privileges The privileges that should be grantable + * + * @return ?bool + */ + public function isGrantable($privileges) + { + if ($this->config['db'] === 'mysql') { + return $this->checkMysqlPrivileges($privileges, true); + } elseif ($this->config['db'] === 'pgsql') { + return $this->checkPgsqlPrivileges($privileges, true); + } + } + + /** + * Grant all given privileges to the given user + * + * @param array $privileges The privilege names to grant + * @param array $context An array describing the context for which the given privileges need to apply. + * Only one or more table names are currently supported + * @param string $username The username to grant the privileges to + */ + public function grantPrivileges(array $privileges, array $context, $username) + { + if ($this->config['db'] === 'mysql') { + list($_, $host) = explode('@', $this->query('select current_user()')->fetchColumn()); + $quotedDbName = $this->quoteIdentifier($this->config['dbname']); + + $grant = 'GRANT %s'; + $on = ' ON %s.%s'; + $to = sprintf( + ' TO %s@%s', + $this->quoteIdentifier($username), + $this->quoteIdentifier($host) + ); + + $dbPrivileges = array(); + $tablePrivileges = array(); + foreach (array_intersect($privileges, array_keys($this->mysqlGrantContexts)) as $privilege) { + if (! empty($context) && $this->mysqlGrantContexts[$privilege] & static::TABLE_LEVEL) { + $tablePrivileges[] = $privilege; + } elseif ($this->mysqlGrantContexts[$privilege] & static::DATABASE_LEVEL) { + $dbPrivileges[] = $privilege; + } + } + + if (! empty($tablePrivileges)) { + $tableGrant = sprintf($grant, join(',', $tablePrivileges)); + foreach ($context as $table) { + $this->exec($tableGrant . sprintf($on, $quotedDbName, $this->quoteIdentifier($table)) . $to); + } + } + + if (! empty($dbPrivileges)) { + $this->exec( + sprintf($grant, join(',', $dbPrivileges)) + . sprintf($on, $this->escapeTableWildcards($quotedDbName), '*') + . $to + ); + } + } elseif ($this->config['db'] === 'pgsql') { + $dbPrivileges = array(); + $schemaPrivileges = []; + foreach (array_intersect($privileges, array_keys($this->pgsqlGrantContexts)) as $privilege) { + if ($this->pgsqlGrantContexts[$privilege] & static::DATABASE_LEVEL) { + $dbPrivileges[] = $privilege; + } + + if ($this->pgsqlGrantContexts[$privilege] & static::GLOBAL_LEVEL) { + $schemaPrivileges[] = $privilege; + } + } + + if (! empty($schemaPrivileges)) { + // Allow the user to create,alter and use all attribute types in schema public + // such as creating and dropping custom data types (boolenum) + $this->exec(sprintf('GRANT %s ON SCHEMA public TO %s', implode(',', $schemaPrivileges), $username)); + } + + if (! empty($dbPrivileges)) { + $this->exec(sprintf( + 'GRANT %s ON DATABASE %s TO %s', + join(',', $dbPrivileges), + $this->config['dbname'], + $username + )); + } + + foreach ($context as $table) { + // PostgreSQL documentation says "You must own the table to use ALTER TABLE.", hence it isn't + // sufficient to just issue grants, as the user is still not allowed to alter that table. + $this->exec(sprintf('ALTER TABLE %s OWNER TO %s', $table, $username)); + } + } + } + + /** + * Return a list of all existing database tables + * + * @return array + */ + public function listTables() + { + $this->assertConnectedToDb(); + return $this->zendConn->listTables(); + } + + /** + * Return whether the given database login exists + * + * @param string $username The username to search + * + * @return ?bool + */ + public function hasLogin($username) + { + if ($this->config['db'] === 'mysql') { + $queryString = <<<EOD +SELECT 1 + FROM information_schema.user_privileges + WHERE grantee = REPLACE(CONCAT("'", REPLACE(CURRENT_USER(), '@', "'@'"), "'"), :current, :wanted) +EOD; + + $query = $this->query( + $queryString, + array( + ':current' => $this->config['username'], + ':wanted' => $username + ) + ); + return count($query->fetchAll()) > 0; + } elseif ($this->config['db'] === 'pgsql') { + $query = $this->query( + 'SELECT 1 FROM pg_catalog.pg_user WHERE usename = :ident LIMIT 1', + array(':ident' => $username) + ); + return count($query->fetchAll()) === 1; + } + } + + /** + * Add a new database login + * + * @param string $username The username of the new login + * @param string $password The password of the new login + */ + public function addLogin($username, $password) + { + if ($this->config['db'] === 'mysql') { + list($_, $host) = explode('@', $this->query('select current_user()')->fetchColumn()); + $this->exec( + 'CREATE USER :user@:host IDENTIFIED BY :passw', + array(':user' => $username, ':host' => $host, ':passw' => $password) + ); + } elseif ($this->config['db'] === 'pgsql') { + $this->exec(sprintf( + 'CREATE USER %s WITH PASSWORD %s', + $this->quoteIdentifier($username), + $this->quote($password) + )); + } + } + + /** + * Check whether the current user has the given privileges + * + * @param array $privileges The privilege names + * @param bool $requireGrants Only return true when all privileges can be granted to others + * @param array $context An array describing the context for which the given privileges need to apply. + * Only one or more table names are currently supported + * @param string $username The login name to which the passed privileges need to be granted + * + * @return bool + */ + protected function checkMysqlPrivileges( + array $privileges, + $requireGrants = false, + array $context = null, + $username = null + ) { + $mysqlPrivileges = array_intersect($privileges, array_keys($this->mysqlGrantContexts)); + list($_, $host) = explode('@', $this->query('select current_user()')->fetchColumn()); + $grantee = "'" . ($username === null ? $this->config['username'] : $username) . "'@'" . $host . "'"; + + if (isset($this->config['dbname'])) { + $dbPrivileges = array(); + $tablePrivileges = array(); + foreach ($mysqlPrivileges as $privilege) { + if (! empty($context) && $this->mysqlGrantContexts[$privilege] & static::TABLE_LEVEL) { + $tablePrivileges[] = $privilege; + } + if ($this->mysqlGrantContexts[$privilege] & static::DATABASE_LEVEL) { + $dbPrivileges[] = $privilege; + } + } + + $dbPrivilegesGranted = true; + $tablePrivilegesGranted = true; + + if (! empty($dbPrivileges)) { + $queryString = 'SELECT COUNT(*) as matches' + . ' FROM information_schema.schema_privileges' + . ' WHERE grantee = :grantee' + . ' AND table_schema = :dbname' + . ' AND privilege_type IN (%s)' + . ($requireGrants ? " AND is_grantable = 'YES'" : ''); + + $dbAndTableQuery = $this->query( + sprintf($queryString, join(',', array_map(array($this, 'quote'), $dbPrivileges))), + array(':grantee' => $grantee, ':dbname' => $this->escapeTableWildcards($this->config['dbname'])) + ); + $grantedDbAndTablePrivileges = (int) $dbAndTableQuery->fetchObject()->matches; + if ($grantedDbAndTablePrivileges === count($dbPrivileges)) { + $tableExclusivePrivileges = array_diff($tablePrivileges, $dbPrivileges); + if (! empty($tableExclusivePrivileges)) { + $tablePrivileges = $tableExclusivePrivileges; + $tablePrivilegesGranted = false; + } + } else { + $tablePrivilegesGranted = false; + $dbExclusivePrivileges = array_diff($dbPrivileges, $tablePrivileges); + if (! empty($dbExclusivePrivileges)) { + $dbExclusiveQuery = $this->query( + sprintf($queryString, join(',', array_map(array($this, 'quote'), $dbExclusivePrivileges))), + array( + ':grantee' => $grantee, + ':dbname' => $this->escapeTableWildcards($this->config['dbname']) + ) + ); + $dbPrivilegesGranted = (int) $dbExclusiveQuery->fetchObject()->matches === count( + $dbExclusivePrivileges + ); + } + } + } + + if (! $tablePrivilegesGranted && !empty($tablePrivileges)) { + $query = $this->query( + 'SELECT COUNT(*) as matches' + . ' FROM information_schema.table_privileges' + . ' WHERE grantee = :grantee' + . ' AND table_schema = :dbname' + . ' AND table_name IN (' . join(',', array_map(array($this, 'quote'), $context)) . ')' + . ' AND privilege_type IN (' . join(',', array_map(array($this, 'quote'), $tablePrivileges)) . ')' + . ($requireGrants ? " AND is_grantable = 'YES'" : ''), + array(':grantee' => $grantee, ':dbname' => $this->config['dbname']) + ); + $expectedAmountOfMatches = count($context) * count($tablePrivileges); + $tablePrivilegesGranted = (int) $query->fetchObject()->matches === $expectedAmountOfMatches; + } + + if ($dbPrivilegesGranted && $tablePrivilegesGranted) { + return true; + } + } + + $query = $this->query( + 'SELECT COUNT(*) as matches FROM information_schema.user_privileges WHERE grantee = :grantee' + . ' AND privilege_type IN (' . join(',', array_map(array($this, 'quote'), $mysqlPrivileges)) . ')' + . ($requireGrants ? " AND is_grantable = 'YES'" : ''), + array(':grantee' => $grantee) + ); + return (int) $query->fetchObject()->matches === count($mysqlPrivileges); + } + + /** + * Check whether the current user has the given privileges + * + * Note that database and table specific privileges (i.e. not SUPER, CREATE and CREATEROLE) are ignored + * in case no connection to the database defined in the resource configuration has been established + * + * @param array $privileges The privilege names + * @param bool $requireGrants Only return true when all privileges can be granted to others + * @param array $context An array describing the context for which the given privileges need to apply. + * Only one or more table names are currently supported + * @param string $username The login name to which the passed privileges need to be granted + * + * @return bool + */ + public function checkPgsqlPrivileges( + array $privileges, + $requireGrants = false, + array $context = null, + $username = null + ) { + $privilegesGranted = true; + $owner = $username ?: $this->config['username']; + $isSuperUser = $this->query('select rolsuper from pg_roles where rolname = :user', [':user' => $owner]) + ->fetchColumn(); + + if ($this->dbFromConfig) { + $schemaPrivileges = []; + $dbPrivileges = array(); + if (! $isSuperUser) { + foreach (array_intersect($privileges, array_keys($this->pgsqlGrantContexts)) as $privilege) { + if ($this->pgsqlGrantContexts[$privilege] & static::DATABASE_LEVEL) { + $dbPrivileges[] = $privilege; + } + if ($this->pgsqlGrantContexts[$privilege] & static::GLOBAL_LEVEL) { + $schemaPrivileges[] = $privilege; + } + } + + if (! empty($schemaPrivileges)) { + foreach ($schemaPrivileges as $schemaPrivilege) { + $query = $this->query( + 'SELECT has_schema_privilege(:user, :schema, :privilege) AS db_privilege_granted', + [ + ':user' => $owner, + ':schema' => 'public', + ':privilege' => $schemaPrivilege . ($requireGrants ? ' WITH GRANT OPTION' : '') + ] + ); + + if (! $query->fetchObject()->db_privilege_granted) { + // The user doesn't fully have the provided privileges. + $privilegesGranted = false; + break; + } + } + } + + if ($privilegesGranted && ! empty($dbPrivileges)) { + foreach ($dbPrivileges as $dbPrivilege) { + $query = $this->query( + 'SELECT has_database_privilege(:user, :dbname, :privilege) AS db_privilege_granted', + array( + ':user' => $owner, + ':dbname' => $this->config['dbname'], + ':privilege' => $dbPrivilege . ($requireGrants ? ' WITH GRANT OPTION' : '') + ) + ); + if (! $query->fetchObject()->db_privilege_granted) { + // The user doesn't fully have the provided privileges. + $privilegesGranted = false; + break; + } + } + } + + if ($privilegesGranted && ! empty($context)) { + foreach (array_intersect($context, $this->listTables()) as $table) { + $query = $this->query( + 'SELECT tableowner FROM pg_catalog.pg_tables WHERE tablename = :tablename', + [':tablename' => $table] + ); + + if ($query->fetchColumn() !== $owner) { + $privilegesGranted = false; + break; + } + } + } + } + } else { + // In case we cannot check whether the user got the required db-/table-privileges due to not being + // connected to the database defined in the resource configuration it is safe to just ignore them + // as the chances are very high that the database is created later causing the current user being + // the owner with ALL privileges. (Which in turn can be granted to others.) + + if (in_array('CREATE', $privileges, true)) { + $query = $this->query( + 'select rolcreatedb from pg_roles where rolname = :user', + array(':user' => $username !== null ? $username : $this->config['username']) + ); + $privilegesGranted = $query->fetchColumn() !== false; + } + } + + if ($privilegesGranted && in_array('CREATEROLE', $privileges, true)) { + $query = $this->query( + 'select rolcreaterole from pg_roles where rolname = :user', + array(':user' => $username !== null ? $username : $this->config['username']) + ); + $privilegesGranted = $query->fetchColumn() !== false; + } + + if ($privilegesGranted && in_array('SUPER', $privileges, true)) { + $privilegesGranted = $isSuperUser === true; + } + + return $privilegesGranted; + } +} diff --git a/modules/setup/library/Setup/Utils/EnableModuleStep.php b/modules/setup/library/Setup/Utils/EnableModuleStep.php new file mode 100644 index 0000000..92af5b7 --- /dev/null +++ b/modules/setup/library/Setup/Utils/EnableModuleStep.php @@ -0,0 +1,77 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Utils; + +use Exception; +use Icinga\Application\Icinga; +use Icinga\Exception\ConfigurationError; +use Icinga\Exception\IcingaException; +use Icinga\Module\Setup\Step; + +class EnableModuleStep extends Step +{ + protected $modulePaths; + + protected $moduleNames; + + protected $errors; + + protected $warnings; + + public function __construct(array $moduleNames) + { + $this->moduleNames = $moduleNames; + + $this->modulePaths = array(); + if (($appModulePath = realpath(Icinga::app()->getApplicationDir() . '/../modules')) !== false) { + $this->modulePaths[] = $appModulePath; + } + } + + public function apply() + { + $moduleManager = Icinga::app()->getModuleManager(); + $moduleManager->detectInstalledModules($this->modulePaths); + + $success = true; + foreach ($this->moduleNames as $moduleName) { + try { + $moduleManager->enableModule($moduleName); + } catch (ConfigurationError $e) { + $this->warnings[$moduleName] = $e; + } catch (Exception $e) { + $this->errors[$moduleName] = $e; + $success = false; + } + } + + return $success; + } + + public function getSummary() + { + // Enabling a module is like a implicit action, which does not need to be shown to the user... + } + + public function getReport() + { + $okMessage = mt('setup', 'Module "%s" has been successfully enabled.'); + $failMessage = mt('setup', 'Module "%s" could not be enabled. An error occured:'); + + $report = array(); + foreach ($this->moduleNames as $moduleName) { + if (isset($this->errors[$moduleName])) { + $report[] = sprintf($failMessage, $moduleName); + $report[] = sprintf(mt('setup', 'ERROR: %s'), IcingaException::describe($this->errors[$moduleName])); + } elseif (isset($this->warnings[$moduleName])) { + $report[] = sprintf($failMessage, $moduleName); + $report[] = sprintf(mt('setup', 'WARNING: %s'), $this->warnings[$moduleName]->getMessage()); + } else { + $report[] = sprintf($okMessage, $moduleName); + } + } + + return $report; + } +} diff --git a/modules/setup/library/Setup/Web/Form/Validator/TokenValidator.php b/modules/setup/library/Setup/Web/Form/Validator/TokenValidator.php new file mode 100644 index 0000000..a3f218b --- /dev/null +++ b/modules/setup/library/Setup/Web/Form/Validator/TokenValidator.php @@ -0,0 +1,73 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Web\Form\Validator; + +use Exception; +use Zend_Validate_Abstract; +use Icinga\Util\File; + +/** + * Validator that checks if a token matches with the contents of a corresponding token-file + */ +class TokenValidator extends Zend_Validate_Abstract +{ + /** + * The path to the token file + * + * @var string + */ + protected $tokenPath; + + /** + * Create a new TokenValidator + * + * @param string $tokenPath The path to the token-file + */ + public function __construct($tokenPath) + { + $this->tokenPath = $tokenPath; + $this->_messageTemplates = array( + 'TOKEN_FILE_ERROR' => sprintf( + mt('setup', 'Cannot validate token: %s (%s)'), + $tokenPath, + '%value%' + ), + 'TOKEN_FILE_EMPTY' => sprintf( + mt('setup', 'Cannot validate token, file "%s" is empty. Please define a token.'), + $tokenPath + ), + 'TOKEN_INVALID' => mt('setup', 'Invalid token supplied.') + ); + } + + /** + * Validate the given token with the one in the token-file + * + * @param string $value The token to validate + * @param null $context The form context (ignored) + * + * @return bool + */ + public function isValid($value, $context = null) + { + try { + $file = new File($this->tokenPath); + $expectedToken = trim($file->fgets()); + } catch (Exception $e) { + $msg = $e->getMessage(); + $this->_error('TOKEN_FILE_ERROR', substr($msg, strpos($msg, ']: ') + 3)); + return false; + } + + if (empty($expectedToken)) { + $this->_error('TOKEN_FILE_EMPTY'); + return false; + } elseif ($value !== $expectedToken) { + $this->_error('TOKEN_INVALID'); + return false; + } + + return true; + } +} diff --git a/modules/setup/library/Setup/WebWizard.php b/modules/setup/library/Setup/WebWizard.php new file mode 100644 index 0000000..f3b5557 --- /dev/null +++ b/modules/setup/library/Setup/WebWizard.php @@ -0,0 +1,768 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +use Icinga\Application\Platform; +use Icinga\Module\Setup\Requirement\SetRequirement; +use Icinga\Module\Setup\Requirement\WebLibraryRequirement; +use InvalidArgumentException; +use PDOException; +use Icinga\Web\Form; +use Icinga\Web\Wizard; +use Icinga\Web\Request; +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Module\Setup\Forms\ModulePage; +use Icinga\Module\Setup\Forms\WelcomePage; +use Icinga\Module\Setup\Forms\SummaryPage; +use Icinga\Module\Setup\Forms\DbResourcePage; +use Icinga\Module\Setup\Forms\AuthBackendPage; +use Icinga\Module\Setup\Forms\AdminAccountPage; +use Icinga\Module\Setup\Forms\LdapDiscoveryPage; +//use Icinga\Module\Setup\Forms\LdapDiscoveryConfirmPage; +use Icinga\Module\Setup\Forms\LdapResourcePage; +use Icinga\Module\Setup\Forms\RequirementsPage; +use Icinga\Module\Setup\Forms\GeneralConfigPage; +use Icinga\Module\Setup\Forms\AuthenticationPage; +use Icinga\Module\Setup\Forms\DatabaseCreationPage; +use Icinga\Module\Setup\Forms\UserGroupBackendPage; +use Icinga\Module\Setup\Steps\DatabaseStep; +use Icinga\Module\Setup\Steps\GeneralConfigStep; +use Icinga\Module\Setup\Steps\ResourceStep; +use Icinga\Module\Setup\Steps\AuthenticationStep; +use Icinga\Module\Setup\Steps\UserGroupStep; +use Icinga\Module\Setup\Utils\EnableModuleStep; +use Icinga\Module\Setup\Utils\DbTool; +use Icinga\Module\Setup\Requirement\OSRequirement; +use Icinga\Module\Setup\Requirement\PhpModuleRequirement; +use Icinga\Module\Setup\Requirement\PhpVersionRequirement; +use Icinga\Module\Setup\Requirement\ConfigDirectoryRequirement; +use Icinga\Module\Monitoring\Forms\Config\Transport\ApiTransportForm; + +/** + * Icinga Web 2 Setup Wizard + */ +class WebWizard extends Wizard implements SetupWizard +{ + /** + * The privileges required by Icinga Web 2 to create the database and a login + * + * @var array + */ + protected $databaseCreationPrivileges = array( + 'CREATE', + 'CREATE USER', // MySQL + 'CREATEROLE' // PostgreSQL + ); + + /** + * The privileges required by Icinga Web 2 to setup the database + * + * @var array + */ + protected $databaseSetupPrivileges = array( + 'CREATE', + 'ALTER', // MySQL only + 'REFERENCES' + ); + + /** + * The privileges required by Icinga Web 2 to operate the database + * + * @var array + */ + protected $databaseUsagePrivileges = array( + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'EXECUTE', + 'CREATE', + 'CREATE VIEW', + 'ALTER', + 'DROP', + 'INDEX', + 'USAGE', // PostgreSQL + 'TEMPORARY', // PostgreSql + 'CREATE TEMPORARY TABLES' // MySQL + ); + + /** + * The database tables operated by Icinga Web 2 + * + * @var array + */ + protected $databaseTables = array( + 'icingaweb_group', + 'icingaweb_group_membership', + 'icingaweb_user', + 'icingaweb_user_preference', + 'icingaweb_rememberme', + 'icingaweb_schema' + ); + + /** + * Register all pages and module wizards for this wizard + */ + protected function init() + { + $this->addPage(new WelcomePage()); + $this->addPage(new ModulePage()); + $this->addPage(new RequirementsPage()); + $this->addPage(new AuthenticationPage()); + $this->addPage(new DbResourcePage(array('name' => 'setup_auth_db_resource'))); + $this->addPage(new DatabaseCreationPage(array('name' => 'setup_auth_db_creation'))); + $this->addPage(new LdapDiscoveryPage()); + //$this->addPage(new LdapDiscoveryConfirmPage()); + $this->addPage(new LdapResourcePage()); + $this->addPage(new AuthBackendPage()); + $this->addPage(new UserGroupBackendPage()); + $this->addPage(new AdminAccountPage()); + $this->addPage(new GeneralConfigPage()); + $this->addPage(new DbResourcePage(array('name' => 'setup_config_db_resource'))); + $this->addPage(new DatabaseCreationPage(array('name' => 'setup_config_db_creation'))); + $this->addPage(new SummaryPage(array('name' => 'setup_summary'))); + + if (($modulePageData = $this->getPageData('setup_modules')) !== null) { + /** @var ModulePage $modulePage */ + $modulePage = $this->getPage('setup_modules')->populate($modulePageData); + foreach ($modulePage->getModuleWizards() as $moduleWizard) { + $this->addPage($moduleWizard); + } + } + } + + /** + * Setup the given page that is either going to be displayed or validated + * + * @param Form $page The page to setup + * @param Request $request The current request + */ + public function setupPage(Form $page, Request $request) + { + if ($page->getName() === 'setup_requirements') { + /** @var RequirementsPage $page */ + $page->setWizard($this); + } elseif ($page->getName() === 'setup_authentication_backend') { + /** @var AuthBackendPage $page */ + + $authData = $this->getPageData('setup_authentication_type'); + if ($authData['type'] === 'db') { + $page->setResourceConfig($this->getPageData('setup_auth_db_resource')); + } elseif ($authData['type'] === 'ldap') { + $page->setResourceConfig($this->getPageData('setup_ldap_resource')); + + $suggestions = $this->getPageData('setup_ldap_discovery'); + if (isset($suggestions['backend'])) { + $page->setSuggestions($suggestions['backend']); + } + + if ($this->getDirection() === static::FORWARD) { + $backendConfig = $this->getPageData('setup_authentication_backend'); + if ($backendConfig !== null && $request->getPost('name') !== $backendConfig['name']) { + $pageData = & $this->getPageData(); + unset($pageData['setup_usergroup_backend']); + } + } + } + + if ($this->getDirection() === static::FORWARD) { + $backendConfig = $this->getPageData('setup_authentication_backend'); + if ($backendConfig !== null && $request->getPost('backend') !== $backendConfig['backend']) { + $pageData = & $this->getPageData(); + unset($pageData['setup_usergroup_backend']); + } + } + /*} elseif ($page->getName() === 'setup_ldap_discovery_confirm') { + $page->setResourceConfig($this->getPageData('setup_ldap_discovery'));*/ + } elseif ($page->getName() === 'setup_auth_db_resource') { + $page->addDescription(mt( + 'setup', + 'Now please configure the database resource where to store users and user groups.' + )); + $page->addDescription(mt( + 'setup', + 'Note that the database itself does not need to exist at this time as' + . ' it is going to be created once the wizard is about to be finished.' + )); + } elseif ($page->getName() === 'setup_usergroup_backend') { + /** @var UserGroupBackendPage $page */ + $page->setResourceConfig($this->getPageData('setup_ldap_resource')); + $page->setBackendConfig($this->getPageData('setup_authentication_backend')); + } elseif ($page->getName() === 'setup_admin_account') { + /** @var AdminAccountPage $page */ + $page->setBackendConfig($this->getPageData('setup_authentication_backend')); + $page->setGroupConfig($this->getPageData('setup_usergroup_backend')); + $authData = $this->getPageData('setup_authentication_type'); + if ($authData['type'] === 'db') { + $page->setResourceConfig($this->getPageData('setup_auth_db_resource')); + } elseif ($authData['type'] === 'ldap') { + $page->setResourceConfig($this->getPageData('setup_ldap_resource')); + } + } elseif ($page->getName() === 'setup_auth_db_creation' || $page->getName() === 'setup_config_db_creation') { + /** @var DatabaseCreationPage $page */ + $page->setDatabaseSetupPrivileges( + array_unique(array_merge($this->databaseCreationPrivileges, $this->databaseSetupPrivileges)) + ); + $page->setDatabaseUsagePrivileges($this->databaseUsagePrivileges); + $page->setResourceConfig( + $this->getPageData('setup_auth_db_resource') ?: $this->getPageData('setup_config_db_resource') + ); + } elseif ($page->getName() === 'setup_summary') { + /** @var SummaryPage $page */ + $page->setSubjectTitle('Icinga Web 2'); + $page->setSummary($this->getSetup()->getSummary()); + } elseif ($page->getName() === 'setup_config_db_resource') { + $page->addDescription(mt( + 'setup', + 'Now please configure the database resource where to store user preferences.' + )); + $page->addDescription(mt( + 'setup', + 'Note that the database itself does not need to exist at this time as' + . ' it is going to be created once the wizard is about to be finished.' + )); + + $ldapData = $this->getPageData('setup_ldap_resource'); + if ($ldapData !== null && $request->getPost('name') === $ldapData['name']) { + $page->error( + mt('setup', 'The given resource name must be unique and is already in use by the LDAP resource') + ); + } + } elseif ($page->getName() === 'setup_ldap_resource') { + $suggestion = $this->getPageData('setup_ldap_discovery'); + if (isset($suggestion['resource'])) { + $page->populate($suggestion['resource']); + } + + if ($this->getDirection() === static::FORWARD) { + $resourceConfig = $this->getPageData('setup_ldap_resource'); + if ($resourceConfig !== null && $request->getPost('name') !== $resourceConfig['name']) { + $pageData = & $this->getPageData(); + unset($pageData['setup_usergroup_backend']); + } + } + } elseif ($page->getName() === 'setup_authentication_type') { + $authData = $this->getPageData($page->getName()); + $pageData = & $this->getPageData(); + + if ($authData !== null && $request->getPost('type') !== $authData['type']) { + // Drop any existing page data in case the authentication type has changed, + // otherwise it will conflict with other forms that depend on this one + unset($pageData['setup_admin_account']); + unset($pageData['setup_authentication_backend']); + + if ($authData['type'] === 'db') { + unset($pageData['setup_auth_db_resource']); + unset($pageData['setup_auth_db_creation']); + } elseif ($request->getPost('type') === 'db') { + unset($pageData['setup_config_db_resource']); + unset($pageData['setup_config_db_creation']); + } + } elseif (isset($authData['type']) && $authData['type'] == 'external') { + // If you choose the authentication type external and validate the database and then come + // back to change the authentication type but do not change it, you will get an database configuration + // related error message on the next page. To avoid this error, the 'setup_config_db_resource' + // page must be unset. + + unset($pageData['setup_config_db_resource']); + } + } + } + + /** + * Return the new page to set as current page + * + * {@inheritdoc} Runs additional checks related to some registered pages. + * + * @param string $requestedPage The name of the requested page + * @param Form $originPage The origin page + * + * @return Form The new page + * + * @throws InvalidArgumentException In case the requested page does not exist or is not permitted yet + */ + protected function getNewPage($requestedPage, Form $originPage) + { + $skip = false; + $newPage = parent::getNewPage($requestedPage, $originPage); + if ($newPage->getName() === 'setup_auth_db_resource') { + $authData = $this->getPageData('setup_authentication_type'); + $skip = $authData['type'] !== 'db'; + } elseif ($newPage->getName() === 'setup_ldap_discovery') { + $authData = $this->getPageData('setup_authentication_type'); + $skip = $authData['type'] !== 'ldap'; + /*} elseif ($newPage->getName() === 'setup_ldap_discovery_confirm') { + $skip = false === $this->hasPageData('setup_ldap_discovery');*/ + } elseif ($newPage->getName() === 'setup_ldap_resource') { + $authData = $this->getPageData('setup_authentication_type'); + $skip = $authData['type'] !== 'ldap'; + } elseif ($newPage->getName() === 'setup_usergroup_backend') { + $backendConfig = $this->getPageData('setup_authentication_backend'); + $skip = $backendConfig['backend'] !== 'ldap'; + } elseif ($newPage->getName() === 'setup_config_db_resource') { + $authData = $this->getPageData('setup_authentication_type'); + $skip = $authData['type'] === 'db'; + } elseif (in_array($newPage->getName(), array('setup_auth_db_creation', 'setup_config_db_creation'))) { + if (($newPage->getName() === 'setup_auth_db_creation' || $this->hasPageData('setup_config_db_resource')) + && (($config = $this->getPageData('setup_auth_db_resource')) !== null + || ($config = $this->getPageData('setup_config_db_resource')) !== null) + && !$config['skip_validation'] && $this->getDirection() == static::FORWARD + ) { + // Execute this code only if the direction is forward. + // Otherwise, an error will be output when you go back. + $db = new DbTool($config); + + try { + $db->connectToDb(); // Are we able to login on the database? + + if (array_search(reset($this->databaseTables), $db->listTables(), true) === false) { + // In case the database schema does not yet exist the + // user needs the privileges to setup the database + $skip = $db->checkPrivileges($this->databaseSetupPrivileges, $this->databaseTables); + } else { + // In case the database schema exists the user needs the required privileges + // to operate the database, if those are missing we ask for another user + $skip = $db->checkPrivileges($this->databaseUsagePrivileges, $this->databaseTables); + } + } catch (PDOException $_) { + try { + $db->connectToHost(); // Are we able to login on the server? + // It is not possible to reliably determine whether a database exists or not if a user can't + // log in to the database, so we just require the user to be able to create the database + $skip = $db->checkPrivileges( + array_unique( + array_merge($this->databaseCreationPrivileges, $this->databaseSetupPrivileges) + ), + $this->databaseTables + ); + } catch (PDOException $_) { + // We are NOT able to login on the server.. + } + } + } else { + $skip = true; + } + } + + return $skip ? $this->skipPage($newPage) : $newPage; + } + + /** + * Add buttons to the given page based on its position in the page-chain + * + * @param Form $page The page to add the buttons to + */ + protected function addButtons(Form $page) + { + parent::addButtons($page); + + $pages = $this->getPages(); + $index = array_search($page, $pages, true); + if ($index === 0) { + $page->getElement(static::BTN_NEXT)->setLabel( + mt('setup', 'Start', 'setup.welcome.btn.next') + ); + } elseif ($index === count($pages) - 1) { + $page->getElement(static::BTN_NEXT)->setLabel( + mt('setup', 'Setup Icinga Web 2', 'setup.summary.btn.finish') + ); + } + + $authData = $this->getPageData('setup_authentication_type'); + $veto = $page->getName() === 'setup_authentication_backend' && $authData['type'] === 'db'; + if (! $veto && in_array($page->getName(), array( + 'setup_authentication_backend', + 'setup_auth_db_resource', + 'setup_config_db_resource', + 'setup_ldap_resource', + 'setup_monitoring_ido', + 'setup_icingadb_resource', + 'setup_icingadb_redis', + 'setup_icingadb_api_transport' + ))) { + $page->addElement( + 'submit', + 'backend_validation', + array( + 'ignore' => true, + 'label' => t('Validate Configuration'), + 'data-progress-label' => t('Validation In Progress'), + 'decorators' => array('ViewHelper'), + 'formnovalidate' => 'formnovalidate' + ) + ); + $page->getDisplayGroup('buttons')->addElement($page->getElement('backend_validation')); + } + + if ($page->getName() === 'setup_command_transport') { + if ($page->getSubForm('transport_form')->getSubForm('transport_form') instanceof ApiTransportForm) { + $page->addElement( + 'submit', + 'transport_validation', + array( + 'ignore' => true, + 'label' => t('Validate Configuration'), + 'data-progress-label' => t('Validation In Progress'), + 'decorators' => array('ViewHelper'), + 'formnovalidate' => 'formnovalidate' + ) + ); + $page->getDisplayGroup('buttons')->addElement($page->getElement('transport_validation')); + } + } + } + + /** + * Clear the session being used by this wizard + * + * @param bool $removeToken If true, the setup token will be removed + */ + public function clearSession($removeToken = true) + { + parent::clearSession(); + + if ($removeToken) { + $tokenPath = Config::resolvePath('setup.token'); + if (file_exists($tokenPath)) { + @unlink($tokenPath); + } + } + } + + /** + * Return the setup for this wizard + * + * @return Setup + */ + public function getSetup() + { + $pageData = $this->getPageData(); + $setup = new Setup(); + + if (isset($pageData['setup_auth_db_resource']) + && !$pageData['setup_auth_db_resource']['skip_validation'] + && (! isset($pageData['setup_auth_db_creation']) + || !$pageData['setup_auth_db_creation']['skip_validation'] + ) + ) { + $setup->addStep( + new DatabaseStep(array( + 'tables' => $this->databaseTables, + 'privileges' => $this->databaseUsagePrivileges, + 'resourceConfig' => $pageData['setup_auth_db_resource'], + 'adminName' => isset($pageData['setup_auth_db_creation']['username']) + ? $pageData['setup_auth_db_creation']['username'] + : null, + 'adminPassword' => isset($pageData['setup_auth_db_creation']['password']) + ? $pageData['setup_auth_db_creation']['password'] + : null, + 'schemaPath' => Config::module('setup') + ->get('schema', 'path', Icinga::app()->getBaseDir('schema')) + )) + ); + } elseif (isset($pageData['setup_config_db_resource']) + && !$pageData['setup_config_db_resource']['skip_validation'] + && (! isset($pageData['setup_config_db_creation']) + || !$pageData['setup_config_db_creation']['skip_validation'] + ) + ) { + $setup->addStep( + new DatabaseStep(array( + 'tables' => $this->databaseTables, + 'privileges' => $this->databaseUsagePrivileges, + 'resourceConfig' => $pageData['setup_config_db_resource'], + 'adminName' => isset($pageData['setup_config_db_creation']['username']) + ? $pageData['setup_config_db_creation']['username'] + : null, + 'adminPassword' => isset($pageData['setup_config_db_creation']['password']) + ? $pageData['setup_config_db_creation']['password'] + : null, + 'schemaPath' => Config::module('setup') + ->get('schema', 'path', Icinga::app()->getBaseDir('schema')) + )) + ); + } + + $setup->addStep( + new GeneralConfigStep(array( + 'generalConfig' => $pageData['setup_general_config'], + 'resourceName' => isset($pageData['setup_auth_db_resource']['name']) + ? $pageData['setup_auth_db_resource']['name'] + : (isset($pageData['setup_config_db_resource']['name']) + ? $pageData['setup_config_db_resource']['name'] + : null + ) + )) + ); + + $adminAccountType = $pageData['setup_admin_account']['user_type']; + if ($adminAccountType === 'user_group') { + $adminAccountData = array('groupname' => $pageData['setup_admin_account'][$adminAccountType]); + } else { + $adminAccountData = array('username' => $pageData['setup_admin_account'][$adminAccountType]); + if ($adminAccountType === 'new_user' && !$pageData['setup_auth_db_resource']['skip_validation'] + && (! isset($pageData['setup_auth_db_creation']) + || !$pageData['setup_auth_db_creation']['skip_validation'] + ) + ) { + $adminAccountData['resourceConfig'] = $pageData['setup_auth_db_resource']; + $adminAccountData['password'] = $pageData['setup_admin_account']['new_user_password']; + } + } + $authType = $pageData['setup_authentication_type']['type']; + $setup->addStep( + new AuthenticationStep(array( + 'adminAccountData' => $adminAccountData, + 'backendConfig' => $pageData['setup_authentication_backend'], + 'resourceName' => $authType === 'db' ? $pageData['setup_auth_db_resource']['name'] : ( + $authType === 'ldap' ? $pageData['setup_ldap_resource']['name'] : null + ) + )) + ); + + if ($authType !== 'external') { + $setup->addStep( + new UserGroupStep(array( + 'backendConfig' => $pageData['setup_authentication_backend'], + 'groupConfig' => isset($pageData['setup_usergroup_backend']) + ? $pageData['setup_usergroup_backend'] + : null, + 'resourceName' => $authType === 'db' + ? $pageData['setup_auth_db_resource']['name'] + : $pageData['setup_ldap_resource']['name'], + 'resourceConfig' => $authType === 'db' + ? $pageData['setup_auth_db_resource'] + : null, + 'username' => $authType === 'db' + ? $pageData['setup_admin_account'][$adminAccountType] + : null + )) + ); + } + + if (isset($pageData['setup_auth_db_resource']) + || isset($pageData['setup_config_db_resource']) + || isset($pageData['setup_ldap_resource']) + ) { + $setup->addStep( + new ResourceStep(array( + 'dbResourceConfig' => isset($pageData['setup_auth_db_resource']) + ? array_diff_key($pageData['setup_auth_db_resource'], array('skip_validation' => null)) + : (isset($pageData['setup_config_db_resource']) + ? array_diff_key($pageData['setup_config_db_resource'], array('skip_validation' => null)) + : null + ), + 'ldapResourceConfig' => isset($pageData['setup_ldap_resource']) + ? array_diff_key($pageData['setup_ldap_resource'], array('skip_validation' => null)) + : null + )) + ); + } + + foreach ($this->getWizards() as $wizard) { + if ($wizard->isComplete()) { + $setup->addSteps($wizard->getSetup()->getSteps()); + } + } + + /** @var ModulePage $setupPage */ + $setupPage = $this->getPage('setup_modules'); + $setup->addStep(new EnableModuleStep(array_keys($setupPage->getCheckedModules()))); + + return $setup; + } + + /** + * Return the requirements of this wizard + * + * @return RequirementSet + */ + public function getRequirements($skipModules = false) + { + $set = new RequirementSet(); + + $set->add(new PhpVersionRequirement(array( + 'condition' => array('>=', '7.2'), + 'description' => sprintf(mt( + 'setup', + 'Running Icinga Web 2 requires PHP version %s.' + ), '7.2') + ))); + + $set->add(new OSRequirement(array( + 'optional' => true, + 'condition' => 'linux', + 'description' => mt( + 'setup', + 'Icinga Web 2 is developed for and tested on Linux. While we cannot' + . ' guarantee they will, other platforms may also perform as well.' + ) + ))); + + $set->add(new WebLibraryRequirement(array( + 'condition' => ['icinga-php-library', '>=', '0.13.0'], + 'alias' => 'Icinga PHP library', + 'description' => mt( + 'setup', + 'The Icinga PHP library (IPL) is required for Icinga Web 2 and modules' + ) + ))); + + $set->add(new WebLibraryRequirement(array( + 'condition' => ['icinga-php-thirdparty', '>=', '0.12.0'], + 'alias' => 'Icinga PHP Thirdparty', + 'description' => mt( + 'setup', + 'The Icinga PHP Thirdparty library is required for Icinga Web 2 and modules' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'condition' => 'OpenSSL', + 'description' => mt( + 'setup', + 'The PHP module for OpenSSL is required to generate cryptographically safe password salts.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'condition' => 'XML', + 'description' => mt( + 'setup', + 'The XML module for PHP is required for Markdown and custom HTML annotations.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'condition' => 'JSON', + 'description' => mt( + 'setup', + 'The JSON module for PHP is required for various export functionalities as well as APIs.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'condition' => 'gettext', + 'description' => mt( + 'setup', + 'For message localization, the gettext module for PHP is required.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'condition' => 'INTL', + 'description' => mt( + 'setup', + 'For language, timezone and date/time format negotiation, the INTL module for PHP is required.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'condition' => 'DOM', + 'description' => mt( + 'setup', + 'For charts and exports of views and reports to PDF, the DOM module for PHP is required.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'optional' => true, + 'condition' => 'LDAP', + 'description' => mt( + 'setup', + 'If you\'d like to authenticate users using LDAP the corresponding PHP module is required.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'optional' => true, + 'condition' => 'mbstring', + 'description' => mt( + 'setup', + 'In case you want views being exported to PDF, you\'ll need the mbstring extension for PHP.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'optional' => true, + 'condition' => 'GD', + 'description' => mt( + 'setup', + 'In case you want views being exported to PDF, you\'ll need the GD extension for PHP.' + ) + ))); + + $set->add(new PhpModuleRequirement(array( + 'optional' => true, + 'condition' => 'Imagick', + 'description' => mt( + 'setup', + 'In case you want graphs being exported to PDF as well, you\'ll need the ImageMagick extension for PHP.' + ) + ))); + + $dbSet = new RequirementSet(false, RequirementSet::MODE_OR); + $dbSet->add(new PhpModuleRequirement(array( + 'optional' => true, + 'condition' => 'pdo_mysql', + 'alias' => 'PDO-MySQL', + 'description' => mt( + 'setup', + 'To store users or preferences in a MySQL database the PDO-MySQL module for PHP is required.' + ) + ))); + $dbSet->add(new PhpModuleRequirement(array( + 'optional' => true, + 'condition' => 'pdo_pgsql', + 'alias' => 'PDO-PostgreSQL', + 'description' => mt( + 'setup', + 'To store users or preferences in a PostgreSQL database the PDO-PostgreSQL module for PHP is required.' + ) + ))); + $set->merge($dbSet); + + $dbRequire = (new SetRequirement(array( + 'optional' => false, + 'condition' => $dbSet, + 'title' =>'Database', + 'alias' => 'PDO-MySQL OR PDO-PostgreSQL', + 'description' => mt( + 'setup', + 'A database is mandatory, therefore at least one module ' + . 'PDO-MySQL or PDO-PostgreSQL for PHP is required.' + ) + ))); + + $set->add($dbRequire); + + $set->add(new ConfigDirectoryRequirement(array( + 'condition' => Icinga::app()->getStorageDir(), + 'title' => mt('setup', 'Read- and writable storage directory'), + 'description' => mt( + 'setup', + 'The Icinga Web 2 storage directory defaults to "/var/lib/icingaweb2", if' . + ' not explicitly set in the environment variable "ICINGAWEB_STORAGEDIR".' + ) + ))); + + $set->add(new ConfigDirectoryRequirement(array( + 'condition' => Icinga::app()->getConfigDir(), + 'description' => mt( + 'setup', + 'The Icinga Web 2 configuration directory defaults to "/etc/icingaweb2", if' . + ' not explicitly set in the environment variable "ICINGAWEB_CONFIGDIR".' + ) + ))); + + if (! $skipModules) { + foreach ($this->getWizards() as $wizard) { + $set->merge($wizard->getRequirements()); + } + } + + return $set; + } +} diff --git a/modules/setup/library/Setup/Webserver.php b/modules/setup/library/Setup/Webserver.php new file mode 100644 index 0000000..77ff237 --- /dev/null +++ b/modules/setup/library/Setup/Webserver.php @@ -0,0 +1,233 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup; + +use Icinga\Application\Icinga; +use Icinga\Exception\ProgrammingError; + +/** + * Base class for generating webserver configuration + */ +abstract class Webserver +{ + /** + * Document root + * + * @var string + */ + protected $documentRoot; + + /** + * URL path of Icinga Web 2 + * + * @var string + */ + protected $urlPath = '/icingaweb2'; + + /** + * Path to Icinga Web 2's configuration files + * + * @var string + */ + protected $configDir; + + /** + * Address or path where to pass requests to FPM + * + * @var string + */ + protected $fpmUri; + + /** + * Enable to pass requests to FPM + * + * @var bool + */ + protected $enableFpm = false; + + /** + * Create instance by type name + * + * @param string $type + * + * @return Webserver + * + * @throws ProgrammingError + */ + public static function createInstance($type) + { + $class = __NAMESPACE__ . '\\Webserver\\' . ucfirst($type); + if (class_exists($class)) { + return new $class(); + } + throw new ProgrammingError('Class "%s" does not exist', $class); + } + + /** + * Generate configuration + * + * @return string + */ + public function generate() + { + $template = $this->getTemplate(); + + $searchTokens = array( + '{urlPath}', + '{documentRoot}', + '{aliasDocumentRoot}', + '{configDir}', + '{fpmUri}' + ); + $replaceTokens = array( + $this->getUrlPath(), + $this->getDocumentRoot(), + preg_match('~/$~', $this->getUrlPath()) ? $this->getDocumentRoot() . '/' : $this->getDocumentRoot(), + $this->getConfigDir(), + $this->getFpmUri() + ); + $template = str_replace($searchTokens, $replaceTokens, $template); + return $template; + } + + /** + * Specific template + * + * @return string + */ + abstract protected function getTemplate(); + + /** + * Set the URL path of Icinga Web 2 + * + * @param string $urlPath + * + * @return $this + */ + public function setUrlPath($urlPath) + { + $this->urlPath = '/' . ltrim(trim((string) $urlPath), '/'); + return $this; + } + + /** + * Get the URL path of Icinga Web 2 + * + * @return string + */ + public function getUrlPath() + { + return $this->urlPath; + } + + /** + * Set the document root + * + * @param string $documentRoot + * + * @return $this + */ + public function setDocumentRoot($documentRoot) + { + $this->documentRoot = trim((string) $documentRoot); + return $this; + } + + /** + * Detect the document root + * + * @return string + */ + public function detectDocumentRoot() + { + return Icinga::app()->getBaseDir('public'); + } + + /** + * Get the document root + * + * @return string + */ + public function getDocumentRoot() + { + if ($this->documentRoot === null) { + $this->documentRoot = $this->detectDocumentRoot(); + } + return $this->documentRoot; + } + + /** + * Set the configuration directory + * + * @param string $configDir + * + * @return $this + */ + public function setConfigDir($configDir) + { + $this->configDir = (string) $configDir; + return $this; + } + + /** + * Get the configuration directory + * + * @return string + */ + public function getConfigDir() + { + if ($this->configDir === null) { + return Icinga::app()->getConfigDir(); + } + return $this->configDir; + } + + /** + * Get whether FPM is enabled + * + * @return bool + */ + public function getEnableFpm() + { + return $this->enableFpm; + } + + /** + * Set FPM enabled + * + * @param bool $flag + * + * @return $this + */ + public function setEnableFpm($flag) + { + $this->enableFpm = (bool) $flag; + + return $this; + } + + /** + * Get the address or path where to pass requests to FPM + * + * @return string + */ + public function getFpmUri() + { + return $this->fpmUri; + } + + /** + * Set the address or path where to pass requests to FPM + * + * @param string $uri + * + * @return $this + */ + public function setFpmUri($uri) + { + $this->fpmUri = (string) $uri; + + return $this; + } +} diff --git a/modules/setup/library/Setup/Webserver/Apache.php b/modules/setup/library/Setup/Webserver/Apache.php new file mode 100644 index 0000000..fdb367f --- /dev/null +++ b/modules/setup/library/Setup/Webserver/Apache.php @@ -0,0 +1,142 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Webserver; + +use Icinga\Module\Setup\Webserver; + +/** + * Generate Apache 2.x configuration + */ +class Apache extends Webserver +{ + protected $fpmUri = '127.0.0.1:9000'; + + protected function getTemplate() + { + if (! $this->enableFpm) { + return <<<'EOD' +Alias {urlPath} "{aliasDocumentRoot}" + +# Remove comments if you want to use PHP FPM and your Apache version is older than 2.4 +#<IfVersion < 2.4> +# # Forward PHP requests to FPM +# SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 +# <LocationMatch "^{urlPath}/(.*\.php)$"> +# ProxyPassMatch "fcgi://{fpmUri}/{documentRoot}/$1" +# </LocationMatch> +#</IfVersion> + +<Directory "{documentRoot}"> + Options SymLinksIfOwnerMatch + AllowOverride None + + DirectoryIndex index.php + + <IfModule mod_authz_core.c> + # Apache 2.4 + <RequireAll> + Require all granted + </RequireAll> + </IfModule> + + <IfModule !mod_authz_core.c> + # Apache 2.2 + Order allow,deny + Allow from all + </IfModule> + + SetEnv ICINGAWEB_CONFIGDIR "{configDir}" + + EnableSendfile Off + + <IfModule mod_rewrite.c> + RewriteEngine on + RewriteBase {urlPath}/ + RewriteCond %{REQUEST_FILENAME} -s [OR] + RewriteCond %{REQUEST_FILENAME} -l [OR] + RewriteCond %{REQUEST_FILENAME} -d + RewriteRule ^.*$ - [NC,L] + RewriteRule ^.*$ index.php [NC,L] + </IfModule> + + <IfModule !mod_rewrite.c> + DirectoryIndex error_norewrite.html + ErrorDocument 404 {urlPath}/error_norewrite.html + </IfModule> + +# Remove comments if you want to use PHP FPM and your Apache version +# is greater than or equal to 2.4 +# <IfVersion >= 2.4> +# # Forward PHP requests to FPM +# SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 +# <FilesMatch "\.php$"> +# SetHandler "proxy:fcgi://{fpmUri}" +# ErrorDocument 503 {urlPath}/error_unavailable.html +# </FilesMatch> +# </IfVersion> +</Directory> +EOD; + } else { + return <<<'EOD' +Alias {urlPath} "{aliasDocumentRoot}" + +<IfVersion < 2.4> + # Forward PHP requests to FPM + SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 + <LocationMatch "^{urlPath}/(.*\.php)$"> + ProxyPassMatch "fcgi://{fpmUri}/{documentRoot}/$1" + </LocationMatch> +</IfVersion> + +<Directory "{documentRoot}"> + Options SymLinksIfOwnerMatch + AllowOverride None + + DirectoryIndex index.php + + <IfModule mod_authz_core.c> + # Apache 2.4 + <RequireAll> + Require all granted + </RequireAll> + </IfModule> + + <IfModule !mod_authz_core.c> + # Apache 2.2 + Order allow,deny + Allow from all + </IfModule> + + SetEnv ICINGAWEB_CONFIGDIR "{configDir}" + + EnableSendfile Off + + <IfModule mod_rewrite.c> + RewriteEngine on + RewriteBase {urlPath}/ + RewriteCond %{REQUEST_FILENAME} -s [OR] + RewriteCond %{REQUEST_FILENAME} -l [OR] + RewriteCond %{REQUEST_FILENAME} -d + RewriteRule ^.*$ - [NC,L] + RewriteRule ^.*$ index.php [NC,L] + </IfModule> + + <IfModule !mod_rewrite.c> + DirectoryIndex error_norewrite.html + ErrorDocument 404 {urlPath}/error_norewrite.html + </IfModule> + + <IfVersion >= 2.4> + # Forward PHP requests to FPM + SetEnvIf Authorization "(.*)" HTTP_AUTHORIZATION=$1 + <FilesMatch "\.php$"> + SetHandler "proxy:fcgi://{fpmUri}" + ErrorDocument 503 {urlPath}/error_unavailable.html + </FilesMatch> + </IfVersion> +</Directory> +EOD; + } + } +} diff --git a/modules/setup/library/Setup/Webserver/Nginx.php b/modules/setup/library/Setup/Webserver/Nginx.php new file mode 100644 index 0000000..c7ae716 --- /dev/null +++ b/modules/setup/library/Setup/Webserver/Nginx.php @@ -0,0 +1,36 @@ +<?php +/* Icinga Web 2 | (c) 2014 Icinga Development Team | GPLv2+ */ + +namespace Icinga\Module\Setup\Webserver; + +use Icinga\Module\Setup\Webserver; + +/** + * Generate nginx configuration + */ +class Nginx extends Webserver +{ + protected $fpmUri = '127.0.0.1:9000'; + + protected $enableFpm = true; + + protected function getTemplate() + { + return <<<'EOD' +location ~ ^{urlPath}/index\.php(.*)$ { + fastcgi_pass {fpmUri}; + fastcgi_index index.php; + include fastcgi_params; + fastcgi_param SCRIPT_FILENAME {documentRoot}/index.php; + fastcgi_param ICINGAWEB_CONFIGDIR {configDir}; + fastcgi_param REMOTE_USER $remote_user; +} + +location ~ ^{urlPath}(.+)? { + alias {documentRoot}; + index index.php; + try_files $1 $uri $uri/ {urlPath}/index.php$is_args$args; +} +EOD; + } +} diff --git a/modules/setup/module.info b/modules/setup/module.info new file mode 100644 index 0000000..6127e6d --- /dev/null +++ b/modules/setup/module.info @@ -0,0 +1,6 @@ +Module: setup +Version: 2.12.1 +Description: Setup module + Web based wizard for setting up Icinga Web 2 and its modules. + This includes the data backends (e.g. relational database, LDAP), + the authentication method, where to store the user preferences and much more. |