diff --git a/docker-compose.staging.yml b/docker-compose.staging.yml index a9e6199d..05109d07 100644 --- a/docker-compose.staging.yml +++ b/docker-compose.staging.yml @@ -70,6 +70,7 @@ services: - ./web/dist:/usr/share/nginx/html:ro - ./web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro environment: + NGINX_ENTRYPOINT_LOCAL_RESOLVERS: "1" SKILLHUB_API_UPSTREAM: http://server:8080 SKILLHUB_WEB_API_BASE_URL: "" SKILLHUB_PUBLIC_BASE_URL: "" diff --git a/scripts/tests/nginx-forwarded-proto-test.sh b/scripts/tests/nginx-forwarded-proto-test.sh index 01be85c3..679b1dd0 100755 --- a/scripts/tests/nginx-forwarded-proto-test.sh +++ b/scripts/tests/nginx-forwarded-proto-test.sh @@ -39,13 +39,25 @@ wait_for_nginx() { fail "$container did not become healthy" } +create_test_network() { + local subnet + for subnet in 172.29.0.0/24 172.30.0.0/24 192.168.252.0/24 10.254.0.0/24; do + if docker network create --driver bridge --subnet "$subnet" "$NETWORK" >/dev/null 2>&1; then + return 0 + fi + done + fail "could not create a test network with an explicit subnet" +} + start_proxy() { local container="$1" local trust_forwarded_proto="$2" + local backend_name="${3:-$BACKEND}" docker run --detach \ --name "$container" \ --network "$NETWORK" \ - --env "SKILLHUB_API_UPSTREAM=http://$BACKEND:8080" \ + --env "NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1" \ + --env "SKILLHUB_API_UPSTREAM=http://$backend_name:8080" \ --env "SKILLHUB_TRUST_FORWARDED_PROTO=$trust_forwarded_proto" \ --volume "$TEMPLATE:/etc/nginx/templates/default.conf.template:ro" \ "$NGINX_IMAGE" >/dev/null @@ -80,7 +92,7 @@ server { } EOF -docker network create "$NETWORK" >/dev/null +create_test_network docker run --detach \ --name "$BACKEND" \ --network "$NETWORK" \ @@ -98,4 +110,89 @@ done assert_proto "$TRUSTED_PROXY" http assert_proto "$TRUSTED_PROXY" http "https,http" -echo "nginx-forwarded-proto-test passed" +DNS_BACKEND="${TEST_ID}-dns-backend" +DNS_OLD_BACKEND="${TEST_ID}-dns-old" +DNS_NEW_BACKEND="${TEST_ID}-dns-new" +DNS_STALE_BACKEND="${TEST_ID}-dns-stale" +DNS_PROXY="${TEST_ID}-dns-proxy" + +start_dns_backend() { + local container="$1" + local response="$2" + local alias="${3:-}" + local static_ip="${4:-}" + local config="$TMP_DIR/$container.conf" + cat >"$config" </dev/null + CONTAINERS+=("$container") +} + +container_ip() { + docker inspect --format "{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}" "$1" +} + +start_dns_backend "$DNS_OLD_BACKEND" first "$DNS_BACKEND" +start_proxy "$DNS_PROXY" false "$DNS_BACKEND" +assert_dns_response() { + local expected="$1" + local actual + actual="$(docker exec "$DNS_PROXY" wget -qO- http://127.0.0.1/api/dns)" \ + || fail "DNS proxy request failed; expected '$expected'" + [[ "$actual" == "$expected" ]] \ + || fail "DNS proxy returned '$actual', expected '$expected'" +} +assert_dns_response first + +old_ip="$(container_ip "$DNS_OLD_BACKEND")" +docker rm -f "$DNS_OLD_BACKEND" >/dev/null +start_dns_backend "$DNS_STALE_BACKEND" stale "" "$old_ip" +start_dns_backend "$DNS_NEW_BACKEND" second "$DNS_BACKEND" +new_ip="$(container_ip "$DNS_NEW_BACKEND")" +[[ "$old_ip" != "$new_ip" ]] || fail "replacement backend reused old IP $old_ip" + +refreshed=false +saw_stale_response=false +refresh_started=$SECONDS +for attempt in {1..36}; do + if actual="$(docker exec "$DNS_PROXY" wget -qO- http://127.0.0.1/api/dns 2>/dev/null)"; then + if [[ "$actual" == second ]]; then + refreshed=true + break + fi + [[ "$actual" == stale ]] \ + || fail "DNS proxy returned unexpected backend marker '$actual'" + saw_stale_response=true + fi + sleep 0.5 +done +[[ "$refreshed" == true ]] \ + || { docker logs "$DNS_PROXY" >&2 || true; fail "unchanged proxy did not reach replacement backend $new_ip"; } +refresh_elapsed=$((SECONDS - refresh_started)) +((refresh_elapsed <= 12)) \ + || fail "DNS refresh took ${refresh_elapsed}s, exceeding the 12s test boundary" + +echo "nginx-forwarded-proto-test passed, including DNS refresh ($old_ip -> $new_ip) in ${refresh_elapsed}s; stale cache observed=$saw_stale_response" diff --git a/scripts/tests/web-base-path-nginx-smoke-test.sh b/scripts/tests/web-base-path-nginx-smoke-test.sh index ba2aa809..d4ef849e 100755 --- a/scripts/tests/web-base-path-nginx-smoke-test.sh +++ b/scripts/tests/web-base-path-nginx-smoke-test.sh @@ -15,7 +15,6 @@ fi ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/../.." && pwd) NGINX_IMAGE="${NGINX_SMOKE_IMAGE:-nginx:alpine}" name="skillhub-base-path-smoke-$$" -port=18080 tmp=$(mktemp -d) cleanup() { @@ -24,6 +23,10 @@ cleanup() { } trap cleanup EXIT +published_port() { + docker inspect --format '{{(index (index .NetworkSettings.Ports "80/tcp") 0).HostPort}}' "$1" +} + html="$tmp/html" mkdir -p "$html/assets" "$html/registry" printf '%s\n' 'INDEX_HTML_MARKER' >"$html/index.html" @@ -40,7 +43,8 @@ cp "$ROOT_DIR/web/docker-entrypoint.d/30-runtime-config.sh" "$entrypoint_d/30-ru chmod +x "$entrypoint_d/20-base-path.sh" "$entrypoint_d/30-runtime-config.sh" if ! docker run -d --name "$name" \ - -p "$port:80" \ + -p 127.0.0.1::80 \ + -e NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 \ -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ -e SKILLHUB_WEB_BASE_PATH=/skillhub/ \ @@ -49,11 +53,12 @@ if ! docker run -d --name "$name" \ -v "$ROOT_DIR/web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro" \ -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ -v "$entrypoint_d/30-runtime-config.sh:/docker-entrypoint.d/30-runtime-config.sh:ro" \ - "$NGINX_IMAGE" >/dev/null 2>&1; then - printf '%s\n' 'web-base-path-nginx-smoke-test skipped (docker run failed, e.g. no image/network)' - exit 0 + "$NGINX_IMAGE" >/dev/null; then + echo 'nginx container failed to start' >&2 + exit 1 fi +port=$(published_port "$name") base="http://127.0.0.1:$port" ready=0 i=0 @@ -151,9 +156,9 @@ printf '%s\n' 'INDEX_HTML_MARKER' >"$default_html/index.html" cp "$ROOT_DIR/web/src/docs/skill.md.template" "$default_html/registry/skill.md.template" cp "$ROOT_DIR/web/runtime-config.js.template" "$default_html/runtime-config.js.template" name_default="$name-default" -port_default=18082 docker run -d --name "$name_default" \ - -p "$port_default:80" \ + -p 127.0.0.1::80 \ + -e NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 \ -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ -e SKILLHUB_WEB_BASE_PATH=/skillhub/ \ @@ -163,6 +168,7 @@ docker run -d --name "$name_default" \ -v "$entrypoint_d/30-runtime-config.sh:/docker-entrypoint.d/30-runtime-config.sh:ro" \ "$NGINX_IMAGE" >/dev/null +port_default=$(published_port "$name_default") default_base="http://127.0.0.1:$port_default" i=0 until curl -fsS -o /dev/null "$default_base/nginx-health" 2>/dev/null; do @@ -203,9 +209,9 @@ printf '%s\n' 'INDEX_HTML_MARKER' >"$trusted_html/index.html" cp "$ROOT_DIR/web/src/docs/skill.md.template" "$trusted_html/registry/skill.md.template" cp "$ROOT_DIR/web/runtime-config.js.template" "$trusted_html/runtime-config.js.template" name_trusted="$name-trusted" -port_trusted=18083 docker run -d --name "$name_trusted" \ - -p "$port_trusted:80" \ + -p 127.0.0.1::80 \ + -e NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 \ -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ -e SKILLHUB_TRUST_FORWARDED_PROTO=true \ -e SKILLHUB_WEB_BASE_PATH=/skillhub/ \ @@ -214,6 +220,7 @@ docker run -d --name "$name_trusted" \ -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ -v "$entrypoint_d/30-runtime-config.sh:/docker-entrypoint.d/30-runtime-config.sh:ro" \ "$NGINX_IMAGE" >/dev/null +port_trusted=$(published_port "$name_trusted") trusted_base="http://127.0.0.1:$port_trusted" i=0 until curl -fsS -o /dev/null "$trusted_base/nginx-health" 2>/dev/null; do @@ -242,10 +249,10 @@ printf '%s\n' 'FIXED_APP_JS_MARKER' >"$fixed_html/assets/app.js" baked_file="$tmp/baked-base-path" printf '%s' '/fixed/' >"$baked_file" fixed_name="$name-fixed" -fixed_port=18081 docker run -d --name "$fixed_name" \ - -p "$fixed_port:80" \ + -p 127.0.0.1::80 \ + -e NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 \ -e SKILLHUB_API_UPSTREAM=http://127.0.0.1:9 \ -e SKILLHUB_TRUST_FORWARDED_PROTO=false \ -e SKILLHUB_WEB_BASE_PATH= \ @@ -254,8 +261,9 @@ docker run -d --name "$fixed_name" \ -v "$baked_file:/etc/skillhub/baked-base-path:ro" \ -v "$ROOT_DIR/web/nginx.conf.template:/etc/nginx/templates/default.conf.template:ro" \ -v "$entrypoint_d/20-base-path.sh:/docker-entrypoint.d/20-base-path.sh:ro" \ - "$NGINX_IMAGE" >/dev/null 2>&1 + "$NGINX_IMAGE" >/dev/null +fixed_port=$(published_port "$fixed_name") fixed_base="http://127.0.0.1:$fixed_port" ready=0 i=0 diff --git a/web/Dockerfile b/web/Dockerfile index bf8f1fb0..74a45aa4 100644 --- a/web/Dockerfile +++ b/web/Dockerfile @@ -13,6 +13,7 @@ RUN pnpm build FROM nginx:alpine ENV SKILLHUB_TRUST_FORWARDED_PROTO=false +ENV NGINX_ENTRYPOINT_LOCAL_RESOLVERS=1 # Record a fixed build-time base so the entrypoint defaults SKILLHUB_WEB_BASE_PATH # to it and generates matching Nginx routing without repeating the value at runtime. # Placeholder builds (the default) intentionally write no file and default to '/'. diff --git a/web/nginx.conf.template b/web/nginx.conf.template index 7bf1c1ee..b2b78d25 100644 --- a/web/nginx.conf.template +++ b/web/nginx.conf.template @@ -2,6 +2,8 @@ server_tokens off; server { listen 80; server_name _; + resolver ${NGINX_LOCAL_RESOLVERS} valid=10s; + set $skillhub_api_upstream "${SKILLHUB_API_UPSTREAM}"; root /usr/share/nginx/html; index index.html; @@ -34,7 +36,7 @@ server { } location /api/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_pass $skillhub_api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -43,7 +45,7 @@ server { } location /oauth2/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_pass $skillhub_api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -52,7 +54,7 @@ server { } location /login/oauth2/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_pass $skillhub_api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; @@ -61,7 +63,7 @@ server { } location /.well-known/ { - proxy_pass ${SKILLHUB_API_UPSTREAM}; + proxy_pass $skillhub_api_upstream; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;