1use std::sync::Arc;
7
8use axum::Router;
9use axum::body::to_bytes;
10use axum::http::request::Parts;
11use axum::http::{StatusCode, header};
12use axum::middleware::Next;
13use axum::response::{IntoResponse, Response};
14use axum::routing::{get, post};
15use futu_auth::{KeyStore, RuntimeCounters};
16use tower_http::cors::{Any, CorsLayer};
17
18fn is_loopback_origin(origin: &axum::http::HeaderValue, _request_parts: &Parts) -> bool {
32 let Ok(s) = origin.to_str() else {
33 return false;
34 };
35 is_loopback_origin_str(s)
36}
37
38pub(crate) fn is_loopback_origin_str(s: &str) -> bool {
46 let after_scheme = match s
47 .strip_prefix("http://")
48 .or_else(|| s.strip_prefix("https://"))
49 {
50 Some(rest) => rest,
51 None => return false,
52 };
53 if after_scheme.contains('/')
55 || after_scheme.contains('?')
56 || after_scheme.contains('#')
57 || after_scheme.contains('@')
58 {
59 return false;
60 }
61 let (host, port_opt): (&str, Option<&str>) =
67 if let Some(rest) = after_scheme.strip_prefix("[::1]") {
68 if rest.is_empty() {
69 ("[::1]", None)
70 } else if let Some(p) = rest.strip_prefix(':') {
71 ("[::1]", Some(p))
72 } else {
73 return false;
74 }
75 } else if let Some((h, p)) = after_scheme.rsplit_once(':') {
76 (h, Some(p))
77 } else {
78 (after_scheme, None)
79 };
80 if !matches!(host, "127.0.0.1" | "localhost" | "[::1]") {
81 return false;
82 }
83 if let Some(port_str) = port_opt {
88 match port_str.parse::<u16>() {
89 Ok(p) if p >= 1 => {} _ => return false, }
92 }
93 true
94}
95
96use futu_server::router::RequestRouter;
97
98use crate::adapter::RestState;
99use crate::auth::{AuthState, bearer_auth};
100use crate::routes::{admin, qot, sys, trd};
101use crate::ws::{self, WsBroadcaster};
102
103#[derive(Default)]
108pub struct RestAdminHooks {
109 pub admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
110 pub admin_reload_handler: Option<crate::adapter::AdminReloadHandler>,
111 pub push_health_snapshot_provider: Option<crate::adapter::PushHealthSnapshotProvider>,
112 pub card_num_resolver: Option<crate::adapter::CardNumResolver>,
113}
114
115pub(crate) const LEGACY_WS_WARN_MESSAGE: &str = "WS endpoint /ws also accepts unauthenticated connections in legacy mode — \
120 same posture as REST mutating-blocked: legacy clients may push to /ws without auth. \
121 Migrate to --rest-keys-file for production. v2 will default-reject.";
122
123async fn metrics_handler(
135 axum::extract::State(state): axum::extract::State<RestState>,
136 headers: axum::http::HeaderMap,
137) -> Response {
138 let env_public = std::env::var_os("FUTU_METRICS_PUBLIC").is_some();
140 if env_public {
141 return render_metrics_body();
142 }
143 if !state.key_store.is_configured() {
145 return render_metrics_body();
146 }
147 let token = headers
149 .get("authorization")
150 .and_then(|v| v.to_str().ok())
151 .and_then(|v| futu_auth_pipeline::parse_bearer_scheme(v).map(|t| t.to_string()));
152 let Some(token) = token else {
153 return (
154 axum::http::StatusCode::UNAUTHORIZED,
155 [(
156 axum::http::header::WWW_AUTHENTICATE,
157 "Bearer realm=\"futu-rest\"",
158 )],
159 axum::Json(serde_json::json!({
160 "error": "missing Authorization: Bearer <api-key>",
161 "hint": "/metrics requires Scope::MetricsRead in scope-mode. \
162 Either set FUTU_METRICS_PUBLIC=1 to revert v1.4.105 \
163 public-no-auth (firewall-controlled deploys), or \
164 `futucli gen-key --id prom --scopes metrics:read`."
165 })),
166 )
167 .into_response();
168 };
169 let Some(rec) = state.key_store.verify(&token) else {
170 return (
171 axum::http::StatusCode::UNAUTHORIZED,
172 axum::Json(serde_json::json!({"error": "invalid api key"})),
173 )
174 .into_response();
175 };
176 let has_metrics = rec.scopes.contains(&futu_auth::Scope::MetricsRead)
179 || rec.scopes.contains(&futu_auth::Scope::Admin);
180 if !has_metrics {
181 return (
183 axum::http::StatusCode::FORBIDDEN,
184 axum::Json(serde_json::json!({"error": "forbidden"})),
185 )
186 .into_response();
187 }
188 render_metrics_body()
189}
190
191fn render_metrics_body() -> Response {
194 let body = futu_auth::metrics::global()
195 .map(|r| r.render_prometheus())
196 .unwrap_or_else(|| {
197 concat!(
198 "# HELP futu_metrics_registry_installed Whether futu_auth metrics registry is installed (1=yes, 0=no)\n",
199 "# TYPE futu_metrics_registry_installed gauge\n",
200 "futu_metrics_registry_installed{state=\"metrics registry not installed\"} 0\n"
201 )
202 .to_string()
203 });
204 (
205 axum::http::StatusCode::OK,
206 [(
207 axum::http::header::CONTENT_TYPE,
208 "text/plain; version=0.0.4",
209 )],
210 body,
211 )
212 .into_response()
213}
214
215async fn health_handler() -> impl IntoResponse {
221 (axum::http::StatusCode::OK, "ok")
222}
223
224async fn readyz_handler(
235 axum::extract::State(state): axum::extract::State<RestState>,
236) -> impl IntoResponse {
237 use bytes::Bytes;
238 use futu_codec::header::ProtoFmtType;
239 use futu_core::proto_id;
240 use futu_proto::get_global_state;
241 use futu_server::conn::IncomingRequest;
242 use prost::Message;
243
244 let req = get_global_state::Request {
245 c2s: get_global_state::C2s { user_id: 0 },
246 };
247 let incoming = IncomingRequest::builder(
248 state.next_conn_id(),
249 proto_id::GET_GLOBAL_STATE,
250 state.next_serial(),
251 ProtoFmtType::Protobuf,
252 Bytes::from(req.encode_to_vec()),
253 )
254 .build();
255 let Some(resp_bytes) = state.router.dispatch(incoming.conn_id, &incoming).await else {
256 return (
257 axum::http::StatusCode::SERVICE_UNAVAILABLE,
258 "gateway dispatch not ready",
259 );
260 };
261 let Ok(resp) = get_global_state::Response::decode(Bytes::from(resp_bytes)) else {
262 return (
263 axum::http::StatusCode::SERVICE_UNAVAILABLE,
264 "gateway response decode failed",
265 );
266 };
267 if resp.ret_type != 0 {
268 return (
269 axum::http::StatusCode::SERVICE_UNAVAILABLE,
270 "gateway not ready",
271 );
272 }
273 (axum::http::StatusCode::OK, "ready")
274}
275
276pub fn build_router(router: Arc<RequestRouter>, ws_broadcaster: Arc<WsBroadcaster>) -> Router {
278 build_router_with_auth(
279 router,
280 ws_broadcaster,
281 Arc::new(KeyStore::empty()),
282 Arc::new(RuntimeCounters::new()),
283 )
284}
285
286pub fn build_router_with_auth(
292 router: Arc<RequestRouter>,
293 ws_broadcaster: Arc<WsBroadcaster>,
294 key_store: Arc<KeyStore>,
295 counters: Arc<RuntimeCounters>,
296) -> Router {
297 build_router_with_auth_and_admin(router, ws_broadcaster, key_store, counters, None)
298}
299
300pub fn build_router_with_auth_and_admin(
304 router: Arc<RequestRouter>,
305 ws_broadcaster: Arc<WsBroadcaster>,
306 key_store: Arc<KeyStore>,
307 counters: Arc<RuntimeCounters>,
308 admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
309) -> Router {
310 build_router_with_auth_full_admin(
311 router,
312 ws_broadcaster,
313 key_store,
314 counters,
315 RestAdminHooks {
316 admin_status_provider,
317 ..RestAdminHooks::default()
318 },
319 )
320}
321
322pub fn build_router_with_auth_full_admin(
327 router: Arc<RequestRouter>,
328 ws_broadcaster: Arc<WsBroadcaster>,
329 key_store: Arc<KeyStore>,
330 counters: Arc<RuntimeCounters>,
331 hooks: RestAdminHooks,
332) -> Router {
333 let RestAdminHooks {
334 admin_status_provider,
335 admin_reload_handler,
336 push_health_snapshot_provider,
337 card_num_resolver,
338 } = hooks;
339 let mut state = RestState::with_auth(
340 router,
341 ws_broadcaster,
342 Arc::clone(&key_store),
343 Arc::clone(&counters),
344 );
345 if let Some(p) = admin_status_provider {
346 state = state.with_admin_status_provider(p);
347 }
348 if let Some(h) = admin_reload_handler {
349 state = state.with_admin_reload_handler(h);
350 }
351 if let Some(p) = push_health_snapshot_provider {
352 state = state.with_push_health_snapshot_provider(p);
353 }
354 if let Some(r) = card_num_resolver {
355 state = state.with_card_num_resolver(r);
356 }
357 let auth_state = AuthState::new(Arc::clone(&key_store), Arc::clone(&counters));
358
359 use axum::http::HeaderValue;
372 use tower_http::cors::AllowOrigin;
373 let cors_allow_origin: AllowOrigin = match std::env::var("FUTU_REST_ALLOWED_ORIGINS") {
374 Ok(raw) if !raw.trim().is_empty() => {
375 let raw_trimmed = raw.trim();
384 if matches!(
385 raw_trimmed.to_ascii_lowercase().as_str(),
386 "*" | "any" | "all"
387 ) {
388 tracing::warn!(
389 raw = raw_trimmed,
390 "REST CORS: FUTU_REST_ALLOWED_ORIGINS='{}' detected as wildcard; \
391 falling through to AllowOrigin::any() (v1.4.104 eli P1-004 P1 fix \
392 防 tower-http panic). 推荐改 explicit origins (e.g. \
393 'https://app.example.com'). 仅当 daemon 确认对所有 origin 开放\
394 (development) 才用 wildcard.",
395 raw_trimmed
396 );
397 AllowOrigin::any()
398 } else {
399 let mut origins: Vec<HeaderValue> = Vec::new();
400 let mut had_invalid = false;
401 for s in raw.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()) {
402 if matches!(s.to_ascii_lowercase().as_str(), "*" | "any" | "all") {
404 tracing::warn!(
405 origin = s,
406 "FUTU_REST_ALLOWED_ORIGINS: 单元素 wildcard '*' 不能与具体 origin \
407 混用 (tower-http panic). 整批 fallback 到 AllowOrigin::any() \
408 (v1.4.104 eli P1-004 P1 fix). 请改纯 explicit origins."
409 );
410 return Router::new(); }
412 match HeaderValue::from_str(s) {
413 Ok(hv) => origins.push(hv),
414 Err(e) => {
415 had_invalid = true;
416 tracing::warn!(
417 origin = s,
418 error = %e,
419 "FUTU_REST_ALLOWED_ORIGINS: invalid origin string, skipped"
420 );
421 }
422 }
423 }
424 if origins.is_empty() {
425 tracing::warn!(
426 had_invalid,
427 "FUTU_REST_ALLOWED_ORIGINS env was set but no valid origins parsed; \
428 falling back to loopback default"
429 );
430 AllowOrigin::predicate(is_loopback_origin)
431 } else {
432 tracing::info!(
433 count = origins.len(),
434 "REST CORS: allowlist from FUTU_REST_ALLOWED_ORIGINS env"
435 );
436 AllowOrigin::list(origins)
437 }
438 }
439 }
440 _ => {
441 if key_store.is_configured() {
442 tracing::info!(
443 "REST CORS: auth enabled + no FUTU_REST_ALLOWED_ORIGINS env → loopback only \
444 (http://127.0.0.1 / http://localhost / http://[::1], any port). \
445 Set FUTU_REST_ALLOWED_ORIGINS=https://app.example.com to allow browser \
446 UI from other origins (BUG-008 fix)."
447 );
448 AllowOrigin::predicate(is_loopback_origin)
449 } else {
450 tracing::warn!(
451 "REST CORS: legacy unauth + no FUTU_REST_ALLOWED_ORIGINS env → wildcard `*` \
452 (backward-compat). 推荐传 --rest-keys-file 启用 Bearer auth + \
453 FUTU_REST_ALLOWED_ORIGINS=<your-origin> 收紧 (BUG-008 fix)."
454 );
455 AllowOrigin::any()
456 }
457 }
458 };
459 let cors = CorsLayer::new()
460 .allow_origin(cors_allow_origin)
461 .allow_methods(Any)
462 .allow_headers(Any);
463
464 Router::new()
465 .route("/ws", get(ws::ws_handler))
467 .route("/api/global-state", get(sys::get_global_state))
469 .route("/api/user-info", get(sys::get_user_info))
470 .route("/api/quote-rights", get(sys::get_quote_rights))
471 .route(
472 "/api/delay-statistics",
473 get(sys::get_delay_statistics).post(sys::get_delay_statistics_post),
474 )
475 .route("/api/ping", get(sys::ping))
477 .route("/api/push-subscriber-info", get(sys::push_subscriber_info))
478 .route("/api/unsub-acc-push", post(sys::unsub_acc_push))
479 .route(
481 "/api/token-state",
482 get(sys::get_token_state).post(sys::get_token_state),
483 )
484 .route("/api/subscribe", post(qot::subscribe))
486 .route("/api/sub-info", get(qot::get_sub_info))
487 .route("/api/query-subscription", post(qot::query_subscription))
489 .route("/api/list-plates", post(qot::list_plates))
491 .route("/api/quote", post(qot::get_basic_qot))
492 .route("/api/kline", post(qot::get_kl))
493 .route("/api/orderbook", post(qot::get_order_book))
494 .route("/api/broker", post(qot::get_broker))
495 .route("/api/ticker", post(qot::get_ticker))
496 .route("/api/rt", post(qot::get_rt))
497 .route("/api/snapshot", post(qot::get_snapshot))
498 .route("/api/static-info", post(qot::get_static_info))
499 .route("/api/plate-set", post(qot::get_plate_set))
500 .route("/api/plate-security", post(qot::get_plate_security))
501 .route("/api/reference", post(qot::get_reference))
502 .route("/api/get-reference", post(qot::get_reference))
504 .route("/api/owner-plate", post(qot::get_owner_plate))
505 .route("/api/option-chain", post(qot::get_option_chain))
506 .route("/api/warrant", post(qot::get_warrant))
507 .route("/api/capital-flow", post(qot::get_capital_flow))
508 .route(
509 "/api/capital-distribution",
510 post(qot::get_capital_distribution),
511 )
512 .route("/api/user-security", post(qot::get_user_security))
513 .route("/api/stock-filter", post(qot::stock_filter))
514 .route("/api/ipo-list", post(qot::get_ipo_list))
515 .route("/api/future-info", post(qot::get_future_info))
516 .route("/api/market-state", post(qot::get_market_state))
517 .route("/api/history-kline", post(qot::request_history_kl))
518 .route("/api/trading-days", post(qot::request_trading_days))
520 .route("/api/rehab", post(qot::request_rehab))
521 .route("/api/suspend", post(qot::get_suspend))
522 .route("/api/history-kl-quota", post(qot::request_history_kl_quota))
524 .route("/api/used-quota", post(qot::get_used_quota))
525 .route("/api/holding-change", post(qot::get_holding_change))
526 .route("/api/modify-user-security", post(qot::modify_user_security))
527 .route("/api/code-change", post(qot::get_code_change))
528 .route("/api/set-price-reminder", post(qot::set_price_reminder))
529 .route("/api/price-reminder", post(qot::get_price_reminder))
530 .route(
531 "/api/option-expiration-date",
532 post(qot::get_option_expiration_date),
533 )
534 .route("/api/unsubscribe", post(qot::unsubscribe))
535 .route(
537 "/api/risk-free-rate",
538 get(qot::get_risk_free_rate).post(qot::get_risk_free_rate),
539 )
540 .route(
542 "/api/spread-table",
543 get(qot::get_spread_table).post(qot::get_spread_table),
544 )
545 .route("/api/ticker-statistic", post(qot::get_ticker_statistic))
547 .route(
549 "/api/ticker-statistic-detail",
550 post(qot::get_ticker_statistic_detail),
551 )
552 .route("/api/flow-summary", post(trd::get_flow_summary))
553 .route("/api/acc-cash-flow", post(trd::get_flow_summary))
557 .route("/api/cash-log", post(trd::get_cash_log))
562 .route("/api/cash-detail", post(trd::get_cash_detail))
563 .route("/api/biz-group", post(trd::get_biz_group))
564 .route("/api/margin-info", post(trd::get_margin_info))
570 .route("/api/account-flag", post(trd::get_account_flag))
574 .route("/api/bond-total-asset", post(trd::get_bond_total_asset))
579 .route("/api/bond-single-asset", post(trd::get_bond_single_asset))
580 .route("/api/bond-position-list", post(trd::get_bond_position_list))
581 .route("/api/bond-answer-state", post(trd::get_bond_answer_state))
582 .route(
583 "/api/bond-trade-reminder",
584 post(trd::get_bond_trade_reminder),
585 )
586 .route("/api/accounts", get(trd::get_acc_list))
588 .route("/api/list-accounts", get(trd::get_acc_list))
590 .route("/api/unlock-trade", post(trd::unlock_trade))
591 .route("/api/sub-acc-push", post(trd::sub_acc_push))
592 .route("/api/funds", post(trd::get_funds))
593 .route("/api/positions", post(trd::get_positions))
594 .route("/api/orders", post(trd::get_orders))
595 .route("/api/order", post(trd::place_order))
596 .route("/api/modify-order", post(trd::modify_order))
597 .route("/api/cancel-all-order", post(trd::cancel_all_order))
599 .route("/api/order-fills", post(trd::get_order_fills))
600 .route("/api/max-trd-qtys", post(trd::get_max_trd_qtys))
601 .route("/api/reconfirm-order", post(trd::reconfirm_order))
603 .route("/api/history-orders", post(trd::get_history_orders))
604 .route(
605 "/api/history-order-fills",
606 post(trd::get_history_order_fills),
607 )
608 .route("/api/margin-ratio", post(trd::get_margin_ratio))
609 .route("/api/order-fee", post(trd::get_order_fee))
610 .route("/api/admin/status", get(admin::admin_status))
627 .route("/api/admin/shutdown", post(admin::admin_shutdown))
628 .route("/api/admin/reload", post(admin::admin_reload))
629 .layer(axum::middleware::from_fn(
635 crate::strict_fields::strict_field_validation_middleware,
636 ))
637 .layer(axum::middleware::from_fn_with_state(
638 auth_state,
639 bearer_auth,
640 ))
641 .layer(axum::middleware::from_fn(rest_error_envelope_middleware))
642 .route("/metrics", get(metrics_handler))
649 .route("/health", get(health_handler))
650 .route("/readyz", get(readyz_handler))
651 .fallback(unknown_route_fallback)
659 .layer(cors)
660 .with_state(state)
661}
662
663async fn unknown_route_fallback(req: axum::extract::Request) -> impl IntoResponse {
666 let path = req.uri().path().to_string();
667 let method = req.method().to_string();
668 (
669 axum::http::StatusCode::NOT_FOUND,
670 [(axum::http::header::CONTENT_TYPE, "application/json")],
671 axum::Json(serde_json::json!({
672 "error": format!("unknown route {method} {path:?}"),
673 "hint": "see categories below or full reference at https://www.futuapi.com/reference/rest-api/",
674 "categories": {
675 "qot (行情)": "/api/quote /api/snapshot /api/kline /api/orderbook /api/ticker /api/option-chain /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",
676 "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/margin-ratio /api/order-fee /api/sub-acc-push /api/flow-summary /api/order /api/modify-order /api/cancel-all-order /api/unlock-trade /api/reconfirm-order",
677 "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",
678 "sys": "/api/global-state /api/user-info /api/quote-rights /api/delay-statistics /api/ping /api/push-subscriber-info /api/admin/status (admin scope)",
679 "infra": "/health (liveness) /readyz (readiness) /metrics (Prometheus) /ws (WebSocket push)"
680 },
681 "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."
682 })),
683 )
684}
685
686async fn rest_error_envelope_middleware(req: axum::extract::Request, next: Next) -> Response {
692 let resp = next.run(req).await;
693 let status = resp.status();
694 if !matches!(
695 status,
696 StatusCode::BAD_REQUEST | StatusCode::UNSUPPORTED_MEDIA_TYPE
697 ) {
698 return resp;
699 }
700 let content_type = resp
701 .headers()
702 .get(header::CONTENT_TYPE)
703 .and_then(|v| v.to_str().ok())
704 .unwrap_or("");
705 if !content_type.starts_with("text/plain") {
706 return resp;
707 }
708
709 let bytes = match to_bytes(resp.into_body(), 64 * 1024).await {
710 Ok(bytes) => bytes,
711 Err(err) => {
712 let msg =
713 format!("REST request body parse error: failed to read rejection body: {err}");
714 return (
715 status,
716 axum::Json(serde_json::json!({
717 "ret_type": -1,
718 "ret_msg": msg,
719 "error": msg,
720 })),
721 )
722 .into_response();
723 }
724 };
725 let raw = String::from_utf8_lossy(&bytes);
726 let msg = format!("REST request body parse error: {}", raw.trim());
727 (
728 status,
729 axum::Json(serde_json::json!({
730 "ret_type": -1,
731 "ret_msg": msg,
732 "error": msg,
733 })),
734 )
735 .into_response()
736}
737
738pub async fn start(listen_addr: &str, router: Arc<RequestRouter>) -> std::io::Result<()> {
740 let ws_broadcaster = Arc::new(WsBroadcaster::new(1024));
741 let app = build_router(router, ws_broadcaster);
742 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
743 tracing::info!(addr = %listen_addr, "REST API 服务已启动 (WebSocket: /ws)");
744 axum::serve(listener, app).await
745}
746
747pub async fn start_with_broadcaster(
749 listen_addr: &str,
750 router: Arc<RequestRouter>,
751 ws_broadcaster: Arc<WsBroadcaster>,
752) -> std::io::Result<()> {
753 let app = build_router(router, ws_broadcaster);
754 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
755 tracing::info!(addr = %listen_addr, "REST API 服务已启动 (WebSocket: /ws)");
756 axum::serve(listener, app).await
757}
758
759pub async fn start_with_auth(
762 listen_addr: &str,
763 router: Arc<RequestRouter>,
764 ws_broadcaster: Arc<WsBroadcaster>,
765 key_store: Arc<KeyStore>,
766 counters: Arc<RuntimeCounters>,
767) -> std::io::Result<()> {
768 start_with_auth_and_admin(
769 listen_addr,
770 router,
771 ws_broadcaster,
772 key_store,
773 counters,
774 None,
775 )
776 .await
777}
778
779pub async fn start_with_auth_and_admin(
782 listen_addr: &str,
783 router: Arc<RequestRouter>,
784 ws_broadcaster: Arc<WsBroadcaster>,
785 key_store: Arc<KeyStore>,
786 counters: Arc<RuntimeCounters>,
787 admin_status_provider: Option<crate::adapter::AdminStatusProvider>,
788) -> std::io::Result<()> {
789 start_with_auth_full_admin(
790 listen_addr,
791 router,
792 ws_broadcaster,
793 key_store,
794 counters,
795 RestAdminHooks {
796 admin_status_provider,
797 ..RestAdminHooks::default()
798 },
799 )
800 .await
801}
802
803pub async fn start_with_auth_full_admin(
808 listen_addr: &str,
809 router: Arc<RequestRouter>,
810 ws_broadcaster: Arc<WsBroadcaster>,
811 key_store: Arc<KeyStore>,
812 counters: Arc<RuntimeCounters>,
813 hooks: RestAdminHooks,
814) -> std::io::Result<()> {
815 let scope_mode = key_store.is_configured();
816 let app = build_router_with_auth_full_admin(router, ws_broadcaster, key_store, counters, hooks);
817 let listener = tokio::net::TcpListener::bind(listen_addr).await?;
818 tracing::info!(
819 addr = %listen_addr,
820 scope_mode,
821 "REST API 服务已启动 (WebSocket: /ws)"
822 );
823 if !scope_mode {
824 tracing::warn!("REST API in legacy mode (no keys.json); mutating endpoints are blocked");
825 tracing::warn!("{}", LEGACY_WS_WARN_MESSAGE);
831 tracing::warn!(
835 listen_addr = %listen_addr,
836 readonly_endpoints = "qot/account/order-read",
837 blocked_mutating_endpoints = "/api/order,/api/modify-order,/api/cancel-all-order,/api/unlock-trade,/api/reconfirm-order,/api/admin/*",
838 ws_legacy_unauthenticated = true,
839 migration = "futucli gen-key --id my-key --scopes qot:read,acc:read,trade:real; restart with --rest-keys-file /path/to/keys.json",
840 "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"
841 );
842 }
843 axum::serve(listener, app).await
844}
845
846#[cfg(test)]
847mod tests;