Skip to main content

beam/adapters/
ws_server.rs

1//! WebSocket server adapter — accepts inbound WebSocket connections.
2//!
3//! [`WsServer`] listens on a TCP port and accepts incoming WebSocket
4//! connections. Each connection is handled by a [`WsConn`] actor. The
5//! server also starts a web server (on `port + 1`) for peer ID discovery.
6//!
7//! # TLS Support
8//!
9//! When `cert_path` and `key_path` are configured in [`WsServerConfig`],
10//! the server uses TLS for both the WebSocket and web server. Otherwise,
11//! plain TCP is used.
12//!
13//! # Ports
14//!
15//! - WebSocket port: `ws_config.port` (default 4944)
16//! - Web UI port: `ws_config.port + 1` (default 4945)
17
18use crate::Config;
19use crate::actor::{Actor, ActorContext, Addr};
20use crate::adapters::ws_conn::WsConn;
21use crate::message::Message;
22
23use async_trait::async_trait;
24use std::collections::HashSet;
25use std::fs::File;
26use std::io::Read;
27use std::sync::Arc;
28use tokio::sync::RwLock;
29
30use futures_util::StreamExt;
31use log::{debug, info};
32use tokio::net::TcpListener;
33use tokio_native_tls::native_tls::Identity;
34
35use tokio_tungstenite::MaybeTlsStream;
36
37/// Shared set of connected client addresses.
38type Clients = Arc<RwLock<HashSet<Addr>>>;
39
40/// Configuration for the [`WsServer`] adapter.
41#[derive(Clone)]
42pub struct WsServerConfig {
43    /// Port to listen for WebSocket connections (default: 4944).
44    pub port: u16,
45    /// Path to TLS certificate file (PEM/PKCS8). If `None`, plain TCP is used.
46    pub cert_path: Option<String>,
47    /// Path to TLS private key file. Required when `cert_path` is set.
48    pub key_path: Option<String>,
49}
50
51impl Default for WsServerConfig {
52    fn default() -> Self {
53        WsServerConfig {
54            port: 4944,
55            cert_path: None,
56            key_path: None,
57        }
58    }
59}
60
61/// WebSocket server adapter that accepts inbound connections.
62///
63/// Listens on a TCP port and spawns a [`WsConn`] actor for each incoming
64/// WebSocket connection. Optionally serves a web UI on `port + 1` for
65/// peer ID discovery.
66#[derive(Clone)]
67pub struct WsServer {
68    config: Config,
69    ws_config: WsServerConfig,
70    clients: Clients,
71}
72
73impl WsServer {
74    /// Creates a new WebSocket server with default config.
75    pub fn new(config: Config) -> Self {
76        Self::new_with_config(config, WsServerConfig::default())
77    }
78
79    /// Creates a new WebSocket server with custom config.
80    ///
81    /// # Arguments
82    ///
83    /// * `config` - Node configuration
84    /// * `ws_config` - WebSocket server config (port, TLS)
85    pub fn new_with_config(config: Config, ws_config: WsServerConfig) -> Self {
86        Self {
87            config,
88            ws_config,
89            clients: Clients::default(),
90        }
91    }
92
93    /// Handles a single incoming WebSocket stream by upgrading it and
94    /// spawning a [`WsConn`] actor.
95    async fn handle_stream(
96        stream: MaybeTlsStream<tokio::net::TcpStream>,
97        ctx: &ActorContext,
98        clients: Clients,
99        allow_public_space: bool,
100    ) {
101        let ws_stream = match tokio_tungstenite::accept_async(stream).await {
102            Ok(s) => s,
103            Err(_e) => {
104                // Suppress errors from receiving normal HTTP requests
105                // (e.g. browser preflight checks).
106                return;
107            }
108        };
109
110        let (sender, receiver) = ws_stream.split();
111
112        let conn = WsConn::new(sender, receiver, allow_public_space);
113        let addr = ctx.start_actor(Box::new(conn));
114        clients.write().await.insert(addr);
115    }
116
117    /// Starts the web server for peer ID discovery.
118    ///
119    /// Serves on `config.port + 1`. Routes:
120    /// - `/peer_id` — returns this node's peer ID
121    ///
122    /// When TLS is configured, the server uses `tokio_native_tls` (the same
123    /// TLS stack as the WebSocket server) rather than warp's built-in TLS,
124    /// which was removed in warp 0.4. This keeps one TLS implementation
125    /// across the codebase (DRY).
126    async fn start_web_server(config: WsServerConfig, peer_id: String) {
127        let port = config.port + 1;
128
129        if let Some(cert_path) = config.cert_path {
130            let key_path = config.key_path.unwrap();
131            let addr = format!("https://localhost:{}", port);
132            eprintln!("Web UI:             {}", addr);
133
134            // Load TLS identity (same pattern as WebSocket TLS above)
135            let cert = std::fs::read(cert_path).expect("failed to read cert file");
136            let key = std::fs::read(key_path).expect("failed to read key file");
137            let identity = tokio_native_tls::native_tls::Identity::from_pkcs8(&cert, &key)
138                .expect("failed to create TLS identity");
139            let acceptor = tokio_native_tls::TlsAcceptor::from(
140                tokio_native_tls::native_tls::TlsAcceptor::new(identity).unwrap(),
141            );
142
143            let listener = tokio::net::TcpListener::bind(("0.0.0.0", port))
144                .await
145                .expect("failed to bind web UI port");
146
147            loop {
148                let (stream, _) = match listener.accept().await {
149                    Ok(s) => s,
150                    Err(e) => {
151                        log::error!("web UI accept error: {}", e);
152                        continue;
153                    }
154                };
155                let acceptor = acceptor.clone();
156                let peer_id = peer_id.clone();
157                tokio::spawn(async move {
158                    let stream = match acceptor.accept(stream).await {
159                        Ok(s) => s,
160                        Err(_) => return,
161                    };
162                    Self::handle_peer_id_request(stream, &peer_id).await;
163                });
164            }
165        }
166
167        // Plain HTTP — use warp (no TLS needed)
168        let addr = format!("http://localhost:{}", port);
169        eprintln!("Web UI:             {}", addr);
170        use warp::Filter;
171        let peer_id_route = warp::path("peer_id".to_string()).map(move || peer_id.to_string());
172        let routes = warp::get().and(peer_id_route);
173        warp::serve(routes).run(([0, 0, 0, 0], port)).await;
174    }
175
176    /// Handle a single HTTP request over a TLS stream for the `/peer_id` endpoint.
177    ///
178    /// Reads one HTTP/1.1 request, responds with the peer ID as plain text,
179    /// and closes the connection. This is a minimal handler — no routing
180    /// framework needed for a single endpoint.
181    async fn handle_peer_id_request(
182        mut stream: tokio_native_tls::TlsStream<tokio::net::TcpStream>,
183        peer_id: &str,
184    ) {
185        use tokio::io::{AsyncReadExt, AsyncWriteExt};
186
187        let mut buf = [0u8; 1024];
188        let n = match stream.read(&mut buf).await {
189            Ok(n) => n,
190            Err(_) => return,
191        };
192
193        // Check if the request targets /peer_id
194        let request = String::from_utf8_lossy(&buf[..n]);
195        let is_peer_id = request
196            .lines()
197            .next()
198            .is_some_and(|line| line.contains("GET /peer_id") || line.contains("GET /peer_id/"));
199
200        let response = if is_peer_id {
201            format!(
202                "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
203                peer_id.len(),
204                peer_id
205            )
206        } else {
207            "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n".to_string()
208        };
209
210        let _ = stream.write_all(response.as_bytes()).await;
211        let _ = stream.shutdown().await;
212    }
213
214    /// Returns the current number of connected WebSocket peers.
215    ///
216    /// Test-only helper to poll mesh readiness without relying on
217    /// blind sleeps. The count reflects the number of [`WsConn`]
218    /// actors that have completed the WebSocket handshake and
219    /// registered themselves in the server's client set.
220    ///
221    /// # Use
222    ///
223    /// ```ignore
224    /// // Wait for both peers to connect before broadcasting a Put.
225    /// while ws_server.peer_count() < 2 {
226    ///     sleep(Duration::from_millis(50)).await;
227    /// }
228    /// ```
229    pub fn peer_count(&self) -> usize {
230        // Try a non-blocking read. If the lock is held, treat as "no
231        // observed peers yet" so the caller polls again. Blocking
232        // would risk stalling the actor's mailbox.
233        if let Ok(count) = self.clients.try_read() {
234            count.len()
235        } else {
236            0
237        }
238    }
239}
240
241#[async_trait]
242impl Actor for WsServer {
243    async fn handle(&mut self, msg: Message, _ctx: &ActorContext) {
244        let client_count = self.clients.read().await.len();
245        eprintln!(
246            "[WSSERVER-DIAG] handle called with {} clients, msg type: {}",
247            client_count,
248            match &msg {
249                Message::Put(_) => "Put",
250                Message::Get(_) => "Get",
251                _ => "Other",
252            }
253        );
254        for conn in self.clients.read().await.iter() {
255            if msg.is_from(conn) {
256                eprintln!("[WSSERVER-DIAG] skipping msg.from == conn");
257                continue;
258            }
259            eprintln!("[WSSERVER-DIAG] relaying to WsConn client");
260            if conn.send(msg.clone()).is_err() {
261                eprintln!("[WSSERVER-DIAG] send failed, removing client");
262                self.clients.write().await.remove(conn);
263            }
264        }
265    }
266
267    async fn pre_start(&mut self, ctx: &ActorContext) {
268        let addr = format!("0.0.0.0:{}", self.ws_config.port).to_string();
269        let ctx = ctx.clone();
270
271        let peer_id = ctx.peer_id.read().clone();
272        let config_clone = self.ws_config.clone();
273        ctx.child_task(async move {
274            Self::start_web_server(config_clone, peer_id).await;
275        });
276
277        // Create the TCP listener
278        let try_socket = TcpListener::bind(&addr).await;
279        let listener = try_socket.expect("Failed to bind");
280        eprintln!("Websocket endpoint: ws://{}/ws", addr);
281
282        let allow_public_space = self.config.allow_public_space;
283        let clients = self.clients.clone();
284        if let Some(cert_path) = &self.ws_config.cert_path {
285            let mut cert_file = File::open(cert_path).unwrap();
286            let mut cert = vec![];
287            cert_file.read_to_end(&mut cert).unwrap();
288
289            let key_path = self.ws_config.key_path.as_ref().unwrap();
290            let mut key_file = File::open(key_path).unwrap();
291            let mut key = vec![];
292            key_file.read_to_end(&mut key).unwrap();
293
294            let identity = Identity::from_pkcs8(&cert, &key).unwrap();
295            let acceptor = tokio_native_tls::native_tls::TlsAcceptor::new(identity).unwrap();
296            let acceptor = tokio_native_tls::TlsAcceptor::from(acceptor);
297            let acceptor = Arc::new(acceptor);
298
299            let mut shutdown_rx = ctx.shutdown_rx.clone();
300            ctx.clone().child_task(async move {
301                loop {
302                    tokio::select! {
303                        biased;
304                        _ = shutdown_rx.changed() => {
305                            debug!("WsServer TLS accept loop shutting down");
306                            break;
307                        }
308                        result = listener.accept() => {
309                            if let Ok((stream, _)) = result {
310                                let acceptor = acceptor.clone();
311                                let clients = clients.clone();
312                                let ctx = ctx.clone();
313                                tokio::spawn(async move {
314                                    let stream = acceptor.accept(stream).await;
315                                    if let Ok(stream) = stream {
316                                        Self::handle_stream(
317                                            MaybeTlsStream::NativeTls(stream),
318                                            &ctx,
319                                            clients.clone(),
320                                            allow_public_space,
321                                        )
322                                        .await;
323                                    }
324                                });
325                            }
326                        }
327                    }
328                }
329            });
330        } else {
331            let mut shutdown_rx = ctx.shutdown_rx.clone();
332            ctx.clone().child_task(async move {
333                loop {
334                    tokio::select! {
335                        biased;
336                        _ = shutdown_rx.changed() => {
337                            debug!("WsServer plain accept loop shutting down");
338                            break;
339                        }
340                        result = listener.accept() => {
341                            if let Ok((stream, _)) = result {
342                                Self::handle_stream(
343                                    MaybeTlsStream::Plain(stream),
344                                    &ctx,
345                                    clients.clone(),
346                                    allow_public_space,
347                                )
348                                .await;
349                            }
350                        }
351                    }
352                }
353            });
354        }
355    }
356
357    /// WsServer is a relay adapter — it fans out Puts to all connected
358    /// WebSocket clients. Must return `true` so the Router adds it to
359    /// `server_peers` and relays Put messages through it.
360    fn subscribe_to_everything(&self) -> bool {
361        true
362    }
363
364    async fn stopping(&mut self, _context: &ActorContext) {
365        info!(
366            "WsServer stopping — closing {} client connections",
367            self.clients.read().await.len()
368        );
369        // Dropping the client Addr senders closes their channels. The WsConn
370        // actors will receive stop signals via ActorContext::stop() and send
371        // WebSocket Close frames in their own stopping(). Here we just clear
372        // the set so no new fan-out attempts are made.
373        self.clients.write().await.clear();
374    }
375}