From 3056311c24faafa2544a3502cf56b224b3f9dd59 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 12 May 2026 09:08:20 +0200 Subject: [PATCH 1/3] fix(ci): resolve smoke test host via bridge gateway, not 127.0.0.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Job containers run in bridge network mode (runner-config.yaml). Inside a bridge-networked container 127.0.0.1 is the container's own loopback; Caddy on the host is unreachable there, causing an immediate ECONNREFUSED. Use the Docker bridge gateway IP instead — the host's docker0 interface where Caddy (bound on 0.0.0.0:443) is reachable from the container. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/nightly.yml | 16 ++++++++++------ .gitea/workflows/release.yml | 9 +++++---- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 490a91fc..a16a0797 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -158,16 +158,20 @@ jobs: # public surface works. This step catches: Caddy not reloaded, HSTS # header dropped, /actuator block bypassed. # - # --resolve pins staging.raddatz.cloud to the runner's loopback so we - # do NOT depend on the host router doing hairpin NAT (many SOHO - # routers do not, or do so only after a firmware update). SNI still - # uses the public hostname so the cert validates correctly. + # --resolve pins staging.raddatz.cloud to the Docker bridge gateway IP + # (the host) so we do NOT depend on hairpin NAT on the host router. + # 127.0.0.1 cannot be used: job containers run in bridge network mode + # (runner-config.yaml), so 127.0.0.1 is the container's loopback, not + # the host's. The bridge gateway IS the host; Caddy binds 0.0.0.0:443 + # and is therefore reachable from the container via that IP. + # SNI still uses the public hostname so the TLS cert validates correctly. run: | set -e HOST="staging.raddatz.cloud" URL="https://$HOST" - RESOLVE="--resolve $HOST:443:127.0.0.1" - echo "Smoke test: $URL (pinned to 127.0.0.1)" + HOST_IP=$(ip route show default | awk '/default/ {print $3}') + RESOLVE="--resolve $HOST:443:$HOST_IP" + echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null # Pin the preload-list-eligible HSTS value, not just header presence: # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 8d355da2..4dafb0e3 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -107,14 +107,15 @@ jobs: - name: Smoke test deployed environment # See nightly.yml — same three checks, against the prod vhost. - # --resolve pins archiv.raddatz.cloud to the runner's loopback so - # the smoke test does NOT depend on hairpin NAT on the host router. + # --resolve pins to the bridge gateway IP (the host), not 127.0.0.1 + # — see nightly.yml for the full network topology explanation. run: | set -e HOST="archiv.raddatz.cloud" URL="https://$HOST" - RESOLVE="--resolve $HOST:443:127.0.0.1" - echo "Smoke test: $URL (pinned to 127.0.0.1)" + HOST_IP=$(ip route show default | awk '/default/ {print $3}') + RESOLVE="--resolve $HOST:443:$HOST_IP" + echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null # Pin the preload-list-eligible HSTS value, not just header presence: # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must -- 2.49.1 From f1032865f38f854deafe3a74b2dae8a90139ee20 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 12 May 2026 09:24:23 +0200 Subject: [PATCH 2/3] fix(ci): guard against empty HOST_IP in smoke test If `ip route show default` returns no output the old code passed an empty string to curl --resolve, producing a confusing error 6 ("couldn't resolve host") with no indication that gateway detection had failed. The new guard exits immediately with a clear message. Addresses review concern raised by Tobias Wendt. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/nightly.yml | 1 + .gitea/workflows/release.yml | 1 + 2 files changed, 2 insertions(+) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index a16a0797..5e57c6e4 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -170,6 +170,7 @@ jobs: HOST="staging.raddatz.cloud" URL="https://$HOST" HOST_IP=$(ip route show default | awk '/default/ {print $3}') + [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; } RESOLVE="--resolve $HOST:443:$HOST_IP" echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 4dafb0e3..714dc864 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -114,6 +114,7 @@ jobs: HOST="archiv.raddatz.cloud" URL="https://$HOST" HOST_IP=$(ip route show default | awk '/default/ {print $3}') + [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; } RESOLVE="--resolve $HOST:443:$HOST_IP" echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null -- 2.49.1 From 6d16be4669655e3ee996b2b6f4110187601d8244 Mon Sep 17 00:00:00 2001 From: Marcel Date: Tue, 12 May 2026 09:25:51 +0200 Subject: [PATCH 3/3] fix(ci): quote \$RESOLVE in all curl calls Unquoted variable expansion is safe here since the value contains no spaces or glob characters, but quoting is the correct default and keeps the script consistent with surrounding style. Addresses review suggestion by Felix Brandt and Tobias Wendt. Co-Authored-By: Claude Sonnet 4.6 --- .gitea/workflows/nightly.yml | 8 ++++---- .gitea/workflows/release.yml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/nightly.yml b/.gitea/workflows/nightly.yml index 5e57c6e4..7bc67a0e 100644 --- a/.gitea/workflows/nightly.yml +++ b/.gitea/workflows/nightly.yml @@ -173,18 +173,18 @@ jobs: [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; } RESOLVE="--resolve $HOST:443:$HOST_IP" echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" - curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null + curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null # Pin the preload-list-eligible HSTS value, not just header presence: # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must # fail this check rather than pass it silently. - curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ + curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \ | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' # Permissions-Policy denies APIs the app does not use (camera, # microphone, geolocation). A regression that loosens or drops the # header now fails the smoke step. - curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ + curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \ | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' - status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") + status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } echo "All smoke checks passed" diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 714dc864..d980ca10 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -117,18 +117,18 @@ jobs: [ -n "$HOST_IP" ] || { echo "ERROR: could not detect Docker bridge gateway via 'ip route'"; exit 1; } RESOLVE="--resolve $HOST:443:$HOST_IP" echo "Smoke test: $URL (pinned to $HOST_IP via bridge gateway)" - curl -fsS $RESOLVE --max-time 10 "$URL/login" -o /dev/null + curl -fsS "$RESOLVE" --max-time 10 "$URL/login" -o /dev/null # Pin the preload-list-eligible HSTS value, not just header presence: # a degraded `max-age=1` or a dropped `includeSubDomains; preload` must # fail this check rather than pass it silently. - curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ + curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \ | grep -Eqi 'strict-transport-security:[[:space:]]*max-age=31536000.*includeSubDomains.*preload' # Permissions-Policy denies APIs the app does not use (camera, # microphone, geolocation). A regression that loosens or drops the # header now fails the smoke step. - curl -fsS $RESOLVE --max-time 10 -I "$URL/" \ + curl -fsS "$RESOLVE" --max-time 10 -I "$URL/" \ | grep -Eqi 'permissions-policy:[[:space:]]*camera=\(\),[[:space:]]*microphone=\(\),[[:space:]]*geolocation=\(\)' - status=$(curl -s $RESOLVE -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") + status=$(curl -s "$RESOLVE" -o /dev/null -w "%{http_code}" --max-time 10 "$URL/actuator/health") [ "$status" = "404" ] || { echo "expected 404 from /actuator/health, got $status"; exit 1; } echo "All smoke checks passed" -- 2.49.1