1use std::sync::Arc;
7
8mod cors;
9
10use axum::Router;
11use axum::body::to_bytes;
12use axum::http::{StatusCode, header};
13use axum::middleware::Next;
14use axum::response::{IntoResponse, Response};
15use axum::routing::{get, post};
16use futu_auth::{KeyStore, RuntimeCounters};
17use tokio::sync::watch;
18
19use futu_server::router::RequestRouter;
20
21use crate::adapter::RestState;
22use crate::auth::{AuthState, bearer_auth};
23use crate::routes::{admin, qot, sys, trd};
24use crate::ws::{self, WsBroadcaster};
25
26#[derive(Default)]
31pub struct RestAdminHooks {
32 pub admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
33 pub admin_shutdown_handler: Option<crate::adapter::AdminShutdownHandler>,
34 pub admin_reload_handler: Option<crate::adapter::AdminReloadHandler>,
35 pub push_health_snapshot_provider: Option<crate::adapter::PushHealthSnapshotProvider>,
36 pub card_num_resolver: Option<crate::adapter::CardNumResolver>,
37}
38
39pub(crate) const LEGACY_WS_WARN_MESSAGE: &str = "WS endpoint /ws also accepts unauthenticated connections in legacy mode — \
44 same posture as REST mutating-blocked: legacy clients may push to /ws without auth. \
45 Migrate to --rest-keys-file for production. v2 will default-reject.";
46
47async fn metrics_handler(
59 axum::extract::State(state): axum::extract::State<RestState>,
60 headers: axum::http::HeaderMap,
61) -> Response {
62 let env_public = std::env::var_os("FUTU_METRICS_PUBLIC").is_some();
64 if env_public {
65 return render_metrics_body();
66 }
67 if !state.key_store.is_configured() {
69 return render_metrics_body();
70 }
71 let token = headers
73 .get("authorization")
74 .and_then(|v| v.to_str().ok())
75 .and_then(|v| futu_auth_pipeline::parse_bearer_scheme(v).map(|t| t.to_string()));
76 let Some(token) = token else {
77 return (
78 axum::http::StatusCode::UNAUTHORIZED,
79 [(
80 axum::http::header::WWW_AUTHENTICATE,
81 "Bearer realm=\"futu-rest\"",
82 )],
83 axum::Json(serde_json::json!({
84 "error": "missing Authorization: Bearer <api-key>",
85 "hint": "/metrics requires Scope::MetricsRead in scope-mode. \
86 Either set FUTU_METRICS_PUBLIC=1 to revert v1.4.105 \
87 public-no-auth (firewall-controlled deploys), or \
88 `futucli gen-key --id prom --scopes metrics:read`."
89 })),
90 )
91 .into_response();
92 };
93 let Some(rec) = state.key_store.verify(&token) else {
94 return (
95 axum::http::StatusCode::UNAUTHORIZED,
96 axum::Json(serde_json::json!({"error": "invalid api key"})),
97 )
98 .into_response();
99 };
100 let has_metrics = rec.scopes.contains(&futu_auth::Scope::MetricsRead)
103 || rec.scopes.contains(&futu_auth::Scope::Admin);
104 if !has_metrics {
105 return (
107 axum::http::StatusCode::FORBIDDEN,
108 axum::Json(serde_json::json!({"error": "forbidden"})),
109 )
110 .into_response();
111 }
112 render_metrics_body()
113}
114
115fn render_metrics_body() -> Response {
118 let body = futu_auth::metrics::global()
119 .map(|r| r.render_prometheus())
120 .unwrap_or_else(|| {
121 concat!(
122 "# HELP futu_metrics_registry_installed Whether futu_auth metrics registry is installed (1=yes, 0=no)\n",
123 "# TYPE futu_metrics_registry_installed gauge\n",
124 "futu_metrics_registry_installed{state=\"metrics registry not installed\"} 0\n"
125 )
126 .to_string()
127 });
128 (
129 axum::http::StatusCode::OK,
130 [(
131 axum::http::header::CONTENT_TYPE,
132 "text/plain; version=0.0.4",
133 )],
134 body,
135 )
136 .into_response()
137}
138
139async fn health_handler() -> impl IntoResponse {
145 (axum::http::StatusCode::OK, "ok")
146}
147
148async fn readyz_handler(
159 axum::extract::State(state): axum::extract::State<RestState>,
160) -> impl IntoResponse {
161 use bytes::Bytes;
162 use futu_codec::header::ProtoFmtType;
163 use futu_core::proto_id;
164 use futu_proto::get_global_state;
165 use futu_server::conn::IncomingRequest;
166 use prost::Message;
167
168 let req = get_global_state::Request {
169 c2s: get_global_state::C2s { user_id: 0 },
170 };
171 let incoming = IncomingRequest::builder(
172 state.next_conn_id(),
173 proto_id::GET_GLOBAL_STATE,
174 state.next_serial(),
175 ProtoFmtType::Protobuf,
176 Bytes::from(req.encode_to_vec()),
177 )
178 .build();
179 let Some(resp_bytes) = state.router.dispatch(incoming.conn_id, &incoming).await else {
180 return (
181 axum::http::StatusCode::SERVICE_UNAVAILABLE,
182 "gateway dispatch not ready",
183 );
184 };
185 let Ok(resp) = get_global_state::Response::decode(Bytes::from(resp_bytes)) else {
186 return (
187 axum::http::StatusCode::SERVICE_UNAVAILABLE,
188 "gateway response decode failed",
189 );
190 };
191 if resp.ret_type != 0 {
192 return (
193 axum::http::StatusCode::SERVICE_UNAVAILABLE,
194 "gateway not ready",
195 );
196 }
197 (axum::http::StatusCode::OK, "ready")
198}
199
200#[cfg(test)]
206pub(crate) fn build_legacy_readonly_router(
207 router: Arc<RequestRouter>,
208 ws_broadcaster: Arc<WsBroadcaster>,
209) -> Router {
210 build_router_with_auth(
211 router,
212 ws_broadcaster,
213 Arc::new(KeyStore::empty()),
214 Arc::new(RuntimeCounters::new()),
215 )
216}
217
218pub fn build_router_with_auth(
225 router: Arc<RequestRouter>,
226 ws_broadcaster: Arc<WsBroadcaster>,
227 key_store: Arc<KeyStore>,
228 counters: Arc<RuntimeCounters>,
229) -> Router {
230 build_router_with_auth_and_admin(router, ws_broadcaster, key_store, counters, None)
231}
232
233pub fn build_router_with_auth_and_admin(
240 router: Arc<RequestRouter>,
241 ws_broadcaster: Arc<WsBroadcaster>,
242 key_store: Arc<KeyStore>,
243 counters: Arc<RuntimeCounters>,
244 admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
245) -> Router {
246 build_router_with_auth_full_admin(
247 router,
248 ws_broadcaster,
249 key_store,
250 counters,
251 RestAdminHooks {
252 admin_status_provider,
253 ..RestAdminHooks::default()
254 },
255 )
256}
257
258pub fn build_router_with_auth_full_admin(
263 router: Arc<RequestRouter>,
264 ws_broadcaster: Arc<WsBroadcaster>,
265 key_store: Arc<KeyStore>,
266 counters: Arc<RuntimeCounters>,
267 hooks: RestAdminHooks,
268) -> Router {
269 let RestAdminHooks {
270 admin_status_provider,
271 admin_shutdown_handler,
272 admin_reload_handler,
273 push_health_snapshot_provider,
274 card_num_resolver,
275 } = hooks;
276 let mut state = RestState::with_auth(
277 router,
278 ws_broadcaster,
279 Arc::clone(&key_store),
280 Arc::clone(&counters),
281 );
282 if let Some(p) = admin_status_provider {
283 state = state.with_admin_status_provider(p);
284 }
285 if let Some(h) = admin_shutdown_handler {
286 state = state.with_admin_shutdown_handler(h);
287 }
288 if let Some(h) = admin_reload_handler {
289 state = state.with_admin_reload_handler(h);
290 }
291 if let Some(p) = push_health_snapshot_provider {
292 state = state.with_push_health_snapshot_provider(p);
293 }
294 if let Some(r) = card_num_resolver {
295 state = state.with_card_num_resolver(r);
296 }
297 let auth_state = AuthState::new(Arc::clone(&key_store), Arc::clone(&counters));
298
299 let cors = cors::build_cors_layer(&key_store);
300
301 Router::new()
302 .route("/ws", get(ws::ws_handler))
304 .route("/api/global-state", get(sys::get_global_state))
306 .route("/api/user-info", get(sys::get_user_info))
307 .route("/api/quote-rights", get(sys::get_quote_rights))
308 .route(
309 "/api/delay-statistics",
310 get(sys::get_delay_statistics).post(sys::get_delay_statistics_post),
311 )
312 .route("/api/ping", get(sys::ping))
314 .route("/api/push-subscriber-info", get(sys::push_subscriber_info))
315 .route("/api/unsub-acc-push", post(sys::unsub_acc_push))
316 .route(
318 "/api/token-state",
319 get(sys::get_token_state).post(sys::get_token_state),
320 )
321 .route("/api/subscribe", post(qot::subscribe))
323 .route("/api/sub-info", get(qot::get_sub_info))
324 .route("/api/query-subscription", post(qot::query_subscription))
326 .route("/api/list-plates", post(qot::list_plates))
328 .route("/api/quote", post(qot::get_basic_qot))
329 .route("/api/kline", post(qot::get_kl))
330 .route("/api/orderbook", post(qot::get_order_book))
331 .route("/api/broker", post(qot::get_broker))
332 .route("/api/ticker", post(qot::get_ticker))
333 .route("/api/rt", post(qot::get_rt))
334 .route("/api/snapshot", post(qot::get_snapshot))
335 .route("/api/static-info", post(qot::get_static_info))
336 .route("/api/plate-set", post(qot::get_plate_set))
337 .route("/api/plate-security", post(qot::get_plate_security))
338 .route("/api/reference", post(qot::get_reference))
339 .route("/api/get-reference", post(qot::get_reference))
341 .route("/api/owner-plate", post(qot::get_owner_plate))
342 .route("/api/option-chain", post(qot::get_option_chain))
343 .route("/api/warrant", post(qot::get_warrant))
344 .route("/api/capital-flow", post(qot::get_capital_flow))
345 .route(
346 "/api/capital-distribution",
347 post(qot::get_capital_distribution),
348 )
349 .route("/api/company-profile", post(qot::get_company_profile))
350 .route("/api/company-executives", post(qot::get_company_executives))
351 .route(
352 "/api/company-executive-background",
353 post(qot::get_company_executive_background),
354 )
355 .route(
356 "/api/company-operational-efficiency",
357 post(qot::get_company_operational_efficiency),
358 )
359 .route(
360 "/api/financials-earnings-price-move",
361 post(qot::get_financials_earnings_price_move),
362 )
363 .route(
364 "/api/financials-earnings-price-history",
365 post(qot::get_financials_earnings_price_history),
366 )
367 .route(
368 "/api/financials-statements",
369 post(qot::get_financials_statements),
370 )
371 .route(
372 "/api/financials-revenue-breakdown",
373 post(qot::get_financials_revenue_breakdown),
374 )
375 .route(
376 "/api/research-analyst-consensus",
377 post(qot::get_research_analyst_consensus),
378 )
379 .route(
380 "/api/research-rating-summary",
381 post(qot::get_research_rating_summary),
382 )
383 .route(
384 "/api/research-morningstar-report",
385 post(qot::get_research_morningstar_report),
386 )
387 .route("/api/valuation-detail", post(qot::get_valuation_detail))
388 .route(
389 "/api/valuation-plate-stock-list",
390 post(qot::get_valuation_plate_stock_list),
391 )
392 .route(
393 "/api/corporate-actions-buybacks",
394 post(qot::get_corporate_actions_buybacks),
395 )
396 .route(
397 "/api/corporate-actions-dividends",
398 post(qot::get_corporate_actions_dividends),
399 )
400 .route(
401 "/api/corporate-actions-stock-splits",
402 post(qot::get_corporate_actions_stock_splits),
403 )
404 .route("/api/daily-short-volume", post(qot::get_daily_short_volume))
405 .route("/api/short-interest", post(qot::get_short_interest))
406 .route(
407 "/api/top-ten-buy-sell-brokers",
408 post(qot::get_top_ten_buy_sell_brokers),
409 )
410 .route(
411 "/api/shareholders-overview",
412 post(qot::get_shareholders_overview),
413 )
414 .route(
415 "/api/shareholders-holding-changes",
416 post(qot::get_shareholders_holding_changes),
417 )
418 .route(
419 "/api/shareholders-holder-detail",
420 post(qot::get_shareholders_holder_detail),
421 )
422 .route(
423 "/api/shareholders-institutional",
424 post(qot::get_shareholders_institutional),
425 )
426 .route(
427 "/api/insider-holder-list",
428 post(qot::get_insider_holder_list),
429 )
430 .route("/api/insider-trade-list", post(qot::get_insider_trade_list))
431 .route("/api/option-volatility", post(qot::get_option_volatility))
432 .route(
433 "/api/option-exercise-probability",
434 post(qot::get_option_exercise_probability),
435 )
436 .route("/api/option-quote", post(qot::get_option_quote))
437 .route("/api/option-strategy", post(qot::get_option_strategy))
438 .route(
439 "/api/option-strategy-analysis",
440 post(qot::get_option_strategy_analysis),
441 )
442 .route(
443 "/api/option-strategy-spread",
444 post(qot::get_option_strategy_spread),
445 )
446 .route("/api/stock-screen", post(qot::stock_screen))
447 .route("/api/option-screen", post(qot::option_screen))
448 .route("/api/warrant-screen", post(qot::warrant_screen))
449 .route("/api/technical-unusual", post(qot::get_technical_unusual))
450 .route("/api/financial-unusual", post(qot::get_financial_unusual))
451 .route("/api/derivative-unusual", post(qot::get_derivative_unusual))
452 .route("/api/user-security", post(qot::get_user_security))
453 .route("/api/stock-filter", post(qot::stock_filter))
454 .route("/api/ipo-list", post(qot::get_ipo_list))
455 .route("/api/future-info", post(qot::get_future_info))
456 .route("/api/market-state", post(qot::get_market_state))
457 .route("/api/history-kline", post(qot::request_history_kl))
458 .route("/api/trading-days", post(qot::request_trading_days))
460 .route("/api/rehab", post(qot::request_rehab))
461 .route("/api/suspend", post(qot::get_suspend))
462 .route("/api/history-kl-quota", post(qot::request_history_kl_quota))
464 .route("/api/used-quota", post(qot::get_used_quota))
465 .route("/api/holding-change", post(qot::get_holding_change))
466 .route("/api/modify-user-security", post(qot::modify_user_security))
467 .route("/api/code-change", post(qot::get_code_change))
468 .route("/api/set-price-reminder", post(qot::set_price_reminder))
469 .route("/api/price-reminder", post(qot::get_price_reminder))
470 .route(
471 "/api/option-expiration-date",
472 post(qot::get_option_expiration_date),
473 )
474 .route("/api/unsubscribe", post(qot::unsubscribe))
475 .route(
477 "/api/risk-free-rate",
478 get(qot::get_risk_free_rate).post(qot::get_risk_free_rate),
479 )
480 .route(
482 "/api/spread-table",
483 get(qot::get_spread_table).post(qot::get_spread_table),
484 )
485 .route("/api/ticker-statistic", post(qot::get_ticker_statistic))
487 .route(
489 "/api/ticker-statistic-detail",
490 post(qot::get_ticker_statistic_detail),
491 )
492 .route("/api/flow-summary", post(trd::get_flow_summary))
493 .route("/api/acc-cash-flow", post(trd::get_flow_summary))
497 .route("/api/cash-log", post(trd::get_cash_log))
502 .route("/api/cash-detail", post(trd::get_cash_detail))
503 .route("/api/biz-group", post(trd::get_biz_group))
504 .route("/api/margin-info", post(trd::get_margin_info))
510 .route("/api/account-flag", post(trd::get_account_flag))
514 .route("/api/bond-total-asset", post(trd::get_bond_total_asset))
519 .route("/api/bond-single-asset", post(trd::get_bond_single_asset))
520 .route("/api/bond-position-list", post(trd::get_bond_position_list))
521 .route("/api/bond-answer-state", post(trd::get_bond_answer_state))
522 .route(
523 "/api/bond-trade-reminder",
524 post(trd::get_bond_trade_reminder),
525 )
526 .route("/api/accounts", get(trd::get_acc_list))
528 .route("/api/list-accounts", get(trd::get_acc_list))
530 .route("/api/unlock-trade", post(trd::unlock_trade))
531 .route("/api/sub-acc-push", post(trd::sub_acc_push))
532 .route("/api/funds", post(trd::get_funds))
533 .route("/api/positions", post(trd::get_positions))
534 .route("/api/orders", post(trd::get_orders))
535 .route("/api/order", post(trd::place_order))
536 .route("/api/combo-order", post(trd::place_combo_order))
537 .route("/api/modify-order", post(trd::modify_order))
538 .route("/api/cancel-all-order", post(trd::cancel_all_order))
540 .route("/api/order-fills", post(trd::get_order_fills))
541 .route("/api/max-trd-qtys", post(trd::get_max_trd_qtys))
542 .route("/api/combo-max-trd-qtys", post(trd::get_combo_max_trd_qtys))
543 .route("/api/reconfirm-order", post(trd::reconfirm_order))
545 .route("/api/history-orders", post(trd::get_history_orders))
546 .route(
547 "/api/history-order-fills",
548 post(trd::get_history_order_fills),
549 )
550 .route("/api/margin-ratio", post(trd::get_margin_ratio))
551 .route("/api/order-fee", post(trd::get_order_fee))
552 .route("/api/admin/status", get(admin::admin_status))
571 .route("/api/admin/shutdown", post(admin::admin_shutdown))
572 .route("/api/admin/reload", post(admin::admin_reload))
573 .layer(axum::middleware::from_fn(
579 crate::strict_fields::strict_field_validation_middleware,
580 ))
581 .layer(axum::middleware::from_fn_with_state(
582 auth_state,
583 bearer_auth,
584 ))
585 .layer(axum::middleware::from_fn(rest_error_envelope_middleware))
586 .route("/metrics", get(metrics_handler))
594 .route("/health", get(health_handler))
595 .route("/readyz", get(readyz_handler))
596 .fallback(unknown_route_fallback)
604 .layer(cors)
605 .with_state(state)
606}
607
608async fn unknown_route_fallback(req: axum::extract::Request) -> impl IntoResponse {
611 let path = req.uri().path().to_string();
612 let method = req.method().to_string();
613 (
614 axum::http::StatusCode::NOT_FOUND,
615 [(axum::http::header::CONTENT_TYPE, "application/json")],
616 axum::Json(serde_json::json!({
617 "error": format!("unknown route {method} {path:?}"),
618 "hint": "see categories below or full reference at https://www.futuapi.com/reference/rest-api/",
619 "categories": {
620 "qot (行情)": "/api/quote /api/snapshot /api/kline /api/orderbook /api/ticker /api/option-chain /api/option-quote /api/option-strategy /api/option-strategy-analysis /api/option-strategy-spread /api/history-kline /api/static-info /api/subscribe /api/sub-info /api/market-state /api/capital-flow /api/option-expiration-date /api/warrant /api/ipo-list",
621 "trd (交易, scope=acc:read or trade:*)": "/api/accounts /api/funds /api/positions /api/orders /api/order-fills /api/history-orders /api/history-order-fills /api/max-trd-qtys /api/combo-max-trd-qtys /api/margin-ratio /api/order-fee /api/sub-acc-push /api/flow-summary /api/order /api/combo-order /api/modify-order /api/cancel-all-order /api/unlock-trade /api/reconfirm-order",
622 "tier-m (mobile-driven, v1.4.94+)": "/api/cash-log /api/cash-detail /api/biz-group /api/margin-info /api/account-flag /api/bond-total-asset /api/bond-single-asset /api/bond-position-list /api/bond-answer-state /api/bond-trade-reminder",
623 "sys": "/api/global-state /api/user-info /api/quote-rights /api/delay-statistics /api/ping /api/push-subscriber-info /api/admin/status (admin scope)",
624 "infra": "/health (liveness) /readyz (readiness) /metrics (Prometheus) /ws (WebSocket push)"
625 },
626 "method_hint": "most endpoints are POST with JSON body; /api/accounts /api/list-accounts /api/health /api/global-state are GET. Check the doc URL for exact verb."
627 })),
628 )
629}
630
631async fn rest_error_envelope_middleware(req: axum::extract::Request, next: Next) -> Response {
637 let resp = next.run(req).await;
638 let status = resp.status();
639 if !matches!(
640 status,
641 StatusCode::BAD_REQUEST | StatusCode::UNSUPPORTED_MEDIA_TYPE
642 ) {
643 return resp;
644 }
645 let content_type = resp
646 .headers()
647 .get(header::CONTENT_TYPE)
648 .and_then(|v| v.to_str().ok())
649 .unwrap_or("");
650 if !content_type.starts_with("text/plain") {
651 return resp;
652 }
653
654 let bytes = match to_bytes(resp.into_body(), 64 * 1024).await {
655 Ok(bytes) => bytes,
656 Err(err) => {
657 let msg =
658 format!("REST request body parse error: failed to read rejection body: {err}");
659 return (
660 status,
661 axum::Json(serde_json::json!({
662 "ret_type": -1,
663 "ret_msg": msg,
664 "error": msg,
665 })),
666 )
667 .into_response();
668 }
669 };
670 let raw = String::from_utf8_lossy(&bytes);
671 let msg = format!("REST request body parse error: {}", raw.trim());
672 (
673 status,
674 axum::Json(serde_json::json!({
675 "ret_type": -1,
676 "ret_msg": msg,
677 "error": msg,
678 })),
679 )
680 .into_response()
681}
682
683pub async fn start_with_auth(
686 listen_addr: &str,
687 router: Arc<RequestRouter>,
688 ws_broadcaster: Arc<WsBroadcaster>,
689 key_store: Arc<KeyStore>,
690 counters: Arc<RuntimeCounters>,
691) -> std::io::Result<()> {
692 start_with_auth_and_admin(
693 listen_addr,
694 router,
695 ws_broadcaster,
696 key_store,
697 counters,
698 None,
699 )
700 .await
701}
702
703pub async fn start_with_auth_and_admin(
708 listen_addr: &str,
709 router: Arc<RequestRouter>,
710 ws_broadcaster: Arc<WsBroadcaster>,
711 key_store: Arc<KeyStore>,
712 counters: Arc<RuntimeCounters>,
713 admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
714) -> std::io::Result<()> {
715 start_with_auth_full_admin(
716 listen_addr,
717 router,
718 ws_broadcaster,
719 key_store,
720 counters,
721 RestAdminHooks {
722 admin_status_provider,
723 ..RestAdminHooks::default()
724 },
725 )
726 .await
727}
728
729pub async fn start_with_auth_full_admin(
734 listen_addr: &str,
735 router: Arc<RequestRouter>,
736 ws_broadcaster: Arc<WsBroadcaster>,
737 key_store: Arc<KeyStore>,
738 counters: Arc<RuntimeCounters>,
739 hooks: RestAdminHooks,
740) -> std::io::Result<()> {
741 let scope_mode = key_store.is_configured();
742 let app = build_router_with_auth_full_admin(router, ws_broadcaster, key_store, counters, hooks);
743 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
744 tracing::info!(
745 addr = %listen_addr,
746 scope_mode,
747 "REST API 服务已启动 (WebSocket: /ws)"
748 );
749 if !scope_mode {
750 warn_legacy_mode(listen_addr);
751 }
752 axum::serve(
753 listener,
754 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
755 )
756 .await
757}
758
759pub async fn start_with_auth_full_admin_until_shutdown(
761 listen_addr: &str,
762 router: Arc<RequestRouter>,
763 ws_broadcaster: Arc<WsBroadcaster>,
764 key_store: Arc<KeyStore>,
765 counters: Arc<RuntimeCounters>,
766 hooks: RestAdminHooks,
767 shutdown_rx: watch::Receiver<bool>,
768) -> std::io::Result<()> {
769 let scope_mode = key_store.is_configured();
770 let app = build_router_with_auth_full_admin(router, ws_broadcaster, key_store, counters, hooks);
771 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
772 tracing::info!(
773 addr = %listen_addr,
774 scope_mode,
775 "REST API 服务已启动 (WebSocket: /ws)"
776 );
777 if !scope_mode {
778 warn_legacy_mode(listen_addr);
779 }
780 axum::serve(
781 listener,
782 app.into_make_service_with_connect_info::<std::net::SocketAddr>(),
783 )
784 .with_graceful_shutdown(rest_shutdown_requested(shutdown_rx))
785 .await
786}
787
788fn warn_legacy_mode(listen_addr: &str) {
789 tracing::warn!("REST API in legacy mode (no keys.json); mutating endpoints are blocked");
790 tracing::warn!("{}", LEGACY_WS_WARN_MESSAGE);
796 tracing::warn!(
800 listen_addr = %listen_addr,
801 readonly_endpoints = "qot/account/order-read",
802 blocked_mutating_endpoints = "/api/order,/api/modify-order,/api/cancel-all-order,/api/unlock-trade,/api/reconfirm-order,/api/admin/*",
803 ws_legacy_unauthenticated = true,
804 migration = "futucli gen-key --id my-key --scopes qot:read,acc:read,trade:real; restart with --rest-keys-file /path/to/keys.json",
805 "REST API legacy mode: no keys.json configured; read endpoints remain unauthenticated, mutating/admin endpoints return 401, /ws still accepts unauthenticated connections for compatibility and v2 will default-reject"
806 );
807}
808
809async fn rest_shutdown_requested(mut shutdown_rx: watch::Receiver<bool>) {
810 loop {
811 if *shutdown_rx.borrow() {
812 tracing::info!("REST API server stopped by shutdown signal");
813 return;
814 }
815 if shutdown_rx.changed().await.is_err() {
816 tracing::info!("REST API server stopped after shutdown sender dropped");
817 return;
818 }
819 }
820}
821
822#[cfg(test)]
823mod tests;