mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
Adds keystore for securely storing settings (#14714)
Introduces bin/kibana-keystore providing create, list, add, and remove actions. Settings stored within the keystore will be loaded at runtime. Signed-off-by: Tyler Smalley <tyler.smalley@elastic.co>
This commit is contained in:
parent
7b9eb6da7b
commit
3dff020add
28 changed files with 1081 additions and 6 deletions
24
bin/kibana-keystore
Executable file
24
bin/kibana-keystore
Executable file
|
@ -0,0 +1,24 @@
|
|||
#!/bin/sh
|
||||
SCRIPT=$0
|
||||
|
||||
# SCRIPT may be an arbitrarily deep series of symlinks. Loop until we have the concrete path.
|
||||
while [ -h "$SCRIPT" ] ; do
|
||||
ls=$(ls -ld "$SCRIPT")
|
||||
# Drop everything prior to ->
|
||||
link=$(expr "$ls" : '.*-> \(.*\)$')
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
SCRIPT="$link"
|
||||
else
|
||||
SCRIPT=$(dirname "$SCRIPT")/"$link"
|
||||
fi
|
||||
done
|
||||
|
||||
DIR="$(dirname "${SCRIPT}")/.."
|
||||
NODE="${DIR}/node/bin/node"
|
||||
test -x "$NODE" || NODE=$(which node)
|
||||
if [ ! -x "$NODE" ]; then
|
||||
echo "unable to find usable node.js executable."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
"${NODE}" "${DIR}/src/cli_keystore" "$@"
|
29
bin/kibana-keystore.bat
Normal file
29
bin/kibana-keystore.bat
Normal file
|
@ -0,0 +1,29 @@
|
|||
@echo off
|
||||
|
||||
SETLOCAL
|
||||
|
||||
set SCRIPT_DIR=%~dp0
|
||||
for %%I in ("%SCRIPT_DIR%..") do set DIR=%%~dpfI
|
||||
|
||||
set NODE=%DIR%\node\node.exe
|
||||
|
||||
WHERE /Q node
|
||||
IF %ERRORLEVEL% EQU 0 (
|
||||
for /f "delims=" %%i in ('WHERE node') do set SYS_NODE=%%i
|
||||
)
|
||||
|
||||
If Not Exist "%NODE%" (
|
||||
IF Exist "%SYS_NODE%" (
|
||||
set "NODE=%SYS_NODE%"
|
||||
) else (
|
||||
Echo unable to find usable node.js executable.
|
||||
Exit /B 1
|
||||
)
|
||||
)
|
||||
|
||||
TITLE Kibana Keystore
|
||||
"%NODE%" "%DIR%\src\cli_keystore" %*
|
||||
|
||||
:finally
|
||||
|
||||
ENDLOCAL
|
|
@ -50,6 +50,8 @@ include::setup/install.asciidoc[]
|
|||
|
||||
include::setup/settings.asciidoc[]
|
||||
|
||||
include::setup/secure-settings.asciidoc[]
|
||||
|
||||
include::setup/docker.asciidoc[]
|
||||
|
||||
include::setup/access.asciidoc[]
|
||||
|
|
64
docs/setup/secure-settings.asciidoc
Normal file
64
docs/setup/secure-settings.asciidoc
Normal file
|
@ -0,0 +1,64 @@
|
|||
[[secure-settings]]
|
||||
=== Secure Settings
|
||||
|
||||
Some settings are sensitive, and relying on filesystem permissions to protect
|
||||
their values is not sufficient. For this use case, Kibana provides a
|
||||
keystore, and the `kibana-keystore` tool to manage the settings in the keystore.
|
||||
|
||||
NOTE: All commands here should be run as the user which will run Kibana.
|
||||
|
||||
[float]
|
||||
[[creating-keystore]]
|
||||
=== Creating the keystore
|
||||
|
||||
To create the `kibana.keystore`, use the `create` command:
|
||||
|
||||
[source,sh]
|
||||
----------------------------------------------------------------
|
||||
bin/kibana-keystore create
|
||||
----------------------------------------------------------------
|
||||
|
||||
The file `kibana.keystore` will be created in the directory defined by the
|
||||
`path.data` configuration setting.
|
||||
|
||||
[float]
|
||||
[[list-settings]]
|
||||
=== Listing settings in the keystore
|
||||
|
||||
A list of the settings in the keystore is available with the `list` command:
|
||||
|
||||
[source,sh]
|
||||
----------------------------------------------------------------
|
||||
bin/kibana-keystore list
|
||||
----------------------------------------------------------------
|
||||
|
||||
[float]
|
||||
[[add-string-to-keystore]]
|
||||
=== Adding string settings
|
||||
|
||||
Sensitive string settings, like authentication credentials for Elasticsearch
|
||||
can be added using the `add` command:
|
||||
|
||||
[source,sh]
|
||||
----------------------------------------------------------------
|
||||
bin/kibana-keystore add the.setting.name.to.set
|
||||
----------------------------------------------------------------
|
||||
|
||||
The tool will prompt for the value of the setting. To pass the value
|
||||
through stdin, use the `--stdin` flag:
|
||||
|
||||
[source,sh]
|
||||
----------------------------------------------------------------
|
||||
cat /file/containing/setting/value | bin/kibana-keystore add --stdin the.setting.name.to.set
|
||||
----------------------------------------------------------------
|
||||
|
||||
[float]
|
||||
[[remove-settings]]
|
||||
=== Removing settings
|
||||
|
||||
To remove a setting from the keystore, use the `remove` command:
|
||||
|
||||
[source,sh]
|
||||
----------------------------------------------------------------
|
||||
bin/kibana-keystore remove the.setting.name.to.remove
|
||||
----------------------------------------------------------------
|
|
@ -1,6 +1,6 @@
|
|||
import expect from 'expect.js';
|
||||
import { join, relative, resolve } from 'path';
|
||||
import readYamlConfig from '../read_yaml_config';
|
||||
import { readYamlConfig } from '../read_yaml_config';
|
||||
|
||||
function fixture(name) {
|
||||
return resolve(__dirname, 'fixtures', name);
|
||||
|
|
|
@ -3,7 +3,7 @@ import { writeFileSync } from 'fs';
|
|||
import { relative, resolve } from 'path';
|
||||
import { safeDump } from 'js-yaml';
|
||||
import es from 'event-stream';
|
||||
import readYamlConfig from '../read_yaml_config';
|
||||
import { readYamlConfig } from '../read_yaml_config';
|
||||
import expect from 'expect.js';
|
||||
|
||||
const testConfigFile = follow(`fixtures/reload_logging_config/kibana.test.yml`);
|
||||
|
|
26
src/cli/serve/read_keystore.js
Normal file
26
src/cli/serve/read_keystore.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import { join } from 'path';
|
||||
import { set } from 'lodash';
|
||||
|
||||
import { Keystore } from '../../server/keystore';
|
||||
import { getData } from '../../server/path';
|
||||
|
||||
export function loadKeystore() {
|
||||
const path = join(getData(), 'kibana.keystore');
|
||||
|
||||
const keystore = new Keystore(path);
|
||||
keystore.load();
|
||||
|
||||
return keystore;
|
||||
}
|
||||
|
||||
export function readKeystore() {
|
||||
const keystore = loadKeystore();
|
||||
const keys = Object.keys(keystore.data);
|
||||
|
||||
const data = {};
|
||||
keys.forEach(key => {
|
||||
set(data, key, keystore.data[key]);
|
||||
});
|
||||
|
||||
return data;
|
||||
}
|
|
@ -24,7 +24,7 @@ export function merge(sources) {
|
|||
}, {});
|
||||
}
|
||||
|
||||
export default function (paths) {
|
||||
export function readYamlConfig(paths) {
|
||||
const files = [].concat(paths || []);
|
||||
const yamls = files.map(path => safeLoad(read(path, 'utf8')));
|
||||
return merge(yamls);
|
||||
|
|
|
@ -2,9 +2,11 @@ import _ from 'lodash';
|
|||
import { statSync } from 'fs';
|
||||
import { isWorker } from 'cluster';
|
||||
import { resolve } from 'path';
|
||||
|
||||
import { fromRoot } from '../../utils';
|
||||
import { getConfig } from '../../server/path';
|
||||
import readYamlConfig from './read_yaml_config';
|
||||
import { readYamlConfig } from './read_yaml_config';
|
||||
import { readKeystore } from './read_keystore';
|
||||
|
||||
import { DEV_SSL_CERT_PATH, DEV_SSL_KEY_PATH } from '../dev_ssl';
|
||||
|
||||
|
@ -67,6 +69,7 @@ function readServerSettings(opts, extraCliOptions) {
|
|||
opts.pluginPath
|
||||
)));
|
||||
|
||||
merge(readKeystore());
|
||||
merge(extraCliOptions);
|
||||
|
||||
return settings;
|
||||
|
|
134
src/cli_keystore/__tests__/add.js
Normal file
134
src/cli_keystore/__tests__/add.js
Normal file
|
@ -0,0 +1,134 @@
|
|||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import mockFs from 'mock-fs';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { Keystore } from '../../server/keystore';
|
||||
import { add } from '../add';
|
||||
import Logger from '../../cli_plugin/lib/logger';
|
||||
import * as prompt from '../../server/utils/prompt';
|
||||
|
||||
describe('Kibana keystore', () => {
|
||||
describe('add', () => {
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
|
||||
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
|
||||
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/data': {
|
||||
'test.keystore': JSON.stringify(keystoreData),
|
||||
}
|
||||
});
|
||||
|
||||
sandbox.stub(prompt, 'confirm');
|
||||
sandbox.stub(prompt, 'question');
|
||||
|
||||
sandbox.stub(Logger.prototype, 'log');
|
||||
sandbox.stub(Logger.prototype, 'error');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('returns an error for a nonexistent keystore', async () => {
|
||||
const keystore = new Keystore('/data/nonexistent.keystore');
|
||||
const message = 'ERROR: Kibana keystore not found. Use \'create\' command to create one.';
|
||||
|
||||
await add(keystore, 'foo');
|
||||
|
||||
sinon.assert.calledOnce(Logger.prototype.error);
|
||||
sinon.assert.calledWith(Logger.prototype.error, message);
|
||||
});
|
||||
|
||||
it('does not attempt to create a keystore', async () => {
|
||||
const keystore = new Keystore('/data/nonexistent.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
await add(keystore, 'foo');
|
||||
|
||||
sinon.assert.notCalled(keystore.save);
|
||||
});
|
||||
|
||||
it('prompts for existing key', async () => {
|
||||
prompt.confirm.returns(Promise.resolve(true));
|
||||
prompt.question.returns(Promise.resolve('bar'));
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
await add(keystore, 'a2');
|
||||
|
||||
sinon.assert.calledOnce(prompt.confirm);
|
||||
sinon.assert.calledOnce(prompt.question);
|
||||
|
||||
const { args } = prompt.confirm.getCall(0);
|
||||
|
||||
expect(args[0]).to.eql('Setting a2 already exists. Overwrite?');
|
||||
});
|
||||
|
||||
it('aborts if overwrite is denied', async () => {
|
||||
prompt.confirm.returns(Promise.resolve(false));
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
await add(keystore, 'a2');
|
||||
|
||||
sinon.assert.notCalled(prompt.question);
|
||||
|
||||
sinon.assert.calledOnce(Logger.prototype.log);
|
||||
sinon.assert.calledWith(Logger.prototype.log, 'Exiting without modifying keystore.');
|
||||
});
|
||||
|
||||
it('overwrites without prompt if force is supplied', async () => {
|
||||
prompt.question.returns(Promise.resolve('bar'));
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
await add(keystore, 'a2', { force: true });
|
||||
|
||||
sinon.assert.notCalled(prompt.confirm);
|
||||
sinon.assert.calledOnce(keystore.save);
|
||||
});
|
||||
|
||||
it('trims value', async () => {
|
||||
prompt.question.returns(Promise.resolve('bar\n'));
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
await add(keystore, 'foo');
|
||||
|
||||
expect(keystore.data.foo).to.eql('bar');
|
||||
});
|
||||
|
||||
it('persists updated keystore', async () => {
|
||||
prompt.question.returns(Promise.resolve('bar\n'));
|
||||
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
await add(keystore, 'foo');
|
||||
|
||||
sinon.assert.calledOnce(keystore.save);
|
||||
});
|
||||
|
||||
it('accepts stdin', async () => {
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
const stdin = new PassThrough();
|
||||
process.nextTick(() => {
|
||||
stdin.write('kibana\n');
|
||||
stdin.end();
|
||||
});
|
||||
|
||||
await add(keystore, 'foo', { stdin });
|
||||
|
||||
expect(keystore.data.foo).to.eql('kibana');
|
||||
});
|
||||
});
|
||||
});
|
79
src/cli_keystore/__tests__/create.js
Normal file
79
src/cli_keystore/__tests__/create.js
Normal file
|
@ -0,0 +1,79 @@
|
|||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import mockFs from 'mock-fs';
|
||||
|
||||
import { Keystore } from '../../server/keystore';
|
||||
import { create } from '../create';
|
||||
import Logger from '../../cli_plugin/lib/logger';
|
||||
import * as prompt from '../../server/utils/prompt';
|
||||
|
||||
describe('Kibana keystore', () => {
|
||||
describe('create', () => {
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
|
||||
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
|
||||
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/data': {
|
||||
'test.keystore': JSON.stringify(keystoreData),
|
||||
}
|
||||
});
|
||||
|
||||
sandbox.stub(Logger.prototype, 'log');
|
||||
sandbox.stub(Logger.prototype, 'error');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('creates keystore file', async () => {
|
||||
const keystore = new Keystore('/data/foo.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
await create(keystore);
|
||||
|
||||
sinon.assert.calledOnce(keystore.save);
|
||||
});
|
||||
|
||||
it('logs successful keystore creating', async () => {
|
||||
const path = '/data/foo.keystore';
|
||||
const keystore = new Keystore(path);
|
||||
|
||||
await create(keystore);
|
||||
|
||||
sinon.assert.calledOnce(Logger.prototype.log);
|
||||
sinon.assert.calledWith(Logger.prototype.log, `Created Kibana keystore in ${path}`);
|
||||
});
|
||||
|
||||
it('prompts for overwrite', async () => {
|
||||
sandbox.stub(prompt, 'confirm').returns(Promise.resolve(true));
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
await create(keystore);
|
||||
|
||||
sinon.assert.calledOnce(prompt.confirm);
|
||||
const { args } = prompt.confirm.getCall(0);
|
||||
|
||||
expect(args[0]).to.eql('A Kibana keystore already exists. Overwrite?');
|
||||
});
|
||||
|
||||
it('aborts if overwrite is denied', async () => {
|
||||
sandbox.stub(prompt, 'confirm').returns(Promise.resolve(false));
|
||||
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
await create(keystore);
|
||||
|
||||
sinon.assert.calledOnce(Logger.prototype.log);
|
||||
sinon.assert.calledWith(Logger.prototype.log, 'Exiting without modifying keystore.');
|
||||
|
||||
sinon.assert.notCalled(keystore.save);
|
||||
});
|
||||
});
|
||||
});
|
48
src/cli_keystore/__tests__/list.js
Normal file
48
src/cli_keystore/__tests__/list.js
Normal file
|
@ -0,0 +1,48 @@
|
|||
import sinon from 'sinon';
|
||||
import mockFs from 'mock-fs';
|
||||
|
||||
import { Keystore } from '../../server/keystore';
|
||||
import { list } from '../list';
|
||||
import Logger from '../../cli_plugin/lib/logger';
|
||||
|
||||
describe('Kibana keystore', () => {
|
||||
describe('list', () => {
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
|
||||
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
|
||||
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/data': {
|
||||
'test.keystore': JSON.stringify(keystoreData),
|
||||
}
|
||||
});
|
||||
|
||||
sandbox.stub(Logger.prototype, 'log');
|
||||
sandbox.stub(Logger.prototype, 'error');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('outputs keys', () => {
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
list(keystore);
|
||||
|
||||
sinon.assert.calledOnce(Logger.prototype.log);
|
||||
sinon.assert.calledWith(Logger.prototype.log, 'a1.b2.c3\na2');
|
||||
});
|
||||
|
||||
it('handles a nonexistent keystore', () => {
|
||||
const keystore = new Keystore('/data/nonexistent.keystore');
|
||||
list(keystore);
|
||||
|
||||
sinon.assert.calledOnce(Logger.prototype.log);
|
||||
sinon.assert.calledWith(Logger.prototype.log, '');
|
||||
});
|
||||
});
|
||||
});
|
46
src/cli_keystore/__tests__/remove.js
Normal file
46
src/cli_keystore/__tests__/remove.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import mockFs from 'mock-fs';
|
||||
|
||||
import { Keystore } from '../../server/keystore';
|
||||
import { remove } from '../remove';
|
||||
|
||||
describe('Kibana keystore', () => {
|
||||
describe('remove', () => {
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
const keystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDyfGfJSy4mH'
|
||||
+ 'BBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqhI4lzJ9M'
|
||||
+ 'Ry21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/data': {
|
||||
'test.keystore': JSON.stringify(keystoreData),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('removes key', () => {
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
|
||||
remove(keystore, 'a2');
|
||||
|
||||
expect(keystore.data).to.eql({ 'a1.b2.c3': 'foo' });
|
||||
});
|
||||
|
||||
it('persists the keystore', () => {
|
||||
const keystore = new Keystore('/data/test.keystore');
|
||||
sandbox.stub(keystore, 'save');
|
||||
|
||||
remove(keystore, 'a2');
|
||||
|
||||
sinon.assert.calledOnce(keystore.save);
|
||||
});
|
||||
});
|
||||
});
|
50
src/cli_keystore/add.js
Normal file
50
src/cli_keystore/add.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import Logger from '../cli_plugin/lib/logger';
|
||||
import { confirm, question } from '../server/utils';
|
||||
import { createPromiseFromStreams, createConcatStream } from '../utils';
|
||||
|
||||
/**
|
||||
* @param {Keystore} keystore
|
||||
* @param {String} key
|
||||
* @param {Object|null} options
|
||||
* @property {Boolean} options.force - if true, will overwrite without prompting
|
||||
* @property {Stream|Boolean} options.stdin - if true, uses process.stdin
|
||||
*/
|
||||
|
||||
export async function add(keystore, key, options = {}) {
|
||||
const logger = new Logger(options);
|
||||
let value;
|
||||
|
||||
if (!keystore.exists()) {
|
||||
return logger.error('ERROR: Kibana keystore not found. Use \'create\' command to create one.');
|
||||
}
|
||||
|
||||
if (!options.force && keystore.has(key)) {
|
||||
const overwrite = await confirm(`Setting ${key} already exists. Overwrite?`);
|
||||
|
||||
if (!overwrite) {
|
||||
return logger.log('Exiting without modifying keystore.');
|
||||
}
|
||||
}
|
||||
|
||||
if (options.stdin) {
|
||||
value = await createPromiseFromStreams([
|
||||
options.stdin || process.stdin,
|
||||
createConcatStream('')
|
||||
]);
|
||||
} else {
|
||||
value = await question(`Enter value for ${key}`, { mask: '*' });
|
||||
}
|
||||
|
||||
keystore.add(key, value.trim());
|
||||
keystore.save();
|
||||
}
|
||||
|
||||
export function addCli(program, keystore) {
|
||||
program
|
||||
.command('add <key>')
|
||||
.description('Add a string setting to the keystore')
|
||||
.option('-f, --force', 'overwrite existing setting without prompting')
|
||||
.option('-x, --stdin', 'read setting value from stdin')
|
||||
.option('-s, --silent', 'prevent all logging')
|
||||
.action(add.bind(null, keystore));
|
||||
}
|
31
src/cli_keystore/cli_keystore.js
Normal file
31
src/cli_keystore/cli_keystore.js
Normal file
|
@ -0,0 +1,31 @@
|
|||
import { join } from 'path';
|
||||
|
||||
import { pkg } from '../utils';
|
||||
import Command from '../cli/command';
|
||||
import { getData } from '../server/path';
|
||||
import { Keystore } from '../server/keystore';
|
||||
|
||||
const path = join(getData(), 'kibana.keystore');
|
||||
const keystore = new Keystore(path);
|
||||
|
||||
import { createCli } from './create';
|
||||
import { listCli } from './list';
|
||||
import { addCli } from './add';
|
||||
import { removeCli } from './remove';
|
||||
|
||||
const program = new Command('bin/kibana-keystore');
|
||||
|
||||
program
|
||||
.version(pkg.version)
|
||||
.description('A tool for managing settings stored in the Kibana keystore');
|
||||
|
||||
createCli(program, keystore);
|
||||
listCli(program, keystore);
|
||||
addCli(program, keystore);
|
||||
removeCli(program, keystore);
|
||||
|
||||
program.parse(process.argv);
|
||||
|
||||
if (!program.args.length) {
|
||||
program.help();
|
||||
}
|
26
src/cli_keystore/create.js
Normal file
26
src/cli_keystore/create.js
Normal file
|
@ -0,0 +1,26 @@
|
|||
import Logger from '../cli_plugin/lib/logger';
|
||||
import { confirm } from '../server/utils';
|
||||
|
||||
export async function create(keystore, command, options) {
|
||||
const logger = new Logger(options);
|
||||
|
||||
if (keystore.exists()) {
|
||||
const overwrite = await confirm('A Kibana keystore already exists. Overwrite?');
|
||||
|
||||
if (!overwrite) {
|
||||
return logger.log('Exiting without modifying keystore.');
|
||||
}
|
||||
}
|
||||
|
||||
keystore.save();
|
||||
|
||||
logger.log(`Created Kibana keystore in ${keystore.path}`);
|
||||
}
|
||||
|
||||
export function createCli(program, keystore) {
|
||||
program
|
||||
.command('create')
|
||||
.description('Creates a new Kibana keystore')
|
||||
.option('-s, --silent', 'prevent all logging')
|
||||
.action(create.bind(null, keystore));
|
||||
}
|
2
src/cli_keystore/index.js
Normal file
2
src/cli_keystore/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
require('../babel-register');
|
||||
require('./cli_keystore');
|
16
src/cli_keystore/list.js
Normal file
16
src/cli_keystore/list.js
Normal file
|
@ -0,0 +1,16 @@
|
|||
import Logger from '../cli_plugin/lib/logger';
|
||||
|
||||
export function list(keystore, command, options = {}) {
|
||||
const logger = new Logger(options);
|
||||
const keys = keystore.keys();
|
||||
|
||||
logger.log(keys.join('\n'));
|
||||
}
|
||||
|
||||
export function listCli(program, keystore) {
|
||||
program
|
||||
.command('list')
|
||||
.description('List entries in the keystore')
|
||||
.option('-s, --silent', 'prevent all logging')
|
||||
.action(list.bind(null, keystore));
|
||||
}
|
12
src/cli_keystore/remove.js
Normal file
12
src/cli_keystore/remove.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
export async function remove(keystore, key) {
|
||||
keystore.remove(key);
|
||||
keystore.save();
|
||||
}
|
||||
|
||||
export function removeCli(program, keystore) {
|
||||
program
|
||||
.command('remove <key>')
|
||||
.description('Remove a setting from the keystore')
|
||||
.option('-s, --silent', 'prevent all logging')
|
||||
.action(remove.bind(null, keystore));
|
||||
}
|
|
@ -2,7 +2,7 @@ import _ from 'lodash';
|
|||
import path from 'path';
|
||||
import { fromRoot } from '../../utils';
|
||||
import KbnServer from '../../server/kbn_server';
|
||||
import readYamlConfig from '../../cli/serve/read_yaml_config';
|
||||
import { readYamlConfig } from '../../cli/serve/read_yaml_config';
|
||||
import { versionSatisfies, cleanVersion } from '../../utils/version';
|
||||
import { statSync } from 'fs';
|
||||
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
*/
|
||||
export default class Logger {
|
||||
|
||||
constructor(settings) {
|
||||
constructor(settings = {}) {
|
||||
this.previousLineEnded = true;
|
||||
this.silent = !!settings.silent;
|
||||
this.quiet = !!settings.quiet;
|
||||
|
|
193
src/server/keystore/__tests__/keystore.js
Normal file
193
src/server/keystore/__tests__/keystore.js
Normal file
|
@ -0,0 +1,193 @@
|
|||
import expect from 'expect.js';
|
||||
import mockFs from 'mock-fs';
|
||||
import sinon from 'sinon';
|
||||
import { readFileSync } from 'fs';
|
||||
|
||||
import { Keystore } from '../keystore';
|
||||
|
||||
describe('Keystore', () => {
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
const protoctedKeystoreData = '1:4BnWfydL8NwFIQJg+VQKe0jlIs7uXtty6+++yaWPbSB'
|
||||
+ 'KIX3d9nPfQ20K1C6Xh26E/gMJAQ9jh7BxK0+W3lt/iDJBJn44wqX3pQ0189iGkNBL0ibDCc'
|
||||
+ 'tz4mRy6+hqwiLxiukpH8ELAJsff8LNNHr+gNzX/2k/GvB7nQ==';
|
||||
|
||||
const unprotectedKeystoreData = '1:IxR0geiUTMJp8ueHDkqeUJ0I9eEw4NJPXIJi22UDy'
|
||||
+ 'fGfJSy4mHBBuGPkkAix/x/YFfIxo4tiKGdJ2oVTtU8LgKDkVoGdL+z7ylY4n3myatt6osqh'
|
||||
+ 'I4lzJ9MRy21UcAJki2qFUTj4TYuvhta3LId+RM5UX/dJ2468hQ==';
|
||||
|
||||
beforeEach(() => {
|
||||
mockFs({
|
||||
'/data': {
|
||||
'protected.keystore': protoctedKeystoreData,
|
||||
'unprotected.keystore': unprotectedKeystoreData,
|
||||
},
|
||||
'/inaccessible': mockFs.directory({
|
||||
mode: '0000',
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockFs.restore();
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('thows permission denied', () => {
|
||||
const path = '/inaccessible/test.keystore';
|
||||
|
||||
try {
|
||||
const keystore = new Keystore(path);
|
||||
keystore.save();
|
||||
|
||||
expect().fail('should throw error');
|
||||
} catch(e) {
|
||||
expect(e.code).to.eql('EACCES');
|
||||
}
|
||||
});
|
||||
|
||||
it('creates keystore with version', () => {
|
||||
const path = '/data/test.keystore';
|
||||
|
||||
const keystore = new Keystore(path);
|
||||
keystore.save();
|
||||
|
||||
const fileBuffer = readFileSync(path);
|
||||
const contents = fileBuffer.toString();
|
||||
const [version, data] = contents.split(':');
|
||||
|
||||
expect(version).to.eql(1);
|
||||
expect(data.length).to.be.greaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('is called on initialization', () => {
|
||||
const load = sandbox.spy(Keystore.prototype, 'load');
|
||||
|
||||
new Keystore('/data/protected.keystore', 'changeme');
|
||||
|
||||
expect(load.calledOnce).to.be(true);
|
||||
});
|
||||
|
||||
it('can load a password protected keystore', () => {
|
||||
const keystore = new Keystore('/data/protected.keystore', 'changeme');
|
||||
expect(keystore.data).to.eql({ 'a1.b2.c3': 'foo', 'a2': 'bar' });
|
||||
});
|
||||
|
||||
it('throws unable to read keystore', () => {
|
||||
try {
|
||||
new Keystore('/data/protected.keystore', 'wrongpassword');
|
||||
|
||||
expect().fail('should throw error');
|
||||
} catch(e) {
|
||||
expect(e).to.be.a(Keystore.errors.UnableToReadKeystore);
|
||||
}
|
||||
});
|
||||
|
||||
it('gracefully handles keystore not found', () => {
|
||||
new Keystore('/data/nonexistent.keystore');
|
||||
});
|
||||
});
|
||||
|
||||
describe('keys', () => {
|
||||
it('lists object keys', () => {
|
||||
const keystore = new Keystore('/data/unprotected.keystore');
|
||||
const keys = keystore.keys();
|
||||
|
||||
expect(keys).to.eql(['a1.b2.c3', 'a2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has', () => {
|
||||
it('returns true if key exists', () => {
|
||||
const keystore = new Keystore('/data/unprotected.keystore');
|
||||
|
||||
expect(keystore.has('a2')).to.be(true);
|
||||
});
|
||||
|
||||
it('returns false if key does not exist', () => {
|
||||
const keystore = new Keystore('/data/unprotected.keystore');
|
||||
|
||||
expect(keystore.has('invalid')).to.be(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('adds a key/value pair', () => {
|
||||
const keystore = new Keystore('/data/unprotected.keystore');
|
||||
keystore.add('a3', 'baz');
|
||||
|
||||
expect(keystore.data).to.eql({
|
||||
'a1.b2.c3': 'foo',
|
||||
'a2': 'bar',
|
||||
'a3': 'baz',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes a key/value pair', () => {
|
||||
const keystore = new Keystore('/data/unprotected.keystore');
|
||||
keystore.remove('a1.b2.c3');
|
||||
|
||||
expect(keystore.data).to.eql({
|
||||
'a2': 'bar',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('encrypt', () => {
|
||||
it('has randomness ', () => {
|
||||
const text = 'foo';
|
||||
const password = 'changeme';
|
||||
|
||||
const dataOne = Keystore.encrypt(text, password);
|
||||
const dataTwo = Keystore.encrypt(text, password);
|
||||
|
||||
expect(dataOne).to.not.eql(dataTwo);
|
||||
});
|
||||
|
||||
it('can immediately be decrypted', () => {
|
||||
const password = 'changeme';
|
||||
const secretText = 'foo';
|
||||
|
||||
const data = Keystore.encrypt(secretText, password);
|
||||
const text = Keystore.decrypt(data, password);
|
||||
|
||||
expect(text).to.eql(secretText);
|
||||
});
|
||||
});
|
||||
|
||||
describe('decrypt', () => {
|
||||
const text = 'foo';
|
||||
const password = 'changeme';
|
||||
const ciphertext = 'ctvRsD0l0u958QoPuINQX+wgspbXt2+7IJ7gNbCND2dCGZxYOCwMH9'
|
||||
+ 'MEdZZG4cevSrnhYOaxh24POFhtisSdCSlLWsKNQU8NK1zqNQ3RRP8HxayZJB7ly9uOLbDS+'
|
||||
+ 'Ew=';
|
||||
|
||||
it('can decrypt data', () => {
|
||||
const data = Keystore.decrypt(ciphertext, password);
|
||||
expect(data).to.eql(text);
|
||||
});
|
||||
|
||||
it('throws error for invalid password', () => {
|
||||
try {
|
||||
Keystore.decrypt(ciphertext, 'invalid');
|
||||
expect().fail('should throw error');
|
||||
} catch(e) {
|
||||
expect(e).to.be.a(Keystore.errors.UnableToReadKeystore);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws error for corrupt ciphertext', () => {
|
||||
try {
|
||||
Keystore.decrypt('thisisinvalid', password);
|
||||
expect().fail('should throw error');
|
||||
} catch(e) {
|
||||
expect(e).to.be.a(Keystore.errors.UnableToReadKeystore);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
12
src/server/keystore/errors.js
Normal file
12
src/server/keystore/errors.js
Normal file
|
@ -0,0 +1,12 @@
|
|||
class KeystoreError extends Error {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
this.name = this.constructor.name;
|
||||
}
|
||||
}
|
||||
|
||||
export class UnableToReadKeystore extends KeystoreError {
|
||||
constructor(message) {
|
||||
super(message || 'unable to read keystore');
|
||||
}
|
||||
}
|
1
src/server/keystore/index.js
Normal file
1
src/server/keystore/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { Keystore } from './keystore';
|
103
src/server/keystore/keystore.js
Normal file
103
src/server/keystore/keystore.js
Normal file
|
@ -0,0 +1,103 @@
|
|||
import { writeFileSync, readFileSync, existsSync } from 'fs';
|
||||
import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync } from 'crypto';
|
||||
import * as errors from './errors';
|
||||
|
||||
const VERSION = 1;
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const ITERATIONS = 10000;
|
||||
|
||||
export class Keystore {
|
||||
constructor(path, password = '') {
|
||||
this.path = path;
|
||||
this.data = {};
|
||||
this.password = password;
|
||||
|
||||
this.load();
|
||||
}
|
||||
|
||||
static errors = errors;
|
||||
|
||||
static encrypt(text, password = '') {
|
||||
const iv = randomBytes(12);
|
||||
const salt = randomBytes(64);
|
||||
const key = pbkdf2Sync(password, salt, ITERATIONS, 32, 'sha512');
|
||||
|
||||
const cipher = createCipheriv(ALGORITHM, key, iv);
|
||||
|
||||
const ciphertext = Buffer.concat([cipher.update(text, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
|
||||
return Buffer.concat([salt, iv, tag, ciphertext]).toString('base64');
|
||||
}
|
||||
|
||||
static decrypt(data, password = '') {
|
||||
try {
|
||||
const bData = new Buffer(data, 'base64');
|
||||
|
||||
// convert data to buffers
|
||||
const salt = bData.slice(0, 64);
|
||||
const iv = bData.slice(64, 76);
|
||||
const tag = bData.slice(76, 92);
|
||||
const text = bData.slice(92);
|
||||
|
||||
const key = pbkdf2Sync(password, salt, ITERATIONS, 32, 'sha512');
|
||||
|
||||
const decipher = createDecipheriv(ALGORITHM, key, iv);
|
||||
decipher.setAuthTag(tag);
|
||||
|
||||
return decipher.update(text, 'binary', 'utf8') + decipher.final('utf8');
|
||||
} catch (e) {
|
||||
throw new errors.UnableToReadKeystore();
|
||||
}
|
||||
}
|
||||
|
||||
save() {
|
||||
const text = JSON.stringify(this.data);
|
||||
|
||||
// The encrypted text and the version are colon delimited to make
|
||||
// it easy to visually read the version as we could have easily
|
||||
// included it with the buffer
|
||||
|
||||
const keystore = [
|
||||
VERSION,
|
||||
Keystore.encrypt(text, this.password)
|
||||
].join(':');
|
||||
|
||||
writeFileSync(this.path, keystore);
|
||||
}
|
||||
|
||||
load() {
|
||||
try {
|
||||
const keystore = readFileSync(this.path);
|
||||
const [, data] = keystore.toString().split(':');
|
||||
|
||||
this.data = JSON.parse(Keystore.decrypt(data, this.password));
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
exists() {
|
||||
return existsSync(this.path);
|
||||
}
|
||||
|
||||
keys() {
|
||||
return Object.keys(this.data);
|
||||
}
|
||||
|
||||
has(key) {
|
||||
return this.keys().indexOf(key) > -1;
|
||||
}
|
||||
|
||||
add(key, value) {
|
||||
this.data[key] = value;
|
||||
}
|
||||
|
||||
remove(key) {
|
||||
delete this.data[key];
|
||||
}
|
||||
}
|
100
src/server/utils/__tests__/prompt.js
Normal file
100
src/server/utils/__tests__/prompt.js
Normal file
|
@ -0,0 +1,100 @@
|
|||
import expect from 'expect.js';
|
||||
import sinon from 'sinon';
|
||||
import { PassThrough } from 'stream';
|
||||
|
||||
import { confirm, question } from '../prompt';
|
||||
|
||||
describe('prompt', () => {
|
||||
const sandbox = sinon.sandbox.create();
|
||||
|
||||
let input;
|
||||
let output;
|
||||
|
||||
beforeEach(() => {
|
||||
input = new PassThrough();
|
||||
output = new PassThrough();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe('confirm', () => {
|
||||
it('prompts for question', async () => {
|
||||
const onData = sandbox.stub(output, 'write');
|
||||
|
||||
confirm('my question', { output });
|
||||
|
||||
sinon.assert.calledOnce(onData);
|
||||
|
||||
const { args } = onData.getCall(0);
|
||||
expect(args[0]).to.eql('my question [y/N] ');
|
||||
});
|
||||
|
||||
it('prompts for question with default true', async () => {
|
||||
const onData = sandbox.stub(output, 'write');
|
||||
|
||||
confirm('my question', { output, default: true });
|
||||
|
||||
sinon.assert.calledOnce(onData);
|
||||
|
||||
const { args } = onData.getCall(0);
|
||||
expect(args[0]).to.eql('my question [Y/n] ');
|
||||
});
|
||||
|
||||
it('defaults to false', async () => {
|
||||
process.nextTick(() => input.write('\n'));
|
||||
|
||||
const answer = await confirm('my question', { output, input });
|
||||
expect(answer).to.be(false);
|
||||
});
|
||||
|
||||
it('accepts "y"', async () => {
|
||||
process.nextTick(() => input.write('y\n'));
|
||||
|
||||
const answer = await confirm('my question', { output, input });
|
||||
expect(answer).to.be(true);
|
||||
});
|
||||
|
||||
it('accepts "Y"', async () => {
|
||||
process.nextTick(() => input.write('Y\n'));
|
||||
|
||||
const answer = await confirm('my question', { output, input });
|
||||
expect(answer).to.be(true);
|
||||
});
|
||||
|
||||
it('accepts "yes"', async () => {
|
||||
process.nextTick(() => input.write('yes\n'));
|
||||
|
||||
const answer = await confirm('my question', { output, input });
|
||||
expect(answer).to.be(true);
|
||||
});
|
||||
|
||||
it('is false when unknown', async () => {
|
||||
process.nextTick(() => input.write('unknown\n'));
|
||||
|
||||
const answer = await confirm('my question', { output, input });
|
||||
expect(answer).to.be(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('question', () => {
|
||||
it('prompts for question', async () => {
|
||||
const onData = sandbox.stub(output, 'write');
|
||||
|
||||
question('my question', { output });
|
||||
|
||||
sinon.assert.calledOnce(onData);
|
||||
|
||||
const { args } = onData.getCall(0);
|
||||
expect(args[0]).to.eql('my question: ');
|
||||
});
|
||||
|
||||
it('can be answered', async () => {
|
||||
process.nextTick(() => input.write('my answer\n'));
|
||||
|
||||
const answer = await question('my question', { input, output });
|
||||
expect(answer).to.be('my answer');
|
||||
});
|
||||
});
|
||||
});
|
1
src/server/utils/index.js
Normal file
1
src/server/utils/index.js
Normal file
|
@ -0,0 +1 @@
|
|||
export { confirm, question } from './prompt';
|
73
src/server/utils/prompt.js
Normal file
73
src/server/utils/prompt.js
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { createInterface } from 'readline';
|
||||
|
||||
/**
|
||||
* @param {String} question
|
||||
* @param {Object|null} options
|
||||
* @property {Boolean} options.default
|
||||
* @property {Stream} options.input - defaults to process.stdin
|
||||
* @property {Stream} options.output - defaults to process.stdout
|
||||
*/
|
||||
|
||||
export function confirm(question, options = {}) {
|
||||
const rl = createInterface({
|
||||
input: options.input || process.stdin,
|
||||
output: options.output || process.stdout
|
||||
});
|
||||
|
||||
return new Promise(resolve => {
|
||||
const defautValue = options.default ? true : false;
|
||||
const defaultPrompt = defautValue ? 'Y/n' : 'y/N';
|
||||
|
||||
rl.question(`${question} [${defaultPrompt}] `, input => {
|
||||
let value = defautValue;
|
||||
|
||||
if (input != null && input !== '') {
|
||||
value = /^y(es)?/i.test(input);
|
||||
}
|
||||
|
||||
rl.close();
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} question
|
||||
* @param {Object|null} options
|
||||
* @property {Boolean} options.mask
|
||||
* @property {Stream} options.input - defaults to process.stdin
|
||||
* @property {Stream} options.output - defaults to process.stdout
|
||||
*/
|
||||
|
||||
export function question(question, options = {}) {
|
||||
const input = options.input || process.stdin;
|
||||
const output = options.output || process.stdout;
|
||||
|
||||
const questionPrompt = `${question}: `;
|
||||
const rl = createInterface({ input, output });
|
||||
|
||||
return new Promise(resolve => {
|
||||
input.on('data', (char) => {
|
||||
char = char + '';
|
||||
|
||||
switch (char) {
|
||||
case '\n':
|
||||
case '\r':
|
||||
case '\u0004':
|
||||
input.pause();
|
||||
break;
|
||||
default:
|
||||
if (options.mask) {
|
||||
output.cursorTo(questionPrompt.length);
|
||||
output.write(Array(rl.line.length + 1).join(options.mask || '*'));
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
rl.question(questionPrompt, value => {
|
||||
resolve(value);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue