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
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]
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]
-------------
$ 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) {
let { resolve, join, sep } = require('path');
let Joi = require('joi');
@ -37,53 +39,117 @@ module.exports = function (kibana) {
defaultServerUrl: Joi.string().default('http://localhost:9200'),
proxyFilter: Joi.array().items(Joi.string()).single().default(['.*']),
ssl: Joi.object({
verify: Joi.boolean().default(true),
verify: Joi.boolean(),
}).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();
},
init: function (server, options) {
const filters = options.proxyFilter.map(str => new RegExp(str));
// http://hapijs.com/api/8.8.1#route-configuration
server.route({
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 (options.ssl && options.ssl.verify) {
throw new Error('sense.ssl.verify is no longer supported.');
}
if (!filters.some(re => re.test(uri))) {
const err = Boom.forbidden();
err.output.payload = "Error connecting to '" + uri + "':\n\nUnable to send requests to that url.";
err.output.headers['content-type'] = 'text/plain';
cb(err);
return;
}
const proxyConfigCollection = new ProxyConfigCollection(options.proxyConfig);
const proxyRouteConfig = {
validate: {
query: Joi.object().keys({
uri: Joi.string().uri({
allowRelative: false,
shema: ['http:', 'https:'],
}),
}).unknown(true),
},
cb(null, uri);
},
rejectUnauthorized: options.ssl.verify,
passThrough: true,
xforward: true,
onResponse: function (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);
}
}
pre: [
function filterUri(req, reply) {
const { uri } = req.query;
if (!filters.some(re => re.test(uri))) {
const err = Boom.forbidden();
err.output.payload = "Error connecting to '" + uri + "':\n\nUnable to send requests to that url.";
err.output.headers['content-type'] = 'text/plain';
reply(err);
} 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({
path: '/api/console/api_server',
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('../app');
const ConfigTemplate = require('ui/ConfigTemplate');
const ConfigTemplate = require('ui/config_template');
const input = require('../input');
const es = require('../es');
const storage = require('../storage');

View file

@ -2,7 +2,7 @@
<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
the response pane on the right side.
</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 });
});
};