diff --git a/AGENTS.md b/AGENTS.md index 4e230dc..675a1bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,6 +24,7 @@ Orchestration for Proxmox VE, Chain 138 (`smom-dbis-138/`), explorers, NPMplus, | Portal login + Keycloak systemd + `.env` (prints password once) | `./scripts/deployment/enable-sankofa-portal-login-7801.sh` (`--dry-run` first) | | Completable (no LAN) | `./scripts/run-completable-tasks-from-anywhere.sh` | | Operator (LAN + secrets) | `./scripts/run-all-operator-tasks-from-lan.sh` (use `--skip-backup` if `NPM_PASSWORD` unset) | +| Cloudflare bulk DNS → `PUBLIC_IP` | `./scripts/update-all-dns-to-public-ip.sh` — use **`--dry-run`** and **`--zone-only=sankofa.nexus`** (or `d-bis.org` / `mim4u.org` / `defi-oracle.io`) to limit scope; see script header | ## Rules of engagement diff --git a/docs/04-configuration/ALL_VMIDS_ENDPOINTS.md b/docs/04-configuration/ALL_VMIDS_ENDPOINTS.md index 3ff8d85..73dcc15 100644 --- a/docs/04-configuration/ALL_VMIDS_ENDPOINTS.md +++ b/docs/04-configuration/ALL_VMIDS_ENDPOINTS.md @@ -249,9 +249,9 @@ The following VMIDs have been permanently removed: **Public Domains** (NPMplus routing): - `sankofa.nexus` → Routes to `http://192.168.11.51:3000` (Sankofa Portal/VMID 7801) ✅ -- `www.sankofa.nexus` → Routes to `http://192.168.11.51:3000` (Sankofa Portal/VMID 7801) ✅ +- `www.sankofa.nexus` → Same upstream as apex; NPM **`advanced_config`** issues **301** to **`https://sankofa.nexus`** (preserve path/query via `$request_uri`). ✅ - `phoenix.sankofa.nexus` → Routes to `http://192.168.11.50:4000` (Phoenix API/VMID 7800) ✅ -- `www.phoenix.sankofa.nexus` → Routes to `http://192.168.11.50:4000` (Phoenix API/VMID 7800) ✅ +- `www.phoenix.sankofa.nexus` → Same upstream; **301** to **`https://phoenix.sankofa.nexus`**. ✅ - `the-order.sankofa.nexus` / `www.the-order.sankofa.nexus` → OSJ management portal (secure auth). App source: **the_order** at `~/projects/the_order`. NPMplus **target** order-haproxy `http://192.168.11.39:80` (VMID **10210**) when that stack is serving. Until then, `update-npmplus-proxy-hosts-api.sh` defaults upstream to Sankofa portal `http://192.168.11.51:3000` (7801); override with `THE_ORDER_UPSTREAM_IP` / `THE_ORDER_UPSTREAM_PORT` when switching to HAProxy. **`www.the-order.sankofa.nexus`** is configured for **301** to **`https://the-order.sankofa.nexus`** (same pattern as `www.sankofa` / `www.phoenix`). - `studio.sankofa.nexus` → Routes to `http://192.168.11.72:8000` (Sankofa Studio / VMID 7805) diff --git a/docs/04-configuration/E2E_ENDPOINTS_LIST.md b/docs/04-configuration/E2E_ENDPOINTS_LIST.md index 69e8639..9cc04dd 100644 --- a/docs/04-configuration/E2E_ENDPOINTS_LIST.md +++ b/docs/04-configuration/E2E_ENDPOINTS_LIST.md @@ -28,9 +28,9 @@ | secure.mim4u.org | web | https://secure.mim4u.org | MIM4U secure portal. | | training.mim4u.org | web | https://training.mim4u.org | MIM4U training site. | | sankofa.nexus | web | https://sankofa.nexus | Sankofa Nexus root / web. | -| www.sankofa.nexus | web | https://www.sankofa.nexus | Sankofa Nexus www. | -| phoenix.sankofa.nexus | web | https://phoenix.sankofa.nexus | Phoenix (Sankofa) web app. | -| www.phoenix.sankofa.nexus | web | https://www.phoenix.sankofa.nexus | Phoenix www. | +| www.sankofa.nexus | web | https://www.sankofa.nexus | **301** to `https://sankofa.nexus` (canonical apex; NPM `advanced_config`). | +| phoenix.sankofa.nexus | web | https://phoenix.sankofa.nexus | Phoenix API (7800); E2E uses `/health` for HTTPS check. | +| www.phoenix.sankofa.nexus | web | https://www.phoenix.sankofa.nexus | **301** to `https://phoenix.sankofa.nexus` (canonical apex; NPM `advanced_config`). | | the-order.sankofa.nexus | web | https://the-order.sankofa.nexus | OSJ (Sovereign Military Order of Malta) management portal behind secure auth; app source repo **the_order** at `~/projects/the_order` (NPM upstream: order-haproxy 10210 when live, else interim portal 7801 per `update-npmplus-proxy-hosts-api.sh`). | | www.the-order.sankofa.nexus | web | https://www.the-order.sankofa.nexus | **301** to `https://the-order.sankofa.nexus` (canonical apex; NPM `advanced_config`). | | studio.sankofa.nexus | web | https://studio.sankofa.nexus | Sankofa Studio (FusionAI Creator) at VMID 7805. | @@ -168,6 +168,10 @@ When running from outside LAN or when backends are down, the following endpoints **Verifier behavior (2026-03):** `openssl s_client` is wrapped with `timeout` (`E2E_OPENSSL_TIMEOUT` default 15s, `E2E_OPENSSL_X509_TIMEOUT` default 5s) so `--profile=private` / `--profile=all` cannot hang. **`--profile=all`** merges private and public `E2E_OPTIONAL_WHEN_FAIL` lists for temporary regressions. Install **`wscat`** (`npm install -g wscat`) for full WSS JSON-RPC checks; the script uses `wscat -n` to match `curl -k`, and now treats a clean `wscat` exit as a successful full WebSocket check even when the tool prints no JSON output. +**Canonical www redirects (2026-03):** For `www.sankofa.nexus`, `www.phoenix.sankofa.nexus`, and `www.the-order.sankofa.nexus`, HTTP **301**/**308** must include a **`Location`** whose host matches the expected apex (`E2E_WWW_CANONICAL_BASE` in `verify-end-to-end-routing.sh`). Wrong apex → HTTPS **fail**. Missing `Location` → **warn**. + +**Cloudflare bulk DNS:** `scripts/update-all-dns-to-public-ip.sh` supports **`--dry-run`** (no API calls) and **`--zone-only=sankofa.nexus`** (or `d-bis.org` | `mim4u.org` | `defi-oracle.io`) to limit blast radius. Env: `CLOUDFLARE_DNS_DRY_RUN=1`, `DNS_ZONE_ONLY=…`. + **WebSocket test-format warnings:** Older runs may show "connection established but RPC test failed" when `wscat` is used: the upgrade succeeded but the verifier expected printable `"result"` output. The script now accepts either explicit JSON output or a clean `wscat` exit, so current runs treat those WS checks as pass when the connection completes successfully. The script also accepts Chain 138 chainId `0x8a` in output. ### Remediation (when you want these to pass from public) diff --git a/scripts/nginx-proxy-manager/update-npmplus-proxy-hosts-api.sh b/scripts/nginx-proxy-manager/update-npmplus-proxy-hosts-api.sh index 5d4058b..380c706 100755 --- a/scripts/nginx-proxy-manager/update-npmplus-proxy-hosts-api.sh +++ b/scripts/nginx-proxy-manager/update-npmplus-proxy-hosts-api.sh @@ -361,7 +361,8 @@ update_proxy_host "keycloak.sankofa.nexus" "http://${IP_KEYCLOAK}:8080" false fa IP_ORDER_HAPROXY="${IP_ORDER_HAPROXY:-192.168.11.39}" THE_ORDER_UPSTREAM_IP="${THE_ORDER_UPSTREAM_IP:-${IP_SANKOFA_PORTAL}}" THE_ORDER_UPSTREAM_PORT="${THE_ORDER_UPSTREAM_PORT:-${SANKOFA_PORTAL_PORT}}" -update_proxy_host "the-order.sankofa.nexus" "http://${THE_ORDER_UPSTREAM_IP}:${THE_ORDER_UPSTREAM_PORT}" false && updated_count=$((updated_count + 1)) || { add_proxy_host "the-order.sankofa.nexus" "${THE_ORDER_UPSTREAM_IP}" "${THE_ORDER_UPSTREAM_PORT}" false false && updated_count=$((updated_count + 1)); } || failed_count=$((failed_count + 1)) +# block_exploits false — same policy as sankofa.nexus portal (Next/API-friendly; avoid 405 on some POST paths) +update_proxy_host "the-order.sankofa.nexus" "http://${THE_ORDER_UPSTREAM_IP}:${THE_ORDER_UPSTREAM_PORT}" false false && updated_count=$((updated_count + 1)) || { add_proxy_host "the-order.sankofa.nexus" "${THE_ORDER_UPSTREAM_IP}" "${THE_ORDER_UPSTREAM_PORT}" false false && updated_count=$((updated_count + 1)); } || failed_count=$((failed_count + 1)) update_proxy_host "www.the-order.sankofa.nexus" "http://${THE_ORDER_UPSTREAM_IP}:${THE_ORDER_UPSTREAM_PORT}" false false "https://the-order.sankofa.nexus" && updated_count=$((updated_count + 1)) || { add_proxy_host "www.the-order.sankofa.nexus" "${THE_ORDER_UPSTREAM_IP}" "${THE_ORDER_UPSTREAM_PORT}" false false "https://the-order.sankofa.nexus" && updated_count=$((updated_count + 1)); } || failed_count=$((failed_count + 1)) # Sankofa Studio (FusionAI) — VMID 7805; UI at /studio/ on same origin (port 8000). Prefer IP_SANKOFA_STUDIO from ip-addresses.conf / .env IP_SANKOFA_STUDIO="${IP_SANKOFA_STUDIO:-192.168.11.72}" diff --git a/scripts/update-all-dns-to-public-ip.sh b/scripts/update-all-dns-to-public-ip.sh index 960cecc..780b54f 100755 --- a/scripts/update-all-dns-to-public-ip.sh +++ b/scripts/update-all-dns-to-public-ip.sh @@ -3,6 +3,16 @@ # Sets all records to DNS only mode (gray cloud) for direct NAT routing # Supports multiple zones: sankofa.nexus, d-bis.org, mim4u.org, defi-oracle.io # UDM Pro port forwarding: 76.53.10.36:80/443 → ${IP_NPMPLUS:-${IP_NPMPLUS:-192.168.11.167}}:80/443 (NPMplus) +# +# WARNING: For each hostname, existing CNAME records are deleted before an A record is created. +# By default all configured zones are processed. Use --zone-only to limit scope. +# +# Usage: +# ./scripts/update-all-dns-to-public-ip.sh +# ./scripts/update-all-dns-to-public-ip.sh --dry-run +# ./scripts/update-all-dns-to-public-ip.sh --zone-only=sankofa.nexus +# ./scripts/update-all-dns-to-public-ip.sh --dry-run --zone-only=d-bis.org +# Env (optional): CLOUDFLARE_DNS_DRY_RUN=1, DNS_ZONE_ONLY=sankofa.nexus (same as flags) set -euo pipefail @@ -11,6 +21,22 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" source "${PROJECT_ROOT}/config/ip-addresses.conf" 2>/dev/null || true +# --help before .env (so operators can read usage without credentials) +for _arg in "$@"; do + if [[ "$_arg" == "--help" || "$_arg" == "-h" ]]; then + cat <<'EOF' +Cloudflare DNS → PUBLIC_IP (A records, DNS-only / gray cloud). + +Options: + --dry-run Log intended changes only; no Cloudflare API calls. + --zone-only=ZONE ZONE one of: sankofa.nexus | d-bis.org | mim4u.org | defi-oracle.io + -h, --help This message. + +Requires repo .env with Cloudflare auth and zone IDs (see script header). +EOF + exit 0 + fi +done # Colors RED='\033[0;31m' @@ -63,6 +89,23 @@ ZONE_D_BIS_ORG="${CLOUDFLARE_ZONE_ID_D_BIS_ORG:-${CLOUDFLARE_ZONE_ID:-}}" ZONE_MIM4U_ORG="${CLOUDFLARE_ZONE_ID_MIM4U_ORG:-}" ZONE_DEFI_ORACLE_IO="${CLOUDFLARE_ZONE_ID_DEFI_ORACLE_IO:-}" +# CLI / env: dry-run and single-zone scope +CLOUDFLARE_DNS_DRY_RUN="${CLOUDFLARE_DNS_DRY_RUN:-0}" +DNS_ZONE_ONLY="${DNS_ZONE_ONLY:-}" +parse_dns_update_cli_args() { + for arg in "$@"; do + case "$arg" in + --dry-run) CLOUDFLARE_DNS_DRY_RUN=1 ;; + --zone-only=*) DNS_ZONE_ONLY="${arg#*=}" ;; + esac + done +} +parse_dns_update_cli_args "$@" + +if [ "$CLOUDFLARE_DNS_DRY_RUN" = "1" ]; then + log_warn "DRY-RUN: no Cloudflare list/create/update/delete API calls will be made." +fi + # Function to make Cloudflare API request cf_api_request() { local method="$1" @@ -152,6 +195,11 @@ create_or_update_dns_record() { fi log_info "Processing: $full_name → $ip (proxied: $proxied)" + + if [ "$CLOUDFLARE_DNS_DRY_RUN" = "1" ]; then + log_success "[dry-run] Would remove CNAME(s) on $full_name if any, then upsert A → $ip (proxied=$proxied)" + return 0 + fi # Check for existing CNAME records (must delete before creating A record) local all_records=$(get_all_dns_records "$zone_id" "$full_name") @@ -256,12 +304,29 @@ main() { echo "" log_info "Public IP: $PUBLIC_IP" log_info "Proxy Mode: DNS Only (gray cloud)" + if [ -n "$DNS_ZONE_ONLY" ]; then + log_info "Zone filter: only $DNS_ZONE_ONLY" + fi echo "" local total_failures=0 + local run_sankofa=1 run_dbis=1 run_mim4u=1 run_defi=1 + if [ -n "$DNS_ZONE_ONLY" ]; then + run_sankofa=0 run_dbis=0 run_mim4u=0 run_defi=0 + case "$DNS_ZONE_ONLY" in + sankofa.nexus) run_sankofa=1 ;; + d-bis.org) run_dbis=1 ;; + mim4u.org) run_mim4u=1 ;; + defi-oracle.io) run_defi=1 ;; + *) + log_error "Unknown --zone-only=$DNS_ZONE_ONLY (expected: sankofa.nexus | d-bis.org | mim4u.org | defi-oracle.io)" + return 2 + ;; + esac + fi # sankofa.nexus domain records - if [ -n "$ZONE_SANKOFA_NEXUS" ]; then + if [ "$run_sankofa" = 1 ] && [ -n "$ZONE_SANKOFA_NEXUS" ]; then SANKOFA_RECORDS=( "@" # sankofa.nexus "www" # www.sankofa.nexus @@ -273,12 +338,12 @@ main() { if ! process_zone "$ZONE_SANKOFA_NEXUS" "sankofa.nexus" "${SANKOFA_RECORDS[@]}"; then ((total_failures++)) fi - else + elif [ "$run_sankofa" = 1 ]; then log_warn "Skipping sankofa.nexus (no zone ID configured)" fi # d-bis.org domain records - if [ -n "$ZONE_D_BIS_ORG" ]; then + if [ "$run_dbis" = 1 ] && [ -n "$ZONE_D_BIS_ORG" ]; then DBIS_RECORDS=( "rpc-http-pub" # rpc-http-pub.d-bis.org "rpc-ws-pub" # rpc-ws-pub.d-bis.org @@ -297,12 +362,12 @@ main() { if ! process_zone "$ZONE_D_BIS_ORG" "d-bis.org" "${DBIS_RECORDS[@]}"; then ((total_failures++)) fi - else + elif [ "$run_dbis" = 1 ]; then log_warn "Skipping d-bis.org (no zone ID configured)" fi # mim4u.org domain records - if [ -n "$ZONE_MIM4U_ORG" ]; then + if [ "$run_mim4u" = 1 ] && [ -n "$ZONE_MIM4U_ORG" ]; then MIM4U_RECORDS=( "@" # mim4u.org "www" # www.mim4u.org @@ -312,12 +377,12 @@ main() { if ! process_zone "$ZONE_MIM4U_ORG" "mim4u.org" "${MIM4U_RECORDS[@]}"; then ((total_failures++)) fi - else + elif [ "$run_mim4u" = 1 ]; then log_warn "Skipping mim4u.org (no zone ID configured)" fi # defi-oracle.io domain records - if [ -n "$ZONE_DEFI_ORACLE_IO" ]; then + if [ "$run_defi" = 1 ] && [ -n "$ZONE_DEFI_ORACLE_IO" ]; then DEFI_ORACLE_RECORDS=( "explorer" # explorer.defi-oracle.io (Blockscout - same as explorer.d-bis.org) "rpc.public-0138" # rpc.public-0138.defi-oracle.io @@ -327,7 +392,7 @@ main() { if ! process_zone "$ZONE_DEFI_ORACLE_IO" "defi-oracle.io" "${DEFI_ORACLE_RECORDS[@]}"; then ((total_failures++)) fi - else + elif [ "$run_defi" = 1 ]; then log_warn "Skipping defi-oracle.io (no zone ID configured)" fi @@ -354,5 +419,5 @@ main() { return $total_failures } -# Run main function -main "$@" +# Run (CLI already parsed above) +main diff --git a/scripts/update-sankofa-npmplus-proxy-hosts.sh b/scripts/update-sankofa-npmplus-proxy-hosts.sh index 8934ec9..8c159d6 100755 --- a/scripts/update-sankofa-npmplus-proxy-hosts.sh +++ b/scripts/update-sankofa-npmplus-proxy-hosts.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -# Update Sankofa NPMplus proxy hosts (portal + Phoenix API) via API. -# Prefer: scripts/nginx-proxy-manager/update-npmplus-proxy-hosts-api.sh (domain-based, runs with operator waves). -# NPM proxy host IDs below match backup backup-20260325_183932 (3–6); if your DB differs, use the main script instead. +# Update Sankofa NPMplus proxy hosts (portal + Phoenix API + the-order) via API by numeric host ID. +# Prefer for production: scripts/nginx-proxy-manager/update-npmplus-proxy-hosts-api.sh (domain-based, full fleet). +# NPM proxy host IDs below match backup backup-20260325_183932 (3–6, 7, 59); override with SANKOFA_NPM_ID_* if your DB differs. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" @@ -27,12 +27,16 @@ IP_SANKOFA_PORTAL="${IP_SANKOFA_PORTAL:-${IP_SERVICE_51:-192.168.11.51}}" IP_SANKOFA_PHOENIX_API="${IP_SANKOFA_PHOENIX_API:-${IP_SERVICE_50:-192.168.11.50}}" SANKOFA_PORTAL_PORT="${SANKOFA_PORTAL_PORT:-3000}" SANKOFA_PHOENIX_API_PORT="${SANKOFA_PHOENIX_API_PORT:-4000}" +THE_ORDER_UPSTREAM_IP="${THE_ORDER_UPSTREAM_IP:-${IP_SANKOFA_PORTAL}}" +THE_ORDER_UPSTREAM_PORT="${THE_ORDER_UPSTREAM_PORT:-${SANKOFA_PORTAL_PORT}}" -# NPM proxy host IDs: sankofa=3, www.sankofa=4, phoenix=5, www.phoenix=6 (typical fresh install order) +# NPM proxy host IDs: sankofa=3, www.sankofa=4, phoenix=5, www.phoenix=6; the-order=7, www.the-order=59 (typical; verify in NPM UI) SANKOFA_NPM_ID_ROOT="${SANKOFA_NPM_ID_ROOT:-3}" SANKOFA_NPM_ID_WWW="${SANKOFA_NPM_ID_WWW:-4}" SANKOFA_NPM_ID_PHOENIX="${SANKOFA_NPM_ID_PHOENIX:-5}" SANKOFA_NPM_ID_WWW_PHOENIX="${SANKOFA_NPM_ID_WWW_PHOENIX:-6}" +SANKOFA_NPM_ID_THE_ORDER="${SANKOFA_NPM_ID_THE_ORDER:-7}" +SANKOFA_NPM_ID_WWW_THE_ORDER="${SANKOFA_NPM_ID_WWW_THE_ORDER:-59}" # Optional 4th field: canonical HTTPS apex — NPM advanced_config 301 (www → apex). Matches update-npmplus-proxy-hosts-api.sh. declare -A PROXY_HOSTS=( @@ -40,6 +44,8 @@ declare -A PROXY_HOSTS=( ["$SANKOFA_NPM_ID_WWW"]="www.sankofa.nexus|${IP_SANKOFA_PORTAL}|${SANKOFA_PORTAL_PORT}|https://sankofa.nexus" ["$SANKOFA_NPM_ID_PHOENIX"]="phoenix.sankofa.nexus|${IP_SANKOFA_PHOENIX_API}|${SANKOFA_PHOENIX_API_PORT}|" ["$SANKOFA_NPM_ID_WWW_PHOENIX"]="www.phoenix.sankofa.nexus|${IP_SANKOFA_PHOENIX_API}|${SANKOFA_PHOENIX_API_PORT}|https://phoenix.sankofa.nexus" + ["$SANKOFA_NPM_ID_THE_ORDER"]="the-order.sankofa.nexus|${THE_ORDER_UPSTREAM_IP}|${THE_ORDER_UPSTREAM_PORT}|" + ["$SANKOFA_NPM_ID_WWW_THE_ORDER"]="www.the-order.sankofa.nexus|${THE_ORDER_UPSTREAM_IP}|${THE_ORDER_UPSTREAM_PORT}|https://the-order.sankofa.nexus" ) echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" diff --git a/scripts/verify/verify-end-to-end-routing.sh b/scripts/verify/verify-end-to-end-routing.sh index 3691d1f..d526165 100755 --- a/scripts/verify/verify-end-to-end-routing.sh +++ b/scripts/verify/verify-end-to-end-routing.sh @@ -193,6 +193,30 @@ declare -A E2E_HTTPS_PATH=( ["studio.sankofa.nexus"]="/studio/" ) +# Expected apex URL for NPM www → canonical 301/308 (Location must use this host; path from E2E_HTTPS_PATH must appear when set) +declare -A E2E_WWW_CANONICAL_BASE=( + ["www.sankofa.nexus"]="https://sankofa.nexus" + ["www.phoenix.sankofa.nexus"]="https://phoenix.sankofa.nexus" + ["www.the-order.sankofa.nexus"]="https://the-order.sankofa.nexus" +) + +# Returns 0 if Location URL matches expected canonical apex (and HTTPS path suffix when non-empty). +e2e_www_redirect_location_ok() { + local loc_val="$1" base="$2" path="${3:-}" + local loc_lc base_lc + loc_lc=$(printf '%s' "$loc_val" | tr '[:upper:]' '[:lower:]') + base_lc=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + if [[ "$loc_lc" != "$base_lc" && "$loc_lc" != "$base_lc/"* ]]; then + return 1 + fi + if [ -n "$path" ] && [ "$path" != "/" ]; then + local p_lc + p_lc=$(printf '%s' "$path" | tr '[:upper:]' '[:lower:]') + [[ "$loc_lc" == *"$p_lc"* ]] || return 1 + fi + return 0 +} + # --list-endpoints: print selected profile endpoints and exit (no tests) if [[ "$LIST_ENDPOINTS" == "1" ]]; then echo "" @@ -332,7 +356,8 @@ test_domain() { if [ -n "$http_code" ]; then # NPM canonical www → apex (advanced_config return 301/308) - _e2e_canonical_www_redirect="" + local _e2e_canonical_www_redirect="" + local location_hdr="" case "$domain" in www.sankofa.nexus|www.phoenix.sankofa.nexus|www.the-order.sankofa.nexus) if [ "$http_code" = "301" ] || [ "$http_code" = "308" ]; then @@ -342,9 +367,25 @@ test_domain() { esac if [ -n "$_e2e_canonical_www_redirect" ]; then location_hdr=$(echo "$headers" | grep -iE '^[Ll]ocation:' | head -1 | tr -d '\r' || echo "") - log_success "HTTPS: $domain returned HTTP $http_code (canonical redirect)${https_path:+ at ${https_url}}" - result=$(echo "$result" | jq --arg code "$http_code" --arg time "$time_total" --arg loc "$location_hdr" \ - '.tests.https = {"status": "pass", "http_code": ($code | tonumber), "response_time_seconds": ($time | tonumber), "canonical_redirect": true, "location_header": $loc}') + loc_val=$(printf '%s' "$location_hdr" | sed -E 's/^[Ll][Oo][Cc][Aa][Tt][Ii][Oo][Nn]:[[:space:]]*//' | sed 's/[[:space:]]*$//') + expected_base="${E2E_WWW_CANONICAL_BASE[$domain]:-}" + if [ -z "$loc_val" ]; then + log_warn "HTTPS: $domain returned HTTP $http_code but no Location header${https_path:+ (${https_url})}" + result=$(echo "$result" | jq --arg code "$http_code" --arg time "$time_total" \ + '.tests.https = {"status": "warn", "http_code": ($code | tonumber), "response_time_seconds": ($time | tonumber), "note": "missing Location on redirect"}') + elif [ -z "$expected_base" ]; then + log_warn "HTTPS: $domain redirect pass (no E2E_WWW_CANONICAL_BASE entry)" + result=$(echo "$result" | jq --arg code "$http_code" --arg time "$time_total" --arg loc "$location_hdr" \ + '.tests.https = {"status": "pass", "http_code": ($code | tonumber), "response_time_seconds": ($time | tonumber), "canonical_redirect": true, "location_header": $loc}') + elif ! e2e_www_redirect_location_ok "$loc_val" "$expected_base" "$https_path"; then + log_error "HTTPS: $domain Location mismatch (got \"$loc_val\", expected prefix \"$expected_base\" with path \"${https_path:-/}\")" + result=$(echo "$result" | jq --arg code "$http_code" --arg time "$time_total" --arg loc "$loc_val" --arg exp "$expected_base" --arg pth "${https_path:-}" \ + '.tests.https = {"status": "fail", "http_code": ($code | tonumber), "response_time_seconds": ($time | tonumber), "reason": "location_mismatch", "location": $loc, "expected_prefix": $exp, "expected_path_suffix": $pth}') + else + log_success "HTTPS: $domain returned HTTP $http_code (canonical redirect → $loc_val)${https_path:+ at ${https_url}}" + result=$(echo "$result" | jq --arg code "$http_code" --arg time "$time_total" --arg loc "$location_hdr" \ + '.tests.https = {"status": "pass", "http_code": ($code | tonumber), "response_time_seconds": ($time | tonumber), "canonical_redirect": true, "location_header": $loc}') + fi elif [ "$http_code" -ge 200 ] && [ "$http_code" -lt 400 ]; then log_success "HTTPS: $domain returned HTTP $http_code (Time: ${time_total}s)${https_path:+ at ${https_path}}"