telemetry: adopt the end-to-end synthetic convention (UPDATE 4)
Replaces the keyed marker from 1c3b5b0 with the ecosystem convention:
any X-Windy-Synthetic value marks the request synthetic; the flag lives in
a per-request contextvar, labels this request's rows, and is FORWARDED on
downstream calls (Eternitas trust lookup, Gitea API). The canary sends
"1". Rows are still recorded; the label separates, never suppresses.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -45,5 +45,4 @@ jobs:
|
|||||||
CANARY_LOGIN_EMAIL: ${{ secrets.CANARY_LOGIN_EMAIL }}
|
CANARY_LOGIN_EMAIL: ${{ secrets.CANARY_LOGIN_EMAIL }}
|
||||||
CANARY_LOGIN_PASSWORD: ${{ secrets.CANARY_LOGIN_PASSWORD }}
|
CANARY_LOGIN_PASSWORD: ${{ secrets.CANARY_LOGIN_PASSWORD }}
|
||||||
CANARY_ALERT_TO: ${{ secrets.CANARY_ALERT_TO }}
|
CANARY_ALERT_TO: ${{ secrets.CANARY_ALERT_TO }}
|
||||||
CANARY_SYNTHETIC_KEY: ${{ secrets.CANARY_SYNTHETIC_KEY }}
|
|
||||||
run: python3 scripts/canary.py
|
run: python3 scripts/canary.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.ept import EptInvalid, looks_like_ept, verify_ept
|
||||||
from api.app.errors import RepairPointer, passport_unresolvable
|
from api.app.errors import RepairPointer, passport_unresolvable
|
||||||
from api.app.hub_jwt import HubTokenInvalid, verify_hub_token
|
from api.app.hub_jwt import HubTokenInvalid, verify_hub_token
|
||||||
|
from api.app.telemetry import synthetic_headers
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
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}"
|
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
|
last_status = 0
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client:
|
async with httpx.AsyncClient(timeout=httpx.Timeout(8.0, connect=3.0)) as client:
|
||||||
|
|||||||
@@ -71,9 +71,6 @@ class Settings(BaseSettings):
|
|||||||
# root-only /etc/windygit/telemetry.env on Veron, never in the repo.
|
# root-only /etc/windygit/telemetry.env on Veron, never in the repo.
|
||||||
windygit_telemetry_token: str = ""
|
windygit_telemetry_token: str = ""
|
||||||
telemetry_ingest_url: str = "https://admin.windyword.ai/v1/events"
|
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
|
# Internal callers (the Cloud portal calling /internal/*). A first-class
|
||||||
# caller class, not a bypass: unset means service calls are REFUSED.
|
# caller class, not a bypass: unset means service calls are REFUSED.
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ from api.app.providers.registry import (
|
|||||||
R2Provider,
|
R2Provider,
|
||||||
)
|
)
|
||||||
from api.app.routes import health, repos, webhooks
|
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(
|
logging.basicConfig(
|
||||||
level=logging.INFO,
|
level=logging.INFO,
|
||||||
@@ -139,7 +139,11 @@ app.include_router(webhooks.router)
|
|||||||
async def _count_requests(request: Request, call_next):
|
async def _count_requests(request: Request, call_next):
|
||||||
"""Heartbeat counts (requests, 4xx/5xx, refusals, p95). Never raises."""
|
"""Heartbeat counts (requests, 4xx/5xx, refusals, p95). Never raises."""
|
||||||
start = time.perf_counter()
|
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)
|
tel = getattr(request.app.state, "telemetry", None)
|
||||||
if tel is not None:
|
if tel is not None:
|
||||||
tel.record_request(
|
tel.record_request(
|
||||||
@@ -167,11 +171,7 @@ async def _repair_pointer_handler(request: Request, exc: RepairPointer) -> JSONR
|
|||||||
caller=caller_class(request.headers),
|
caller=caller_class(request.headers),
|
||||||
route=getattr(route, "path", None),
|
route=getattr(route, "path", None),
|
||||||
upstream_status=getattr(exc, "upstream_status", None),
|
upstream_status=getattr(exc, "upstream_status", None),
|
||||||
synthetic=is_synthetic(
|
synthetic=is_synthetic(request.headers),
|
||||||
request.headers, request.app.state.settings.windygit_synthetic_key
|
|
||||||
)
|
|
||||||
if hasattr(request.app.state, "settings")
|
|
||||||
else False,
|
|
||||||
)
|
)
|
||||||
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
return JSONResponse(status_code=exc.status_code, content=exc.detail)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import httpx
|
|||||||
|
|
||||||
from api.app.config import Settings
|
from api.app.config import Settings
|
||||||
from api.app.errors import RepairPointer, provider_unconfigured
|
from api.app.errors import RepairPointer, provider_unconfigured
|
||||||
|
from api.app.telemetry import synthetic_headers
|
||||||
|
|
||||||
_TIMEOUT = httpx.Timeout(20.0, connect=5.0)
|
_TIMEOUT = httpx.Timeout(20.0, connect=5.0)
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ class GiteaClient:
|
|||||||
return {
|
return {
|
||||||
"Authorization": f"token {self._s.gitea_admin_token}",
|
"Authorization": f"token {self._s.gitea_admin_token}",
|
||||||
"Content-Type": "application/json",
|
"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:
|
async def _request(self, method: str, path: str, **kw: Any) -> httpx.Response:
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ rows (bounded) and retries on the next tick; it never raises into a request.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import contextvars
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -65,12 +66,21 @@ def _iso(epoch: float) -> str:
|
|||||||
return datetime.fromtimestamp(epoch, UTC).isoformat().replace("+00:00", "Z")
|
return datetime.fromtimestamp(epoch, UTC).isoformat().replace("+00:00", "Z")
|
||||||
|
|
||||||
|
|
||||||
def is_synthetic(headers, key: str) -> bool:
|
# Ecosystem convention (Telemetry UPDATE 4): synthetic traffic travels END TO
|
||||||
"""Our own tooling proves itself with the shared key; a bare header proves nothing."""
|
# END. Originators (canaries, probes, journeys) send `X-Windy-Synthetic: 1`;
|
||||||
import hmac
|
# 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:
|
def caller_class(headers) -> str:
|
||||||
|
|||||||
@@ -143,23 +143,20 @@ def test_caller_classes_are_the_declared_three():
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_canary_refusals_are_marked_synthetic_only_with_the_real_key():
|
async def test_synthetic_header_marks_the_row_and_absent_means_real():
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
async def refusal(headers):
|
async def refusal(headers):
|
||||||
tel = _tel()
|
tel = _tel()
|
||||||
app = _app(tel)
|
await _get(_app(tel), "/api/v1/repos/x/grants", headers)
|
||||||
app.state.settings = SimpleNamespace(windygit_synthetic_key="k3y")
|
return [e for e in tel.buffer if e["event_type"] == "forge.auth.failed"][0]["metadata"]["synthetic"]
|
||||||
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"
|
|
||||||
]
|
|
||||||
|
|
||||||
assert await refusal({"X-Windy-Synthetic": "k3y"}) is True
|
assert await refusal({"X-Windy-Synthetic": "1"}) is True
|
||||||
assert await refusal({"X-Windy-Synthetic": "guess"}) is False # an attacker can't hide
|
|
||||||
assert await refusal({}) is False
|
assert await refusal({}) is False
|
||||||
|
|
||||||
|
|
||||||
def test_synthetic_needs_a_configured_key():
|
def test_synthetic_is_forwarded_downstream_only_for_synthetic_requests():
|
||||||
assert tmod.is_synthetic({"x-windy-synthetic": ""}, "") is False
|
token = tmod.SYNTHETIC.set(True)
|
||||||
assert tmod.is_synthetic({"x-windy-synthetic": "anything"}, "") is False
|
try:
|
||||||
|
assert tmod.synthetic_headers() == {"X-Windy-Synthetic": "1"}
|
||||||
|
finally:
|
||||||
|
tmod.SYNTHETIC.reset(token)
|
||||||
|
assert tmod.synthetic_headers() == {}
|
||||||
|
|||||||
@@ -73,10 +73,9 @@ class Check:
|
|||||||
def _probe(c: Check) -> Result:
|
def _probe(c: Check) -> Result:
|
||||||
data = json.dumps(c.body).encode() if c.body else None
|
data = json.dumps(c.body).encode() if c.body else None
|
||||||
headers = {"User-Agent": "windy-git-canary/1.0", **c.headers}
|
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.
|
# Our own probes are synthetic traffic (ecosystem convention, Telemetry
|
||||||
# A shared secret, not a flag: a bare header would let an attacker hide.
|
# UPDATE 4): every service they touch labels the resulting rows.
|
||||||
if os.environ.get("CANARY_SYNTHETIC_KEY"):
|
headers["X-Windy-Synthetic"] = "1"
|
||||||
headers["X-Windy-Synthetic"] = os.environ["CANARY_SYNTHETIC_KEY"]
|
|
||||||
if data:
|
if data:
|
||||||
headers["Content-Type"] = "application/json"
|
headers["Content-Type"] = "application/json"
|
||||||
req = urllib.request.Request(c.url, data=data, method=c.method, headers=headers)
|
req = urllib.request.Request(c.url, data=data, method=c.method, headers=headers)
|
||||||
|
|||||||
Reference in New Issue
Block a user