Merge remote-tracking branch 'sense/master' into feature/console

This commit is contained in:
spalger 2016-03-23 14:19:02 -07:00
commit 1728cf84a1
12 changed files with 734 additions and 38 deletions

21
.travis.yml Normal file
View file

@ -0,0 +1,21 @@
language: node_js
node_js: 4
env:
- CXX=g++-4.8
addons:
apt:
sources:
- ubuntu-toolchain-r-test
packages:
- g++-4.8
install:
- npm install
- npm run setup_kibana
cache:
directories:
- node_modules
- ../kibana
script: npm test

View file

@ -86,6 +86,33 @@ You can add the following options in the `config/kibana.yml` file:
`sense.proxyFilter`:: A list of regular expressions that are used to validate any outgoing request from Sense. If none `sense.proxyFilter`:: A list of regular expressions that are used to validate any outgoing request from Sense. If none
of these match, the request will be rejected. Defaults to `.*` . See <<securing_sense>> for more details. of these match, the request will be rejected. Defaults to `.*` . See <<securing_sense>> for more details.
`sense.proxyConfig`:: A list of configuration options that are based on the proxy target. Use this to set custom timeouts or SSL settings for specific hosts. This is done by defining a set of `match` criteria using wildcards/globs which will be checked against each request. The configuration from all matching rules will then be merged together to configure the proxy used for that request.
+
The valid match keys are `match.protocol`, `match.host`, `match.port`, and `match.path`. All of these keys default to `*`, which means they will match any value.
+
Example:
+
[source,yaml]
--------
sense.proxyConfig:
- match:
host: "*.internal.org" # allow any host that ends in .internal.org
port: "{9200..9299}" # allow any port from 9200-9299
ssl:
ca: "/opt/certs/internal.ca"
# "key" and "cert" are also valid options here
- match:
protocol: "https"
ssl:
verify: false # allows any certificate to be used, even self-signed certs
# since this rule has no "match" section it matches everything
- timeout: 180000 # 3 minutes
--------
[NOTE] [NOTE]
Kibana needs to be restarted after each change to the configuration for them to be applied. Kibana needs to be restarted after each change to the configuration for them to be applied.
@ -101,7 +128,5 @@ Once downloaded you can install Sense using the following command:
[source,bash] [source,bash]
------------- -------------
$ bin/kibana plugin -i sense -u file://PATH_TO_SENSE_TAR_FILE $ bin/kibana plugin -i sense -u file:///PATH_TO_SENSE_TAR_FILE
------------- -------------

View file

