├── 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 | Proxy Error 6 | 7 | 8 | 9 |

Proxy Error

10 |
11 |

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 | 404: Not Found 6 | 7 | 8 | 9 |

404: Not Found

10 |

No service is registered at this URL

11 |
12 |

configurable-http-proxy

13 | 14 | 15 | -------------------------------------------------------------------------------- /lib/error/503.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 503: Proxy Target Missing 6 | 7 | 8 | 9 |

503: Proxy Target Missing

10 |

The upstream service is unavailable

11 |
12 |

configurable-http-proxy

13 | 14 | 15 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 2.0.0-dev 3 | parse = (?P\d+)\.(?P\d+)\.(?P\d+)(\-(?P[a-z0-9]+))? 4 | tag_name = {new_version} 5 | allow_dirty = True 6 | commit = True 7 | tag = False 8 | serialize = 9 | {major}.{minor}.{patch}-{release} 10 | {major}.{minor}.{patch} 11 | 12 | [bumpversion:file:package.json] 13 | 14 | [bumpversion:part:release] 15 | optional_value = stable 16 | values = 17 | dev 18 | stable 19 | 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0-dev", 3 | "name": "configurable-http-proxy", 4 | "description": "A configurable-on-the-fly HTTP Proxy", 5 | "author": "Jupyter Developers", 6 | "license": "BSD-3-Clause", 7 | "repository": { 8 | "type": "git", 9 | "url": "https://github.com/jupyterhub/configurable-http-proxy.git" 10 | }, 11 | "dependencies": { 12 | "commander": "~2.9", 13 | "http-proxy": "~1.13.2", 14 | "lynx": "^0.2.0", 15 | "redis": "~2.6", 16 | "strftime": "~0.9", 17 | "winston": "~2.2" 18 | }, 19 | "devDependencies": { 20 | "jasmine": "^2.5.1", 21 | "jshint": "^2.9.2", 22 | "nyc": "^6.4.0", 23 | "request": "~2", 24 | "ws": "^1.1" 25 | }, 26 | "main": "index.js", 27 | "files": [ 28 | "index.js", 29 | "lib/configproxy.js", 30 | "lib/store.js", 31 | "lib/trie.js", 32 | "lib/error/*.html", 33 | "bin/configurable-http-proxy" 34 | ], 35 | "bin": { 36 | "configurable-http-proxy": "bin/configurable-http-proxy" 37 | }, 38 | "scripts": { 39 | "jshint": "jshint bin/ lib/ test/", 40 | "test": "nyc jasmine JASMINE_CONFIG_PATH=test/jasmine.json", 41 | "coverage-html": "nyc report --reporter=html", 42 | "codecov": "nyc report --reporter=lcov && codecov" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changes in configurable-http-proxy 2 | 3 | For detailed changes from the prior release, click on the version number, and 4 | its link will bring up a GitHub listing of changes. Use `git log` on the 5 | command line for details. 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.3] - 2016-08-01 10 | 11 | ### 1.3.1 12 | 13 | - small fixes for node 6 support 14 | - fix `--no-x-forward` again (for real, this time) 15 | 16 | ### 1.3.0 17 | 18 | - add `--ssl-protocol`, so that one can restrict to TLS, e.g. `--ssl-protocol=TLSv1` 19 | - fix handling of ``--no-x-forward` 20 | 21 | ## [1.2] - 2016-04-19 22 | 23 | - add statsd support 24 | 25 | ## [1.1] - 2016-01-04 26 | 27 | - add `--ssl-request-cert` args for certificate-based client authentication 28 | - fix some SSL parameters that were ignored for API requests 29 | 30 | ## [1.0] - 2016-01-04 31 | 32 | - add `ConfigProxy.proxy_request` event, for customizing requests as the pass through the proxy. 33 | - add more ssl-related options for specifying options on the CLI. 34 | - fix regression in 0.5 where deleting a top-level route would also delete the default route. 35 | 36 | ## [0.5] - 2015-10-05 37 | 38 | - add `--error-target` for letting another http server render error pages. 39 | Server must handle `/404` and `/503` URLs. 40 | - add `--error-path` for custom static HTML error pages. 41 | `[CODE].html` will be used if it exists, otherwise `error.html`. 42 | - fix bug preventing root route from being deleted 43 | 44 | ## [0.4] - 2015-10-02 45 | 46 | - add `--redirect-port` for automatically redirecting a common port to the correct one (e.g. redirecting http to https) 47 | 48 | ## [0.3] - 2015-04-29 49 | 50 | - fixes for URL escaping 51 | - add host-based routing 52 | 53 | ## [0.2.1] - 2014-11-21 54 | 55 | ## [0.2.0] - 2014-11-14 56 | 57 | ## 0.1.1 - 2014-10-01 58 | 59 | 60 | [Unreleased]: https://github.com/jupyterhub/configurable-http-proxy/compare/1.3.0...HEAD 61 | [1.3]: https://github.com/jupyterhub/configurable-http-proxy/compare/1.2.0...1.3.0 62 | [1.2]: https://github.com/jupyterhub/configurable-http-proxy/compare/1.1.0...1.2.0 63 | [1.1]: https://github.com/jupyterhub/configurable-http-proxy/compare/1.0.0...1.1.0 64 | [1.0]: https://github.com/jupyterhub/configurable-http-proxy/compare/0.5.0...1.0.0 65 | [0.5]: https://github.com/jupyterhub/configurable-http-proxy/compare/0.4.0...0.5.0 66 | [0.4]: https://github.com/jupyterhub/configurable-http-proxy/compare/0.3.0...0.4.0 67 | [0.3]: https://github.com/jupyterhub/configurable-http-proxy/compare/0.2.1...0.3.0 68 | [0.2.1]: https://github.com/jupyterhub/configurable-http-proxy/compare/0.2.0...0.2.1 69 | [0.2.0]: https://github.com/jupyterhub/configurable-http-proxy/compare/0.1.1...0.2.0 -------------------------------------------------------------------------------- /doc/rest-api.yml: -------------------------------------------------------------------------------- 1 | swagger: '2.0' 2 | info: 3 | title: Configurable HTTP Proxy 4 | description: The REST API for configurable-http-proxy. 5 | version: 1.1.0 6 | schemes: 7 | - http 8 | securityDefinitions: 9 | token: 10 | type: apiKey 11 | name: Authorization 12 | in: header 13 | security: 14 | - token: [] 15 | basePath: /api/ 16 | produces: 17 | - application/json 18 | paths: 19 | /routes/: 20 | get: 21 | summary: All routes 22 | description: All routes currently in the proxy's routing table, excluding the default route 23 | parameters: 24 | - name: inactive_since 25 | in: query 26 | description: Only return routes with last_activity before this time 27 | required: false 28 | type: string 29 | format: ISO8601 Timestamp 30 | responses: 31 | '200': 32 | description: The routing table 33 | schema: 34 | $ref: '#/definitions/RoutingTable' 35 | /routes/{route_spec}: 36 | post: 37 | summary: Create a new route 38 | parameters: 39 | - name: route_spec 40 | description: Route specification to create - a path prefix if the proxy is in path mode (default) or '/' followed by hostname if it is in host mode. 41 | in: path 42 | required: true 43 | type: string 44 | format: Path 45 | - name: body 46 | in: body 47 | schema: 48 | $ref: '#/definitions/RouteTarget' 49 | required: true 50 | responses: 51 | '201': 52 | description: Successfully created 53 | delete: 54 | summary: Delete the given route 55 | parameters: 56 | - name: route_spec 57 | in: path 58 | required: true 59 | type: string 60 | format: Path 61 | description: Route specification to create - a path prefix if the proxy is in path mode (default) or '/' followed by hostname if it is in host mode. 62 | responses: 63 | '204': 64 | description: Successfully deleted 65 | definitions: 66 | RoutingTable: 67 | type: object 68 | description: Maps keys (route path prefixes / hostnames) to their targets 69 | additionalProperties: 70 | $ref: '#/definitions/RouteTarget' 71 | RouteTarget: 72 | type: object 73 | properties: 74 | target: 75 | type: string 76 | format: URI 77 | description: 'Fully qualified URL that will be the target of proxied 78 | requests that match this route' 79 | last_activity: 80 | type: string 81 | format: ISO8601 Timestamp 82 | readOnly: true 83 | description: ISO8601 Timestamp of when this route was last used to route a request 84 | -------------------------------------------------------------------------------- /COPYING.md: -------------------------------------------------------------------------------- 1 | # Modified License 2 | 3 | configurable-http-proxy is licensed under the terms of the Modified BSD License 4 | (also known as New or Revised or 3-Clause BSD), as follows: 5 | 6 | - Copyright (c) 2014-, Jupyter Development Team 7 | 8 | All rights reserved. 9 | 10 | Redistribution and use in source and binary forms, with or without 11 | modification, are permitted provided that the following conditions are met: 12 | 13 | Redistributions of source code must retain the above copyright notice, this 14 | list of conditions and the following disclaimer. 15 | 16 | Redistributions in binary form must reproduce the above copyright notice, this 17 | list of conditions and the following disclaimer in the documentation and/or 18 | other materials provided with the distribution. 19 | 20 | Neither the name of the Jupyter Development Team nor the names of its 21 | contributors may be used to endorse or promote products derived from this 22 | software without specific prior written permission. 23 | 24 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 25 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 26 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 27 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE 28 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 29 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 30 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 31 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 32 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 33 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 34 | 35 | ## About the Jupyter Development Team 36 | 37 | The Jupyter Development Team is the set of all contributors to the Jupyter project. 38 | This includes all of the Jupyter subprojects. 39 | 40 | The core team that coordinates development on GitHub can be found here: 41 | https://github.com/jupyter/. 42 | 43 | ## Our Copyright Policy 44 | 45 | Jupyter uses a shared copyright model. Each contributor maintains copyright 46 | over their contributions to Jupyter. But, it is important to note that these 47 | contributions are typically only changes to the repositories. Thus, the Jupyter 48 | source code, in its entirety is not the copyright of any single person or 49 | institution. Instead, it is the collective copyright of the entire Jupyter 50 | Development Team. If individual contributors want to maintain a record of what 51 | changes/contributions they have specific copyright on, they should indicate 52 | their copyright in the commit message of the change, when they commit the 53 | change to one of the Jupyter repositories. 54 | 55 | With this in mind, the following banner should be used in any source code file 56 | to indicate the copyright and license terms: 57 | 58 | // Copyright (c) Jupyter Development Team. 59 | // Distributed under the terms of the Modified BSD License. 60 | -------------------------------------------------------------------------------- /lib/trie.js: -------------------------------------------------------------------------------- 1 | // A simple trie for URL prefix matching 2 | // 3 | // Copyright (c) Jupyter Development Team. 4 | // Distributed under the terms of the Modified BSD License. 5 | // 6 | // Store data at nodes in the trie with Trie.add("/path/", {data}) 7 | // 8 | // Get data for a prefix with Trie.get("/path/to/something/inside") 9 | // 10 | 11 | function trim_prefix (prefix) { 12 | // cleanup prefix form: /foo/bar 13 | // ensure prefix starts with / 14 | if (prefix.length === 0 || prefix[0] !== '/') { 15 | prefix = '/' + prefix; 16 | } 17 | // ensure prefix *doesn't* end with / (unless it's exactly /) 18 | if (prefix.length > 1 && prefix[prefix.length - 1] === '/') { 19 | prefix = prefix.substr(0, prefix.length - 1); 20 | } 21 | return prefix; 22 | } 23 | 24 | exports.trim_prefix = trim_prefix; 25 | 26 | function URLTrie (prefix) { 27 | // create a new URLTrie with data 28 | this.prefix = trim_prefix(prefix || '/'); 29 | this.branches = {}; 30 | this.size = 0; 31 | } 32 | 33 | var _slashes_re = /^[\/]+|[\/]+$/g; 34 | function string_to_path (s) { 35 | // turn a /prefix/string/ into ['prefix', 'string'] 36 | s = s.replace(_slashes_re, ""); 37 | if (s.length === 0) { 38 | // special case because ''.split() gives [''], which is wrong. 39 | return []; 40 | } else { 41 | return s.split('/'); 42 | } 43 | } 44 | 45 | exports.string_to_path = string_to_path; 46 | 47 | URLTrie.prototype.add = function (path, data) { 48 | // add data to a node in the trie at path 49 | if (typeof path === 'string') { 50 | path = string_to_path(path); 51 | } 52 | if (path.length === 0) { 53 | this.data = data; 54 | return; 55 | } 56 | var part = path.shift(); 57 | if (!this.branches.hasOwnProperty(part)) { 58 | // join with /, and handle the fact that only root ends with '/' 59 | var prefix = this.prefix.length === 1 ? this.prefix : this.prefix + '/'; 60 | this.branches[part] = new URLTrie(prefix + part); 61 | this.size += 1; 62 | } 63 | this.branches[part].add(path, data); 64 | }; 65 | 66 | URLTrie.prototype.remove = function (path) { 67 | // remove `path` from the trie 68 | if (typeof path === 'string') { 69 | path = string_to_path(path); 70 | } 71 | if (path.length === 0) { 72 | // allow deleting root 73 | delete this.data; 74 | return; 75 | } 76 | var part = path.shift(); 77 | var child = this.branches[part]; 78 | if (child === undefined) { 79 | // Requested node doesn't exist, 80 | // consider it already removed. 81 | return; 82 | } 83 | child.remove(path); 84 | if (child.size === 0 && child.data === undefined) { 85 | // child has no branches and is not a leaf 86 | delete this.branches[part]; 87 | this.size -= 1; 88 | } 89 | }; 90 | 91 | URLTrie.prototype.get = function (path) { 92 | // get the data stored at a matching prefix 93 | // returns: 94 | // { 95 | // prefix: "/the/matching/prefix", 96 | // data: {whatever: "was stored by add"} 97 | // } 98 | 99 | // if I have data, return me, otherwise return undefined 100 | var me = this.data === undefined ? undefined: this; 101 | 102 | if (typeof path === 'string') { 103 | path = string_to_path(path); 104 | } 105 | if (path.length === 0) { 106 | // exact match, it's definitely me! 107 | return me; 108 | } 109 | var part = path.shift(); 110 | var child = this.branches[part]; 111 | if (child === undefined) { 112 | // prefix matches, and I don't have any more specific children 113 | return me; 114 | } else { 115 | // I match and I have a more specific child that matches. 116 | // That *does not* mean that I have a more specific *leaf* that matches. 117 | var node = child.get(path); 118 | if (node) { 119 | // found a more specific leaf 120 | return node; 121 | } else { 122 | // I'm still the most specific match 123 | return me; 124 | } 125 | } 126 | }; 127 | 128 | exports.URLTrie = URLTrie; 129 | -------------------------------------------------------------------------------- /test/store_spec.js: -------------------------------------------------------------------------------- 1 | // jshint jasmine: true 2 | 3 | var store = require("../lib/store.js"); 4 | 5 | describe("MemoryStore", function () { 6 | beforeEach(function () { 7 | this.subject = store.MemoryStore(); 8 | }); 9 | 10 | describe("get", function () { 11 | it("returns the data for the specified path", function (done) { 12 | this.subject.add("/my_route", { "test": "value" }); 13 | 14 | this.subject.get("/my_route", function (data) { 15 | expect(data).toEqual({ "test": "value" }); 16 | done(); 17 | }); 18 | }); 19 | 20 | it("returns undefined when not found", function () { 21 | expect(this.subject.get("/wut")).toBe(undefined); 22 | }); 23 | }); 24 | 25 | describe("getTarget", function () { 26 | it("returns the target object for the path", function (done) { 27 | this.subject.add("/my_route", { "target": "http://localhost:8213" }); 28 | 29 | this.subject.getTarget("/my_route", function (target) { 30 | expect(target.prefix).toEqual("/my_route"); 31 | expect(target.data.target).toEqual("http://localhost:8213"); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | 37 | describe("getAll", function () { 38 | it("returns all routes", function (done) { 39 | this.subject.add("/my_route", { "test": "value1" }); 40 | this.subject.add("/my_other_route", { "test": "value2" }); 41 | 42 | this.subject.getAll(function (routes) { 43 | expect(Object.keys(routes).length).toEqual(2); 44 | expect(routes["/my_route"]).toEqual({ "test": "value1" }); 45 | expect(routes["/my_other_route"]).toEqual({ "test": "value2" }); 46 | done(); 47 | }); 48 | }); 49 | 50 | it("returns a blank object when no routes defined", function (done) { 51 | this.subject.getAll(function (routes) { 52 | expect(routes).toEqual({}); 53 | done(); 54 | }); 55 | }); 56 | }); 57 | 58 | describe("add", function () { 59 | it("adds data to the store for the specified path", function (done) { 60 | this.subject.add("/my_route", { "test": "value" }); 61 | 62 | this.subject.get("/my_route", function (route) { 63 | expect(route).toEqual({ "test": "value" }); 64 | done(); 65 | }); 66 | }); 67 | 68 | it("overwrites any existing values", function (done) { 69 | this.subject.add("/my_route", { "test": "value" }); 70 | this.subject.add("/my_route", { "test": "updatedValue" }); 71 | 72 | this.subject.get("/my_route", function (route) { 73 | expect(route).toEqual({ "test": "updatedValue" }); 74 | done(); 75 | }); 76 | }); 77 | }); 78 | 79 | describe("update", function () { 80 | it("merges supplied data with existing data", function (done) { 81 | this.subject.add("/my_route", { "version": 1, "test": "value" }); 82 | this.subject.update("/my_route", { "version": 2 }); 83 | 84 | this.subject.get("/my_route", function (route) { 85 | expect(route.version).toEqual(2); 86 | expect(route.test).toEqual("value"); 87 | done(); 88 | }); 89 | }); 90 | }); 91 | 92 | describe("remove", function () { 93 | it("removes a route from the table", function (done) { 94 | this.subject.add("/my_route", { "test": "value" }); 95 | this.subject.remove("/my_route"); 96 | 97 | this.subject.get("/my_route", function (route) { 98 | expect(route).toBe(undefined); 99 | done(); 100 | }); 101 | }); 102 | 103 | it("doesn't explode when route is not defined", function (done) { 104 | // would blow up if an error was thrown 105 | this.subject.remove("/my_route/foo/bar", done); 106 | }); 107 | }); 108 | 109 | describe("hasRoute", function () { 110 | it("returns false when the path is not found", function (done) { 111 | this.subject.add("/my_route", { "test": "value" }); 112 | this.subject.hasRoute("/my_route", function (result) { 113 | expect(result).toBe(true); 114 | done(); 115 | }); 116 | }); 117 | 118 | it("returns false when the path is not found", function (done) { 119 | this.subject.hasRoute("/wut", function (result) { 120 | expect(result).toBe(false); 121 | done(); 122 | }); 123 | }); 124 | }); 125 | }); 126 | -------------------------------------------------------------------------------- /lib/store.js: -------------------------------------------------------------------------------- 1 | var trie = require("./trie.js"); 2 | var redis = require("redis"); 3 | var log = require("winston"); 4 | 5 | var DEFAULT_HOST = process.env.REDIS_HOST || "127.0.0.1"; 6 | var DEFAULT_PORT = process.env.REDIS_PORT || 6379; 7 | var DEFAULT_DB = process.env.REDIS_DB || 3; 8 | 9 | function createClient (host, port, db) { 10 | var client = redis.createClient( 11 | port || DEFAULT_PORT, 12 | host || DEFAULT_HOST 13 | ); 14 | 15 | if (db || 0 !== DEFAULT_DB) { 16 | client.select(db); 17 | } 18 | 19 | return client; 20 | } 21 | 22 | function scrub (data) { 23 | if (data && data.last_activity) { 24 | // save as ISO string 25 | data.last_activity = data.last_activity.toISOString(); 26 | } 27 | 28 | return data; 29 | } 30 | 31 | var NotImplemented = function (name) { 32 | return { 33 | name: "NotImplementedException", 34 | message: "method '" + name + "' not implemented" 35 | }; 36 | }; 37 | 38 | var BaseStore = Object.create(Object.prototype, { 39 | // "abstract" methods 40 | get: { value: function () { throw NotImplemented("get"); } }, 41 | getTarget: { value: function () { throw NotImplemented("getTarget"); } }, 42 | getAll: { value: function () { throw NotImplemented("getAll"); } }, 43 | add: { value: function () { throw NotImplemented("add"); } }, 44 | update: { value: function () { throw NotImplemented("update"); } }, 45 | remove: { value: function () { throw NotImplemented("remove"); } }, 46 | hasRoute: { value: function () { throw NotImplemented("hasRoute"); } }, 47 | 48 | cleanPath: { 49 | value: function (path) { 50 | return trie.trim_prefix(path); 51 | } 52 | }, 53 | 54 | notify: { 55 | value: function (cb) { 56 | if (typeof(cb) === "function") { 57 | var args = Array.prototype.slice.call(arguments, 1); 58 | cb.apply(this, args); 59 | } 60 | } 61 | } 62 | }); 63 | 64 | function MemoryStore () { 65 | var routes = {}; 66 | var urls = new trie.URLTrie(); 67 | var redis_client = createClient(DEFAULT_HOST, DEFAULT_PORT, DEFAULT_DB); 68 | 69 | return Object.create(BaseStore, { 70 | get: { 71 | value: function (path, cb) { 72 | var that = this; 73 | log.debug("get path :", path); 74 | var rootPath = "/" + (path || "/").split("/")[1]; 75 | log.debug("get rootpath :", rootPath); 76 | 77 | redis_client.hgetall(rootPath, function(err, res) { 78 | value = { prefix: rootPath, data: res } 79 | that.notify(cb, res); 80 | }); 81 | } 82 | }, 83 | getTarget: { 84 | value: function (path, cb) { 85 | var that = this; 86 | log.debug("getTarget path :", path); 87 | 88 | var rootPath = "/" + (path || "/").split("/")[1]; 89 | log.debug("get rootpath :", rootPath); 90 | 91 | redis_client.hgetall(rootPath, function(err, res) { 92 | value = { prefix: rootPath, data: res } 93 | that.notify(cb, value); 94 | }); 95 | } 96 | }, 97 | getAll: { 98 | value: function (cb) { 99 | log.debug("getAll path :", path); 100 | this.notify(cb, routes); 101 | } 102 | }, 103 | add: { 104 | value: function (path, data, cb) { 105 | var that = this; 106 | log.debug("add path :", path, " data: ", data); 107 | 108 | redis_client.hmset(path, data, function(err, res) { 109 | that.notify(cb) 110 | }); 111 | } 112 | }, 113 | update: { 114 | value: function (path, data, cb) { 115 | var that = this; 116 | log.debug("update path :", path, " data:" , data); 117 | 118 | this.get(path, function(current) { 119 | log.debug("current :" , current); 120 | redis_client.hmset(path, data, function(err, res) { 121 | that.notify(cb); 122 | }); 123 | }); 124 | } 125 | }, 126 | remove: { 127 | value: function (path, cb) { 128 | delete routes[path]; 129 | urls.remove(path); 130 | this.notify(cb); 131 | } 132 | }, 133 | hasRoute: { 134 | value: function (path, cb) { 135 | log.debug("hasRoute path :", path); 136 | var that = this; 137 | 138 | redis_client.exists(path, function(err, res) { 139 | log.debug("Returning :", res === 1); 140 | that.notify(cb, res === 1); 141 | }); 142 | } 143 | } 144 | }); 145 | } 146 | 147 | exports.MemoryStore = MemoryStore; 148 | -------------------------------------------------------------------------------- /lib/testutil.js: -------------------------------------------------------------------------------- 1 | var http = require('http'); 2 | var URL = require('url'); 3 | var extend = require('util')._extend; 4 | var WebSocketServer = require('ws').Server; 5 | var querystring = require('querystring'); 6 | 7 | var configproxy = require('../lib/configproxy'); 8 | 9 | var servers = []; 10 | 11 | var add_target = exports.add_target = function (proxy, path, port, websocket, target_path, cb) { 12 | var target = 'http://127.0.0.1:' + port; 13 | if (target_path) { 14 | target = target + target_path; 15 | } 16 | var server; 17 | var data = { 18 | target: target, 19 | path: path, 20 | }; 21 | 22 | server = http.createServer(function (req, res) { 23 | var reply = extend({}, data); 24 | reply.url = req.url; 25 | reply.headers = req.headers; 26 | res.write(JSON.stringify(reply)); 27 | res.end(); 28 | }); 29 | if (websocket) { 30 | var wss = new WebSocketServer({ 31 | server: server 32 | }); 33 | wss.on('connection', function(ws) { 34 | ws.on('message', function(message) { 35 | var reply = extend({}, data); 36 | reply.message = message; 37 | ws.send(JSON.stringify(reply)); 38 | }); 39 | ws.send('connected'); 40 | }); 41 | } 42 | server.listen(port); 43 | servers.push(server); 44 | proxy.add_route(path, {target: target}, cb); 45 | }; 46 | 47 | var add_target_redirecting = exports.add_target_redirecting = function (proxy, path, port, target_path, redirect_to) { 48 | // Like the above, but the server returns a redirect response with a Location header. 49 | // Cannot use default arguments as they are apparently not supported. 50 | var target = 'http://127.0.0.1:' + port; 51 | if (target_path) { 52 | target = target + target_path; 53 | } 54 | 55 | proxy.add_route(path, { target: target }, function (route) { 56 | var server = http.createServer(function (req, res) { 57 | res.setHeader("Location", redirect_to); 58 | res.statusCode = 301; 59 | res.write(''); 60 | res.end(); 61 | }); 62 | 63 | server.listen(port); 64 | servers.push(server); 65 | }); 66 | }; 67 | 68 | function add_targets (proxy, paths, port, callback) { 69 | var remainingPaths = paths.length; 70 | if (remainingPaths === 0) { 71 | callback(); 72 | return; 73 | } 74 | 75 | paths.forEach(function (path) { 76 | add_target(proxy, path, ++port, true, null, function () { 77 | if (--remainingPaths === 0) { 78 | callback(); 79 | } 80 | }); 81 | }); 82 | } 83 | 84 | exports.setup_proxy = function (port, callback, options, paths) { 85 | options = options || {}; 86 | options.auth_token = 'secret'; 87 | 88 | var proxy = new configproxy.ConfigurableProxy(options); 89 | var ip = '127.0.0.1'; 90 | var countdown = 2; 91 | 92 | var onlisten = function () { 93 | if (--countdown === 0) { 94 | if (callback) { 95 | callback(proxy); 96 | } 97 | } 98 | }; 99 | 100 | if (options.error_target) { 101 | countdown++; 102 | var error_server = http.createServer(function (req, res) { 103 | var parsed = URL.parse(req.url); 104 | var query = querystring.parse(parsed.query); 105 | res.setHeader('Content-Type', 'text/plain'); 106 | req.on('data', function () {}); 107 | req.on('end', function () { 108 | res.write(query.url); 109 | res.end(); 110 | }); 111 | }); 112 | error_server.on('listening', onlisten); 113 | error_server.listen(URL.parse(options.error_target).port, ip); 114 | servers.push(error_server); 115 | } 116 | 117 | servers.push(proxy.proxy_server); 118 | servers.push(proxy.api_server); 119 | proxy.api_server.on('listening', onlisten); 120 | proxy.proxy_server.on('listening', onlisten); 121 | 122 | add_targets(proxy, paths || ["/"], port + 1, function () { 123 | proxy.proxy_server.listen(port, ip); 124 | proxy.api_server.listen(port + 1, ip); 125 | }); 126 | }; 127 | 128 | exports.teardown_servers = function (callback) { 129 | var count = 0; 130 | var onclose = function () { 131 | count = count + 1; 132 | if (count === servers.length) { 133 | servers = []; 134 | callback(); 135 | } 136 | }; 137 | for (var i = servers.length - 1; i >= 0; i--) { 138 | servers[i].close(onclose); 139 | } 140 | }; 141 | 142 | var onfinish = exports.onfinish = function (res, callback) { 143 | res.body = ''; 144 | res.on('data', function (chunk) { 145 | res.body += chunk; 146 | }); 147 | res.on('end', function () { 148 | callback(res); 149 | }); 150 | }; 151 | -------------------------------------------------------------------------------- /test/trie_spec.js: -------------------------------------------------------------------------------- 1 | // jshint jasmine: true 2 | 3 | var URLTrie = require('../lib/trie').URLTrie; 4 | 5 | describe("URLTrie", function () { 6 | 7 | var full_trie = function () { 8 | // return a simple trie for testing 9 | var trie = new URLTrie(); 10 | var paths = [ 11 | '/1', 12 | '/2', 13 | '/a/b/c/d', 14 | '/a/b/d', 15 | '/a/b/e', 16 | '/b', 17 | '/b/c', 18 | '/b/c/d', 19 | ]; 20 | for (var i=0; i < paths.length; i++) { 21 | var path = paths[i]; 22 | trie.add(path, {path: path}); 23 | } 24 | return trie; 25 | }; 26 | 27 | it("trie_init", function (done) { 28 | var trie = new URLTrie(); 29 | expect(trie.prefix).toEqual('/'); 30 | expect(trie.size).toEqual(0); 31 | expect(trie.data).toBe(undefined); 32 | expect(trie.branches).toEqual({}); 33 | 34 | trie = new URLTrie('/foo'); 35 | expect(trie.size).toEqual(0); 36 | expect(trie.prefix).toEqual('/foo'); 37 | expect(trie.data).toBe(undefined); 38 | expect(trie.branches).toEqual({}); 39 | 40 | done(); 41 | }); 42 | 43 | it("trie_root", function (done) { 44 | var trie = new URLTrie(); 45 | trie.add('/', -1); 46 | var node = trie.get('/1/etc/etc/'); 47 | expect(node).toBeTruthy(); 48 | expect(node.prefix).toEqual('/'); 49 | expect(node.data).toEqual(-1); 50 | 51 | node = trie.get('/'); 52 | expect(node).toBeTruthy(); 53 | expect(node.prefix).toEqual('/'); 54 | expect(node.data).toEqual(-1); 55 | 56 | node = trie.get(''); 57 | expect(node).toBeTruthy(); 58 | expect(node.prefix).toEqual('/'); 59 | expect(node.data).toEqual(-1); 60 | done(); 61 | }); 62 | 63 | it("trie_add", function (done) { 64 | var trie = new URLTrie(); 65 | 66 | trie.add('foo', 1); 67 | expect(trie.size).toEqual(1); 68 | 69 | expect(trie.data).toBe(undefined); 70 | expect(trie.branches.foo.data).toEqual(1); 71 | expect(trie.branches.foo.size).toEqual(0); 72 | 73 | trie.add('bar/leaf', 2); 74 | expect(trie.size).toEqual(2); 75 | var bar = trie.branches.bar; 76 | expect(bar.prefix).toEqual('/bar'); 77 | expect(bar.size).toEqual(1); 78 | expect(bar.branches.leaf.data).toEqual(2); 79 | 80 | trie.add('/a/b/c/d', 4); 81 | expect(trie.size).toEqual(3); 82 | var a = trie.branches.a; 83 | expect(a.prefix).toEqual('/a'); 84 | expect(a.size).toEqual(1); 85 | expect(a.data).toBe(undefined); 86 | 87 | var b = a.branches.b; 88 | expect(b.prefix).toEqual('/a/b'); 89 | expect(b.size).toEqual(1); 90 | expect(b.data).toBe(undefined); 91 | 92 | var c = b.branches.c; 93 | expect(c.prefix).toEqual('/a/b/c'); 94 | expect(c.size).toEqual(1); 95 | expect(c.data).toBe(undefined); 96 | var d = c.branches.d; 97 | expect(d.prefix).toEqual('/a/b/c/d'); 98 | expect(d.size).toEqual(0); 99 | expect(d.data).toEqual(4); 100 | 101 | done(); 102 | }); 103 | 104 | it("trie_get", function (done) { 105 | var trie = full_trie(); 106 | expect(trie.get('/not/found')).toBe(undefined); 107 | 108 | var node = trie.get('/1'); 109 | expect(node.prefix).toEqual('/1'); 110 | expect(node.data.path).toEqual('/1'); 111 | 112 | node = trie.get('/1/etc/etc/'); 113 | expect(node).toBeTruthy(); 114 | expect(node.prefix).toEqual('/1'); 115 | expect(node.data.path).toEqual('/1'); 116 | 117 | expect(trie.get('/a')).toBe(undefined); 118 | expect(trie.get('/a/b/c')).toBe(undefined); 119 | 120 | node = trie.get('/a/b/c/d/e/f'); 121 | expect(node).toBeTruthy(); 122 | expect(node.prefix).toEqual('/a/b/c/d'); 123 | expect(node.data.path).toEqual('/a/b/c/d'); 124 | 125 | node = trie.get('/b/c/d/word'); 126 | expect(node).toBeTruthy(); 127 | expect(node.prefix).toEqual('/b/c/d'); 128 | expect(node.data.path).toEqual('/b/c/d'); 129 | 130 | node = trie.get('/b/c/dword'); 131 | expect(node).toBeTruthy(); 132 | expect(node.prefix).toEqual('/b/c'); 133 | expect(node.data.path).toEqual('/b/c'); 134 | 135 | done(); 136 | }); 137 | 138 | it("trie_remove", function (done) { 139 | var trie = full_trie(); 140 | var size = trie.size; 141 | var node; 142 | node = trie.get('/b/just-b'); 143 | expect(node.prefix).toEqual('/b'); 144 | 145 | trie.remove('/b'); 146 | // deleting a node doesn't change size if no children 147 | expect(trie.size).toEqual(size); 148 | expect(trie.get('/b/just-b')).toBe(undefined); 149 | node = trie.get('/b/c/sub-still-here'); 150 | expect(node.prefix).toEqual('/b/c'); 151 | 152 | node = trie.get('/a/b/c/d/word'); 153 | expect(node.prefix).toEqual('/a/b/c/d'); 154 | var b = trie.branches.a.branches.b; 155 | expect(b.size).toEqual(3); 156 | trie.remove('/a/b/c/d'); 157 | expect(b.size).toEqual(2); 158 | expect(b.branches.c).toBe(undefined); 159 | 160 | trie.remove('/'); 161 | node = trie.get('/'); 162 | expect(node).toBe(undefined); 163 | 164 | done(); 165 | }); 166 | 167 | it("trie_sub_paths", function (done) { 168 | var trie = new URLTrie(), node; 169 | trie.add('/', { 170 | path: '/' 171 | }); 172 | 173 | node = trie.get('/prefix/sub'); 174 | expect(node).toBeTruthy(); 175 | expect(node.prefix).toEqual('/'); 176 | 177 | // add /prefix/sub/tree 178 | trie.add('/prefix/sub/tree', {}); 179 | 180 | // which shouldn't change the results for /prefix and /prefix/sub 181 | node = trie.get('/prefix'); 182 | expect(node).toBeTruthy(); 183 | expect(node.prefix).toEqual('/'); 184 | 185 | node = trie.get('/prefix/sub'); 186 | expect(node).toBeTruthy(); 187 | expect(node.prefix).toEqual('/'); 188 | 189 | node = trie.get('/prefix/sub/tree'); 190 | expect(node).toBeTruthy(); 191 | expect(node.prefix).toEqual('/prefix/sub/tree'); 192 | 193 | // add /prefix, and run one more time 194 | trie.add('/prefix', {}); 195 | 196 | node = trie.get('/prefix'); 197 | expect(node).toBeTruthy(); 198 | expect(node.prefix).toEqual('/prefix'); 199 | 200 | node = trie.get('/prefix/sub'); 201 | expect(node).toBeTruthy(); 202 | expect(node.prefix).toEqual('/prefix'); 203 | 204 | node = trie.get('/prefix/sub/tree'); 205 | expect(node).toBeTruthy(); 206 | expect(node.prefix).toEqual('/prefix/sub/tree'); 207 | 208 | done(); 209 | }); 210 | 211 | it("remove first leaf doesn't remove root", function (done) { 212 | var trie = new URLTrie(), node; 213 | trie.add('/', { 214 | path: '/' 215 | }); 216 | 217 | node = trie.get('/prefix/sub'); 218 | expect(node).toBeTruthy(); 219 | expect(node.prefix).toEqual('/'); 220 | 221 | trie.add('/prefix', { 222 | path: '/prefix' 223 | }); 224 | 225 | node = trie.get('/prefix/sub'); 226 | expect(node).toBeTruthy(); 227 | expect(node.prefix).toEqual('/prefix'); 228 | 229 | trie.remove('/prefix/'); 230 | 231 | node = trie.get('/prefix/sub'); 232 | expect(node).toBeTruthy(); 233 | expect(node.prefix).toEqual('/'); 234 | 235 | done(); 236 | }); 237 | }); 238 | -------------------------------------------------------------------------------- /test/api_spec.js: -------------------------------------------------------------------------------- 1 | // jshint jasmine: true 2 | 3 | var util = require('../lib/testutil'); 4 | var extend = require('util')._extend; 5 | var request = require('request'); 6 | var log = require('winston'); 7 | // disable logging during tests 8 | log.remove(log.transports.Console); 9 | 10 | describe("API Tests", function () { 11 | var port = 8902; 12 | var api_port = port + 1; 13 | var proxy; 14 | var api_url = "http://127.0.0.1:" + api_port + '/api/routes'; 15 | 16 | var r; 17 | 18 | beforeEach(function (callback) { 19 | function setup_r() { 20 | r = request.defaults({ 21 | method: 'GET', 22 | headers: {Authorization: 'token ' + proxy.auth_token}, 23 | port: api_port, 24 | url: api_url, 25 | }); 26 | callback(); 27 | } 28 | 29 | util.setup_proxy(port, function (new_proxy) { 30 | proxy = new_proxy; 31 | setup_r(); 32 | }); 33 | }); 34 | 35 | afterEach(function (callback) { 36 | util.teardown_servers(callback); 37 | }); 38 | 39 | it("Basic proxy constructor", function (done) { 40 | expect(proxy).toBeDefined(); 41 | expect(proxy.default_target).toBe(undefined); 42 | 43 | proxy.target_for_req({ url: "/" }, function (route) { 44 | expect(route).toEqual({ 45 | prefix: "/", 46 | target: "http://127.0.0.1:" + (port + 2) 47 | }); 48 | 49 | done(); 50 | }); 51 | }); 52 | 53 | it("Default target is used for /any/random/url", function (done) { 54 | proxy.target_for_req({ url: "/any/random/url" }, function (target) { 55 | expect(target).toEqual({ 56 | prefix: "/", 57 | target: "http://127.0.0.1:" + (port + 2) 58 | }); 59 | 60 | done(); 61 | }); 62 | }); 63 | 64 | it("Default target is used for /", function (done) { 65 | proxy.target_for_req({ url: "/" }, function (target) { 66 | expect(target).toEqual({ 67 | prefix: "/", 68 | target: "http://127.0.0.1:" + (port + 2) 69 | }); 70 | 71 | done(); 72 | }); 73 | }); 74 | 75 | it("GET /api/routes fetches the routing table", function (done) { 76 | r(api_url, function (error, res, body) { 77 | expect(error).toBe(null); 78 | expect(res.statusCode).toEqual(200); 79 | body = JSON.parse(res.body); 80 | var keys = Object.keys(body); 81 | expect(keys.length).toEqual(1); 82 | expect(keys).toContain('/'); 83 | done(); 84 | }); 85 | }); 86 | 87 | it("POST /api/routes[/path] creates a new route", function (done) { 88 | var port = 8998; 89 | var target = 'http://127.0.0.1:' + port; 90 | 91 | r.post({ 92 | url: api_url + '/user/foo', 93 | body: JSON.stringify({target: target}), 94 | }, function (error, res, body) { 95 | expect(error).toBe(null); 96 | expect(res.statusCode).toEqual(201); 97 | expect(res.body).toEqual(''); 98 | 99 | proxy._routes.get('/user/foo', function (route) { 100 | expect(route.target).toEqual(target); 101 | expect(typeof route.last_activity).toEqual('object'); 102 | done(); 103 | }); 104 | }); 105 | }); 106 | 107 | it("POST /api/routes[/foo%20bar] handles URI escapes", function (done) { 108 | var port = 8998; 109 | var target = 'http://127.0.0.1:' + port; 110 | r.post({ 111 | url: api_url + '/user/foo%40bar', 112 | body: JSON.stringify({target: target}), 113 | }, function (error, res, body) { 114 | expect(error).toBe(null); 115 | expect(res.statusCode).toEqual(201); 116 | expect(res.body).toEqual(''); 117 | 118 | proxy._routes.get('/user/foo@bar', function (route) { 119 | expect(route.target).toEqual(target); 120 | expect(typeof route.last_activity).toEqual('object'); 121 | 122 | proxy.target_for_req({ url: "/user/foo@bar/path" }, function (proxy_target) { 123 | expect(proxy_target.target).toEqual(target); 124 | done(); 125 | }); 126 | }); 127 | }); 128 | }); 129 | 130 | it("POST /api/routes creates a new root route", function (done) { 131 | var port = 8998; 132 | var target = 'http://127.0.0.1:' + port; 133 | r.post({ 134 | url: api_url, 135 | body: JSON.stringify({target: target}), 136 | }, function (error, res, body) { 137 | expect(error).toBe(null); 138 | expect(res.statusCode).toEqual(201); 139 | expect(res.body).toEqual(''); 140 | 141 | proxy._routes.get("/", function (route) { 142 | expect(route.target).toEqual(target); 143 | expect(typeof route.last_activity).toEqual('object'); 144 | done(); 145 | }); 146 | }); 147 | }); 148 | 149 | it("DELETE /api/routes[/path] deletes a route", function (done) { 150 | var port = 8998; 151 | var target = 'http://127.0.0.1:' + port; 152 | var path = '/user/bar'; 153 | 154 | util.add_target(proxy, path, port, null, null, function () { 155 | proxy._routes.get(path, function (route) { 156 | expect(route.target).toEqual(target); 157 | 158 | r.del(api_url + path, function (error, res, body) { 159 | expect(error).toBe(null); 160 | expect(res.statusCode).toEqual(204); 161 | expect(res.body).toEqual(''); 162 | 163 | proxy._routes.get(path, function (deleted_route) { 164 | expect(deleted_route).toBe(undefined); 165 | done(); 166 | }); 167 | }); 168 | }); 169 | }); 170 | }); 171 | 172 | it("GET /api/routes?inactive_since= with bad value returns a 400", function (done) { 173 | r.get(api_url + "?inactive_since=endoftheuniverse", function (error, res, body) { 174 | expect(error).toBe(null); 175 | expect(res.statusCode).toEqual(400); 176 | done(); 177 | }); 178 | }); 179 | 180 | it("GET /api/routes?inactive_since= filters inactive entries", function (done) { 181 | var port = 8998; 182 | var path = '/yesterday'; 183 | 184 | var now = new Date(); 185 | var yesterday = new Date(now.getTime() - (24 * 3.6e6)); 186 | var long_ago = new Date(1); 187 | var hour_ago = new Date(now.getTime() - 3.6e6); 188 | var hour_from_now = new Date(now.getTime() + 3.6e6); 189 | 190 | var tests = [ 191 | { 192 | name: 'long ago', 193 | since: long_ago, 194 | expected: {} 195 | }, 196 | { 197 | name: 'an hour ago', 198 | since: hour_ago, 199 | expected: {'/yesterday': true} 200 | }, 201 | { 202 | name: 'the future', 203 | since: hour_from_now, 204 | expected: { 205 | '/yesterday': true, 206 | '/today': true 207 | } 208 | } 209 | ]; 210 | 211 | var seen = 0; 212 | var do_req = function (i) { 213 | var t = tests[i]; 214 | r.get(api_url + '?inactive_since=' + t.since.toISOString(), function (error, res, body) { 215 | expect(error).toBe(null); 216 | expect(res.statusCode).toEqual(200); 217 | 218 | var routes = JSON.parse(res.body); 219 | var route_keys = Object.keys(routes); 220 | var expected_keys = Object.keys(t.expected); 221 | 222 | route_keys.forEach(function (key) { 223 | // check that all routes are expected 224 | expect(expected_keys).toContain(key); 225 | }); 226 | 227 | expected_keys.forEach(function (key) { 228 | // check that all expected routes are found 229 | expect(route_keys).toContain(key); 230 | }); 231 | 232 | seen += 1; 233 | if (seen === tests.length) { 234 | done(); 235 | } else { 236 | do_req(seen); 237 | } 238 | }); 239 | }; 240 | 241 | proxy.remove_route("/", function () { 242 | util.add_target(proxy, '/yesterday', port, null, null, function () { 243 | util.add_target(proxy, '/today', port + 1, null, null, function () { 244 | proxy._routes.update('/yesterday', { last_activity: yesterday }, function () { 245 | do_req(0); 246 | }); 247 | }); 248 | }); 249 | }); 250 | }); 251 | }); 252 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | **[Install](#install)** | 2 | **[Using configurable-http-proxy](#using-configurable-http-proxy)** | 3 | **[Using the REST API](#using-the-rest-api)** | 4 | **[Custom error pages](#custom-error-pages)** | 5 | **[Host-based routing](#host-based-routing)** 6 | 7 | # configurable-http-proxy 8 | 9 | [![Build Status](https://travis-ci.org/jupyterhub/configurable-http-proxy.svg?branch=master)](https://travis-ci.org/jupyterhub/configurable-http-proxy) 10 | 11 | 12 | **configurable-http-proxy**, a simple wrapper around [node-http-proxy][], adds 13 | a REST API for updating the routing table. 14 | 15 | The proxy is developed as a part of the [JupyterHub][] multi-user server. 16 | 17 | Note: [node-http-proxy][] is an HTTP programmable proxying library 18 | that supports websockets. It is suitable for implementing components such 19 | as reverse proxies and load balancers. configurable-http-proxy wraps 20 | node-http-proxy to provide this functionality to JupyterHub. 21 | 22 | [node-http-proxy]: https://github.com/nodejitsu/node-http-proxy 23 | [JupyterHub]: https://github.com/jupyterhub/jupyterhub 24 | 25 | 26 | ## Install 27 | 28 | Prerequisite: [Node.js](https://nodejs.org/en/download/) 29 | 30 | To install globally from the `configurable-http-proxy` package release 31 | using the npm package manager: 32 | 33 | npm install -g configurable-http-proxy 34 | 35 | To install from the source code found in this GitHub repo: 36 | 37 | git clone https://github.com/jupyterhub/configurable-http-proxy.git 38 | cd configurable-http-proxy 39 | # Use -g for global install 40 | npm install [-g] 41 | 42 | 43 | ## Using configurable-http-proxy 44 | 45 | The configurable proxy runs two HTTP(S) servers: 46 | 47 | 1. The **public-facing interface to your application** (controlled by `--ip`, 48 | `--port`, etc.). This listens on **all interfaces** by default. 49 | 2. The **inward-facing REST API** (`--api-ip`, `--api-port`). This listens on 50 | localhost by default. The REST API uses token authorization, set by the 51 | `CONFIGPROXY_AUTH_TOKEN` environment variable. 52 | 53 | ![](./doc/_static/chp.png) 54 | 55 | ### Setting a default target 56 | 57 | When you start the proxy from the command line, you can set a 58 | default target (`--default-target` option) to be used when no 59 | matching route is found in the proxy table: 60 | 61 | configurable-http-proxy --default-target=http://localhost:8888 62 | 63 | ### Command-line options 64 | 65 | ``` 66 | Usage: configurable-http-proxy [options] 67 | 68 | Options: 69 | 70 | -h, --help output usage information 71 | -V, --version output the version number 72 | --ip Public-facing IP of the proxy 73 | --port (defaults to 8000) Public-facing port of the proxy 74 | 75 | --ssl-key SSL key to use, if any 76 | --ssl-cert SSL certificate to use, if any 77 | --ssl-ca SSL certificate authority, if any 78 | --ssl-request-cert Request SSL certs to authenticate clients 79 | --ssl-reject-unauthorized Reject unauthorized SSL connections (only meaningful if --ssl-request-cert is given) 80 | --ssl-protocol Set specific HTTPS protocol, e.g. TLSv1_2, TLSv1, etc. 81 | --ssl-ciphers `:`-separated ssl cipher list. Default excludes RC4 82 | --ssl-allow-rc4 Allow RC4 cipher for SSL (disabled by default) 83 | --ssl-dhparam SSL Diffie-Helman Parameters pem file, if any 84 | 85 | --api-ip Inward-facing IP for API requests 86 | --api-port Inward-facing port for API requests (defaults to --port=value+1) 87 | --api-ssl-key SSL key to use, if any, for API requests 88 | --api-ssl-cert SSL certificate to use, if any, for API requests 89 | --api-ssl-ca SSL certificate authority, if any, for API requests 90 | --api-ssl-request-cert Request SSL certs to authenticate clients for API requests 91 | --api-ssl-reject-unauthorized Reject unauthorized SSL connections (only meaningful if --api-ssl-request-cert is given) 92 | 93 | --default-target Default proxy target (proto://host[:port]) 94 | --error-target Alternate server for handling proxy errors (proto://host[:port]) 95 | --error-path Alternate server for handling proxy errors (proto://host[:port]) 96 | --redirect-port Redirect HTTP requests on this port to the server on HTTPS 97 | --pid-file Write our PID to a file 98 | --no-x-forward Don't add 'X-forward-' headers to proxied requests 99 | --no-prepend-path Avoid prepending target paths to proxied requests 100 | --no-include-prefix Don't include the routing prefix in proxied requests 101 | --insecure Disable SSL cert verification 102 | --host-routing Use host routing (host as first level of path) 103 | --statsd-host Host to send statsd statistics to 104 | --statsd-port Port to send statsd statistics to 105 | --statsd-prefix Prefix to use for statsd statistics 106 | --log-level Log level (debug, info, warn, error) 107 | --proxy-timeout Timeout (in millis) when proxy receives no response from target 108 | ``` 109 | 110 | 111 | ## Using the REST API 112 | 113 | The configurable-http-proxy API is documented and available at the 114 | interactive swagger site, [petstore](http://petstore.swagger.io/?url=https://raw.githubusercontent.com/jupyterhub/configurable-http-proxy/master/doc/rest-api.yml#/default) 115 | or as a [swagger specification file in this repo](https://github.com/jupyterhub/configurable-http-proxy/blob/master/doc/rest-api.yml). 116 | 117 | ### Authenticating via passing a token 118 | 119 | The REST API is authenticated via passing a token in the `Authorization` header. 120 | The API is served under the `/api/routes` base URL. For example, execute 121 | this `curl` command in the terminal to authenticate and retrieve the 122 | current routing table: 123 | 124 | curl -H "Authorization: token $CONFIGPROXY_AUTH_TOKEN" http://localhost:8001/api/routes 125 | 126 | ### Getting the current routing table 127 | 128 | **Request** 129 | 130 | GET /api/routes[?inactive_since=ISO8601-timestamp] 131 | 132 | The GET request returns a JSON dictionary of the current routing table. 133 | 134 | This JSON dictionary *excludes* the default route. If the `inactive_since` URL 135 | parameter is given as an [ISO8601](http://en.wikipedia.org/wiki/ISO_8601) 136 | timestamp, only routes whose `last_activity` is earlier than the timestamp 137 | will be returned. 138 | 139 | **Response** 140 | 141 | Status code: 142 | 143 | status: 200 OK 144 | 145 | Returned JSON dictionary of current routing table: 146 | 147 | ```json 148 | { 149 | "/user/foo": { 150 | "target": "http://localhost:8002", 151 | "last_activity": "2014-09-08T19:43:08.321Z" 152 | }, 153 | "/user/bar": { 154 | "target": "http://localhost:8003", 155 | "last_activity": "2014-09-08T19:40:17.819Z" 156 | } 157 | } 158 | ``` 159 | 160 | The `last_activity` timestamp is updated whenever the proxy passes data to 161 | or from the proxy target. 162 | 163 | ### Adding new routes 164 | 165 | POST requests create new routes. The body of the request should be a JSON 166 | dictionary with at least one key: `target`, the target host to be proxied. 167 | 168 | **Request** 169 | 170 | POST /api/routes/[:path] 171 | 172 | *Input - request body* 173 | 174 | `target`: The host URL 175 | 176 | **Response** 177 | 178 | status: 201 Created 179 | 180 | After adding the new route, any request to `/path/prefix` on the proxy's 181 | public interface will be proxied to `target`. 182 | 183 | ### Deleting routes 184 | 185 | **Request** 186 | 187 | DELETE /api/routes/[:path] 188 | 189 | **Response** 190 | 191 | status: 204 No Content 192 | 193 | Removes a route from the proxy's routing table. 194 | 195 | 196 | ## Custom error pages 197 | 198 | With version 0.5, configurable-host-proxy (CHP) adds two ways to provide 199 | custom error pages when the proxy encounters an error, and has no proxy target 200 | to handle a request. There are two typical errors that CHP can hit, along 201 | with their status code: 202 | 203 | - 404: a client has requested a URL for which there is no routing target. 204 | This can be prevented if a `default target` is specified when starting 205 | the configurable-http-proxy. 206 | 207 | - 503: a route exists, but the upstream server isn't responding. 208 | This is more common, and can be due to any number of reasons, 209 | including the target service having died or not finished starting. 210 | 211 | ### error-path 212 | 213 | If you specify an error path `--error-path /usr/share/chp-errors` when 214 | starting the CHP: 215 | 216 | configurable-http-proxy --error-path /usr/share/chp-errors 217 | 218 | then when a proxy error occurs, CHP will look in 219 | `/usr/share/chp-errors/.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 | --------------------------------------------------------------------------------