Skip to main content

futu_rest/
server.rs

1//! REST API HTTP 服务
2//!
3//! 使用 axum 构建,复用 OpenD 的 RequestRouter 处理请求。
4//! 支持 WebSocket 推送: 客户端连接 /ws 可接收实时行情和交易推送。
5
6use 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
18/// v1.4.102 BUG-008 + codex 25 F5 / 30 F3 / 31 F6 (P2/P3) refine: loopback
19/// origin predicate, 严格 URL parsing.
20///
21/// 之前手写 strip + split, 接受 `http://[::1].evil.com` (前缀 `[::1]` 被 split
22/// 后第一段是 `[`, 但 starts_with("[::1]") 返 true). 改用 url crate 严格
23/// parse host, 比对完整 host 而非 prefix.
24///
25/// **接受**: `http://127.0.0.1[:port]` / `http://localhost[:port]` /
26/// `http://[::1][:port]` / `https://` 同等. 不接受 fragment / path / userinfo
27/// 等其他形态.
28///
29/// **不接受**: `http://[::1].evil.com` / `http://localhost.evil.com` /
30/// `http://127.0.0.1.evil.com`.
31fn 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
38/// v1.4.102 codex 25 F5 / 30 F3 / 31 F6 strict parser (REST + WS 共用).
39/// 严格匹配 host, 不接受 prefix / suffix-混入的 evil hosts.
40///
41/// 实装策略 (避免引新 url crate dep): 1) strip scheme; 2) 剩下部分必须
42/// 完全等于 "127.0.0.1[:port]" / "localhost[:port]" / "[::1][:port]";
43/// 3) port 必须纯 ASCII digit 1-5 位; 4) 不允许 / ? # 任何 path / query /
44/// fragment / 其他字符.
45pub(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    // 不允许任何 path / query / fragment 或 userinfo 形式 (origin 不应有这些)
54    if after_scheme.contains('/')
55        || after_scheme.contains('?')
56        || after_scheme.contains('#')
57        || after_scheme.contains('@')
58    {
59        return false;
60    }
61    // 拆出 host 和 optional port. IPv6 [::1] 形式特殊处理.
62    // **port 区分 None (无 :) vs Some("") (有 : 但空)** — 两者不一样:
63    //   - "http://localhost" → port = None (no separator) → OK 用默认 80/443
64    //   - "http://localhost:" → port = Some("") (有 : 但空) → 拒 (用户写错)
65    //   - "http://localhost:8080" → port = Some("8080") → 校验 1..=65535
66    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    // v1.4.102 codex 41 F4 (P3): port 必须 (a) 缺 OR (b) 解析为 1..=65535.
84    // 之前用 port_str.is_empty() 判断, 但分不出 "http://localhost" (None) vs
85    // "http://localhost:" (Some("")) — 两者都 port_str="". 现在 None 接受,
86    // Some("") / Some("0") / Some("99999") / 非数字 全拒.
87    if let Some(port_str) = port_opt {
88        match port_str.parse::<u16>() {
89            Ok(p) if p >= 1 => {} // 1..=65535 OK
90            _ => return false,    // 空 / 0 / 非数字 / 超 u16 → 拒
91        }
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/// REST admin/diagnostic extension hooks injected by `futu-opend`.
104///
105/// These hooks are a single surface-adapter bundle: the REST crate keeps the
106/// HTTP routing shape, while `futu-opend` owns the gateway/cache providers.
107#[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
115/// v1.4.93 P0-5 (NEW-C-02): REST `/ws` legacy mode 的 startup WARN 文本。
116///
117/// 抽出 const 以便单测验证 warn 消息携带 "v2"/"reject" 等关键提示词,
118/// 防止后续被误删(同模式 v1.4.86 SEC-003 Q4 已沉淀)。
119pub(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
123/// Prometheus `/metrics` handler —— **v1.4.106 codex 0542 F1 [P2 SECURITY]**:
124/// 默认 scope-gated (`Scope::MetricsRead`).
125///
126/// **三态**:
127///
128/// 1) **`FUTU_METRICS_PUBLIC=1` env 设** → 完全公开 (无 auth + 明文 key_id), 回退 v1.4.105 行为. opt-out 路径 (老 dashboard / 运维 firewall-only).
129/// 2) **legacy 模式** (KeyStore 未配 / `is_configured() == false`) → 公开 (向后兼容: 用户没配 keys.json 之前也能用 /metrics).
130/// 3) **scope-mode** (KeyStore 配置) → 强制 `Authorization: Bearer <plaintext>` + `Scope::MetricsRead` (或 `Scope::Admin` 兼容). 缺 → 401 / 403. body 里 key_id 为 `kh_<8hex>` 短 SHA256 redact (除非 env opt-out).
131///
132/// **路由位置**: route 注册在 bearer_auth middleware **之外** (path 不以 `/api/`
133/// 起头, middleware 不拦), 所以 auth check 在 handler 内自己做.
134async fn metrics_handler(
135    axum::extract::State(state): axum::extract::State<RestState>,
136    headers: axum::http::HeaderMap,
137) -> Response {
138    // (1) opt-out env: 完全公开 (老行为)
139    let env_public = std::env::var_os("FUTU_METRICS_PUBLIC").is_some();
140    if env_public {
141        return render_metrics_body();
142    }
143    // (2) legacy mode: 公开 (KeyStore 未配)
144    if !state.key_store.is_configured() {
145        return render_metrics_body();
146    }
147    // (3) scope-mode: 必须有 Bearer + Scope::MetricsRead/Admin
148    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    // 持 MetricsRead 或 Admin 任一即过 (Admin 是 superset, 兼容老 admin key
177    // dashboard 抓取场景, 不强制单独发新 key).
178    let has_metrics = rec.scopes.contains(&futu_auth::Scope::MetricsRead)
179        || rec.scopes.contains(&futu_auth::Scope::Admin);
180    if !has_metrics {
181        // BUG-011 不泄 key_id / scope: 通用 forbidden, 不暗示 "你少哪个 scope"
182        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
191/// Render the actual prometheus body (no auth check). Pulled out so all 3
192/// success branches in `metrics_handler` share rendering.
193fn 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
215/// `/health` liveness probe handler —— 200 OK + body "ok"
216///
217/// 故意做得很轻:只要 axum 还能 schedule 任务返回响应就算"alive"。**不**检查
218/// 网关连接 / DB / 下游依赖(那是 readiness 的活,运维真要可以另写一个
219/// `/ready`)。LB / k8s liveness probe 直接打这个端点,bind_auth 不拦。
220async fn health_handler() -> impl IntoResponse {
221    (axum::http::StatusCode::OK, "ok")
222}
223
224/// `/readyz` readiness probe handler —— 200 OK if gateway dispatch ready, else 503
225///
226/// v1.4.27(UX-1,加拿大同事 v1.4.26 回归测试发现):冷启动 ~30~60s 期间
227/// `/api/quote` 返空 list、`/api/history-kline` 报 `no backend connection`,
228/// 用户看不到就绪信号以为坏了。`/health` 只反映进程 alive(axum 能响应就
229/// 算通),但用户真正想知道的是 "gateway dispatch 层是否 ready 接收业务
230/// 请求"。
231///
232/// 实现:内部 dispatch 一次 `GET_GLOBAL_STATE`,能成功 decode 出响应就算
233/// ready;否则 503。k8s readinessProbe / LB 直接打这个端点。
234async 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
276/// 构建 REST API 路由(无鉴权,向后兼容)
277pub 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
286/// 构建 REST API 路由,携带 KeyStore 做 Bearer Token 鉴权 + RuntimeCounters 做限额
287///
288/// `key_store.is_configured() == false` 时等价于 `build_router`(保持旧行为)。
289/// `counters` 应由 main 全进程共享:REST / gRPC / MCP 共用一个实例才能保证
290/// rate limit / 日累计跨接口一致
291pub 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
300/// v1.4.32+ 扩展:额外传入 admin_status_provider,`/api/admin/status` 用。
301/// 旧 `build_router_with_auth` 内部委托到此,`admin_status_provider = None`
302/// 时行为与之前完全一致(admin_status endpoint 返 503)。
303pub 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
322/// v1.4.32+ 完整扩展:同时接 status provider + reload handler。
323///
324/// v1.4.83 §9 Phase 2 F5: 加 `push_health_snapshot_provider` 参数支持
325/// `/api/push-subscriber-info` 返真实 push 通道健康 state。
326pub 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    // v1.4.102 BUG-008 fix (P1, leaf v1.4.100 报告): protected REST endpoint
360    // 默认不再返 wildcard `Access-Control-Allow-Origin: *`. 历史: 任何 evil
361    // origin 都能通过 CORS preflight 拿 200, 配合 cookie / token 暴露链接
362    // 增加浏览器侧攻击面.
363    //
364    // **新策略**:
365    //   1. `FUTU_REST_ALLOWED_ORIGINS=https://app.example.com,http://localhost:3000`
366    //      显式 allowlist (推荐).
367    //   2. key_store.is_configured() (auth enabled) + 未设 env → loopback only
368    //      (http://127.0.0.1, http://localhost, http://[::1] + 任意端口).
369    //   3. legacy unauth + 未设 env → 仍 wildcard (保持 backward compat,
370    //      但启动 warn 推荐配置 allowlist).
371    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            // v1.4.104 eli P1-004 (P1) fix: 用户传 FUTU_REST_ALLOWED_ORIGINS='*'
376            // 触发 tower-http panic (CorsLayer::list 不接受 '*' as Origin), panic
377            // 被 global hook 吞 → REST task 死, 但 gRPC/TCP/WS/telnet 在同毫秒
378            // 内继续 bind 成功 → daemon "half-dead" silent failure (用户 /health
379            // 不通以为整 daemon 死, 实际其他 surface 在响应).
380            //
381            // 修法: 检测 '*' / 'any' / 'all' 字符串作 wildcard 意图早期, 直接
382            // 用 AllowOrigin::any() 而不是 AllowOrigin::list([*]) (后者 panic).
383            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                    // v1.4.104 eli P1-004: 单元素是 '*' / 'any' 也提前拦
403                    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(); // 不应到达, 防御
411                    }
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        // ── WebSocket 推送 ──
466        .route("/ws", get(ws::ws_handler))
467        // ── 系统 ──
468        .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        // v1.4.74 A2 BUG-013 fix: 7 missing REST endpoints(对齐 MCP tools)
476        .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        // v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token state query
480        .route(
481            "/api/token-state",
482            get(sys::get_token_state).post(sys::get_token_state),
483        )
484        // ── 行情 ──
485        .route("/api/subscribe", post(qot::subscribe))
486        .route("/api/sub-info", get(qot::get_sub_info))
487        // v1.4.74 A2 BUG-013 fix: query-subscription (POST 版,可传 is_req_all_conn)
488        .route("/api/query-subscription", post(qot::query_subscription))
489        // v1.4.74 A2 BUG-013 fix: list-plates alias(对齐 MCP `futu_list_plates`)
490        .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        // v1.4.74 A2 BUG-013 fix: get-reference alias(对齐 MCP `futu_get_reference`)
503        .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        // v1.4.30
519        .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        // v1.4.30 P2(100% 覆盖)
523        .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        // v1.4.98 T2-2 (mobile-source-audit Phase 2): risk-free rate (期权定价)
536        .route(
537            "/api/risk-free-rate",
538            get(qot::get_risk_free_rate).post(qot::get_risk_free_rate),
539        )
540        // v1.4.98 T2-1: 摆盘步长 (价位表)
541        .route(
542            "/api/spread-table",
543            get(qot::get_spread_table).post(qot::get_spread_table),
544        )
545        // v1.4.98 T2-3: 逐笔统计
546        .route("/api/ticker-statistic", post(qot::get_ticker_statistic))
547        // v1.4.106 codex 0500 ζ23-redo: 逐笔统计 Detail (价位级分布)
548        .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        // v1.4.51 (eli v1.4.48 pre-existing): `/api/acc-cash-flow` 404 —— CLI
554        // 命令名 / MCP tool 名都是 `acc-cash-flow`,REST 之前只注册 `/api/flow-summary`
555        // 别名。加 alias 让两种 URL 都 work(向后兼容 + 对齐 CLI/MCP 直觉)。
556        .route("/api/acc-cash-flow", post(trd::get_flow_summary))
557        // v1.4.94 Tier M (mobile-driven extension): 资金明细 / cash log
558        // 来源: ftcnnproto/.../realtime_asset_log.proto + FLCltProtocol.h:123
559        // (clt_cmd_trade_cash_log = 3000). 比 /api/flow-summary 字段更全 +
560        // cursor 分页 + 多维过滤. 见 docs/protocol/cash-log.md.
561        .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        // v1.4.95 U2-D Tier M (mobile-driven extension): per-account margin info
565        // 来源: ftcnnproto/.../risk_user_account_info.proto + FLCltProtocol.h
566        // (clt_cmd_hk_margin_info=3101 / us=3102 / cn_ah=3107). 与 /api/margin-ratio
567        // (per-security ratio) 互补: 本 endpoint 给 per-account 全景 (购买力 / 杠杆
568        // / 风险等级 / 流动性 / HK-specific 港股保证金).
569        .route("/api/margin-info", post(trd::get_margin_info))
570        // v1.4.95 U2-A Tier M (mobile-driven extension): account compliance flags
571        // 来源: ftcnnproto/.../account_flag.proto + NN cmd 5281. 查询账户合规
572        // 状态 (产品准入 / 风险评估 / opt-in 标志). 高级交易准入强制要求.
573        .route("/api/account-flag", post(trd::get_account_flag))
574        // v1.4.95 U2-B Tier M (mobile-driven extension): bond holdings + trade prep
575        // 来源: ftcnnproto/.../bond_client_view.proto + FLCltProtocol.h
576        // 5 endpoint × 5 cmd_id (9373/9374/9375/10043/10057), 共享 acc_id +
577        // trd_env + market("HK"/"US"/"SG"). 仅 HK / US / SG 债券账户有数据.
578        .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        // ── 交易 ──
587        .route("/api/accounts", get(trd::get_acc_list))
588        // v1.4.74 A2 BUG-013 fix: list-accounts alias(对齐 MCP `futu_list_accounts`)
589        .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        // v1.4.30: cancel_all_order 便捷端点(= modify_order 带 for_all=true + op=Cancel)
598        .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        // v1.4.40 #4 fix: expose reconfirm-order endpoint(daemon handler 已注册但 REST 缺路由)
602        .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        // ── v1.4.32+ daemon admin(Scope::Admin)──
611        // 注意:admin endpoint 走 bearer_auth,scope 不对会被拒;未配置
612        // key_store 的 legacy 模式允许通过但日志 WARN。
613        //
614        // v1.4.106 codex 0554 F4 [P3] runtime context note:
615        // - status: 同步生成 snapshot, <1ms, 无 I/O.
616        // - shutdown: 同步 200 + tokio::spawn 1s 后 std::process::exit(0).
617        // - reload: 同步阶段清 cipher + bump cipher_state_version (<10ms);
618        //   后台 tokio::spawn 跑 refresh_credentials_on_disk 网络 I/O, 写
619        //   bridge.last_reload_refresh; ops 看 /api/admin/status 的
620        //   last_reload_refresh 字段监控. 自 v1.4.106 起 reload response 不
621        //   再 hang 几秒 (老版 await 模式已 retire).
622        //
623        // POST body 校验: shutdown + reload 仅接受 empty/{}/null,
624        // strict_fields::validate_admin_empty_body. 任何 user-supplied 字段
625        // 返 400 (handler 完全不读 body, 防 silent-accept).
626        .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        // v1.4.93 P0-2 (BUG-002): strict field validation for 7 critical
630        // endpoints. Runs AFTER bearer_auth (axum layer ordering: this `.layer()`
631        // is added BEFORE `.layer(bearer_auth)` -> strict is INNER -> auth runs
632        // first). Pre-auth callers can't probe valid field names. Non-strict
633        // paths and non-POST methods pass through unmodified.
634        .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        // `/metrics` + `/health` + `/readyz` 都在 bearer_auth 之外
643        // (middleware 只过 /api/*)
644        //   - `/metrics`:Prometheus 抓取(无需 token,运维用 firewall 控访问)
645        //   - `/health`:liveness probe —— 进程 alive 就 200
646        //   - `/readyz`:readiness probe —— gateway dispatch ready 才 200,
647        //     冷启动期间返 503 避免 LB 打流量进来(v1.4.27 新加)
648        .route("/metrics", get(metrics_handler))
649        .route("/health", get(health_handler))
650        .route("/readyz", get(readyz_handler))
651        // v1.4.96 BUG #009 sym 4 hotfix (eli double-tester report 2026-04-26):
652        // 之前 unmatched /api/foobar 返默认 axum "Not Found" 纯文本, 用户 /
653        // LLM agent 完全不知有哪些 endpoint. v1.4.96 加 fallback handler 返
654        // JSON + 列出可用 endpoint 类目 + 文档 URL.
655        //
656        // 注意: scope-mode 下 bearer_auth 已 fail-closed 拦 /api/* 未注册路径
657        // 返 404 + JSON. 本 fallback 兜底 legacy mode + non-/api 路径.
658        .fallback(unknown_route_fallback)
659        .layer(cors)
660        .with_state(state)
661}
662
663/// v1.4.96 BUG #009 sym 4 hotfix: 给所有 unmatched 路由返 helpful JSON 404,
664/// 列出可用 endpoint 类目 + futuapi.com 文档 URL.
665async 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
686/// releasegate f18fc66da BUG-RG-001: axum extractor rejections (empty JSON
687/// body, missing/invalid Content-Type, malformed body at extractor layer)
688/// happen before our route handlers run, so they used to escape as
689/// `text/plain`. Normalize those framework-level failures to the same machine
690/// envelope used by handler-level validation.
691async 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
738/// 启动 REST API 服务,返回 WsBroadcaster 供外部推送事件
739pub 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
747/// 启动 REST API 服务并返回 WsBroadcaster(供外部推送系统使用)
748pub 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
759/// 同 `start_with_broadcaster`,但挂载 KeyStore 做 Bearer Token 鉴权 +
760/// RuntimeCounters 做限额
761pub 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
779/// v1.4.32+ 同 `start_with_auth`,但额外接 admin_status_provider。
780/// 让 `/api/admin/status` 能返回实时健康快照。
781pub 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
803/// v1.4.32+ 完整 admin 入口:同时接 status provider + reload handler。
804///
805/// v1.4.83 §9 Phase 2 F5: 加 `push_health_snapshot_provider` 参数支持
806/// `/api/push-subscriber-info` 返真实 push 通道健康 state。
807pub 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        // v1.4.93 P0-5 (NEW-C-02): WS 也对齐 mutating-blocked policy 的"loud
826        // unauth"信号 — REST `/ws` route 在 legacy 模式接受 unauthenticated
827        // handshake (no-token / wrong-bearer / bogus-query 都 HTTP 101),未授
828        // 权客户端可接收 live push。本版**不 reject**(保持向后兼容,未来
829        // major 版默认 reject),但补 startup loud WARN + CHANGELOG 公告。
830        tracing::warn!("{}", LEGACY_WS_WARN_MESSAGE);
831        // v1.4.86 SEC-003 Q4 真 fix: legacy mode 下 mutating endpoint 硬门禁
832        // (/api/order / modify-order / cancel-all-order / unlock-trade /
833        // reconfirm-order / admin/*). 只读 endpoint 继续 legacy 允许.
834        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;