Skip to main content

futu_server/conn/
model.rs

1/// 单个客户端连接
2pub use futu_core::INTERNAL_UI_CLIENT_ID;
3
4pub(crate) struct DecodedInitConnect {
5    request: futu_proto::init_connect::Request,
6}
7
8pub(crate) struct PreparedInitConnect {
9    response_body: Vec<u8>,
10    recv_notify: bool,
11    ai_type: i32,
12    enable_aes: bool,
13}
14
15impl DecodedInitConnect {
16    pub(crate) fn is_internal_ui(&self) -> bool {
17        self.request.c2s.client_id == INTERNAL_UI_CLIENT_ID
18    }
19}
20
21pub struct ClientConn {
22    /// 随机连接 ID(对应 C++ `GetRand_MilliTimeAndU22`)
23    pub conn_id: u64,
24    /// Monotonic physical-socket generation allocated at accept time. Direct
25    /// async push owners must match this value inside the final connection map
26    /// lookup; a retired request may never recreate it.
27    pub session_generation: u64,
28    /// 连接状态:InitConnect 前 / 后 / 已断开
29    pub state: ConnState,
30    /// 随机 AES-128 key(InitConnect 响应里下发给客户端)
31    pub aes_key: [u8; 16],
32    /// AES 加解密已启用(InitConnect 完成且配置了 RSA 时为 true)
33    pub aes_encrypt_enabled: bool,
34    /// 该连接协商的 proto 格式(Protobuf / JSON)
35    pub proto_fmt_type: ProtoFmtType,
36    /// 上次收到 KeepAlive 的时间,用于超时检查
37    pub last_keepalive: Instant,
38    /// InitConnect.C2S.recvNotify:此连接是否接收市场状态 / 交易解锁等通知。
39    ///
40    /// C++ 在 `APIServer_InitConnect.cpp` 里把该字段写入 ConnInfo;
41    /// `RegQotPush` / `Qot_Sub(isRegOrUnRegPush)` 不会修改这个开关。
42    pub recv_notify: bool,
43    /// InitConnect.C2S.aiType:AI 调用类型。C++ 10.7
44    /// `APIServer_InitConnect.cpp` 缺省为 0,并写入连接状态。
45    pub ai_type: i32,
46    /// 已收到的 KeepAlive 计数(监控用)
47    pub keepalive_count: AtomicU32,
48    /// 发送帧到此连接
49    pub tx: mpsc::Sender<FutuFrame>,
50
51    // ---- v1.0 WS per-message scope 鉴权(raw TCP legacy 兼容:scopes 空集全放行)----
52    /// 该连接绑定的 API key id;WS 握手时填,未配 keys.json 时为 None
53    pub key_id: Option<String>,
54    /// 该连接持有的 scope 集合;空集 = legacy 模式 / TCP 直连,scope 检查放行
55    pub scopes: HashSet<Scope>,
56    /// v1.4.105 D3 (Phase 4) T-B2: 该连接 caller key 的 `allowed_markets` 硬
57    /// 限额 (大写字符串 set, e.g. {"HK","US"}). `None` = 无限制 (legacy 模式
58    /// / TCP 直连默认全开 / 未配 allowed_markets); `Some(set)` 非空 → push 端
59    /// 应过滤 trd_market 不在 set 中的 trade event.
60    ///
61    /// **触发**: WS handshake 时从 `KeyRecord.allowed_markets` 拷贝过来.
62    /// `PushDispatcher::push_trd_acc` 端 Layer 3 filter 检查. 与
63    /// `caller_allowed_acc_ids` (Layer 1, per-call snapshot in IncomingRequest)
64    /// 区别: 本字段是 per-conn snapshot (handshake 时一次性), 不随 per-call
65    /// 重读 — KeyRecord SIGHUP reload 后**仅新建连接生效**, 老连接保持 snapshot
66    /// (与 `scopes` / `caller_allowed_acc_ids` 的 snapshot 语义一致).
67    pub allowed_markets: Option<std::sync::Arc<HashSet<String>>>,
68    /// codex round 1 F4 (P2) v1.4.105: 该连接 caller key 的 `allowed_acc_ids`
69    /// 硬限额 (per-conn snapshot, handshake 时一次性). `None` / `Some(empty)` =
70    /// 无限制 (legacy 模式 / TCP 直连默认全开 / 未配 allowed_acc_ids);
71    /// `Some(non-empty set)` →
72    /// `PushDispatcher::push_trd_acc` 端 push-time 硬过滤 acc_id 不在 set 中
73    /// 的 trade event (Layer 1, 与 `allowed_markets` 的 Layer 3 互补).
74    /// Deny-all 使用 sentinel `{0}`,不使用空集合。
75    ///
76    /// **触发**: codex F4 指出 raw TCP push 端只查 `acc:read` scope +
77    /// `allowed_markets`, 不查 `allowed_acc_ids`. 即使 request-time
78    /// `SubAccPushHandler` 已阻止越权订阅, stale subscription / KeyRecord
79    /// reload 后窄化的 acc 范围 / 历史 bug 留下的 conn→acc 关系 仍可能让 push
80    /// 漏 leak. 本字段提供第二层 push-time 兜底.
81    ///
82    /// 与 `caller_allowed_acc_ids` (IncomingRequest, per-call) 区别: 本字段
83    /// 在 push-time 用 (无 IncomingRequest), per-conn snapshot 与 `scopes` /
84    /// `allowed_markets` 的 snapshot 语义一致.
85    pub allowed_acc_ids: Option<std::sync::Arc<HashSet<u64>>>,
86}
87
88#[derive(Clone, Copy, Debug, Eq, PartialEq)]
89pub enum RequestTransport {
90    RawTcp,
91    RawWebSocket,
92}
93
94/// 从连接接收到的请求
95#[derive(Debug)]
96pub struct IncomingRequest {
97    response_commit: Arc<ResponseCommitState>,
98    pub transport: RequestTransport,
99    /// Physical socket generation copied from `ClientConn` by the listener
100    /// immediately before dispatch. Non-raw adapters retain zero.
101    pub session_generation: u64,
102    /// 发送请求的连接 ID(用于响应路由 + SubscriptionManager / cache 记账)
103    ///
104    /// **跨 surface 命名空间分配**(v1.4.106 codex 0517 ζ25-redo F2 沉淀):
105    /// - raw TCP listener: `ClientConn::generate_conn_id()` 派生(u32 范围)
106    /// - REST: `crates/futu-rest/src/routes/qot.rs::REST_SHARED_CONN`
107    ///   = `0xFFFF_FFFE`(u32 上限附近, 单值共享)
108    /// - gRPC: `crates/futu-grpc/src/auth.rs::GRPC_STABLE_CONN_NAMESPACE`
109    ///   = `0x4000_0000_0000_0000`(bit 62 namespace, 按 caller 派生)
110    /// - WS / MCP: 通常派生自所属物理 TCP 连接的 conn_id
111    ///
112    /// 各 surface 不重叠. 加新 surface 时分配一个 namespace base, 不要与
113    /// 上述 4 个段重合.
114    pub conn_id: u64,
115    /// 协议 ID(对齐 C++ `NN_ProtoCmd_*`)
116    pub proto_id: u32,
117    /// 序列号(和 Response 配对,供 client 端请求-响应匹配)
118    pub serial_no: u32,
119    /// 请求体 proto 格式(Protobuf / JSON)
120    pub proto_fmt_type: ProtoFmtType,
121    /// 请求 body(已解密后的明文)
122    pub body: Bytes,
123    /// v1.4.38 Phase 4: 订单幂等 key(由 REST `Idempotency-Key` header / gRPC
124    /// metadata / WS envelope / MCP tool args 填入)。None 表示客户端未传,
125    /// handler 走无幂等直通 path(backward-compat)。
126    pub idempotency_key: Option<String>,
127    /// v1.4.106 codex 0920 F1 (P1): caller key id 副本 (per-call snapshot,
128    /// 由 surface adapter 层从 KeyRecord 读取后填入).
129    ///
130    /// **目标**: idempotency cache key namespace 必须含 caller key id, 否则
131    /// 不同 caller 用同 Idempotency-Key 会跨 caller 命中老 response —— 严重
132    /// 跨账户数据泄漏 + 重复下单 silent fail.
133    ///
134    /// `None` = 无 caller 标识 (legacy TCP / 未 auth) → namespace 用 `<no_key>`
135    /// 占位符. `Some("alice")` = WS / MCP / REST 已 auth 的 caller —— namespace
136    /// 用 `<caller_key_id="alice">`, 不与其他 caller 串.
137    pub caller_key_id: Option<String>,
138    /// v1.4.105 D2 contract-hardening 补丁: caller key 的 `allowed_acc_ids` 硬限额
139    /// 副本 (per-call snapshot, 在 surface adapter 层从 KeyRecord 读取后填入).
140    ///
141    /// **目标**: 让 dispatch-time handlers (e.g. `SubAccPushHandler` 注册 acc_id
142    /// 到 SubscriptionManager) 也能 enforce per-acc whitelist — 即使上游 pipeline
143    /// body-aware step 已 enforce, 让 handler 自己 defense-in-depth 防 future
144    /// regression (新 surface 加进来漏调 pipeline body-aware).
145    ///
146    /// `None` / `Some(empty)` = caller 无 acc_id 限制 (legacy mode 或 unrestricted
147    /// key) → handler 不 filter; `Some(non-empty set)` → handler 应 reject 不在
148    /// set 中的 acc_id. Deny-all 使用 sentinel `{0}`,不使用空集合。
149    pub caller_allowed_acc_ids: Option<std::sync::Arc<std::collections::HashSet<u64>>>,
150    /// Central pre-login Verification admission facts supplied by adapters.
151    pub caller_has_auth_setup_scope: bool,
152    pub caller_is_loopback: bool,
153    pub caller_legacy_local_mode: bool,
154}
155
156impl IncomingRequest {
157    #[must_use]
158    pub fn response_commit_waiter(&self) -> ResponseCommitWaiter {
159        ResponseCommitWaiter {
160            state: Arc::clone(&self.response_commit),
161        }
162    }
163
164    pub(crate) fn mark_response_committed(&self) {
165        self.response_commit
166            .committed
167            .store(true, std::sync::atomic::Ordering::Release);
168        self.response_commit.notify.notify_waiters();
169    }
170
171    /// codex 0522 F4 v1.4.106: cross-surface 单测 hook. 构 IncomingRequest
172    /// 并填 caller scope (`caller_key_id` + `caller_allowed_acc_ids`) — 让
173    /// REST / gRPC / WS / MCP 等 surface 的 adapter 都用同一构造路径, 防
174    /// "某个 surface 漏填字段" silent regression.
175    ///
176    /// 之前 4 surface 各写一份 struct literal, 加新字段需逐个改, 漏一个就
177    /// 出现 silent None — 与坑 #54 schema-only fix 同模式 (实装符号 vs 真
178    /// 行为差距). 本 helper 是 single point, 加新字段 schema 自动 propagate.
179    ///
180    /// **注意**: 本 helper 不 take ownership of body — caller 已 own bytes.
181    /// idempotency_key / caller_key_id 接 String 而非 &str 让 caller 决定
182    /// 是 clone 还是 move.
183    pub fn builder(
184        conn_id: u64,
185        proto_id: u32,
186        serial_no: u32,
187        proto_fmt_type: ProtoFmtType,
188        body: Bytes,
189    ) -> IncomingRequestBuilder {
190        IncomingRequestBuilder {
191            request: Self {
192                response_commit: Arc::new(ResponseCommitState::default()),
193                transport: RequestTransport::RawTcp,
194                session_generation: 0,
195                conn_id,
196                proto_id,
197                serial_no,
198                proto_fmt_type,
199                body,
200                idempotency_key: None,
201                caller_allowed_acc_ids: None,
202                caller_key_id: None,
203                caller_has_auth_setup_scope: false,
204                caller_is_loopback: false,
205                caller_legacy_local_mode: false,
206            },
207        }
208    }
209}
210
211#[derive(Debug, Default)]
212struct ResponseCommitState {
213    committed: std::sync::atomic::AtomicBool,
214    notify: tokio::sync::Notify,
215}
216
217#[derive(Clone, Debug)]
218pub struct ResponseCommitWaiter {
219    state: Arc<ResponseCommitState>,
220}
221
222impl ResponseCommitWaiter {
223    pub async fn wait(self) {
224        loop {
225            let notified = self.state.notify.notified();
226            if self
227                .state
228                .committed
229                .load(std::sync::atomic::Ordering::Acquire)
230            {
231                return;
232            }
233            notified.await;
234        }
235    }
236}
237
238/// Thin builder for `IncomingRequest`.
239///
240/// The base request shape is the wire envelope; idempotency and caller scope are
241/// optional per-surface decorations. Keeping those defaults here avoids every
242/// REST / gRPC / raw WS / MCP adapter spelling out `None` independently.
243#[derive(Debug)]
244pub struct IncomingRequestBuilder {
245    request: IncomingRequest,
246}
247
248impl IncomingRequestBuilder {
249    pub(crate) fn with_session_generation(mut self, session_generation: u64) -> Self {
250        self.request.session_generation = session_generation;
251        self
252    }
253
254    /// Reuse the wire request's response-commit state when an adapter rebuilds
255    /// the request to attach an authenticated caller snapshot.
256    ///
257    /// The WebSocket pipeline sends the response for the original wire request,
258    /// while handlers receive the rebuilt request. Sharing this state is what
259    /// lets an ACK-gated handler observe the actual socket send commit.
260    pub(crate) fn with_response_commit_from(mut self, wire_request: &IncomingRequest) -> Self {
261        self.request.response_commit = Arc::clone(&wire_request.response_commit);
262        self
263    }
264
265    pub fn with_transport(mut self, transport: RequestTransport) -> Self {
266        self.request.transport = transport;
267        self
268    }
269
270    pub fn with_idempotency_key(mut self, idempotency_key: Option<String>) -> Self {
271        self.request.idempotency_key = idempotency_key;
272        self
273    }
274
275    pub fn with_caller_scope(
276        mut self,
277        caller_allowed_acc_ids: Option<std::sync::Arc<HashSet<u64>>>,
278        caller_key_id: Option<String>,
279    ) -> Self {
280        self.request.caller_allowed_acc_ids = caller_allowed_acc_ids;
281        self.request.caller_key_id = caller_key_id;
282        self
283    }
284
285    pub fn with_auth_setup_admission(
286        mut self,
287        caller_has_auth_setup_scope: bool,
288        caller_is_loopback: bool,
289        caller_legacy_local_mode: bool,
290    ) -> Self {
291        self.request.caller_has_auth_setup_scope = caller_has_auth_setup_scope;
292        self.request.caller_is_loopback = caller_is_loopback;
293        self.request.caller_legacy_local_mode = caller_legacy_local_mode;
294        self
295    }
296
297    pub fn build(self) -> IncomingRequest {
298        self.request
299    }
300}
301
302impl From<IncomingRequestBuilder> for IncomingRequest {
303    fn from(builder: IncomingRequestBuilder) -> Self {
304        builder.build()
305    }
306}
307
308fn conn_id_epoch_elapsed_or_zero() -> Duration {
309    match SystemTime::now().duration_since(UNIX_EPOCH) {
310        Ok(elapsed) => elapsed,
311        Err(err) => {
312            tracing::warn!(
313                error = %err,
314                "system wall clock is before UNIX_EPOCH; using zero duration fallback for conn_id"
315            );
316            Duration::ZERO
317        }
318    }
319}