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}