@ -1,3 +1,5 @@
import { ProxyConfigCollection } from './server/proxy_config_collection';
module.exports = function (kibana) { module.exports = function (kibana) {
let { resolve, join, sep } = require('path'); let { resolve, join, sep } = require('path');
let Joi = require('joi'); let Joi = require('joi');
@ -37,53 +39,117 @@ module.exports = function (kibana) {
defaultServerUrl: Joi.string().default('http://localhost:9200'), defaultServerUrl: Joi.string().default('http://localhost:9200'),
proxyFilter: Joi.array().items(Joi.string()).single().default(['.*']), proxyFilter: Joi.array().items(Joi.string()).single().default(['.*']),
ssl: Joi.object({ ssl: Joi.object({
verify: Joi.boolean().default(true), verify: Joi.boolean(),
}).default(), }).default(),
proxyConfig: Joi.array().items(
Joi.object().keys({
match: Joi.object().keys({
protocol: Joi.string().default('*'),
host: Joi.string().default('*'),
port: Joi.string().default('*'),
path: Joi.string().default('*')
}),
timeout: Joi.number(),
ssl: Joi.object().keys({
verify: Joi.boolean(),
ca: Joi.array().single().items(Joi.string()),
cert: Joi.string(),
key: Joi.string()
}).default()
})
).default([
{
match: {
protocol: '*',
host: '*',
port: '*',
path: '*'
},
timeout: 180000,
ssl: {
verify: true
}
}
])
}).default(); }).default();
}, },
init: function (server, options) { init: function (server, options) {
const filters = options.proxyFilter.map(str => new RegExp(str)); const filters = options.proxyFilter.map(str => new RegExp(str));
// http://hapijs.com/api/8.8.1#route-configuration if (options.ssl && options.ssl.verify) {
server.route({ throw new Error('sense.ssl.verify is no longer supported.');
path: '/api/console/proxy', }
method: ['*', 'GET'],
config: {
handler: {
proxy: {
mapUri: function (req, cb) {
let { uri } = req.query;
if (!uri) {
cb(Boom.badRequest('URI is a required param.'));
return;
}
if (!filters.some(re => re.test(uri))) { const proxyConfigCollection = new ProxyConfigCollection(options.proxyConfig);
const err = Boom.forbidden(); const proxyRouteConfig = {
err.output.payload = "Error connecting to '" + uri + "':\n\nUnable to send requests to that url."; validate: {
err.output.headers['content-type'] = 'text/plain'; query: Joi.object().keys({
cb(err); uri: Joi.string().uri({
return; allowRelative: false,
} shema: ['http:', 'https:'],
}),
}).unknown(true),
},
cb(null, uri); pre: [
}, function filterUri(req, reply) {
rejectUnauthorized: options.ssl.verify, const { uri } = req.query;
passThrough: true,
xforward: true, if (!filters.some(re => re.test(uri))) {
onResponse: function (err, res, request, reply, settings, ttl) { const err = Boom.forbidden();
if (err != null) { err.output.payload = "Error connecting to '" + uri + "':\n\nUnable to send requests to that url.";
reply("Error connecting to '" + request.query.uri + "':\n\n" + err.message).type("text/plain").statusCode = 502; err.output.headers['content-type'] = 'text/plain';
} else { reply(err);
reply(null, res); } else {
} reply();
}
} }
} }
],
handler(req, reply) {
const { uri } = req.query;
reply.proxy({
uri,
xforward: true,
passThrough: true,
onResponse(err, res, request, reply, settings, ttl) {
if (err != null) {
reply("Error connecting to '" + request.query.uri + "':\n\n" + err.message).type("text/plain").statusCode = 502;
} else {
reply(null, res);
}
},
...proxyConfigCollection.configForUri(uri)
})
}
};
server.route({
path: '/api/console/proxy',
method: '*',
config: {
...proxyRouteConfig,
payload: {
output: 'stream',
parse: false
},
} }
}); });
server.route({
path: '/api/console/proxy',
method: 'GET',
config: {
...proxyRouteConfig
}
})
server.route({ server.route({
path: '/api/console/api_server', path: '/api/console/api_server',
method: ['GET', 'POST'], method: ['GET', 'POST'],

View file

@ -10,7 +10,7 @@ require('ui/modules')
// require the root app code, which expects to execute once the dom is loaded up // require the root app code, which expects to execute once the dom is loaded up
require('../app'); require('../app');
const ConfigTemplate = require('ui/ConfigTemplate'); const ConfigTemplate = require('ui/config_template');
const input = require('../input'); const input = require('../input');
const es = require('../es'); const es = require('../es');
const storage = require('../storage'); const storage = require('../storage');

View file

@ -2,7 +2,7 @@
<h4>Quick intro to the UI</h4> <h4>Quick intro to the UI</h4>
<p>Sense is split into two panes: an editor pane (white) and a response pane (black). <p>Sense is split into two panes: an editor pane (left) and a response pane (right).
Use the editor to type requests and submit them to Elasticsearch. The results will be displayed in Use the editor to type requests and submit them to Elasticsearch. The results will be displayed in
the response pane on the right side. the response pane on the right side.
</p> </p>

View file

@ -0,0 +1,234 @@
/* eslint-env mocha */
import expect from 'expect.js';
import sinon from 'sinon';
import fs from 'fs';
import https, { Agent as HttpsAgent } from 'https';
import { parse as parseUrl } from 'url';
import { ProxyConfig } from '../proxy_config'
const matchGoogle = {
protocol: 'https',
host: 'google.com',
path: '/search'
}
const parsedGoogle = parseUrl('https://google.com/search');
const parsedLocalEs = parseUrl('https://localhost:5601/search');
describe('ProxyConfig', function () {
beforeEach(function () {
sinon.stub(fs, 'readFileSync', function (path) {
return { path }
});
});
afterEach(function () {
fs.readFileSync.restore();
});
describe('constructor', function () {
beforeEach(function () {
sinon.stub(https, 'Agent');
});
afterEach(function () {
https.Agent.restore();
});
it('uses ca to create sslAgent', function () {
const config = new ProxyConfig({
ssl: {
ca: ['path/to/ca']
}
});
expect(config.sslAgent).to.be.a(https.Agent);
sinon.assert.calledOnce(https.Agent);
const sslAgentOpts = https.Agent.firstCall.args[0];
expect(sslAgentOpts).to.eql({
ca: [{ path: 'path/to/ca' }],
cert: undefined,
key: undefined,
});
});
it('uses cert, and key to create sslAgent', function () {
const config = new ProxyConfig({
ssl: {
cert: 'path/to/cert',
key: 'path/to/key'
}
});
expect(config.sslAgent).to.be.a(https.Agent);
sinon.assert.calledOnce(https.Agent);
const sslAgentOpts = https.Agent.firstCall.args[0];
expect(sslAgentOpts).to.eql({
ca: undefined,
cert: { path: 'path/to/cert' },
key: { path: 'path/to/key' },
});
});
it('uses ca, cert, and key to create sslAgent', function () {
const config = new ProxyConfig({
ssl: {
ca: ['path/to/ca'],
cert: 'path/to/cert',
key: 'path/to/key'
}
});
expect(config.sslAgent).to.be.a(https.Agent);
sinon.assert.calledOnce(https.Agent);
const sslAgentOpts = https.Agent.firstCall.args[0];
expect(sslAgentOpts).to.eql({
ca: [{ path: 'path/to/ca' }],
cert: { path: 'path/to/cert' },
key: { path: 'path/to/key' },
});
});
});
describe('#getForParsedUri', function () {
context('parsed url does not match', function () {
it('returns {}', function () {
const config = new ProxyConfig({
match: matchGoogle,
timeout: 100
});
expect(config.getForParsedUri(parsedLocalEs)).to.eql({});
});
});
context('parsed url does match', function () {
it('assigns timeout value', function () {
const football = {};
const config = new ProxyConfig({
match: matchGoogle,
timeout: football
});
expect(config.getForParsedUri(parsedGoogle).timeout).to.be(football);
});
it('assigns ssl.verify to rejectUnauthorized', function () {
const football = {};
const config = new ProxyConfig({
match: matchGoogle,
ssl: {
verify: football
}
});
expect(config.getForParsedUri(parsedGoogle).rejectUnauthorized).to.be(football);
});
context('uri us http', function () {
context('ca is set', function () {
it('creates but does not output the agent', function () {
const config = new ProxyConfig({
ssl: {
ca: ['path/to/ca']
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined);
});
});
context('cert is set', function () {
it('creates but does not output the agent', function () {
const config = new ProxyConfig({
ssl: {
cert: 'path/to/cert'
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined);
});
});
context('key is set', function () {
it('creates but does not output the agent', function () {
const config = new ProxyConfig({
ssl: {
key: 'path/to/key'
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined);
});
});
context('cert + key are set', function () {
it('creates but does not output the agent', function () {
const config = new ProxyConfig({
ssl: {
cert: 'path/to/cert',
key: 'path/to/key'
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'http:' }).agent).to.be(undefined);
});
});
});
context('uri us https', function () {
context('ca is set', function () {
it('creates and outputs the agent', function () {
const config = new ProxyConfig({
ssl: {
ca: ['path/to/ca']
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent);
});
});
context('cert is set', function () {
it('creates and outputs the agent', function () {
const config = new ProxyConfig({
ssl: {
cert: 'path/to/cert'
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent);
});
});
context('key is set', function () {
it('creates and outputs the agent', function () {
const config = new ProxyConfig({
ssl: {
key: 'path/to/key'
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent);
});
});
context('cert + key are set', function () {
it('creates and outputs the agent', function () {
const config = new ProxyConfig({
ssl: {
cert: 'path/to/cert',
key: 'path/to/key'
}
});
expect(config.sslAgent).to.be.an(HttpsAgent);
expect(config.getForParsedUri({ protocol: 'https:' }).agent).to.be(config.sslAgent);
});
});
});
});
});
});

View file

@ -0,0 +1,156 @@
/* eslint-env mocha */
import expect from 'expect.js';
import sinon from 'sinon';
import fs from 'fs';
import { Agent as HttpsAgent } from 'https';
import { ProxyConfigCollection } from '../proxy_config_collection'
describe('ProxyConfigCollection', function () {
beforeEach(function () {
sinon.stub(fs, 'readFileSync', () => new Buffer(0));
});
afterEach(function () {
fs.readFileSync.restore();
});
const proxyConfigs = [
{
match: {
protocol: 'https',
host: 'localhost',
port: 5601,
path: '/.kibana'
},
timeout: 1,
},
{
match: {
protocol: 'https',
host: 'localhost',
port: 5601
},
timeout: 2,
},
{
match: {
host: 'localhost',
port: 5601
},
timeout: 3,
},
{
match: {
host: 'localhost'
},
timeout: 4,
},
{
match: {},
timeout: 5
}
]
function getTimeout(uri) {
const collection = new ProxyConfigCollection(proxyConfigs);
return collection.configForUri(uri).timeout;
}
context('http://localhost:5601', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('http://localhost:5601')).to.be(3)
});
});
context('https://localhost:5601/.kibana', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('https://localhost:5601/.kibana')).to.be(1);
});
});
context('http://localhost:5602', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('http://localhost:5602')).to.be(4);
});
});
context('https://localhost:5602', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('https://localhost:5602')).to.be(4);
});
});
context('http://localhost:5603', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('http://localhost:5603')).to.be(4);
});
});
context('https://localhost:5603', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('https://localhost:5603')).to.be(4);
});
});
context('https://localhost:5601/index', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('https://localhost:5601/index')).to.be(2);
});
});
context('http://localhost:5601/index', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('http://localhost:5601/index')).to.be(3);
});
});
context('https://localhost:5601/index/type', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('https://localhost:5601/index/type')).to.be(2);
});
});
context('http://notlocalhost', function () {
it('defaults to the first matching timeout', function () {
expect(getTimeout('http://notlocalhost')).to.be(5);
});
});
context('collection with ssl config and root level verify:false', function () {
function makeCollection() {
return new ProxyConfigCollection([
{
match: { host: '*.internal.org' },
ssl: { ca: ['path/to/ca'] }
},
{
match: { host: '*' },
ssl: { verify: false }
}
]);
}
it('verifies for config that produces ssl agent', function () {
const conf = makeCollection().configForUri('https://es.internal.org/_search');
expect(conf).to.have.property('rejectUnauthorized', true);
expect(conf.agent).to.be.an(HttpsAgent);
});
it('disabled verification for * config', function () {
const conf = makeCollection().configForUri('https://extenal.org/_search');
expect(conf).to.have.property('rejectUnauthorized', false);
expect(conf.agent).to.be(undefined);
});
});
});

