icingadb-web/application/clicommands/MigrateCommand.php
2025-04-10 15:04:41 +02:00

832 lines
31 KiB
PHP

<?php
/* Icinga DB Web | (c) 2023 Icinga GmbH | GPLv2 */
namespace Icinga\Module\Icingadb\Clicommands;
use Icinga\Application\Config;
use Icinga\Application\Logger;
use Icinga\Cli\Command;
use Icinga\Data\ConfigObject;
use Icinga\Exception\NotReadableError;
use Icinga\Exception\NotWritableError;
use Icinga\Module\Icingadb\Compat\UrlMigrator;
use Icinga\Util\DirectoryIterator;
use Icinga\Web\Request;
use ipl\Stdlib\Str;
use ipl\Web\Filter\QueryString;
use ipl\Web\Url;
class MigrateCommand extends Command
{
/** @var bool Skip the migration, only perform transformations */
protected $skipMigration = false;
public function init(): void
{
Logger::getInstance()->setLevel(Logger::INFO);
}
/**
* Migrate monitoring navigation items to Icinga DB Web
*
* USAGE
*
* icingacli icingadb migrate navigation [options]
*
* REQUIRED OPTIONS:
*
* --user=<name> Migrate navigation items whose owner matches the given
* name or owners matching the given pattern. Wildcard
* matching by `*` possible.
*
* OPTIONS:
*
* --override Replace existing or already migrated items
* (Attention: Actions are not backed up)
*
* --no-backup Remove monitoring actions and don't back up menu items
*/
public function navigationAction(): void
{
/** @var string $user */
$user = $this->params->getRequired('user');
$noBackup = $this->params->get('no-backup');
$preferencesPath = Config::resolvePath('preferences');
$sharedNavigation = Config::resolvePath('navigation');
if (! file_exists($preferencesPath) && ! file_exists($sharedNavigation)) {
Logger::info('There are no user navigation items to migrate');
return;
}
$rc = 0;
$directories = file_exists($preferencesPath) ? new DirectoryIterator($preferencesPath) : [];
$anythingChanged = false;
/** @var string $directory */
foreach ($directories as $directory) {
/** @var string $username */
$username = $directories->key() === false ? '' : $directories->key();
if (fnmatch($user, $username) === false) {
continue;
}
$menuItems = $this->readFromIni($directory . '/menu.ini', $rc);
$hostActions = $this->readFromIni($directory . '/host-actions.ini', $rc);
$serviceActions = $this->readFromIni($directory . '/service-actions.ini', $rc);
$icingadbHostActions = $this->readFromIni($directory . '/icingadb-host-actions.ini', $rc);
$icingadbServiceActions = $this->readFromIni($directory . '/icingadb-service-actions.ini', $rc);
$menuUpdated = false;
$originalMenuItems = $this->readFromIni($directory . '/menu.ini', $rc);
Logger::info(
'Transforming legacy wildcard filters of existing Icinga DB Web items for user "%s"',
$username
);
if (! $menuItems->isEmpty()) {
$menuUpdated = $this->transformNavigationItems($menuItems, $username, $rc);
$anythingChanged |= $menuUpdated;
}
if (! $icingadbHostActions->isEmpty()) {
$anythingChanged |= $this->transformNavigationItems($icingadbHostActions, $username, $rc);
}
if (! $icingadbServiceActions->isEmpty()) {
$anythingChanged |= $this->transformNavigationItems(
$icingadbServiceActions,
$username,
$rc
);
}
if (! $this->skipMigration) {
Logger::info('Migrating monitoring navigation items for user "%s" to Icinga DB Web', $username);
if (! $menuItems->isEmpty()) {
$menuUpdated = $this->migrateNavigationItems($menuItems, $username, $directory . '/menu.ini', $rc);
$anythingChanged |= $menuUpdated;
}
if (! $hostActions->isEmpty()) {
$anythingChanged |= $this->migrateNavigationItems(
$hostActions,
$username,
$directory . '/icingadb-host-actions.ini',
$rc
);
}
if (! $serviceActions->isEmpty()) {
$anythingChanged |= $this->migrateNavigationItems(
$serviceActions,
$username,
$directory . '/icingadb-service-actions.ini',
$rc
);
}
}
if ($menuUpdated && ! $noBackup) {
$this->createBackupIni("$directory/menu", $originalMenuItems);
}
}
// Start migrating shared navigation items
$menuItems = $this->readFromIni($sharedNavigation . '/menu.ini', $rc);
$hostActions = $this->readFromIni($sharedNavigation . '/host-actions.ini', $rc);
$serviceActions = $this->readFromIni($sharedNavigation . '/service-actions.ini', $rc);
$icingadbHostActions = $this->readFromIni($sharedNavigation . '/icingadb-host-actions.ini', $rc);
$icingadbServiceActions = $this->readFromIni($sharedNavigation . '/icingadb-service-actions.ini', $rc);
$menuUpdated = false;
$originalMenuItems = $this->readFromIni($sharedNavigation . '/menu.ini', $rc);
Logger::info('Transforming legacy wildcard filters of existing shared Icinga DB Web navigation items');
if (! $menuItems->isEmpty()) {
$menuUpdated = $this->transformNavigationItems($menuItems, $user, $rc);
$anythingChanged |= $menuUpdated;
}
if (! $icingadbHostActions->isEmpty()) {
$anythingChanged |= $this->transformNavigationItems($icingadbHostActions, $user, $rc);
}
if (! $icingadbServiceActions->isEmpty()) {
$anythingChanged |= $this->transformNavigationItems(
$icingadbServiceActions,
$user,
$rc
);
}
if (! $this->skipMigration) {
Logger::info('Migrating shared monitoring navigation items to the Icinga DB Web items');
if (! $menuItems->isEmpty()) {
$menuUpdated = $this->migrateNavigationItems($menuItems, $user, $sharedNavigation . '/menu.ini', $rc);
$anythingChanged |= $menuUpdated;
}
if (! $hostActions->isEmpty()) {
$anythingChanged |= $this->migrateNavigationItems(
$hostActions,
$user,
$sharedNavigation . '/icingadb-host-actions.ini',
$rc
);
}
if (! $serviceActions->isEmpty()) {
$anythingChanged |= $this->migrateNavigationItems(
$serviceActions,
$user,
$sharedNavigation . '/icingadb-service-actions.ini',
$rc
);
}
}
if ($menuUpdated && ! $noBackup) {
$this->createBackupIni("$sharedNavigation/menu", $originalMenuItems);
}
if ($rc > 0) {
if ($this->skipMigration) {
Logger::error('Failed to transform some icingadb navigation items');
} else {
Logger::error('Failed to migrate some monitoring navigation items');
}
exit($rc);
}
if (! $anythingChanged) {
Logger::info('Nothing to do');
} elseif ($this->skipMigration) {
Logger::info('Successfully transformed all icingadb navigation item filters');
} else {
Logger::info('Successfully migrated all monitoring navigation items');
}
}
/**
* Migrate monitoring restrictions and permissions to Icinga DB Web
*
* Migrated roles do not grant general or full access to users afterward.
* It is recommended to review any changes made by this command, before
* manually granting access.
*
* USAGE
*
* icingacli icingadb migrate role [options]
*
* REQUIRED OPTIONS: (Use either, not both)
*
* --group=<name> Update roles that are assigned to the given group or to
* groups matching the pattern. Wildcard matching by `*`
* possible.
*
* --role=<name> Update role with the given name or roles whose names
* match the pattern. Wildcard matching by `*` possible.
*
* OPTIONS:
*
* --override Reset any existing Icinga DB Web rules
*
* --no-backup Don't back up roles
*/
public function roleAction(): void
{
/** @var ?bool $override */
$override = $this->params->get('override');
$noBackup = $this->params->get('no-backup');
/** @var ?string $groupName */
$groupName = $this->params->get('group');
/** @var ?string $roleName */
$roleName = $this->params->get('role');
if ($roleName === null && $groupName === null) {
$this->fail("One of the parameters 'group' or 'role' must be supplied");
} elseif ($roleName !== null && $groupName !== null) {
$this->fail("Use either 'group' or 'role'. Both cannot be used as role overrules group.");
}
$rc = 0;
$changed = false;
$restrictions = Config::$configDir . '/roles.ini';
$rolesConfig = $this->readFromIni($restrictions, $rc);
$monitoringRestriction = 'monitoring/filter/objects';
$monitoringPropertyBlackList = 'monitoring/blacklist/properties';
$icingadbRestrictions = [
'objects' => 'icingadb/filter/objects',
'hosts' => 'icingadb/filter/hosts',
'services' => 'icingadb/filter/services'
];
$icingadbPropertyDenyList = 'icingadb/denylist/variables';
foreach ($rolesConfig as $name => $role) {
/** @var string[] $role */
$role = iterator_to_array($role);
if ($roleName === '*' || $groupName === '*') {
$roleMatch = true;
} elseif ($roleName !== null && fnmatch($roleName, $name)) {
$roleMatch = true;
} elseif ($groupName !== null && isset($role['groups'])) {
$roleGroups = array_map('trim', explode(',', $role['groups']));
$roleMatch = false;
foreach ($roleGroups as $roleGroup) {
if (fnmatch($groupName, $roleGroup)) {
$roleMatch = true;
break;
}
}
} else {
$roleMatch = false;
}
if ($roleMatch && ! $this->skipMigration && $this->shouldUpdateRole($role, $override)) {
if (isset($role[$monitoringRestriction])) {
Logger::info(
'Migrating monitoring restriction filter for role "%s" to the Icinga DB Web restrictions',
$name
);
$transformedFilter = UrlMigrator::transformFilter(
QueryString::parse($role[$monitoringRestriction])
);
if ($transformedFilter) {
$role[$icingadbRestrictions['objects']] = QueryString::render($transformedFilter);
$changed = true;
}
}
if (isset($role[$monitoringPropertyBlackList])) {
Logger::info(
'Migrating monitoring blacklisted properties for role "%s" to the Icinga DB Web deny list',
$name
);
$icingadbProperties = [];
foreach (explode(',', $role[$monitoringPropertyBlackList]) as $property) {
$icingadbProperties[] = preg_replace('/^(?:host|service)\.vars\./i', '', $property, 1);
}
$role[$icingadbPropertyDenyList] = str_replace(
'**',
'*',
implode(',', array_unique($icingadbProperties))
);
$changed = true;
}
if (isset($role['permissions'])) {
$updatedPermissions = [];
Logger::info(
'Migrating monitoring permissions for role "%s" to the Icinga DB Web permissions',
$name
);
if (strpos($role['permissions'], 'monitoring')) {
$monitoringProtection = Config::module('monitoring')
->get('security', 'protected_customvars');
if ($monitoringProtection !== null) {
$role['icingadb/protect/variables'] = $monitoringProtection;
$changed = true;
}
}
foreach (explode(',', $role['permissions']) as $permission) {
if (Str::startsWith($permission, 'icingadb/') || $permission === 'module/icingadb') {
continue;
} elseif (Str::startsWith($permission, 'monitoring/command/')) {
$changed = true;
$updatedPermissions[] = $permission;
$updatedPermissions[] = str_replace('monitoring/', 'icingadb/', $permission);
} elseif ($permission === 'no-monitoring/contacts') {
$changed = true;
$updatedPermissions[] = $permission;
$role['icingadb/denylist/routes'] = 'contacts,contactgroups';
} else {
$updatedPermissions[] = $permission;
}
}
$role['permissions'] = implode(',', $updatedPermissions);
}
if (isset($role['refusals']) && is_string($role['refusals'])) {
$updatedRefusals = [];
Logger::info(
'Migrating monitoring refusals for role "%s" to the Icinga DB Web refusals',
$name
);
foreach (explode(',', $role['refusals']) as $refusal) {
if (Str::startsWith($refusal, 'icingadb/') || $refusal === 'module/icingadb') {
continue;
} elseif (Str::startsWith($refusal, 'monitoring/command/')) {
$changed = true;
$updatedRefusals[] = $refusal;
$updatedRefusals[] = str_replace('monitoring/', 'icingadb/', $refusal);
} else {
$updatedRefusals[] = $refusal;
}
}
$role['refusals'] = implode(',', $updatedRefusals);
}
}
if ($roleMatch) {
foreach ($icingadbRestrictions as $object => $icingadbRestriction) {
if (isset($role[$icingadbRestriction]) && is_string($role[$icingadbRestriction])) {
$filter = QueryString::parse($role[$icingadbRestriction]);
$filter = UrlMigrator::transformLegacyWildcardFilter($filter);
if ($filter) {
$filter = QueryString::render($filter);
if ($filter !== $role[$icingadbRestriction]) {
Logger::info(
'Icinga Db Web restriction of role "%s" for %s changed from "%s" to "%s"',
$name,
$object,
$role[$icingadbRestriction],
$filter
);
$role[$icingadbRestriction] = $filter;
$changed = true;
}
}
}
}
}
$rolesConfig->setSection($name, $role);
}
if ($changed) {
if (! $noBackup) {
$this->createBackupIni(Config::$configDir . '/roles');
}
try {
$rolesConfig->saveIni();
} catch (NotWritableError $error) {
Logger::error($error);
if ($this->skipMigration) {
Logger::error('Failed to transform icingadb restrictions');
} else {
Logger::error('Failed to migrate monitoring restrictions');
}
exit(256);
}
if ($this->skipMigration) {
Logger::info('Successfully transformed all icingadb restrictions');
} else {
Logger::info('Successfully migrated monitoring restrictions and permissions in roles');
}
} else {
Logger::info('Nothing to do');
}
}
/**
* Migrate monitoring dashboards to Icinga DB Web
*
* USAGE
*
* icingacli icingadb migrate dashboard [options]
*
* REQUIRED OPTIONS:
*
* --user=<name> Migrate dashboards whose owner matches the given
* name or owners matching the given pattern. Wildcard
* matching by `*` possible.
*
* OPTIONS:
*
* --no-backup Don't back up dashboards
*/
public function dashboardAction(): void
{
/** @var string $user */
$user = $this->params->getRequired('user');
$noBackup = $this->params->get('no-backup');
$dashboardsPath = Config::resolvePath('dashboards');
if (! file_exists($dashboardsPath)) {
Logger::info('There are no dashboards to migrate');
return;
}
$rc = 0;
$directories = new DirectoryIterator($dashboardsPath);
$anythingChanged = false;
/** @var string $directory */
foreach ($directories as $directory) {
/** @var string $userName */
$userName = $directories->key() === false ? '' : $directories->key();
if (fnmatch($user, $userName) === false) {
continue;
}
$dashboardsConfig = $this->readFromIni($directory . '/dashboard.ini', $rc);
$backupConfig = $this->readFromIni($directory . '/dashboard.ini', $rc);
Logger::info(
'Migrating monitoring dashboards to Icinga DB Web dashboards for user "%s"',
$userName
);
$changed = false;
/** @var ConfigObject<string> $dashboardConfig */
foreach ($dashboardsConfig->getConfigObject() as $name => $dashboardConfig) {
$dashboardUrlString = $dashboardConfig->get('url');
if ($dashboardUrlString !== null) {
$dashBoardUrl = Url::fromPath($dashboardUrlString, [], new Request());
if (! $this->skipMigration && Str::startsWith(ltrim($dashboardUrlString, '/'), 'monitoring/')) {
$dashboardConfig->url = UrlMigrator::transformUrl($dashBoardUrl)->getRelativeUrl();
$changed = true;
}
if (Str::startsWith(ltrim($dashboardUrlString, '/'), 'icingadb/')) {
$finalUrl = $dashBoardUrl->onlyWith(['sort', 'limit', 'view', 'columns', 'page']);
$params = $dashBoardUrl->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
$filter = QueryString::parse($params->toString());
$filter = UrlMigrator::transformLegacyWildcardFilter($filter);
if ($filter) {
$oldFilterString = $params->toString();
$newFilterString = QueryString::render($filter);
if ($oldFilterString !== $newFilterString) {
Logger::info(
'Icinga Db Web filter of dashboard "%s" has changed from "%s" to "%s"',
$name,
$params->toString(),
QueryString::render($filter)
);
$finalUrl->setFilter($filter);
$dashboardConfig->url = $finalUrl->getRelativeUrl();
$changed = true;
}
}
}
}
}
if ($changed && $noBackup === null) {
$this->createBackupIni("$directory/dashboard", $backupConfig);
}
if ($changed) {
$anythingChanged = true;
}
try {
$dashboardsConfig->saveIni();
} catch (NotWritableError $error) {
Logger::error($error);
$rc = 256;
}
}
if ($rc > 0) {
if ($this->skipMigration) {
Logger::error('Failed to transform some icingadb dashboards');
} else {
Logger::error('Failed to migrate some monitoring dashboards');
}
exit($rc);
}
if (! $anythingChanged) {
Logger::info('Nothing to do');
} elseif ($this->skipMigration) {
Logger::info('Successfully transformed all icingadb dashboards');
} else {
Logger::info('Successfully migrated dashboards for all the matched users');
}
}
/**
* Migrate Icinga DB Web wildcard filters of navigation items, dashboards and roles
*
* USAGE
*
* icingacli icingadb migrate filter
*
* OPTIONS:
*
* --no-backup Don't back up menu items, dashboards and roles
*/
public function filterAction(): void
{
$this->skipMigration = true;
$this->params->set('user', '*');
$this->navigationAction();
$this->dashboardAction();
$this->params->set('role', '*');
$this->roleAction();
}
private function transformNavigationItems(Config $config, string $owner, int &$rc): bool
{
$updated = false;
/** @var ConfigObject<string> $newConfigObject */
foreach ($config->getConfigObject() as $section => $newConfigObject) {
$configOwner = $newConfigObject->get('owner') ?? '';
if ($configOwner && $configOwner !== $owner) {
continue;
}
if (
$newConfigObject->get('type') === 'icingadb-host-action'
|| $newConfigObject->get('type') === 'icingadb-service-action'
) {
/** @var ?string $legacyFilter */
$legacyFilter = $newConfigObject->get('filter');
if ($legacyFilter !== null) {
$filter = QueryString::parse($legacyFilter);
$filter = UrlMigrator::transformLegacyWildcardFilter($filter);
if ($filter) {
$filter = QueryString::render($filter);
if ($legacyFilter !== $filter) {
$newConfigObject->filter = $filter;
$updated = true;
Logger::info(
'Icinga DB Web filter of action "%s" is changed from %s to "%s"',
$section,
$legacyFilter,
$filter
);
}
}
}
}
/** @var string $url */
$url = $newConfigObject->get('url');
if ($url && Str::startsWith(ltrim($url, '/'), 'icingadb/')) {
$url = Url::fromPath($url, [], new Request());
$finalUrl = $url->onlyWith(['sort', 'limit', 'view', 'columns', 'page']);
$params = $url->without(['sort', 'limit', 'view', 'columns', 'page'])->getParams();
$filter = QueryString::parse($params->toString());
$filter = UrlMigrator::transformLegacyWildcardFilter($filter);
if ($filter) {
$oldFilterString = $params->toString();
$newFilterString = QueryString::render($filter);
if ($oldFilterString !== $newFilterString) {
Logger::info(
'Icinga Db Web filter of navigation item "%s" has changed from "%s" to "%s"',
$section,
$oldFilterString,
$newFilterString
);
$newConfigObject->url = $finalUrl->setFilter($filter)->getRelativeUrl();
$updated = true;
}
}
}
}
if ($updated) {
try {
$config->saveIni();
} catch (NotWritableError $error) {
Logger::error($error);
$rc = 256;
return false;
}
}
return $updated;
}
/**
* Migrate the given config to the given new config path
*
* @param Config $config
* @param string $owner
* @param string $path
* @param int $rc
*
* @return bool
*/
private function migrateNavigationItems(Config $config, string $owner, string $path, int &$rc): bool
{
$deleteLegacyFiles = $this->params->get('no-backup');
$override = $this->params->get('override');
$newConfig = $config->getConfigFile() === $path ? $config : $this->readFromIni($path, $rc);
$updated = false;
/** @var ConfigObject<string> $configObject */
foreach ($config->getConfigObject() as $configObject) {
$configOwner = $configObject->get('owner') ?? '';
if ($configOwner && $configOwner !== $owner) {
continue;
}
$migrateFilter = false;
if ($configObject->type === 'host-action') {
$updated = true;
$migrateFilter = true;
$configObject->type = 'icingadb-host-action';
} elseif ($configObject->type === 'service-action') {
$updated = true;
$migrateFilter = true;
$configObject->type = 'icingadb-service-action';
}
/** @var ?string $urlString */
$urlString = $configObject->get('url');
if ($urlString !== null) {
$urlString = str_replace(
['$SERVICEDESC$', '$HOSTNAME$', '$HOSTADDRESS$', '$HOSTADDRESS6$'],
['$service.name$', '$host.name$', '$host.address$', '$host.address6$'],
$urlString
);
if ($urlString !== $configObject->url) {
$configObject->url = $urlString;
$updated = true;
}
$url = Url::fromPath($urlString, [], new Request());
try {
$urlString = UrlMigrator::transformUrl($url)->getRelativeUrl();
$configObject->url = $urlString;
$updated = true;
} catch (\InvalidArgumentException $err) {
// Do nothing
}
}
/** @var ?string $legacyFilter */
$legacyFilter = $configObject->get('filter');
if ($migrateFilter && $legacyFilter) {
$updated = true;
$filter = QueryString::parse($legacyFilter);
$filter = UrlMigrator::transformFilter($filter);
if ($filter !== false) {
$configObject->filter = QueryString::render($filter);
} else {
unset($configObject->filter);
}
}
$section = $config->key();
if (! $newConfig->hasSection($section) || $newConfig === $config || $override) {
$newConfig->setSection($section, $configObject);
}
}
if ($updated) {
try {
$newConfig->saveIni();
// Remove the legacy file only if explicitly requested
if ($deleteLegacyFiles && $newConfig !== $config) {
unlink($config->getConfigFile());
}
} catch (NotWritableError $error) {
Logger::error($error);
$rc = 256;
return false;
}
}
return $updated;
}
/**
* Get the navigation items config from the given ini path
*
* @param string $path Absolute path of the ini file
* @param int $rc The return code used to exit the action
*
* @return Config
*/
private function readFromIni($path, &$rc)
{
try {
$config = Config::fromIni($path);
} catch (NotReadableError $error) {
Logger::error($error);
$config = new Config();
$rc = 128;
}
return $config;
}
private function createBackupIni(string $path, Config $config = null): void
{
$counter = 0;
while (true) {
$filepath = $counter > 0
? "$path.backup$counter.ini"
: "$path.backup.ini";
if (! file_exists($filepath)) {
if ($config) {
$config->saveIni($filepath);
} else {
copy("$path.ini", $filepath);
}
break;
} else {
$counter++;
}
}
}
/**
* Checks if the given role should be updated
*
* @param string[] $role
* @param bool $override
*
* @return bool
*/
private function shouldUpdateRole(array $role, ?bool $override): bool
{
return ! (
isset($role['icingadb/filter/objects'])
|| isset($role['icingadb/filter/hosts'])
|| isset($role['icingadb/filter/services'])
|| isset($role['icingadb/denylist/routes'])
|| isset($role['icingadb/denylist/variables'])
|| isset($role['icingadb/protect/variables'])
|| (isset($role['permissions']) && str_contains($role['permissions'], 'icingadb'))
)
|| $override;
}
}