name: Smoke test description: >- Verify the deployed public surface (login reachable, HSTS pinned, Permissions-Policy present, /actuator blocked) against a given vhost. inputs: host: description: Public vhost to smoke-test, e.g. staging.raddatz.cloud required: true runs: using: composite steps: - name: Smoke test deployed environment shell: bash # Healthchecks confirm containers are healthy; they do NOT confirm the # public surface works. This step catches: Caddy not reloaded, HSTS # header dropped, /actuator block bypassed. # # --resolve pins the public host 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. # # --resolve is stored as a Bash array so "${RESOLVE[@]}" expands to two # separate arguments; a quoted string would pass the flag and its value # as one token and curl would reject it as an unknown option. # # Gateway detection reads /proc/net/route (always present, no package # required) instead of `ip route` to avoid a dependency on iproute2. # Field $2=="00000000" is the default route; field $3 is the gateway as # a little-endian 32-bit hex value which awk decodes to dotted-decimal. env: HOST: ${{ inputs.host }} run: | set -e URL="https://$HOST" HOST_IP=$(awk 'NR>1 && $2=="00000000"{h=$3;printf "%d.%d.%d.%d\n",strtonum("0x"substr(h,7,2)),strtonum("0x"substr(h,5,2)),strtonum("0x"substr(h,3,2)),strtonum("0x"substr(h,1,2));exit}' /proc/net/route) [ -n "$HOST_IP" ] || { echo "::error::could not detect Docker bridge gateway via /proc/net/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 # 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/" \ | 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/" \ | 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" = "404" ] || { echo "::error::expected 404 from /actuator/health, got $status"; exit 1; } echo "All smoke checks passed"