diff --git a/.gitea/workflows/canary.yml b/.gitea/workflows/canary.yml index 241de27..9fb0ba7 100644 --- a/.gitea/workflows/canary.yml +++ b/.gitea/workflows/canary.yml @@ -45,5 +45,4 @@ jobs: CANARY_LOGIN_EMAIL: ${{ secrets.CANARY_LOGIN_EMAIL }} CANARY_LOGIN_PASSWORD: ${{ secrets.CANARY_LOGIN_PASSWORD }} CANARY_ALERT_TO: ${{ secrets.CANARY_ALERT_TO }} - CANARY_SYNTHETIC_KEY: ${{ secrets.CANARY_SYNTHETIC_KEY }} run: python3 scripts/canary.py diff --git a/api/app/auth.py b/api/app/auth.py index a60e472..015b850 100644 --- a/api/app/auth.py +++ b/api/app/auth.py @@ -28,6 +28,7 @@ from api.app.config import Settings from api.app.ept import EptInvalid, looks_like_ept, verify_ept from api.app.errors import RepairPointer, passport_unresolvable from api.app.hub_jwt import HubTokenInvalid, verify_hub_token +from api.app.telemetry import synthetic_headers log = logging.getLogger(__name__) @@ -125,7 +126,7 @@ async def resolve_passport(settings: Settings, passport: str) -> tuple[str, tupl ) url = f"{settings.eternitas_base_url}/api/v1/trust/{passport}" - headers = {"X-API-Key": settings.eternitas_platform_api_key} + headers = {"X-API-Key": settings.eternitas_platform_api_key, **synthetic_headers()} last_status = 0 for attempt in range(3): async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client: diff --git a/api/app/config.py b/api/app/config.py index b1b1ddb..da32d66 100644 --- a/api/app/config.py +++ b/api/app/config.py @@ -71,9 +71,6 @@ class Settings(BaseSettings): # root-only /etc/windygit/telemetry.env on Veron, never in the repo. windygit_telemetry_token: str = "" telemetry_ingest_url: str = "https://admin.windyword.ai/v1/events" - # Shared with our canary (Gitea repo secret CANARY_SYNTHETIC_KEY). A request - # carrying it in X-Windy-Synthetic is our own tooling -> synthetic:true. - windygit_synthetic_key: str = "" # Internal callers (the Cloud portal calling /internal/*). A first-class # caller class, not a bypass: unset means service calls are REFUSED. diff --git a/api/app/main.py b/api/app/main.py index 4656744..3d18a39 100644 --- a/api/app/main.py +++ b/api/app/main.py @@ -27,7 +27,7 @@ from api.app.providers.registry import ( R2Provider, ) from api.app.routes import health, repos, webhooks -from api.app.telemetry import Telemetry, caller_class, is_synthetic +from api.app.telemetry import SYNTHETIC, Telemetry, caller_class, is_synthetic logging.basicConfig( level=logging.INFO, @@ -139,7 +139,11 @@ app.include_router(webhooks.router) async def _count_requests(request: Request, call_next): """Heartbeat counts (requests, 4xx/5xx, refusals, p95). Never raises.""" start = time.perf_counter() - response = await call_next(request) + marker = SYNTHETIC.set(is_synthetic(request.headers)) + try: + response = await call_next(request) + finally: + SYNTHETIC.reset(marker) tel = getattr(request.app.state, "telemetry", None) if tel is not None: tel.record_request( @@ -167,11 +171,7 @@ async def _repair_pointer_handler(request: Request, exc: RepairPointer) -> JSONR caller=caller_class(request.headers), route=getattr(route, "path", None), upstream_status=getattr(exc, "upstream_status", None), - synthetic=is_synthetic( - request.headers, request.app.state.settings.windygit_synthetic_key - ) - if hasattr(request.app.state, "settings") - else False, + synthetic=is_synthetic(request.headers), ) return JSONResponse(status_code=exc.status_code, content=exc.detail) diff --git a/api/app/services/gitea_client.py b/api/app/services/gitea_client.py index 7e4ef60..c597718 100644 --- a/api/app/services/gitea_client.py +++ b/api/app/services/gitea_client.py @@ -20,6 +20,7 @@ import httpx from api.app.config import Settings from api.app.errors import RepairPointer, provider_unconfigured +from api.app.telemetry import synthetic_headers _TIMEOUT = httpx.Timeout(20.0, connect=5.0) @@ -36,6 +37,7 @@ class GiteaClient: return { "Authorization": f"token {self._s.gitea_admin_token}", "Content-Type": "application/json", + **synthetic_headers(), # end-to-end synthetic convention (Telemetry UPDATE 4) } async def _request(self, method: str, path: str, **kw: Any) -> httpx.Response: diff --git a/api/app/telemetry.py b/api/app/telemetry.py index e363e47..6d7f2d5 100644 --- a/api/app/telemetry.py +++ b/api/app/telemetry.py @@ -26,6 +26,7 @@ rows (bounded) and retries on the next tick; it never raises into a request. from __future__ import annotations import asyncio +import contextvars import json import logging import time @@ -65,12 +66,21 @@ def _iso(epoch: float) -> str: return datetime.fromtimestamp(epoch, UTC).isoformat().replace("+00:00", "Z") -def is_synthetic(headers, key: str) -> bool: - """Our own tooling proves itself with the shared key; a bare header proves nothing.""" - import hmac +# Ecosystem convention (Telemetry UPDATE 4): synthetic traffic travels END TO +# END. Originators (canaries, probes, journeys) send `X-Windy-Synthetic: 1`; +# every service marks all of that request's rows synthetic:true AND forwards the +# header on every downstream call. Absent = real. Never strip it, never set it +# on real traffic. The label separates rows — it never suppresses them. +SYNTHETIC: contextvars.ContextVar[bool] = contextvars.ContextVar("windy_synthetic", default=False) - presented = headers.get("x-windy-synthetic") or "" - return bool(key) and bool(presented) and hmac.compare_digest(presented, key) + +def is_synthetic(headers) -> bool: + return bool((headers.get("x-windy-synthetic") or "").strip()) + + +def synthetic_headers() -> dict: + """Merge into every downstream request made while serving this one.""" + return {"X-Windy-Synthetic": "1"} if SYNTHETIC.get() else {} def caller_class(headers) -> str: diff --git a/api/tests/test_telemetry.py b/api/tests/test_telemetry.py index e307318..2a85d58 100644 --- a/api/tests/test_telemetry.py +++ b/api/tests/test_telemetry.py @@ -143,23 +143,20 @@ def test_caller_classes_are_the_declared_three(): @pytest.mark.asyncio -async def test_canary_refusals_are_marked_synthetic_only_with_the_real_key(): - from types import SimpleNamespace - +async def test_synthetic_header_marks_the_row_and_absent_means_real(): async def refusal(headers): tel = _tel() - app = _app(tel) - app.state.settings = SimpleNamespace(windygit_synthetic_key="k3y") - await _get(app, "/api/v1/repos/x/grants", headers) - return [e for e in tel.buffer if e["event_type"] == "forge.auth.failed"][0]["metadata"][ - "synthetic" - ] + await _get(_app(tel), "/api/v1/repos/x/grants", headers) + return [e for e in tel.buffer if e["event_type"] == "forge.auth.failed"][0]["metadata"]["synthetic"] - assert await refusal({"X-Windy-Synthetic": "k3y"}) is True - assert await refusal({"X-Windy-Synthetic": "guess"}) is False # an attacker can't hide + assert await refusal({"X-Windy-Synthetic": "1"}) is True assert await refusal({}) is False -def test_synthetic_needs_a_configured_key(): - assert tmod.is_synthetic({"x-windy-synthetic": ""}, "") is False - assert tmod.is_synthetic({"x-windy-synthetic": "anything"}, "") is False +def test_synthetic_is_forwarded_downstream_only_for_synthetic_requests(): + token = tmod.SYNTHETIC.set(True) + try: + assert tmod.synthetic_headers() == {"X-Windy-Synthetic": "1"} + finally: + tmod.SYNTHETIC.reset(token) + assert tmod.synthetic_headers() == {} diff --git a/scripts/canary.py b/scripts/canary.py index 1d50132..1df4e9a 100755 --- a/scripts/canary.py +++ b/scripts/canary.py @@ -73,10 +73,9 @@ class Check: def _probe(c: Check) -> Result: data = json.dumps(c.body).encode() if c.body else None headers = {"User-Agent": "windy-git-canary/1.0", **c.headers} - # Mark our own probes so the ledger can tell a canary forgery from an attack. - # A shared secret, not a flag: a bare header would let an attacker hide. - if os.environ.get("CANARY_SYNTHETIC_KEY"): - headers["X-Windy-Synthetic"] = os.environ["CANARY_SYNTHETIC_KEY"] + # Our own probes are synthetic traffic (ecosystem convention, Telemetry + # UPDATE 4): every service they touch labels the resulting rows. + headers["X-Windy-Synthetic"] = "1" if data: headers["Content-Type"] = "application/json" req = urllib.request.Request(c.url, data=data, method=c.method, headers=headers)