View file

@ -0,0 +1,55 @@
/* eslint-env mocha */
import expect from 'expect.js'
import { WildcardMatcher } from '../wildcard_matcher'
function should(candidate, ...constructorArgs) {
if (!new WildcardMatcher(...constructorArgs).match(candidate)) {
throw new Error(`Expected pattern ${[...constructorArgs]} to match ${candidate}`);
}
}
function shouldNot(candidate, ...constructorArgs) {
if (new WildcardMatcher(...constructorArgs).match(candidate)) {
throw new Error(`Expected pattern ${[...constructorArgs]} to not match ${candidate}`);
}
}
describe('WildcardMatcher', function () {
context('pattern = *', function () {
it('matches http', () => should('http', '*'));
it('matches https', () => should('https', '*'));
it('matches nothing', () => should('', '*'));
it('does not match /', () => shouldNot('/', '*'));
it('matches localhost', () => should('localhost', '*'));
it('matches a path', () => should('/index/type/_search', '*'));
context('defaultValue = /', function () {
it('matches /', () => should('/', '*', '/'));
});
});
context('pattern = http', function () {
it('matches http', () => should('http', 'http'));
it('does not match https', () => shouldNot('https', 'http'));
it('does not match nothing', () => shouldNot('', 'http'));
it('does not match localhost', () => shouldNot('localhost', 'http'));
it('does not match a path', () => shouldNot('/index/type/_search', 'http'));
});
context('pattern = 560{1..9}', function () {
it('does not match http', () => shouldNot('http', '560{1..9}'));
it('does not matches 5600', () => shouldNot('5600', '560{1..9}'));
it('matches 5601', () => should('5601', '560{1..9}'));
it('matches 5602', () => should('5602', '560{1..9}'));
it('matches 5603', () => should('5603', '560{1..9}'));
it('matches 5604', () => should('5604', '560{1..9}'));
it('matches 5605', () => should('5605', '560{1..9}'));
it('matches 5606', () => should('5606', '560{1..9}'));
it('matches 5607', () => should('5607', '560{1..9}'));
it('matches 5608', () => should('5608', '560{1..9}'));
it('matches 5609', () => should('5609', '560{1..9}'));
it('does not matches 5610', () => shouldNot('5610', '560{1..9}'));
});
});

