From bd2d4bc9aea8cf92a3cbc7cbfe61c917d589d948 Mon Sep 17 00:00:00 2001 From: petruki <31597636+petruki@users.noreply.github.com> Date: Mon, 4 May 2026 17:43:35 -0700 Subject: [PATCH] feat: added HTTP/2 support and server protocol selection --- .env-cmdrc-template | 8 +- .github/FUNDING.yml | 1 - .github/workflows/master.yml | 2 +- .github/workflows/sonar.yml | 2 +- README.md | 10 ++ docker-compose.yml | 3 +- npm-shrinkwrap.json | 187 ++++++++++++++++------------- package.json | 10 +- src/app-server.js | 113 +++++++++++++++-- src/app.js | 10 +- tests/app.test.js | 6 + tests/unit-test/app-server.test.js | 167 ++++++++++++++++++++++++++ 12 files changed, 410 insertions(+), 109 deletions(-) create mode 100644 tests/unit-test/app-server.test.js diff --git a/.env-cmdrc-template b/.env-cmdrc-template index 9f70d265..f94f682c 100644 --- a/.env-cmdrc-template +++ b/.env-cmdrc-template @@ -2,6 +2,9 @@ "dev": { "ENV": "DEV", "PORT": "3000", + "SERVER_PROTOCOL": "auto", + "SSL_KEY": "", + "SSL_CERT": "", "MONGODB_URI": "mongodb://mongodb:27017/switcher-api", "RESOURCE_SECRECT": "admin", "JWT_SECRET": "[JWT_SECRET]", @@ -48,6 +51,9 @@ "ENV": "TEST", "NODE_OPTIONS": "--experimental-vm-modules", "PORT": "3000", + "SERVER_PROTOCOL": "auto", + "SSL_KEY": "", + "SSL_CERT": "", "MONGODB_URI": "mongodb://mongodb:27017/switcher-api-test", "JWT_SECRET": "[JWT_SECRET]", "JWT_ADMIN_TOKEN_RENEW_INTERVAL": "5m", @@ -75,4 +81,4 @@ "SWITCHER_GITOPS_JWT_SECRET": "[SWITCHER_GITOPS_JWT_SECRET]", "SWITCHER_GITOPS_URL": "http://localhost:8000" } -} \ No newline at end of file +} diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml index 21925c34..28caaaa6 100644 --- a/.github/FUNDING.yml +++ b/.github/FUNDING.yml @@ -1,3 +1,2 @@ -patreon: switcherapi ko_fi: petruki github: [petruki] \ No newline at end of file diff --git a/.github/workflows/master.yml b/.github/workflows/master.yml index b2f7bd22..1aece932 100644 --- a/.github/workflows/master.yml +++ b/.github/workflows/master.yml @@ -60,7 +60,7 @@ jobs: SWITCHER_API_LOGGER: false - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v7.0.0 + uses: sonarsource/sonarqube-scan-action@v8.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/.github/workflows/sonar.yml b/.github/workflows/sonar.yml index d90be917..77ee1cd5 100644 --- a/.github/workflows/sonar.yml +++ b/.github/workflows/sonar.yml @@ -75,7 +75,7 @@ jobs: SWITCHER_API_LOGGER: false - name: SonarCloud Scan - uses: sonarsource/sonarqube-scan-action@v7.0.0 + uses: sonarsource/sonarqube-scan-action@v8.0.0 env: SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} if: env.SONAR_TOKEN != '' diff --git a/README.md b/README.md index 55504767..88b20133 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,16 @@ Main features: 2. Add .env-cmdrc file into the project directory (use '.env-cmdrc-template') 3. Replace values such as secret keys and URLs +### HTTP/2 and TLS + +Switcher API can negotiate HTTP/2 directly from the Node.js server when TLS certificates are available. + +- `SERVER_PROTOCOL=auto` (default): uses HTTP/2 when `SSL_KEY` and `SSL_CERT` are configured; otherwise uses cleartext HTTP/1.1. +- `SERVER_PROTOCOL=http1`: forces HTTP/1.1. If TLS certificates are configured, the server stays on HTTPS over HTTP/1.1. +- `SERVER_PROTOCOL=http2`: requires `SSL_KEY` and `SSL_CERT`; startup fails fast when they are missing. +- The HTTP/2 server is created with HTTP/1.1 fallback enabled, so TLS clients without HTTP/2 support can still connect. +- `GET /check?details=1` reports the active server mode and negotiated protocol configuration. + ### Auth Providers Switcher API supports multiple auth providers such as email/password-based authentication, SAML 2.0 for Single Sign-On (SSO), or GitHub/Bitbucket OAuth. diff --git a/docker-compose.yml b/docker-compose.yml index ecad01c0..1b9ce331 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: - NODE_ENV=development - PORT=3000 - ENV=${ENV} + - SERVER_PROTOCOL=${SERVER_PROTOCOL:-auto} - SSL_KEY=${SSL_KEY} - SSL_CERT=${SSL_CERT} @@ -107,4 +108,4 @@ services: - BITBUCKET_CLIENTID=${BITBUCKET_OAUTH_CLIENT_ID} - SWITCHERSLACKAPP_URL=${SWITCHERSLACKAPP_URL} depends_on: - - switcherapi \ No newline at end of file + - switcherapi diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 88af38f0..36a9ba47 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -10,12 +10,12 @@ "license": "MIT", "dependencies": { "@node-saml/passport-saml": "^5.1.0", - "axios": "^1.15.0", + "axios": "^1.16.0", "bcryptjs": "^3.0.3", "cors": "^2.8.6", "express": "^5.2.1", "express-basic-auth": "^1.2.1", - "express-rate-limit": "^8.3.2", + "express-rate-limit": "^8.5.0", "express-session": "^1.19.0", "express-validator": "^7.3.2", "graphql": "^16.13.2", @@ -24,8 +24,8 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "moment": "^2.30.1", - "mongodb": "^7.1.1", - "mongoose": "^9.4.1", + "mongodb": "^7.2.0", + "mongoose": "^9.6.1", "passport": "^0.7.0", "pino": "^10.3.1", "pino-pretty": "^13.1.3", @@ -35,7 +35,7 @@ }, "devDependencies": { "env-cmd": "^11.0.0", - "eslint": "^10.2.0", + "eslint": "^10.3.0", "jest": "^30.3.0", "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", @@ -60,9 +60,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", "dev": true, "license": "MIT", "engines": { @@ -231,9 +231,9 @@ } }, "node_modules/@babel/parser": { - "version": "7.29.2", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", - "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", "dev": true, "license": "MIT", "dependencies": { @@ -551,9 +551,9 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, @@ -563,9 +563,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", - "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -692,29 +692,43 @@ } }, "node_modules/@humanfs/core": { - "version": "0.19.1", - "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", - "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, "engines": { "node": ">=18.18.0" } }, "node_modules/@humanfs/node": { - "version": "0.16.7", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", - "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@humanfs/core": "^0.19.1", + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -835,9 +849,9 @@ } }, "node_modules/@istanbuljs/schema": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", - "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", "dev": true, "license": "MIT", "engines": { @@ -1235,9 +1249,9 @@ } }, "node_modules/@mongodb-js/saslprep": { - "version": "1.4.6", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.6.tgz", - "integrity": "sha512-y+x3H1xBZd38n10NZF/rEBlvDOOMQ6LKUTHqr8R9VkJ+mmQOYtJFxIlkkK8fZrtOiL6VixbOBWMbZGBdal3Z1g==", + "version": "1.4.11", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.4.11.tgz", + "integrity": "sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==", "license": "MIT", "dependencies": { "sparse-bitfield": "^3.0.3" @@ -1405,9 +1419,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1991,9 +2005,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -2036,9 +2050,9 @@ } }, "node_modules/ajv": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", - "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", "dev": true, "license": "MIT", "dependencies": { @@ -2157,12 +2171,12 @@ } }, "node_modules/axios": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", - "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", "proxy-from-env": "^2.1.0" } @@ -2277,9 +2291,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.18.tgz", - "integrity": "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2498,9 +2512,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001787", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001787.tgz", - "integrity": "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==", + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", "dev": true, "funding": [ { @@ -2997,9 +3011,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.335", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.335.tgz", - "integrity": "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q==", + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", "dev": true, "license": "ISC" }, @@ -3144,18 +3158,18 @@ } }, "node_modules/eslint": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.2.0.tgz", - "integrity": "sha512-+L0vBFYGIpSNIt/KWTpFonPrqYvgKw1eUI5Vn7mEogrQcWtWYtNQ7dNqC+px/J0idT3BAkiWrhfS7k+Tum8TUA==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.3.0.tgz", + "integrity": "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.4", - "@eslint/config-helpers": "^0.5.4", - "@eslint/core": "^1.2.0", - "@eslint/plugin-kit": "^0.7.0", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.5.5", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -3430,9 +3444,9 @@ } }, "node_modules/express-rate-limit": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.2.tgz", - "integrity": "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg==", + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.5.0.tgz", + "integrity": "sha512-XKhFohWaSBdVJNTi5TaHziqnPkv04I9UQV6q1Wy7Ui6GGQZVW12ojDFwqer14EvCXxjvPG0CyWXx7cAXpALB4Q==", "license": "MIT", "dependencies": { "ip-address": "10.1.0" @@ -3653,9 +3667,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", @@ -4044,9 +4058,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2" @@ -5179,9 +5193,9 @@ } }, "node_modules/kareem": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.2.0.tgz", - "integrity": "sha512-VS8MWZz/cT+SqBCpVfNN4zoVz5VskR3N4+sTmUXme55e9avQHntpwpNq0yjnosISXqwJ3AQVjlbI4Dyzv//JtA==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/kareem/-/kareem-3.3.0.tgz", + "integrity": "sha512-kpSuLD3/7RenBnjnJdOHXCKC8dTd1JzeOiJhN0necWWci6cC+qX+VuwPnMVgb+a4+KNJSfgqahpnfWaeDXCimw==", "license": "Apache-2.0", "engines": { "node": ">=18.0.0" @@ -5487,13 +5501,13 @@ } }, "node_modules/mongodb": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.1.tgz", - "integrity": "sha512-067DXiMjcpYQl6bGjWQoTUEE9UoRViTtKFcoqX7z08I+iDZv/emH1g8XEFiO3qiDfXAheT5ozl1VffDTKhIW/w==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.2.0.tgz", + "integrity": "sha512-F/2+BMZtLVhY30ioZp0dAmZ+IRZMBqI+nrv6t5+9/1AIwCa8sMRC3jBf81lpxMhnZgqq8CoUD503Z1oZWq1/sw==", "license": "Apache-2.0", "dependencies": { "@mongodb-js/saslprep": "^1.3.0", - "bson": "^7.1.1", + "bson": "^7.2.0", "mongodb-connection-string-url": "^7.0.0" }, "engines": { @@ -5546,13 +5560,13 @@ } }, "node_modules/mongoose": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.4.1.tgz", - "integrity": "sha512-4rFBWa+/wdBQSfvnOPJBpiSG6UCEbhSQh865dEdaH9Y8WfHBUC+I2XT28dp0IBIGrEwmh+gzrgZgea5PbmrHWA==", + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-9.6.1.tgz", + "integrity": "sha512-3T8/b0plM3ZJPW3WjlzVMIGJEYYTjgDPQ05Qzru3xu3/wOPSFKWYxdwUF2dl8h3NG5dVkzIuOkZdLacnlLf/sA==", "license": "MIT", "dependencies": { - "kareem": "3.2.0", - "mongodb": "~7.1", + "kareem": "3.3.0", + "mongodb": "~7.2", "mpath": "0.9.0", "mquery": "6.0.0", "ms": "2.1.3", @@ -5658,9 +5672,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.37", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", - "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, @@ -7046,9 +7060,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.32.2", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.2.tgz", - "integrity": "sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==", + "version": "5.32.5", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.5.tgz", + "integrity": "sha512-7/FQfWe9A4qoyYFdAwy0chD0uDYidDp/ZT9VQ9LZlgD4AnnHJk8/+ytAA1HkJYOPySmK6helPDdJQMlcumt7HA==", "license": "Apache-2.0", "dependencies": { "@scarf/scarf": "=1.4.0" @@ -7404,6 +7418,7 @@ "version": "8.3.2", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", "dev": true, "license": "MIT", "bin": { diff --git a/package.json b/package.json index 68497bf4..cc4f156f 100644 --- a/package.json +++ b/package.json @@ -37,12 +37,12 @@ ], "license": "MIT", "dependencies": { - "axios": "^1.15.0", + "axios": "^1.16.0", "bcryptjs": "^3.0.3", "cors": "^2.8.6", "express": "^5.2.1", "express-basic-auth": "^1.2.1", - "express-rate-limit": "^8.3.2", + "express-rate-limit": "^8.5.0", "express-session": "^1.19.0", "express-validator": "^7.3.2", "graphql": "^16.13.2", @@ -51,8 +51,8 @@ "helmet": "^8.1.0", "jsonwebtoken": "^9.0.3", "moment": "^2.30.1", - "mongodb": "^7.1.1", - "mongoose": "^9.4.1", + "mongodb": "^7.2.0", + "mongoose": "^9.6.1", "passport": "^0.7.0", "@node-saml/passport-saml": "^5.1.0", "pino": "^10.3.1", @@ -63,7 +63,7 @@ }, "devDependencies": { "env-cmd": "^11.0.0", - "eslint": "^10.2.0", + "eslint": "^10.3.0", "jest": "^30.3.0", "jest-sonar-reporter": "^2.0.0", "node-notifier": "^10.0.1", diff --git a/src/app-server.js b/src/app-server.js index 41cac02b..79a8ed04 100644 --- a/src/app-server.js +++ b/src/app-server.js @@ -1,19 +1,108 @@ import https from 'node:https'; import http from 'node:http'; +import http2 from 'node:http2'; import fs from 'node:fs'; import Logger from './helpers/logger.js'; +const SERVER_PROTOCOL = Object.freeze({ + AUTO: 'auto', + HTTP1: 'http1', + HTTP2: 'http2' +}); + +const PROTOCOL = Object.freeze({ + HTTP1: 'http/1.1', + HTTPS_HTTP1: 'https/1.1', + HTTP2: 'http2' +}); + +function getServerProtocolMode() { + const protocol = (process.env.SERVER_PROTOCOL || SERVER_PROTOCOL.AUTO).toLowerCase(); + + if (!Object.values(SERVER_PROTOCOL).includes(protocol)) { + throw new Error(`Unsupported SERVER_PROTOCOL '${process.env.SERVER_PROTOCOL}'. Use auto, http1, or http2.`); + } + + return protocol; +} + +function isTlsEnabled() { + return Boolean(process.env.SSL_CERT && process.env.SSL_KEY); +} + +function getTlsOptions() { + return { + key: fs.readFileSync(process.env.SSL_KEY), + cert: fs.readFileSync(process.env.SSL_CERT) + }; +} + +function decorateServer(server, { protocol, mode, tls, allowHttp1Fallback = false }) { + server.switcherProtocol = protocol; + server.switcherProtocolMode = mode; + server.switcherTlsEnabled = tls; + server.switcherAllowHttp1Fallback = allowHttp1Fallback; + + Logger.info(`Server protocol selected: ${protocol}`, { + mode, + tls, + allowHttp1Fallback + }); + + return server; +} + export const createServer = (app) => { - if (process.env.SSL_CERT && process.env.SSL_KEY) { - const options = { - key: fs.readFileSync(process.env.SSL_KEY), - cert: fs.readFileSync(process.env.SSL_CERT) - }; - - Logger.info('SSL enabled'); - return https.createServer(options, app); + const mode = getServerProtocolMode(); + const tlsEnabled = isTlsEnabled(); + + if (mode === SERVER_PROTOCOL.HTTP2) { + if (!tlsEnabled) { + throw new Error('SERVER_PROTOCOL=http2 requires SSL_CERT and SSL_KEY.'); + } + + return decorateServer(http2.createSecureServer({ + ...getTlsOptions(), + allowHTTP1: true + }, app), { + protocol: PROTOCOL.HTTP2, + mode, + tls: true, + allowHttp1Fallback: true + }); } - - Logger.info('SSL disabled'); - return http.createServer(app); -}; \ No newline at end of file + + if (mode === SERVER_PROTOCOL.HTTP1) { + if (tlsEnabled) { + return decorateServer(https.createServer(getTlsOptions(), app), { + protocol: PROTOCOL.HTTPS_HTTP1, + mode, + tls: true + }); + } + + return decorateServer(http.createServer(app), { + protocol: PROTOCOL.HTTP1, + mode, + tls: false + }); + } + + if (tlsEnabled) { + return decorateServer(http2.createSecureServer({ + ...getTlsOptions(), + allowHTTP1: true + }, app), { + protocol: PROTOCOL.HTTP2, + mode, + tls: true, + allowHttp1Fallback: true + }); + } + + return decorateServer(http.createServer(app), { + protocol: PROTOCOL.HTTP1, + mode, + tls: false + }); +}; diff --git a/src/app.js b/src/app.js index 4eb04b8e..29e369f1 100644 --- a/src/app.js +++ b/src/app.js @@ -126,6 +126,12 @@ app.get('/check', defaultLimiter, (req, res) => { max_metrics_pages: process.env.METRICS_MAX_PAGE, max_stretegy_op: process.env.MAX_STRATEGY_OPERATION, max_rpm: process.env.MAX_REQUEST_PER_MINUTE || DEFAULT_RATE_LIMIT, + server: { + protocol_mode: server.switcherProtocolMode, + protocol: server.switcherProtocol, + tls: server.switcherTlsEnabled, + allow_http1_fallback: server.switcherAllowHttp1Fallback + }, auth_providers: { saml: isSamlAvailable(), github: isOauthAvailableFor('GIT_OAUTH_CLIENT_ID', 'GIT_OAUTH_SECRET'), @@ -151,4 +157,6 @@ function isEnabled(feature) { return process.env[feature]?.toLowerCase() === 'true'; } -export default createServer(app); \ No newline at end of file +const server = createServer(app); + +export default server; diff --git a/tests/app.test.js b/tests/app.test.js index 9e382697..aec888c6 100644 --- a/tests/app.test.js +++ b/tests/app.test.js @@ -25,6 +25,12 @@ describe('Testing app [REST] ', () => { expect(req.statusCode).toBe(200); expect(req.body.status).toEqual('UP'); expect(req.body.attributes).toBeDefined(); + expect(req.body.attributes.server).toEqual({ + protocol_mode: 'auto', + protocol: 'http/1.1', + tls: false, + allow_http1_fallback: false + }); }); test('APP_SUITE - Should return 404 - Operation not found', async () => { diff --git a/tests/unit-test/app-server.test.js b/tests/unit-test/app-server.test.js new file mode 100644 index 00000000..5873ff11 --- /dev/null +++ b/tests/unit-test/app-server.test.js @@ -0,0 +1,167 @@ +import { jest } from '@jest/globals'; + +const ORIGINAL_ENV = process.env; + +async function loadCreateServer({ + httpServer = {}, + httpsServer = {}, + http2Server = {}, + key = 'KEY_DATA', + cert = 'CERT_DATA' +} = {}) { + const createHttpServer = jest.fn(() => httpServer); + const createHttpsServer = jest.fn(() => httpsServer); + const createHttp2Server = jest.fn(() => http2Server); + const readFileSync = jest.fn(path => { + if (path === process.env.SSL_KEY) { + return key; + } + + if (path === process.env.SSL_CERT) { + return cert; + } + + return `UNEXPECTED:${path}`; + }); + const loggerInfo = jest.fn(); + + jest.resetModules(); + jest.unstable_mockModule('node:http', () => ({ + default: { createServer: createHttpServer } + })); + jest.unstable_mockModule('node:https', () => ({ + default: { createServer: createHttpsServer } + })); + jest.unstable_mockModule('node:http2', () => ({ + default: { createSecureServer: createHttp2Server } + })); + jest.unstable_mockModule('node:fs', () => ({ + default: { readFileSync } + })); + jest.unstable_mockModule('../../src/helpers/logger.js', () => ({ + default: { info: loggerInfo } + })); + + const { createServer } = await import('../../src/app-server.js'); + + return { + createServer, + mocks: { + createHttpServer, + createHttpsServer, + createHttp2Server, + readFileSync, + loggerInfo + } + }; +} + +describe('app-server', () => { + beforeEach(() => { + process.env = { ...ORIGINAL_ENV }; + delete process.env.SERVER_PROTOCOL; + delete process.env.SSL_KEY; + delete process.env.SSL_CERT; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + test('APP_UNIT_SUITE - Should create a cleartext HTTP/1.1 server in auto mode without TLS certs', async () => { + const app = {}; + const server = {}; + const { createServer, mocks } = await loadCreateServer({ httpServer: server }); + + const response = createServer(app); + + expect(response).toBe(server); + expect(mocks.createHttpServer).toHaveBeenCalledWith(app); + expect(mocks.createHttpsServer).not.toHaveBeenCalled(); + expect(mocks.createHttp2Server).not.toHaveBeenCalled(); + expect(server.switcherProtocolMode).toBe('auto'); + expect(server.switcherProtocol).toBe('http/1.1'); + expect(server.switcherTlsEnabled).toBe(false); + expect(server.switcherAllowHttp1Fallback).toBe(false); + expect(mocks.loggerInfo).toHaveBeenCalledWith('Server protocol selected: http/1.1', { + mode: 'auto', + tls: false, + allowHttp1Fallback: false + }); + }); + + test('APP_UNIT_SUITE - Should create an HTTP/2 server in auto mode when TLS certs are provided', async () => { + process.env.SSL_KEY = 'server.key'; + process.env.SSL_CERT = 'server.crt'; + + const app = {}; + const server = {}; + const { createServer, mocks } = await loadCreateServer({ http2Server: server }); + + const response = createServer(app); + + expect(response).toBe(server); + expect(mocks.readFileSync).toHaveBeenNthCalledWith(1, 'server.key'); + expect(mocks.readFileSync).toHaveBeenNthCalledWith(2, 'server.crt'); + expect(mocks.createHttp2Server).toHaveBeenCalledWith({ + key: 'KEY_DATA', + cert: 'CERT_DATA', + allowHTTP1: true + }, app); + expect(mocks.createHttpsServer).not.toHaveBeenCalled(); + expect(mocks.createHttpServer).not.toHaveBeenCalled(); + expect(server.switcherProtocolMode).toBe('auto'); + expect(server.switcherProtocol).toBe('http2'); + expect(server.switcherTlsEnabled).toBe(true); + expect(server.switcherAllowHttp1Fallback).toBe(true); + }); + + test('APP_UNIT_SUITE - Should create an HTTPS HTTP/1.1 server when explicitly configured', async () => { + process.env.SERVER_PROTOCOL = 'http1'; + process.env.SSL_KEY = 'server.key'; + process.env.SSL_CERT = 'server.crt'; + + const app = {}; + const server = {}; + const { createServer, mocks } = await loadCreateServer({ httpsServer: server }); + + const response = createServer(app); + + expect(response).toBe(server); + expect(mocks.createHttpsServer).toHaveBeenCalledWith({ + key: 'KEY_DATA', + cert: 'CERT_DATA' + }, app); + expect(mocks.createHttp2Server).not.toHaveBeenCalled(); + expect(server.switcherProtocolMode).toBe('http1'); + expect(server.switcherProtocol).toBe('https/1.1'); + expect(server.switcherTlsEnabled).toBe(true); + expect(server.switcherAllowHttp1Fallback).toBe(false); + }); + + test('APP_UNIT_SUITE - Should reject explicit HTTP/2 mode without TLS certs', async () => { + process.env.SERVER_PROTOCOL = 'http2'; + + const { createServer, mocks } = await loadCreateServer(); + + expect(() => createServer({})).toThrow('SERVER_PROTOCOL=http2 requires SSL_CERT and SSL_KEY.'); + expect(mocks.createHttpServer).not.toHaveBeenCalled(); + expect(mocks.createHttpsServer).not.toHaveBeenCalled(); + expect(mocks.createHttp2Server).not.toHaveBeenCalled(); + expect(mocks.loggerInfo).not.toHaveBeenCalled(); + }); + + test('APP_UNIT_SUITE - Should reject unsupported protocol modes', async () => { + process.env.SERVER_PROTOCOL = 'spdy'; + + const { createServer } = await loadCreateServer(); + + expect(() => createServer({})).toThrow( + "Unsupported SERVER_PROTOCOL 'spdy'. Use auto, http1, or http2." + ); + }); +});