diff --git a/.eslintrc.json b/.eslintrc.json index ef5dd10..f21a775 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -6,7 +6,7 @@ "mocha": true }, "parserOptions": { - "ecmaVersion": 2017 + "ecmaVersion": 2020 } } diff --git a/lib/createdb.js b/lib/createdb.js index 8d2d3d8..347322c 100755 --- a/lib/createdb.js +++ b/lib/createdb.js @@ -8,10 +8,10 @@ args .option("--sessions ", "Routes file to monitor") .option("--verbose"); -const main = function(argv_) { +const main = function (argv_) { const argv = argv_ || process.argv; args.parse(argv); - let db = new sqlite3.Database(args.sessions, err => { + let db = new sqlite3.Database(args.sessions, (err) => { if (err) { return console.error(err.message); } @@ -27,11 +27,11 @@ CREATE TABLE gxitproxy info text, PRIMARY KEY (key, key_type) );`, - err => { + (err) => { if (err) { return console.log(err.message); } - } + }, ); db.close(); }; diff --git a/lib/main.js b/lib/main.js index 8a9def4..3474a37 100755 --- a/lib/main.js +++ b/lib/main.js @@ -18,11 +18,11 @@ args .option("--forwardPort ", "Forward all requests to port", parseInt) .option( "--reverseProxy", - "Cause the proxy to rewrite location blocks with its own port" + "Cause the proxy to rewrite location blocks with its own port", ) .option("--verbose"); -const main = function(argv_) { +const main = function (argv_) { const argv = argv_ || process.argv; args.parse(argv); @@ -35,7 +35,7 @@ const main = function(argv_) { sessionMap: sessions, proxyPathPrefix: args.proxyPathPrefix, verbose: args.verbose, - port: args.port + port: args.port, }; if (args.reverseProxy) { @@ -53,7 +53,7 @@ const main = function(argv_) { const dynamicProxy = new DynamicProxy(dynamicProxyOptions); const listen = { port: args.port || 8000, - ip: args.ip + ip: args.ip, }; dynamicProxy.listen(listen); return dynamicProxy; diff --git a/lib/mapper.js b/lib/mapper.js index c6e7591..14e8365 100644 --- a/lib/mapper.js +++ b/lib/mapper.js @@ -2,23 +2,29 @@ var fs = require("fs"); var sqlite3 = require("sqlite3"); var watch = require("node-watch"); -var endsWith = function(subjectString, searchString) { +var endsWith = function (subjectString, searchString) { var position = subjectString.length; position -= searchString.length; var lastIndex = subjectString.indexOf(searchString, position); return lastIndex !== -1 && lastIndex === position; }; -var updateFromJson = function(path, map) { +var updateFromJson = function (path, map) { var content = fs.readFileSync(path, "utf8"); var keyToSession = JSON.parse(content); var newSessions = {}; for (var key in keyToSession) { + var info = keyToSession[key]?.info; + if (info) { + info = JSON.parse(info); + } newSessions[key] = { target: { host: keyToSession[key]["host"], - port: parseInt(keyToSession[key]["port"]) - } + port: parseInt(keyToSession[key]["port"]), + requires_path_in_url: info?.requires_path_in_url, + requires_path_in_header_named: info?.requires_path_in_header_named, + }, }; } for (var oldSession in map) { @@ -45,24 +51,30 @@ INSERT INTO "gxitproxy" VALUES('d24902ddec2e97f1','interactivetoolentrypoint','b */ -var updateFromSqlite = function(path, map) { +var updateFromSqlite = function (path, map) { var newSessions = {}; - var loadSessions = function() { + var loadSessions = function () { db.each( - "SELECT key, key_type, token, host, port FROM gxitproxy", - function(err, row) { + "SELECT key, key_type, token, host, port, info FROM gxitproxy", + function (err, row) { var key = row["key"]; + var info = row["info"]; + if (info) { + info = JSON.parse(info); + } newSessions[key] = { target: { host: row["host"], port: parseInt(row["port"]) }, key_type: row["key_type"], - token: row["token"] + token: row["token"], + requires_path_in_url: info?.requires_path_in_url, + requires_path_in_header_named: info?.requires_path_in_header_named, }; }, - finish + finish, ); }; - var finish = function() { + var finish = function () { for (var oldSession in map) { if (!(oldSession in newSessions)) { delete map[oldSession]; @@ -77,15 +89,15 @@ var updateFromSqlite = function(path, map) { var db = new sqlite3.Database(path, loadSessions); }; -var mapFor = function(path) { +var mapFor = function (path) { var map = {}; var loadMap; if (endsWith(path, ".sqlite")) { - loadMap = function() { + loadMap = function () { updateFromSqlite(path, map); }; } else { - loadMap = function() { + loadMap = function () { updateFromJson(path, map); }; } diff --git a/lib/proxy.js b/lib/proxy.js index 22924a1..35a10f9 100644 --- a/lib/proxy.js +++ b/lib/proxy.js @@ -1,15 +1,15 @@ const http = require("http"), httpProxy = require("http-proxy"); -const bound = function(that, method) { +const bound = function (that, method) { // bind a method, to ensure `this=that` when it is called // because prototype languages are bad - return function() { + return function () { method.apply(that, arguments); }; }; -const DynamicProxy = function(options) { +const DynamicProxy = function (options) { var dynamicProxy = this; this.sessionCookie = options.sessionCookie; this.sessionMap = options.sessionMap; @@ -20,8 +20,8 @@ const DynamicProxy = function(options) { this.forwardPort = options.forwardPort; this.proxyPathPrefix = options.proxyPathPrefix; - var log_errors = function(handler) { - return function(req) { + var log_errors = function (handler) { + return function (req) { try { return handler.apply(dynamicProxy, arguments); } catch (e) { @@ -33,27 +33,28 @@ const DynamicProxy = function(options) { " " + req.url + ": ", - e + e, ); } }; }; this.proxy = httpProxy.createProxyServer({ - ws: true + ws: true, }); this.proxy_server = http.createServer( - log_errors(dynamicProxy.handleProxyRequest) + log_errors(dynamicProxy.handleProxyRequest), ); this.proxy_server.on("upgrade", bound(this, this.handleWs)); }; -DynamicProxy.prototype.rewriteRequest = function(request) { +DynamicProxy.prototype.rewriteRequest = function (request) { + // TODO: This no longer seems needed. Remove? if (request.url.indexOf("rstudio") != -1) { var remap = { "content-type": "Content-Type", - "content-length": "Content-Length" + "content-length": "Content-Length", }; // RStudio isn't spec compliant and pitches a fit on NodeJS's http module's lowercase HTTP headers for (var i = 0; i < Object.keys(remap).length; i++) { @@ -73,7 +74,7 @@ DynamicProxy.prototype.rewriteRequest = function(request) { } }; -DynamicProxy.prototype.targetFromSessionMap = function(key, token) { +DynamicProxy.prototype.targetFromSessionMap = function (key, token) { for (let mappedSession in this.sessionMap) { if (key == mappedSession) { if (this.sessionMap[key].token == token) { @@ -83,7 +84,7 @@ DynamicProxy.prototype.targetFromSessionMap = function(key, token) { } }; -DynamicProxy.prototype.targetFromHeaders = function(request) { +DynamicProxy.prototype.targetFromHeaders = function (request) { let host = request.headers["x-interactive-tool-host"]; let port = request.headers["x-interactive-tool-port"]; if (!port && host.indexOf(":") > 0) { @@ -93,32 +94,50 @@ DynamicProxy.prototype.targetFromHeaders = function(request) { } return { host: host, - port: parseInt(port) + port: parseInt(port), }; }; -DynamicProxy.prototype.targetForRequest = function(request) { +DynamicProxy.prototype.targetForRequest = function (request) { // return proxy target for a given url - // extract key and token from subdomain if domain-based const req_host = request.headers.host; - let key = req_host.substring(0, req_host.indexOf("-")); - let token = req_host.substring( - req_host.indexOf("-") + 1, - req_host.indexOf(".") - ); + let key; + let token; // extract key, token, and target_url from path if path-based - if (!(token && key) && this.proxyPathPrefix && request.url.startsWith(this.proxyPathPrefix)) { - console.log('Using proxyPathPrefix: ' + this.proxyPathPrefix + ' for url: ' + request.url); - const rest_path = request.url.substring(this.proxyPathPrefix.length); - console.assert(rest_path.startsWith('/')) - const path_split = rest_path.split('/') - key = path_split[1]; - token = path_split[2]; + if (this.proxyPathPrefix && request.url.startsWith(this.proxyPathPrefix)) { + console.log( + `Using proxyPathPrefix: ${this.proxyPathPrefix} for url: ${request.url}`, + ); + const rest_path = request.url.substring(this.proxyPathPrefix.length); + console.assert(rest_path.startsWith("/")); + + const path_split = rest_path.split("/"); + key = path_split[1]; + token = path_split[2]; + + const target_inject_header = + this.sessionMap[key]?.requires_path_in_header_named; + if (target_inject_header) { + request.headers[target_inject_header] = [ + this.proxyPathPrefix, + key, + token, + ].join("/"); + } + + if (!this.sessionMap[key]?.requires_path_in_url) { const target_url = "/" + path_split.slice(3).join("/"); - console.log(key + " - " + token + " " + target_url); request.url = target_url; + } + } else { + // extract key and token from subdomain if domain-based + key = req_host.substring(0, req_host.indexOf("-")); + token = req_host.substring( + req_host.indexOf("-") + 1, + req_host.indexOf("."), + ); } let target; @@ -139,17 +158,17 @@ DynamicProxy.prototype.targetForRequest = function(request) { " " + request.method + " " + - request.url + request.url, ); } return null; }; -DynamicProxy.prototype.configureForward = function(req, target) { +DynamicProxy.prototype.configureForward = function (req, target) { var _target = Object.assign({}, target); if (this.forwardIP) { console.log( - "Forwarding request for " + target.host + " to " + this.forwardIP + "Forwarding request for " + target.host + " to " + this.forwardIP, ); req.headers["x-interactive-tool-host"] = target.host; _target.host = this.forwardIP; @@ -158,7 +177,7 @@ DynamicProxy.prototype.configureForward = function(req, target) { } if (this.forwardPort) { console.log( - "Forwarding request for " + target.port + " to " + this.forwardPort + "Forwarding request for " + target.port + " to " + this.forwardPort, ); req.headers["x-interactive-tool-port"] = target.port; _target.port = this.forwardPort; @@ -168,7 +187,7 @@ DynamicProxy.prototype.configureForward = function(req, target) { return _target; }; -DynamicProxy.prototype.handleProxyRequest = function(req, res) { +DynamicProxy.prototype.handleProxyRequest = function (req, res) { var othis = this; var target = this.targetForRequest(req); if (this.debug && target) { @@ -182,14 +201,14 @@ DynamicProxy.prototype.handleProxyRequest = function(req, res) { " to " + target.host + ":" + - target.port + target.port, ); } var origin = req.headers.origin; this.rewriteRequest(req); res.oldWriteHead = res.writeHead; - res.writeHead = function(statusCode, headers) { + res.writeHead = function (statusCode, headers) { if (othis.reverseProxy && statusCode === 302) { if (res && res._headers) { if (othis.debug) { @@ -198,7 +217,7 @@ DynamicProxy.prototype.handleProxyRequest = function(req, res) { if (res._headers.location) { res._headers.location = res._headers.location.replace( "http://localhost/", - "http://localhost:" + othis.port + "/" + "http://localhost:" + othis.port + "/", ); } if (othis.debug) { @@ -226,29 +245,29 @@ DynamicProxy.prototype.handleProxyRequest = function(req, res) { req, res, { - target: target + target: target, }, - function(e) { + function (e) { console.log("Proxy error: ", e); res.writeHead(502); res.write("Proxy target missing"); res.end(); - } + }, ); }; -DynamicProxy.prototype.handleWs = function(req, res, head) { +DynamicProxy.prototype.handleWs = function (req, res, head) { // no local route found, time to proxy var target = this.targetForRequest(req); if (this.debug && target) { console.log( - "PROXY WS " + req.url + " to " + target.host + ":" + target.port + "PROXY WS " + req.url + " to " + target.host + ":" + target.port, ); } var origin = req.headers.origin; this.rewriteRequest(req); res.oldWriteHead = res.writeHead; - res.writeHead = function(statusCode, headers) { + res.writeHead = function (statusCode, headers) { try { if (origin) { res.setHeader("Access-Control-Allow-Origin", origin); @@ -269,18 +288,18 @@ DynamicProxy.prototype.handleWs = function(req, res, head) { res, head, { - target: target + target: target, }, - function(e) { + function (e) { console.log("Proxy error: ", e); res.writeHead(502); res.write("Proxy target missing"); res.end(); - } + }, ); }; -DynamicProxy.prototype.listen = function(args_) { +DynamicProxy.prototype.listen = function (args_) { const args = args_ || {}; const port = this.port || 8000; const ip = args.ip || "localhost"; @@ -290,7 +309,7 @@ DynamicProxy.prototype.listen = function(args_) { this.proxy_server.listen(port, ip); }; -DynamicProxy.prototype.close = function() { +DynamicProxy.prototype.close = function () { this.proxy.close(); this.proxy_server.close(); }; diff --git a/package.json b/package.json index bf2af33..193577c 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,6 @@ "eslint": "^8.3.0", "eslint-config-strongloop": "^2.1.0", "mocha": "*", - "prettier": "1.19.1" + "prettier": "^3.0.3" } } diff --git a/test/test.js b/test/test.js index 81f3192..7faeb43 100644 --- a/test/test.js +++ b/test/test.js @@ -16,7 +16,7 @@ axiosRetry(axios, { retries: 3 }); const TEST_PORT = 9000; // Loosely based on https://stackoverflow.com/questions/16333790/node-js-quick-file-server-static-files-over-http -const testServer = http.createServer(function(req, res) { +const testServer = http.createServer(function (req, res) { console.log(`${req.method} ${req.url}`); // parse URL @@ -38,10 +38,10 @@ const testServer = http.createServer(function(req, res) { ".mp3": "audio/mpeg", ".svg": "image/svg+xml", ".pdf": "application/pdf", - ".doc": "application/msword" + ".doc": "application/msword", }; - fs.exists(pathname, function(exist) { + fs.exists(pathname, function (exist) { if (!exist) { // if the file is not found, return 404 res.statusCode = 404; @@ -53,7 +53,7 @@ const testServer = http.createServer(function(req, res) { if (fs.statSync(pathname).isDirectory()) pathname += "/index" + ext; // read file from file system - fs.readFile(pathname, function(err, data) { + fs.readFile(pathname, function (err, data) { if (err) { res.statusCode = 500; res.end(`Error getting the file: ${err}.`); @@ -66,7 +66,7 @@ const testServer = http.createServer(function(req, res) { }); }); -const waitForServer = async function(server, listening) { +const waitForServer = async function (server, listening) { for (;;) { if (server.listening == listening) { return; @@ -75,153 +75,256 @@ const waitForServer = async function(server, listening) { } }; -const useTestServer = function() { - before(async function() { +const useTestServer = function () { + before(async function () { testServer.listen(TEST_PORT); await waitForServer(testServer, true); }); - after(async function() { + after(async function () { testServer.close(); await waitForServer(testServer, false); }); }; -const verifyProxyOnPort = async function(port, headers, path='/README.md') { +const verifyProxyOnPort = async function (port, headers, path = "/README.md") { let res = await axios.get(`http://localhost:${port}${path}`, { - headers: headers + headers: headers, }); let { data } = res; data.should.include("# A dynamic configurable reverse proxy"); }; -describe("test server", function() { +describe("test server", function () { useTestServer(); - it("should serve direct requests as files", async function() { + it("should serve direct requests as files", async function () { await verifyProxyOnPort(TEST_PORT, {}); }); }); -describe("DynamicProxy", function() { +describe("DynamicProxy", function () { useTestServer(); - describe("x-interactive-tool-* headers", function() { - it("should respect host and port", async function() { + describe("x-interactive-tool-* headers", function () { + it("should respect host and port", async function () { const proxy = new DynamicProxy({ port: 5098, verbose: true }); proxy.listen(); // This never becomes True for the proxy server... why? // await waitForServer(proxy.proxy, true); const headers = { "x-interactive-tool-host": "localhost", - "x-interactive-tool-port": TEST_PORT + "x-interactive-tool-port": TEST_PORT, }; await verifyProxyOnPort(5098, headers); proxy.close(); }); }); - describe("x-interactive-tool-target headers", function() { - it("should respect host and port in one header", async function() { + describe("x-interactive-tool-target headers", function () { + it("should respect host and port in one header", async function () { const proxy = new DynamicProxy({ port: 5097, verbose: true }); proxy.listen(); const headers = { - "x-interactive-tool-host": "localhost:" + TEST_PORT + "x-interactive-tool-host": "localhost:" + TEST_PORT, }; await verifyProxyOnPort(5097, headers); proxy.close(); }); }); - describe("map based forwarding using subdomain", function() { - it("should respect session map", async function() { + describe("map based forwarding using subdomain", function () { + it("should respect session map", async function () { const sessionMap = { coolkey: { token: "cooltoken", target: { host: "localhost", - port: TEST_PORT - } - } + port: TEST_PORT, + }, + }, }; const proxy = new DynamicProxy({ port: 5099, verbose: true, - sessionMap: sessionMap + sessionMap: sessionMap, }); proxy.listen(); const headers = { - host: "coolkey-cooltoken.usegalaxy.org" + host: "coolkey-cooltoken.usegalaxy.org", }; await verifyProxyOnPort(5099, headers); proxy.close(); }); }); - describe("map based forwarding using path", function() { - it("should respect session map", async function() { - const sessionMap = { - coolkey: { - token: "cooltoken", - target: { - host: "localhost", - port: TEST_PORT - } - } - }; - const proxy = new DynamicProxy({ - port: 5099, - verbose: true, - sessionMap: sessionMap, - proxyPathPrefix: '/interactivetool/access/interactivetoolentrypoint', + describe("map based path forwarding to full path", function () { + it("should respect session map with requires_path_in_url=true and leave path unmodified", async function () { + const sessionMap = { + coolkey: { + token: "cooltoken", + target: { + host: "localhost", + port: TEST_PORT, + }, + requires_path_in_url: true, + }, + }; + const proxy = new DynamicProxy({ + port: 5100, + verbose: true, + sessionMap: sessionMap, + proxyPathPrefix: "/test_data/interactivetool/ep", + }); + proxy.listen(); + const headers = { + host: "usegalaxy.org", + }; + const path = + "/test_data/interactivetool/ep/coolkey/cooltoken/extradir/README.md"; + await verifyProxyOnPort(5100, headers, path); + proxy.close(); }); - proxy.listen(); - const headers = { - host: "usegalaxy.org", - }; - const path = "/interactivetool/access/interactivetoolentrypoint/coolkey/cooltoken/README.md" - await verifyProxyOnPort(5099, headers, path); - proxy.close(); }); -}); - describe("double proxying", function() { - it("should proxy across two servers", async function() { + describe("map based path forwarding to top-level path (default)", function () { + it("should respect session map without requires_path_in_url and strip entry point path from url", async function () { const sessionMap = { coolkey: { token: "cooltoken", target: { host: "localhost", - port: TEST_PORT - } - } + port: TEST_PORT, + }, + }, + }; + const proxy = new DynamicProxy({ + port: 5101, + verbose: true, + sessionMap: sessionMap, + proxyPathPrefix: "/interactivetool/ep", + }); + proxy.listen(); + const headers = { + host: "usegalaxy.org", + }; + // "/interactivetool/ep/coolkey/cooltoken" will be stripped from the path and + // "/test_data/extradir/README.md" will be read and validated + const path = + "/interactivetool/ep/coolkey/cooltoken/test_data/extradir/README.md"; + await verifyProxyOnPort(5101, headers, path); + proxy.close(); + }); + }); + + describe("map based path forwarding to top-level path (requires_path_in_url=false)", function () { + it("should respect session map with requires_path_in_url=false and strip entry point path from url", async function () { + const sessionMap = { + coolkey: { + token: "cooltoken", + target: { + host: "localhost", + port: TEST_PORT, + }, + requires_path_in_url: false, + }, + }; + const proxy = new DynamicProxy({ + port: 5102, + verbose: true, + sessionMap: sessionMap, + proxyPathPrefix: "/interactivetool/ep", + }); + proxy.listen(); + const headers = { + host: "usegalaxy.org", + }; + // "/interactivetool/ep/coolkey/cooltoken" will be stripped from the path and + // "/test_data/extradir/README.md" will be read and validated + const path = + "/interactivetool/ep/coolkey/cooltoken/test_data/extradir/README.md"; + await verifyProxyOnPort(5102, headers, path); + proxy.close(); + }); + }); + + describe("map based path forwarding to top-level path with entry point path in header", function () { + it( + 'should respect session map with requires_path_in_header_named="X-My-Header", strip entry point path from ' + + 'url and instead provide it in header "X-My-Header"', + async function () { + const sessionMap = { + coolkey: { + token: "cooltoken", + target: { + host: "localhost", + port: TEST_PORT, + }, + requires_path_in_header_named: "X-My-Header", + }, + }; + const proxy = new DynamicProxy({ + port: 5103, + verbose: true, + sessionMap: sessionMap, + proxyPathPrefix: "/interactivetool/ep", + }); + proxy.listen(); + const headers = { + host: "usegalaxy.org", + }; + + proxy.proxy.on("proxyReq", function (proxyReq) { + proxyReq + .getHeader("X-My-Header") + .should.equal("/interactivetool/ep/coolkey/cooltoken"); + }); + + const path = + "/interactivetool/ep/coolkey/cooltoken/test_data/extradir/README.md"; + await verifyProxyOnPort(5103, headers, path); + proxy.close(); + }, + ); + }); + + describe("double proxying", function () { + it("should proxy across two servers", async function () { + const sessionMap = { + coolkey: { + token: "cooltoken", + target: { + host: "localhost", + port: TEST_PORT, + }, + }, }; const outerProxy = new DynamicProxy({ - port: 5100, + port: 5200, verbose: true, sessionMap: sessionMap, forwardIP: "localhost", - forwardPort: 5101 + forwardPort: 5201, }); - const innerProxy = new DynamicProxy({ port: 5101, verbose: true }); + const innerProxy = new DynamicProxy({ port: 5201, verbose: true }); outerProxy.listen(); innerProxy.listen(); const headers = { - host: "coolkey-cooltoken.usegalaxy.org" + host: "coolkey-cooltoken.usegalaxy.org", }; - await verifyProxyOnPort(5100, headers); + await verifyProxyOnPort(5200, headers); innerProxy.close(); outerProxy.close(); }); }); }); -describe("Main function", function() { +describe("Main function", function () { useTestServer(); - it("should parse simple arguments and start proxy", async function() { - const proxy = main(["nodejs", "coolproxy", "--port", "5200", "--verbose"]); + it("should parse simple arguments and start proxy", async function () { + const proxy = main(["nodejs", "coolproxy", "--port", "5300", "--verbose"]); const headers = { "x-interactive-tool-host": "localhost", - "x-interactive-tool-port": TEST_PORT + "x-interactive-tool-port": TEST_PORT, }; - await verifyProxyOnPort(5200, headers); + await verifyProxyOnPort(5300, headers); proxy.close(); }); }); diff --git a/test_data/extradir/README.md b/test_data/extradir/README.md new file mode 120000 index 0000000..fe84005 --- /dev/null +++ b/test_data/extradir/README.md @@ -0,0 +1 @@ +../../README.md \ No newline at end of file diff --git a/test_data/interactivetool/ep/coolkey/cooltoken/extradir/README.md b/test_data/interactivetool/ep/coolkey/cooltoken/extradir/README.md new file mode 120000 index 0000000..8addd38 --- /dev/null +++ b/test_data/interactivetool/ep/coolkey/cooltoken/extradir/README.md @@ -0,0 +1 @@ +../../../../../../README.md \ No newline at end of file