diff --git a/bin/configurable-http-proxy b/bin/configurable-http-proxy index c85e53ee..6f5eca17 100755 --- a/bin/configurable-http-proxy +++ b/bin/configurable-http-proxy @@ -10,6 +10,8 @@ import fs from "node:fs"; import { Command } from "commander"; import ConfigurableProxy from "../lib/configproxy.js"; +import { parseListenOptions } from "../lib/configproxy.js"; + import { defaultLogger } from "../lib/log.js"; import { createRequire } from "node:module"; @@ -21,6 +23,7 @@ cli .version(pkg.version) .option("--ip ", "Public-facing IP of the proxy") .option("--port (defaults to 8000)", "Public-facing port of the proxy", parseInt) + .option("--socket ", "Path to a UNIX domain socket for the proxy to listen on. Alternative to specifying IP and port.") .option("--ssl-key ", "SSL key to use, if any") .option("--ssl-cert ", "SSL certificate to use, if any") .option("--ssl-ca ", "SSL certificate authority, if any") @@ -39,6 +42,7 @@ cli "Inward-facing port for API requests (defaults to --port=value+1)", parseInt ) + .option("--api-socket ", "Path to a UNIX domain socket for the API server to listen on. Alternative to specifying API IP and port.") .option("--api-ssl-key ", "SSL key to use, if any, for API requests") .option("--api-ssl-cert ", "SSL certificate to use, if any, for API requests") .option("--api-ssl-ca ", "SSL certificate authority, if any, for API requests") @@ -93,6 +97,7 @@ cli .option("--host-routing", "Use host routing (host as first level of path)") .option("--metrics-ip ", "IP for metrics server", "") .option("--metrics-port ", "Port of metrics server. Defaults to no metrics server") + .option("--metrics-socket ", "Path to a UNIX domain socket for the metrics server to listen on. Alternative to specifying metrics IP and port.") .option("--log-level ", "Log level (debug, info, warn, error)", "info") .option( "--timeout ", @@ -279,7 +284,7 @@ options.proxyTimeout = args.proxyTimeout; options.keepAliveTimeout = args.keepAliveTimeout; // metrics options -options.enableMetrics = !!args.metricsPort; +options.enableMetrics = !!args.metricsPort || !!args.metricsSocket; // certs need to be provided for https redirection if (!options.ssl && options.redirectPort) { @@ -320,42 +325,27 @@ options.storageBackend = args.storageBackend; var proxy = new ConfigurableProxy(options); -var listen = {}; -listen.port = parseInt(args.port) || 8000; -if (args.ip === "*") { - // handle ip=* alias for all interfaces - log.warn( - "Interpreting ip='*' as all-interfaces. Preferred usage is 0.0.0.0 for all IPv4 or '' for all-interfaces." - ); - args.ip = ""; -} -listen.ip = args.ip; -listen.apiIp = args.apiIp; -listen.apiPort = args.apiPort || listen.port + 1; -listen.metricsIp = args.metricsIp; -listen.metricsPort = args.metricsPort; - -proxy.proxyServer.listen(listen.port, listen.ip); -proxy.apiServer.listen(listen.apiPort, listen.apiIp); -if (listen.metricsPort) { - proxy.metricsServer.listen(listen.metricsPort, listen.metricsIp); +var listen = parseListenOptions(args); + +proxy.proxyServer.listen(...listen.proxyTarget); +proxy.apiServer.listen(...listen.apiTarget); +if (listen.metricsTarget) { + proxy.metricsServer.listen(...listen.metricsTarget); } log.info( - "Proxying %s://%s:%s to %s", + "Proxying %s://%s to %s", options.ssl ? "https" : "http", - listen.ip || "*", - listen.port, + listen.proxyTarget.join(":"), options.defaultTarget || "(no default)" ); log.info( - "Proxy API at %s://%s:%s/api/routes", + "Proxy API at %s://%s/api/routes", options.apiSsl ? "https" : "http", - listen.apiIp || "*", - listen.apiPort + listen.apiTarget.join(":") ); -if (listen.metricsPort) { - log.info("Serve metrics at %s://%s:%s/metrics", "http", listen.metricsIp, listen.metricsPort); +if (listen.metricsTarget) { + log.info("Serve metrics at %s://%s/metrics", "http", listen.metricsTarget.join(":")); } if (args.pidFile) { @@ -370,7 +360,7 @@ if (args.pidFile) { } // Redirect HTTP to HTTPS on the proxy's port -if (options.redirectPort && listen.port !== 80) { +if (options.redirectPort && listen.port && listen.port !== 80) { var http = require("http"); var redirectPort = options.redirectTo ? options.redirectTo : listen.port; var server = http diff --git a/lib/configproxy.js b/lib/configproxy.js index c88fe9ac..1fb99ec0 100644 --- a/lib/configproxy.js +++ b/lib/configproxy.js @@ -24,6 +24,48 @@ const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); +export function parseListenOptions(args) { + var listen = {}; + + if (args.socket) { + listen.proxyTarget = [args.socket]; + log.warn( + "Proxy will listen on UNIX domain socket, --ip and --port options will be ignored." + ); + } else { + listen.port = parseInt(args.port) || 8000; + if (args.ip === "*") { + // handle ip=* alias for all interfaces + log.warn( + "Interpreting ip='*' as all-interfaces. Preferred usage is 0.0.0.0 for all IPv4 or '' for all-interfaces." + ); + args.ip = ""; + } + listen.ip = args.ip; + listen.proxyTarget = [listen.port, listen.ip]; + } + + if (args.apiSocket) { + listen.apiSocket = [args.apiSocket]; + log.warn( + "API server will listen on UNIX domain socket, --api-ip and --api-port options will be ignored." + ); + } else { + listen.apiPort = args.apiPort ? parseInt(args.apiPort) : (listen.port ? listen.port + 1 : 8001); + listen.apiTarget = [listen.apiPort, args.apiIp]; + } + + if (args.metricsSocket) { + listen.metricsSocket = [args.metricsSocket]; + log.warn( + "Metrics server will listen on UNIX domain socket, --metrics-ip and --metrics-port options will be ignored." + ); + } else if (args.metricsPort) { + listen.metricsTarget = [parseInt(args.metricsPort), args.metricsIp]; + } + return listen; +} + function bound(that, method) { // bind a method, to ensure `this=that` when it is called // because prototype languages are bad diff --git a/lib/testutil.js b/lib/testutil.js index fac9e68a..df09c5b7 100644 --- a/lib/testutil.js +++ b/lib/testutil.js @@ -3,7 +3,7 @@ import http from "node:http"; import https from "node:https"; import { WebSocketServer } from "ws"; -import { ConfigurableProxy } from "./configproxy.js"; +import { ConfigurableProxy, parseListenOptions } from "./configproxy.js"; import { defaultLogger } from "./log.js"; var servers = []; @@ -87,14 +87,16 @@ export function addTargets(proxy, paths, port) { }); } -export function setupProxy(port, options, paths) { +export function setupProxy(listenOptions, options, paths) { options = options || {}; options.authToken = "secret"; options.log = defaultLogger({ level: "error" }); - + var listen = parseListenOptions(listenOptions); + var port = listen.port || 8000; + var ip = listen.ip; var proxy = new ConfigurableProxy(options); proxy._setup_timestamp = new Date(new Date().getTime() - 60000); - var ip = "127.0.0.1"; + var countdown = 2; var resolvePromise; @@ -134,10 +136,10 @@ export function setupProxy(port, options, paths) { proxy.proxyServer.on("listening", onlisten); addTargets(proxy, paths || ["/"], port + 2).then(function () { - proxy.proxyServer.listen(port, ip); - proxy.apiServer.listen(port + 1, ip); + proxy.proxyServer.listen(...listen.proxyTarget); + proxy.apiServer.listen(...listen.apiTarget); if (options.enableMetrics) { - proxy.metricsServer.listen(port + 3, ip); + proxy.metricsServer.listen(...listen.metricsTarget); } }); return p; diff --git a/test/api_spec.js b/test/api_spec.js index 0511b55b..0affcd82 100644 --- a/test/api_spec.js +++ b/test/api_spec.js @@ -8,15 +8,19 @@ log.remove(log.transports.Console); describe("API Tests", function () { var port = 8902; - var apiPort = port + 1; + var listenOptions = { + port: port, + apiPort: 8903, + ip: '127.0.0.1' + }; var proxy; - var apiUrl = "http://127.0.0.1:" + apiPort + "/api/routes"; + var apiUrl = "http://" + listenOptions.ip + ":" + listenOptions.apiPort + "/api/routes"; var r; beforeEach(function (callback) { util - .setupProxy(port) + .setupProxy(listenOptions) .then(function (newProxy) { proxy = newProxy; }) diff --git a/test/proxy_spec.js b/test/proxy_spec.js index eccb046e..bca788f9 100644 --- a/test/proxy_spec.js +++ b/test/proxy_spec.js @@ -13,14 +13,18 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); describe("Proxy Tests", function () { var port = 8902; + var listenOptions = { + port: port, + ip: '127.0.0.1' + }; var testPort = port + 10; var proxy; - var proxyUrl = "http://127.0.0.1:" + port; + var proxyUrl = "http://" + listenOptions.ip + ":" + port; var hostTest = "test.localhost.jovyan.org"; var hostUrl = "http://" + hostTest + ":" + port; beforeEach(function (callback) { - util.setupProxy(port).then(function (newProxy) { + util.setupProxy(listenOptions).then(function (newProxy) { proxy = newProxy; callback(); }); @@ -338,10 +342,12 @@ describe("Proxy Tests", function () { }); it("custom error target", function (done) { - var proxyPort = 55550; + var listenOptions = { + port: 55550 + }; util - .setupProxy(proxyPort, { errorTarget: "http://127.0.0.1:55565" }, []) - .then(() => fetch("http://127.0.0.1:" + proxyPort + "/foo/bar")) + .setupProxy(listenOptions, { errorTarget: "http://127.0.0.1:55565" }, []) + .then(() => fetch("http://127.0.0.1:" + listenOptions.port + "/foo/bar")) .then((res) => { expect(res.status).toEqual(404); expect(res.headers.get("content-type")).toEqual("text/plain"); @@ -405,10 +411,12 @@ describe("Proxy Tests", function () { }); it("backend error", function (done) { - var proxyPort = 55550; + var listenOptions = { + port: 55550 + }; util - .setupProxy(proxyPort, { errorTarget: "http://127.0.0.1:55565" }, []) - .then(() => fetch("http://127.0.0.1:" + proxyPort + "/%")) + .setupProxy(listenOptions, { errorTarget: "http://127.0.0.1:55565" }, []) + .then(() => fetch("http://127.0.0.1:" + listenOptions.port + "/%")) .then((res) => { expect(res.status).toEqual(500); expect(res.headers.get("content-type")).toEqual("text/plain"); @@ -433,7 +441,9 @@ describe("Proxy Tests", function () { }); it("Redirect location with rewriting", function (done) { - var proxyPort = 55556; + var listenOptions = { + port: 55556 + }; var options = { protocolRewrite: "https", autoRewrite: true, @@ -442,10 +452,10 @@ describe("Proxy Tests", function () { // where the backend server redirects us. // Note that http-proxy requires (logically) the redirection to be to the same (internal) host. var redirectTo = "https://127.0.0.1:" + testPort + "/whatever"; - var expectedRedirect = "https://127.0.0.1:" + proxyPort + "/whatever"; + var expectedRedirect = "https://127.0.0.1:" + listenOptions.port + "/whatever"; util - .setupProxy(proxyPort, options, []) + .setupProxy(listenOptions, options, []) .then((proxy) => util.addTargetRedirecting( proxy, @@ -456,7 +466,7 @@ describe("Proxy Tests", function () { ) ) .then(() => - fetch("http://127.0.0.1:" + proxyPort + "/external/urlpath/", { redirect: "manual" }) + fetch("http://127.0.0.1:" + listenOptions.port + "/external/urlpath/", { redirect: "manual" }) ) .then((res) => { expect(res.status).toEqual(301); @@ -483,8 +493,10 @@ describe("Proxy Tests", function () { done(); return; } - var proxyPort = 55556; - var testPort = proxyPort + 20; + var listenOptions = { + port: 55556 + }; + var testPort = listenOptions.port + 20; var options = { clientSsl: { key: fs.readFileSync(path.resolve(__dirname, "ssl/proxy-client/proxy-client.key")), @@ -494,7 +506,7 @@ describe("Proxy Tests", function () { }; util - .setupProxy(proxyPort, options, []) + .setupProxy(listenOptions, options, []) .then((proxy) => util.addTarget(proxy, "/backend/", testPort, false, null, { key: fs.readFileSync(path.resolve(__dirname, "ssl/backend/backend.key")), @@ -503,7 +515,7 @@ describe("Proxy Tests", function () { requestCert: true, }) ) - .then(() => fetch("http://127.0.0.1:" + proxyPort + "/backend/urlpath/")) + .then(() => fetch("http://127.0.0.1:" + listenOptions.port + "/backend/urlpath/")) .then((res) => { expect(res.status).toEqual(200); }) @@ -513,3 +525,79 @@ describe("Proxy Tests", function () { .then(done); }); }); + + + +describe("Proxy Tests with Unix socket", function () { + var listenOptions = { + socketPath: '/tmp/test.sock', + }; + var proxy; + var proxyUrl = "http://localhost/" + var hostTest = "test.localhost.jovyan.org"; + var hostUrl = "http://" + hostTest + ":" + '8902'; + + beforeEach(function (callback) { + util.setupProxy(listenOptions).then(function (newProxy) { + proxy = newProxy; + callback(); + }); + }); + + afterEach(function (callback) { + util.teardownServers(callback); + }); + + it("basic HTTP request", function (done) { + fetch(proxyUrl, {unix: listenOptions.socketPath}) + .then((res) => res.json()) + .then((body) => { + expect(body).toEqual( + jasmine.objectContaining({ + path: "/", + }) + ); + + // check last_activity was updated + return proxy._routes.get("/").then((route) => { + expect(route.last_activity).toBeGreaterThan(proxy._setup_timestamp); + done(); + }); + }); + }); + + it("basic WebSocket request", function (done) { + var ws = new WebSocket("ws:///tmp/test.sock"); + ws.on("error", function () { + // jasmine fail is only in master + expect("error").toEqual("ok"); + done(); + }); + var nmsgs = 0; + ws.on("message", function (msg) { + msg = msg.toString(); + if (nmsgs === 0) { + expect(msg).toEqual("connected"); + } else { + msg = JSON.parse(msg); + expect(msg).toEqual( + jasmine.objectContaining({ + path: "/", + message: "hi", + }) + ); + // check last_activity was updated + return proxy._routes.get("/").then((route) => { + expect(route.last_activity).toBeGreaterThan(proxy._setup_timestamp); + ws.close(); + done(); + }); + } + nmsgs++; + }); + ws.on("open", function () { + ws.send("hi"); + }); + }); + +}); \ No newline at end of file