From 491e36374ceb3729cdc0a1e3986c823a7809fd3d Mon Sep 17 00:00:00 2001 From: utpal singh Date: Mon, 12 Oct 2026 03:02:03 +0530 Subject: [PATCH] feat: make container listen port configurable A different published host port already works without a rebuild. Host networking and required container ports still need nginx to listen on something other than 8080. OPENCONCHO_PORT defaults to 8080. Invalid values fail before nginx starts. IPv4 and IPv6 listen directives stay in sync. --- Dockerfile | 6 ++- docker-compose.yml | 6 ++- docker/40-openconcho-config.sh | 34 ++++++++++++++ docker/nginx.conf.template | 4 +- docker/smoke-test.sh | 86 ++++++++++++++++++++++++++++++++++ docs/docker.md | 19 ++++++-- 6 files changed, 147 insertions(+), 8 deletions(-) diff --git a/Dockerfile b/Dockerfile index 1427e1b..b1a5e61 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,8 @@ # Multi-stage build: # 1. node:22-alpine + pnpm builds the @openconcho/web SPA to packages/web/dist # 2. nginx-unprivileged serves the static bundle as non-root (UID 101) on -# port 8080 — runs cleanly under read-only filesystem + cap_drop ALL. +# port 8080 by default (OPENCONCHO_PORT) — runs cleanly under read-only +# filesystem + cap_drop ALL. # ---------- Builder stage ---------- FROM node:22-alpine AS builder @@ -43,7 +44,10 @@ COPY --chown=101:101 --chmod=0755 docker/40-openconcho-config.sh /docker-entrypo # Empty default → clean first run (configure the instance in Settings). Override per # deploy to seed the first instance; the browser routes via /api with an # X-Honcho-Upstream header. Optional OPENCONCHO_UPSTREAM_ALLOWLIST guards the proxy. +# OPENCONCHO_PORT changes nginx's listen port at start (default 8080). EXPOSE is +# metadata only; map the published host port to the same container port. ENV OPENCONCHO_DEFAULT_HONCHO_URL="" +ENV OPENCONCHO_PORT=8080 EXPOSE 8080 diff --git a/docker-compose.yml b/docker-compose.yml index 34b0e40..4b668bd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,8 +24,12 @@ x-openconcho: &openconcho # binding below). Set comma-separated host globs before exposing the proxy: # OPENCONCHO_UPSTREAM_ALLOWLIST: honcho.example.net,*.honcho.dev OPENCONCHO_UPSTREAM_ALLOWLIST: ${OPENCONCHO_UPSTREAM_ALLOWLIST:-} + # nginx listen port inside the container. Host mapping below uses the same + # value as the target port. To change only the host port, edit the left side + # (127.0.0.1:9090:8080) and leave this at 8080. + OPENCONCHO_PORT: ${OPENCONCHO_PORT:-8080} ports: - - "127.0.0.1:8080:8080" + - "127.0.0.1:8080:${OPENCONCHO_PORT:-8080}" # Lets the default host.docker.internal upstream resolve on Linux too # (Docker Desktop / Colima provide it automatically). extra_hosts: diff --git a/docker/40-openconcho-config.sh b/docker/40-openconcho-config.sh index 672c569..35f2b49 100644 --- a/docker/40-openconcho-config.sh +++ b/docker/40-openconcho-config.sh @@ -3,10 +3,27 @@ # Lets one prebuilt image target any Honcho backend without a rebuild. # OPENCONCHO_DEFAULT_HONCHO_URL — absolute URL seeding the first instance, or empty. # OPENCONCHO_UPSTREAM_ALLOWLIST — optional comma-separated host globs (SSRF guard). +# OPENCONCHO_PORT — nginx listen port; unset/empty → 8080. Decimal 1-65535, no +# leading zeros. UID 101 cannot bind privileged ports (<1024) unless the host allows it. # Runs from /docker-entrypoint.d before nginx starts. Writes config.js to /tmp # so the container works cleanly under a read-only root filesystem. set -eu +# Validate before any config writes. Do not envsubst the nginx file: that would +# empty runtime variables such as $upstream and $proxy_host. +port="${OPENCONCHO_PORT:-8080}" +case "$port" in + [1-9]|[1-9][0-9]|[1-9][0-9][0-9]|[1-9][0-9][0-9][0-9]|[1-9][0-9][0-9][0-9][0-9]) ;; + *) + echo "openconcho: OPENCONCHO_PORT must be a decimal port 1-65535 with no leading zeros, got '${OPENCONCHO_PORT:-}'" >&2 + exit 1 + ;; +esac +if [ "$port" -gt 65535 ]; then + echo "openconcho: OPENCONCHO_PORT must be a decimal port 1-65535 with no leading zeros, got '${OPENCONCHO_PORT:-}'" >&2 + exit 1 +fi + cat > /tmp/openconcho-config.js < "$ALLOWLIST_CONF" fi + +# Render both listen directives from the validated port. 20-envsubst copies the +# template first; this replaces only __OPENCONCHO_PORT__ in the staged file. +NGINX_CONF=/etc/nginx/conf.d/default.conf +if [ ! -f "$NGINX_CONF" ]; then + echo "openconcho: $NGINX_CONF is missing; cannot set listen port" >&2 + exit 1 +fi +sed -i "s/__OPENCONCHO_PORT__/${port}/g" "$NGINX_CONF" +if grep -q '__OPENCONCHO_PORT__' "$NGINX_CONF"; then + echo "openconcho: failed to render listen port into $NGINX_CONF" >&2 + exit 1 +fi +if ! grep -qF "listen ${port};" "$NGINX_CONF" || ! grep -qF "listen [::]:${port};" "$NGINX_CONF"; then + echo "openconcho: listen directives were not updated to ${port}" >&2 + exit 1 +fi diff --git a/docker/nginx.conf.template b/docker/nginx.conf.template index 6c62014..ef5a716 100644 --- a/docker/nginx.conf.template +++ b/docker/nginx.conf.template @@ -3,8 +3,8 @@ # The browser sends X-Honcho-Upstream per request; nginx forwards server-side (no browser CORS). server { - listen 8080; - listen [::]:8080; + listen __OPENCONCHO_PORT__; + listen [::]:__OPENCONCHO_PORT__; server_name _; root /usr/share/nginx/html; index index.html; diff --git a/docker/smoke-test.sh b/docker/smoke-test.sh index f933c63..d39efa7 100755 --- a/docker/smoke-test.sh +++ b/docker/smoke-test.sh @@ -42,6 +42,50 @@ check() { # label expected actual if [ "$2" = "$3" ]; then echo " PASS: $1 ($3)"; else echo " FAIL: $1 — expected $2, got $3"; FAIL=1; fi } +# Assert nginx listen directives (config inspection, not IPv6 connectivity). +check_listen() { # label port + local conf port_line ipv6_line + if ! docker exec "$APP" true >/dev/null 2>&1; then + echo " FAIL: $1 container not running" + FAIL=1 + return + fi + conf=$(docker exec "$APP" cat /etc/nginx/conf.d/default.conf) + port_line=$(printf '%s\n' "$conf" | grep -F "listen $2;" || true) + ipv6_line=$(printf '%s\n' "$conf" | grep -F "listen [::]:$2;" || true) + if [ -n "$port_line" ]; then echo " PASS: $1 ipv4 listen $2"; else echo " FAIL: $1 missing ipv4 listen $2"; FAIL=1; fi + if [ -n "$ipv6_line" ]; then echo " PASS: $1 ipv6 listen $2"; else echo " FAIL: $1 missing ipv6 listen $2"; FAIL=1; fi +} + +# Invalid OPENCONCHO_PORT must fail before nginx serves. timeout 124 = still started. +expect_invalid_port() { # value + local val="$1" out ec + set +e + out=$(timeout 12 docker run --rm -e "OPENCONCHO_PORT=$val" "$IMAGE" 2>&1) + ec=$? + set -e + if [ "$ec" -eq 0 ]; then + echo " FAIL: OPENCONCHO_PORT=$val started nginx" + FAIL=1 + elif [ "$ec" -eq 124 ]; then + echo " FAIL: OPENCONCHO_PORT=$val hung (nginx likely started)" + FAIL=1 + elif printf '%s' "$out" | grep -q 'OPENCONCHO_PORT'; then + echo " PASS: OPENCONCHO_PORT=$val rejected" + else + echo " FAIL: OPENCONCHO_PORT=$val exited $ec without diagnostic" + FAIL=1 + fi +} + +run_app() { # container_port extra docker run args... + local container_port="$1" + shift + docker rm -f "$APP" >/dev/null 2>&1 || true + docker run -d --name "$APP" --network "$NET" -p "$PORT:$container_port" "$@" "$IMAGE" >/dev/null + wait_ready "http://localhost:$PORT/healthz" +} + echo "==> build image" docker build -t "$IMAGE" . >/dev/null @@ -55,6 +99,7 @@ docker run -d --name "$APP" --network "$NET" -p "$PORT:8080" \ wait_ready "http://localhost:$PORT/healthz" echo "==> assertions" +check_listen "default port" 8080 check "healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")" check "SPA served 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")" check "config.js injected" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")" @@ -79,4 +124,45 @@ reject=$(curl -s -D- -o /dev/null "http://localhost:$PORT/api/v3/test" \ -H "X-Honcho-Upstream: http://$UPSTREAM:8080" -X POST -d '{}' | grep -i 'X-Honcho-Proxy-Reject' | tr -d '\r') if echo "$reject" | grep -qi 'allowlist'; then echo " PASS: reject sentinel header present"; else echo " FAIL: missing reject sentinel — got: $reject"; FAIL=1; fi +# Host $PORT (SMOKE_PORT) vs container listen port: same image, no rebuild. +INTERNAL_PORT=9090 +echo "==> custom internal port $INTERNAL_PORT (same image)" +run_app "$INTERNAL_PORT" -e "OPENCONCHO_PORT=$INTERNAL_PORT" \ + -e "OPENCONCHO_DEFAULT_HONCHO_URL=http://$UPSTREAM:8080" +check_listen "custom port" "$INTERNAL_PORT" +check "custom healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")" +check "custom SPA 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")" +check "custom config.js 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/config.js")" +body=$(curl -s "http://localhost:$PORT/api/v3/test" \ + -H "X-Honcho-Upstream: http://$UPSTREAM:8080" -H 'content-type: application/json' -X POST -d '{}') +if echo "$body" | grep -q '/v3/test'; then echo " PASS: custom port /api forwards"; else echo " FAIL: custom port forward — body: $body"; FAIL=1; fi +check "custom missing header 421" 421 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/api/v3/test" -X POST -d '{}')" + +echo "==> empty OPENCONCHO_PORT defaults to 8080" +run_app 8080 -e "OPENCONCHO_PORT=" +check_listen "empty port default" 8080 +check "empty-port healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")" + +echo "==> invalid OPENCONCHO_PORT fails before nginx" +expect_invalid_port 0 +expect_invalid_port -1 +expect_invalid_port 65536 +expect_invalid_port 99999 +expect_invalid_port abc +expect_invalid_port 08080 +expect_invalid_port '8080;rm' +expect_invalid_port '8080}' +expect_invalid_port '$upstream' + +echo "==> read-only root + custom port" +run_app "$INTERNAL_PORT" --read-only --cap-drop ALL --security-opt no-new-privileges \ + --tmpfs /tmp:uid=101,gid=101 \ + --tmpfs /var/cache/nginx:uid=101,gid=101 \ + --tmpfs /var/run:uid=101,gid=101 \ + --tmpfs /etc/nginx/conf.d:uid=101,gid=101 \ + -e "OPENCONCHO_PORT=$INTERNAL_PORT" +check_listen "read-only custom port" "$INTERNAL_PORT" +check "read-only healthz 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/healthz")" +check "read-only SPA 200" 200 "$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:$PORT/")" + if [ "$FAIL" = 0 ]; then echo "==> SMOKE TEST PASSED"; else echo "==> SMOKE TEST FAILED"; exit 1; fi diff --git a/docs/docker.md b/docs/docker.md index 1a12dba..e0663f1 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -81,12 +81,23 @@ Runtime knobs (no rebuild needed): |-----|---------|---------| | `OPENCONCHO_DEFAULT_HONCHO_URL` | _(empty)_ | Absolute URL seeding the first instance; empty = configure in Settings | | `OPENCONCHO_UPSTREAM_ALLOWLIST` | _(empty)_ | Optional SSRF guard: comma-separated host globs (e.g. `honcho.example.net,*.honcho.dev`). Empty = forward anywhere | +| `OPENCONCHO_PORT` | `8080` | nginx listen port inside the container (IPv4 and IPv6). Unset or empty keeps 8080. Decimal 1-65535, no leading zeros. | + +`OPENCONCHO_PORT` is the process listen port, not the published host port. On a +bridge network `docker run -p 9090:8080` already remaps localhost without a +rebuild. Set the env when the process itself must listen elsewhere (host +networking, or a required container port). The image runs as UID 101, so ports +below 1024 fail to bind unless the host allows unprivileged low ports. Compose +publishes `127.0.0.1:8080:${OPENCONCHO_PORT:-8080}` so the target matches the +listener; change the left-hand host port separately if you only need a different +localhost port. Hardened run adds `--read-only --cap-drop ALL --security-opt no-new-privileges` -with `--tmpfs /tmp --tmpfs /var/cache/nginx`. Note: the entrypoint writes -`config.js` and the allowlist map at start, which a read-only root blocks — under -`--read-only` either bind-mount those paths or leave the env empty and configure -the URL in Settings. +with tmpfs on `/tmp`, `/var/cache/nginx`, `/var/run`, and `/etc/nginx/conf.d`. +Those tmpfs mounts must be writable by UID 101 (`uid=101,gid=101` on `docker run +--tmpfs`, or `fsGroup: 101` in the chart). The entrypoint writes `config.js`, the +allowlist map, and the listen port at start, which a read-only root blocks +without those mounts. ## Serving under a sub-path