k_card/phase5_chain_regression.sh

231 lines
6.7 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
CLIENT_HOST="${CLIENT_HOST:-k_client}"
CA_FILE="${CA_FILE:-/home/user/chromecard/tls/phase2/ca.crt}"
PROXY_URL="${PROXY_URL:-https://127.0.0.1:9771}"
USERNAME="${USERNAME:-alice}"
REQUESTS="${REQUESTS:-20}"
PARALLELISM="${PARALLELISM:-8}"
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}"
LOGIN_TIMEOUT="${LOGIN_TIMEOUT:-90}"
INTERACTIVE_CARD="${INTERACTIVE_CARD:-0}"
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE:-}"
SSH_CONFIG="${SSH_CONFIG:-/home/user/.ssh/config}"
usage() {
cat <<'EOF'
Usage: phase5_chain_regression.sh [options]
Runs the Phase 5 split-VM regression from the host by executing the client-side
flow inside k_client over SSH.
Options:
--client-host HOST SSH host alias for k_client (default: k_client)
--ca-file PATH CA bundle path inside k_client
--proxy-url URL Proxy URL visible from k_client
--username NAME Username for session login
--requests N Number of counter requests to issue
--parallelism N Number of concurrent workers
--connect-timeout SEC SSH connect timeout
--login-timeout SEC Timeout for the interactive login request (default: 90)
--interactive-card Print card-confirmation instructions before login
--expect-auth-mode NAME Require login response auth_mode to match
--ssh-config PATH SSH config file to use (default: /home/user/.ssh/config)
-h, --help Show this help text
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--client-host)
CLIENT_HOST="$2"
shift 2
;;
--ca-file)
CA_FILE="$2"
shift 2
;;
--proxy-url)
PROXY_URL="$2"
shift 2
;;
--username)
USERNAME="$2"
shift 2
;;
--requests)
REQUESTS="$2"
shift 2
;;
--parallelism)
PARALLELISM="$2"
shift 2
;;
--connect-timeout)
CONNECT_TIMEOUT="$2"
shift 2
;;
--login-timeout)
LOGIN_TIMEOUT="$2"
shift 2
;;
--interactive-card)
INTERACTIVE_CARD=1
shift
;;
--expect-auth-mode)
EXPECT_AUTH_MODE="$2"
shift 2
;;
--ssh-config)
SSH_CONFIG="$2"
shift 2
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
done
if [[ "${INTERACTIVE_CARD}" == "1" ]]; then
cat <<EOF
Starting interactive login for ${USERNAME}.
When the card shows the authentication prompt, press yes to approve.
Press no only if you want to reject the login.
EOF
fi
ssh \
-F "${SSH_CONFIG}" \
-o BatchMode=yes \
-o StrictHostKeyChecking=accept-new \
-o ConnectTimeout="${CONNECT_TIMEOUT}" \
"${CLIENT_HOST}" \
env \
CA_FILE="${CA_FILE}" \
PROXY_URL="${PROXY_URL}" \
USERNAME="${USERNAME}" \
REQUESTS="${REQUESTS}" \
PARALLELISM="${PARALLELISM}" \
LOGIN_TIMEOUT="${LOGIN_TIMEOUT}" \
EXPECT_AUTH_MODE="${EXPECT_AUTH_MODE}" \
python3 - <<'PY'
import concurrent.futures
import json
import os
import ssl
import sys
import urllib.error
import urllib.request
ca_file = os.environ["CA_FILE"]
proxy_url = os.environ["PROXY_URL"].rstrip("/")
username = os.environ["USERNAME"]
requests = int(os.environ["REQUESTS"])
parallelism = int(os.environ["PARALLELISM"])
login_timeout = int(os.environ["LOGIN_TIMEOUT"])
expect_auth_mode = os.environ["EXPECT_AUTH_MODE"]
if requests < 1:
raise SystemExit("REQUESTS must be >= 1")
if parallelism < 1:
raise SystemExit("PARALLELISM must be >= 1")
ctx = ssl.create_default_context(cafile=ca_file)
def post_json(path: str, payload: dict | None = None, token: str | None = None, timeout: int = 10):
data = None if payload is None else json.dumps(payload).encode("utf-8")
headers = {}
if payload is not None:
headers["Content-Type"] = "application/json"
if token:
headers["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(
f"{proxy_url}{path}",
data=data,
headers=headers,
method="POST",
)
try:
with urllib.request.urlopen(req, context=ctx, timeout=timeout) as resp:
return resp.status, json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8")
try:
payload = json.loads(body)
except json.JSONDecodeError:
payload = {"ok": False, "error": body}
return exc.code, payload
status, login = post_json("/session/login", {"username": username}, timeout=login_timeout)
if status != 200 or "session_token" not in login:
print(json.dumps({"ok": False, "stage": "login", "status": status, "response": login}))
raise SystemExit(1)
if expect_auth_mode and login.get("auth_mode") != expect_auth_mode:
print(
json.dumps(
{
"ok": False,
"stage": "login",
"error": "unexpected auth_mode",
"expected": expect_auth_mode,
"response": login,
}
)
)
raise SystemExit(1)
token = login["session_token"]
values = []
def fetch_one(_: int) -> int:
status, payload = post_json("/resource/counter", {}, token=token)
if status != 200:
raise RuntimeError(json.dumps({"status": status, "response": payload}))
return int(payload["upstream"]["value"])
try:
with concurrent.futures.ThreadPoolExecutor(max_workers=parallelism) as pool:
for value in pool.map(fetch_one, range(requests)):
values.append(value)
status_resp, session = post_json("/session/status", {}, token=token)
logout_status, logout = post_json("/session/logout", {}, token=token)
invalid_status, invalid = post_json("/resource/counter", {}, token=token)
except Exception as exc:
try:
post_json("/session/logout", {}, token=token)
finally:
raise SystemExit(str(exc))
sorted_values = sorted(values)
expected = list(range(sorted_values[0], sorted_values[-1] + 1)) if sorted_values else []
summary = {
"ok": True,
"username": username,
"proxy_url": proxy_url,
"requests": requests,
"parallelism": parallelism,
"unique": len(set(values)) == len(values),
"gap_free": sorted_values == expected,
"min": min(sorted_values) if sorted_values else None,
"max": max(sorted_values) if sorted_values else None,
"values": sorted_values,
"login": login,
"session_status": {"status": status_resp, "response": session},
"logout": {"status": logout_status, "response": logout},
"post_logout": {"status": invalid_status, "response": invalid},
}
print(json.dumps(summary, indent=2, sort_keys=True))
if not summary["unique"] or not summary["gap_free"] or logout_status != 200 or invalid_status != 401:
raise SystemExit(1)
PY