From 73d6dc3196932f24370303c8bfb48ea443a582b8 Mon Sep 17 00:00:00 2001 From: Darren Paxton Date: Mon, 28 Sep 2026 22:08:48 -0400 Subject: [PATCH] fix(ui): discover the nginx resolver at run time instead of hard-coding Docker's MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The UI proxies API prefixes to the studio so the browser stays same-origin, and since 63e6d2f that proxy_pass goes through a variable to force per-request DNS resolution — which requires an explicit `resolver`. That resolver was pinned to 127.0.0.11, Docker's embedded DNS, which does not exist under podman: its aardvark-dns sits on the network gateway (10.89.0.x, varying by network). The failure mode is the worst shape available. nginx starts, the SPA is served normally, and every proxied API call dies with no response at all — including POST /api/Account/PasswordLogin, so the login page renders and simply does nothing. The only clue is "recv() failed (111: Connection refused) while resolving, resolver: 127.0.0.11:53" buried in the UI container's log. Rather than branching on the runtime, ask the network. Any container attached to the project network is handed the correct nameserver by the runtime itself, so a busybox init service reads its own /etc/resolv.conf, substitutes the value into nginx/default.conf, and writes the result to a volume that duplo-ui mounts. There is no docker-vs-podman case to keep in sync, and it stays correct for any runtime added later. It cannot be done inside the UI image: its entrypoint is `nginx` directly — no /docker-entrypoint.d/, no /etc/nginx/templates — and it runs as nonroot 65532, so the usual envsubst-on-a-template trick is unavailable. nginx has never read /etc/resolv.conf for its own `resolver` directive, so substitution has to happen before nginx starts. The init-container shape mirrors init-perms, already in this file. Verified on both runtimes, same image and same compose file: podman nginx resolver = 10.89.0.1 SPA 200, /v1/... 401, login 401 docker nginx resolver = 127.0.0.11 SPA 200, /v1/... 401, login 401 401 is the pass: nginx resolved the upstream and the studio answered with an auth challenge. Before the fix the same calls returned 000 — nginx never reached it. Zero resolver errors in the UI log on either runtime. tests/test-ui-nginx.sh covers it statically, so it runs on any machine with no runtime installed: the conf must not pin a runtime-specific address, the init service must read /etc/resolv.conf rather than a literal, duplo-ui must wait for it via service_completed_successfully, and shell variables in the init command must be written $$ — compose interpolates $VAR in `command:` before the shell sees it, and an interpolated-away variable yields an empty resolver and an nginx that will not load its config at all. Note the volume replaces the whole /etc/nginx/conf.d directory; the image ships only default.conf there, and the test guards that assumption. Co-Authored-By: Claude Opus 5 (1M context) --- docker-compose.yml | 41 +++++++++++++++++++++++++++--- nginx/default.conf | 8 ++++-- tests/test-ui-nginx.sh | 57 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 101 insertions(+), 5 deletions(-) create mode 100755 tests/test-ui-nginx.sh diff --git a/docker-compose.yml b/docker-compose.yml index 1959051..2519b60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -137,16 +137,50 @@ services: init-perms: condition: service_completed_successfully + # Renders nginx/default.conf into a volume with the DNS resolver this runtime actually uses. + # + # It cannot be done in the UI image: that image's entrypoint is `nginx` directly — no + # /docker-entrypoint.d/, no /etc/nginx/templates, and it runs as nonroot 65532 — so the usual + # envsubst-on-a-template trick is unavailable. nginx itself has never read /etc/resolv.conf for its + # `resolver` directive, so the address has to be substituted before nginx starts. + # + # Asking the network rather than branching on the runtime: any container on this network is handed + # the correct nameserver by the runtime itself, so busybox reads its own /etc/resolv.conf and there + # is no docker-vs-podman case to keep in sync (verified: 127.0.0.11 on docker, the network gateway + # on podman). + # + # $$ not $: compose interpolates $VAR in `command:` before the shell sees it, and an interpolated-away + # variable yields an empty resolver and an nginx that will not load its config at all. + init-nginx-conf: + image: busybox:1.36 + command: + - sh + - -c + - | + R="$$(awk '/^nameserver/{print $$2; exit}' /etc/resolv.conf)" + [ -n "$$R" ] || { echo "no nameserver in /etc/resolv.conf" >&2; exit 1; } + sed "s|__RESOLVER__|$$R|" /tpl/default.conf > /out/default.conf + chmod 0644 /out/default.conf + echo "nginx resolver = $$R" + volumes: + - ./nginx/default.conf:/tpl/default.conf:ro + - nginx_conf:/out + duplo-ui: image: quay.io/duplocloud/duplo-ai-helpdesk-ui:${UI_TAG} restart: unless-stopped ports: - "${UI_PORT:-4200}:8080" volumes: - # Published image serves the SPA only; this conf proxies API prefixes to the studio (same-origin, no CORS). - - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + # Published image serves the SPA only; the rendered conf proxies API prefixes to the studio + # (same-origin, no CORS). The whole directory is replaced: the image ships only default.conf in + # there, and tests/test-ui-nginx.sh guards that assumption. + - nginx_conf:/etc/nginx/conf.d:ro depends_on: - - duplo-ai-studio + duplo-ai-studio: + condition: service_started + init-nginx-conf: + condition: service_completed_successfully xterm: image: quay.io/duplocloud/duplo-xterm:${XTERM_TAG:-main-d323e0b} @@ -209,6 +243,7 @@ services: - DUPLO_ADMIN_TOKEN=${DUPLO_ADMIN_TOKEN:-} volumes: + nginx_conf: mongo_data: platform_data: extension_studio_data: diff --git a/nginx/default.conf b/nginx/default.conf index e2ce12a..1402ed1 100644 --- a/nginx/default.conf +++ b/nginx/default.conf @@ -36,9 +36,13 @@ server { # of the container, so recreating the studio (any compose change to it OR to mongo, which it # depends_on) strands this container proxying to a dead address — every API call 502s and the # login page renders with no auth methods, since it is those 502s that decide what to show. - # Docker's embedded DNS at 127.0.0.11 plus a variable in proxy_pass forces a fresh lookup. + # The resolver address differs per runtime — Docker's embedded DNS is 127.0.0.11, podman's + # aardvark-dns sits on the network gateway (10.89.0.x, varying by network) — so it is NOT + # hard-coded here. __RESOLVER__ is substituted at startup by the init-nginx-conf service, which + # reads the value out of its own /etc/resolv.conf on this same network. Hard-coding either + # address serves the SPA fine on the other runtime while every proxied API call fails. # $request_uri is required: a variable proxy_pass no longer forwards the URI implicitly. - resolver 127.0.0.11 valid=10s ipv6=off; + resolver __RESOLVER__ valid=10s ipv6=off; set $studio http://duplo-ai-studio:60021; proxy_pass $studio$request_uri; proxy_http_version 1.1; diff --git a/tests/test-ui-nginx.sh b/tests/test-ui-nginx.sh new file mode 100755 index 0000000..8b574e1 --- /dev/null +++ b/tests/test-ui-nginx.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Checks that the UI's nginx config is runtime-agnostic — specifically that the DNS resolver it proxies +# through is discovered at run time rather than hard-coded to one container runtime's address. +# +# Static checks only: nothing here starts the stack or needs docker/podman installed, so it runs the +# same on a docker laptop, a podman box and CI. +set -uo pipefail +cd "$(dirname "$0")/.." || exit 1 +PASS=0; FAIL=0 +t() { printf ' %s … ' "$1"; } +ok() { echo "ok"; PASS=$((PASS+1)); } +bad() { echo "FAIL: $1"; FAIL=$((FAIL+1)); } + +CONF=nginx/default.conf +COMPOSE=docker-compose.yml + +echo "UI nginx resolver is runtime-agnostic:" + +# 127.0.0.11 is Docker's embedded DNS and does not exist under podman, whose aardvark-dns sits on the +# network gateway (10.89.0.1 on a default install, but it varies by network). Hard-coding either one +# breaks the other runtime: the SPA still serves, every proxied API call fails, and the login page is +# simply inert — the worst shape of broken. +t "nginx conf does not hard-code a runtime-specific DNS address" +if grep -qE 'resolver[[:space:]]+(127\.0\.0\.11|10\.[0-9]+\.[0-9]+\.[0-9]+)' "$CONF"; then + bad "resolver is pinned to one runtime's DNS: $(grep -E '^[[:space:]]*resolver' "$CONF" | tr -d ' ')" +else ok; fi + +t "nginx conf carries a resolver placeholder for substitution" +grep -qE '^[[:space:]]*resolver[[:space:]]+__RESOLVER__' "$CONF" && ok || bad "no __RESOLVER__ placeholder" + +t "compose defines an init service that renders the conf" +grep -qE '^[[:space:]]*init-nginx-conf:' "$COMPOSE" && ok || bad "no init-nginx-conf service" + +# Extract ONLY the service definition, from its own key to the next top-level service. A plain +# `grep -A` also matches the depends_on reference in duplo-ui and runs off the end into the next +# service, which made this file's first draft report xterm's ${XTERM_TAG} as an unescaped variable. +svc_block() { awk '/^ init-nginx-conf:$/{f=1;next} f&&/^ [a-z]/{exit} f' "$COMPOSE"; } + +t "the init service discovers DNS from its own resolv.conf, not a literal" +if svc_block | grep -q '/etc/resolv.conf'; then ok +else bad "init does not read /etc/resolv.conf"; fi + +t "duplo-ui waits for the render to complete before starting" +if grep -A22 'duplo-ui:' "$COMPOSE" | grep -A2 'init-nginx-conf:' | grep -q 'service_completed_successfully'; then ok +else bad "duplo-ui does not depend on init-nginx-conf completing"; fi + +# Compose interpolates $VAR inside `command:` before the shell ever sees it, so a shell variable must be +# written $$VAR. Getting this wrong yields an empty resolver and nginx fails to load the conf at all. +t "shell variables in the init command are escaped for compose interpolation" +BLOCK="$(svc_block)" +if printf '%s' "$BLOCK" | grep -qE '(^|[^$])\$[A-Za-z{(]'; then + bad "unescaped \$ in the init command — compose will interpolate it away" +else ok; fi + +echo +echo "$PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ]