diff --git a/.gitea/actions/smoke-test/action.yml b/.gitea/actions/smoke-test/action.yml new file mode 100644 index 00000000..cf8a07e0 --- /dev/null +++ b/.gitea/actions/smoke-test/action.yml @@ -0,0 +1,58 @@ +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 "expected 404 from /actuator/health, got $status"; exit 1; } + echo "All smoke checks passed"