├── .dockerignore ├── .gitignore ├── .jshintrc ├── .vmcignore ├── Dockerfile ├── README.md ├── app.js ├── lib ├── es-proxy.js └── google-oauth.js ├── manifest-example.yml └── package.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | node_modules 3 | grafana/ 4 | kibana/ 5 | manifest* -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .DS_Store 4 | meta.json 5 | .idea 6 | coverage.html 7 | lib-cov 8 | .coverage_data 9 | reports 10 | html-report 11 | build 12 | cobertura-coverage.xml 13 | metadata 14 | kibana 15 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "proto": true, 3 | "browser": true, 4 | "curly": true, 5 | "devel": true, 6 | "eqeqeq": true, 7 | "eqnull": true, 8 | "es5": false, 9 | "evil": false, 10 | "immed": false, 11 | "jquery": true, 12 | "latedef": false, 13 | "laxcomma": true, 14 | "newcap": true, 15 | "node": true, 16 | "noempty": true, 17 | "nonew": true, 18 | "predef": 19 | [ 20 | "after", 21 | "afterEach", 22 | "before", 23 | "beforeEach", 24 | "describe", 25 | "it", 26 | "unescape", 27 | "par", 28 | "each", 29 | "setImmediate" 30 | ], 31 | "smarttabs": true, 32 | "trailing": false, 33 | "undef": true, 34 | "strict": false, 35 | "expr": true 36 | } 37 | -------------------------------------------------------------------------------- /.vmcignore: -------------------------------------------------------------------------------- 1 | manifest.yml 2 | node_modules/.bin 3 | .git 4 | kibana/.git 5 | .jshintrc 6 | manifest* 7 | .gitignore 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM google/nodejs 2 | MAINTAINER Hugues MALPHETTES 3 | 4 | WORKDIR /app 5 | ADD . /app 6 | RUN npm install --unsafe-perm 7 | 8 | EXPOSE 3003 9 | 10 | CMD ["/nodejs/bin/npm", "start"] 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | kibana-proxy 2 | ============ 3 | 4 | Hosts [Kibana 3](http://three.kibana.org) as a nodejs express application. 5 | 6 | Features: 7 | - Proxy access to Elasticsearch: all elasticsearch queries are sent through the express application. 8 | - Optional Google OAuth2 login with (passport)[http://passport.org]. 9 | - Support for Elasticsearch protected by basic-authentication: only the express app will know about the username and password. 10 | 11 | Front-ends specifically tested: 12 | - [Kibana 3](http://three.kibana.org) 13 | - [Head](https://github.com/mobz/elasticsearch-head) 14 | - [Bigdesk](https://github.com/lukas-vlcek/bigdesk) 15 | 16 | Note regarding Kibana 4: it won't be supported here. 17 | ---------------------------------------------------- 18 | 19 | Please use Elastic's Shield or contribute to https://github.com/hmalphettes/kibana-auth-plugin or wait for https://github.com/elastic/kibana/issues/3904 20 | 21 | Longer story: Kibana 4 requires its own web-tier; it processes the requests issued by the browser and then sends them to Elasticsearch. 22 | 23 | Reference: https://github.com/elasticsearch/kibana/issues/1628#issuecomment-58611294 24 | 25 | For Kibana 4 it seems an authentication plugin is what is needed to replace this proxy. 26 | 27 | In fact Kibana 4 is a NodeJS app since RC1. We should be able to plug our middleware for authentication in a sniffy! 28 | 29 | Usage 30 | ===== 31 | 32 | ``` 33 | git clone https://github.com/hmalphettes/kibana-proxy.git 34 | npm install 35 | npm start & 36 | open http://localhost:3003/index.html 37 | open http://localhost:3003/_plugin/head 38 | open http://localhost:3003/_plugin/bigdesk 39 | ``` 40 | 41 | Configuration 42 | ============= 43 | Configuration is done via environment variables: 44 | - `ES_URL`: example: `http://user:password@your-elasticsearch.local`; default: `http://localhost:9200` 45 | - `PORT`: the port where the app is run, default to `VCAP_PORT` and then to `3003`. 46 | - `APP_ID`, `APP_SECRET`: Google OAuth2 config. Optional. 47 | - `AUTHORIZED_EMAILS`: define what authenticated email is granted access; a comma separated listed of patterns; defaults to `*`. example: `*@stoic.com,justme@gmail.com` each pattern must be one-of: 48 | - `*`: anything, 49 | - `*@domain`: any email in the domain 50 | - `an@email`: a specific email. 51 | - OTHER_REVERSE_PROXIED: optional: Proxy to other host. For example to proxy to the consul UI: '{"/v1,/ui":{"host":"localhost","port":8500}}' 52 | 53 | Docker Usage 54 | ============ 55 | ``` 56 | docker run -e APP_ID=abc APP_SECRET=def hmalphettes/kibana-proxy 57 | ``` 58 | 59 | Push to cloudfoundry.com 60 | ======================== 61 | - Copy the `manifest-example.yml` file as `manifest-real.yml` provided as an example. 62 | - Must: change the name of the app and elasticsearch URL. 63 | - Could: add `APP_ID`, `APP_SECRET` and `AUTHORIZED_EMAILS` to protect access. 64 | - `vmc push --manifest-real.yml`. 65 | 66 | Resources 67 | ========= 68 | - [Kibana 3](http://three.kibana.org) and its friend [Logstash](http://logstash.net) 69 | - Generate some logs from nodejs without going through logstash: [log4js-elasticsearch](https://github.com/hmalphettes/log4js-elasticsearch) 70 | 71 | License 72 | ======= 73 | kibana-proxy is freely distributable under the terms of the MIT license. 74 | 75 | Copyright (c) 2013 Sutoiku, Inc. 76 | 77 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 78 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the 79 | rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit 80 | persons to whom the Software is furnished to do so, subject to the following conditions: 81 | 82 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 83 | 84 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE 85 | WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 86 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR 87 | OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 88 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Hosts kibana as an express application. 3 | * Optional google oauth integration. 4 | * Proxies the calls to elasticsearch 5 | * Insert basic authentication to elasticsearch calls if necessary. 6 | * Deployable on cloudfoundry. 7 | * 8 | * License: MIT 9 | * Copyright: Sutoiku, Inc. 10 | * Author: Hugues Malphettes. 11 | */ 12 | 13 | var fs = require('fs'); 14 | var parseUrl = require('url').parse; 15 | var express = require('express'); 16 | var app = express(); 17 | var server; 18 | 19 | var configureESProxy = require('./lib/es-proxy').configureESProxy; 20 | var configureOAuth = require('./lib/google-oauth').configureOAuth; 21 | 22 | var config = createConfig(); 23 | configureApp(app, config); 24 | 25 | function createConfig() { 26 | var config = {}; 27 | if (!config.port) { 28 | config.port = process.env.PORT || process.env.VCAP_APP_PORT || 3003; 29 | } 30 | if (!config.es_url) { 31 | config.es_url = process.env.ES_URL || 'http://localhost:9200'; 32 | } 33 | // oauth 34 | if (process.env.APP_ID && process.env.APP_SECRET) { 35 | config.appID = process.env.APP_ID; 36 | config.appSecret = process.env.APP_SECRET; 37 | config.authorizedEmails = (process.env.AUTHORIZED_EMAILS || '*').split(','); 38 | } 39 | if (process.env.OTHER_REVERSE_PROXIED) { 40 | // for example for consul: 41 | // {"/v1,/ui":{"host":"localhost","port":8500}} 42 | try { 43 | config.others = JSON.parse(process.env.OTHER_REVERSE_PROXIED); 44 | } catch(x) { 45 | console.error('Could not parse process.env.OTHER_REVERSE_PROXIED: ' + process.env.OTHER_REVERSE_PROXIED, x); 46 | } 47 | } 48 | // parse the url 49 | parseESURL(config.es_url, config); 50 | 51 | return config; 52 | } 53 | 54 | function configureApp(app, config) { 55 | app.disable('x-powered-by'); 56 | app.use(express.logger('dev')); 57 | 58 | configureOAuth(express, app, config); 59 | 60 | app.get('/config.js', kibanaConfig); 61 | 62 | configureESProxy(app, config.es_host, config.secure, config.es_port, 63 | config.es_username, config.es_password, config.others); 64 | 65 | var kibanaPath = 'kibana-build'; 66 | if (fs.existsSync('kibana/src')) { 67 | kibanaPath = 'kibana/src'; 68 | } else if (fs.existsSync('kibana/index.html')) { 69 | kibanaPath = 'kibana'; 70 | } 71 | 72 | app.use('/', express.static(__dirname + '/' + kibanaPath)); 73 | server = app.listen(config.port, "0.0.0.0", function() { 74 | console.log('server listening on ' + config.port); 75 | }); 76 | 77 | // a basic healchckeck for a load balancer 78 | app.get('/healthcheck', function(req, res){ 79 | res.json({ "status": "ok" }); 80 | }); 81 | } 82 | 83 | function parseESURL(esurl, config) { 84 | var urlP = parseUrl(esurl); 85 | config.es_host = urlP.hostname; 86 | 87 | var secure = urlP.protocol === 'https:'; 88 | if (urlP.port !== null && urlP.port !== undefined) { 89 | config.es_port = urlP.port; 90 | } else if (secure) { 91 | config.es_port = '443'; 92 | } else { 93 | config.es_port = '80'; 94 | } 95 | config.secure = secure; 96 | if (urlP.auth) { 97 | var toks = urlP.auth.split(':'); 98 | config.es_username = toks[0]; 99 | config.es_password = toks[1] || ''; 100 | } 101 | } 102 | 103 | function kibanaConfig(request, response) { 104 | //returns the javascript that is the config object. for kibana. 105 | var responseBody = [ 106 | "define(['settings'],", 107 | "function (Settings) {", 108 | ' "use strict";', 109 | " return new Settings({", 110 | ' elasticsearch: "//" + window.location.hostname + ( window.location.port ? ":" + window.location.port : "") + "/__es",', 111 | " default_route : '/dashboard/file/default.json',", 112 | ' kibana_index: "kibana-int",', 113 | " panel_names: [ 'histogram', 'map', 'goal', 'table', 'filtering', 'timepicker', 'text', 'hits', 'column', 'trends', 'bettermap', 'query', 'terms', 'stats', 'sparklines' ]", 114 | " });", 115 | "});" 116 | ].join("\n"); 117 | response.setHeader('Content-Type', 'application/javascript'); 118 | response.end(responseBody); 119 | } 120 | -------------------------------------------------------------------------------- /lib/es-proxy.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Proxies the request to elasticsearch 3 | * node-http-proxy worked really well until it met elasticsearch deployed on cloudfoundry 4 | * hence this small proxy and naive proxy based on: 5 | * http://www.catonmat.net/http-proxy-in-nodejs/ 6 | */ 7 | var http = require('http'); 8 | var https = require('https'); 9 | 10 | function proxyRequest(request, response, host, esSecure, port, user, password, getProxiedRequestPath, isUI) { 11 | if (redirects(isUI, request, response)) { 12 | return; 13 | } 14 | var filteredHeaders = {}; 15 | Object.keys(request.headers).forEach(function(header) { 16 | if (header === 'host') { 17 | //most necessary: 18 | filteredHeaders[header] = host; 19 | } else if (header !== 'cookie' && 20 | (isUI === true || (header !== 'referer' && 21 | header !== 'user-agent' && header !== 'accept-language'))) { 22 | //avoid leaking unecessay info and save some room 23 | filteredHeaders[header] = request.headers[header]; 24 | } 25 | }); 26 | if (user) { 27 | var auth = 'Basic ' + new Buffer(user + ':' + password).toString('base64'); 28 | filteredHeaders.authorization = auth; 29 | } 30 | 31 | var options = { 32 | path: getProxiedRequestPath(request), 33 | method: request.method, 34 | hostname: host, 35 | port: port, 36 | headers: filteredHeaders 37 | }; 38 | if (user) { 39 | options.auth = password ? user + ':' + password : user; 40 | } 41 | //console.log('headers', filteredHeaders); 42 | var proxyReq = esSecure ? https.request(options) : http.request(options); 43 | 44 | proxyReq.addListener('response', function(proxyResp) { 45 | var http10 = request.httpVersionMajor === 1 && request.httpVersionMinor < 1; 46 | if(http10 && proxyResp.headers['transfer-encoding'] !== undefined){ 47 | //filter headers 48 | var headers = proxyResp.headers; 49 | delete proxyResp.headers['transfer-encoding']; 50 | var buffer = ""; 51 | 52 | //buffer answer 53 | proxyResp.addListener('data', function(chunk) { 54 | buffer += chunk; 55 | }); 56 | proxyResp.addListener('end', function() { 57 | headers['Content-length'] = buffer.length;//cancel transfer encoding "chunked" 58 | response.writeHead(proxyResp.statusCode, headers); 59 | response.write(buffer, 'binary'); 60 | response.end(); 61 | }); 62 | } else { 63 | //send headers as received 64 | response.writeHead(proxyResp.statusCode, proxyResp.headers); 65 | 66 | //easy data forward 67 | proxyResp.addListener('data', function(chunk) { 68 | response.write(chunk, 'binary'); 69 | }); 70 | proxyResp.addListener('end', function() { 71 | response.end(); 72 | }); 73 | } 74 | }); 75 | 76 | //proxies to SEND request to real server 77 | request.addListener('data', function(chunk) { 78 | proxyReq.write(chunk, 'binary'); 79 | }); 80 | request.addListener('end', function() { 81 | proxyReq.end(); 82 | }); 83 | } 84 | 85 | /** 86 | * Detect our favorite ES plugins index request and redirect to the 87 | * Properly connected endpoint. 88 | * 89 | * @return true if a redirection took place 90 | * @return false otherwise 91 | * 92 | * Q: Are we getting out of scope? 93 | * A: Yes a bit. Should we rename this Ze ES Proxy? 94 | */ 95 | function redirects(isUI, request, response) { 96 | if (isUI) { 97 | if (request.originalUrl === '/_plugin/head/') { 98 | //index.html?base_uri=http://node-01.example.com:9200 99 | var initHead = request.originalUrl + 'index.html?base_uri=' + request.protocol + 100 | '://' + request.headers.host + '/__es'; 101 | response.redirect(initHead); 102 | return true; 103 | } else if (request.originalUrl === '/_plugin/bigdesk/') { 104 | // index.html?endpoint=http%3A%2F%2F127.0.0.1%3A9201&refresh=3000&connect=true 105 | var initBigdesk = request.originalUrl + 'index.html?connect=true&endpoint=' + request.protocol + 106 | '://' + request.headers.host + '/__es'; 107 | response.redirect(initBigdesk); 108 | return true; 109 | } 110 | } 111 | return false; 112 | } 113 | 114 | exports.configureESProxy = function(app, esHost, esSecure, esPort, esUser, esPassword, others) { 115 | app.use("/__es", function(request, response, next) { 116 | proxyRequest(request, response, esHost, esSecure, esPort, esUser, esPassword, 117 | function getProxiedRequestPath(request) { 118 | return request.url; 119 | }); 120 | }); 121 | app.use("/_plugin", function(request, response, next) { 122 | proxyRequest(request, response, esHost, esSecure, esPort, esUser, esPassword, 123 | function getProxiedRequestPath(request) { 124 | return request.originalUrl; 125 | }, true); 126 | }); 127 | if (others) { 128 | Object.keys(others).forEach(function(k) { 129 | var other = others[k]; 130 | var prox = function(request, response, next) { 131 | proxyRequest(request, response, other.host, other.secure, other.port, other.user, other.password, 132 | function getProxiedRequestPath(request) { 133 | return request.originalUrl; 134 | }, true); 135 | }; 136 | k.split(',').forEach(function(base) { 137 | app.use(base, prox); 138 | }); 139 | }); 140 | } 141 | }; 142 | 143 | /* 144 | Did not quite work with node-http-proxy. 145 | works very well until we point it at elasiticsearch deployed on cloudfoundry... 146 | 147 | function configureESProxy(app, esHost, esPort, esUser, esPassword) { 148 | var httpProxy = require('http-proxy'); 149 | var proxy = new httpProxy.RoutingProxy({changeOrigin: true, enable: {xforward: false}}); 150 | app.use("/__es", function(req, res, next) { 151 | var buffer = httpProxy.buffer(req); 152 | if (esUser) { 153 | var auth = 'Basic ' + new Buffer(esUser + ':' + esPassword).toString('base64'); 154 | req.headers.authorization = auth; 155 | } 156 | delete req.headers.cookie; 157 | proxy.proxyRequest(req, res, { 158 | host: esHost, 159 | port: esPort, 160 | buffer: buffer 161 | }); 162 | }); 163 | //Allow observer to modify headers or abort response: 164 | // proxy.on('proxyResponse', function (req, res, response) { 165 | // console.log("[proxyResponse]", response); 166 | // }); 167 | // proxy.on('proxyError', function () { 168 | // console.log("[proxyError]", arguments); 169 | // }); 170 | } 171 | 172 | */ 173 | 174 | -------------------------------------------------------------------------------- /lib/google-oauth.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Configure google oauth passport's. 3 | * Config parameters: 4 | * - appID: the application ID 5 | * - appSecret: the applicatin secrete 6 | * - authorizedEmails: a comma separated listed of patterns 7 | * each pattern can be '*': anything, 8 | * '*@domain': any email in the domain 9 | * 'an@email': a specific email. 10 | * When no appID, no oauth config takes place. 11 | */ 12 | exports.configureOAuth = function(express, app, config) { 13 | if (!config.appID) { 14 | console.log('not setup of google oauth'); 15 | return; 16 | } 17 | 18 | var validateUser = function(passportProfile) { 19 | var validEmail; 20 | passportProfile.emails.some(function(email) { 21 | email = email.value; 22 | config.authorizedEmails.some(function(patt) { 23 | if (patt === email) { 24 | validEmail = email; 25 | return true; 26 | } 27 | if (patt === '*') { 28 | validEmail = email; 29 | return true; 30 | } 31 | if ('*' + email.slice(email.indexOf('@')) === patt) { 32 | validEmail = email; 33 | return true; 34 | } 35 | }); 36 | if (validEmail) { 37 | return true; 38 | } 39 | }); 40 | return validEmail; 41 | }; 42 | 43 | var GoogleStrategy = require('passport-google-oauth').OAuth2Strategy; 44 | var passport = require('passport'); 45 | var scope = config.scope || [ 'https://www.googleapis.com/auth/userinfo.email' ]; 46 | 47 | var passportIsSet = false; 48 | 49 | var lazySetupPassport = function(req) { 50 | passportIsSet = true; 51 | 52 | var protocol = (req.connection.encrypted || req.headers['x-forwarded-proto'] === "https" ) ? "https" : "http"; 53 | 54 | //not doing anything with this: 55 | //it will try to serialize the users in the session. 56 | passport.serializeUser(function(user, done) { 57 | done(null, user); 58 | }); 59 | passport.deserializeUser(function(obj, done) { 60 | done(null, obj); 61 | }); 62 | 63 | var callbackUrl = protocol + "://" + req.headers.host + "/auth/google/callback"; 64 | passport.use(new GoogleStrategy({ 65 | clientID: config.appID, clientSecret: config.appSecret, callbackURL: callbackUrl 66 | }, function(accessToken, refreshToken, profile, done) { 67 | var validEmail = validateUser(profile); 68 | if (!validEmail) { 69 | done(null, false, { message: 'not an authorized email ' + profile.emails[0] }); 70 | } else { 71 | done(null, profile); 72 | } 73 | })); 74 | 75 | app.get('/auth/google', passport.authenticate('google' 76 | , { scope: scope, 'approvalPrompt': 'force'/*, 'accessType': 'offline'*/ }) 77 | , function(req, res) { 78 | // The request will be redirected to Google for authentication, so 79 | // this function will not be called. 80 | }); 81 | 82 | app.get('/auth/google/callback', passport.authenticate('google' 83 | , { failureRedirect: req.session.beforeLoginURL || '/' }) 84 | , function(req, res) { 85 | // Successful authentication, redirect home. 86 | req.session.authenticated = true; 87 | res.redirect(req.session.beforeLoginURL || '/'); 88 | }); 89 | 90 | }; 91 | 92 | app.use(express.bodyParser()); 93 | app.use(require('connect-restreamer')()); 94 | app.use(express.cookieParser()); 95 | //replacement for bodyParser that is compatible with htt-proxy if we must have one (but we don't) 96 | app.use(express.session({ secret: 'dccUrr11d$#≈cdabsc' })); 97 | app.use(function(req, res, next) { 98 | if (req.isAuthenticated() || 99 | req.url.indexOf('/auth/google') === 0 || 100 | req.session.authenticated || 101 | req.url === '/healthcheck') { 102 | return next(); 103 | } 104 | if (!passportIsSet) { 105 | lazySetupPassport(req); 106 | } 107 | 108 | req.session.beforeLoginURL = req.url; 109 | res.redirect('/auth/google'); 110 | }); 111 | app.use(passport.initialize()); 112 | 113 | }; 114 | -------------------------------------------------------------------------------- /manifest-example.yml: -------------------------------------------------------------------------------- 1 | --- 2 | applications: 3 | - name: kibana-on-cloudfoundry 4 | framework: node 5 | runtime: node10 6 | memory: 64M 7 | instances: 1 8 | url: kibana-on-cloudfoundry.cfapps.io 9 | command: node app 10 | path: . 11 | env: 12 | ES_URL: 'http://user:password@your-elasticsearch.cfapps.io' 13 | APP_ID: '' 14 | APP_SECRET: '' 15 | AUTHORIZED_EMAILS: '*@sutoiku.com,hugues@stoic.cc' 16 | 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "kibana-proxy", 3 | "version": "0.0.0", 4 | "description": "Server tier for (kibana)[http://kibana.org/kibana3]; Proxies the calls to Elasticsearch and insert authentication.", 5 | "main": "app.js", 6 | "scripts": { 7 | "install": "[ -f kibana/index.html ] && exit 0; mkdir -p kibana; curl https://download.elasticsearch.org/kibana/kibana/kibana-3.1.2.tar.gz | tar xvzf - -C kibana --strip-components=1", 8 | "start": "node app", 9 | "test": "" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "http://github.com/hmalphettes/kibana-proxy" 14 | }, 15 | "keywords": [ 16 | "kibana", 17 | "elasticsearch" 18 | ], 19 | "author": "Hugues Malphettes", 20 | "license": "MIT", 21 | "copyright": "Sutoiku", 22 | "dependencies": { 23 | "express": "3.4.8", 24 | "passport": "*", 25 | "passport-google-oauth": "*", 26 | "connect-restreamer": "*" 27 | }, 28 | "devDependencies": { 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/hmalphettes/kibana-proxy/issues" 32 | } 33 | } 34 | --------------------------------------------------------------------------------