diff --git a/app/Console/Commands/GetConfigCommand.php b/app/Console/Commands/GetConfigCommand.php index 7190eaba00..31999028d4 100644 --- a/app/Console/Commands/GetConfigCommand.php +++ b/app/Console/Commands/GetConfigCommand.php @@ -24,7 +24,7 @@ class GetConfigCommand extends LnmsCommand parent::__construct(); $this->addArgument('setting', InputArgument::OPTIONAL); - $this->addOption('json'); + $this->addOption('dump'); } /** @@ -42,7 +42,7 @@ class GetConfigCommand extends LnmsCommand Config::forget("os.{$matches['os']}.definition_loaded"); } - if ($this->option('json')) { + if ($this->option('dump')) { $this->line($setting ? json_encode(Config::get($setting)) : Config::toJson()); return 0; @@ -55,7 +55,7 @@ class GetConfigCommand extends LnmsCommand if (Config::has($setting)) { $output = Config::get($setting); if (! is_string($output)) { - $output = var_export($output, true); + $output = json_encode($output, JSON_PRETTY_PRINT); } $this->line($output); diff --git a/app/Console/Commands/SetConfigCommand.php b/app/Console/Commands/SetConfigCommand.php index 2328f59581..f6098d06c5 100644 --- a/app/Console/Commands/SetConfigCommand.php +++ b/app/Console/Commands/SetConfigCommand.php @@ -6,9 +6,13 @@ use App\Console\Commands\Traits\CompletesConfigArgument; use App\Console\LnmsCommand; use Illuminate\Support\Arr; use Illuminate\Support\Str; +use JsonSchema\Constraints\Constraint; +use JsonSchema\Exception\ValidationException; +use JsonSchema\Validator; use LibreNMS\Config; use LibreNMS\DB\Eloquent; use LibreNMS\Util\DynamicConfig; +use LibreNMS\Util\OS; use Symfony\Component\Console\Input\InputArgument; class SetConfigCommand extends LnmsCommand @@ -43,7 +47,17 @@ class SetConfigCommand extends LnmsCommand $force = $this->option('ignore-checks'); $parent = null; - if (! $definition->isValidSetting($setting)) { + if (preg_match('/^os\.(?[a-z_\-]+)\.(?.*)$/', $setting, $matches)) { + $os = $matches['os']; + try { + $this->validateOsSetting($os, $matches['setting'], $value); + } catch (ValidationException $e) { + $this->error(trans('commands.config:set.errors.invalid')); + $this->line($e->getMessage()); + + return 2; + } + } elseif (! $definition->isValidSetting($setting)) { $parent = $this->findParentSetting($definition, $setting); if (! $force && ! $parent) { $this->error(trans('commands.config:set.errors.invalid')); @@ -87,7 +101,7 @@ class SetConfigCommand extends LnmsCommand } // handle setting value inside multi-dimensional array - if ($parent) { + if ($parent && $parent !== $setting) { $parent_data = Config::get($parent); Arr::set($parent_data, $this->getChildPath($setting, $parent), $value); $value = $parent_data; @@ -95,7 +109,10 @@ class SetConfigCommand extends LnmsCommand } $configItem = $definition->get($setting); - if (! $force && ! $configItem->checkValue($value)) { + if (! $force + && empty($os) // if os is set, value was already validated against os config + && ! $configItem->checkValue($value) + ) { $message = ($configItem->type || $configItem->validate) ? $configItem->getValidationMessage($value) : trans('commands.config:set.errors.no-validation', ['setting' => $setting]); @@ -118,7 +135,7 @@ class SetConfigCommand extends LnmsCommand * * @return mixed */ - private function juggleType(string $value) + private function juggleType(?string $value) { $json = json_decode($value, true); @@ -185,4 +202,54 @@ class SetConfigCommand extends LnmsCommand Arr::forget($data, $matches); } } + + /** + * @param string $os + * @param string $setting + * @param mixed $value + * @throws \JsonSchema\Exception\ValidationException + */ + private function validateOsSetting(string $os, string $setting, $value) + { + // prep data to be validated + OS::loadDefinition($os); + $os_data = \LibreNMS\Config::get("os.$os"); + if ($os_data === null) { + throw new ValidationException(trans('commands.config:set.errors.invalid_os', ['os' => $os])); + } + Arr::set($os_data, $setting, $this->juggleType($value)); + unset($os_data['definition_loaded']); + + $validator = new Validator; + $validator->validate( + $os_data, + (object) ['$ref' => 'file://' . base_path('/misc/os_schema.json')], + Constraint::CHECK_MODE_TYPE_CAST + ); + + $code = 0; + + $errors = collect($validator->getErrors())->filter(function ($error) use ($value, &$code) { + if ($error['constraint'] == 'additionalProp') { + $code = 1; + + return true; + } + + // only check type if value is set (otherwise we are unsetting it) + if (! empty($value) && $error['constraint'] == 'type') { + if ($code === 0) { + $code = 2; // wrong path takes precedence over wrong type + } + + return true; + } + + return false; + }); + + if ($errors->isNotEmpty()) { + throw new ValidationException($errors->pluck('message')->implode(PHP_EOL), $code); + } + } } diff --git a/composer.json b/composer.json index a9fc231475..21963cb181 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,7 @@ "genealabs/laravel-caffeine": "^8.0", "guzzlehttp/guzzle": "^7.0.1", "influxdb/influxdb-php": "^1.14", + "justinrainbow/json-schema": "^5.2", "laravel/framework": "^8.12", "laravel/tinker": "^2.5", "laravel/ui": "^3.0", @@ -59,7 +60,6 @@ "facade/ignition": "^2.5", "fakerphp/faker": "^1.9.1", "friendsofphp/php-cs-fixer": "^2.16", - "justinrainbow/json-schema": "^5.2", "laravel/dusk": "^6.15", "mockery/mockery": "^1.4.2", "nunomaduro/collision": "^5.0", diff --git a/composer.lock b/composer.lock index b85fcb2311..a0337e68d7 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7e036d2ebed332bf23de83ae6dd716a3", + "content-hash": "7971c0ef0ed8ea90a3df66dbd8f05295", "packages": [ { "name": "amenadiel/jpgraph", @@ -1729,6 +1729,76 @@ }, "time": "2020-09-11T11:05:47+00:00" }, + { + "name": "justinrainbow/json-schema", + "version": "5.2.11", + "source": { + "type": "git", + "url": "https://github.com/justinrainbow/json-schema.git", + "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ab6744b7296ded80f8cc4f9509abbff393399aa", + "reference": "2ab6744b7296ded80f8cc4f9509abbff393399aa", + "shasum": "" + }, + "require": { + "php": ">=5.3.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", + "json-schema/json-schema-test-suite": "1.2.0", + "phpunit/phpunit": "^4.8.35" + }, + "bin": [ + "bin/validate-json" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "5.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "JsonSchema\\": "src/JsonSchema/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Bruno Prieto Reis", + "email": "bruno.p.reis@gmail.com" + }, + { + "name": "Justin Rainbow", + "email": "justin.rainbow@gmail.com" + }, + { + "name": "Igor Wiedler", + "email": "igor@wiedler.ch" + }, + { + "name": "Robert Schönthal", + "email": "seroscho@googlemail.com" + } + ], + "description": "A library to validate a json schema.", + "homepage": "https://github.com/justinrainbow/json-schema", + "keywords": [ + "json", + "schema" + ], + "support": { + "issues": "https://github.com/justinrainbow/json-schema/issues", + "source": "https://github.com/justinrainbow/json-schema/tree/5.2.11" + }, + "time": "2021-07-22T09:24:00+00:00" + }, { "name": "laravel/framework", "version": "v8.49.2", @@ -8260,76 +8330,6 @@ }, "time": "2020-07-09T08:09:16+00:00" }, - { - "name": "justinrainbow/json-schema", - "version": "5.2.10", - "source": { - "type": "git", - "url": "https://github.com/justinrainbow/json-schema.git", - "reference": "2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/justinrainbow/json-schema/zipball/2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b", - "reference": "2ba9c8c862ecd5510ed16c6340aa9f6eadb4f31b", - "shasum": "" - }, - "require": { - "php": ">=5.3.3" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "~2.2.20||~2.15.1", - "json-schema/json-schema-test-suite": "1.2.0", - "phpunit/phpunit": "^4.8.35" - }, - "bin": [ - "bin/validate-json" - ], - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.0.x-dev" - } - }, - "autoload": { - "psr-4": { - "JsonSchema\\": "src/JsonSchema/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bruno Prieto Reis", - "email": "bruno.p.reis@gmail.com" - }, - { - "name": "Justin Rainbow", - "email": "justin.rainbow@gmail.com" - }, - { - "name": "Igor Wiedler", - "email": "igor@wiedler.ch" - }, - { - "name": "Robert Schönthal", - "email": "seroscho@googlemail.com" - } - ], - "description": "A library to validate a json schema.", - "homepage": "https://github.com/justinrainbow/json-schema", - "keywords": [ - "json", - "schema" - ], - "support": { - "issues": "https://github.com/justinrainbow/json-schema/issues", - "source": "https://github.com/justinrainbow/json-schema/tree/5.2.10" - }, - "time": "2020-05-27T16:41:55+00:00" - }, { "name": "laravel/dusk", "version": "v6.15.1", @@ -11460,5 +11460,5 @@ "ext-xml": "*" }, "platform-dev": [], - "plugin-api-version": "2.0.0" + "plugin-api-version": "2.1.0" } diff --git a/doc/Support/Configuration.md b/doc/Support/Configuration.md index 1f60550e08..90f0383e59 100644 --- a/doc/Support/Configuration.md +++ b/doc/Support/Configuration.md @@ -70,7 +70,7 @@ lnms config:set snmp.community > yes -lnms config:get snmp.community --json +lnms config:get snmp.community ["public"] ``` diff --git a/resources/lang/en/commands.php b/resources/lang/en/commands.php index 8b92e8f74e..de80a606a6 100644 --- a/resources/lang/en/commands.php +++ b/resources/lang/en/commands.php @@ -7,7 +7,7 @@ return [ 'setting' => 'setting to get value of in dot notation (example: snmp.community.0)', ], 'options' => [ - 'json' => 'Output setting or entire config as json', + 'dump' => 'Output the entire config as json', ], ], 'config:set' => [ @@ -24,7 +24,8 @@ return [ 'errors' => [ 'append' => 'Cannot append to non-array setting', 'failed' => 'Failed to set :setting', - 'invalid' => 'This is not a valid setting. Please check your spelling', + 'invalid' => 'This is not a valid setting. Please check your input', + 'invalid_os' => 'Specified OS (:os) does not exist', 'nodb' => 'Database is not connected', 'no-validation' => 'Cannot set :setting, it is missing validation definition.', ], diff --git a/tests/Feature/Commands/TestConfigCommands.php b/tests/Feature/Commands/TestConfigCommands.php new file mode 100644 index 0000000000..3feb7d2536 --- /dev/null +++ b/tests/Feature/Commands/TestConfigCommands.php @@ -0,0 +1,135 @@ +. + * + * @package LibreNMS + * @link http://librenms.org + * @copyright 2021 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Tests\Feature\Commands; + +use LibreNMS\Config; +use LibreNMS\Tests\InMemoryDbTestCase; + +class TestConfigCommands extends InMemoryDbTestCase +{ + public function testSetting(): void + { + // simple + Config::set('login_message', null); + $this->assertCliSets('login_message', 'hello'); + + // nested + Config::forget('allow_entity_sensor.amperes'); + $this->assertCliSets('allow_entity_sensor.amperes', 'false'); + + // set inside + $this->assertCliGets('auth_ldap_groups.somegroup', null); + $this->artisan('config:set', ['setting' => 'auth_ldap_groups.somegroup', 'value' => '{"level": 3}'])->assertExitCode(0); + $this->assertCliGets('auth_ldap_groups.somegroup', ['level' => 3]); + $this->artisan('config:set', ['setting' => 'auth_ldap_groups.somegroup']) + ->expectsConfirmation(trans('commands.config:set.forget_from', ['path' => 'somegroup', 'parent' => 'auth_ldap_groups']), 'yes') + ->assertExitCode(0); + + // test append + $community = Config::get('snmp.community'); + $this->assertCliGets('snmp.community', $community); + $community[] = 'extra_community'; + $this->artisan('config:set', ['setting' => 'snmp.community.+', 'value' => 'extra_community'])->assertExitCode(0); + $this->assertCliGets('snmp.community', $community); + + // os bool + $this->assertCliSets('os.ios.rfc1628_compat', true); + + // os array and append + $this->assertCliSets('os.netonix.bad_iftype', ['ethernet', 'psuedowire']); +// $this->artisan('config:set', ['setting' => 'os.netonix.bad_iftype.+', 'value' => 'other'])->assertExitCode(0); +// $this->assertCliGets('os.netonix.bad_iftype', ['ethernet', 'psuedowire', 'other']); + + // dump + $this->artisan('config:get', ['--dump' => true]) + ->expectsOutput(Config::toJson()) + ->assertExitCode(0); + } + + public function testInvalidSetting(): void + { + // non-existent setting + $this->artisan('config:set', ['setting' => 'this_will_never_be.a.setting']) + ->assertExitCode(2); + + // invalid type + $this->artisan('config:set', ['setting' => 'alert_rule.interval', 'value' => 'string', '--no-ansi' => true]) + ->expectsOutput(trans('settings.validate.integer', ['value' => '"string"'])) + ->assertExitCode(2); + + // non-existent os + $this->artisan('config:set', ['setting' => 'os.someos.this_will_never_be.a.setting']) + ->expectsOutput(trans('commands.config:set.errors.invalid_os', ['os' => 'someos'])) + ->assertExitCode(2); + + // non-existent os setting + $this->artisan('config:set', ['setting' => 'os.ios.this_will_never_be.a.setting']) + ->doesntExpectOutput(trans('commands.config:set.errors.invalid_os', ['os' => 'ios'])) + ->assertExitCode(2); + + // append to non-array + Config::set('login_message', 'blah'); + $message = Config::get('login_message'); + $this->artisan('config:set', ['setting' => 'login_message.+', 'value' => 'something', '--no-ansi' => true]) + ->expectsOutput(trans('commands.config:set.errors.append')) + ->assertExitCode(2); + } + + /** + * @param string $setting + * @param mixed $expected + */ + private function assertCliSets(string $setting, $expected): void + { + $this->assertCliGets($setting, null); + $this->artisan('config:set', ['setting' => $setting, 'value' => json_encode($expected)])->assertExitCode(0); + $this->assertCliGets($setting, $expected); + $this->artisan('config:set', ['setting' => $setting]) + ->expectsQuestion(trans('commands.config:set.confirm', ['setting' => $setting]), true) + ->assertExitCode(0); + $this->assertCliGets($setting, null); + } + + /** + * @param string $setting + * @param mixed $expected + */ + private function assertCliGets(string $setting, $expected): void + { + $this->assertSame($expected, \LibreNMS\Config::get($setting)); + + $command = $this->artisan('config:get', ['setting' => $setting]); + if ($expected === null) { + $command->assertExitCode(1); + + return; + } + + $command->assertExitCode(0) + ->expectsOutput(is_string($expected) ? $expected : json_encode($expected, JSON_PRETTY_PRINT)) + ->assertExitCode(0); + } +} diff --git a/tests/InMemoryDbTestCase.php b/tests/InMemoryDbTestCase.php new file mode 100644 index 0000000000..25f869edb4 --- /dev/null +++ b/tests/InMemoryDbTestCase.php @@ -0,0 +1,42 @@ +. + * + * @package LibreNMS + * @link http://librenms.org + * @copyright 2021 Tony Murray + * @author Tony Murray + */ + +namespace LibreNMS\Tests; + +class InMemoryDbTestCase extends TestCase +{ + /** @var string */ + protected $connection = 'testing_memory'; + + protected function setUp(): void + { + parent::setUp(); + $this->artisan('migrate:fresh', ['--database' => $this->connection]); + + $current = config('database.default'); + config(['database.default' => $this->connection]); + \DB::purge($current); + } +}