Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
6 changes: 5 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
34 changes: 34 additions & 0 deletions docker/40-openconcho-config.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
window.__OPENCONCHO_DEFAULT_HONCHO_URL__ = "${OPENCONCHO_DEFAULT_HONCHO_URL:-}";
EOF
Expand Down Expand Up @@ -43,3 +60,20 @@ else
printf '}\n'
} > "$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
4 changes: 2 additions & 2 deletions docker/nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
86 changes: 86 additions & 0 deletions docker/smoke-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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")"
Expand All @@ -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
19 changes: 15 additions & 4 deletions docs/docker.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading