diff --git a/README.md b/README.md index d4e1f86..a677df2 100755 --- a/README.md +++ b/README.md @@ -65,7 +65,12 @@ nodes: rateLimit: windowMs: 30000 maxRequests: 600 -version: "2025-03-07" +headers: + "https://api.justyy.com": + "X-Edge-Key": "${X_EDGE_KEY}" + "https://api2.justyy.com": + "X-Edge-Key": "${X_EDGE_KEY}" +version: "2026-01-14" max_age: 3 logging: true max_payload_size: "5mb" @@ -112,6 +117,7 @@ strategy: "max_jussi_number" # options: first, random, max_jussi_number, latest - debug: When set to debug, more messages are set e.g. in the response header. - firstK: Choosing the node which has the max Jussi Number from the first `firstK` nodes that respond OK. Default is 1. - strategy: The strategy to pick the chosen node. This can be one of: first, random, max_jussi_number (default), latest_version +- headers: The headers can be specified to pass to the downstream nodes. When proxying requests, the Load Balancer injects a shared-secret header so downstream nodes can identify trusted traffic and apply elevated (or exempt) rate-limit policies. ## Installation Clone the Repository: @@ -220,7 +226,7 @@ Use the following script i.e. [integration-tests.sh](./tests/integration-tests.s ```bash source ./setup-env.sh -## on success, exit code is 0. +## on success, e1it code is 0. ## on failure, exit code is 1. ./tests/integration-tests.sh ``` diff --git a/config.yaml b/config.yaml index 6aea605..68bcd40 100644 --- a/config.yaml +++ b/config.yaml @@ -15,6 +15,11 @@ nodes: rateLimit: windowMs: 10000 maxRequests: 600 +headers: + "https://api.justyy.com": + "X-Edge-Key": "${X_EDGE_KEY}" + "https://api2.justyy.com": + "X-Edge-Key": "${X_EDGE_KEY}" version: "${STEEM_LB_VERSION}" max_age: 3 logging: true diff --git a/docker-compose.yml b/docker-compose.yml index bf6825b..dda0272 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,7 @@ services: - CACHE_TTL=${CACHE_TTL} - STEEM_LB_VERSION=${STEEM_LB_VERSION} - DEBUG=${DEBUG} + - X_EDGE_KEY=${X_EDGE_KEY} volumes: - /root/.acme.sh/:/root/.acme.sh/ - ./config.yaml:/app/config.yaml diff --git a/jest.config.mjs b/jest.config.mjs index 76940a6..a2ee950 100644 --- a/jest.config.mjs +++ b/jest.config.mjs @@ -13,10 +13,10 @@ export default { coverageReporters: ["text", "lcov", "json", "json-summary", "html"], // Specify desired coverage reporters coverageThreshold: { global: { - branches: 21, - functions: 45, - lines: 23, - statements: 24, + branches: 22.52, + functions: 48.52, + lines: 29.15, + statements: 30.35, }, }, }; diff --git a/js_tests/forward.test.js b/js_tests/forward.test.js new file mode 100644 index 0000000..ecab5db --- /dev/null +++ b/js_tests/forward.test.js @@ -0,0 +1,145 @@ +import fetch from "node-fetch"; +import { sleep, limitStringMaxLength } from "../src/functions.js"; +import { forwardRequestGET, forwardRequestPOST } from "../src/network.js"; + +jest.mock("node-fetch", () => jest.fn()); +jest.mock("../src/functions.js", () => { + const actual = jest.requireActual("../src/functions.js"); + return { + ...actual, + sleep: jest.fn(), + limitStringMaxLength: jest.fn((s) => s), + log: jest.fn(), + }; +}); + +describe("forwardRequestGET", () => { + const apiURL = "https://example.com/api"; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + test("passes headers and returns data", async () => { + // Mock fetch with a simple object implementing .text() and .status + fetch.mockResolvedValue({ + status: 200, + text: jest.fn().mockResolvedValue("OK"), + }); + + const result = await forwardRequestGET(apiURL, { + agent: false, + timeout: 5000, + retry_count: 1, + user_agent: "jest-agent", + headers: { "X-Test": "123" }, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + + const [url, options] = fetch.mock.calls[0]; + expect(url).toBe(apiURL); + expect(options.headers).toMatchObject({ + "Content-Type": "application/json", + "User-Agent": "jest-agent", + "X-Test": "123", + }); + + expect(result).toEqual({ statusCode: 200, data: "OK" }); + }); + + test("retries on failure", async () => { + fetch + .mockRejectedValueOnce(new Error("network error")) + .mockResolvedValueOnce({ + status: 200, + text: jest.fn().mockResolvedValue("OK"), + }); + + const result = await forwardRequestGET(apiURL, { + agent: false, + timeout: 5000, + retry_count: 2, + user_agent: "jest-agent", + headers: { "X-Retry": "yes" }, + }); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + expect(result.statusCode).toBe(200); + }); + + test("throws after max retries", async () => { + fetch.mockRejectedValue(new Error("timeout")); + + await expect( + forwardRequestGET(apiURL, { + agent: false, + timeout: 5000, + retry_count: 2, + user_agent: "jest-agent", + headers: {}, + }), + ).rejects.toThrow("timeout"); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); +}); + +describe("forwardRequestPOST", () => { + const apiURL = "https://example.com/api"; + const body = JSON.stringify({ hello: "world" }); + + beforeEach(() => { + jest.clearAllMocks(); + limitStringMaxLength.mockImplementation((s) => s); + }); + + test("passes headers and body and returns data", async () => { + fetch.mockResolvedValue({ + status: 201, + text: jest.fn().mockResolvedValue("CREATED"), + }); + + const result = await forwardRequestPOST(apiURL, body, { + agent: false, + timeout: 5000, + retry_count: 1, + user_agent: "jest-agent", + logging_max_body_len: 100, + headers: { Authorization: "Bearer token" }, + }); + + expect(fetch).toHaveBeenCalledTimes(1); + const [url, options] = fetch.mock.calls[0]; + + expect(url).toBe(apiURL); + expect(options.body).toBe(body); + expect(options.headers).toMatchObject({ + "Content-Type": "application/json", + "User-Agent": "jest-agent", + Authorization: "Bearer token", + }); + + expect(result).toEqual({ statusCode: 201, data: "CREATED" }); + }); + + test("retries and throws after failure", async () => { + fetch.mockRejectedValue(new Error("server down")); + + await expect( + forwardRequestPOST(apiURL, body, { + agent: false, + timeout: 5000, + retry_count: 2, + user_agent: "jest-agent", + logging_max_body_len: 100, + headers: {}, + }), + ).rejects.toThrow("server down"); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(sleep).toHaveBeenCalledTimes(1); + }); +}); diff --git a/package-lock.json b/package-lock.json index 1254f63..40229bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "@babel/preset-env": "^7.26.9", "@vitest/coverage-v8": "^3.2.4", "babel-jest": "^29.7.0", + "baseline-browser-mapping": "^2.9.14", "eslint": "^9.27.0", "globals": "^16.0.0", "jest": "^29.7.0", @@ -3843,10 +3844,11 @@ "dev": true }, "node_modules/baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", "dev": true, + "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.js" } @@ -10955,9 +10957,9 @@ "dev": true }, "baseline-browser-mapping": { - "version": "2.8.10", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.10.tgz", - "integrity": "sha512-uLfgBi+7IBNay8ECBO2mVMGZAc1VgZWEChxm4lv+TobGdG82LnXMjuNGo/BSSZZL4UmkWhxEHP2f5ziLNwGWMA==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.14.tgz", + "integrity": "sha512-B0xUquLkiGLgHhpPBqvl7GWegWBUNuujQ6kXd/r1U38ElPT6Ok8KZ8e+FpUGEc2ZoRQUzq/aUnaKFc/svWUGSg==", "dev": true }, "body-parser": { diff --git a/package.json b/package.json index 0b25e77..53c6929 100755 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@babel/preset-env": "^7.26.9", "@vitest/coverage-v8": "^3.2.4", "babel-jest": "^29.7.0", + "baseline-browser-mapping": "^2.9.14", "eslint": "^9.27.0", "globals": "^16.0.0", "jest": "^29.7.0", diff --git a/run.sh b/run.sh index 1f50928..5ab4582 100755 --- a/run.sh +++ b/run.sh @@ -26,6 +26,7 @@ docker run \ -e CACHE_ENABLED=$CACHE_ENABLED \ -e CACHE_TTL=$CACHE_TTL \ -e STEEM_LB_VERSION=$STEEM_LB_VERSION \ + -e X_EDGE_KEY=$X_EDGE_KEY \ --name $DOCKER_IMAGE \ --restart on-failure:$RETRY_COUNT \ -p $STEEM_LB_PORT:9091 \ diff --git a/setup-env.sh b/setup-env.sh index 282e4d2..3798e1c 100755 --- a/setup-env.sh +++ b/setup-env.sh @@ -9,8 +9,10 @@ export SSL_KEY_PATH=$SSL_KEY_PATH export CACHE_ENABLED=true export CACHE_TTL=3 export DEBUG=false +export X_EDGE_KEY=$X_EDGE_KEY + ## if git is available, use the latest commit hash as version, otherwise use the current date export STEEM_LB_VERSION=`git rev-parse HEAD 2>/dev/null || date +%F` ## listen to port 443 (HTTPS) -export STEEM_LB_PORT=443 \ No newline at end of file +export STEEM_LB_PORT=443 diff --git a/src/index.js b/src/index.js index 71ccbeb..76b694b 100755 --- a/src/index.js +++ b/src/index.js @@ -563,28 +563,27 @@ app.all("/", async (req, res) => { try { if (method === "GET") { - result = await forwardRequestGET( - chosenNode.server, + result = await forwardRequestGET(chosenNode.server, { + agent, + timeout, retry_count, user_agent, - timeout, - agent, - ); + headers: config.headers?.[chosenNode.server] || {}, + }); } else if (method === "POST") { let reqbody = req.body; const body = JSON.stringify(reqbody); log( `Request Body is ${limitStringMaxLength(body, logging_max_body_len)}`, ); - result = await forwardRequestPOST( - chosenNode.server, - body, + result = await forwardRequestPOST(chosenNode.server, body, { agent, timeout, retry_count, user_agent, logging_max_body_len, - ); + headers: config.headers?.[chosenNode.server] || {}, + }); } else { return res.status(405).json({ error: "Method Not Allowed" }); } diff --git a/src/network.js b/src/network.js index 3839a3c..b708ff9 100644 --- a/src/network.js +++ b/src/network.js @@ -31,10 +31,7 @@ async function fetchWithTimeout(url, options = {}, timeout = 5000) { // Forward GET request to the chosen node async function forwardRequestGET( apiURL, - retry_count, - user_agent, - timeout, - agent, + { agent, timeout, retry_count, user_agent, headers } = {}, ) { for (let i = 0; i < retry_count; ++i) { try { @@ -48,6 +45,7 @@ async function forwardRequestGET( headers: { "Content-Type": "application/json", "User-Agent": user_agent, + ...headers, }, redirect: "follow", agent, @@ -72,11 +70,14 @@ async function forwardRequestGET( async function forwardRequestPOST( apiURL, body, - agent, - timeout, - retry_count, - user_agent, - logging_max_body_len, + { + agent, + timeout, + retry_count, + user_agent, + logging_max_body_len, + headers, + } = {}, ) { for (let i = 0; i < retry_count; ++i) { try { @@ -92,6 +93,7 @@ async function forwardRequestPOST( headers: { "Content-Type": "application/json", "User-Agent": user_agent, + ...headers, }, redirect: "follow", body: body,