View file

@ -0,0 +1,70 @@
import { memoize, values } from 'lodash'
import { format as formatUrl } from 'url'
import { Agent as HttpsAgent } from 'https'
import { readFileSync } from 'fs'
import { WildcardMatcher } from './wildcard_matcher'
const makeHttpsAgent = memoize(
opts => new HttpsAgent(opts),
opts => JSON.stringify(opts)
)
export class ProxyConfig {
constructor(config) {
config = Object.assign({}, config);
// -----
// read "match" info
// -----
const rawMatches = Object.assign({}, config.match);
this.id = formatUrl({
protocol: rawMatches.protocol,
hostname: rawMatches.host,
port: rawMatches.port,
pathname: rawMatches.path
}) || '*';
this.matchers = {
protocol: new WildcardMatcher(rawMatches.protocol),
host: new WildcardMatcher(rawMatches.host),
port: new WildcardMatcher(rawMatches.port),
path: new WildcardMatcher(rawMatches.path, '/'),
};
// -----
// read config vars
// -----
this.timeout = config.timeout;
this.sslAgent = this._makeSslAgent(config);
}
_makeSslAgent(config) {
const ssl = config.ssl || {};
this.verifySsl = ssl.verify;
const sslAgentOpts = {
ca: ssl.ca && ssl.ca.map(ca => readFileSync(ca)),
cert: ssl.cert && readFileSync(ssl.cert),
key: ssl.key && readFileSync(ssl.key),
};
if (values(sslAgentOpts).filter(Boolean).length) {
return new HttpsAgent(sslAgentOpts);
}
}
getForParsedUri({ protocol, hostname, port, pathname }) {
let match = this.matchers.protocol.match(protocol.slice(0, -1));
match = match && this.matchers.host.match(hostname);
match = match && this.matchers.port.match(port);
match = match && this.matchers.path.match(pathname);
if (!match) return {};
return {
timeout: this.timeout,
rejectUnauthorized: this.sslAgent ? true : this.verifySsl,
agent: protocol === 'https:' ? this.sslAgent : undefined
};
}
}

