1use std::collections::HashSet;
31use std::sync::Arc;
32use std::time::Instant;
33
34use dashmap::DashMap;
35use tokio::net::TcpListener;
36use tokio::sync::{mpsc, watch};
37
38mod connection;
39mod handshake;
40
41use connection::prepare_ws_connection;
42use handshake::AuthResult;
43
44use futu_auth::{KeyStore, RuntimeCounters};
45use futu_auth_pipeline::{
46 AuthDecision, AuthEnvelope, Credential, Endpoint, FilterRegistry, RejectKind, SurfaceId,
47 authenticate_request,
48};
49
50pub struct WsAdapter;
63
64impl futu_auth_pipeline::SurfaceAdapter for WsAdapter {
65 type WireResponse = ();
66
67 fn surface_id() -> SurfaceId {
68 SurfaceId::Ws
69 }
70
71 fn translate_reject(_kind: RejectKind, _reason: String) -> Self::WireResponse {
72 }
75}
76use futu_codec::header::ProtoFmtType;
77use futu_core::proto_id;
78use futu_core::server_time::ServerTimeAnchorStore;
79
80use crate::conn::{ClientConn, ConnState, DisconnectNotify, IncomingRequest};
81use crate::listener::{
82 MAX_CONNECTIONS, PER_CONNECTION_REQUEST_QUEUE_CAPACITY, ServerConfig,
83 default_server_time_store, server_now_ts,
84};
85use crate::listener_status::{
86 ListenerBindEventSender, ListenerSurface, notify_listener_failed, notify_listener_opened,
87};
88use crate::router::RequestRouter;
89
90pub struct WsServer {
92 listen_addr: String,
93 config: ServerConfig,
94 connections: Arc<DashMap<u64, ClientConn>>,
95 router: Arc<RequestRouter>,
96 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
97 key_store: Option<Arc<KeyStore>>,
99 counters: Option<Arc<RuntimeCounters>>,
101 filter_registry: Option<Arc<FilterRegistry>>,
104 server_time_store: Arc<ServerTimeAnchorStore>,
105}
106
107pub struct WsServerDeps {
113 connections: Arc<DashMap<u64, ClientConn>>,
114 router: Arc<RequestRouter>,
115 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
116}
117
118impl WsServerDeps {
119 pub fn new(
120 connections: Arc<DashMap<u64, ClientConn>>,
121 router: Arc<RequestRouter>,
122 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
123 ) -> Self {
124 Self {
125 connections,
126 router,
127 subscriptions,
128 }
129 }
130}
131
132impl WsServer {
133 pub fn new(
135 listen_addr: String,
136 config: ServerConfig,
137 connections: Arc<DashMap<u64, ClientConn>>,
138 router: Arc<RequestRouter>,
139 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
140 ) -> Self {
141 Self::with_auth(
142 listen_addr,
143 config,
144 WsServerDeps::new(connections, router, subscriptions),
145 None,
146 None,
147 )
148 }
149
150 pub fn with_auth(
153 listen_addr: String,
154 config: ServerConfig,
155 deps: WsServerDeps,
156 key_store: Option<Arc<KeyStore>>,
157 counters: Option<Arc<RuntimeCounters>>,
158 ) -> Self {
159 Self {
160 listen_addr,
161 config,
162 connections: deps.connections,
163 router: deps.router,
164 subscriptions: deps.subscriptions,
165 key_store,
166 counters,
167 filter_registry: None,
168 server_time_store: default_server_time_store(),
169 }
170 }
171
172 pub fn with_server_time_store(mut self, store: Arc<ServerTimeAnchorStore>) -> Self {
174 self.server_time_store = store;
175 self
176 }
177
178 pub fn with_filter_registry(mut self, registry: Arc<FilterRegistry>) -> Self {
181 self.filter_registry = Some(registry);
182 self
183 }
184
185 pub async fn run(&self) -> anyhow::Result<()> {
187 let (_shutdown_tx, shutdown_rx) = watch::channel(false);
188 self.run_until_shutdown(shutdown_rx).await
189 }
190
191 pub async fn run_until_shutdown(
193 &self,
194 shutdown_rx: watch::Receiver<bool>,
195 ) -> anyhow::Result<()> {
196 self.run_until_shutdown_with_listener_events(shutdown_rx, None)
197 .await
198 }
199
200 pub async fn run_until_shutdown_with_listener_events(
202 &self,
203 mut shutdown_rx: watch::Receiver<bool>,
204 listener_events: Option<ListenerBindEventSender>,
205 ) -> anyhow::Result<()> {
206 let listener = TcpListener::bind(&self.listen_addr)
207 .await
208 .map_err(|error| {
209 notify_listener_failed(&listener_events, ListenerSurface::WebSocket);
210 anyhow::Error::new(crate::bind_hint::io_bind_error(
211 "WebSocket",
212 "--websocket-port",
213 &self.listen_addr,
214 error,
215 ))
216 })?;
217 tracing::info!(addr = %self.listen_addr, "WebSocket server listening");
218
219 let (disconnect_tx, mut disconnect_rx) = mpsc::unbounded_channel::<DisconnectNotify>();
223
224 let key_store_for_process = self
229 .key_store
230 .clone()
231 .unwrap_or_else(|| Arc::new(KeyStore::empty()));
232 let counters_for_process = self
233 .counters
234 .clone()
235 .unwrap_or_else(|| Arc::new(RuntimeCounters::new()));
236 let filter_registry_for_process = self
237 .filter_registry
238 .clone()
239 .unwrap_or_else(|| Arc::new(FilterRegistry::with_defaults()));
240 let cleanup_connections = Arc::clone(&self.connections);
242 let cleanup_subs = self.subscriptions.clone();
243 tokio::spawn(async move {
244 while let Some(notify) = disconnect_rx.recv().await {
245 let removed = cleanup_connections.remove(¬ify.conn_id);
246 if removed.is_some() {
247 if let Some(ref subs) = cleanup_subs {
248 subs.on_disconnect(notify.conn_id);
249 }
250 tracing::info!(
251 conn_id = notify.conn_id,
252 remaining = cleanup_connections.len(),
253 "ws connection removed from pool"
254 );
255 }
256 }
257 });
258
259 let connections = Arc::clone(&self.connections);
261 let key_store_accept = self.key_store.clone();
262 let scope_mode = self.key_store.as_ref().is_some_and(|ks| ks.is_configured());
264 if !scope_mode {
265 tracing::warn!("{}", legacy_mode_warn_tracing_message());
270 eprintln!("{}", legacy_mode_warn_stderr_message());
271 }
272 let _serving =
273 notify_listener_opened(&listener_events, ListenerSurface::WebSocket, &shutdown_rx)
274 .await?;
275 drop(listener_events);
276
277 loop {
278 let (stream, peer_addr) = tokio::select! {
279 _ = crate::listener::shutdown_requested(&mut shutdown_rx) => {
280 tracing::info!("WebSocket server accept loop stopped by shutdown signal");
281 break;
282 }
283 accepted = listener.accept() => accepted?,
284 };
285
286 if connections.len() >= MAX_CONNECTIONS {
287 tracing::warn!(
288 peer = %peer_addr,
289 "max connections reached ({}), rejecting ws client",
290 MAX_CONNECTIONS,
291 );
292 drop(stream);
293 continue;
294 }
295
296 let conn_id = ClientConn::generate_conn_id();
297 let session_generation = ClientConn::generate_session_generation();
298 let aes_key = ClientConn::generate_aes_key();
299 crate::listener::set_nodelay_with_log(&stream, peer_addr, "ws");
300
301 tracing::info!(
302 conn_id = conn_id,
303 peer = %peer_addr,
304 total = connections.len() + 1,
305 "ws client connected"
306 );
307
308 let (req_tx, req_rx) =
309 mpsc::channel::<IncomingRequest>(PER_CONNECTION_REQUEST_QUEUE_CAPACITY);
310 let (tx, authed, close_control, io_start) = prepare_ws_connection(
311 stream,
312 peer_addr,
313 conn_id,
314 aes_key,
315 req_tx,
316 disconnect_tx.clone(),
317 shutdown_rx.clone(),
318 key_store_accept.clone(),
319 )
320 .await;
321
322 let Some(authed) = authed else {
324 continue;
325 };
326 let (key_id, scopes, allowed_markets, allowed_acc_ids) = match authed {
327 AuthResult::Authenticated(rec) => (
328 Some(rec.id.clone()),
329 rec.scopes.clone(),
330 rec.allowed_markets
333 .as_ref()
334 .map(|s| std::sync::Arc::new(s.clone())),
335 rec.allowed_acc_ids
340 .as_ref()
341 .map(|s| std::sync::Arc::new(s.clone())),
342 ),
343 AuthResult::Legacy => (None, HashSet::new(), None, None),
344 };
345
346 let conn = ClientConn {
347 conn_id,
348 session_generation,
349 state: ConnState::Connected,
350 aes_key,
351 aes_encrypt_enabled: false,
352 proto_fmt_type: ProtoFmtType::Protobuf,
353 last_keepalive: Instant::now(),
354 recv_notify: false,
355 ai_type: 0,
356 keepalive_count: std::sync::atomic::AtomicU32::new(0),
357 tx,
358 key_id,
359 scopes,
360 allowed_markets,
361 allowed_acc_ids,
362 };
363
364 connections.insert(conn_id, conn);
365 if let Some(ref subscriptions) = self.subscriptions {
366 subscriptions.on_connect(conn_id, session_generation);
367 subscriptions.register_client_close_control(conn_id, close_control);
368 }
369 tokio::spawn(ws_process_requests(
370 req_rx,
371 Arc::clone(&connections),
372 Arc::clone(&self.router),
373 self.subscriptions.clone(),
374 self.config.clone(),
375 Arc::clone(&counters_for_process),
376 Arc::clone(&key_store_for_process),
377 Arc::clone(&filter_registry_for_process),
378 Arc::clone(&self.server_time_store),
379 peer_addr.ip().is_loopback(),
380 ));
381 io_start.start();
382 }
383
384 Ok(())
385 }
386}
387
388pub(crate) const fn legacy_mode_warn_tracing_message() -> &'static str {
393 "WS server running WITHOUT API key auth (legacy mode); \
394 all WS clients accept unauthenticated handshake (no-token / \
395 wrong-bearer / bogus-query all return success). \
396 Pass KeyStore via with_auth() to enable. \
397 v2 will default-reject; migrate to --rest-keys-file / --ws-keys-file for production."
398}
399
400pub(crate) const fn legacy_mode_warn_stderr_message() -> &'static str {
404 "⚠️ WS server (legacy mode, no --ws-keys-file): \
405 unauthenticated handshakes accepted. v2 will default-reject. \
406 Migrate to --ws-keys-file for production."
407}
408
409async fn ws_process_requests(
429 mut req_rx: mpsc::Receiver<IncomingRequest>,
430 connections: Arc<DashMap<u64, ClientConn>>,
431 router: Arc<RequestRouter>,
432 subscriptions: Option<Arc<crate::subscription::SubscriptionManager>>,
433 config: ServerConfig,
434 counters: Arc<RuntimeCounters>,
435 key_store: Arc<KeyStore>,
436 filter_registry: Arc<FilterRegistry>,
437 server_time_store: Arc<ServerTimeAnchorStore>,
438 peer_is_loopback: bool,
439) {
440 use crate::listener::ApiServer;
441
442 let mut pending_init_connect: Option<tokio::task::JoinHandle<()>> = None;
443 while let Some(mut req) = req_rx.recv().await {
444 req.caller_is_loopback = peer_is_loopback;
445 req.caller_legacy_local_mode = !key_store.is_configured();
446 let Some(session_generation) = connections
447 .get(&req.conn_id)
448 .map(|conn| conn.session_generation)
449 else {
450 continue;
451 };
452 req.session_generation = session_generation;
453 req.caller_has_auth_setup_scope = connections
454 .get(&req.conn_id)
455 .is_some_and(|conn| conn.scopes.contains(&futu_auth::Scope::AuthSetup));
456 let conn_id = req.conn_id;
457 let proto_id_val = req.proto_id;
458 let serial_no = req.serial_no;
459
460 if let Some(mut conn) = connections.get_mut(&conn_id) {
462 conn.last_keepalive = Instant::now();
463 }
464
465 if futu_auth::is_internal_proto_id(proto_id_val) {
470 tracing::warn!(
471 conn_id,
472 proto_id = proto_id_val,
473 "rejecting daemon-internal proto_id at raw WS public surface (audit 0532 F3)"
474 );
475 continue;
476 }
477
478 if proto_id_val != proto_id::INIT_CONNECT
479 && connections
480 .get(&conn_id)
481 .is_some_and(|conn| conn.state == crate::conn::ConnState::Connected)
482 && !crate::identity::StartupReadiness::is_prelogin_proto(proto_id_val)
483 {
484 if router.startup_readiness().snapshot().state != crate::identity::StartupState::Ready {
485 if let Some(body) = router.dispatch(conn_id, &req).await
486 && ApiServer::send_response(
487 connections.as_ref(),
488 conn_id,
489 proto_id_val,
490 serial_no,
491 body,
492 )
493 .await
494 {
495 req.mark_response_committed();
496 }
497 continue;
498 }
499 let Some(pending) = pending_init_connect.take() else {
500 tracing::warn!(
501 conn_id,
502 proto_id = proto_id_val,
503 "dropping WS request from Ready connection with no completed InitConnect"
504 );
505 continue;
506 };
507 if let Err(error) = pending.await {
508 tracing::warn!(conn_id, error = %error, "pending WS InitConnect task failed");
509 continue;
510 }
511 if connections
512 .get(&conn_id)
513 .is_none_or(|conn| conn.state == crate::conn::ConnState::Connected)
514 {
515 continue;
516 }
517 }
518
519 if proto_id_val != proto_id::INIT_CONNECT
523 && let Some(conn) = connections.get(&conn_id)
524 && conn.aes_encrypt_enabled
525 {
526 match conn.decrypt_body(&req.body) {
527 Ok(decrypted) => {
528 req.body = bytes::Bytes::from(decrypted);
529 }
530 Err(e) => {
531 tracing::warn!(
532 conn_id = conn_id,
533 proto_id = proto_id_val,
534 error = %e,
535 "ws AES decrypt request failed, dropping"
536 );
537 continue;
538 }
539 }
540 }
541
542 let needed_scope = futu_auth_pipeline::capability::scope_for_proto_id(proto_id_val);
546 let dispatch_caller_key_id: Option<String> =
551 connections.get(&conn_id).and_then(|c| c.key_id.clone());
552 let allowed_acc_ids_for_resp_filter: Option<HashSet<u64>> =
553 if proto_id_val == proto_id::INIT_CONNECT || needed_scope.is_none() {
554 None
555 } else {
556 let key_id_snap = dispatch_caller_key_id.clone();
560 let rec_opt = key_id_snap.as_ref().and_then(|id| key_store.get_by_id(id));
561 let credential = match rec_opt {
562 Some(rec) => Credential::PreVerified(rec),
563 None => Credential::None,
564 };
565
566 let env = AuthEnvelope {
567 surface: SurfaceId::Ws,
568 endpoint: Endpoint::Proto(proto_id_val),
569 needed_scope,
570 credential,
571 proto_id: Some(proto_id_val),
572 body: &req.body,
573 explicit_acc_id: None,
574 explicit_ctx: None,
575 commit_rate: true, audit_emit: true,
577 };
578 let session_id = conn_id.to_string();
579 let audit_ctx =
580 futu_auth::audit::AuditContext::new(None::<&str>, Some(session_id.as_str()));
581
582 use futu_auth_pipeline::SurfaceAdapter;
586 match futu_auth::audit::with_context(audit_ctx.clone(), || {
587 authenticate_request(&key_store, &counters, env)
588 }) {
589 AuthDecision::Allow {
590 allowed_acc_ids, ..
591 } => allowed_acc_ids,
592 decision @ AuthDecision::Reject { .. } => {
593 let silent_drop = WsAdapter::translate_decision(decision);
596 debug_assert!(silent_drop.is_some());
597 continue;
598 }
599 }
600 };
601
602 let response_body = match proto_id_val {
604 proto_id::INIT_CONNECT => match crate::conn::start_init_connect_for_startup(
605 Arc::clone(&connections),
606 router.startup_readiness().clone(),
607 conn_id,
608 &req.body,
609 serial_no,
610 config.server_ver,
611 config.keepalive_interval,
612 config.rsa_private_key.clone(),
613 ) {
614 Ok(crate::conn::InitConnectStart::Immediate(prepared)) => {
615 if let Err(error) = crate::conn::complete_prepared_init_connect(
616 connections.as_ref(),
617 conn_id,
618 serial_no,
619 prepared,
620 )
621 .await
622 {
623 tracing::warn!(conn_id, error = %error, "ws InitConnect response failed");
624 }
625 None
626 }
627 Ok(crate::conn::InitConnectStart::Deferred(task)) => {
628 if let Some(previous) = pending_init_connect.replace(task) {
629 previous.abort();
630 }
631 None
632 }
633 Err(error) => {
634 tracing::warn!(
635 conn_id,
636 proto_id = proto_id_val,
637 error = %error,
638 "ws InitConnect handling failed"
639 );
640 None
641 }
642 },
643 proto_id::KEEP_ALIVE => match connections.get(&conn_id) {
644 Some(conn) => {
645 match conn.handle_keepalive_at(&req.body, server_now_ts(&server_time_store)) {
646 Ok(body) => Some(body),
647 Err(error) => {
648 tracing::warn!(
649 conn_id,
650 proto_id = proto_id_val,
651 error = %error,
652 "ws KeepAlive handling failed"
653 );
654 None
655 }
656 }
657 }
658 None => {
659 tracing::warn!(
660 conn_id,
661 proto_id = proto_id_val,
662 "ws KeepAlive request received for missing connection"
663 );
664 None
665 }
666 },
667 _ => {
668 let dispatch_req = IncomingRequest::builder(
674 req.conn_id,
675 req.proto_id,
676 req.serial_no,
677 req.proto_fmt_type,
678 req.body.clone(),
679 )
680 .with_response_commit_from(&req)
681 .with_transport(req.transport)
682 .with_session_generation(req.session_generation)
683 .with_idempotency_key(req.idempotency_key.clone())
684 .with_caller_scope(
685 allowed_acc_ids_for_resp_filter
686 .as_ref()
687 .map(|s| std::sync::Arc::new(s.clone())),
688 dispatch_caller_key_id.clone(),
689 )
690 .build();
691 router.dispatch(conn_id, &dispatch_req).await
692 }
693 };
694
695 crate::listener::reconcile_post_dispatch_connection_state(
696 &connections,
697 subscriptions.as_deref(),
698 conn_id,
699 );
700
701 if let Some(body) = response_body {
703 let filtered =
706 filter_registry.apply(proto_id_val, body, allowed_acc_ids_for_resp_filter.as_ref());
707 if ApiServer::send_response(&connections, conn_id, proto_id_val, serial_no, filtered)
708 .await
709 {
710 req.mark_response_committed();
711 }
712 }
713 }
714 if let Some(pending) = pending_init_connect {
715 pending.abort();
716 }
717}
718
719#[cfg(test)]
720mod tests;