futu_server/ws_listener.rs
1// WebSocket 监听器:接受 WebSocket 连接,复用 TCP 的请求路由和连接池
2//
3// 每个 WebSocket 二进制消息 = 一个完整的 FutuAPI 帧(44 字节帧头 + body)。
4// 与 TCP 共享同一个 connections DashMap、RequestRouter、SubscriptionManager。
5//
6// ## v1.0 鉴权
7//
8// 握手阶段(accept_hdr_async)校验 HTTP `Authorization: Bearer <token>` 或
9// `?token=<plaintext>` query —— 通过 `KeyStore::verify` 得到 `KeyRecord`,
10// 把 scope 集合和 key_id 存到 `ClientConn`。每条消息进 `ws_process_requests`
11// 时按 `futu_auth::scope_for_proto_id(proto_id)` 查所需 scope,不匹配 → 不 dispatch、
12// 记 audit reject。`trade:real` 额外跑 `check_and_commit` 过一道 rate + hours
13// 全局闸门。未注入 KeyStore(TCP listener 或 legacy 模式)→ scopes 空集被
14// 解释为"全放行",保持向后兼容。
15//
16// ## v1.4.104 阶段 3
17//
18// 把 inline scope gate / rate gate / body-aware acc_id check 替换为
19// `futu_auth_pipeline::authenticate_request` 单一调用. 流程:
20// 1. AES decrypt (handshake protocol INIT_CONNECT 跳过)
21// 2. (非 INIT_CONNECT + 非 1xxx 系统协议) → 调 pipeline
22// Credential::PreVerified(rec_from_get_by_id) 拿 SIGHUP-aware 最新 rec.
23// Reject → drop. Allow → 取 allowed_acc_ids 给 response filter.
24// 3. dispatch (router / handle_init_connect / handle_keepalive)
25// 4. response filter (proto 2001 TRD_GET_ACC_LIST 等)
26//
27// 删除: 旧 `ws_body_aware_check` (功能进 pipeline body_aware::build_check_ctxs).
28// 删除: 旧 inline scope gate + rate gate + audit allow/reject. 全 pipeline 一处.
29
30use std::collections::HashSet;
31use std::sync::Arc;
32use std::sync::atomic::AtomicI64;
33use std::time::Instant;
34
35use dashmap::DashMap;
36use tokio::net::TcpListener;
37use tokio::sync::{mpsc, watch};
38
39mod connection;
40mod handshake;
41
42use connection::run_ws_connection;
43use handshake::AuthResult;
44
45use futu_auth::{KeyStore, RuntimeCounters};
46use futu_auth_pipeline::{
47 AuthDecision, AuthEnvelope, Credential, Endpoint, FilterRegistry, RejectKind, SurfaceId,
48 authenticate_request,
49};
50
51/// v1.4.106 D1 5c: WS surface adapter — `AuthDecision::Reject` 翻成 silent drop.
52///
53/// **历史**: WS 在 v1.4.103 / v1.4.104 阶段 3 都按 silent drop 处理 reject
54/// (与 v1.4.103 行为一致, 防 timing 探测 — 给客户端任何 wire response 都让
55/// 它知道帧被读了 vs 没读). v1.4.106 D1 把这层"翻译为 unit"也走 trait, 让 4
56/// surface SurfaceAdapter 一致.
57///
58/// **WireResponse = ()**: WS 不发任何东西回 client; 调用方拿 `Option<()>`
59/// 知道是 reject (Some) 还是 allow (None) 即可继续 dispatch / 丢弃.
60///
61/// **不变量**: `translate_reject` 不能写日志 (pipeline 已 audit::reject 一次,
62/// 不要重复). 只 drain reason / kind 让它进 `_`.
63pub struct WsAdapter;
64
65impl futu_auth_pipeline::SurfaceAdapter for WsAdapter {
66 type WireResponse = ();
67
68 fn surface_id() -> SurfaceId {
69 SurfaceId::Ws
70 }
71
72 fn translate_reject(_kind: RejectKind, _reason: String) -> Self::WireResponse {
73 // 与 v1.4.103/104 行为一致: silent drop. pipeline 已 audit reject,
74 // 不再写 log; reject fields intentionally unused to avoid leaking daemon state.
75 }
76}
77use futu_codec::header::ProtoFmtType;
78use futu_core::proto_id;
79
80use crate::conn::{ClientConn, ConnState, DisconnectNotify, IncomingRequest};
81use crate::listener::{
82 MAX_CONNECTIONS, REQUEST_QUEUE_CAPACITY, ServerConfig, default_server_time_offset_secs,
83 server_now_ts,
84};
85use crate::router::RequestRouter;
86
87/// WebSocket 服务端
88pub struct WsServer {
89 listen_addr: String,
90 config: ServerConfig,
91 connections: Arc<DashMap<u64, ClientConn>>,
92 router: Arc<RequestRouter>,
93 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
94 /// v1.0:握手时做 Bearer token 鉴权。None 或 `!is_configured()` → legacy 模式放行
95 key_store: Option<Arc<KeyStore>>,
96 /// v1.0:跨 REST / gRPC / WS 共享的限额 counters
97 counters: Option<Arc<RuntimeCounters>>,
98 /// v1.4.104 阶段 3: 跨 surface 共享的 response filter registry (proto 2001
99 /// TRD_GET_ACC_LIST 默认装入). None 时 fallback 到内置 with_defaults.
100 filter_registry: Option<Arc<FilterRegistry>>,
101 server_time_offset_secs: Arc<AtomicI64>,
102}
103
104/// Shared dependencies for the WebSocket server.
105///
106/// These are the same runtime objects the raw TCP server owns. Keeping them in a
107/// bundle avoids every constructor carrying a growing positional list as auth /
108/// counters / filters evolve.
109pub struct WsServerDeps {
110 connections: Arc<DashMap<u64, ClientConn>>,
111 router: Arc<RequestRouter>,
112 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
113}
114
115impl WsServerDeps {
116 pub fn new(
117 connections: Arc<DashMap<u64, ClientConn>>,
118 router: Arc<RequestRouter>,
119 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
120 ) -> Self {
121 Self {
122 connections,
123 router,
124 subscriptions,
125 }
126 }
127}
128
129impl WsServer {
130 /// 创建 WsServer,共享 TCP 的连接池、路由器、订阅管理器(无鉴权,向后兼容)
131 pub fn new(
132 listen_addr: String,
133 config: ServerConfig,
134 connections: Arc<DashMap<u64, ClientConn>>,
135 router: Arc<RequestRouter>,
136 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
137 ) -> Self {
138 Self::with_auth(
139 listen_addr,
140 config,
141 WsServerDeps::new(connections, router, subscriptions),
142 None,
143 None,
144 )
145 }
146
147 /// v1.0 入口:同时接入 KeyStore + 共享 RuntimeCounters 做握手鉴权和 per-message
148 /// scope / 限额检查。`key_store = None` 或未配置时保持 legacy(全放行)。
149 pub fn with_auth(
150 listen_addr: String,
151 config: ServerConfig,
152 deps: WsServerDeps,
153 key_store: Option<Arc<KeyStore>>,
154 counters: Option<Arc<RuntimeCounters>>,
155 ) -> Self {
156 Self {
157 listen_addr,
158 config,
159 connections: deps.connections,
160 router: deps.router,
161 subscriptions: deps.subscriptions,
162 key_store,
163 counters,
164 filter_registry: None,
165 server_time_offset_secs: default_server_time_offset_secs(),
166 }
167 }
168
169 /// Inject backend server-time offset for SDK-facing WS protocol fields.
170 pub fn with_server_time_offset_secs(mut self, offset: Arc<AtomicI64>) -> Self {
171 self.server_time_offset_secs = offset;
172 self
173 }
174
175 /// v1.4.104 阶段 3: 注入显式 FilterRegistry (跨 surface 共享同一份).
176 /// 不调用此 setter 则 run() 时 fallback 到 `FilterRegistry::with_defaults()`.
177 pub fn with_filter_registry(mut self, registry: Arc<FilterRegistry>) -> Self {
178 self.filter_registry = Some(registry);
179 self
180 }
181
182 /// 启动 WebSocket 服务端监听
183 pub async fn run(&self) -> anyhow::Result<()> {
184 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
185 self.run_until_shutdown(shutdown_rx).await
186 }
187
188 /// 启动 WebSocket 服务端监听,并在 shutdown 信号到来时停止接受新连接。
189 pub async fn run_until_shutdown(
190 &self,
191 mut shutdown_rx: watch::Receiver<bool>,
192 ) -> anyhow::Result<()> {
193 let listener = TcpListener::bind(&self.listen_addr)
194 .await
195 .map_err(|error| {
196 anyhow::anyhow!(
197 "{}",
198 crate::bind_hint::bind_error_message(
199 "WebSocket",
200 "--websocket-port",
201 &self.listen_addr,
202 error
203 )
204 )
205 })?;
206 tracing::info!(addr = %self.listen_addr, "WebSocket server listening");
207
208 let (req_tx, req_rx) = mpsc::channel::<IncomingRequest>(REQUEST_QUEUE_CAPACITY);
209 // The disconnect cleanup signal is intentionally unbounded: each item
210 // is a tiny `conn_id`, and blocking this path behind request-queue
211 // backpressure would risk leaking connection/subscription state.
212 let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel::<DisconnectNotify>();
213
214 // 启动请求处理任务(与 TCP 共享同一逻辑)
215 let connections = Arc::clone(&self.connections);
216 let router = Arc::clone(&self.router);
217 let config = self.config.clone();
218 // v1.4.104 阶段 3: KeyStore + RuntimeCounters 总是材料化 (legacy mode 用
219 // empty / new()), 让 ws_process_requests 拿 non-Option Arc 直接调 pipeline.
220 // 行为与 v1.4.103 等价: KeyStore::empty().is_configured() = false, pipeline
221 // 走 legacy short-circuit (Allow{rec:None} 不 audit, body-aware 不 enforce).
222 let key_store_for_process = self
223 .key_store
224 .clone()
225 .unwrap_or_else(|| Arc::new(KeyStore::empty()));
226 let counters_for_process = self
227 .counters
228 .clone()
229 .unwrap_or_else(|| Arc::new(RuntimeCounters::new()));
230 let filter_registry_for_process = self
231 .filter_registry
232 .clone()
233 .unwrap_or_else(|| Arc::new(FilterRegistry::with_defaults()));
234 let server_time_offset_secs = Arc::clone(&self.server_time_offset_secs);
235 tokio::spawn(async move {
236 ws_process_requests(
237 req_rx,
238 connections,
239 router,
240 config,
241 counters_for_process,
242 key_store_for_process,
243 filter_registry_for_process,
244 server_time_offset_secs,
245 )
246 .await;
247 });
248
249 // 启动连接清理任务
250 let cleanup_connections = Arc::clone(&self.connections);
251 let cleanup_subs = self.subscriptions.clone();
252 tokio::spawn(async move {
253 while let Some(notify) = disconnect_rx.recv().await {
254 let removed = cleanup_connections.remove(¬ify.conn_id);
255 if removed.is_some() {
256 if let Some(ref subs) = cleanup_subs {
257 subs.on_disconnect(notify.conn_id);
258 }
259 tracing::info!(
260 conn_id = notify.conn_id,
261 remaining = cleanup_connections.len(),
262 "ws connection removed from pool"
263 );
264 }
265 }
266 });
267
268 // 接受连接循环
269 let connections = Arc::clone(&self.connections);
270 let key_store_accept = self.key_store.clone();
271 // v1.4.104 阶段 3: scope_mode 局部计算 (KeyStore configured = 启用 auth).
272 let scope_mode = self.key_store.as_ref().is_some_and(|ks| ks.is_configured());
273 if !scope_mode {
274 // v1.4.93 P0-5 (NEW-C-02): 加强 legacy mode loud WARN —
275 // 对齐 REST mutating-blocked policy 的 startup signal。任何
276 // 未授权客户端都可 handshake + 接收 push(HTTP 101 OK)。本版
277 // **不 reject**(保持向后兼容),未来 v2 默认 reject。
278 tracing::warn!("{}", legacy_mode_warn_tracing_message());
279 eprintln!("{}", legacy_mode_warn_stderr_message());
280 }
281 loop {
282 let (stream, peer_addr) = tokio::select! {
283 _ = crate::listener::shutdown_requested(&mut shutdown_rx) => {
284 tracing::info!("WebSocket server accept loop stopped by shutdown signal");
285 break;
286 }
287 accepted = listener.accept() => accepted?,
288 };
289
290 if connections.len() >= MAX_CONNECTIONS {
291 tracing::warn!(
292 peer = %peer_addr,
293 "max connections reached ({}), rejecting ws client",
294 MAX_CONNECTIONS,
295 );
296 drop(stream);
297 continue;
298 }
299
300 let conn_id = ClientConn::generate_conn_id();
301 let aes_key = ClientConn::generate_aes_key();
302 crate::listener::set_nodelay_with_log(&stream, peer_addr, "ws");
303
304 tracing::info!(
305 conn_id = conn_id,
306 peer = %peer_addr,
307 total = connections.len() + 1,
308 "ws client connected"
309 );
310
311 let (tx, authed) = run_ws_connection(
312 stream,
313 peer_addr,
314 conn_id,
315 aes_key,
316 req_tx.clone(),
317 disconnect_tx.clone(),
318 shutdown_rx.clone(),
319 key_store_accept.clone(),
320 )
321 .await;
322
323 // 握手鉴权失败 → run_ws_connection 已经 drop 连接;这里什么都不做
324 let Some(authed) = authed else {
325 continue;
326 };
327
328 let (key_id, scopes, allowed_markets, allowed_acc_ids) = match authed {
329 AuthResult::Authenticated(rec) => (
330 Some(rec.id.clone()),
331 rec.scopes.clone(),
332 // v1.4.105 D3 (Phase 4) T-B2: 拷贝 caller key 的 allowed_markets
333 // 到 ClientConn 让 PushDispatcher::push_trd_acc Layer 3 用.
334 rec.allowed_markets
335 .as_ref()
336 .map(|s| std::sync::Arc::new(s.clone())),
337 // codex round 1 F4 (P2) v1.4.105: 拷贝 caller key 的
338 // allowed_acc_ids 到 ClientConn 让 PushDispatcher::push_trd_acc
339 // Layer 1 push-time 硬过滤. 防 stale subscription /
340 // KeyRecord reload 后 acc 范围窄化 时 push leak.
341 rec.allowed_acc_ids
342 .as_ref()
343 .map(|s| std::sync::Arc::new(s.clone())),
344 ),
345 AuthResult::Legacy => (None, HashSet::new(), None, None),
346 };
347
348 let conn = ClientConn {
349 conn_id,
350 state: ConnState::Connected,
351 aes_key,
352 aes_encrypt_enabled: false,
353 proto_fmt_type: ProtoFmtType::Protobuf,
354 last_keepalive: Instant::now(),
355 recv_notify: false,
356 ai_type: 0,
357 keepalive_count: std::sync::atomic::AtomicU32::new(0),
358 tx,
359 key_id,
360 scopes,
361 allowed_markets,
362 allowed_acc_ids,
363 };
364
365 connections.insert(conn_id, conn);
366 }
367
368 Ok(())
369 }
370}
371
372/// v1.4.93 P0-5 (NEW-C-02): legacy mode 的 `tracing::warn!` 内容。
373///
374/// 抽出 const fn 以便单测验证 warn 消息携带 "v2"/"reject" 等关键提示词,
375/// 防止后续被误删(同模式 v1.4.86 SEC-003 Q4 已沉淀)。
376pub(crate) const fn legacy_mode_warn_tracing_message() -> &'static str {
377 "WS server running WITHOUT API key auth (legacy mode); \
378 all WS clients accept unauthenticated handshake (no-token / \
379 wrong-bearer / bogus-query all return success). \
380 Pass KeyStore via with_auth() to enable. \
381 v2 will default-reject; migrate to --rest-keys-file / --ws-keys-file for production."
382}
383
384/// v1.4.93 P0-5 (NEW-C-02): legacy mode 的 stderr 用户可见消息。
385///
386/// 比 tracing::warn 更短,方便 systemd / docker logs 一行抓住。
387pub(crate) const fn legacy_mode_warn_stderr_message() -> &'static str {
388 "⚠️ WS server (legacy mode, no --ws-keys-file): \
389 unauthenticated handshakes accepted. v2 will default-reject. \
390 Migrate to --ws-keys-file for production."
391}
392
393/// 处理 WebSocket 连接的请求(逻辑与 TCP 的 process_requests 相同,额外做 scope / 限额)
394///
395/// v1.4.104 阶段 3 重构: inline scope gate + trade:real rate gate +
396/// `ws_body_aware_check` 三段折叠为单一 `authenticate_request` pipeline 调用.
397///
398/// **流程**:
399/// 1. AES decrypt (handshake INIT_CONNECT 跳过 — body 是明文 RSA 加密 InitReq)
400/// 2. 非 INIT_CONNECT + scope_for_proto_id != None → 调 pipeline:
401/// - Credential::PreVerified(`KeyStore::get_by_id(key_id)`) 拿 SIGHUP-aware
402/// 最新 rec, 复用 handshake 已 verify 的身份 (skip re-verify).
403/// - Endpoint::Proto(proto_id), commit_rate=true (per-msg rate 闸门).
404/// - Reject → drop request (audit 已 emit). Allow → 拿 allowed_acc_ids.
405/// 3. dispatch (router / handle_init_connect / handle_keepalive)
406/// 4. response filter (proto 2001 等, 通过 FilterRegistry::apply).
407///
408/// **legacy mode 行为**: KeyStore::empty().is_configured() = false, pipeline
409/// 走 legacy short-circuit (Allow{rec:None}, 不 audit, body-aware 不 enforce).
410/// 1xxx 系统协议 (INIT_CONNECT / KEEP_ALIVE / GET_GLOBAL_STATE) 与 v1.4.103
411/// 一致跳过 pipeline 直接 dispatch (handshake / heartbeat / 公开协议无 scope).
412async fn ws_process_requests(
413 mut req_rx: mpsc::Receiver<IncomingRequest>,
414 connections: Arc<DashMap<u64, ClientConn>>,
415 router: Arc<RequestRouter>,
416 config: ServerConfig,
417 counters: Arc<RuntimeCounters>,
418 key_store: Arc<KeyStore>,
419 filter_registry: Arc<FilterRegistry>,
420 server_time_offset_secs: Arc<AtomicI64>,
421) {
422 use crate::listener::ApiServer;
423
424 while let Some(mut req) = req_rx.recv().await {
425 let conn_id = req.conn_id;
426 let proto_id_val = req.proto_id;
427 let serial_no = req.serial_no;
428
429 // 更新 last_keepalive(任何包都算活跃)
430 if let Some(mut conn) = connections.get_mut(&conn_id) {
431 conn.last_keepalive = Instant::now();
432 }
433
434 // ── Step 0: v1.4.106 codex 0532 F3 (P2): daemon-internal proto_id
435 // (高位 0x8000_0000 bit) 绝不应从 raw WS 公开 surface 进入 — 仅 REST
436 // handler 内部合成给 router. 在 AES decrypt 前显式 reject + log,
437 // 防探测 daemon 内部 routing.
438 if futu_auth::is_internal_proto_id(proto_id_val) {
439 tracing::warn!(
440 conn_id,
441 proto_id = proto_id_val,
442 "rejecting daemon-internal proto_id at raw WS public surface (audit 0532 F3)"
443 );
444 continue;
445 }
446
447 // ── Step 1: AES decrypt (always for non-INIT_CONNECT) ─────────────────
448 // pipeline + body-aware 都需 plaintext body. INIT_CONNECT body 是 RSA
449 // 加密的 ConnInitReq, 由 handle_init_connect 自行 RSA decrypt.
450 if proto_id_val != proto_id::INIT_CONNECT
451 && let Some(conn) = connections.get(&conn_id)
452 && conn.aes_encrypt_enabled
453 {
454 match conn.decrypt_body(&req.body) {
455 Ok(decrypted) => {
456 req.body = bytes::Bytes::from(decrypted);
457 }
458 Err(e) => {
459 tracing::warn!(
460 conn_id = conn_id,
461 proto_id = proto_id_val,
462 error = %e,
463 "ws AES decrypt request failed, dropping"
464 );
465 continue;
466 }
467 }
468 }
469
470 // ── Step 2: Pipeline auth ─────────────────────────────────────────────
471 // INIT_CONNECT (handshake) + 1xxx 系统协议 (scope_for_proto_id == None)
472 // 跳 pipeline. 与 v1.4.103 行为一致: 系统协议不审 / 不限频 / 直 dispatch.
473 let needed_scope = futu_auth_pipeline::capability::scope_for_proto_id(proto_id_val);
474 // codex 0522 F1 v1.4.106: 提前抓 conn.key_id 快照让 dispatch IncomingRequest
475 // 也能填 caller_key_id (per-call snapshot, 与 caller_allowed_acc_ids 同源).
476 // 即使 INIT_CONNECT / 1xxx 系统协议 (跳 pipeline) 也带上 — handler 可基于
477 // key_id 做 per-key 订阅配额 / cleanup / 审计.
478 let dispatch_caller_key_id: Option<String> =
479 connections.get(&conn_id).and_then(|c| c.key_id.clone());
480 let allowed_acc_ids_for_resp_filter: Option<HashSet<u64>> =
481 if proto_id_val == proto_id::INIT_CONNECT || needed_scope.is_none() {
482 None
483 } else {
484 // 从 conn 取 key_id 快照, 然后 KeyStore::get_by_id 拿 SIGHUP-aware
485 // 最新 rec (limits / scopes / allowed_acc_ids 都跟最新). 找不到 ↦
486 // Credential::None (legacy mode 放行 / scope mode reject Unauth).
487 let key_id_snap = dispatch_caller_key_id.clone();
488 let rec_opt = key_id_snap.as_ref().and_then(|id| key_store.get_by_id(id));
489 let credential = match rec_opt {
490 Some(rec) => Credential::PreVerified(rec),
491 None => Credential::None,
492 };
493
494 let env = AuthEnvelope {
495 surface: SurfaceId::Ws,
496 endpoint: Endpoint::Proto(proto_id_val),
497 needed_scope,
498 credential,
499 proto_id: Some(proto_id_val),
500 body: &req.body,
501 explicit_acc_id: None,
502 explicit_ctx: None,
503 commit_rate: true, // WS per-msg 是 trade:real 唯一 rate gate
504 audit_emit: true,
505 };
506 let session_id = conn_id.to_string();
507 let audit_ctx =
508 futu_auth::audit::AuditContext::new(None::<&str>, Some(session_id.as_str()));
509
510 // v1.4.106 D1 5c: 走 SurfaceAdapter trait
511 // (`WsAdapter::translate_decision`), 与 4 surface 一致.
512 // Allow → Some(allowed_acc_ids), Reject → None + silent drop.
513 use futu_auth_pipeline::SurfaceAdapter;
514 match futu_auth::audit::with_context(audit_ctx.clone(), || {
515 authenticate_request(&key_store, &counters, env)
516 }) {
517 AuthDecision::Allow {
518 allowed_acc_ids, ..
519 } => allowed_acc_ids,
520 decision @ AuthDecision::Reject { .. } => {
521 // WsAdapter::translate_decision 返 Some(()) 表 reject
522 // (silent drop). pipeline 已 audit reject, 这里不打 log.
523 let silent_drop = WsAdapter::translate_decision(decision);
524 debug_assert!(silent_drop.is_some());
525 continue;
526 }
527 }
528 };
529
530 // ── Step 3: Dispatch ──────────────────────────────────────────────────
531 let response_body = match proto_id_val {
532 proto_id::INIT_CONNECT => match connections.get_mut(&conn_id) {
533 Some(mut conn) => match conn.handle_init_connect(
534 &req.body,
535 config.server_ver,
536 config.login_user_id,
537 config.keepalive_interval,
538 config.rsa_private_key.as_deref(),
539 ) {
540 Ok(body) => Some(body),
541 Err(error) => {
542 tracing::warn!(
543 conn_id,
544 proto_id = proto_id_val,
545 error = %error,
546 "ws InitConnect handling failed"
547 );
548 None
549 }
550 },
551 None => {
552 tracing::warn!(
553 conn_id,
554 proto_id = proto_id_val,
555 "ws InitConnect request received for missing connection"
556 );
557 None
558 }
559 },
560 proto_id::KEEP_ALIVE => match connections.get(&conn_id) {
561 Some(conn) => match conn
562 .handle_keepalive_at(&req.body, server_now_ts(&server_time_offset_secs))
563 {
564 Ok(body) => Some(body),
565 Err(error) => {
566 tracing::warn!(
567 conn_id,
568 proto_id = proto_id_val,
569 error = %error,
570 "ws KeepAlive handling failed"
571 );
572 None
573 }
574 },
575 None => {
576 tracing::warn!(
577 conn_id,
578 proto_id = proto_id_val,
579 "ws KeepAlive request received for missing connection"
580 );
581 None
582 }
583 },
584 _ => {
585 // v1.4.105 D2 T-A1 fix: caller_allowed_acc_ids 从 pipeline allow
586 // decision 真填进 IncomingRequest, 让 dispatch handler (e.g.
587 // SubAccPushHandler) 端 enforce per-acc whitelist defense-in-depth.
588 // codex 0522 F1 v1.4.106: 同步填 caller_key_id (per-call snapshot
589 // 来自 conn.key_id), 让 cross-surface handler 都能识别 caller.
590 let dispatch_req = IncomingRequest::builder(
591 req.conn_id,
592 req.proto_id,
593 req.serial_no,
594 req.proto_fmt_type,
595 req.body.clone(),
596 )
597 .with_idempotency_key(req.idempotency_key.clone())
598 .with_caller_scope(
599 allowed_acc_ids_for_resp_filter
600 .as_ref()
601 .map(|s| std::sync::Arc::new(s.clone())),
602 dispatch_caller_key_id.clone(),
603 )
604 .build();
605 router.dispatch(conn_id, &dispatch_req).await
606 }
607 };
608
609 // ── Step 4: Response filter (TRD_GET_ACC_LIST 等) ────────────────────
610 if let Some(body) = response_body {
611 // FilterRegistry::apply(): proto_id 未注册 → 原 body 不动 (no-op).
612 // 注册了 (e.g. proto 2001) → filter by allowed_acc_ids.
613 let filtered =
614 filter_registry.apply(proto_id_val, body, allowed_acc_ids_for_resp_filter.as_ref());
615 ApiServer::send_response(&connections, conn_id, proto_id_val, serial_no, filtered)
616 .await;
617 }
618 }
619}
620
621#[cfg(test)]
622mod tests;