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};
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/{topic}/history", get(events_history))
52 .fallback(passthrough_any)
60 .with_state(state)
61}
62
63const HOP_BY_HOP: &[&str] = &[
77 "host",
78 "content-length",
79 "connection",
80 "keep-alive",
81 "proxy-authenticate",
82 "proxy-authorization",
83 "te",
84 "trailer",
85 "transfer-encoding",
86 "upgrade",
87 "cookie",
88 "authorization",
89 "x-forwarded-for",
90 "x-forwarded-host",
91 "x-forwarded-proto",
92 "x-real-ip",
93 "forwarded",
94];
95
96fn is_hop_by_hop(name: &str) -> bool {
97 HOP_BY_HOP.iter().any(|h| name.eq_ignore_ascii_case(h))
98}
99
100#[tracing::instrument(skip_all, fields(method, path))]
104async fn passthrough_any(
105 State(state): State<AppState>,
106 req: axum::extract::Request,
107) -> Result<Response<Body>, AppError> {
108 use axum::http::Method;
109 let method = req.method().clone();
110 let path_and_query = req
111 .uri()
112 .path_and_query()
113 .map(|pq| pq.as_str())
114 .unwrap_or("/")
115 .to_string();
116 let path_only = req.uri().path().to_string();
119 tracing::Span::current().record("method", tracing::field::display(&method));
122 tracing::Span::current().record("path", tracing::field::display(&path_only));
123
124 let gateway_root = state.client().gateway_root_url().as_str();
129 let target = format!("{}{}", gateway_root.trim_end_matches('/'), path_and_query);
130 let target_url: reqwest::Url = target
131 .parse()
132 .map_err(|e| bezant::Error::BadRequest(format!("target url: {e}")))?;
133
134 let headers = req.headers().clone();
135
136 let jar = state.client().cookie_jar();
151 let mut pairs: Vec<&str> = Vec::new();
152 for cookie_header in headers.get_all(axum::http::header::COOKIE) {
153 if let Ok(raw) = cookie_header.to_str() {
154 for pair in raw.split(';') {
155 let trimmed = pair.trim();
156 if trimmed.is_empty() {
157 continue;
158 }
159 let name = trimmed.split('=').next().unwrap_or("").trim();
166 if is_edge_auth_cookie(name) {
167 continue;
168 }
169 pairs.push(trimmed);
170 }
171 }
172 }
173 let injected = pairs.len();
174 if injected > 0 {
175 jar.set_pairs(&pairs);
176 }
177 tracing::debug!(
181 path = %path_only,
182 cookies = injected,
183 "passthrough cookie replay"
184 );
185
186 let body_bytes = axum::body::to_bytes(req.into_body(), 10 * 1024 * 1024)
187 .await
188 .map_err(|e| bezant::Error::BadRequest(format!("read body: {e}")))?;
189
190 let method_reqwest = reqwest::Method::from_bytes(method.as_str().as_bytes())
191 .map_err(|e| bezant::Error::BadRequest(format!("method: {e}")))?;
192 let rewrite_origin = path_only.starts_with("/v1/api/") || path_only == "/v1/api";
204 let gateway_origin = if rewrite_origin {
205 let scheme = target_url.scheme();
206 target_url.host_str().map(|h| match target_url.port() {
207 Some(p) => format!("{scheme}://{h}:{p}"),
208 None => format!("{scheme}://{h}"),
209 })
210 } else {
211 None
212 };
213 let mut builder = state.client().http().request(method_reqwest, &target);
214 for (name, value) in headers.iter() {
215 if is_hop_by_hop(name.as_str()) {
219 continue;
220 }
221 let lower = name.as_str().to_ascii_lowercase();
222 if let Some(ref origin) = gateway_origin {
223 if lower == "origin" {
224 if let Ok(v) = reqwest::header::HeaderValue::from_str(origin) {
225 builder = builder.header(reqwest::header::ORIGIN, v);
226 }
227 continue;
228 }
229 if lower == "referer" {
230 if let Ok(orig) = value.to_str() {
234 let rewritten = rewrite_referer_origin(orig, origin);
235 if let Ok(v) = reqwest::header::HeaderValue::from_str(&rewritten) {
236 builder = builder.header(reqwest::header::REFERER, v);
237 }
238 }
239 continue;
240 }
241 }
242 if let Ok(v) = reqwest::header::HeaderValue::from_bytes(value.as_bytes()) {
243 if let Ok(name) = reqwest::header::HeaderName::from_bytes(name.as_str().as_bytes()) {
244 builder = builder.header(name, v);
245 }
246 }
247 }
248 if method != Method::GET && method != Method::HEAD {
249 let len = body_bytes.len();
254 builder = builder
255 .header(reqwest::header::CONTENT_LENGTH, len.to_string())
256 .body(body_bytes.to_vec());
257 }
258
259 let resp = builder.send().await.map_err(bezant::Error::Http)?;
260
261 if path_only.ends_with("/iserver/auth/ssodh/init") {
271 state.record_sso_bridge(resp.status().as_u16());
272 }
273
274 forward(resp).await
275}
276
277#[derive(Serialize)]
278struct HealthBody {
279 authenticated: bool,
280 connected: bool,
281 competing: bool,
282 message: Option<String>,
283 #[serde(skip_serializing_if = "Option::is_none")]
290 sso_bridge: Option<SsoBridgeBody>,
291}
292
293#[derive(Debug, Serialize)]
303struct SsoStatusBody {
304 observed: bool,
308 #[serde(skip_serializing_if = "Option::is_none")]
309 status: Option<u16>,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 at: Option<u64>,
312 consecutive_faults: u32,
313 wedged: bool,
314}
315
316#[derive(Debug, Serialize)]
317struct SsoBridgeBody {
318 status: u16,
320 at: u64,
322 consecutive_faults: u32,
324 wedged: bool,
326}
327
328async fn debug_jar(
338 State(state): State<AppState>,
339 headers: HeaderMap,
340 Query(q): Query<HashMap<String, String>>,
341) -> Response<Body> {
342 if let Err(resp) = debug_auth(&state, &headers, &q) {
343 return resp;
344 }
345 let jar = state.client().cookie_jar();
346 let entries: Vec<serde_json::Value> = jar
347 .snapshot()
348 .into_iter()
349 .map(|(name, value)| {
350 serde_json::json!({
351 "name": name,
352 "value_length": value.len(),
353 })
354 })
355 .collect();
356 let body = serde_json::json!({
357 "gateway_root": state.client().gateway_root_url().as_str(),
358 "size": entries.len(),
359 "entries": entries,
360 });
361 Json(body).into_response()
362}
363
364#[allow(clippy::result_large_err)]
378fn debug_auth(
381 state: &AppState,
382 headers: &HeaderMap,
383 query: &HashMap<String, String>,
384) -> Result<(), Response<Body>> {
385 let Some(expected) = state.debug_token() else {
386 return Err(Response::builder()
387 .status(StatusCode::NOT_FOUND)
388 .body(Body::empty())
389 .unwrap_or_default());
390 };
391 let presented = headers
392 .get("x-bezant-debug-token")
393 .and_then(|v| v.to_str().ok())
394 .or_else(|| query.get("token").map(String::as_str))
395 .unwrap_or("");
396 if constant_time_eq(presented.as_bytes(), expected.as_bytes()) {
397 return Ok(());
398 }
399 Err(Response::builder()
400 .status(StatusCode::UNAUTHORIZED)
401 .header(header::CONTENT_TYPE, "application/json")
402 .body(Body::from(
403 r#"{"code":"debug_unauthorized","message":"missing or invalid debug token"}"#,
404 ))
405 .unwrap_or_default())
406}
407
408fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
412 if a.len() != b.len() {
413 return false;
414 }
415 let mut diff = 0u8;
416 for (x, y) in a.iter().zip(b.iter()) {
417 diff |= x ^ y;
418 }
419 diff == 0
420}
421
422async fn debug_probe(
451 State(state): State<AppState>,
452 headers: HeaderMap,
453 Query(q): Query<HashMap<String, String>>,
454) -> Response<Body> {
455 if let Err(resp) = debug_auth(&state, &headers, &q) {
456 return resp;
457 }
458 let client = state.client();
459 let started = std::time::Instant::now();
460 let jar_before = client.cookie_jar().snapshot().len();
461
462 let auth_status = probe_step(
463 client,
464 "auth_status",
465 reqwest::Method::POST,
466 &["iserver", "auth", "status"],
467 None,
468 )
469 .await;
470 let already_bridged = is_authenticated(&auth_status);
478 let ssodh_init = if already_bridged {
479 skipped_step(
480 "ssodh_init",
481 "POST",
482 "/v1/api/iserver/auth/ssodh/init",
483 "session already bridged (auth_status authenticated)",
484 )
485 } else {
486 probe_step(
487 client,
488 "ssodh_init",
489 reqwest::Method::POST,
490 &["iserver", "auth", "ssodh", "init"],
491 Some(serde_json::json!({ "publish": true, "compete": true })),
492 )
493 .await
494 };
495 let tickle = probe_step(client, "tickle", reqwest::Method::POST, &["tickle"], None).await;
496 let accounts = probe_step(
497 client,
498 "accounts",
499 reqwest::Method::GET,
500 &["portfolio", "accounts"],
501 None,
502 )
503 .await;
504
505 let verdict = compute_verdict(&auth_status, &ssodh_init, &tickle, &accounts);
506 let jar_after = client.cookie_jar().snapshot().len();
507
508 let body = serde_json::json!({
509 "gateway_root": client.gateway_root_url().as_str(),
510 "elapsed_ms": started.elapsed().as_millis() as u64,
511 "jar_size_before": jar_before,
512 "jar_size_after": jar_after,
513 "verdict": verdict,
514 "steps": [auth_status, ssodh_init, tickle, accounts],
515 });
516 Json(body).into_response()
517}
518
519async fn probe_step(
528 client: &bezant::Client,
529 name: &'static str,
530 method: reqwest::Method,
531 path_segments: &[&str],
532 body: Option<serde_json::Value>,
533) -> serde_json::Value {
534 let mut url = client.base_url().clone();
535 if let Ok(mut segs) = url.path_segments_mut() {
536 for seg in path_segments {
537 segs.push(seg);
538 }
539 }
540 let path_for_log = url.path().to_owned();
541
542 let gateway_origin = client
543 .gateway_root_url()
544 .as_str()
545 .trim_end_matches('/')
546 .to_owned();
547
548 let mut builder = client
549 .http()
550 .request(method.clone(), url.clone())
551 .header(reqwest::header::ORIGIN, &gateway_origin)
552 .header(reqwest::header::REFERER, format!("{gateway_origin}/"));
553
554 let body_bytes: Vec<u8> = match (&method, body) {
555 (m, _) if m == reqwest::Method::GET || m == reqwest::Method::HEAD => Vec::new(),
556 (_, Some(json)) => serde_json::to_vec(&json).unwrap_or_default(),
557 (_, None) => Vec::new(),
558 };
559 if method != reqwest::Method::GET && method != reqwest::Method::HEAD {
560 builder = builder
561 .header(
562 reqwest::header::CONTENT_LENGTH,
563 body_bytes.len().to_string(),
564 )
565 .body(body_bytes.clone());
566 if !body_bytes.is_empty() {
567 builder = builder.header(reqwest::header::CONTENT_TYPE, "application/json");
568 }
569 }
570
571 let started = std::time::Instant::now();
572 let result = match tokio::time::timeout(std::time::Duration::from_secs(5), builder.send()).await
577 {
578 Ok(send_result) => send_result.map_err(|e| e.to_string()),
579 Err(_) => Err("step timed out after 5s".to_string()),
580 };
581 let latency_ms = started.elapsed().as_millis() as u64;
582
583 match result {
584 Ok(resp) => {
585 let status = resp.status().as_u16();
586 let set_cookie_names: Vec<String> = resp
587 .headers()
588 .get_all(reqwest::header::SET_COOKIE)
589 .iter()
590 .filter_map(|v| v.to_str().ok())
591 .map(|raw| {
592 raw.split(';')
593 .next()
594 .and_then(|s| s.split('=').next())
595 .map(|s| s.trim().to_owned())
596 .unwrap_or_default()
597 })
598 .filter(|s| !s.is_empty())
599 .collect();
600 let bytes = read_capped(resp, 1024 * 1024).await.unwrap_or_default();
605 let parsed_authenticated = serde_json::from_slice::<serde_json::Value>(&bytes)
611 .ok()
612 .and_then(|v| v["authenticated"].as_bool());
613 let preview_len = bytes.len().min(512);
619 let raw_preview = String::from_utf8_lossy(&bytes[..preview_len]).into_owned();
620 let body_preview = redact_tokens(&raw_preview);
621 serde_json::json!({
622 "name": name,
623 "method": method.as_str(),
624 "path": path_for_log,
625 "status": status,
626 "latency_ms": latency_ms,
627 "body_bytes": bytes.len(),
628 "body_preview": body_preview,
629 "set_cookie_names": set_cookie_names,
630 "error": serde_json::Value::Null,
631 "_authenticated": parsed_authenticated,
632 })
633 }
634 Err(e) => serde_json::json!({
635 "name": name,
636 "method": method.as_str(),
637 "path": path_for_log,
638 "status": serde_json::Value::Null,
639 "latency_ms": latency_ms,
640 "body_bytes": 0,
641 "body_preview": "",
642 "set_cookie_names": [],
643 "error": e,
644 }),
645 }
646}
647
648fn redact_tokens(preview: &str) -> String {
661 let Ok(mut value) = serde_json::from_str::<serde_json::Value>(preview) else {
662 return preview.to_owned();
666 };
667 redact_in_place(&mut value);
668 value.to_string()
669}
670
671fn redact_in_place(value: &mut serde_json::Value) {
672 match value {
673 serde_json::Value::Object(map) => {
674 for (k, v) in map.iter_mut() {
675 let lower = k.to_ascii_lowercase();
676 if lower == "session"
677 || lower == "ssoconclusion"
678 || lower.contains("token")
679 || lower.contains("secret")
680 {
681 *v = serde_json::Value::String("<redacted>".to_owned());
682 } else {
683 redact_in_place(v);
684 }
685 }
686 }
687 serde_json::Value::Array(arr) => {
688 for v in arr.iter_mut() {
689 redact_in_place(v);
690 }
691 }
692 _ => {}
693 }
694}
695
696fn compute_verdict(
704 auth_status: &serde_json::Value,
705 ssodh_init: &serde_json::Value,
706 tickle: &serde_json::Value,
707 accounts: &serde_json::Value,
708) -> &'static str {
709 if !is_2xx(auth_status) {
710 return "auth_status_failed";
711 }
712 let ssodh_ran_and_failed = !is_skipped(ssodh_init) && !is_2xx(ssodh_init);
719 if ssodh_ran_and_failed {
720 return "ssodh_failed";
721 }
722 if !is_authenticated(auth_status) {
723 return "needs_login";
724 }
725 if !is_2xx_or_skipped(tickle) {
726 return "tickle_failed";
727 }
728 if !is_2xx_or_skipped(accounts) {
729 return "accounts_failed";
730 }
731 "ok"
732}
733
734fn is_skipped(step: &serde_json::Value) -> bool {
735 step["skipped"].as_bool().unwrap_or(false)
736}
737
738fn is_2xx(step: &serde_json::Value) -> bool {
739 matches!(step["status"].as_u64(), Some(200..=299))
740}
741
742fn is_2xx_or_skipped(step: &serde_json::Value) -> bool {
743 is_2xx(step) || step["skipped"].as_bool().unwrap_or(false)
744}
745
746fn is_authenticated(step: &serde_json::Value) -> bool {
752 if !is_2xx(step) {
753 return false;
754 }
755 step["_authenticated"].as_bool().unwrap_or(false)
756}
757
758fn skipped_step(
762 name: &'static str,
763 method: &'static str,
764 path: &'static str,
765 reason: &'static str,
766) -> serde_json::Value {
767 serde_json::json!({
768 "name": name,
769 "method": method,
770 "path": path,
771 "status": serde_json::Value::Null,
772 "latency_ms": 0,
773 "body_bytes": 0,
774 "body_preview": "",
775 "set_cookie_names": [],
776 "error": serde_json::Value::Null,
777 "skipped": true,
778 "skipped_reason": reason,
779 })
780}
781
782const SSO_WEDGED_AFTER: u32 = 3;
786
787#[tracing::instrument(skip_all)]
790async fn health_sso(State(state): State<AppState>) -> Json<SsoStatusBody> {
791 Json(state.sso_bridge().map_or(
792 SsoStatusBody {
793 observed: false,
794 status: None,
795 at: None,
796 consecutive_faults: 0,
797 wedged: false,
798 },
799 |b| SsoStatusBody {
800 observed: true,
801 status: Some(b.status),
802 at: Some(b.at),
803 consecutive_faults: b.consecutive_faults,
804 wedged: b.consecutive_faults >= SSO_WEDGED_AFTER,
805 },
806 ))
807}
808
809#[tracing::instrument(skip_all)]
810async fn health(State(state): State<AppState>) -> Result<Json<HealthBody>, AppError> {
811 let status = state.client().auth_status().await?;
812 Ok(Json(HealthBody {
813 authenticated: status.authenticated,
814 connected: status.connected,
815 competing: status.competing,
816 message: status.message,
817 sso_bridge: state.sso_bridge().map(|b| SsoBridgeBody {
818 status: b.status,
819 at: b.at,
820 consecutive_faults: b.consecutive_faults,
821 wedged: b.consecutive_faults >= SSO_WEDGED_AFTER,
822 }),
823 }))
824}
825
826#[tracing::instrument(skip_all)]
827async fn accounts(State(state): State<AppState>) -> Result<Response<Body>, AppError> {
828 passthrough_get(&state, &["portfolio", "accounts"], &[]).await
829}
830
831#[tracing::instrument(skip(state), fields(account_id = %account_id))]
832async fn account_summary(
833 State(state): State<AppState>,
834 Path(account_id): Path<String>,
835) -> Result<Response<Body>, AppError> {
836 passthrough_get(&state, &["portfolio", account_id.as_str(), "summary"], &[]).await
837}
838
839#[derive(Deserialize, Debug)]
840struct PositionsQuery {
841 #[serde(default)]
842 page: u32,
843}
844
845#[tracing::instrument(skip(state), fields(account_id = %account_id, page = q.page))]
846async fn account_positions(
847 State(state): State<AppState>,
848 Path(account_id): Path<String>,
849 Query(q): Query<PositionsQuery>,
850) -> Result<Response<Body>, AppError> {
851 let page = q.page.to_string();
852 passthrough_get(
853 &state,
854 &["portfolio", account_id.as_str(), "positions", page.as_str()],
855 &[],
856 )
857 .await
858}
859
860#[tracing::instrument(skip(state), fields(account_id = %account_id))]
861async fn account_ledger(
862 State(state): State<AppState>,
863 Path(account_id): Path<String>,
864) -> Result<Response<Body>, AppError> {
865 passthrough_get(&state, &["portfolio", account_id.as_str(), "ledger"], &[]).await
866}
867
868#[tracing::instrument(skip(state), fields(account_id = %account_id))]
870async fn account_orders(
871 State(state): State<AppState>,
872 Path(account_id): Path<String>,
873) -> Result<Response<Body>, AppError> {
874 passthrough_get(
876 &state,
877 &["iserver", "account", "orders"],
878 &[("accountId", account_id.as_str())],
879 )
880 .await
881}
882
883#[tracing::instrument(skip(state, body), fields(account_id = %account_id))]
889async fn submit_order(
890 State(state): State<AppState>,
891 Path(account_id): Path<String>,
892 axum::extract::Json(body): axum::extract::Json<serde_json::Value>,
893) -> Result<Response<Body>, AppError> {
894 let mut url = state.client().base_url().clone();
895 {
896 let mut segs = url
897 .path_segments_mut()
898 .map_err(|()| bezant::Error::UrlNotABase {
899 url: state.client().base_url().to_string(),
900 })?;
901 segs.push("iserver")
902 .push("account")
903 .push(account_id.as_str())
904 .push("orders");
905 }
906 let resp = state
907 .client()
908 .http()
909 .post(url)
910 .json(&body)
911 .send()
912 .await
913 .map_err(bezant::Error::Http)?;
914 forward(resp).await
915}
916
917#[tracing::instrument(skip(state), fields(account_id = %account_id, order_id = %order_id))]
919async fn cancel_order(
920 State(state): State<AppState>,
921 Path((account_id, order_id)): Path<(String, String)>,
922) -> Result<Response<Body>, AppError> {
923 let mut url = state.client().base_url().clone();
924 {
925 let mut segs = url
926 .path_segments_mut()
927 .map_err(|()| bezant::Error::UrlNotABase {
928 url: state.client().base_url().to_string(),
929 })?;
930 segs.push("iserver")
931 .push("account")
932 .push(account_id.as_str())
933 .push("order")
934 .push(order_id.as_str());
935 }
936 let resp = state
937 .client()
938 .http()
939 .delete(url)
940 .send()
941 .await
942 .map_err(bezant::Error::Http)?;
943 forward(resp).await
944}
945
946#[derive(Deserialize)]
947struct ContractSearchQuery {
948 symbol: String,
949 #[serde(default)]
950 name: bool,
951 #[serde(rename = "secType", default = "default_sec_type")]
952 sec_type: String,
953}
954
955fn default_sec_type() -> String {
956 "STK".into()
957}
958
959async fn contract_search(
960 State(state): State<AppState>,
961 Query(q): Query<ContractSearchQuery>,
962) -> Result<Response<Body>, AppError> {
963 let mut url = state.client().base_url().clone();
965 {
966 let mut segs = url
967 .path_segments_mut()
968 .map_err(|()| bezant::Error::UrlNotABase {
969 url: state.client().base_url().to_string(),
970 })?;
971 segs.push("iserver").push("secdef").push("search");
972 }
973 let body = serde_json::json!({
974 "symbol": q.symbol,
975 "name": q.name,
976 "secType": q.sec_type,
977 });
978 let resp = state
979 .client()
980 .http()
981 .post(url)
982 .json(&body)
983 .send()
984 .await
985 .map_err(bezant::Error::Http)?;
986 forward(resp).await
987}
988
989async fn market_snapshot(
990 State(state): State<AppState>,
991 Query(q): Query<HashMap<String, String>>,
992) -> Result<Response<Body>, AppError> {
993 let conids = q
994 .get("conids")
995 .ok_or(bezant::Error::MissingQuery { name: "conids" })?;
996 let fields = q
997 .get("fields")
998 .cloned()
999 .unwrap_or_else(|| "31,84,86,87".into());
1000 passthrough_get(
1001 &state,
1002 &["iserver", "marketdata", "snapshot"],
1003 &[("conids", conids), ("fields", &fields)],
1004 )
1005 .await
1006}
1007
1008async fn passthrough_get(
1011 state: &AppState,
1012 path_segments: &[&str],
1013 query: &[(&str, &str)],
1014) -> Result<Response<Body>, AppError> {
1015 let mut url = state.client().base_url().clone();
1016 {
1017 let mut segs = url
1018 .path_segments_mut()
1019 .map_err(|()| bezant::Error::UrlNotABase {
1020 url: state.client().base_url().to_string(),
1021 })?;
1022 for seg in path_segments {
1023 segs.push(seg);
1024 }
1025 }
1026 if !query.is_empty() {
1027 let mut q = url.query_pairs_mut();
1028 for (k, v) in query {
1029 q.append_pair(k, v);
1030 }
1031 }
1032 let resp = state
1033 .client()
1034 .http()
1035 .get(url)
1036 .send()
1037 .await
1038 .map_err(bezant::Error::Http)?;
1039 forward(resp).await
1040}
1041
1042const MAX_UPSTREAM_BODY_BYTES: usize = 25 * 1024 * 1024;
1075
1076#[tracing::instrument(skip_all, fields(upstream_status = %resp.status()))]
1077async fn forward(resp: reqwest::Response) -> Result<Response<Body>, AppError> {
1078 let status = resp.status();
1079 let headers_src = resp.headers().clone();
1080 let body_must_be_empty =
1081 matches!(status.as_u16(), 100..=199 | 204 | 304) || status.is_redirection();
1082
1083 let bytes: Vec<u8> = match read_capped(resp, MAX_UPSTREAM_BODY_BYTES).await {
1084 Ok(b) => b,
1085 Err(e) if body_must_be_empty => {
1086 tracing::debug!(
1091 %status,
1092 error = %e,
1093 "forward: empty-body fallback on no-body status"
1094 );
1095 Vec::new()
1096 }
1097 Err(e) => {
1098 return Err(bezant::Error::UpstreamStatus {
1099 endpoint: "passthrough",
1100 status: status.as_u16(),
1101 body_preview: Some(e),
1102 }
1103 .into())
1104 }
1105 };
1106
1107 let body_is_empty = bytes.is_empty();
1108
1109 let status = StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY);
1110
1111 let mut headers = HeaderMap::new();
1112 let mut had_content_type = false;
1113 for (name, value) in headers_src.iter() {
1114 let n = name.as_str().to_ascii_lowercase();
1115 if is_hop_by_hop_response(&n) {
1116 continue;
1117 }
1118 if n == "set-cookie" {
1120 let value_bytes: Vec<u8> = match value.to_str() {
1121 Ok(raw) => strip_cookie_domain(raw).into_bytes(),
1122 Err(_) => value.as_bytes().to_vec(),
1123 };
1124 if let (Ok(name), Ok(value)) = (
1125 header::HeaderName::from_bytes(name.as_str().as_bytes()),
1126 HeaderValue::from_bytes(&value_bytes),
1127 ) {
1128 headers.append(name, value);
1129 }
1130 continue;
1131 }
1132 if n == "content-type" {
1136 let raw = value.to_str().unwrap_or("");
1137 let rewrite = !body_is_empty
1138 && !body_must_be_empty
1139 && raw.eq_ignore_ascii_case("application/octet-stream");
1140 let bytes_to_emit: &[u8] = if rewrite {
1141 b"text/html; charset=UTF-8"
1142 } else {
1143 value.as_bytes()
1144 };
1145 if let (Ok(name), Ok(value)) = (
1146 header::HeaderName::from_bytes(name.as_str().as_bytes()),
1147 HeaderValue::from_bytes(bytes_to_emit),
1148 ) {
1149 headers.insert(name, value);
1150 had_content_type = true;
1151 }
1152 continue;
1153 }
1154 if let (Ok(name), Ok(value)) = (
1155 header::HeaderName::from_bytes(name.as_str().as_bytes()),
1156 HeaderValue::from_bytes(value.as_bytes()),
1157 ) {
1158 headers.append(name, value);
1159 }
1160 }
1161
1162 if !had_content_type && !body_is_empty && !body_must_be_empty {
1166 headers.insert(
1167 header::CONTENT_TYPE,
1168 HeaderValue::from_static("text/html; charset=UTF-8"),
1169 );
1170 }
1171
1172 let mut response = Response::builder()
1178 .status(status)
1179 .body(Body::from(bytes))
1180 .map_err(|e| bezant::Error::ResponseBuild(e.to_string()))?;
1181 *response.headers_mut() = headers;
1182 Ok(response)
1183}
1184
1185fn is_hop_by_hop_response(name: &str) -> bool {
1187 matches!(
1188 name,
1189 "content-length"
1190 | "connection"
1191 | "keep-alive"
1192 | "proxy-authenticate"
1193 | "proxy-authorization"
1194 | "te"
1195 | "trailer"
1196 | "transfer-encoding"
1197 | "upgrade"
1198 )
1199}
1200
1201fn rewrite_referer_origin(original: &str, new_origin: &str) -> String {
1206 match url::Url::parse(original) {
1207 Ok(u) => {
1208 let mut path_and_query = u.path().to_owned();
1209 if let Some(q) = u.query() {
1210 path_and_query.push('?');
1211 path_and_query.push_str(q);
1212 }
1213 format!("{}{}", new_origin.trim_end_matches('/'), path_and_query)
1214 }
1215 Err(_) => new_origin.to_owned(),
1216 }
1217}
1218
1219fn is_edge_auth_cookie(name: &str) -> bool {
1236 const BUILTIN_PREFIXES: &[&str] = &[
1237 "CF_Authorization",
1238 "CF_AppSession",
1239 "AWSELBAuthSessionCookie",
1240 "_oauth2_proxy",
1241 "_vercel_jwt",
1242 "_vercel_sso_nonce",
1243 "_pomerium",
1244 ];
1245 if BUILTIN_PREFIXES
1246 .iter()
1247 .any(|p| name.eq_ignore_ascii_case(p) || name.starts_with(p))
1248 {
1249 return true;
1250 }
1251 if let Ok(extra) = std::env::var("BEZANT_EDGE_COOKIE_PREFIXES") {
1253 for prefix in extra.split(',').map(str::trim).filter(|p| !p.is_empty()) {
1254 if name.starts_with(prefix) {
1255 return true;
1256 }
1257 }
1258 }
1259 false
1260}
1261
1262async fn read_capped(resp: reqwest::Response, max: usize) -> std::result::Result<Vec<u8>, String> {
1270 use futures_util::StreamExt;
1271 let mut bytes = Vec::new();
1272 let mut stream = resp.bytes_stream();
1273 while let Some(chunk) = stream.next().await {
1274 let chunk = chunk.map_err(|e| format!("read chunk: {e}"))?;
1275 if bytes.len() + chunk.len() > max {
1276 return Err(format!(
1277 "upstream body exceeded {max} byte cap (>{}B)",
1278 bytes.len() + chunk.len()
1279 ));
1280 }
1281 bytes.extend_from_slice(&chunk);
1282 }
1283 Ok(bytes)
1284}
1285
1286fn strip_cookie_domain(value: &str) -> String {
1291 value
1292 .split(';')
1293 .filter(|part| !part.trim().to_ascii_lowercase().starts_with("domain="))
1294 .collect::<Vec<_>>()
1295 .join(";")
1296}
1297
1298#[derive(Debug, Deserialize)]
1305struct EventsQuery {
1306 #[serde(default)]
1309 since: u64,
1310 #[serde(default = "EventsQuery::default_limit")]
1313 limit: usize,
1314}
1315
1316impl EventsQuery {
1317 fn default_limit() -> usize {
1318 100
1319 }
1320}
1321
1322#[derive(Debug, Serialize)]
1324struct CursorExpiredBody {
1325 code: &'static str,
1326 head_cursor: u64,
1327 reset_epoch: u64,
1328 message: &'static str,
1329}
1330
1331async fn events_orders(
1332 State(state): State<AppState>,
1333 Query(q): Query<EventsQuery>,
1334) -> Response<Body> {
1335 read_events_topic(&state, "orders", q).await
1336}
1337
1338async fn events_pnl(State(state): State<AppState>, Query(q): Query<EventsQuery>) -> Response<Body> {
1339 read_events_topic(&state, "pnl", q).await
1340}
1341
1342async fn events_gap(State(state): State<AppState>, Query(q): Query<EventsQuery>) -> Response<Body> {
1343 read_events_topic(&state, "gap", q).await
1344}
1345
1346#[derive(Debug, Deserialize)]
1347struct MarketDataEventsQuery {
1348 conid: i64,
1349 #[serde(default)]
1350 since: u64,
1351 #[serde(default = "EventsQuery::default_limit")]
1352 limit: usize,
1353}
1354
1355async fn events_marketdata(
1356 State(state): State<AppState>,
1357 Query(q): Query<MarketDataEventsQuery>,
1358) -> Response<Body> {
1359 let Some(handle) = state.events() else {
1360 return events_disabled_response();
1361 };
1362 if let Err(e) = handle.ensure_market_data(q.conid).await {
1364 return (
1365 StatusCode::BAD_GATEWAY,
1366 Json(serde_json::json!({
1367 "code": "events_subscribe_failed",
1368 "message": e,
1369 })),
1370 )
1371 .into_response();
1372 }
1373
1374 let topic = format!("marketdata:{}", q.conid);
1375 let limit = q.limit.min(1_000);
1376 read_events_topic_resolved(handle, &topic, q.since, limit).await
1377}
1378
1379async fn events_status(State(state): State<AppState>) -> Response<Body> {
1380 let Some(handle) = state.events() else {
1381 return events_disabled_response();
1382 };
1383 Json(handle.status().await).into_response()
1384}
1385
1386#[derive(Debug, Deserialize)]
1387struct HistoryQuery {
1388 since_ts: String,
1390 #[serde(default = "HistoryQuery::default_limit")]
1392 limit: usize,
1393}
1394
1395impl HistoryQuery {
1396 fn default_limit() -> usize {
1397 500
1398 }
1399}
1400
1401async fn events_history(
1402 State(state): State<AppState>,
1403 Path(topic): Path<String>,
1404 Query(q): Query<HistoryQuery>,
1405) -> Response<Body> {
1406 let Some(handle) = state.events() else {
1407 return events_disabled_response();
1408 };
1409 let Some(log) = handle.event_log() else {
1410 return (
1411 StatusCode::SERVICE_UNAVAILABLE,
1412 Json(serde_json::json!({
1413 "code": "events_history_disabled",
1414 "message": "events sqlite persistence is not enabled \
1415 (set BEZANT_EVENTS_DB_PATH to turn it on)"
1416 })),
1417 )
1418 .into_response();
1419 };
1420 let limit = q.limit.clamp(1, 5_000);
1421 let log_clone = log.clone();
1422 let topic_clone = topic.clone();
1423 let since_ts = q.since_ts.clone();
1424 let result =
1425 tokio::task::spawn_blocking(move || log_clone.query_since(&topic_clone, &since_ts, limit))
1426 .await;
1427 match result {
1428 Ok(Ok(events)) => {
1429 let body = serde_json::json!({
1430 "topic": topic,
1431 "events": events,
1432 "count": events.len(),
1433 });
1434 (StatusCode::OK, Json(body)).into_response()
1435 }
1436 Ok(Err(e)) => (
1437 StatusCode::INTERNAL_SERVER_ERROR,
1438 Json(serde_json::json!({
1439 "code": "events_history_query_failed",
1440 "message": e.to_string(),
1441 })),
1442 )
1443 .into_response(),
1444 Err(e) => (
1445 StatusCode::INTERNAL_SERVER_ERROR,
1446 Json(serde_json::json!({
1447 "code": "events_history_join_failed",
1448 "message": e.to_string(),
1449 })),
1450 )
1451 .into_response(),
1452 }
1453}
1454
1455async fn read_events_topic(state: &AppState, topic: &str, q: EventsQuery) -> Response<Body> {
1456 let Some(handle) = state.events() else {
1457 return events_disabled_response();
1458 };
1459 read_events_topic_resolved(handle, topic, q.since, q.limit.min(1_000)).await
1460}
1461
1462async fn read_events_topic_resolved(
1463 handle: &crate::events::EventsHandle,
1464 topic: &str,
1465 since: u64,
1466 limit: usize,
1467) -> Response<Body> {
1468 let Some(result) = handle.read_topic(topic, since, limit.max(1)).await else {
1469 let status = handle.status().await;
1473 let body = serde_json::json!({
1474 "events": [],
1475 "next_cursor": since,
1476 "reset_epoch": status.reset_epoch,
1477 });
1478 return (StatusCode::OK, Json(body)).into_response();
1479 };
1480
1481 use crate::events::ReadResult;
1482 match result {
1483 ReadResult::Ok {
1484 events,
1485 next_cursor,
1486 } => {
1487 if events.is_empty() {
1488 let mut response = Response::builder()
1491 .status(StatusCode::NO_CONTENT)
1492 .body(Body::empty())
1493 .unwrap();
1494 let cursor_str = next_cursor.to_string();
1495 if let Ok(v) = HeaderValue::from_str(&cursor_str) {
1496 response.headers_mut().insert("x-bezant-cursor", v);
1497 }
1498 return response;
1499 }
1500 let reset_epoch = events.first().map(|e| e.reset_epoch).unwrap_or_else(|| 0);
1501 let body = serde_json::json!({
1502 "events": events,
1503 "next_cursor": next_cursor,
1504 "reset_epoch": reset_epoch,
1505 });
1506 (StatusCode::OK, Json(body)).into_response()
1507 }
1508 ReadResult::CursorExpired {
1509 head_cursor,
1510 reset_epoch,
1511 } => {
1512 let body = CursorExpiredBody {
1513 code: "cursor_expired",
1514 head_cursor,
1515 reset_epoch,
1516 message: "the requested cursor is older than the oldest buffered event; \
1517 reset to head_cursor and emit a synthetic gap on the consumer side",
1518 };
1519 (StatusCode::PRECONDITION_FAILED, Json(body)).into_response()
1520 }
1521 }
1522}
1523
1524fn events_disabled_response() -> Response<Body> {
1525 (
1526 StatusCode::SERVICE_UNAVAILABLE,
1527 Json(serde_json::json!({
1528 "code": "events_disabled",
1529 "message": "events capture is not enabled on this bezant-server instance \
1530 (set BEZANT_EVENTS_ENABLED=1 to turn it on)"
1531 })),
1532 )
1533 .into_response()
1534}
1535
1536#[cfg(test)]
1537mod redact_tests {
1538 use super::redact_tokens;
1539
1540 #[test]
1541 fn redacts_session_field() {
1542 let input = r#"{"session":"AAAA-real-token","other":1}"#;
1543 let out = redact_tokens(input);
1544 assert!(out.contains("<redacted>"), "got: {out}");
1545 assert!(!out.contains("AAAA-real-token"), "raw token leaked: {out}");
1546 assert!(out.contains("\"other\":1"), "got: {out}");
1548 }
1549
1550 #[test]
1551 fn redacts_token_substring_keys() {
1552 let input = r#"{"accessToken":"x","refresh_token":"y","tokenExpiry":99}"#;
1553 let out = redact_tokens(input);
1554 assert!(!out.contains(r#""x""#), "accessToken leaked: {out}");
1555 assert!(!out.contains(r#""y""#), "refresh_token leaked: {out}");
1556 assert!(!out.contains("99"), "tokenExpiry value leaked: {out}");
1560 }
1561
1562 #[test]
1563 fn passes_non_json_through_verbatim() {
1564 let input = "Not a JSON body, just text.";
1565 let out = redact_tokens(input);
1566 assert_eq!(out, input);
1567 }
1568
1569 #[test]
1570 fn redacts_inside_arrays_and_nested_objects() {
1571 let input = r#"{"sessions":[{"session":"a"},{"session":"b"}]}"#;
1572 let out = redact_tokens(input);
1573 assert!(!out.contains(r#""a""#), "got: {out}");
1574 assert!(!out.contains(r#""b""#), "got: {out}");
1575 }
1576}
1577
1578#[cfg(test)]
1579mod forward_tests {
1580 use super::strip_cookie_domain;
1581
1582 #[test]
1583 fn drops_ibkr_domain_attribute() {
1584 let input = "SID=abc; Domain=.ibkr.com; Path=/; Secure";
1585 assert_eq!(strip_cookie_domain(input), "SID=abc; Path=/; Secure");
1588 }
1589
1590 #[test]
1591 fn leaves_cookie_without_domain_untouched() {
1592 let input = "SID=abc; Path=/; Secure";
1593 assert_eq!(strip_cookie_domain(input), input);
1594 }
1595
1596 #[test]
1597 fn case_insensitive() {
1598 let input = "SID=abc; DOMAIN=ibkr.com; Path=/";
1599 assert_eq!(strip_cookie_domain(input), "SID=abc; Path=/");
1600 }
1601}