Search: FlightAware backbone, blob catalog, diagnostic infra
route-explorer's /api/token sits behind invisible Cloudflare Turnstile
that requires Apple's Private Access Token attestation. Third-party
iOS apps don't qualify for PAT issuance, and Linux Docker containers
can't pass it either (cross-OS fingerprint, even with patchright /
Camoufox). Migrates direct-flight search to FlightAware; multi-stop
and where-can-I-go remain via embedded SFSafariViewController.
- FlightAwareScheduleClient — scrapes route.rvt + trackpoll JSON for
real schedules without auth. T+0..2 day window. Tests against
captured HTML fixtures.
- BlobRouteClient — pulls the public Vercel blob route catalog
route-explorer's frontend reads (no auth, no Turnstile).
- DiagnosticLogger + LoggingURLSessionDelegate + DiagnosticsView —
device-shareable forensic trace. Boot header captures device, OS,
locale, UA; share-sheet export of session logs.
- TurnstileDebugView — live WKWebView gate inspector. Used to prove
the PAT-entitlement gap on a real device.
- RouteExplorerBrowserView — SFSafariViewController wrapper. Real
Safari clears Turnstile naturally; the in-app browser opens at
pre-filled search URLs. Surfaced from Search ("Open in
route-explorer") and Settings → Tools.
- RouteExplorerTokenStore + RouteExplorerSetupView — bookmarklet
capture flow (token round-tripped via flights://routeexplorer-token
URL scheme). Kept dormant for future use.
backend/ — Docker proxy attempts (Playwright, patchright, Camoufox).
All fail on Linux because Cloudflare auto-denies before the Turnstile
widget renders. Documented; kept as scaffolding for a future paid-
solver integration.
scripts/probe_flightaware.py — reference algorithm for the FA path.
scripts/probe_nodriver.py — local-Mac sanity check confirming the
gate clears with real macOS Chrome (proves the blocker is
fingerprint-level, not network-level).
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Mint a rex_clearance + token via nodriver on this Mac, then verify
|
||||
whether those credentials work:
|
||||
A) from a plain curl on this Mac (same IP, no browser)
|
||||
B) with an iOS Safari UA instead of Chrome UA
|
||||
C) from a DIFFERENT IP (Anthropic infra via fly.io ipv6 / etc.)
|
||||
|
||||
Outputs the captured cookie + token so we can hardcode and replay.
|
||||
"""
|
||||
import asyncio, json, subprocess, sys
|
||||
import nodriver as uc
|
||||
|
||||
BASE = "https://route-explorer.com"
|
||||
SAFARI_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 "
|
||||
"Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
|
||||
async def mint() -> tuple[str, str, str]:
|
||||
"""Returns (rex_clearance_value, am_user_session_value, token)."""
|
||||
# Use nodriver's default Chrome stealth profile. Overriding UA at the
|
||||
# process level breaks its detection-evasion shims. We test cross-UA
|
||||
# replay separately after minting.
|
||||
browser = await uc.start(headless=False)
|
||||
tab = await browser.get(BASE + "/")
|
||||
|
||||
# accept cookies
|
||||
await tab.evaluate("""
|
||||
for (const b of document.querySelectorAll('button')) {
|
||||
if (/accept|agree|allow/i.test((b.innerText||'').trim())) b.click();
|
||||
}
|
||||
""")
|
||||
|
||||
for tick in range(1, 60):
|
||||
await asyncio.sleep(1)
|
||||
status = await tab.evaluate("""
|
||||
(async () => {
|
||||
try { const r = await fetch('/api/token', { credentials: 'include' });
|
||||
return r.status;
|
||||
} catch (e) { return -1; }
|
||||
})()
|
||||
""", await_promise=True)
|
||||
if status == 200:
|
||||
print(f" cleared at t+{tick}s")
|
||||
break
|
||||
else:
|
||||
browser.stop()
|
||||
raise RuntimeError("Never cleared.")
|
||||
|
||||
body = await tab.evaluate("""
|
||||
(async () => (await (await fetch('/api/token', {credentials:'include'})).text()))()
|
||||
""", await_promise=True)
|
||||
token = json.loads(body)["token"]
|
||||
cookies = await browser.cookies.get_all()
|
||||
rex = next((c for c in cookies if c.name == "rex_clearance"), None)
|
||||
am = next((c for c in cookies if c.name == "am_user_session"), None)
|
||||
if not rex:
|
||||
browser.stop()
|
||||
raise RuntimeError("Cleared but no rex_clearance cookie found.")
|
||||
|
||||
print(f"\n rex_clearance: {rex.value}")
|
||||
print(f" am_user_session: {am.value if am else '<none>'}")
|
||||
print(f" token: {token}")
|
||||
print(f" cookie expires: {getattr(rex, 'expires', None)}")
|
||||
browser.stop()
|
||||
return rex.value, am.value if am else "", token
|
||||
|
||||
|
||||
def curl(cookie_jar: str, ua: str, label: str) -> int:
|
||||
"""Replay /api/token via curl with given cookies + UA, return HTTP status."""
|
||||
cmd = [
|
||||
"/usr/bin/curl", "-s", "-o", "/tmp/replay_body", "-w", "%{http_code}",
|
||||
f"{BASE}/api/token",
|
||||
"-H", f"User-Agent: {ua}",
|
||||
"-H", "Accept: application/json",
|
||||
"-H", f"Origin: {BASE}",
|
||||
"-H", f"Referer: {BASE}/",
|
||||
"-H", f"Cookie: {cookie_jar}",
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
code = int(r.stdout.strip() or 0)
|
||||
body = open("/tmp/replay_body").read()[:200]
|
||||
print(f" {label}: HTTP {code} body: {body}")
|
||||
return code
|
||||
|
||||
|
||||
def main():
|
||||
print("Minting credentials via nodriver…")
|
||||
rex_val, am_val, token = uc.loop().run_until_complete(mint())
|
||||
|
||||
cookie_jar = f"rex_clearance={rex_val}; am_user_session={am_val}"
|
||||
|
||||
print("\n=== A: same Mac IP, iOS Safari UA, captured cookies ===")
|
||||
curl(cookie_jar, SAFARI_UA, " same-IP/iOS-UA")
|
||||
|
||||
print("\n=== B: same Mac IP, Chrome UA (UA mismatch test) ===")
|
||||
chrome_ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36"
|
||||
curl(cookie_jar, chrome_ua, " same-IP/Chrome-UA")
|
||||
|
||||
print("\n=== C: flight-search with captured token ===")
|
||||
cmd = [
|
||||
"/usr/bin/curl", "-s", "-o", "/tmp/fs_body", "-w", "%{http_code}",
|
||||
"-X", "POST", f"{BASE}/api/flight-search",
|
||||
"-H", f"User-Agent: {SAFARI_UA}",
|
||||
"-H", "Content-Type: application/json",
|
||||
"-H", f"Origin: {BASE}",
|
||||
"-H", f"Referer: {BASE}/",
|
||||
"-H", f"Cookie: {cookie_jar}",
|
||||
"-H", f"X-API-Token: {token}",
|
||||
"-d", json.dumps({
|
||||
"endpoint": "/route",
|
||||
"body": {"json": {
|
||||
"departureAirportIata": "DAL",
|
||||
"arrivalAirportIata": "HOU",
|
||||
"departureDates": ["2026-05-31"],
|
||||
"maxStops": 0, "limit": 20, "includeAppendix": True,
|
||||
}},
|
||||
}),
|
||||
]
|
||||
r = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
|
||||
fs_code = int(r.stdout.strip() or 0)
|
||||
body = open("/tmp/fs_body").read()
|
||||
print(f" /api/flight-search: HTTP {fs_code}")
|
||||
if fs_code == 200:
|
||||
data = json.loads(body)
|
||||
conns = data.get("json", {}).get("connections", [])
|
||||
print(f" → {len(conns)} connections")
|
||||
for c in conns[:5]:
|
||||
for f in c.get("flights", []):
|
||||
print(f" {f['carrierIata']}{f['flightNumber']} "
|
||||
f"{f['departure']['airportIata']}@{f['departure']['dateTime'][11:16]}"
|
||||
f" → {f['arrival']['airportIata']}@{f['arrival']['dateTime'][11:16]} "
|
||||
f"({f.get('equipmentIata','?')})")
|
||||
else:
|
||||
print(f" body: {body[:300]}")
|
||||
|
||||
print(f"\n=== CAPTURED FOR HARDCODING ===")
|
||||
print(f"REX_CLEARANCE = {rex_val!r}")
|
||||
print(f"AM_USER_SESSION = {am_val!r}")
|
||||
print(f"TOKEN = {token!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user