View file

@ -0,0 +1,17 @@
import { defaultsDeep } from 'lodash'
import { ProxyConfig } from './proxy_config'
import { parse as parseUrl } from 'url'
export class ProxyConfigCollection {
constructor(configs = []) {
this.configs = configs.map(settings => new ProxyConfig(settings))
}
configForUri(uri) {
const parsedUri = parseUrl(uri);
const settings = this.configs.map(config => config.getForParsedUri(parsedUri));
return defaultsDeep({}, ...settings);
}
}

View file

@ -0,0 +1,24 @@
import { Minimatch } from 'minimatch'
export class WildcardMatcher {
constructor(wildcardPattern, emptyVal) {
this.emptyVal = emptyVal;
this.pattern = String(wildcardPattern || '*');
this.matcher = new Minimatch(this.pattern, {
noglobstar: true,
dot: true,
nocase: true,
matchBase: true,
nocomment: true
})
}
match(candidate) {
const empty = !candidate || candidate === this.emptyVal;
if (empty && this.pattern === '*') {
return true;
}
return this.matcher.match(candidate || '')
}
}

28
tasks/setup_kibana.js Normal file
View file

@ -0,0 +1,28 @@
const exec = require('child_process').execFileSync;
const stat = require('fs').statSync;
const fromRoot = require('path').resolve.bind(null, __dirname, '../');
module.exports = function (grunt) {
grunt.registerTask('setup_kibana', function () {
const kbnDir = fromRoot('../kibana');
const kbnGitDir = fromRoot('../kibana/.git');
try {
if (stat(kbnGitDir).isDirectory()) {
exec('git', ['pull', 'origin', 'master'], { cwd: kbnDir });
} else {
throw new Error(`${kbnGitDir} is not a directory??`);
}
} catch (error) {
if (error.code === 'ENOENT') {
exec('git', ['clone', 'https://github.com/elastic/kibana.git', kbnDir]);
} else {
throw error;
}
}
exec('npm', ['prune'], { cwd: kbnDir });
exec('npm', ['install'], { cwd: kbnDir });
});
};