diff --git a/.github/workflows/diagnose-account-facts-receiver.yml b/.github/workflows/diagnose-account-facts-receiver.yml new file mode 100644 index 00000000..148154e7 --- /dev/null +++ b/.github/workflows/diagnose-account-facts-receiver.yml @@ -0,0 +1,48 @@ +name: Diagnose Binance Account Facts Receiver + +"on": + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: binance-account-facts-readonly + cancel-in-progress: false + +jobs: + readiness: + if: github.event_name == 'workflow_dispatch' && github.repository == 'QuantStrategyLab/BinancePlatform' && github.ref == 'refs/heads/main' + runs-on: self-hosted + timeout-minutes: 2 + environment: binance-runtime + steps: + - name: Checkout the exact dispatch revision + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + with: + ref: ${{ github.sha }} + persist-credentials: false + + - name: Require the exact current main revision + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_EVENT_NAME" != "workflow_dispatch" || \ + "$GITHUB_REPOSITORY" != "QuantStrategyLab/BinancePlatform" || \ + "$GITHUB_REF" != "refs/heads/main" || \ + ! "$GITHUB_SHA" =~ ^[0-9a-f]{40}$ || \ + "$(git rev-parse HEAD)" != "$GITHUB_SHA" ]]; then + echo "receiver_diagnosis_source_unqualified" >&2 + exit 1 + fi + current_main_sha="$(git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main 2>/dev/null | awk 'NF == 2 && $2 == "refs/heads/main" { print $1 }')" + if [[ ! "$current_main_sha" =~ ^[0-9a-f]{40}$ || "$current_main_sha" != "$GITHUB_SHA" ]]; then + echo "receiver_diagnosis_main_sha_mismatch" >&2 + exit 1 + fi + + - name: Read receiver readiness once + shell: bash + env: + BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }} + run: python3 scripts/diagnose_binance_account_facts_receiver.py diff --git a/scripts/diagnose_binance_account_facts_receiver.py b/scripts/diagnose_binance_account_facts_receiver.py new file mode 100644 index 00000000..7401fbc4 --- /dev/null +++ b/scripts/diagnose_binance_account_facts_receiver.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Make one authenticated, read-only readiness request to the QRS receiver.""" +from __future__ import annotations + +import json +import os +from dataclasses import dataclass +from urllib.error import HTTPError +from urllib.request import HTTPRedirectHandler, Request, build_opener + +QRS_ENDPOINT = "https://qsl-strategy-switch-console.pigbibi.workers.dev/api/internal/binance-account-facts" + +_READINESS_FIELDS = {"ok", "ready", "binding_valid", "account_options_readable", "unique_match"} +_READINESS_ERROR_FIELDS = _READINESS_FIELDS | {"error"} +_TOKEN_ERROR_CODES = {"binance_account_facts_token_invalid"} +_BINDING_ERROR_CODES = {"binance_account_facts_binding_unmatched"} +_CONFIG_ERROR_CODES = { + "binance_account_facts_token_unavailable", + "binance_account_facts_binding_missing", + "binance_account_facts_binding_invalid", +} +_READINESS_ERROR_CODES = _TOKEN_ERROR_CODES | _BINDING_ERROR_CODES | _CONFIG_ERROR_CODES | { + "binance_account_facts_account_options_unavailable", + "binance_account_facts_unavailable", +} +_MAX_BODY_BYTES = 4096 + + +class _NoRedirect(HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + return None + + +@dataclass(frozen=True) +class ReceiverDiagnosis: + category: str + http_status: int | None = None + body_shape: str = "none" + receiver_error: str | None = None + cloudflare_code: int | None = None + + +def _json_body_shape(raw: bytes, headers) -> tuple[str, str | None, int | None]: + if len(raw) > _MAX_BODY_BYTES: + return "oversized", None, None + if not raw: + return "empty", None, None + try: + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("duplicate JSON key") + result[key] = value + return result + + body = json.loads( + raw.decode("utf-8"), + object_pairs_hook=unique_object, + parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("invalid JSON constant")), + ) + except (UnicodeError, ValueError, RecursionError): + return "invalid_json", None, None + if not isinstance(body, dict): + return "json_non_object", None, None + + if (set(body) == _READINESS_FIELDS + and all(type(body.get(key)) is bool for key in _READINESS_FIELDS)): + if all(body[key] is True for key in _READINESS_FIELDS): + return "readiness", None, None + return "readiness_not_ready", None, None + + if (set(body) == _READINESS_ERROR_FIELDS + and all(type(body.get(key)) is bool for key in _READINESS_FIELDS) + and body.get("ok") is False and body.get("ready") is False + and isinstance(body.get("error"), str) + and body["error"] in _READINESS_ERROR_CODES): + error = body["error"] + if error in _TOKEN_ERROR_CODES: + return "receiver_error", "token_invalid", None + if error in _BINDING_ERROR_CODES: + return "receiver_error", "binding_unmatched", None + if error in _CONFIG_ERROR_CODES: + return "receiver_error", "config_or_binding", None + if error == "binance_account_facts_account_options_unavailable": + return "receiver_error", "account_options_unavailable", None + return "receiver_error", "receiver_unavailable", None + + if (set(body) == {"ok", "error"} and body.get("ok") is False + and isinstance(body.get("error"), str) + and body["error"] in _TOKEN_ERROR_CODES): + return "token_error", "token_invalid", None + + cf_code = _cloudflare_error_code(body, headers) + if cf_code is not None: + return "cloudflare_error", None, cf_code + return "json_object_other", None, None + + +def _cloudflare_error_code(body: dict, headers) -> int | None: + """Expose only the numeric code from the standard Cloudflare error envelope.""" + if str(headers.get("Server", "")).lower() != "cloudflare": + return None + if set(body) != {"success", "errors", "messages", "result"}: + return None + if body.get("success") is not False or body.get("messages") != [] or body.get("result") is not None: + return None + errors = body.get("errors") + if not isinstance(errors, list) or len(errors) != 1 or not isinstance(errors[0], dict): + return None + error = errors[0] + if (set(error) != {"code", "message"} or type(error.get("code")) is not int + or not 0 <= error["code"] <= 999_999 or not isinstance(error.get("message"), str)): + return None + return error["code"] + + +def _read_once(request: Request, *, opener_factory=build_opener) -> tuple[int, bytes, object]: + try: + with opener_factory(_NoRedirect).open(request, timeout=15) as response: + return response.status, response.read(_MAX_BODY_BYTES + 1), response.headers + except HTTPError as error: + try: + return error.code, error.read(_MAX_BODY_BYTES + 1), error.headers or {} + finally: + error.close() + + +def diagnose_receiver(*, token: str, opener_factory=build_opener) -> ReceiverDiagnosis: + if not isinstance(token, str) or not token or token != token.strip(): + return ReceiverDiagnosis("token_missing") + request = Request( + QRS_ENDPOINT, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json"}, + method="GET", + ) + try: + status, raw, headers = _read_once(request, opener_factory=opener_factory) + except Exception: + return ReceiverDiagnosis("transport") + body_shape, safe_code, cf_code = _json_body_shape(raw, headers) + if status == 200 and body_shape == "readiness": + return ReceiverDiagnosis("ready", status, body_shape) + if safe_code: + return ReceiverDiagnosis(safe_code, status, body_shape, safe_code) + if cf_code is not None: + return ReceiverDiagnosis("cloudflare_rejected", status, body_shape, cloudflare_code=cf_code) + if 300 <= status <= 399: + return ReceiverDiagnosis("redirect", status, body_shape) + if 100 <= status <= 599: + return ReceiverDiagnosis("http_rejected", status, body_shape) + return ReceiverDiagnosis("response_invalid", body_shape=body_shape) + + +def main() -> int: + result = diagnose_receiver(token=os.environ.get("BINANCE_ACCOUNT_FACTS_SYNC_TOKEN", "")) + fields = [f"category={result.category}"] + if result.http_status is not None: + fields.append(f"http_status={result.http_status}") + fields.append(f"body_shape={result.body_shape}") + if result.receiver_error is not None: + fields.append(f"receiver_code={result.receiver_error}") + if result.cloudflare_code is not None: + fields.append(f"cloudflare_code={result.cloudflare_code}") + print("binance_account_facts_receiver_diagnosis " + " ".join(fields)) + return 0 if result.category == "ready" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/publish_binance_account_facts.py b/scripts/publish_binance_account_facts.py index 86dde13a..4e26af43 100644 --- a/scripts/publish_binance_account_facts.py +++ b/scripts/publish_binance_account_facts.py @@ -47,20 +47,29 @@ def redirect_request(self, request, response, code, message, headers, new_url): def _http_failure_code(error: HTTPError) -> str: + status = error.code + if type(status) is not int or not 100 <= status <= 599: + return _HTTP_REJECTED try: raw = error.read(4097) - if not isinstance(raw, bytes) or len(raw) > 4096: - return _HTTP_REJECTED - body = json.loads(raw.decode("utf-8")) - if (not isinstance(body, dict) or set(body) != {"ok", "error"} - or body.get("ok") is not False or not isinstance(body.get("error"), str)): - return _HTTP_REJECTED - label = _HTTP_ERROR_LABELS.get((error.code, body["error"])) - if label is None: - return _HTTP_REJECTED - return f"account_facts_publish_{label}" + if not isinstance(raw, bytes): + shape = "invalid_body" + elif len(raw) > 4096: + shape = "body_oversized" + else: + shape = "body_invalid_json" + body = json.loads(raw.decode("utf-8")) + if isinstance(body, dict): + shape = "body_unrecognized_json" + if (set(body) == {"ok", "error"} and body.get("ok") is False + and isinstance(body.get("error"), str)): + shape = "body_unknown_error_code" + label = _HTTP_ERROR_LABELS.get((status, body["error"])) + if label is not None: + return f"account_facts_publish_{label}" + return f"account_facts_publish_http_{status}_{shape}" except (UnicodeError, ValueError, TypeError, RecursionError): - return _HTTP_REJECTED + return f"account_facts_publish_http_{status}_body_invalid_json" def publish_account_facts(*, facts_path: Path, env) -> str: diff --git a/tests/test_binance_account_facts.py b/tests/test_binance_account_facts.py index 02f843b0..7acbc0af 100644 --- a/tests/test_binance_account_facts.py +++ b/tests/test_binance_account_facts.py @@ -1153,13 +1153,16 @@ def tracked_read(amount=None): assert reads == [4097] -@pytest.mark.parametrize(("status", "body"), [ - (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}"), - (418, b"SENSITIVE private body"), - (409, b"x" * 4097), +@pytest.mark.parametrize(("status", "body", "reason"), [ + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\",\"private\":\"SENSITIVE\"}", + "account_facts_publish_http_400_body_unrecognized_json"), + (400, b"{\"ok\":false,\"error\":\"unknown-private-code\"}", + "account_facts_publish_http_400_body_unknown_error_code"), + (418, b"SENSITIVE private body", "account_facts_publish_http_418_body_invalid_json"), + (409, b"x" * 4097, "account_facts_publish_http_409_body_oversized"), ]) def test_publisher_rejects_unknown_or_oversized_http_error_body_closed( - tmp_path, monkeypatch, status, body + tmp_path, monkeypatch, status, body, reason ): from io import BytesIO from urllib.error import HTTPError @@ -1196,7 +1199,8 @@ def tracked_read(amount=None): "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN": "synthetic-token", }, ) - assert str(raised.value) == "account_facts_publish_http_rejected" + assert str(raised.value) == reason + assert str(status) in str(raised.value) assert "SENSITIVE" not in str(raised.value) assert "private.example" not in str(raised.value) assert reads == [4097] @@ -1274,3 +1278,148 @@ def open(self, _request, timeout): assert str(raised.value) == "account_facts_publish_ack_invalid" assert "synthetic-secret" not in str(raised.value) assert calls == [20] + + +def test_receiver_diagnosis_is_one_authenticated_get_with_closed_success(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + from scripts import publish_binance_account_facts as publisher + + assert diagnosis.QRS_ENDPOINT == publisher.QRS_ENDPOINT + calls = [] + + class Response: + status = 200 + headers = {"Content-Type": "application/json"} + + def __enter__(self): + return self + + def __exit__(self, *_args): + return False + + def read(self, limit): + assert limit == 4097 + return json.dumps({ + "ok": True, "ready": True, "binding_valid": True, + "account_options_readable": True, "unique_match": True, + }).encode() + + class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 15 + return Response() + + result = diagnosis.diagnose_receiver(token="synthetic-secret", opener_factory=lambda _handler: Opener()) + assert result.category == "ready" + assert result.http_status == 200 + assert result.body_shape == "readiness" + assert len(calls) == 1 + assert calls[0].method == "GET" + assert calls[0].data is None + assert calls[0].get_header("Authorization") == "Bearer synthetic-secret" + assert "synthetic-secret" not in repr(result) + + +@pytest.mark.parametrize( + "status,body,headers,category,shape,receiver_code,cf_code", + [ + (403, {"success": False, "errors": [{"code": 1020, "message": "private edge detail"}], + "messages": [], "result": None}, {"Server": "cloudflare"}, + "cloudflare_rejected", "cloudflare_error", None, 1020), + (403, {"private": "sensitive response"}, {"Server": "cloudflare"}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": []}, {}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": {}}, {}, + "http_rejected", "json_object_other", None, None), + (401, {"ok": False, "error": "binance_account_facts_token_invalid"}, {}, + "token_invalid", "token_error", "token_invalid", None), + (503, {"ok": False, "ready": False, "binding_valid": False, + "account_options_readable": False, "unique_match": False, + "error": "binance_account_facts_account_options_unavailable"}, {}, + "account_options_unavailable", "receiver_error", "account_options_unavailable", None), + ], +) +def test_receiver_diagnosis_emits_only_closed_status_and_body_metadata( + status, body, headers, category, shape, receiver_code, cf_code +): + from io import BytesIO + from urllib.error import HTTPError + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + calls = [] + + class Opener: + def open(self, request, timeout): + calls.append(request) + assert timeout == 15 + error = HTTPError( + "https://private.example/private-path", status, "sensitive exception", + headers, BytesIO(json.dumps(body).encode()), + ) + raise error + + result = diagnosis.diagnose_receiver(token="synthetic-token", opener_factory=lambda _handler: Opener()) + assert result.category == category + assert result.http_status == status + assert result.body_shape == shape + assert result.receiver_error == receiver_code + assert result.cloudflare_code == cf_code + assert len(calls) == 1 and calls[0].method == "GET" and calls[0].data is None + assert "private edge detail" not in repr(result) + assert "private.example" not in repr(result) + assert "sensitive exception" not in repr(result) + + +def test_receiver_diagnosis_missing_token_and_transport_failure_never_retry(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + calls = [] + missing = diagnosis.diagnose_receiver(token="", opener_factory=lambda *_: calls.append("unexpected")) + assert missing.category == "token_missing" + assert calls == [] + + class Opener: + def open(self, request, timeout): + calls.append((request.method, timeout)) + raise OSError("private transport detail") + + failed = diagnosis.diagnose_receiver(token="synthetic-token", opener_factory=lambda _handler: Opener()) + assert failed.category == "transport" + assert len(calls) == 1 and calls[0] == ("GET", 15) + + +def test_receiver_diagnosis_rejects_ambiguous_json_and_unverified_cloudflare_code(): + from scripts import diagnose_binance_account_facts_receiver as diagnosis + + shape, code, cf_code = diagnosis._json_body_shape( + b'{"ok":true,"ok":false}', {"Server": "cloudflare"} + ) + assert (shape, code, cf_code) == ("invalid_json", None, None) + envelope = json.dumps({ + "success": False, + "errors": [{"code": 1020, "message": "private"}], + "messages": [], "result": None, + }).encode() + shape, code, cf_code = diagnosis._json_body_shape(envelope, {"Server": "other"}) + assert (shape, code, cf_code) == ("json_object_other", None, None) + + +def test_receiver_diagnosis_workflow_is_manual_main_only_and_get_only(): + workflow = (Path(__file__).parents[1] / ".github" / "workflows" + / "diagnose-account-facts-receiver.yml").read_text(encoding="utf-8") + assert '"on":\n workflow_dispatch:' in workflow + assert "refs/heads/main" in workflow + assert "git ls-remote https://github.com/QuantStrategyLab/BinancePlatform.git refs/heads/main" in workflow + assert 'ref: ${{ github.sha }}' in workflow + assert "git rev-parse HEAD" in workflow + assert "group: binance-account-facts-readonly" in workflow + assert "environment: binance-runtime" in workflow + assert "runs-on: self-hosted" in workflow + assert "BINANCE_ACCOUNT_FACTS_SYNC_TOKEN: ${{ secrets.BINANCE_ACCOUNT_FACTS_SYNC_TOKEN }}" in workflow + assert "python3 scripts/diagnose_binance_account_facts_receiver.py" in workflow + assert "account_facts_publish" not in workflow + assert "read_binance_account_facts.py" not in workflow + assert "workflow_run:" not in workflow and "schedule:" not in workflow and "push:" not in workflow + assert "RUNTIME_TARGET_ENABLED" not in workflow and "BINANCE_ACCOUNT_FACTS_ENABLED" not in workflow