1use axum::body::Body;
13use axum::extract::{Path, Query, State};
14use axum::http::{header, HeaderMap, HeaderValue, Response, StatusCode};
15use axum::response::IntoResponse;
16use axum::routing::{delete, get, post};
17use axum::{Json, Router};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20
21use crate::error::AppError;
22use crate::state::AppState;
23
24pub fn router(state: AppState) -> Router {
27 Router::new()
28 .route("/health", get(health))
29 .route("/health/sso", get(health_sso))
30 .route("/debug/jar", get(debug_jar))
31 .route("/debug/probe", get(debug_probe))
32 .route("/accounts", get(accounts))
33 .route("/accounts/{account_id}/summary", get(account_summary))
34 .route("/accounts/{account_id}/positions", get(account_positions))
35 .route("/accounts/{account_id}/ledger", get(account_ledger))
36 .route(
37 "/accounts/{account_id}/orders",
38 get(account_orders).post(submit_order),
39 )
40 .route(
41 "/accounts/{account_id}/orders/{order_id}",
42 delete(cancel_order),
43 )
44 .route("/contracts/search", get(contract_search))
45 .route("/market/snapshot", get(market_snapshot))
46 .route("/events/orders", get(events_orders))
47 .route("/events/pnl", get(events_pnl))
48 .route("/events/marketdata", get(events_marketdata))
49 .route("/events/gap", get(events_gap))
50 .route("/events/_status", get(events_status))
51 .route("/events/_reconnect", post(events_reconnect))
52 .route("/events/{topic}/history", get(events_history))
53 .fallback(passthrough_any)
61 .with_state(state)
62}
63
64const HOP_BY_HOP: &[&str] = &[
78 "host",
79 "content-length",
80 "connection",
81 "keep-alive",
82 "proxy-authenticate",
83 "proxy-authorization",
84 "te",
85 "trailer",
86 "transfer-encoding",
87 "upgrade",
88 "cookie",
89 "authorization",
90 "x-forwarded-for",
91 "x-forwarded-host",
92 "x-forwarded-proto",
93 "x-real-ip",
94 "forwarded",
95];
96
97fn is_hop_by_hop(name: &str) -> bool {
98 HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h))
99}
100
101#[tracing::instrument(skip_all, fields(method, path))]
105async fn passthrough_any(
106 State(state): State<AppState>,
107 req: axum::extract::Request,
108) -> Result<Response<Body>, AppError> {
109 use axum::http::Method;
110 let method = req.method().clone();
111 let path_and_query = req
112 .uri()
113 .path_and_query()
114 .map(|pq| pq.as_str())
115 .unwrap_or("/")
116 .to_string();
117 let path_only = req.uri().path().to_string();
120 tracing::Span::current().record("method", tracing::field::display(&method));
123 tracing::Span::current().record("path", tracing::field::display(&path_only));
124
125 let gateway_root = state.client().gateway_root_url().as_str();
130 let target = format!("{}{}", gateway_root.trim_end_matches('/'), path_and_query);
131 let target_url: reqwest::Url = target
132 .parse()
133 .map_err(|e| bezant::Error::BadRequest(format!("target url: {e}")))?;
134
135 let headers = req.headers().clone();
136
137 let jar = state.client().cookie_jar();
152 let mut pairs: Vec<&str> = Vec::new();
153 for cookie_header in headers.get_all(axum::http::header::COOKIE) {
154 if let Ok(raw) = cookie_header.to_str() {
155 for pair in raw.split(';') {
156 let trimmed = pair.trim();
157 if trimmed.is_empty() {
158 continue;
159 }
160 let name = trimmed.split('=').next().unwrap_or("").trim();
167 if is_edge_auth_cookie(name) {
168 continue;
169 }
170 pairs.push(trimmed);
171 }
172 }
173 }
174 let injected = pairs.len();
175 if injected > 0 {
176 jar.set_pairs(&pairs);
177 }
178 tracing::debug!(
182 path = %path_only,
183 cookies = injected,
184 "passthrough cookie replay"
185 );
186
187 let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024)
188 .await
189 .map_err(|e| bezant::Error::BadRequest(format!("read body: {e}")))?;
190
191 let method_reqwest = reqwest::Method::from_bytes(method.as_str().as_bytes())
192 .map_err(|e| bezant::Error::BadRequest(format!("method: {e}")))?;
193 let rewrite_origin = path_only.starts_with("/v1/api/") || path_only == "/v1/api";
205 let gateway_origin = if rewrite_origin {
206 let scheme = target_url.scheme();
207 target_url.host_str().map(|h| match target_url.port() {
208 Some(p) => format!("{scheme}://{h}:{p}"),
209 None => format!("{scheme}://{h}"),
210 })
211 } else {
212 None
213 };
214 let mut builder = state.client().http().request(method_reqwest, &target);
215 for (name, value) in headers.iter() {
216 if is_hop_by_hop(name.as_str()) {
220 continue;
221 }
222 let lower = name.as_str().to_ascii_lowercase();
223 if let Some(ref origin) = gateway_origin {
224 if lower == "origin" {
225 if let Ok(v) = reqwest::header::HeaderValue::from_str(origin) {
226 builder = builder.header(reqwest::header::ORIGIN, v);
227 }
228 continue;
229 }
230 if lower == "referer" {
231 if let Ok(orig) = value.to_str() {
235 let rewritten = rewrite_referer_origin(orig, origin);
236 if let Ok(v) = reqwest::header::HeaderValue::from_str(&rewritten) {
237 builder = builder.header(reqwest::header::REFERER, v);
238 }
239 }
240 continue;
241 }
242 }
243 if let Ok(v) = reqwest::header::HeaderValue::from_bytes(value.as_bytes()) {
244 if let Ok(name) = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) {
245 builder = builder.header(name, v);
246 }
247 }
248 }
249 if method != Method::GET && method != Method::HEAD {
250 let len = body_bytes.len();
255 builder = builder
256 .header(reqwest::header::CONTENT_LENGTH, len.to_string())
257 .body(body_bytes.to_vec());
258 }
259
260 let resp = builder.send().await.map_err(bezant::Error::Http)?;
261
262 if path_only.ends_with("/iserver/auth/ssodh/init") {
272 state.record_sso_bridge(resp.status().as_u16());
273 }
274
275 forward(resp).await
276}
277
278#[derive(Serialize)]
279struct HealthBody {
280 authenticated: bool,
281 connected: bool,
282 competing: bool,
283 message: Option<String>,
284 #[serde(skip_serializing_if = "Option::is_none")]
291 sso_bridge: Option<SsoBridgeBody>,
292}
293
294#[derive(Debug, Serialize)]
304struct SsoStatusBody {
305 observed: bool,
309 #[serde(skip_serializing_if = "Option::is_none")]
310 status: Option<u16>,
311 #[serde(skip_serializing_if = "Option::is_none")]
312 at: Option<u64>,
313 consecutive_faults: u32,
314 wedged: bool,
315}
316
317#[derive(Debug, Serialize)]
318struct SsoBridgeBody {
319 status: u16,
321 at: u64,
323 consecutive_faults: u32,
325 wedged: bool,
327}
328
329async fn debug_jar(
339 State(state): State<AppState>,
340 headers: HeaderMap,
341 Query(q): Query<HashMap<String, String>>,
342) -> Response<Body> {
343 if let Err(resp) = debug_auth(&state, &headers, &q) {
344 return resp;
345 }
346 let jar = state.client().cookie_jar();
347 let entries: Vec<serde_json::Value> = jar
348 .snapshot()
349 .into_iter()
350 .map(|(name, value)| {
351 serde_json::json!({
352 "name": name,
353 "value_length": value.len(),
354 })
355 })
356 .collect();
357 let body = serde_json::json!({
358 "gateway_root": state.client().gateway_root_url().as_str(),
359 "size": entries.len(),
360 "entries": entries,
361 });
362 Json(body).into_response()
363}
364
365#[allow(clippy::result_large_err)]
379fn debug_auth(
382 state: &AppState,
383 headers: &HeaderMap,
384 query: &HashMap<String, String>,
385) -> Result<(), Response<Body>> {
386 let Some(expected) = state.debug_token() else {
387 return Err(Response::builder()
388 .status(StatusCode::NOT_FOUND)
389 .body(Body::empty())
390 .unwrap_or_default());
391 };
392 let presented = headers
393 .get("x-bezant-debug-token")
394 .and_then(|v| v.to_str().ok())
395 .or_else(|| query.get("token").map(String::as_str))
396 .unwrap_or("");
397 if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
398 return Ok(());
399 }
400 Err(Response::builder()
401 .status(StatusCode::UNAUTHORIZED)
402 .header(header::CONTENT_TYPE, "application/json")
403 .body(Body::from(
404 r#"{"code":"debug_unauthorized","message":"missing or invalid debug token"}"#,
405 ))
406 .unwrap_or_default())
407}
408
409fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
413 if a.len() != b.len() {
414 return false;
415 }
416 let mut diff = 0u8;
417 for (x, y) in a.iter().zip(b.iter()) {
418 diff |= x ^ y;
419 }
420 diff == 0
421}
422
423async fn debug_probe(
452 State(state): State<AppState>,
453 headers: HeaderMap,
454 Query(q): Query<HashMap<String, String>>,
455) -> Response<Body> {
456 if let Err(resp) = debug_auth(&state, &headers, &q) {
457 return resp;
458 }
459 let client = state.client();
460 let started = std::time::Instant::now();
461 let jar_before = client.cookie_jar().snapshot().len();
462
463 let auth_status = probe_step(
464 client,
465 "auth_status",
466 reqwest::Method::POST,
467 &["iserver", "auth", "status"],
468 None,
469 )
470 .await;
471 let already_bridged = is_authenticated(&auth_status);
479 let ssodh_init = if already_bridged {
480 skipped_step(
481 "ssodh_init",
482 "POST",
483 "/v1/api/iserver/auth/ssodh/init",
484 "session already bridged (auth_status authenticated)",
485 )
486 } else {
487 probe_step(
488 client,
489 "ssodh_init",
490 reqwest::Method::POST,
491 &["iserver", "auth", "ssodh", "init"],
492 Some(serde_json::json!({ "publish": true, "compete": true })),
493 )
494 .await
495 };
496 let tickle = probe_step(client, "tickle", reqwest::Method::POST, &["tickle"], None).await;
497 let accounts = probe_step(
498 client,
499 "accounts",
500 reqwest::Method::GET,
501 &["portfolio", "accounts"],
502 None,
503 )
504 .await;
505
506 let verdict = compute_verdict(&auth_status, &ssodh_init, &tickle, &accounts);
507 let jar_after = client.cookie_jar().snapshot().len();
508
509 let body = serde_json::json!({
510 "gateway_root": client.gateway_root_url().as_str(),
511 "elapsed_ms": started.elapsed().as_millis() as u64,
512 "jar_size_before": jar_before,
513 "jar_size_after": jar_after,
514 "verdict": verdict,
515 "steps": [auth_status, ssodh_init, tickle, accounts],
516 });
517 Json(body).into_response()
518}
519
520async fn probe_step(
529 client: &bezant::Client,
530 name: &'static str,
531 method: reqwest::Method,
532 path_segments: &[&str],
533 body: Option<serde_json::Value>,
534) -> serde_json::Value {
535 let mut url = client.base_url().clone();
536 if let Ok(mut segs) = url.path_segments_mut() {
537 for seg in path_segments {
538 segs.push(seg);
539 }
540 }
541 let path_for_log = url.path().to_owned();
542
543 let gateway_origin = client
544 .gateway_root_url()
545 .as_str()
546 .trim_end_matches('/')
547 .to_owned();
548
549 let mut builder = client
550 .http()
551 .request(method.clone(), url.clone())
552 .header(reqwest::header::ORIGIN, &gateway_origin)
553 .header(reqwest::header::REFERER, format!("{gateway_origin}/"));
554
555 let body_bytes: Vec<u8> = match (&method, body) {
556 (m, _) if m == reqwest::Method::GET || m == reqwest::Method::HEAD => Vec::new(),
557 (_, Some(json)) => serde_json::to_vec(&json).unwrap_or_default(),
558 (_, None) => Vec::new(),
559 };
560 if method != reqwest::Method::GET && method != reqwest::Method::HEAD {
561 builder = builder
562 .header(
563 reqwest::header::CONTENT_LENGTH,
564 body_bytes.len().to_string(),
565 )
566 .body(body_bytes.clone());
567 if !body_bytes.is_empty() {
568 builder = builder.header(reqwest::header::CONTENT_TYPE, "application/json");
569 }
570 }
571
572 let started = std::time::Instant::now();
573 let result = match tokio::time::timeout(std::time::Duration::from_secs(5), builder.send()).await
578 {
579 Ok(send_result) => send_result.map_err(|e| e.to_string()),
580 Err(_) => Err("step timed out after 5s".to_string()),
581 };
582 let latency_ms = started.elapsed().as_millis() as u64;
583
584 match result {
585 Ok(resp) => {
586 let status = resp.status().as_u16();
587 let set_cookie_names: Vec<String> = resp
588 .headers()
589 .get_all(reqwest::header::SET_COOKIE)
590 .iter()
591 .filter_map(|v| v.to_str().ok())
592 .map(|raw| {
593 raw.split(';')
594 .next()
595 .and_then(|s| s.split('=').next())
596 .map(|s| s.trim().to_owned())
597 .unwrap_or_default()
598 })
599 .filter(|s| !s.is_empty())
600 .collect();
601 let bytes = read_capped(resp, 1024 * 1024).await.unwrap_or_default();
606 let parsed_authenticated = serde_json::from_slice::<serde_json::Value>(&bytes)
612 .ok()
613 .and_then(|v| v["authenticated"].as_bool());
614 let preview_len = bytes.len().min(512);
620 let raw_preview = String::from_utf8_lossy(&bytes[..preview_len]).into_owned();
621 let body_preview = redact_tokens(&raw_preview);
622 serde_json::json!({
623 "name": name,
624 "method": method.as_str(),
625 "path": path_for_log,
626 "status": status,
627 "latency_ms": latency_ms,
628 "body_bytes": bytes.len(),
629 "body_preview": body_preview,
630 "set_cookie_names": set_cookie_names,
631 "error": serde_json::Value::Null,
632 "_authenticated": parsed_authenticated,
633 })
634 }
635 Err(e) => serde_json::json!({
636 "name": name,
637 "method": method.as_str(),
638 "path": path_for_log,
639 "status": serde_json::Value::Null,
640 "latency_ms": latency_ms,
641 "body_bytes": 0,
642 "body_preview": "",
643 "set_cookie_names": [],
644 "error": e,
645 }),
646 }
647}
648
649fn redact_tokens(preview: &str) -> String {
662 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(preview) else {
663 return preview.to_owned();
667 };
668 redact_in_place(&mut value);
669 value.to_string()
670}
671
672fn redact_in_place(value: &mut serde_json::Value) {
673 match value {
674 serde_json::Value::Object(map) => {
675 for (k, v) in map.iter_mut() {
676 let lower = k.to_ascii_lowercase();
677 if lower == "session"
678 || lower == "ssoconclusion"
679 || lower.contains("token")
680 || lower.contains("secret")
681 {
682 *v = serde_json::Value::String("<redacted>".to_owned());
683 } else {
684 redact_in_place(v);
685 }
686 }
687 }
688 serde_json::Value::Array(arr) => {
689 for v in arr.iter_mut() {
690 redact_in_place(v);
691 }
692 }
693 _ => {}
694 }
695}
696
697fn compute_verdict(
705 auth_status: &serde_json::Value,
706 ssodh_init: &serde_json::Value,
707 tickle: &serde_json::Value,
708 accounts: &serde_json::Value,
709) -> &'static str {
710 if !is_2xx(auth_status) {
711 return "auth_status_failed";
712 }
713 let ssodh_ran_and_failed = !is_skipped(ssodh_init) && !is_2xx(ssodh_init);
720 if ssodh_ran_and_failed {
721 return "ssodh_failed";
722 }
723 if !is_authenticated(auth_status) {
724 return "needs_login";
725 }
726 if !is_2xx_or_skipped(tickle) {
727 return "tickle_failed";
728 }
729 if !is_2xx_or_skipped(accounts) {
730 return "accounts_failed";
731 }
732 "ok"
733}
734
735fn is_skipped(step: &serde_json::Value) -> bool {
736 step["skipped"].as_bool().unwrap_or(false)
737}
738
739fn is_2xx(step: &serde_json::Value) -> bool {
740 matches!(step["status"].as_u64(), Some(200..=299))
741}
742
743fn is_2xx_or_skipped(step: &serde_json::Value) -> bool {
744 is_2xx(step) || step["skipped"].as_bool().unwrap_or(false)
745}
746
747fn is_authenticated(step: &serde_json::Value) -> bool {
753 if !is_2xx(step) {
754 return false;
755 }
756 step["_authenticated"].as_bool().unwrap_or(false)
757}
758
759fn skipped_step(
763 name: &'static str,
764 method: &'static str,
765 path: &'static str,
766 reason: &'static str,
767) -> serde_json::Value {
768 serde_json::json!({
769 "name": name,
770 "method": method,
771 "path": path,
772 "status": serde_json::Value::Null,
773 "latency_ms": 0,
774 "body_bytes": 0,
775 "body_preview": "",
776 "set_cookie_names": [],
777 "error": serde_json::Value::Null,
778 "skipped": true,
779 "skipped_reason": reason,
780 })
781}
782
783const SSO_WEDGED_AFTER: u32 = 3;
787
788#[tracing::instrument(skip_all)]
791async fn health_sso(State(state): State<AppState>) -> Json<SsoStatusBody> {
792 Json(state.sso_bridge().map_or(
793 SsoStatusBody {
794 observed: false,
795 status: None,
796 at: None,
797 consecutive_faults: 0,
798 wedged: false,
799 },
800 |b| SsoStatusBody {
801 observed: true,
802 status: Some(b.status),
803 at: Some(b.at),
804 consecutive_faults: b.consecutive_faults,
805 wedged: b.consecutive_faults >= SSO_WEDGED_AFTER,
806 },
807 ))
808}
809
810pub(crate) const GATEWAY_UPSTREAM_FAILING: &str = "gateway_upstream_failing";
821
822fn gateway_upstream_failing(err: &bezant::Error) -> Option<Response<Body>> {
825 let bezant::Error::UpstreamStatus { status, .. } = err else {
826 return None;
827 };
828 let code = StatusCode::from_u16(*status).ok()?;
829 if !code.is_server_error() {
830 return None;
831 }
832 tracing::warn!(
833 upstream_status = status,
834 "health: gateway is up but its upstream (api.ibkr.com) is failing"
835 );
836 Some(
837 (
838 code,
839 Json(serde_json::json!({
840 "code": GATEWAY_UPSTREAM_FAILING,
841 "message": format!(
842 "the IBKR Gateway answered, but its auth/status failed upstream with HTTP {status}; \
843 the Gateway process is up — restarting it will not help and logs the session out"
844 ),
845 "gateway_reachable": true,
846 "upstream_status": status,
847 })),
848 )
849 .into_response(),
850 )
851}
852
853#[tracing::instrument(skip_all)]
854async fn health(State(state): State<AppState>) -> Result<Response<Body>, AppError> {
855 let status = match state.client().auth_status().await {
856 Ok(s) => s,
857 Err(e) => return gateway_upstream_failing(&e).ok_or_else(|| e.into()),
858 };
859 Ok(Json(HealthBody {
860 authenticated: status.authenticated,
861 connected: status.connected,
862 competing: status.competing,
863 message: status.message,
864 sso_bridge: state.sso_bridge().map(|b| SsoBridgeBody {
865 status: b.status,
866 at: b.at,
867 consecutive_faults: b.consecutive_faults,
868 wedged: b.consecutive_faults >= SSO_WEDGED_AFTER,
869 }),
870 })
871 .into_response())
872}
873
874#[tracing::instrument(skip_all)]
875async fn accounts(State(state): State<AppState>) -> Result<Response<Body>, AppError> {
876 passthrough_get(&state, &["portfolio", "accounts"], &[]).await
877}
878
879#[tracing::instrument(skip(state), fields(account_id = %account_id))]
880async fn account_summary(
881 State(state): State<AppState>,
882 Path(account_id): Path<String>,
883) -> Result<Response<Body>, AppError> {
884 passthrough_get(&state, &["portfolio", account_id.as_str(), "summary"], &[]).await
885}
886
887#[derive(Deserialize, Debug)]
888struct PositionsQuery {
889 #[serde(default)]
890 page: u32,
891}
892
893#[tracing::instrument(skip(state), fields(account_id = %account_id, page = q.page))]
894async fn account_positions(
895 State(state): State<AppState>,
896 Path(account_id): Path<String>,
897 Query(q): Query<PositionsQuery>,
898) -> Result<Response<Body>, AppError> {
899 let page = q.page.to_string();
900 passthrough_get(
901 &state,
902 &["portfolio", account_id.as_str(), "positions", page.as_str()],
903 &[],
904 )
905 .await
906}
907
908#[tracing::instrument(skip(state), fields(account_id = %account_id))]
909async fn account_ledger(
910 State(state): State<AppState>,
911 Path(account_id): Path<String>,
912) -> Result<Response<Body>, AppError> {
913 passthrough_get(&state, &["portfolio", account_id.as_str(), "ledger"], &[]).await
914}
915
916#[tracing::instrument(skip(state), fields(account_id = %account_id))]
918async fn account_orders(
919 State(state): State<AppState>,
920 Path(account_id): Path<String>,
921) -> Result<Response<Body>, AppError> {
922 passthrough_get(
924 &state,
925 &["iserver", "account", "orders"],
926 &[("accountId", account_id.as_str())],
927 )
928 .await
929}
930
931#[tracing::instrument(skip(state, body), fields(account_id = %account_id))]
937async fn submit_order(
938 State(state): State<AppState>,
939 Path(account_id): Path<String>,
940 axum::extract::Json(body): axum::extract::Json<serde_json::Value>,
941) -> Result<Response<Body>, AppError> {
942 let mut url = state.client().base_url().clone();
943 {
944 let mut segs = url
945 .path_segments_mut()
946 .map_err(|()| bezant::Error::UrlNotABase {
947 url: state.client().base_url().to_string(),
948 })?;
949 segs.push("iserver")
950 .push("account")
951 .push(account_id.as_str())
952 .push("orders");
953 }
954 let resp = state
955 .client()
956 .http()
957 .post(url)
958 .json(&body)
959 .send()
960 .await
961 .map_err(bezant::Error::Http)?;
962 forward(resp).await
963}
964
965#[tracing::instrument(skip(state), fields(account_id = %account_id, order_id = %order_id))]
967async fn cancel_order(
968 State(state): State<AppState>,
969 Path((account_id, order_id)): Path<(String, String)>,
970) -> Result<Response<Body>, AppError> {
971 let mut url = state.client().base_url().clone();
972 {
973 let mut segs = url
974 .path_segments_mut()
975 .map_err(|()| bezant::Error::UrlNotABase {
976 url: state.client().base_url().to_string(),
977 })?;
978 segs.push("iserver")
979 .push("account")
980 .push(account_id.as_str())
981 .push("order")
982 .push(order_id.as_str());
983 }
984 let resp = state
985 .client()
986 .http()
987 .delete(url)
988 .send()
989 .await
990 .map_err(bezant::Error::Http)?;
991 forward(resp).await
992}
993
994#[derive(Deserialize)]
995struct ContractSearchQuery {
996 symbol: String,
997 #[serde(default)]
998 name: bool,
999 #[serde(rename = "secType", default = "default_sec_type")]
1000 sec_type: String,
1001}
1002
1003fn default_sec_type() -> String {
1004 "STK".into()
1005}
1006
1007async fn contract_search(
1008 State(state): State<AppState>,
1009 Query(q): Query<ContractSearchQuery>,
1010) -> Result<Response<Body>, AppError> {
1011 let mut url = state.client().base_url().clone();
1013 {
1014 let mut segs = url
1015 .path_segments_mut()
1016 .map_err(|()| bezant::Error::UrlNotABase {
1017 url: state.client().base_url().to_string(),
1018 })?;
1019 segs.push("iserver").push("secdef").push("search");
1020 }
1021 let body = serde_json::json!({
1022 "symbol": q.symbol,
1023 "name": q.name,
1024 "secType": q.sec_type,
1025 });
1026 let resp = state
1027 .client()
1028 .http()
1029 .post(url)
1030 .json(&body)
1031 .send()
1032 .await
1033 .map_err(bezant::Error::Http)?;
1034 forward(resp).await
1035}
1036
1037async fn market_snapshot(
1038 State(state): State<AppState>,
1039 Query(q): Query<HashMap<String, String>>,
1040) -> Result<Response<Body>, AppError> {
1041 let conids = q
1042 .get("conids")
1043 .ok_or(bezant::Error::MissingQuery { name: "conids" })?;
1044 let fields = q
1045 .get("fields")
1046 .cloned()
1047 .unwrap_or_else(|| "31,84,86,87".into());
1048 passthrough_get(
1049 &state,
1050 &["iserver", "marketdata", "snapshot"],
1051 &[("conids", conids), ("fields", &fields)],
1052 )
1053 .await
1054}
1055
1056async fn passthrough_get(
1059 state: &AppState,
1060 path_segments: &[&str],
1061 query: &[(&str, &str)],
1062) -> Result<Response<Body>, AppError> {
1063 let mut url = state.client().base_url().clone();
1064 {
1065 let mut segs = url
1066 .path_segments_mut()
1067 .map_err(|()| bezant::Error::UrlNotABase {
1068 url: state.client().base_url().to_string(),
1069 })?;
1070 for seg in path_segments {
1071 segs.push(seg);
1072 }
1073 }
1074 if !query.is_empty() {
1075 let mut q = url.query_pairs_mut();
1076 for (k, v) in query {
1077 q.append_pair(k, v);
1078 }
1079 }
1080 let resp = state
1081 .client()
1082 .http()
1083 .get(url)
1084 .send()
1085 .await
1086 .map_err(bezant::Error::Http)?;
1087 forward(resp).await
1088}
1089
1090const MAX_UPSTREAM_BODY_BYTES: usize = 25 * 1024 * 1024;
1123
1124#[tracing::instrument(skip_all, fields(upstream_status = %resp.status()))]
1125async fn forward(resp: reqwest::Response) -> Result<Response<Body>, AppError> {
1126 let status = resp.status();
1127 let headers_src = resp.headers().clone();
1128 let body_must_be_empty =
1129 matches!(status.as_u16(), 100..=199 | 204 | 304) || status.is_redirection();
1130
1131 let bytes: Vec<u8> = match read_capped(resp, MAX_UPSTREAM_BODY_BYTES).await {
1132 Ok(b) => b,
1133 Err(e) if body_must_be_empty => {
1134 tracing::debug!(
1139 %status,
1140 error = %e,
1141 "forward: empty-body fallback on no-body status"
1142 );
1143 Vec::new()
1144 }
1145 Err(e) => {
1146 return Err(bezant::Error::UpstreamStatus {
1147 endpoint: "passthrough",
1148 status: status.as_u16(),
1149 body_preview: Some(e),
1150 }
1151 .into())
1152 }
1153 };
1154
1155 let body_is_empty = bytes.is_empty();
1156
1157 let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
1158
1159 let mut headers = HeaderMap::new();
1160 let mut had_content_type = false;
1161 for (name, value) in headers_src.iter() {
1162 let n = name.as_str().to_ascii_lowercase();
1163 if is_hop_by_hop_response(&n) {
1164 continue;
1165 }
1166 if n == "set-cookie" {
1168 let value_bytes: Vec<u8> = match value.to_str() {
1169 Ok(raw) => strip_cookie_domain(raw).into_bytes(),
1170 Err(_) => value.as_bytes().to_vec(),
1171 };
1172 if let (Ok(name), Ok(value)) = (
1173 header::HeaderName::from_bytes(name.as_str().as_bytes()),
1174 HeaderValue::from_bytes(&value_bytes),
1175 ) {
1176 headers.append(name, value);
1177 }
1178 continue;
1179 }
1180 if n == "content-type" {
1184 let raw = value.to_str().unwrap_or("");
1185 let rewrite = !body_is_empty
1186 && !body_must_be_empty
1187 && raw.eq_ignore_ascii_case("application/octet-stream");
1188 let bytes_to_emit: &[u8] = if rewrite {
1189 b"text/html; charset=UTF-8"
1190 } else {
1191 value.as_bytes()
1192 };
1193 if let (Ok(name), Ok(value)) = (
1194 header::HeaderName::from_bytes(name.as_str().as_bytes()),
1195 HeaderValue::from_bytes(bytes_to_emit),
1196 ) {
1197 headers.insert(name, value);
1198 had_content_type = true;
1199 }
1200 continue;
1201 }
1202 if let (Ok(name), Ok(value)) = (
1203 header::HeaderName::from_bytes(name.as_str().as_bytes()),
1204 HeaderValue::from_bytes(value.as_bytes()),
1205 ) {
1206 headers.append(name, value);
1207 }
1208 }
1209
1210 if !had_content_type && !body_is_empty && !body_must_be_empty {
1214 headers.insert(
1215 header::CONTENT_TYPE,
1216 HeaderValue::from_static("text/html; charset=UTF-8"),
1217 );
1218 }
1219
1220 let mut response = Response::builder()
1226 .status(status)
1227 .body(Body::from(bytes))
1228 .map_err(|e| bezant::Error::ResponseBuild(e.to_string()))?;
1229 *response.headers_mut() = headers;
1230 Ok(response)
1231}
1232
1233fn is_hop_by_hop_response(name: &str) -> bool {
1235 matches!(
1236 name,
1237 "content-length"
1238 | "connection"
1239 | "keep-alive"
1240 | "proxy-authenticate"
1241 | "proxy-authorization"
1242 | "te"
1243 | "trailer"
1244 | "transfer-encoding"
1245 | "upgrade"
1246 )
1247}
1248
1249fn rewrite_referer_origin(original: &str, new_origin: &str) -> String {
1254 match url::Url::parse(original) {
1255 Ok(u) => {
1256 let mut path_and_query = u.path().to_owned();
1257 if let Some(q) = u.query() {
1258 path_and_query.push('?');
1259 path_and_query.push_str(q);
1260 }
1261 format!("{}{}", new_origin.trim_end_matches('/'), path_and_query)
1262 }
1263 Err(_) => new_origin.to_owned(),
1264 }
1265}
1266
1267fn is_edge_auth_cookie(name: &str) -> bool {
1284 const BUILTIN_PREFIXES: &[&str] = &[
1285 "CF_Authorization",
1286 "CF_AppSession",
1287 "AWSELBAuthSessionCookie",
1288 "_oauth2_proxy",
1289 "_vercel_jwt",
1290 "_vercel_sso_nonce",
1291 "_pomerium",
1292 ];
1293 if BUILTIN_PREFIXES
1294 .iter()
1295 .any(|p| name.eq_ignore_ascii_case(p) || name.starts_with(p))
1296 {
1297 return true;
1298 }
1299 if let Ok(extra) = std::env::var("BEZANT_EDGE_COOKIE_PREFIXES") {
1301 for prefix in extra.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1302 if name.starts_with(prefix) {
1303 return true;
1304 }
1305 }
1306 }
1307 false
1308}
1309
1310async fn read_capped(resp: reqwest::Response, max: usize) -> std::result::Result<Vec<u8>, String> {
1318 use futures_util::StreamExt;
1319 let mut bytes = Vec::new();
1320 let mut stream = resp.bytes_stream();
1321 while let Some(chunk) = stream.next().await {
1322 let chunk = chunk.map_err(|e| format!("read chunk: {e}"))?;
1323 if bytes.len() + chunk.len() > max {
1324 return Err(format!(
1325 "upstream body exceeded {max} byte cap (>{}B)",
1326 bytes.len() + chunk.len()
1327 ));
1328 }
1329 bytes.extend_from_slice(&chunk);
1330 }
1331 Ok(bytes)
1332}
1333
1334fn strip_cookie_domain(value: &str) -> String {
1339 value
1340 .split(';')
1341 .filter(|part| !part.trim().to_ascii_lowercase().starts_with("domain="))
1342 .collect::<Vec<_>>()
1343 .join(";")
1344}
1345
1346#[derive(Debug, Deserialize)]
1353struct EventsQuery {
1354 #[serde(default)]
1357 since: u64,
1358 #[serde(default = "EventsQuery::default_limit")]
1361 limit: usize,
1362}
1363
1364impl EventsQuery {
1365 fn default_limit() -> usize {
1366 100
1367 }
1368}
1369
1370#[derive(Debug, Serialize)]
1372struct CursorExpiredBody {
1373 code: &'static str,
1374 head_cursor: u64,
1375 reset_epoch: u64,
1376 message: &'static str,
1377}
1378
1379async fn events_orders(
1380 State(state): State<AppState>,
1381 Query(q): Query<EventsQuery>,
1382) -> Response<Body> {
1383 read_events_topic(&state, "orders", q).await
1384}
1385
1386async fn events_pnl(State(state): State<AppState>, Query(q): Query<EventsQuery>) -> Response<Body> {
1387 read_events_topic(&state, "pnl", q).await
1388}
1389
1390async fn events_gap(State(state): State<AppState>, Query(q): Query<EventsQuery>) -> Response<Body> {
1391 read_events_topic(&state, "gap", q).await
1392}
1393
1394#[derive(Debug, Deserialize)]
1395struct MarketDataEventsQuery {
1396 conid: i64,
1397 #[serde(default)]
1398 since: u64,
1399 #[serde(default = "EventsQuery::default_limit")]
1400 limit: usize,
1401}
1402
1403async fn events_marketdata(
1404 State(state): State<AppState>,
1405 Query(q): Query<MarketDataEventsQuery>,
1406) -> Response<Body> {
1407 let Some(handle) = state.events() else {
1408 return events_disabled_response();
1409 };
1410 if let Err(e) = handle.ensure_market_data(q.conid).await {
1412 return (
1413 StatusCode::BAD_GATEWAY,
1414 Json(serde_json::json!({
1415 "code": "events_subscribe_failed",
1416 "message": e,
1417 })),
1418 )
1419 .into_response();
1420 }
1421
1422 let topic = format!("marketdata:{}", q.conid);
1423 let limit = q.limit.min(1_000);
1424 read_events_topic_resolved(handle, &topic, q.since, limit).await
1425}
1426
1427async fn events_status(State(state): State<AppState>) -> Response<Body> {
1428 let Some(handle) = state.events() else {
1429 return events_disabled_response();
1430 };
1431 Json(handle.status().await).into_response()
1432}
1433
1434async fn events_reconnect(
1447 State(state): State<AppState>,
1448 headers: HeaderMap,
1449 Query(q): Query<HashMap<String, String>>,
1450) -> Response<Body> {
1451 if let Err(resp) = debug_auth(&state, &headers, &q) {
1452 return resp;
1453 }
1454 let Some(handle) = state.events() else {
1455 return events_disabled_response();
1456 };
1457 match handle.request_reconnect().await {
1458 Ok(was_connected) => {
1459 tracing::info!(was_connected, "events: reconnect requested over HTTP");
1460 (
1461 StatusCode::ACCEPTED,
1462 Json(serde_json::json!({
1463 "code": "reconnect_requested",
1464 "was_connected": was_connected,
1465 })),
1466 )
1467 .into_response()
1468 }
1469 Err(message) => (
1470 StatusCode::SERVICE_UNAVAILABLE,
1471 Json(serde_json::json!({
1472 "code": "events_connector_unavailable",
1473 "message": message,
1474 })),
1475 )
1476 .into_response(),
1477 }
1478}
1479
1480#[derive(Debug, Deserialize)]
1481struct HistoryQuery {
1482 since_ts: String,
1484 #[serde(default = "HistoryQuery::default_limit")]
1486 limit: usize,
1487}
1488
1489impl HistoryQuery {
1490 fn default_limit() -> usize {
1491 500
1492 }
1493}
1494
1495async fn events_history(
1496 State(state): State<AppState>,
1497 Path(topic): Path<String>,
1498 Query(q): Query<HistoryQuery>,
1499) -> Response<Body> {
1500 let Some(handle) = state.events() else {
1501 return events_disabled_response();
1502 };
1503 let Some(log) = handle.event_log() else {
1504 return (
1505 StatusCode::SERVICE_UNAVAILABLE,
1506 Json(serde_json::json!({
1507 "code": "events_history_disabled",
1508 "message": "events sqlite persistence is not enabled \
1509 (set BEZANT_EVENTS_DB_PATH to turn it on)"
1510 })),
1511 )
1512 .into_response();
1513 };
1514 let limit = q.limit.clamp(1, 5_000);
1515 let log_clone = log.clone();
1516 let topic_clone = topic.clone();
1517 let since_ts = q.since_ts.clone();
1518 let result =
1519 tokio::task::spawn_blocking(move || log_clone.query_since(&topic_clone, &since_ts, limit))
1520 .await;
1521 match result {
1522 Ok(Ok(events)) => {
1523 let body = serde_json::json!({
1524 "topic": topic,
1525 "events": events,
1526 "count": events.len(),
1527 });
1528 (StatusCode::OK, Json(body)).into_response()
1529 }
1530 Ok(Err(e)) => (
1531 StatusCode::INTERNAL_SERVER_ERROR,
1532 Json(serde_json::json!({
1533 "code": "events_history_query_failed",
1534 "message": e.to_string(),
1535 })),
1536 )
1537 .into_response(),
1538 Err(e) => (
1539 StatusCode::INTERNAL_SERVER_ERROR,
1540 Json(serde_json::json!({
1541 "code": "events_history_join_failed",
1542 "message": e.to_string(),
1543 })),
1544 )
1545 .into_response(),
1546 }
1547}
1548
1549async fn read_events_topic(state: &AppState, topic: &str, q: EventsQuery) -> Response<Body> {
1550 let Some(handle) = state.events() else {
1551 return events_disabled_response();
1552 };
1553 read_events_topic_resolved(handle, topic, q.since, q.limit.min(1_000)).await
1554}
1555
1556async fn read_events_topic_resolved(
1557 handle: &crate::events::EventsHandle,
1558 topic: &str,
1559 since: u64,
1560 limit: usize,
1561) -> Response<Body> {
1562 use crate::events::ReadResult;
1563
1564 let (result, ring_exists) = match handle.read_topic(topic, since, limit.max(1)).await {
1565 Some(r) => (r, true),
1566 None => (handle.read_empty_topic(topic, since).await, false),
1570 };
1571
1572 match result {
1573 ReadResult::Ok {
1574 events,
1575 next_cursor,
1576 reset_epoch,
1577 } => {
1578 if events.is_empty() && next_cursor == since && ring_exists {
1584 let mut response = Response::builder()
1585 .status(StatusCode::NO_CONTENT)
1586 .body(Body::empty())
1587 .unwrap_or_default();
1588 let headers = response.headers_mut();
1589 if let Ok(v) = HeaderValue::from_str(&next_cursor.to_string()) {
1590 headers.insert("x-bezant-cursor", v);
1591 }
1592 if let Ok(v) = HeaderValue::from_str(&reset_epoch.to_string()) {
1593 headers.insert("x-bezant-reset-epoch", v);
1594 }
1595 return response;
1596 }
1597 let body = serde_json::json!({
1600 "events": events,
1601 "next_cursor": next_cursor,
1602 "reset_epoch": reset_epoch,
1603 });
1604 (StatusCode::OK, Json(body)).into_response()
1605 }
1606 ReadResult::CursorExpired {
1607 head_cursor,
1608 reset_epoch,
1609 } => {
1610 let body = CursorExpiredBody {
1611 code: "cursor_expired",
1612 head_cursor,
1613 reset_epoch,
1614 message: "the requested cursor is not in this ring (older than the oldest \
1615 buffered event, or issued by a previous process); reset to \
1616 head_cursor - 1 and emit a synthetic gap on the consumer side",
1617 };
1618 (StatusCode::PRECONDITION_FAILED, Json(body)).into_response()
1619 }
1620 }
1621}
1622
1623fn events_disabled_response() -> Response<Body> {
1624 (
1625 StatusCode::SERVICE_UNAVAILABLE,
1626 Json(serde_json::json!({
1627 "code": "events_disabled",
1628 "message": "events capture is not enabled on this bezant-server instance \
1629 (set BEZANT_EVENTS_ENABLED=1 to turn it on)"
1630 })),
1631 )
1632 .into_response()
1633}
1634
1635#[cfg(test)]
1636mod redact_tests {
1637 use super::redact_tokens;
1638
1639 #[test]
1640 fn redacts_session_field() {
1641 let input = r#"{"session":"AAAA-real-token","other":1}"#;
1642 let out = redact_tokens(input);
1643 assert!(out.contains("<redacted>"), "got: {out}");
1644 assert!(!out.contains("AAAA-real-token"), "raw token leaked: {out}");
1645 assert!(out.contains("\"other\":1"), "got: {out}");
1647 }
1648
1649 #[test]
1650 fn redacts_token_substring_keys() {
1651 let input = r#"{"accessToken":"x","refresh_token":"y","tokenExpiry":99}"#;
1652 let out = redact_tokens(input);
1653 assert!(!out.contains(r#""x""#), "accessToken leaked: {out}");
1654 assert!(!out.contains(r#""y""#), "refresh_token leaked: {out}");
1655 assert!(!out.contains("99"), "tokenExpiry value leaked: {out}");
1659 }
1660
1661 #[test]
1662 fn passes_non_json_through_verbatim() {
1663 let input = "Not a JSON body, just text.";
1664 let out = redact_tokens(input);
1665 assert_eq!(out, input);
1666 }
1667
1668 #[test]
1669 fn redacts_inside_arrays_and_nested_objects() {
1670 let input = r#"{"sessions":[{"session":"a"},{"session":"b"}]}"#;
1671 let out = redact_tokens(input);
1672 assert!(!out.contains(r#""a""#), "got: {out}");
1673 assert!(!out.contains(r#""b""#), "got: {out}");
1674 }
1675}
1676
1677#[cfg(test)]
1678mod forward_tests {
1679 use super::strip_cookie_domain;
1680
1681 #[test]
1682 fn drops_ibkr_domain_attribute() {
1683 let input = "SID=abc; Domain=.ibkr.com; Path=/; Secure";
1684 assert_eq!(strip_cookie_domain(input), "SID=abc; Path=/; Secure");
1687 }
1688
1689 #[test]
1690 fn leaves_cookie_without_domain_untouched() {
1691 let input = "SID=abc; Path=/; Secure";
1692 assert_eq!(strip_cookie_domain(input), input);
1693 }
1694
1695 #[test]
1696 fn case_insensitive() {
1697 let input = "SID=abc; DOMAIN=ibkr.com; Path=/";
1698 assert_eq!(strip_cookie_domain(input), "SID=abc; Path=/");
1699 }
1700}