├── test ├── error │ ├── 404.html │ └── error.html ├── jasmine.json ├── store_spec.js ├── trie_spec.js ├── api_spec.js └── proxy_spec.js ├── index.js ├── doc ├── _static │ └── chp.png └── rest-api.yml ├── .jshintrc ├── .gitignore ├── Dockerfile ├── lib ├── error │ ├── error.html │ ├── 404.html │ └── 503.html ├── trie.js ├── store.js ├── testutil.js └── configproxy.js ├── .travis.yml ├── .bumpversion.cfg ├── package.json ├── CHANGELOG.md ├── COPYING.md ├── README.md └── bin └── configurable-http-proxy /test/error/404.html: -------------------------------------------------------------------------------- 1 | 404'D! 2 | -------------------------------------------------------------------------------- /test/error/error.html: -------------------------------------------------------------------------------- 1 | UNKNOWN ERROR 2 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./lib/configproxy.js'); 2 | -------------------------------------------------------------------------------- /doc/_static/chp.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/floydhub/configurable-http-proxy/master/doc/_static/chp.png -------------------------------------------------------------------------------- /test/jasmine.json: -------------------------------------------------------------------------------- 1 | { 2 | "spec_dir": "test", 3 | "stopSpecOnExpectationFailure": false, 4 | "spec_files": ["store_spec.js"] 5 | } 6 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "eqeqeq": true, 3 | "devel": true, 4 | "forin": true, 5 | "latedef": true, 6 | "node": true, 7 | "undef": true 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | node_modules 3 | *.py[co] 4 | *~ 5 | .DS_Store 6 | /configurable-http-proxy 7 | bench/env 8 | bench/results 9 | bench/html 10 | coverage 11 | dist 12 | .nyc_output 13 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:5-slim 2 | 3 | EXPOSE 8000 4 | 5 | ADD . /srv/configurable-http-proxy 6 | WORKDIR /srv/configurable-http-proxy 7 | RUN npm install -g 8 | 9 | USER nobody 10 | 11 | ENTRYPOINT ["configurable-http-proxy"] 12 | -------------------------------------------------------------------------------- /lib/error/error.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
4 | 5 |configurable-http-proxy
12 | 13 | 14 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - "6" 5 | - "5" 6 | - "4" 7 | install: 8 | - npm install -g codecov 9 | - npm install 10 | script: 11 | - npm run -s jshint 12 | - travis_retry npm test 13 | after_success: 14 | - npm run codecov 15 | -------------------------------------------------------------------------------- /lib/error/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |No service is registered at this URL
11 |configurable-http-proxy
13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/error/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 |The upstream service is unavailable
11 |configurable-http-proxy
13 | 14 | 15 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.0.0-dev 3 | parse = (?P.html` (where CODE is the status code number)
220 | for an html page to serve, e.g. `404.html` or `503.html`.
221 |
222 | If no file exists for the error code, `error.html` file will be used.
223 | If you specify an error path, make sure you also create `error.html`.
224 |
225 | ### error-target
226 |
227 | When starting the CHP, you can pass a command line option for `--error-target`.
228 | If you specify `--error-target http://localhost:1234`,
229 | then when the proxy encounters an error, it will make a GET request to
230 | this server, with URL `/CODE`, and the URL of the failing request
231 | escaped in a URL parameter, e.g.:
232 |
233 | GET /404?url=%2Fescaped%2Fpath
234 |
235 |
236 | ## Host-based routing
237 |
238 | If the CHP is started with the `--host-routing` option, the proxy will
239 | pick a target based on the host of the incoming request, instead of the
240 | URL prefix.
241 |
242 | The API when using host-based routes is the same as if the hostname were the
243 | first part of the URL path, e.g.:
244 |
245 | ```python
246 | {
247 | "/example.com": "https://localhost:1234",
248 | "/otherdomain.biz": "http://10.0.1.4:5555",
249 | }
250 | ```
251 |
--------------------------------------------------------------------------------
/bin/configurable-http-proxy:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 | //
3 | // cli entrypoint for starting a Configurable Proxy
4 | //
5 | // Copyright (c) Jupyter Development Team.
6 | // Distributed under the terms of the Modified BSD License.
7 | //
8 |
9 | var fs = require('fs'),
10 | pkg = require('../package.json'),
11 | args = require('commander'),
12 | strftime = require('strftime'),
13 | tls = require('tls'),
14 | log = require('winston');
15 |
16 | args
17 | .version(pkg.version)
18 | .option('--ip ', 'Public-facing IP of the proxy')
19 | .option('--port (defaults to 8000)', 'Public-facing port of the proxy', parseInt)
20 | .option('--ssl-key ', 'SSL key to use, if any')
21 | .option('--ssl-cert ', 'SSL certificate to use, if any')
22 | .option('--ssl-ca ', 'SSL certificate authority, if any')
23 | .option('--ssl-request-cert', 'Request SSL certs to authenticate clients')
24 | .option('--ssl-reject-unauthorized', 'Reject unauthorized SSL connections (only meaningful if --ssl-request-cert is given)')
25 | .option('--ssl-protocol ', 'Set specific SSL protocol, e.g. TLSv1.2, SSLv3')
26 | .option('--ssl-ciphers ', '`:`-separated ssl cipher list. Default excludes RC4')
27 | .option('--ssl-allow-rc4', 'Allow RC4 cipher for SSL (disabled by default)')
28 | .option('--ssl-dhparam ', 'SSL Diffie-Helman Parameters pem file, if any')
29 | .option('--api-ip ', 'Inward-facing IP for API requests', 'localhost')
30 | .option('--api-port ', 'Inward-facing port for API requests (defaults to --port=value+1)', parseInt)
31 | .option('--api-ssl-key ', 'SSL key to use, if any, for API requests')
32 | .option('--api-ssl-cert ', 'SSL certificate to use, if any, for API requests')
33 | .option('--api-ssl-ca ', 'SSL certificate authority, if any, for API requests')
34 | .option('--api-ssl-request-cert', 'Request SSL certs to authenticate clients for API requests')
35 | .option('--api-ssl-reject-unauthorized', 'Reject unauthorized SSL connections (only meaningful if --api-ssl-request-cert is given)')
36 | .option('--default-target ', 'Default proxy target (proto://host[:port])')
37 | .option('--error-target ', 'Alternate server for handling proxy errors (proto://host[:port])')
38 | .option('--error-path ', 'Alternate server for handling proxy errors (proto://host[:port])')
39 | .option('--redirect-port ', 'Redirect HTTP requests on this port to the server on HTTPS')
40 | .option('--pid-file ', 'Write our PID to a file')
41 | // passthrough http-proxy options
42 | .option('--no-x-forward', "Don't add 'X-forward-' headers to proxied requests")
43 | .option('--no-prepend-path', "Avoid prepending target paths to proxied requests")
44 | .option('--no-include-prefix', "Don't include the routing prefix in proxied requests")
45 | .option('--auto-rewrite', "Rewrite the Location header host/port in redirect responses")
46 | .option('--protocol-rewrite ', "Rewrite the Location header protocol in redirect responses to the specified protocol")
47 | .option('--insecure', "Disable SSL cert verification")
48 | .option('--host-routing', "Use host routing (host as first level of path)")
49 | .option('--statsd-host ', 'Host to send statsd statistics to')
50 | .option('--statsd-port ', 'Port to send statsd statistics to', parseInt)
51 | .option('--statsd-prefix ', 'Prefix to use for statsd statistics')
52 | .option('--log-level ', 'Log level (debug, info, warn, error)', 'info')
53 | .option('--proxy-timeout ', 'Timeout (in millis) when proxy receives no response from target.', parseInt);
54 |
55 | args.parse(process.argv);
56 |
57 | log.remove(log.transports.Console);
58 | log.add(log.transports.Console, {
59 | colorize: (process.stdout.isTTY && process.stderr.isTTY),
60 | level: args.logLevel.toLowerCase(),
61 | timestamp: function () {
62 | return strftime("%H:%M:%S.%L", new Date());
63 | },
64 | label: 'ConfigProxy',
65 | });
66 |
67 | var ConfigurableProxy = require('../lib/configproxy.js').ConfigurableProxy;
68 |
69 | var options = {};
70 |
71 | var ssl_ciphers;
72 | if (args.sslCiphers) {
73 | ssl_ciphers = args.sslCiphers;
74 | } else {
75 | var rc4 = "!RC4"; // disable RC4 by default
76 | if (args.sslAllowRc4) { // autoCamelCase is duMb
77 | rc4 = "RC4";
78 | }
79 | // ref: https://iojs.org/api/tls.html#tls_modifying_the_default_tls_cipher_suite
80 | ssl_ciphers = [
81 | "ECDHE-RSA-AES128-GCM-SHA256",
82 | "ECDHE-ECDSA-AES128-GCM-SHA256",
83 | "ECDHE-RSA-AES256-GCM-SHA384",
84 | "ECDHE-ECDSA-AES256-GCM-SHA384",
85 | "DHE-RSA-AES128-GCM-SHA256",
86 | "ECDHE-RSA-AES128-SHA256",
87 | "DHE-RSA-AES128-SHA256",
88 | "ECDHE-RSA-AES256-SHA384",
89 | "DHE-RSA-AES256-SHA384",
90 | "ECDHE-RSA-AES256-SHA256",
91 | "DHE-RSA-AES256-SHA256",
92 | "HIGH",
93 | rc4,
94 | "!aNULL",
95 | "!eNULL",
96 | "!EXPORT",
97 | "!DES",
98 | "!RC4",
99 | "!MD5",
100 | "!PSK",
101 | "!SRP",
102 | "!CAMELLIA",
103 | ].join(':');
104 | }
105 |
106 | // ssl options
107 | if (args.sslKey || args.sslCert) {
108 | options.ssl = {};
109 | if (args.sslKey) {
110 | options.ssl.key = fs.readFileSync(args.sslKey);
111 | }
112 | if (args.sslCert) {
113 | options.ssl.cert = fs.readFileSync(args.sslCert);
114 | }
115 | if (args.sslCa) {
116 | options.ssl.ca = fs.readFileSync(args.sslCa);
117 | }
118 | if (args.sslDhparam) {
119 | options.ssl.dhparam = fs.readFileSync(args.sslDhparam);
120 | }
121 | if (args.sslProtocol) {
122 | options.ssl.secureProtocol = args.sslProtocol + '_method';
123 | }
124 | options.ssl.ciphers = ssl_ciphers;
125 | options.ssl.honorCipherOrder = true;
126 | options.ssl.requestCert = args.sslRequestCert;
127 | options.ssl.rejectUnauthorized = args.sslRejectUnauthorized;
128 | }
129 |
130 | // ssl options for the API interface
131 | if (args.apiSslKey || args.apiSslCert) {
132 | options.api_ssl = {};
133 | if (args.apiSslKey) {
134 | options.api_ssl.key = fs.readFileSync(args.apiSslKey);
135 | }
136 | if (args.apiSslCert) {
137 | options.api_ssl.cert = fs.readFileSync(args.apiSslCert);
138 | }
139 | if (args.apiSslCa) {
140 | options.api_ssl.ca = fs.readFileSync(args.apiSslCa);
141 | }
142 | if (args.sslDhparam) {
143 | options.api_ssl.dhparam = fs.readFileSync(args.sslDhparam);
144 | }
145 | if (args.sslProtocol) {
146 | options.api_ssl.secureProtocol = args.sslProtocol + '_method';
147 | }
148 | options.api_ssl.ciphers = ssl_ciphers;
149 | options.api_ssl.honorCipherOrder = true;
150 | options.api_ssl.requestCert = args.apiSslRequestCert;
151 | options.api_ssl.rejectUnauthorized = args.apiSslRejectUnauthorized;
152 | }
153 |
154 | // because camelCase is the js way!
155 | options.default_target = args.defaultTarget;
156 | options.error_target = args.errorTarget;
157 | options.error_path = args.errorPath;
158 | options.host_routing = args.hostRouting;
159 | options.auth_token = process.env.CONFIGPROXY_AUTH_TOKEN;
160 | options.redirectPort = args.redirectPort;
161 | options.proxyTimeout = args.proxyTimeout;
162 |
163 | // statsd options
164 | if (args.statsdHost) {
165 | var lynx = require('lynx');
166 | options.statsd = new lynx(args.statsdHost, args.statsdPort || 8125, {
167 | scope: args.statsdPrefix || 'chp',
168 | });
169 | log.info('Sending metrics to statsd at ' + args.statsdHost + ':' + args.statsdPort || 8125);
170 | }
171 |
172 | // certs need to be provided for https redirection
173 | if (!options.ssl && options.redirectPort) {
174 | log.error("HTTPS redirection specified but certificates not provided.");
175 | process.exit(1);
176 | }
177 |
178 | if (options.error_target && options.error_path) {
179 | log.error("Cannot specify both error-target and error-path. Pick one.");
180 | process.exit(1);
181 | }
182 |
183 | // passthrough for http-proxy options
184 | if (args.insecure) options.secure = false;
185 | options.xfwd = args.xForward;
186 | options.prependPath = args.prependPath;
187 | options.includePrefix = args.includePrefix;
188 | if (args.autoRewrite) {
189 | options.autoRewrite = true;
190 | log.info("AutoRewrite of Location headers enabled.");
191 | }
192 |
193 | if (args.protocolRewrite) {
194 | options.protocolRewrite = args.protocolRewrite;
195 | log.info("ProtocolRewrite enabled. Rewriting to "+options.protocolRewrite);
196 | }
197 |
198 | if (!options.auth_token) {
199 | log.warn("REST API is not authenticated.");
200 | }
201 |
202 | var proxy = new ConfigurableProxy(options);
203 |
204 | var listen = {};
205 | listen.port = parseInt(args.port) || 8000;
206 | if (args.ip === '*') {
207 | // handle ip=* alias for all interfaces
208 | log.warn("Interpreting ip='*' as all-interfaces. Use 0.0.0.0 or ''.");
209 | args.ip = '';
210 | }
211 | listen.ip = args.ip;
212 | listen.api_ip = args.apiIp || 'localhost';
213 | listen.api_port = args.apiPort || listen.port + 1;
214 |
215 | proxy.proxy_server.listen(listen.port, listen.ip);
216 | proxy.api_server.listen(listen.api_port, listen.api_ip);
217 |
218 | log.info("Proxying %s://%s:%s to %s",
219 | options.ssl ? 'https' : 'http',
220 | (listen.ip || '*'), listen.port,
221 | options.default_target || "(no default)"
222 | );
223 | log.info("Proxy API at %s://%s:%s/api/routes",
224 | options.api_ssl ? 'https' : 'http',
225 | (listen.api_ip || '*'),
226 | listen.api_port);
227 |
228 | if (args.pidFile) {
229 | log.info("Writing pid %s to %s", process.pid, args.pidFile);
230 | var fd = fs.openSync(args.pidFile, 'w');
231 | fs.writeSync(fd, process.pid.toString());
232 | fs.closeSync(fd);
233 | process.on('exit', function () {
234 | log.debug("Removing %s", args.pidFile);
235 | fs.unlinkSync(args.pidFile);
236 | });
237 | }
238 |
239 | // Redirect HTTP to HTTPS on the proxy's port
240 | if (options.redirectPort && listen.port !== 80) {
241 | var http = require('http');
242 |
243 | http.createServer(function (req, res) {
244 | var host = req.headers.host.split(':')[0];
245 |
246 | // Make sure that when we redirect, it's to the port the proxy is running on
247 | if (listen.port !== 443) {
248 | host = host + ':' + listen.port;
249 | }
250 | res.writeHead(301, { "Location": "https://" + host + req.url });
251 | res.end();
252 | }).listen(options.redirectPort);
253 | }
254 |
255 | // trigger normal exit on sigint
256 | // without this, PID cleanup won't fire on SIGINT
257 | process.on('SIGINT', function () {
258 | log.warn("Interrupted");
259 | process.exit(2);
260 | });
261 |
262 | // log uncaught exceptions, don't exit now that setup is complete
263 | process.on('uncaughtException', function(e) {
264 | log.error('Uncaught Exception', e.stack);
265 | });
266 |
--------------------------------------------------------------------------------
/test/proxy_spec.js:
--------------------------------------------------------------------------------
1 | // jshint jasmine: true
2 |
3 | var path = require('path');
4 | var util = require('../lib/testutil');
5 | var request = require('request');
6 | var WebSocket = require('ws');
7 |
8 | var ConfigurableProxy = require('../lib/configproxy').ConfigurableProxy;
9 |
10 | describe("Proxy Tests", function () {
11 | var port = 8902;
12 | var test_port = port + 10;
13 | var proxy;
14 | var proxy_url = "http://127.0.0.1:" + port;
15 | var host_test = "test.127.0.0.1.xip.io";
16 | var host_url = "http://" + host_test + ":" + port;
17 |
18 | var r = request.defaults({
19 | method: 'GET',
20 | url: proxy_url,
21 | followRedirect: false,
22 | });
23 |
24 | beforeEach(function (callback) {
25 | util.setup_proxy(port, function (new_proxy) {
26 | proxy = new_proxy;
27 | callback();
28 | });
29 | });
30 |
31 | afterEach(function (callback) {
32 | util.teardown_servers(callback);
33 | });
34 |
35 | it("basic HTTP request", function (done) {
36 | r(proxy_url, function (error, res, body) {
37 | expect(error).toBe(null);
38 | expect(res.statusCode).toEqual(200);
39 | body = JSON.parse(body);
40 | expect(body).toEqual(jasmine.objectContaining({
41 | path: '/',
42 | }));
43 | done();
44 | });
45 | });
46 |
47 | it("basic WebSocker request", function (done) {
48 | var ws = new WebSocket('ws://127.0.0.1:' + port);
49 | ws.on('error', function () {
50 | // jasmine fail is only in master
51 | expect('error').toEqual('ok');
52 | done();
53 | });
54 | var nmsgs = 0;
55 | ws.on('message', function (msg) {
56 | if (nmsgs === 0) {
57 | expect(msg).toEqual('connected');
58 | } else {
59 | msg = JSON.parse(msg);
60 | expect(msg).toEqual(jasmine.objectContaining({
61 | path: '/',
62 | message: 'hi'
63 | }));
64 | ws.close();
65 | done();
66 | }
67 | nmsgs++;
68 | });
69 | ws.on('open', function () {
70 | ws.send('hi');
71 | });
72 | });
73 |
74 | it("proxy_request event can modify headers", function (done) {
75 | var called = {};
76 | proxy.on('proxy_request', function (req, res) {
77 | req.headers.testing = 'Test Passed';
78 | called.proxy_request = true;
79 | });
80 |
81 | r(proxy_url, function (error, res, body) {
82 | expect(error).toBe(null);
83 | expect(res.statusCode).toEqual(200);
84 | body = JSON.parse(body);
85 | expect(called.proxy_request).toBe(true);
86 | expect(body).toEqual(jasmine.objectContaining({
87 | path: '/',
88 | }));
89 | expect(body.headers).toEqual(jasmine.objectContaining({
90 | testing: 'Test Passed',
91 | }));
92 |
93 | done();
94 | });
95 | });
96 |
97 | it("target path is prepended by default", function (done) {
98 | util.add_target(proxy, '/bar', test_port, false, '/foo', function () {
99 | r(proxy_url + '/bar/rest/of/it', function (error, res, body) {
100 | expect(error).toBe(null);
101 | expect(res.statusCode).toEqual(200);
102 | body = JSON.parse(body);
103 | expect(body).toEqual(jasmine.objectContaining({
104 | path: '/bar',
105 | url: '/foo/bar/rest/of/it'
106 | }));
107 | done();
108 | });
109 | });
110 | });
111 |
112 | it("handle URI encoding", function (done) {
113 | util.add_target(proxy, '/b@r/b r', test_port, false, '/foo', function () {
114 | r(proxy_url + '/b%40r/b%20r/rest/of/it', function (error, res, body) {
115 | expect(error).toBe(null);
116 | expect(res.statusCode).toEqual(200);
117 | body = JSON.parse(body);
118 | expect(body).toEqual(jasmine.objectContaining({
119 | path: '/b@r/b r',
120 | url: '/foo/b%40r/b%20r/rest/of/it'
121 | }));
122 | done();
123 | });
124 | });
125 | });
126 |
127 | it("handle @ in URI same as %40", function (done) {
128 | util.add_target(proxy, '/b@r/b r', test_port, false, '/foo', function () {
129 | r(proxy_url + '/b@r/b%20r/rest/of/it', function (error, res, body) {
130 | expect(error).toBe(null);
131 | expect(res.statusCode).toEqual(200);
132 | body = JSON.parse(body);
133 | expect(body).toEqual(jasmine.objectContaining({
134 | path: '/b@r/b r',
135 | url: '/foo/b@r/b%20r/rest/of/it'
136 | }));
137 | done();
138 | });
139 | });
140 | });
141 |
142 | it("prependPath: false prevents target path from being prepended", function (done) {
143 | proxy.proxy.options.prependPath = false;
144 | util.add_target(proxy, '/bar', test_port, false, '/foo', function () {
145 | r(proxy_url + '/bar/rest/of/it', function (error, res, body) {
146 | expect(error).toBe(null);
147 | expect(res.statusCode).toEqual(200);
148 | body = JSON.parse(body);
149 | expect(body).toEqual(jasmine.objectContaining({
150 | path: '/bar',
151 | url: '/bar/rest/of/it'
152 | }));
153 | done();
154 | });
155 | });
156 | });
157 |
158 | it("includePrefix: false strips routing prefix from request", function (done) {
159 | proxy.includePrefix = false;
160 | util.add_target(proxy, '/bar', test_port, false, '/foo', function () {
161 | r(proxy_url + '/bar/rest/of/it', function (error, res, body) {
162 | expect(error).toBe(null);
163 | expect(res.statusCode).toEqual(200);
164 | body = JSON.parse(body);
165 | expect(body).toEqual(jasmine.objectContaining({
166 | path: '/bar',
167 | url: '/foo/rest/of/it'
168 | }));
169 | done();
170 | });
171 | });
172 | });
173 |
174 | it('options.default_target', function (done) {
175 | var options = {
176 | default_target: 'http://127.0.0.1:9001',
177 | };
178 |
179 | var cp = new ConfigurableProxy(options);
180 | cp._routes.get("/", function (route) {
181 | expect(route.target).toEqual("http://127.0.0.1:9001");
182 | done();
183 | });
184 | });
185 |
186 | it("includePrefix: false + prependPath: false", function (done) {
187 | proxy.includePrefix = false;
188 | proxy.proxy.options.prependPath = false;
189 | util.add_target(proxy, '/bar', test_port, false, '/foo', function() {
190 | r(proxy_url + '/bar/rest/of/it', function (error, res, body) {
191 | expect(error).toBe(null);
192 | expect(res.statusCode).toEqual(200);
193 | body = JSON.parse(body);
194 | expect(body).toEqual(jasmine.objectContaining({
195 | path: '/bar',
196 | url: '/rest/of/it'
197 | }));
198 | done();
199 | });
200 | });
201 | });
202 |
203 | it("hostRouting: routes by host", function(done) {
204 | proxy.host_routing = true;
205 | util.add_target(proxy, '/' + host_test, test_port, false, null, function () {
206 | r(host_url + '/some/path', function(error, res, body) {
207 | expect(error).toBe(null);
208 | expect(res.statusCode).toEqual(200);
209 | body = JSON.parse(body);
210 | expect(body).toEqual(jasmine.objectContaining({
211 | target: "http://127.0.0.1:" + test_port,
212 | url: '/some/path'
213 | }));
214 | done();
215 | });
216 | });
217 | });
218 |
219 | it("custom error target", function (done) {
220 | var port = 55555;
221 | var cb = function (proxy) {
222 | var url = 'http://127.0.0.1:' + port + '/foo/bar';
223 | r(url, function (error, res, body) {
224 | expect(error).toBe(null);
225 | expect(res.statusCode).toEqual(404);
226 | expect(res.headers['content-type']).toEqual('text/plain');
227 | expect(body).toEqual('/foo/bar');
228 | done();
229 | });
230 | };
231 |
232 | util.setup_proxy(port, cb, { error_target: "http://127.0.0.1:55565" }, []);
233 | });
234 |
235 | it("custom error path", function (done) {
236 | proxy.error_path = path.join(__dirname, 'error');
237 | proxy.remove_route('/', function () {
238 | proxy.add_route('/missing', { target: 'https://127.0.0.1:54321' }, function (route) {
239 | r(host_url + '/nope', function (error, res, body) {
240 | expect(error).toBe(null);
241 | expect(res.statusCode).toEqual(404);
242 | expect(res.headers['content-type']).toEqual('text/html');
243 | expect(body).toMatch(/404'D/);
244 | r(host_url + '/missing/prefix', function (error, res, body) {
245 | expect(error).toBe(null);
246 | expect(res.statusCode).toEqual(503);
247 | expect(res.headers['content-type']).toEqual('text/html');
248 | expect(body).toMatch(/UNKNOWN/);
249 | done();
250 | });
251 | });
252 | });
253 | });
254 | });
255 |
256 | it("default error html", function (done) {
257 | proxy.remove_route('/');
258 | proxy.add_route('/missing', { target: 'https://127.0.0.1:54321' }, function (route) {
259 | r(host_url + '/nope', function (error, res, body) {
260 | expect(error).toBe(null);
261 | expect(res.statusCode).toEqual(404);
262 | expect(res.headers['content-type']).toEqual('text/html');
263 | expect(body).toMatch(/404:/);
264 | r(host_url + '/missing/prefix', function (error, res, body) {
265 | expect(res.statusCode).toEqual(503);
266 | expect(res.headers['content-type']).toEqual('text/html');
267 | expect(body).toMatch(/503:/);
268 | done();
269 | });
270 | });
271 | });
272 | });
273 |
274 | it("Redirect location untouched without rewrite options", function (done) {
275 | var redirect_to = 'http://foo.com:12345/whatever';
276 | util.add_target_redirecting(proxy, '/external/urlpath/', test_port, '/internal/urlpath/', redirect_to);
277 | r(proxy_url + '/external/urlpath/rest/of/it', function (error, res, body) {
278 | expect(error).toBe(null);
279 | expect(res.statusCode).toEqual(301);
280 | expect(res.headers.location).toEqual(redirect_to);
281 | done();
282 | });
283 | });
284 |
285 | it("Redirect location with rewriting", function (done) {
286 | var proxy_port = 55555;
287 | var options = {
288 | protocolRewrite: "https",
289 | autoRewrite: true,
290 | };
291 |
292 | // where the backend server redirects us.
293 | // Note that http-proxy requires (logically) the redirection to be to the same (internal) host.
294 | var redirect_to = "http://127.0.0.1:"+test_port+"/whatever";
295 |
296 | var validation_callback = function (proxy) {
297 | util.add_target_redirecting(proxy, '/external/urlpath/', test_port, '/internal/urlpath/', redirect_to);
298 | var url = 'http://127.0.0.1:' + proxy_port;
299 |
300 | r(url + '/external/urlpath/', function (error, res, body) {
301 | expect(error).toBe(null);
302 | expect(res.statusCode).toEqual(301);
303 | expect(res.headers.location).toEqual("https://127.0.0.1:"+proxy_port+"/whatever");
304 | done();
305 | });
306 | };
307 |
308 | util.setup_proxy(proxy_port, validation_callback, options, []);
309 | });
310 | });
311 |
--------------------------------------------------------------------------------
/lib/configproxy.js:
--------------------------------------------------------------------------------
1 | // A Configurable node-http-proxy
2 | //
3 | // Copyright (c) Jupyter Development Team.
4 | // Distributed under the terms of the Modified BSD License.
5 | //
6 | // POST, DELETE to /api/routes[:/path/to/proxy] to update the routing table
7 | // GET /api/routes to see the current routing table
8 | //
9 |
10 | var http = require('http'),
11 | https = require('https'),
12 | fs = require('fs'),
13 | path = require('path'),
14 | EventEmitter = require('events').EventEmitter,
15 | httpProxy = require('http-proxy'),
16 | log = require('winston'),
17 | util = require('util'),
18 | URL = require('url'),
19 | querystring = require('querystring'),
20 | store = require('./store.js');
21 |
22 | function bound (that, method) {
23 | // bind a method, to ensure `this=that` when it is called
24 | // because prototype languages are bad
25 | return function () {
26 | method.apply(that, arguments);
27 | };
28 | }
29 |
30 | function arguments_array (args) {
31 | // cast arguments object to array, because Javascript.
32 | return Array.prototype.slice.call(args, 0);
33 | }
34 |
35 | function fail (req, res, code, msg) {
36 | // log a failure, and finish the HTTP request with an error code
37 | msg = msg || '';
38 | log.error("%s %s %s %s", code, req.method, req.url, msg);
39 | if (res.writeHead) res.writeHead(code);
40 | if (res.write) {
41 | if (!msg) {
42 | msg = http.STATUS_CODES[code];
43 | }
44 | res.write(msg);
45 | }
46 | if (res.end) res.end();
47 | }
48 |
49 | function json_handler (handler) {
50 | // wrap json handler, so the handler is called with parsed data,
51 | // rather than implementing streaming parsing in the handler itself
52 | return function (req, res) {
53 | var args = arguments_array(arguments);
54 | var buf = '';
55 | req.on('data', function (chunk) {
56 | buf += chunk;
57 | });
58 | req.on('end', function () {
59 | var data;
60 | try {
61 | data = JSON.parse(buf) || {};
62 | } catch (e) {
63 | fail(req, res, 400, "Body not valid JSON: " + e);
64 | return;
65 | }
66 | args.push(data);
67 | handler.apply(handler, args);
68 | });
69 | };
70 | }
71 |
72 | function authorized (method) {
73 | // decorator for token-authorized handlers
74 | return function (req, res) {
75 | if (req.url.indexOf("health") > 0) {
76 | return method.apply(this, arguments);
77 | }
78 | if (!this.auth_token) {
79 | return method.apply(this, arguments);
80 | }
81 | var match = (req.headers.authorization || '').match(/token\s+(\S+)/);
82 | var token;
83 | if (match !== null) {
84 | token = match[1];
85 | }
86 | if (token === this.auth_token) {
87 | return method.apply(this, arguments);
88 | } else {
89 | res.writeHead(403);
90 | res.end();
91 | }
92 | };
93 | }
94 |
95 | function parse_host (req) {
96 | var host = req.headers.host;
97 | if (host) {
98 | host = host.split(':')[0];
99 | }
100 | return host;
101 | }
102 |
103 | function ConfigurableProxy (options) {
104 | var that = this;
105 | this.options = options || {};
106 |
107 | this._routes = store.MemoryStore();
108 | this.auth_token = this.options.auth_token;
109 | this.includePrefix = options.includePrefix === undefined ? true : options.includePrefix;
110 | this.host_routing = this.options.host_routing;
111 | this.error_target = options.error_target;
112 | if (this.error_target && this.error_target.slice(-1) !== '/') {
113 | this.error_target = this.error_target + '/'; // ensure trailing /
114 | }
115 | this.error_path = options.error_path || path.join(__dirname, 'error');
116 | if (options.statsd) {
117 | this.statsd = options.statsd;
118 | } else {
119 | // Mock the statsd object, rather than pepper the codebase with
120 | // null checks. FIXME: Maybe use a JS Proxy object (if available?)
121 | this.statsd = {
122 | increment: function() {},
123 | decrement: function() {},
124 | timing: function() {},
125 | gauge: function() {},
126 | set: function() {},
127 | createTimer: function() {
128 | return {
129 | stop: function() {}
130 | };
131 | }
132 | };
133 | }
134 |
135 | if (this.options.default_target) {
136 | this.add_route('/', {
137 | target: this.options.default_target
138 | });
139 | }
140 | options.ws = true;
141 | var proxy = this.proxy = httpProxy.createProxyServer(options);
142 |
143 | // tornado-style regex routing,
144 | // because cross-language cargo-culting is always a good idea
145 |
146 | this.api_handlers = [
147 | [ /^\/api\/routes(\/.*)?$/, {
148 | get : bound(this, authorized(this.get_routes)),
149 | post : json_handler(bound(this, authorized(this.post_routes))),
150 | 'delete' : bound(this, authorized(this.delete_routes))
151 | } ]
152 | ];
153 |
154 | var log_errors = function (handler) {
155 | return function (req, res) {
156 | try {
157 | return handler.apply(that, arguments);
158 | } catch (e) {
159 | log.error("Error in handler for " +
160 | req.method + ' ' + req.url + ': ', e
161 | );
162 | }
163 | };
164 | };
165 |
166 | // handle API requests
167 | var api_callback = log_errors(that.handle_api_request);
168 | if ( this.options.api_ssl ) {
169 | this.api_server = https.createServer(this.options.api_ssl, api_callback);
170 | } else {
171 | this.api_server = http.createServer(api_callback);
172 | }
173 |
174 | // proxy requests separately
175 | var proxy_callback = log_errors(this.handle_proxy_web);
176 | if ( this.options.ssl ) {
177 | this.proxy_server = https.createServer(this.options.ssl, proxy_callback);
178 | } else {
179 | this.proxy_server = http.createServer(proxy_callback);
180 | }
181 | // proxy websockets
182 | this.proxy_server.on('upgrade', bound(this, this.handle_proxy_ws));
183 |
184 | this.proxy.on('proxyRes', function (proxyRes, req, res) {
185 | that.statsd.increment('requests.' + proxyRes.statusCode, 1);
186 | });
187 | }
188 |
189 | util.inherits(ConfigurableProxy, EventEmitter);
190 |
191 | ConfigurableProxy.prototype.add_route = function (path, data, cb) {
192 | // add a route to the routing table
193 | path = this._routes.cleanPath(path);
194 | if (this.host_routing && path !== '/') {
195 | data.host = path.split('/')[1];
196 | }
197 |
198 | var that = this;
199 |
200 | this._routes.add(path, data, function () {
201 | that.update_last_activity(path, function () {
202 | if (typeof(cb) === "function") {
203 | cb();
204 | }
205 | });
206 | });
207 | };
208 |
209 | ConfigurableProxy.prototype.remove_route = function (path, cb) {
210 | // remove a route from the routing table
211 | var routes = this._routes;
212 |
213 | routes.hasRoute(path, function (result) {
214 | if (result) {
215 | routes.remove(path, cb);
216 | }
217 | });
218 | };
219 |
220 | ConfigurableProxy.prototype.get_routes = function (req, res) {
221 | // GET returns routing table as JSON dict
222 | var that = this;
223 | var parsed = URL.parse(req.url);
224 | var inactive_since = null;
225 | if (parsed.query) {
226 | var query = querystring.parse(parsed.query);
227 |
228 | if (query.inactive_since !== undefined) {
229 | var timestamp = Date.parse(query.inactive_since);
230 | if (isFinite(timestamp)) {
231 | inactive_since = new Date(timestamp);
232 | } else {
233 | fail(req, res, 400, "Invalid datestamp '" + query.inactive_since + "' must be ISO8601.");
234 | return;
235 | }
236 | }
237 | }
238 | res.writeHead(200, { 'Content-Type': 'application/json' });
239 | if (req.url.indexOf("health") > 0) {
240 | res.write("OK");
241 | res.end();
242 | return;
243 | }
244 |
245 | this._routes.getAll(function (routes) {
246 | var results = {};
247 |
248 | if (inactive_since) {
249 | Object.keys(routes).forEach(function (path) {
250 | if (routes[path].last_activity < inactive_since) {
251 | results[path] = routes[path];
252 | }
253 | });
254 | } else {
255 | results = routes;
256 | }
257 |
258 | res.write(JSON.stringify(results));
259 | res.end();
260 | that.statsd.increment('api.route.get', 1);
261 | });
262 | };
263 |
264 | ConfigurableProxy.prototype.post_routes = function (req, res, path, data) {
265 | // POST adds a new route
266 | path = path || '/';
267 | log.debug('POST', path, data);
268 |
269 | if (typeof data.target !== 'string') {
270 | log.warn("Bad POST data: %s", JSON.stringify(data));
271 | fail(req, res, 400, "Must specify 'target' as string");
272 | return;
273 | }
274 |
275 | var that = this;
276 | this.add_route(path, data, function () {
277 | res.writeHead(201);
278 | res.end();
279 | that.statsd.increment('api.route.add', 1);
280 | });
281 | };
282 |
283 | ConfigurableProxy.prototype.delete_routes = function (req, res, path) {
284 | // DELETE removes an existing route
285 | log.debug('DELETE', path);
286 |
287 | var that = this;
288 | this._routes.hasRoute(path, function (result) {
289 | if (result) {
290 | that.remove_route(path, function () {
291 | res.writeHead(204);
292 | res.end();
293 | that.statsd.increment('api.route.delete', 1);
294 | });
295 | } else {
296 | res.writeHead(404);
297 | res.end();
298 | that.statsd.increment('api.route.delete', 1);
299 | }
300 | });
301 | };
302 |
303 | ConfigurableProxy.prototype.target_for_req = function (req, cb) {
304 | var timer = this.statsd.createTimer('find_target_for_req');
305 | // return proxy target for a given url path
306 | var base_path = (this.host_routing) ? '/' + parse_host(req) : '';
307 |
308 | this._routes.getTarget(base_path + decodeURIComponent(req.url), function (route) {
309 | timer.stop();
310 | if (route) {
311 | cb({
312 | prefix: route.prefix,
313 | target: route.data.target
314 | });
315 | return;
316 | }
317 |
318 | cb(null);
319 | });
320 | };
321 |
322 | ConfigurableProxy.prototype.update_last_activity = function (prefix, cb) {
323 | var timer = this.statsd.createTimer('last_activity_updating');
324 | var routes = this._routes;
325 |
326 | routes.hasRoute(prefix, function (result) {
327 | cb = cb || function() {};
328 |
329 | if (result) {
330 | routes.update(prefix, { "last_activity": new Date() }, function () {
331 | timer.stop();
332 | cb();
333 | });
334 | } else {
335 | timer.stop();
336 | cb();
337 | }
338 | });
339 | };
340 |
341 | ConfigurableProxy.prototype._handle_proxy_error_default = function (code, kind, req, res) {
342 | // called when no custom error handler is registered,
343 | // or is registered and doesn't work
344 | if (res.writeHead) res.writeHead(code);
345 | if (res.write) res.write(http.STATUS_CODES[code]);
346 | if (res.end) res.end();
347 | };
348 |
349 | ConfigurableProxy.prototype.handle_proxy_error = function (code, kind, req, res) {
350 | // called when proxy itself has an error
351 | // so far, just 404 for no target and 503 for target not responding
352 | // custom error server gets `/CODE?url=/escaped_url/`, e.g.
353 | // /404?url=%2Fuser%2Ffoo
354 |
355 | var proxy = this;
356 | log.error("%s %s %s", code, req.method, req.url);
357 | this.statsd.increment('requests.' + code, 1);
358 | if (this.error_target) {
359 | var url_spec = URL.parse(this.error_target);
360 | url_spec.search = '?' + querystring.encode({url: req.url});
361 | url_spec.pathname = url_spec.pathname + code.toString();
362 | var url = URL.format(url_spec);
363 | var error_request = http.request(url, function (upstream) {
364 | ['content-type', 'content-encoding'].map(function (key) {
365 | if (!upstream.headers[key]) return;
366 | res.setHeader(key, upstream.headers[key]);
367 | });
368 | if (res.writeHead) res.writeHead(code);
369 | upstream.on('data', function (data) {
370 | if (res.write) res.write(data);
371 | });
372 | upstream.on('end', function () {
373 | if (res.end) res.end();
374 | });
375 | });
376 | error_request.on('error', function(e) {
377 | // custom error failed, fallback on default
378 | log.error("Failed to get custom error page", e);
379 | proxy._handle_proxy_error_default(code, kind, req, res);
380 | });
381 | error_request.end();
382 | } else if (this.error_path) {
383 | var filename = path.join(this.error_path, code.toString() + '.html');
384 | if (!fs.existsSync(filename)) {
385 | log.debug("No error file %s", filename);
386 | filename = path.join(this.error_path, 'error.html');
387 | if (!fs.existsSync(filename)) {
388 | log.error("No error file %s", filename);
389 | proxy._handle_proxy_error_default(code, kind, req, res);
390 | return;
391 | }
392 | }
393 | fs.readFile(filename, function (err, data) {
394 | if (err) {
395 | log.error("Error reading %s %s", filename, err);
396 | proxy._handle_proxy_error_default(code, kind, req, res);
397 | return;
398 | }
399 | if (res.writeHead) res.writeHead(code, {'Content-Type': 'text/html'});
400 | if (res.write) res.write(data);
401 | if (res.end) res.end();
402 | });
403 | } else {
404 | this._handle_proxy_error_default(code, kind, req, res);
405 | }
406 | };
407 |
408 | ConfigurableProxy.prototype.handle_proxy = function (kind, req, res) {
409 | // proxy any request
410 | var that = this;
411 | var args = Array.prototype.slice.call(arguments, 1);
412 |
413 | // get the proxy target
414 | this.target_for_req(req, function (match) {
415 | if (!match) {
416 | that.handle_proxy_error(404, kind, req, res);
417 | return;
418 | }
419 |
420 | that.emit("proxy_request", req, res);
421 | var prefix = match.prefix;
422 | var target = match.target;
423 | log.debug("prefix:", prefix, "|");
424 | log.debug("target:", target, "|");
425 | log.debug("PROXY", kind.toUpperCase(), req.url, "to", target);
426 |
427 | if (!that.includePrefix) {
428 | req.url = req.url.slice(prefix.length);
429 | }
430 |
431 | log.debug("req.url:", req.url, "|");
432 |
433 | // add config argument
434 | args.push({ target: target });
435 |
436 | // add error handling
437 | args.push(function (e) {
438 | log.error("Proxy error: ", e);
439 | that.handle_proxy_error(503, kind, req, res);
440 | });
441 |
442 | // update timestamp on any reply data as well (this includes websocket data)
443 | req.on('data', function () {
444 | that.update_last_activity(prefix);
445 | });
446 |
447 | res.on('data', function () {
448 | that.update_last_activity(prefix);
449 | });
450 |
451 | // update last activity timestamp in routing table
452 | that.update_last_activity(prefix, function () {
453 | // dispatch the actual method
454 | that.proxy[kind].apply(that.proxy, args);
455 | });
456 | });
457 | };
458 |
459 | ConfigurableProxy.prototype.handle_proxy_ws = function (req, res, head) {
460 | // Proxy a websocket request
461 | this.statsd.increment('requests.ws', 1);
462 | return this.handle_proxy('ws', req, res, head);
463 | };
464 |
465 | ConfigurableProxy.prototype.handle_proxy_web = function (req, res) {
466 | // Proxy a web request
467 | if (req.url.indexOf("health") > 0) {
468 | res.write("OK");
469 | res.end();
470 | return;
471 | }
472 | this.statsd.increment('requests.web', 1);
473 | return this.handle_proxy('web', req, res);
474 | };
475 |
476 | ConfigurableProxy.prototype.handle_api_request = function (req, res) {
477 | // Handle a request to the REST API
478 | this.statsd.increment('requests.api', 1);
479 | var args = [req, res];
480 | function push_path_arg (arg) {
481 | args.push(arg === undefined ? arg : decodeURIComponent(arg));
482 | }
483 | for (var i = 0; i < this.api_handlers.length; i++) {
484 | var pat = this.api_handlers[i][0];
485 | var match = pat.exec(URL.parse(req.url).pathname);
486 | if (match) {
487 | var handlers = this.api_handlers[i][1];
488 | var handler = handlers[req.method.toLowerCase()];
489 | if (!handler) {
490 | // 405 on found resource, but not found method
491 | fail(req, res, 405, "Method not supported.");
492 | return;
493 | }
494 | match.slice(1).forEach(push_path_arg);
495 | handler.apply(handler, args);
496 | return;
497 | }
498 | }
499 | fail(req, res, 404);
500 | };
501 |
502 | exports.ConfigurableProxy = ConfigurableProxy;
503 |
--------------------------------------------------------------------------------