diff --git a/.gitignore b/.gitignore index 5bf37c0df1..b723cbdb0e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .DS_Store .idea +.claude .qodo ._* .vscode diff --git a/backend/internal/nginx.js b/backend/internal/nginx.js index fe84607f96..97fa71aa0e 100644 --- a/backend/internal/nginx.js +++ b/backend/internal/nginx.js @@ -240,6 +240,8 @@ const internalNginx = { // Set the IPv6 setting for the host host.ipv6 = internalNginx.ipv6Enabled(); + host.http_port = internalNginx.getHttpPort(); + host.https_port = internalNginx.getHttpsPort(); locationsPromise.then(() => { renderEngine @@ -285,6 +287,7 @@ const internalNginx = { } certificate.ipv6 = internalNginx.ipv6Enabled(); + certificate.http_port = internalNginx.getHttpPort(); renderEngine .parseAndRender(template, certificate) @@ -432,6 +435,27 @@ const internalNginx = { return true; }, + + /** + * @returns {number} + */ + getHttpPort: () => { + return Number.parseInt(process.env.HTTP_PORT, 10) || 80; + }, + + /** + * @returns {number} + */ + getHttpsPort: () => { + return Number.parseInt(process.env.HTTPS_PORT, 10) || 443; + }, + + /** + * @returns {number} + */ + getWebUiPort: () => { + return Number.parseInt(process.env.WEB_UI_PORT, 10) || 81; + }, }; export default internalNginx; diff --git a/backend/templates/_listen.conf b/backend/templates/_listen.conf index 34a808e6a0..6448023b7f 100644 --- a/backend/templates/_listen.conf +++ b/backend/templates/_listen.conf @@ -1,15 +1,15 @@ - listen 80; + listen {{ http_port }}; {% if ipv6 -%} - listen [::]:80; + listen [::]:{{ http_port }}; {% else -%} - #listen [::]:80; + #listen [::]:{{ http_port }}; {% endif %} {% if certificate -%} - listen 443 ssl; + listen {{ https_port }} ssl; {% if ipv6 -%} - listen [::]:443 ssl; + listen [::]:{{ https_port }} ssl; {% else -%} - #listen [::]:443; + #listen [::]:{{ https_port }}; {% endif %} {% endif %} server_name {{ domain_names | join: " " }}; diff --git a/backend/templates/default.conf b/backend/templates/default.conf index cc590f9d85..c68171953c 100644 --- a/backend/templates/default.conf +++ b/backend/templates/default.conf @@ -5,11 +5,11 @@ # Skipping output, congratulations page configration is baked in. {%- else %} server { - listen 80 default; + listen {{ http_port }} default; {% if ipv6 -%} - listen [::]:80 default; + listen [::]:{{ http_port }} default; {% else -%} - #listen [::]:80 default; + #listen [::]:{{ http_port }} default; {% endif %} server_name default-host.localhost; access_log /data/logs/default-host_access.log combined; diff --git a/backend/templates/letsencrypt-request.conf b/backend/templates/letsencrypt-request.conf index 676c8a60fd..211cd9ed03 100644 --- a/backend/templates/letsencrypt-request.conf +++ b/backend/templates/letsencrypt-request.conf @@ -1,9 +1,9 @@ {% include "_header_comment.conf" %} server { - listen 80; + listen {{ http_port }}; {% if ipv6 -%} - listen [::]:80; + listen [::]:{{ http_port }}; {% endif %} server_name {{ domain_names | join: " " }}; diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh index d2e62f3bbc..13b0ab4495 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/00-all.sh @@ -18,5 +18,6 @@ fi . /etc/s6-overlay/s6-rc.d/prepare/30-ownership.sh . /etc/s6-overlay/s6-rc.d/prepare/40-dynamic.sh . /etc/s6-overlay/s6-rc.d/prepare/50-ipv6.sh -. /etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh +. /etc/s6-overlay/s6-rc.d/prepare/60-ports.sh +. /etc/s6-overlay/s6-rc.d/prepare/70-secrets.sh . /etc/s6-overlay/s6-rc.d/prepare/90-banner.sh diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/60-ports.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/60-ports.sh new file mode 100755 index 0000000000..94e6690ece --- /dev/null +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/60-ports.sh @@ -0,0 +1,41 @@ +#!/command/with-contenv bash +# shellcheck shell=bash + +set -e + +log_info 'Ports ...' + +# Default ports +HTTP_PORT=${HTTP_PORT:-80} +HTTPS_PORT=${HTTPS_PORT:-443} +WEB_UI_PORT=${WEB_UI_PORT:-81} + +process_folder () { + echo "Processing folder: $1" + FILES=$(find "$1" -type f -name "*.conf") + + for FILE in $FILES + do + echo "- ${FILE}" + + # HTTP port: plain listen with no ssl/default keyword following + sed -i -E "s/listen [0-9]+(;)/listen ${HTTP_PORT}\1/g" "$FILE" + sed -i -E "s/listen \[::\]:[0-9]+(;)/listen [::]:${HTTP_PORT}\1/g" "$FILE" + sed -i -E "s/set \$port \"80\"/set \$port \"${HTTP_PORT}\"/g" "$FILE" + + # HTTPS port: listen identified by ssl keyword + sed -i -E "s/listen [0-9]+( ssl)/listen ${HTTPS_PORT}\1/g" "$FILE" + sed -i -E "s/listen \[::\]:[0-9]+( ssl)/listen [::]:${HTTPS_PORT}\1/g" "$FILE" + sed -i -E "s/set \$port \"443\"/set \$port \"${HTTPS_PORT}\"/g" "$FILE" + + # Web UI port: listen identified by default keyword + sed -i -E "s/listen [0-9]+( default)/listen ${WEB_UI_PORT}\1/g" "$FILE" + sed -i -E "s/listen \[::\]:[0-9]+( default)/listen [::]:${WEB_UI_PORT}\1/g" "$FILE" + done + + # ensure the files are still owned by the npm user + chown -R "$PUID:$PGID" "$1" +} + +process_folder /etc/nginx/conf.d +process_folder /data/nginx diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-secrets.sh similarity index 100% rename from docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/60-secrets.sh rename to docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/70-secrets.sh diff --git a/docs/src/advanced-config/index.md b/docs/src/advanced-config/index.md index 3ab04ce25b..9d4c5bdb7d 100644 --- a/docs/src/advanced-config/index.md +++ b/docs/src/advanced-config/index.md @@ -235,8 +235,25 @@ Setting these environment variables will create the default user on startup, ski environment: INITIAL_ADMIN_EMAIL: my@example.com INITIAL_ADMIN_PASSWORD: mypassword1 + +## Custom Ports + +If you need to change the default ports that NPM listens on (80 for HTTP, 443 for HTTPS, and 81 for the Web UI), you can do so by setting environment variables. Note that you must also update your Docker port mappings to match these internal ports. + +```yml + ports: + - '8080:8080' # Public HTTP Port + - '8443:8443' # Public HTTPS Port + - '8181:8181' # Admin Web Port + environment: + HTTP_PORT: 8080 + HTTPS_PORT: 8443 + WEB_UI_PORT: 8181 ``` +> [!IMPORTANT] +> When changing these environment variables, the internal Nginx configuration will be updated to listen on these new ports. Your Docker `ports` mapping must use these new ports as the container-side port (the second number). + ## Disable Nginx Resolver On startup, we generate a resolvers directive for Nginx unless this is defined: diff --git a/test/cypress/e2e/api/CustomPorts.cy.js b/test/cypress/e2e/api/CustomPorts.cy.js new file mode 100644 index 0000000000..6a0fc009dc --- /dev/null +++ b/test/cypress/e2e/api/CustomPorts.cy.js @@ -0,0 +1,245 @@ +/// + +// Tests for the custom ports feature (HTTP_PORT, HTTPS_PORT, WEB_UI_PORT env vars). +// +// The CI environment runs NPM with default ports (80/443/81). These tests verify +// the default-port baseline and proxy host behaviour with port-aware nginx templates. +// To test non-default ports, run NPM with e.g. HTTP_PORT=8080 HTTPS_PORT=8443 and +// update the port constants below. + +const { hostname: NPM_HOST } = new URL(Cypress.config('baseUrl')); +const HTTP_PORT = Cypress.env('HTTP_PORT') || 80; +const HTTPS_PORT = Cypress.env('HTTPS_PORT') || 443; +const WEB_UI_PORT = Cypress.env('WEB_UI_PORT') || 81; + +describe('Custom Ports', () => { + let token; + + before(() => { + cy.resetUsers(); + cy.getToken().then((tok) => { + token = tok; + }); + }); + + // ── Port accessibility ─────────────────────────────────────────────────── + + it(`HTTP fallback server responds on port ${HTTP_PORT}`, () => { + cy.request({ + url: `http://${NPM_HOST}:${HTTP_PORT}`, + failOnStatusCode: false, + }).then((response) => { + // The NPM fallback server always returns a non-5xx response + expect(response.status).to.be.lessThan(500); + }); + }); + + it(`Admin Web UI responds on port ${WEB_UI_PORT}`, () => { + cy.request({ + url: `http://${NPM_HOST}:${WEB_UI_PORT}/api/`, + failOnStatusCode: false, + }).then((response) => { + expect(response.status).to.be.lessThan(500); + expect(response.body).to.have.property('status', 'OK'); + }); + }); + + // ── Proxy host template variables ──────────────────────────────────────── + + it('Proxy host creation succeeds with port-aware nginx templates', () => { + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/proxy-hosts', + data: { + domain_names: ['custom-ports-test.example.com'], + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 3000, + access_list_id: '0', + certificate_id: 0, + meta: { dns_challenge: false }, + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + }, + }).then((data) => { + cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', data); + expect(data).to.have.property('id'); + expect(data.id).to.be.greaterThan(0); + expect(data).to.have.property('enabled', true); + + // Fetch the created host and verify it's retrievable + cy.task('backendApiGet', { + token: token, + path: `/api/nginx/proxy-hosts/${data.id}`, + }).then((host) => { + cy.validateSwaggerSchema('get', 200, '/nginx/proxy-hosts/{hostID}', host); + expect(host.id).to.equal(data.id); + expect(host.domain_names).to.deep.equal(['custom-ports-test.example.com']); + }); + + // Clean up + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/proxy-hosts/${data.id}`, + }); + }); + }); + + // ── Port change after a previous custom port was already set ──────────── + // + // When a user changes ports a second time (e.g. HTTP_PORT was 8080 and is + // now changed to 9090), the nginx config files already contain 8080, not + // the image default 80. The expected result is that the new + // port appears in the config and the previous custom port is gone. + + it('port regex matches any number, not just image defaults (80 → 8080 → 9090)', () => { + const applyPortSed = (config, httpPort, httpsPort, webUiPort) => + config + .replace(/listen (\d+)(;)/g, `listen ${httpPort}$2`) + .replace(/listen \[::\]:(\d+)(;)/g, `listen [::]:${httpPort}$2`) + .replace(/listen (\d+)( ssl)/g, `listen ${httpsPort}$2`) + .replace(/listen \[::\]:(\d+)( ssl)/g, `listen [::]:${httpsPort}$2`) + .replace(/listen (\d+)( default)/g, `listen ${webUiPort}$2`) + .replace(/listen \[::\]:(\d+)( default)/g, `listen [::]:${webUiPort}$2`); + + const fresh = [ + 'listen 80;', + 'listen [::]:80;', + 'listen 443 ssl;', + 'listen [::]:443 ssl;', + 'listen 81 default;', + 'listen [::]:81 default;', + ].join('\n'); + + // First change: image defaults → custom ports + const afterFirst = applyPortSed(fresh, 8080, 8443, 8181); + expect(afterFirst).to.include('listen 8080;'); + expect(afterFirst).to.include('listen 8443 ssl;'); + expect(afterFirst).to.include('listen 8181 default;'); + expect(afterFirst).not.to.include('listen 80;'); + expect(afterFirst).not.to.include('listen 443 ssl;'); + + // Second change: custom ports → new custom ports (the maintainer's scenario) + const afterSecond = applyPortSed(afterFirst, 9090, 9443, 9191); + expect(afterSecond).to.include('listen 9090;'); + expect(afterSecond).to.include('listen [::]:9090;'); + expect(afterSecond).to.include('listen 9443 ssl;'); + expect(afterSecond).to.include('listen [::]:9443 ssl;'); + expect(afterSecond).to.include('listen 9191 default;'); + expect(afterSecond).to.include('listen [::]:9191 default;'); + + // Old custom ports from the first change must be gone + expect(afterSecond).not.to.include('listen 8080;'); + expect(afterSecond).not.to.include('listen 8443 ssl;'); + expect(afterSecond).not.to.include('listen 8181 default;'); + }); + + it('HTTPS regex does not corrupt HTTP port during a second port change', () => { + const applyPortSed = (config, httpPort, httpsPort) => + config + .replace(/listen (\d+)(;)/g, `listen ${httpPort}$2`) + .replace(/listen (\d+)( ssl)/g, `listen ${httpsPort}$2`); + + // After first change both ports are custom + const config = 'listen 8080;\nlisten 8443 ssl;\n'; + + const result = applyPortSed(config, 9090, 9443); + + // Ports must be updated independently + expect(result).to.include('listen 9090;'); + expect(result).to.include('listen 9443 ssl;'); + + // Verify no cross-contamination: HTTP port not set to HTTPS value + expect(result).not.to.include('listen 9443;'); + // Verify HTTPS port not set to HTTP value + expect(result).not.to.include('listen 9090 ssl;'); + }); + + it('Multiple proxy hosts can be created without port conflicts', () => { + const hosts = []; + + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/proxy-hosts', + data: { + domain_names: ['ports-host-1.example.com'], + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 3001, + access_list_id: '0', + certificate_id: 0, + meta: { dns_challenge: false }, + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + }, + }).then((h1) => { + cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', h1); + expect(h1.id).to.be.greaterThan(0); + hosts.push(h1.id); + + cy.task('backendApiPost', { + token: token, + path: '/api/nginx/proxy-hosts', + data: { + domain_names: ['ports-host-2.example.com'], + forward_scheme: 'http', + forward_host: '127.0.0.1', + forward_port: 3002, + access_list_id: '0', + certificate_id: 0, + meta: { dns_challenge: false }, + advanced_config: '', + locations: [], + block_exploits: false, + caching_enabled: false, + allow_websocket_upgrade: false, + http2_support: false, + hsts_enabled: false, + hsts_subdomains: false, + ssl_forced: false, + }, + }).then((h2) => { + cy.validateSwaggerSchema('post', 201, '/nginx/proxy-hosts', h2); + expect(h2.id).to.be.greaterThan(0); + hosts.push(h2.id); + + // Both hosts should be independently fetchable + cy.task('backendApiGet', { + token: token, + path: `/api/nginx/proxy-hosts/${h1.id}`, + }).then((fetched) => { + expect(fetched.domain_names).to.deep.equal(['ports-host-1.example.com']); + }); + + cy.task('backendApiGet', { + token: token, + path: `/api/nginx/proxy-hosts/${h2.id}`, + }).then((fetched) => { + expect(fetched.domain_names).to.deep.equal(['ports-host-2.example.com']); + }); + + // Clean up both + hosts.forEach((id) => { + cy.task('backendApiDelete', { + token: token, + path: `/api/nginx/proxy-hosts/${id}`, + }); + }); + }); + }); + }); +});