From 651c014270da571f27ec622f49b90e23c930c8b2 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 2 May 2026 03:25:12 +0100 Subject: [PATCH] test: add live coordinator auth smoke --- CHANGELOG.md | 1 + docs/features/broker-auth-routing.md | 8 +- docs/operations.md | 10 +- docs/source-map.md | 1 + package.json | 1 + scripts/live-auth-smoke.sh | 142 +++++++++++++++++++++++++++ 6 files changed, 157 insertions(+), 6 deletions(-) create mode 100755 scripts/live-auth-smoke.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 2452a7c..a9a9e6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Crabbox 0.3.0 adds the first trusted-operator image lifecycle for AWS runners: o - Added `docs/commands/image.md` and linked the image command from the CLI docs, command index, docs site, and source map. - Added `npm run docs:check` with internal Markdown link validation plus docs-site generation, and wired it into CI. - Added `scripts/live-smoke.sh` for opt-in AWS, Hetzner, and Blacksmith Testbox live smoke coverage from a real repository checkout. +- Added `scripts/live-auth-smoke.sh` for opt-in live proof that shared tokens cannot call admin routes, admin tokens can, Access edge auth works, and raw Access identity headers are ignored. ### Changed diff --git a/docs/features/broker-auth-routing.md b/docs/features/broker-auth-routing.md index e706df9..557c4ca 100644 --- a/docs/features/broker-auth-routing.md +++ b/docs/features/broker-auth-routing.md @@ -118,12 +118,14 @@ Useful proof commands: curl -i https://crabbox-access.openclaw.ai/v1/health CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai bin/crabbox doctor CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai bin/crabbox whoami +CRABBOX_LIVE=1 CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai CRABBOX_BIN=bin/crabbox scripts/live-auth-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=aws CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai CRABBOX_BIN=bin/crabbox scripts/live-smoke.sh ``` -The first command should fail at Cloudflare Access without credentials. The CLI -commands should pass when local Access credentials and Crabbox broker auth are -configured. +The first command should fail at Cloudflare Access without credentials. The auth +smoke should pass when local Access credentials, shared broker auth, and admin +broker auth are configured. The provider smoke additionally proves the same +route can lease, run, and release a real machine. Owner selection for bearer-token requests: diff --git a/docs/operations.md b/docs/operations.md index 0add411..8947d91 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -118,12 +118,16 @@ Use the protected route when testing the Cloudflare Access layer: ```sh CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai bin/crabbox doctor CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai bin/crabbox whoami +CRABBOX_LIVE=1 CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai CRABBOX_BIN=bin/crabbox scripts/live-auth-smoke.sh CRABBOX_LIVE=1 CRABBOX_LIVE_PROVIDERS=aws CRABBOX_COORDINATOR=https://crabbox-access.openclaw.ai CRABBOX_BIN=bin/crabbox scripts/live-smoke.sh ``` -`doctor` should report `access=service-token`. A raw request without Access -headers to `https://crabbox-access.openclaw.ai/v1/health` should return a -Cloudflare Access `403`. +`doctor` should report `access=service-token`. `scripts/live-auth-smoke.sh` +proves the auth boundary without leasing a machine: no Access headers are denied +at the edge, shared-token user auth works, raw Access identity spoofing is +ignored, shared-token admin calls fail, and admin-token admin calls pass. A raw +request without Access headers to `https://crabbox-access.openclaw.ai/v1/health` +should return a Cloudflare Access `403`. Use `crabbox config show` to confirm which URL and provider the CLI will use: diff --git a/docs/source-map.md b/docs/source-map.md index 6470d87..e27a328 100644 --- a/docs/source-map.md +++ b/docs/source-map.md @@ -77,3 +77,4 @@ Bootstrap is intentionally tiny: OpenSSH, CA certificates, curl, Git, rsync, jq, - GoReleaser archives and Homebrew formula config: `.goreleaser.yaml` - Docs link check, site builder, and Pages deployment: `scripts/check-docs-links.mjs`, `scripts/build-docs-site.mjs`, `.github/workflows/pages.yml` - Live provider smoke coverage: `scripts/live-smoke.sh` +- Live coordinator auth smoke coverage: `scripts/live-auth-smoke.sh` diff --git a/package.json b/package.json index 21d0495..cf2b698 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "scripts": { "check": "node --check index.js && node --test index.test.js", "docs:check": "node scripts/check-docs-links.mjs && node scripts/build-docs-site.mjs", + "live:auth": "CRABBOX_LIVE=1 scripts/live-auth-smoke.sh", "test": "node --test index.test.js" }, "files": [ diff --git a/scripts/live-auth-smoke.sh b/scripts/live-auth-smoke.sh new file mode 100755 index 0000000..9804140 --- /dev/null +++ b/scripts/live-auth-smoke.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ "${CRABBOX_LIVE:-}" != "1" ]]; then + echo "set CRABBOX_LIVE=1 to run live coordinator auth smoke tests" >&2 + exit 2 +fi + +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cb="${CRABBOX_BIN:-$root/bin/crabbox}" +coord="${CRABBOX_AUTH_SMOKE_COORDINATOR:-${CRABBOX_COORDINATOR:-https://crabbox-access.openclaw.ai}}" +config_path="$("$cb" config path)" + +need_tool() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "missing required tool: $1" >&2 + exit 2 + fi +} + +need_tool curl +need_tool jq +need_tool ruby + +config_value() { + local key_path="$1" + ruby -ryaml -e ' + value = ARGV[1].split(".").reduce(YAML.load_file(ARGV[0])) do |memo, key| + memo.is_a?(Hash) ? memo[key] : nil + end + exit 3 if value.nil? || value.to_s.empty? + print value + ' "$config_path" "$key_path" +} + +curl_quote() { + ruby -e 'print ARGV[0].inspect' "$1" +} + +write_curl_config() { + local token="$1" + local url="$2" + local output="$3" + shift 3 + : >"$output" + chmod 0600 "$output" + { + printf 'url = %s\n' "$(curl_quote "$url")" + printf 'request = "GET"\n' + printf 'connect-timeout = "10"\n' + printf 'max-time = "300"\n' + printf 'silent\n' + printf 'show-error\n' + printf 'location\n' + printf 'output = "-"\n' + printf 'write-out = "\\n%%{http_code}"\n' + printf 'header = %s\n' "$(curl_quote "Authorization: Bearer $token")" + if [[ -n "${access_client_id:-}" && -n "${access_client_secret:-}" ]]; then + printf 'header = %s\n' "$(curl_quote "CF-Access-Client-Id: $access_client_id")" + printf 'header = %s\n' "$(curl_quote "CF-Access-Client-Secret: $access_client_secret")" + fi + for header in "$@"; do + printf 'header = %s\n' "$(curl_quote "$header")" + done + } >>"$output" +} + +request_json() { + local token="$1" + local path="$2" + local body_file="$3" + shift 3 + local cfg + cfg="$(mktemp)" + write_curl_config "$token" "${coord%/}$path" "$cfg" "$@" + local response + response="$(curl --config "$cfg")" + rm -f "$cfg" + local status="${response##*$'\n'}" + local body="${response%$'\n'*}" + printf '%s' "$body" >"$body_file" + printf '%s' "$status" +} + +shared_token="$(config_value broker.token)" +admin_token="$(config_value broker.adminToken)" +access_client_id="${CRABBOX_ACCESS_CLIENT_ID:-$(config_value broker.access.clientId 2>/dev/null || true)}" +access_client_secret="${CRABBOX_ACCESS_CLIENT_SECRET:-$(config_value broker.access.clientSecret 2>/dev/null || true)}" +owner="${CRABBOX_OWNER:-$(git config user.email 2>/dev/null || true)}" +owner="${owner:-crabbox-auth-smoke@example.invalid}" +org="${CRABBOX_ORG:-openclaw}" + +if [[ "$coord" == *"crabbox-access.openclaw.ai"* ]]; then + no_access_code="$(curl -sS -o /dev/null -w '%{http_code}' "${coord%/}/v1/health")" + if [[ "$no_access_code" != "403" ]]; then + echo "failed no-access edge check: HTTP $no_access_code" >&2 + exit 1 + fi + echo "ok no-access edge denied http=403" +fi + +whoami="$(env -u CRABBOX_COORDINATOR_TOKEN CRABBOX_COORDINATOR="$coord" "$cb" whoami --json)" +printf '%s\n' "$whoami" | jq -e '.auth == "bearer" and (.owner | length > 0) and (.org | length > 0)' >/dev/null +echo "ok shared token whoami owner=$(printf '%s\n' "$whoami" | jq -r '.owner') org=$(printf '%s\n' "$whoami" | jq -r '.org')" + +body="$(mktemp)" +trap 'rm -f "$body"' EXIT + +status="$(request_json "$shared_token" "/v1/whoami" "$body" \ + "X-Crabbox-Owner: $owner" \ + "X-Crabbox-Org: $org" \ + "cf-access-authenticated-user-email: spoof@example.invalid")" +if [[ "$status" != "200" ]]; then + echo "failed raw Access spoof check: HTTP $status body=$(cat "$body")" >&2 + exit 1 +fi +spoof_owner="$(jq -r '.owner' "$body")" +if [[ "$spoof_owner" == "spoof@example.invalid" ]]; then + echo "failed raw Access spoof check: spoofed owner accepted" >&2 + exit 1 +fi +echo "ok raw Access identity spoof ignored owner=$spoof_owner" + +status="$(request_json "$shared_token" "/v1/admin/leases?limit=1" "$body" \ + "X-Crabbox-Owner: $owner" \ + "X-Crabbox-Org: $org")" +if [[ "$status" != "403" ]]; then + echo "failed shared-token admin denial: HTTP $status body=$(cat "$body")" >&2 + exit 1 +fi +jq -e '.message == "admin token required"' "$body" >/dev/null +echo "ok shared token denied for admin http=403" + +status="$(request_json "$admin_token" "/v1/admin/leases?limit=1" "$body" \ + "X-Crabbox-Owner: $owner" \ + "X-Crabbox-Org: $org")" +if [[ "$status" != "200" ]]; then + echo "failed admin-token admin check: HTTP $status body=$(cat "$body")" >&2 + exit 1 +fi +jq -e '.leases | type == "array"' "$body" >/dev/null +echo "ok admin token accepted for admin leases=$(jq '.leases | length' "$body")"