Skip to main content

fips_core/transport/
websocket.rs

1//! Binary WebSocket physical transport.
2//!
3//! One binary WebSocket message carries one FIPS physical record. A tiny
4//! nonce-bound key-hint exchange precedes Noise IK when a client has only a
5//! seed URL; the hint is untrusted routing metadata and never bypasses FIPS
6//! identity authentication or ACLs.
7
8use super::tcp::stream::validate_stream_record;
9use super::{
10    ConnectionState, DiscoveredPeer, PacketBuffer, PacketTx, ReceivedPacket, Transport,
11    TransportAddr, TransportError, TransportId, TransportState, TransportType,
12};
13use crate::Identity;
14use crate::config::WebSocketConfig;
15use crate::dataplane::validate_direct_fsp_transport_fragment;
16use crate::discovery::local_udp::LocalKeyHint;
17use futures::{SinkExt, StreamExt};
18use rand::RngExt;
19use secp256k1::XOnlyPublicKey;
20use serde::Serialize;
21use std::collections::{HashMap, VecDeque};
22use std::net::SocketAddr;
23use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
24use std::sync::{Arc, Mutex as StdMutex};
25use std::time::{Duration, SystemTime, UNIX_EPOCH};
26use tokio::net::{TcpListener, TcpStream};
27use tokio::sync::{Mutex, Semaphore, mpsc};
28use tokio::task::JoinHandle;
29use tokio_tungstenite::tungstenite::handshake::server::{ErrorResponse, Request, Response};
30use tokio_tungstenite::tungstenite::protocol::WebSocketConfig as TungsteniteConfig;
31use tokio_tungstenite::tungstenite::{Bytes, Message};
32use tokio_tungstenite::{WebSocketStream, accept_hdr_async_with_config, connect_async_with_config};
33use tracing::{debug, info};
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36enum Direction {
37    Inbound,
38    Outbound,
39}
40
41struct Connection {
42    generation: u64,
43    tx: mpsc::Sender<Vec<u8>>,
44}
45
46type ConnectionPool = Arc<Mutex<HashMap<TransportAddr, Connection>>>;
47type ConnectionStates = Arc<StdMutex<HashMap<TransportAddr, ConnectionState>>>;
48type DiscoveryQueue = Arc<StdMutex<VecDeque<DiscoveredPeer>>>;
49
50#[derive(Debug, Default)]
51struct WebSocketStats {
52    connections_opened: AtomicU64,
53    connections_closed: AtomicU64,
54    connections_rejected: AtomicU64,
55    reconnect_attempts: AtomicU64,
56    frames_sent: AtomicU64,
57    frames_received: AtomicU64,
58    bytes_sent: AtomicU64,
59    bytes_received: AtomicU64,
60    invalid_frames: AtomicU64,
61    send_queue_full: AtomicU64,
62}
63
64#[derive(Debug, Serialize)]
65pub struct WebSocketStatsSnapshot {
66    pub connections_opened: u64,
67    pub connections_closed: u64,
68    pub connections_rejected: u64,
69    pub reconnect_attempts: u64,
70    pub frames_sent: u64,
71    pub frames_received: u64,
72    pub bytes_sent: u64,
73    pub bytes_received: u64,
74    pub invalid_frames: u64,
75    pub send_queue_full: u64,
76}
77
78impl WebSocketStats {
79    fn snapshot(&self) -> WebSocketStatsSnapshot {
80        let load = |value: &AtomicU64| value.load(Ordering::Relaxed);
81        WebSocketStatsSnapshot {
82            connections_opened: load(&self.connections_opened),
83            connections_closed: load(&self.connections_closed),
84            connections_rejected: load(&self.connections_rejected),
85            reconnect_attempts: load(&self.reconnect_attempts),
86            frames_sent: load(&self.frames_sent),
87            frames_received: load(&self.frames_received),
88            bytes_sent: load(&self.bytes_sent),
89            bytes_received: load(&self.bytes_received),
90            invalid_frames: load(&self.invalid_frames),
91            send_queue_full: load(&self.send_queue_full),
92        }
93    }
94}
95
96#[derive(Clone)]
97struct Runtime {
98    transport_id: TransportId,
99    config: WebSocketConfig,
100    local_pubkey: [u8; 32],
101    packet_tx: PacketTx,
102    pool: ConnectionPool,
103    states: ConnectionStates,
104    discoveries: DiscoveryQueue,
105    running: Arc<AtomicBool>,
106    total_slots: Arc<Semaphore>,
107    inbound_slots: Arc<Semaphore>,
108    generation: Arc<AtomicU64>,
109    stats: Arc<WebSocketStats>,
110}
111
112impl Runtime {
113    fn websocket_config(&self) -> TungsteniteConfig {
114        let mut config = TungsteniteConfig::default();
115        config.max_message_size = Some(self.config.max_frame_bytes());
116        config.max_frame_size = Some(self.config.max_frame_bytes());
117        config.max_write_buffer_size = self.config.max_frame_bytes().saturating_mul(2);
118        config.write_buffer_size = 0;
119        config
120    }
121
122    fn next_generation(&self) -> u64 {
123        self.generation.fetch_add(1, Ordering::Relaxed)
124    }
125
126    fn set_state(&self, addr: &TransportAddr, state: ConnectionState) {
127        self.states
128            .lock()
129            .unwrap_or_else(|error| error.into_inner())
130            .insert(addr.clone(), state);
131    }
132
133    fn clear_state_if(&self, addr: &TransportAddr, generation: u64) {
134        let connected_generation = self
135            .pool
136            .try_lock()
137            .ok()
138            .and_then(|pool| pool.get(addr).map(|connection| connection.generation));
139        if connected_generation != Some(generation) {
140            self.states
141                .lock()
142                .unwrap_or_else(|error| error.into_inner())
143                .remove(addr);
144        }
145    }
146}
147
148/// Generic WebSocket physical transport.
149pub struct WebSocketTransport {
150    transport_id: TransportId,
151    name: Option<String>,
152    config: WebSocketConfig,
153    state: TransportState,
154    local_addr: Option<SocketAddr>,
155    runtime: Runtime,
156    tasks: Arc<StdMutex<Vec<JoinHandle<()>>>>,
157}
158
159impl WebSocketTransport {
160    pub fn new(
161        transport_id: TransportId,
162        name: Option<String>,
163        config: WebSocketConfig,
164        packet_tx: PacketTx,
165        identity: &Identity,
166    ) -> Self {
167        let max_connections = config.max_connections();
168        let max_inbound = config.max_inbound_connections();
169        let runtime = Runtime {
170            transport_id,
171            config: config.clone(),
172            local_pubkey: identity.pubkey().serialize(),
173            packet_tx,
174            pool: Arc::new(Mutex::new(HashMap::new())),
175            states: Arc::new(StdMutex::new(HashMap::new())),
176            discoveries: Arc::new(StdMutex::new(VecDeque::new())),
177            running: Arc::new(AtomicBool::new(false)),
178            total_slots: Arc::new(Semaphore::new(max_connections)),
179            inbound_slots: Arc::new(Semaphore::new(max_inbound)),
180            generation: Arc::new(AtomicU64::new(1)),
181            stats: Arc::new(WebSocketStats::default()),
182        };
183        Self {
184            transport_id,
185            name,
186            config,
187            state: TransportState::Configured,
188            local_addr: None,
189            runtime,
190            tasks: Arc::new(StdMutex::new(Vec::new())),
191        }
192    }
193
194    pub fn name(&self) -> Option<&str> {
195        self.name.as_deref()
196    }
197
198    pub fn local_addr(&self) -> Option<SocketAddr> {
199        self.local_addr
200    }
201
202    pub fn public_url(&self) -> Option<&str> {
203        self.config.public_url.as_deref()
204    }
205
206    pub(crate) fn is_configured_seed_addr(&self, addr: &TransportAddr) -> bool {
207        addr.as_str().is_some_and(|candidate| {
208            self.config
209                .seed_urls
210                .iter()
211                .any(|seed_url| seed_url == candidate)
212        })
213    }
214
215    pub(crate) fn is_configured_adjacency(
216        &self,
217        addr: &TransportAddr,
218        handshake_is_initiator: bool,
219    ) -> bool {
220        if self.is_configured_seed_addr(addr) {
221            // The URL identifies the operator-configured physical dial even
222            // when simultaneous FIPS initiation makes this node the responder.
223            // Handshake role is not transport direction.
224            true
225        } else if !handshake_is_initiator {
226            // Accepting clients on a configured listener is an explicit
227            // operator choice; every promoted client has already completed
228            // the authenticated FIPS handshake.
229            self.config.bind_addr.is_some()
230        } else {
231            false
232        }
233    }
234
235    pub fn stats(&self) -> WebSocketStatsSnapshot {
236        self.runtime.stats.snapshot()
237    }
238
239    fn push_task(&self, task: JoinHandle<()>) {
240        self.tasks
241            .lock()
242            .unwrap_or_else(|error| error.into_inner())
243            .push(task);
244    }
245
246    pub async fn start_async(&mut self) -> Result<(), TransportError> {
247        if !self.state.can_start() {
248            return Err(TransportError::AlreadyStarted);
249        }
250        self.config
251            .validate()
252            .map_err(TransportError::StartFailed)?;
253        self.state = TransportState::Starting;
254        self.runtime.running.store(true, Ordering::Release);
255
256        if let Some(bind_addr) = self.config.bind_addr.as_deref() {
257            let bind_addr = bind_addr
258                .parse::<SocketAddr>()
259                .map_err(|error| TransportError::StartFailed(error.to_string()))?;
260            let listener = TcpListener::bind(bind_addr)
261                .await
262                .map_err(|error| TransportError::bind_failed(bind_addr, error))?;
263            self.local_addr = Some(
264                listener
265                    .local_addr()
266                    .map_err(|error| TransportError::StartFailed(error.to_string()))?,
267            );
268            self.push_task(tokio::spawn(run_accept_loop(
269                self.runtime.clone(),
270                listener,
271            )));
272        }
273
274        for seed_url in self.config.seed_urls.clone() {
275            let addr = TransportAddr::from_string(&seed_url);
276            self.runtime.set_state(&addr, ConnectionState::Connecting);
277            self.push_task(tokio::spawn(run_seed_dialer(self.runtime.clone(), addr)));
278        }
279
280        self.state = TransportState::Up;
281        info!(
282            transport_id = %self.transport_id,
283            local_addr = ?self.local_addr,
284            seeds = self.config.seed_urls.len(),
285            "WebSocket transport started"
286        );
287        Ok(())
288    }
289
290    pub async fn stop_async(&mut self) -> Result<(), TransportError> {
291        if !self.state.is_operational() {
292            return Err(TransportError::NotStarted);
293        }
294        self.runtime.running.store(false, Ordering::Release);
295        let tasks = {
296            let mut tasks = self.tasks.lock().unwrap_or_else(|error| error.into_inner());
297            std::mem::take(&mut *tasks)
298        };
299        for task in &tasks {
300            task.abort();
301        }
302        for task in tasks {
303            let _ = task.await;
304        }
305        self.runtime.pool.lock().await.clear();
306        self.runtime
307            .states
308            .lock()
309            .unwrap_or_else(|error| error.into_inner())
310            .clear();
311        self.local_addr = None;
312        self.state = TransportState::Down;
313        Ok(())
314    }
315
316    pub async fn send_async(
317        &self,
318        addr: &TransportAddr,
319        data: &[u8],
320    ) -> Result<usize, TransportError> {
321        if !self.state.is_operational() {
322            return Err(TransportError::NotStarted);
323        }
324        if data.len() > self.config.max_frame_bytes() {
325            return Err(TransportError::MtuExceeded {
326                packet_size: data.len(),
327                mtu: self.config.max_frame_bytes().min(u16::MAX as usize) as u16,
328            });
329        }
330        validate_websocket_record(data).map_err(TransportError::SendFailed)?;
331        let tx = self
332            .runtime
333            .pool
334            .lock()
335            .await
336            .get(addr)
337            .map(|connection| connection.tx.clone())
338            .ok_or(TransportError::NotStarted)?;
339        tx.try_send(data.to_vec()).map_err(|error| match error {
340            mpsc::error::TrySendError::Full(_) => {
341                self.runtime
342                    .stats
343                    .send_queue_full
344                    .fetch_add(1, Ordering::Relaxed);
345                TransportError::SendFailed("WebSocket send queue full".into())
346            }
347            mpsc::error::TrySendError::Closed(_) => {
348                TransportError::SendFailed("WebSocket connection closed".into())
349            }
350        })?;
351        Ok(data.len())
352    }
353
354    pub async fn connect_async(&self, addr: &TransportAddr) -> Result<(), TransportError> {
355        if !self.state.is_operational() {
356            return Err(TransportError::NotStarted);
357        }
358        let raw = addr
359            .as_str()
360            .ok_or_else(|| TransportError::InvalidAddress(addr.to_string()))?;
361        let candidate = WebSocketConfig {
362            seed_urls: vec![raw.to_owned()],
363            ..self.config.clone()
364        };
365        candidate
366            .validate()
367            .map_err(TransportError::InvalidAddress)?;
368        if self.connection_state_sync(addr) == ConnectionState::Connected {
369            return Ok(());
370        }
371        {
372            let mut states = self
373                .runtime
374                .states
375                .lock()
376                .unwrap_or_else(|error| error.into_inner());
377            if matches!(states.get(addr), Some(ConnectionState::Connecting)) {
378                return Ok(());
379            }
380            states.insert(addr.clone(), ConnectionState::Connecting);
381        }
382        self.push_task(tokio::spawn(run_one_shot_dial(
383            self.runtime.clone(),
384            addr.clone(),
385        )));
386        Ok(())
387    }
388
389    pub fn connection_state_sync(&self, addr: &TransportAddr) -> ConnectionState {
390        if let Ok(pool) = self.runtime.pool.try_lock()
391            && pool.contains_key(addr)
392        {
393            return ConnectionState::Connected;
394        }
395        self.runtime
396            .states
397            .lock()
398            .unwrap_or_else(|error| error.into_inner())
399            .get(addr)
400            .cloned()
401            .unwrap_or(ConnectionState::None)
402    }
403
404    pub async fn close_connection_async(&self, addr: &TransportAddr) {
405        self.runtime.pool.lock().await.remove(addr);
406        self.runtime
407            .states
408            .lock()
409            .unwrap_or_else(|error| error.into_inner())
410            .remove(addr);
411    }
412
413    /// Replace TCP-backed WebSocket streams after a confirmed network change.
414    ///
415    /// An established stream can remain locally "connected" long after its
416    /// source address or NAT mapping vanished. Dropping the connection senders
417    /// closes stale streams and wakes the existing seed dialers without
418    /// releasing and racing to reacquire a configured listener socket.
419    pub(crate) async fn restart_after_network_change(&mut self) -> Result<bool, TransportError> {
420        if !(self.state.is_operational() || self.state.can_start()) {
421            return Ok(false);
422        }
423        if self.state.is_operational() {
424            self.runtime.pool.lock().await.clear();
425            self.runtime
426                .states
427                .lock()
428                .unwrap_or_else(|error| error.into_inner())
429                .clear();
430            return Ok(true);
431        }
432        let previous_state = self.state;
433        let previous_local_addr = self.local_addr;
434        match self.start_async().await {
435            Ok(()) => Ok(true),
436            Err(error) => {
437                self.runtime.running.store(false, Ordering::Release);
438                self.local_addr = previous_local_addr;
439                self.state = previous_state;
440                Err(error)
441            }
442        }
443    }
444
445    pub(crate) async fn rollback_network_change_start(
446        &mut self,
447        previous_state: TransportState,
448    ) -> Result<(), TransportError> {
449        if self.state.is_operational() {
450            self.stop_async().await?;
451        }
452        self.state = previous_state;
453        Ok(())
454    }
455}
456
457impl Transport for WebSocketTransport {
458    fn transport_id(&self) -> TransportId {
459        self.transport_id
460    }
461
462    fn transport_type(&self) -> &TransportType {
463        &TransportType::WEBSOCKET
464    }
465
466    fn state(&self) -> TransportState {
467        self.state
468    }
469
470    fn mtu(&self) -> u16 {
471        self.config.mtu()
472    }
473
474    fn start(&mut self) -> Result<(), TransportError> {
475        Err(TransportError::NotSupported(
476            "use start_async() for WebSocket transport".into(),
477        ))
478    }
479
480    fn stop(&mut self) -> Result<(), TransportError> {
481        Err(TransportError::NotSupported(
482            "use stop_async() for WebSocket transport".into(),
483        ))
484    }
485
486    fn send(&self, _addr: &TransportAddr, _data: &[u8]) -> Result<(), TransportError> {
487        Err(TransportError::NotSupported(
488            "use send_async() for WebSocket transport".into(),
489        ))
490    }
491
492    fn discover(&self) -> Result<Vec<DiscoveredPeer>, TransportError> {
493        Ok(self
494            .runtime
495            .discoveries
496            .lock()
497            .unwrap_or_else(|error| error.into_inner())
498            .drain(..)
499            .collect())
500    }
501
502    fn auto_connect(&self) -> bool {
503        true
504    }
505
506    fn accept_connections(&self) -> bool {
507        self.config.accept_connections()
508    }
509}
510
511async fn run_accept_loop(runtime: Runtime, listener: TcpListener) {
512    while runtime.running.load(Ordering::Acquire) {
513        let Ok((stream, peer_addr)) = listener.accept().await else {
514            continue;
515        };
516        let Ok(inbound_permit) = runtime.inbound_slots.clone().try_acquire_owned() else {
517            runtime
518                .stats
519                .connections_rejected
520                .fetch_add(1, Ordering::Relaxed);
521            continue;
522        };
523        let Ok(total_permit) = runtime.total_slots.clone().try_acquire_owned() else {
524            runtime
525                .stats
526                .connections_rejected
527                .fetch_add(1, Ordering::Relaxed);
528            continue;
529        };
530        let accepted_runtime = runtime.clone();
531        tokio::spawn(async move {
532            let _inbound_permit = inbound_permit;
533            let _total_permit = total_permit;
534            if let Err(error) = accept_connection(accepted_runtime, stream, peer_addr).await {
535                debug!(%peer_addr, %error, "WebSocket inbound connection ended");
536            }
537        });
538    }
539}
540
541#[allow(clippy::result_large_err)]
542async fn accept_connection(
543    runtime: Runtime,
544    stream: TcpStream,
545    peer_addr: SocketAddr,
546) -> Result<(), TransportError> {
547    let path = runtime.config.path().to_owned();
548    let callback = move |request: &Request, response: Response| {
549        if request.uri().path() == path {
550            Ok(response)
551        } else {
552            let mut error = ErrorResponse::new(Some("not found".into()));
553            *error.status_mut() = tokio_tungstenite::tungstenite::http::StatusCode::NOT_FOUND;
554            Err(error)
555        }
556    };
557    let websocket =
558        accept_hdr_async_with_config(stream, callback, Some(runtime.websocket_config()))
559            .await
560            .map_err(|_| TransportError::ConnectionRefused)?;
561    let generation = runtime.next_generation();
562    let addr = TransportAddr::from_string(&format!("ws-peer://{peer_addr}/{generation}"));
563    run_connection(
564        runtime,
565        addr,
566        websocket,
567        generation,
568        Direction::Inbound,
569        false,
570    )
571    .await
572}
573
574async fn run_seed_dialer(runtime: Runtime, addr: TransportAddr) {
575    let mut delay_ms = runtime.config.reconnect_initial_ms();
576    while runtime.running.load(Ordering::Acquire) {
577        runtime.set_state(&addr, ConnectionState::Connecting);
578        runtime
579            .stats
580            .reconnect_attempts
581            .fetch_add(1, Ordering::Relaxed);
582        let result = dial_and_run(runtime.clone(), addr.clone()).await;
583        if !runtime.running.load(Ordering::Acquire) {
584            break;
585        }
586        match result {
587            Ok(()) => {
588                delay_ms = runtime.config.reconnect_initial_ms();
589            }
590            Err(error) => {
591                runtime.set_state(&addr, ConnectionState::Failed(error.to_string()));
592                debug!(remote_addr = %addr, %error, "WebSocket seed connection failed");
593                delay_ms = delay_ms
594                    .saturating_mul(2)
595                    .min(runtime.config.reconnect_max_ms());
596            }
597        }
598        tokio::time::sleep(Duration::from_millis(delay_ms)).await;
599    }
600}
601
602async fn run_one_shot_dial(runtime: Runtime, addr: TransportAddr) {
603    let result = dial_and_run(runtime.clone(), addr.clone()).await;
604    if let Err(error) = result {
605        runtime.set_state(&addr, ConnectionState::Failed(error.to_string()));
606    }
607}
608
609async fn dial_and_run(runtime: Runtime, addr: TransportAddr) -> Result<(), TransportError> {
610    let _slot = runtime
611        .total_slots
612        .clone()
613        .try_acquire_owned()
614        .map_err(|_| TransportError::ConnectionRefused)?;
615    let url = addr
616        .as_str()
617        .ok_or_else(|| TransportError::InvalidAddress(addr.to_string()))?;
618    let connect = connect_async_with_config(url, Some(runtime.websocket_config()), false);
619    let (websocket, _) = tokio::time::timeout(
620        Duration::from_millis(runtime.config.connect_timeout_ms()),
621        connect,
622    )
623    .await
624    .map_err(|_| TransportError::Timeout)?
625    .map_err(|error| TransportError::StartFailed(error.to_string()))?;
626    let generation = runtime.next_generation();
627    run_connection(
628        runtime,
629        addr,
630        websocket,
631        generation,
632        Direction::Outbound,
633        true,
634    )
635    .await
636}
637
638async fn run_connection<S>(
639    runtime: Runtime,
640    addr: TransportAddr,
641    websocket: WebSocketStream<S>,
642    generation: u64,
643    direction: Direction,
644    request_key_hint: bool,
645) -> Result<(), TransportError>
646where
647    S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
648{
649    let (mut sink, mut stream) = websocket.split();
650    let (tx, mut rx) = mpsc::channel::<Vec<u8>>(runtime.config.max_send_queue());
651    {
652        let mut pool = runtime.pool.lock().await;
653        if pool.contains_key(&addr) {
654            return Err(TransportError::AlreadyStarted);
655        }
656        pool.insert(addr.clone(), Connection { generation, tx });
657    }
658    runtime.set_state(&addr, ConnectionState::Connected);
659    runtime
660        .stats
661        .connections_opened
662        .fetch_add(1, Ordering::Relaxed);
663
664    let mut pending_nonce = request_key_hint.then(|| rand::rng().random::<u64>());
665    if let Some(nonce) = pending_nonce {
666        sink.send(Message::Binary(
667            LocalKeyHint::Request { nonce }.encode().into(),
668        ))
669        .await
670        .map_err(|error| TransportError::SendFailed(error.to_string()))?;
671    }
672
673    let started = tokio::time::Instant::now();
674    let mut last_received = started;
675    let ping_secs = runtime.config.ping_interval_secs();
676    let idle_secs = runtime.config.idle_timeout_secs();
677    let mut ping = tokio::time::interval(if ping_secs == 0 {
678        Duration::from_secs(24 * 60 * 60)
679    } else {
680        Duration::from_secs(ping_secs)
681    });
682    ping.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
683    let mut check = tokio::time::interval(Duration::from_secs(1));
684    check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
685
686    enum Event {
687        Outbound(Option<Vec<u8>>),
688        Inbound(Option<Result<Message, tokio_tungstenite::tungstenite::Error>>),
689        Ping,
690        Check,
691    }
692
693    let outcome = loop {
694        let event = tokio::select! {
695            outbound = rx.recv() => Event::Outbound(outbound),
696            inbound = stream.next() => Event::Inbound(inbound),
697            _ = ping.tick(), if ping_secs > 0 => Event::Ping,
698            _ = check.tick() => Event::Check,
699        };
700        match event {
701            Event::Outbound(Some(data)) => {
702                let len = data.len();
703                if let Err(error) = sink.send(Message::Binary(data.into())).await {
704                    break Err(TransportError::SendFailed(error.to_string()));
705                }
706                runtime.stats.frames_sent.fetch_add(1, Ordering::Relaxed);
707                runtime
708                    .stats
709                    .bytes_sent
710                    .fetch_add(len as u64, Ordering::Relaxed);
711            }
712            Event::Outbound(None) => break Ok(()),
713            Event::Inbound(Some(Ok(Message::Binary(data)))) => {
714                last_received = tokio::time::Instant::now();
715                if let Some(hint) = LocalKeyHint::decode(&data) {
716                    match hint {
717                        LocalKeyHint::Request { nonce } => {
718                            let reply = LocalKeyHint::Response {
719                                nonce,
720                                pubkey: runtime.local_pubkey,
721                            };
722                            if let Err(error) =
723                                sink.send(Message::Binary(reply.encode().into())).await
724                            {
725                                break Err(TransportError::SendFailed(error.to_string()));
726                            }
727                        }
728                        LocalKeyHint::Response { nonce, pubkey }
729                            if pending_nonce == Some(nonce) =>
730                        {
731                            pending_nonce = None;
732                            if pubkey != runtime.local_pubkey
733                                && let Ok(pubkey) = XOnlyPublicKey::from_slice(&pubkey)
734                            {
735                                runtime
736                                    .discoveries
737                                    .lock()
738                                    .unwrap_or_else(|error| error.into_inner())
739                                    .push_back(DiscoveredPeer::with_hint(
740                                        runtime.transport_id,
741                                        addr.clone(),
742                                        pubkey,
743                                    ));
744                            }
745                        }
746                        LocalKeyHint::Response { .. } => {}
747                    }
748                    continue;
749                }
750                if data.len() > runtime.config.max_frame_bytes()
751                    || validate_websocket_record(&data).is_err()
752                {
753                    runtime.stats.invalid_frames.fetch_add(1, Ordering::Relaxed);
754                    break Err(TransportError::RecvFailed(
755                        "invalid WebSocket FIPS physical record".into(),
756                    ));
757                }
758                let len = data.len();
759                let packet = ReceivedPacket::with_timestamp(
760                    runtime.transport_id,
761                    addr.clone(),
762                    PacketBuffer::new(data.to_vec()),
763                    now_ms(),
764                );
765                if runtime.packet_tx.send(packet).is_err() {
766                    break Err(TransportError::RecvFailed(
767                        "node packet channel closed".into(),
768                    ));
769                }
770                runtime
771                    .stats
772                    .frames_received
773                    .fetch_add(1, Ordering::Relaxed);
774                runtime
775                    .stats
776                    .bytes_received
777                    .fetch_add(len as u64, Ordering::Relaxed);
778            }
779            Event::Inbound(Some(Ok(Message::Ping(payload)))) => {
780                last_received = tokio::time::Instant::now();
781                if let Err(error) = sink.send(Message::Pong(payload)).await {
782                    break Err(TransportError::SendFailed(error.to_string()));
783                }
784            }
785            Event::Inbound(Some(Ok(Message::Pong(_)))) => {
786                last_received = tokio::time::Instant::now();
787            }
788            Event::Inbound(Some(Ok(Message::Close(_))) | None) => break Ok(()),
789            Event::Inbound(Some(Ok(Message::Text(_) | Message::Frame(_)))) => {
790                runtime.stats.invalid_frames.fetch_add(1, Ordering::Relaxed);
791                break Err(TransportError::RecvFailed(
792                    "WebSocket transport accepts binary messages only".into(),
793                ));
794            }
795            Event::Inbound(Some(Err(error))) => {
796                break Err(TransportError::RecvFailed(error.to_string()));
797            }
798            Event::Ping => {
799                if let Err(error) = sink.send(Message::Ping(Bytes::new())).await {
800                    break Err(TransportError::SendFailed(error.to_string()));
801                }
802            }
803            Event::Check => {
804                if pending_nonce.is_some()
805                    && started.elapsed()
806                        >= Duration::from_millis(runtime.config.key_hint_timeout_ms())
807                {
808                    break Err(TransportError::Timeout);
809                }
810                if idle_secs > 0 && last_received.elapsed() >= Duration::from_secs(idle_secs) {
811                    break Err(TransportError::Timeout);
812                }
813            }
814        }
815    };
816
817    {
818        let mut pool = runtime.pool.lock().await;
819        if pool
820            .get(&addr)
821            .is_some_and(|connection| connection.generation == generation)
822        {
823            pool.remove(&addr);
824        }
825    }
826    runtime.clear_state_if(&addr, generation);
827    runtime
828        .stats
829        .connections_closed
830        .fetch_add(1, Ordering::Relaxed);
831    debug!(
832        transport_id = %runtime.transport_id,
833        remote_addr = %addr,
834        ?direction,
835        "WebSocket physical connection closed"
836    );
837    outcome
838}
839
840fn now_ms() -> u64 {
841    SystemTime::now()
842        .duration_since(UNIX_EPOCH)
843        .unwrap_or_default()
844        .as_millis() as u64
845}
846
847fn validate_websocket_record(data: &[u8]) -> Result<(), String> {
848    if validate_direct_fsp_transport_fragment(data) {
849        return Ok(());
850    }
851    validate_stream_record(data).map_err(|error| format!("invalid FIPS physical record: {error}"))
852}
853
854#[cfg(test)]
855mod tests;