netget/server/webrtc_signaling/mod.rs
1//! WebRTC Signaling Server - WebSocket-based SDP relay for WebRTC connections
2pub mod actions;
3
4use anyhow::{Context, Result};
5use futures::{SinkExt, StreamExt};
6use std::collections::HashMap;
7use std::net::SocketAddr;
8use std::sync::Arc;
9use tokio::net::{TcpListener, TcpStream};
10use tokio::sync::{mpsc, Mutex};
11use tokio_tungstenite::{
12 accept_async_with_config,
13 tungstenite::{protocol::WebSocketConfig, Message},
14};
15use tracing::{debug, error, info, trace, warn};
16
17use crate::llm::action_helper::call_llm;
18use crate::llm::ollama_client::OllamaClient;
19use crate::logging::emit::Log;
20use crate::protocol::Event;
21use crate::server::connection::ConnectionId;
22use crate::server::WebRtcSignalingProtocol;
23use crate::state::app_state::AppState;
24use crate::state::ServerId;
25use actions::{
26 WEBRTC_SIGNALING_MESSAGE_RECEIVED_EVENT, WEBRTC_SIGNALING_PEER_CONNECTED_EVENT,
27 WEBRTC_SIGNALING_PEER_DISCONNECTED_EVENT,
28};
29
30/// Unique identifier for a signaling peer
31pub type PeerId = String;
32
33/// Longest a peer may take to complete the WebSocket upgrade.
34const SIGNALING_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
35
36/// Largest WebSocket message accepted, and this server's declared `max_inbound_bytes`.
37/// Signaling carries SDP and ICE candidates; a large real offer is a few kilobytes.
38///
39/// tungstenite enforces it on the length a frame header declares, before the payload is read,
40/// and on the reassembled message; the upgrade request itself is capped by tungstenite's
41/// handshake reader at 64 KiB. Over the limit the peer gets a WebSocket close with code 1009
42/// (Message Too Big) and the connection ends.
43pub const SIGNALING_MAX_MESSAGE_BYTES: usize = 256 * 1024;
44
45/// The close frame for a message over [`SIGNALING_MAX_MESSAGE_BYTES`]: RFC 6455's 1009, with a
46/// fixed reason.
47fn message_too_big_close() -> Message {
48 Message::Close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame {
49 code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Size,
50 reason: "message too big".into(),
51 }))
52}
53
54/// How long a signalling connection may go without a single frame from the peer.
55///
56/// A signalling relay's peers are silent most of the time by design —
57/// a registered peer waits, possibly for minutes, for another peer's offer or candidate — so a
58/// bound on silence alone would evict exactly the peers the relay exists for. The bound is on the peer's **liveness**, not its conversation: at half of it the server
59/// sends a Ping (`accept_bounded::watch_idle_with_probe`), every RFC 6455 endpoint answers a Ping
60/// with a Pong by itself — a browser tab, tungstenite, NetGet's own client — and any inbound
61/// frame resets the clock. What reaches the bound is a peer that has stopped reading or vanished
62/// without a FIN, and it is closed with 1001 ("going away") and `decision=idle_timeout`.
63///
64/// 600 seconds, the number `websocket` uses and for the same reason: NetGet's own WebRTC client
65/// (`src/client/webrtc/mod.rs`) does not read its signalling socket while its connected-event
66/// model turn runs, and a `manual` rule parks that turn for up to 300 seconds; twice that window
67/// keeps the operator's own peer. Declared as the `idle_timeout_secs` startup parameter.
68pub const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(600);
69
70/// Largest number of peers that may be registered at once.
71///
72/// The registry is keyed on a string the peer chooses, so without this one host
73/// can register until memory runs out. The protocol's own CLAUDE.md recommended
74/// "1,000-10,000 concurrent peers" while nothing enforced anything.
75const SIGNALING_MAX_PEERS: usize = 1024;
76
77/// Longest peer id accepted. The id is echoed in log lines, in the registration
78/// event and on every relayed frame, so an unbounded one is stored and repeated
79/// many times over.
80const MAX_PEER_ID_BYTES: usize = 128;
81
82/// Concurrent connections this server admits before it starts refusing.
83///
84/// **1024, not the house default of 256, and the number is
85/// [`SIGNALING_MAX_PEERS`] on purpose.** One connection here carries at most one registered
86/// peer for its lifetime, so the accept cap and the registry cap measure the same population
87/// from two sides. Taking
88/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`] would have silently replaced
89/// this server's declared 1024-peer capacity with an undocumented 256 and made
90/// [`SIGNALING_MAX_PEERS`]'s own refusal path unreachable — a bound that reads as protection
91/// and can never fire, which is the `PrivilegedPort(3690)` failure in a different costume.
92/// Going higher would be the opposite mistake: admitting sockets the registry has already
93/// decided it has no room for, so the extra slots buy nothing but a longer path to the same
94/// refusal.
95///
96/// [`SIGNALING_HANDSHAKE_TIMEOUT`] bounds how long one peer holds a slot before it has
97/// upgraded; only this bounds how many of them there can be at once. The per-connection cost
98/// is a socket, two tasks and at most [`SIGNALING_MAX_MESSAGE_BYTES`] of transient reassembly,
99/// which is what makes 1024 of them a bounded total rather than a large one.
100const MAX_CONNECTIONS: usize = SIGNALING_MAX_PEERS;
101
102/// The body of [`CONNECTION_CAP_REFUSAL`].
103const CONNECTION_CAP_BODY: &str = "Too many signaling connections, try again later.\n";
104
105/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
106///
107/// **This server's own refusals are signaling frames, and a refused peer cannot read one.**
108/// A registration turned away by [`SIGNALING_MAX_PEERS`] gets an `error` message that names
109/// the reason — but that is a WebSocket text frame, and a peer refused at the accept has not
110/// completed the RFC 6455 upgrade, so it has no frame parser running. Writing signaling JSON
111/// to a socket still waiting for an HTTP response is the mis-parse this codebase refuses to
112/// ship; the client reports a malformed handshake rather than a full server.
113///
114/// What it *is* in the middle of is an ordinary HTTP/1.1 GET (RFC 6455 §4.1), so 503 with
115/// `Retry-After` is inside the protocol rather than beside it, and every WebSocket client
116/// surfaces a non-101 status as a failed handshake carrying that code. RFC 9112 §3.3 allows
117/// the response before the request is complete, and `Connection: close` tells a client still
118/// writing its head to stop and read.
119static CONNECTION_CAP_REFUSAL: std::sync::LazyLock<Vec<u8>> = std::sync::LazyLock::new(|| {
120 format!(
121 "HTTP/1.1 503 Service Unavailable\r\nRetry-After: 30\r\nContent-Type: \
122 text/plain\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
123 CONNECTION_CAP_BODY.len(),
124 CONNECTION_CAP_BODY
125 )
126 .into_bytes()
127});
128
129/// Signaling message types
130#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131#[serde(tag = "type")]
132pub enum SignalingMessage {
133 /// Register with a peer ID
134 #[serde(rename = "register")]
135 Register { peer_id: String },
136
137 /// SDP offer
138 #[serde(rename = "offer")]
139 Offer {
140 from: String,
141 to: String,
142 sdp: serde_json::Value,
143 },
144
145 /// SDP answer
146 #[serde(rename = "answer")]
147 Answer {
148 from: String,
149 to: String,
150 sdp: serde_json::Value,
151 },
152
153 /// ICE candidate
154 #[serde(rename = "ice_candidate")]
155 IceCandidate {
156 from: String,
157 to: String,
158 candidate: serde_json::Value,
159 },
160
161 /// Error message
162 #[serde(rename = "error")]
163 Error { message: String },
164
165 /// Registration success
166 #[serde(rename = "registered")]
167 Registered { peer_id: String },
168
169 /// Generic relay message
170 #[serde(rename = "relay")]
171 Relay {
172 from: String,
173 to: String,
174 data: serde_json::Value,
175 },
176}
177
178/// Peer connection data
179///
180/// Holds a channel into the connection's writer task rather than the `SplitSink` itself.
181/// That is the whole fix for the relay: `register_peer` used to take ownership of the
182/// sink, so `handle_connection` could not keep reading and had to `break` immediately
183/// after a successful registration — which ran the disconnect cleanup, unregistered the
184/// peer that had just registered, and dropped its socket. No peer was ever registered for
185/// longer than one statement, so `forward_message` could never find a recipient and not a
186/// single offer, answer or ICE candidate was ever relayed.
187struct PeerConnection {
188 #[allow(dead_code)]
189 peer_id: PeerId,
190 /// Messages queued here are written by this connection's writer task.
191 out_tx: mpsc::UnboundedSender<Message>,
192 #[allow(dead_code)]
193 remote_addr: SocketAddr,
194 #[allow(dead_code)]
195 connection_id: ConnectionId,
196}
197
198/// WebRTC signaling server shared state
199pub struct WebRtcSignalingServerData {
200 /// Connected peers indexed by peer ID
201 peers: Arc<Mutex<HashMap<PeerId, PeerConnection>>>,
202}
203
204impl Default for WebRtcSignalingServerData {
205 fn default() -> Self {
206 Self::new()
207 }
208}
209
210impl WebRtcSignalingServerData {
211 pub fn new() -> Self {
212 Self {
213 peers: Arc::new(Mutex::new(HashMap::new())),
214 }
215 }
216
217 /// Register a new peer
218 async fn register_peer(
219 &self,
220 peer_id: PeerId,
221 out_tx: mpsc::UnboundedSender<Message>,
222 remote_addr: SocketAddr,
223 connection_id: ConnectionId,
224 ) -> Result<()> {
225 if peer_id.is_empty() || peer_id.len() > MAX_PEER_ID_BYTES {
226 anyhow::bail!(
227 "Peer ID must be 1-{} bytes, got {}",
228 MAX_PEER_ID_BYTES,
229 peer_id.len()
230 );
231 }
232
233 let mut peers = self.peers.lock().await;
234 if peers.len() >= SIGNALING_MAX_PEERS {
235 anyhow::bail!(
236 "Signaling server is full ({} peers registered)",
237 SIGNALING_MAX_PEERS
238 );
239 }
240 if peers.contains_key(&peer_id) {
241 anyhow::bail!("Peer ID {} already registered", peer_id);
242 }
243
244 peers.insert(
245 peer_id.clone(),
246 PeerConnection {
247 peer_id: peer_id.clone(),
248 out_tx,
249 remote_addr,
250 connection_id,
251 },
252 );
253 info!("Registered signaling peer: {}", peer_id);
254
255 Ok(())
256 }
257
258 /// Unregister a peer
259 pub async fn unregister_peer(&self, peer_id: &str) {
260 let mut peers = self.peers.lock().await;
261 peers.remove(peer_id);
262 info!("Unregistered signaling peer: {}", peer_id);
263 }
264
265 /// Forward message to a specific peer
266 pub async fn forward_message(&self, to: &str, message: &SignalingMessage) -> Result<()> {
267 let msg_json = serde_json::to_string(message)?;
268 let peers = self.peers.lock().await;
269 let peer_conn = peers.get(to).context(format!("Peer {} not found", to))?;
270 peer_conn
271 .out_tx
272 .send(Message::Text(msg_json))
273 .context("Peer's writer task has stopped")?;
274 drop(peers);
275
276 trace!("Forwarded message to peer {}: {:?}", to, message);
277 Ok(())
278 }
279
280 /// Send a message to one peer without requiring it to be the relay target
281 pub async fn send_to_peer(&self, peer_id: &str, text: String) -> Result<()> {
282 let peers = self.peers.lock().await;
283 let peer_conn = peers
284 .get(peer_id)
285 .context(format!("Peer {} not found", peer_id))?;
286 peer_conn
287 .out_tx
288 .send(Message::Text(text))
289 .context("Peer's writer task has stopped")?;
290 Ok(())
291 }
292
293 /// List all connected peer IDs
294 pub async fn list_peers(&self) -> Vec<String> {
295 self.peers.lock().await.keys().cloned().collect()
296 }
297
298 /// Get peer count
299 pub async fn peer_count(&self) -> usize {
300 self.peers.lock().await.len()
301 }
302}
303
304/// WebRTC signaling server
305pub struct WebRtcSignalingServer;
306
307impl WebRtcSignalingServer {
308 /// Spawn the WebRTC signaling server
309 pub async fn spawn_with_llm_actions(
310 listen_addr: SocketAddr,
311 llm_client: OllamaClient,
312 app_state: Arc<AppState>,
313 status_tx: mpsc::UnboundedSender<String>,
314 server_id: ServerId,
315 idle_timeout: std::time::Duration,
316 ) -> Result<SocketAddr> {
317 let listener = TcpListener::bind(listen_addr).await?;
318 let local_addr = listener.local_addr()?;
319 Log::new(Some(&status_tx)).info(format!(
320 "WebRTC Signaling server listening on {}",
321 local_addr
322 ));
323
324 // Create server data
325 //
326 // This used to be followed by a `set_protocol_field("server_data_ptr",
327 // Arc::into_raw(...) as usize)`. Nothing in the tree ever read that field
328 // and nothing ever called `Arc::from_raw`, so it leaked one `Arc` per
329 // server start and wrote a live heap address into server state that the
330 // TUI and MCP surfaces will happily print — an ASLR disclosure bought for
331 // a capability nobody used.
332 let server_data = Arc::new(WebRtcSignalingServerData::new());
333
334 let protocol = Arc::new(WebRtcSignalingProtocol::new());
335
336 // Spawn accept loop
337 let task_registrar = app_state.clone();
338 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
339 let cap_status_tx = status_tx.clone();
340 let accept_handle = tokio::spawn(async move {
341 loop {
342 match crate::server::accept_bounded::accept_bounded(
343 &listener,
344 &limiter,
345 &CONNECTION_CAP_REFUSAL,
346 "WEBRTC_SIGNALING",
347 Some(&cap_status_tx),
348 )
349 .await
350 {
351 Ok((stream, remote_addr, permit)) => {
352 info!("Signaling server accepted connection from {}", remote_addr);
353
354 let server_data_clone = Arc::clone(&server_data);
355 let app_state_clone = Arc::clone(&app_state);
356 let status_tx_clone = status_tx.clone();
357 let llm_client_clone = llm_client.clone();
358 let protocol_clone = Arc::clone(&protocol);
359
360 // Tracked, not detached: stop_server must abort this task too.
361 let task_owner = app_state.clone();
362 task_owner
363 .spawn_server_task(server_id, async move {
364 // Released when this task ends, and this task is the whole of
365 // the connection: `handle_connection` spawns a writer task but
366 // `.await`s it on its only exit path, so there is nothing left
367 // holding the socket when this future resolves and
368 // `MAX_CONNECTIONS` caps live connections rather than accepts.
369 let _permit = permit;
370 if let Err(e) = Self::handle_connection(
371 stream,
372 remote_addr,
373 server_data_clone,
374 app_state_clone,
375 status_tx_clone,
376 llm_client_clone,
377 server_id,
378 protocol_clone,
379 idle_timeout,
380 )
381 .await
382 {
383 error!(
384 "Error handling signaling connection from {}: {}",
385 remote_addr, e
386 );
387 }
388 })
389 .await;
390 }
391 Err(e) => {
392 error!("Error accepting signaling connection: {}", e);
393 }
394 }
395 }
396 });
397
398 task_registrar
399 .register_server_task(server_id, accept_handle)
400 .await;
401
402 Ok(local_addr)
403 }
404
405 #[allow(clippy::too_many_arguments)]
406 async fn handle_connection(
407 stream: TcpStream,
408 remote_addr: SocketAddr,
409 server_data: Arc<WebRtcSignalingServerData>,
410 app_state: Arc<AppState>,
411 status_tx: mpsc::UnboundedSender<String>,
412 llm_client: OllamaClient,
413 server_id: ServerId,
414 protocol: Arc<WebRtcSignalingProtocol>,
415 idle_timeout: std::time::Duration,
416 ) -> Result<()> {
417 // Upgrade to WebSocket.
418 //
419 // Two bounds, both absent before and both reachable pre-registration by
420 // anyone who can open a TCP connection:
421 //
422 // * The handshake gets a deadline. A socket that connects and never sends
423 // an HTTP upgrade parked inside `accept_async` forever, holding a task
424 // and an fd while remaining invisible to the dashboard — a connection is
425 // only added to `AppState` once it registers.
426 // * Frames get a size limit. Bare `accept_async` takes tungstenite's
427 // defaults of 64 MiB per message and 16 MiB per frame, so a single peer
428 // could make the server buffer 64 MiB, and a peer id is just a string in
429 // a frame. Signaling carries SDP and ICE candidates; the largest real
430 // offer is a few kilobytes, so 256 KiB is generous by two orders of
431 // magnitude and still bounds the damage.
432 let config = WebSocketConfig {
433 max_message_size: Some(SIGNALING_MAX_MESSAGE_BYTES),
434 max_frame_size: Some(SIGNALING_MAX_MESSAGE_BYTES),
435 ..Default::default()
436 };
437 let ws_stream = tokio::time::timeout(
438 SIGNALING_HANDSHAKE_TIMEOUT,
439 accept_async_with_config(stream, Some(config)),
440 )
441 .await
442 .map_err(|_| {
443 anyhow::anyhow!(
444 "WebSocket handshake from {} did not complete within {:?}",
445 remote_addr,
446 SIGNALING_HANDSHAKE_TIMEOUT
447 )
448 })??;
449 info!("WebSocket connection established with {}", remote_addr);
450
451 let (mut ws_tx, mut ws_rx) = ws_stream.split();
452
453 // One writer task owns the sink. Everything that wants to write — this read loop,
454 // and any other peer relaying towards us — queues on `out_tx` instead. Handing the
455 // sink itself to the peer registry is what previously forced the read loop to
456 // terminate on registration.
457 let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Message>();
458 let writer = tokio::spawn(async move {
459 while let Some(msg) = out_rx.recv().await {
460 if let Err(e) = ws_tx.send(msg).await {
461 debug!("Signaling writer stopped: {}", e);
462 break;
463 }
464 }
465 let _ = ws_tx.close().await;
466 });
467
468 let mut peer_id: Option<PeerId> = None;
469 let mut connection_id: Option<ConnectionId> = None;
470
471 // Touched by every inbound frame, Pongs included, and busy while one is handled.
472 let activity = Arc::new(crate::server::accept_bounded::ConnectionActivity::new());
473
474 // Handle incoming messages
475 loop {
476 let keepalive_tx = out_tx.clone();
477 let msg_result = tokio::select! {
478 msg = ws_rx.next() => msg,
479 _ = crate::server::accept_bounded::watch_idle_with_probe(
480 Arc::clone(&activity),
481 idle_timeout,
482 move || {
483 let _ = keepalive_tx.send(Message::Ping(
484 crate::server::accept_bounded::KEEPALIVE_PING_PAYLOAD.to_vec(),
485 ));
486 },
487 ) => {
488 info!(
489 "WebRTC signaling peer {} sent no frame for {}s, not even a Pong; \
490 closing decision=idle_timeout",
491 remote_addr,
492 idle_timeout.as_secs()
493 );
494 let _ = out_tx.send(Message::Close(Some(
495 tokio_tungstenite::tungstenite::protocol::CloseFrame {
496 code: tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Away,
497 reason: "idle timeout".into(),
498 },
499 )));
500 break;
501 }
502 };
503 let Some(msg_result) = msg_result else { break };
504 // Busy for the rest of this iteration. Registration and its connected event run
505 // inline, so the watchdog is not even polled while they are outstanding — but the
506 // guard's release is what gives the connection a fresh bound afterwards. Without it,
507 // an event parked for a human longer than the bound would come back to a clock that
508 // had long run out, and the peer would be closed the instant it was answered.
509 let _busy = activity.busy();
510 match msg_result {
511 Ok(Message::Text(text)) => {
512 trace!("Received signaling message: {}", text);
513
514 // Parse message
515 let message: SignalingMessage = match serde_json::from_str(&text) {
516 Ok(m) => m,
517 Err(e) => {
518 // The error goes to the log; the peer gets a category. serde's
519 // message names the enum's variants and the exact column it
520 // choked on ("unknown variant `foo`, expected one of `register`,
521 // `offer`, ... at line 1 column 15"), and that was going verbatim
522 // to an unauthenticated stranger. Same rule as
523 // `crate::utils::WireFailure`, which does not apply here only
524 // because this is a parse failure rather than a backend one.
525 warn!(
526 "Invalid signaling message from {}: {}",
527 remote_addr,
528 crate::utils::truncate_for_log(&e.to_string(), 300)
529 );
530 let _ = Self::reply(
531 &out_tx,
532 &SignalingMessage::Error {
533 message: "invalid signaling message: expected a JSON \
534 object with a 'type' of register, offer, \
535 answer, ice_candidate or relay"
536 .to_string(),
537 },
538 );
539 continue;
540 }
541 };
542
543 match message {
544 SignalingMessage::Register {
545 peer_id: new_peer_id,
546 } => {
547 if peer_id.is_some() {
548 let _ = Self::reply(
549 &out_tx,
550 &SignalingMessage::Error {
551 message: "This connection is already registered"
552 .to_string(),
553 },
554 );
555 continue;
556 }
557
558 let conn_id = ConnectionId::new(app_state.get_next_unified_id().await);
559
560 match server_data
561 .register_peer(
562 new_peer_id.clone(),
563 out_tx.clone(),
564 remote_addr,
565 conn_id,
566 )
567 .await
568 {
569 Ok(_) => {
570 peer_id = Some(new_peer_id.clone());
571 connection_id = Some(conn_id);
572
573 // Add connection to server
574 use crate::state::server::{
575 ConnectionState as ServerConnectionState, ConnectionStatus,
576 ProtocolConnectionInfo,
577 };
578 let now = crate::utils::clock::Instant::now();
579 let conn_state = ServerConnectionState {
580 id: conn_id,
581 remote_addr,
582 local_addr: "0.0.0.0:0".parse().unwrap(),
583 bytes_sent: 0,
584 bytes_received: 0,
585 packets_sent: 0,
586 packets_received: 0,
587 last_activity: now,
588 status: ConnectionStatus::Active,
589 status_changed_at: now,
590 protocol_info: ProtocolConnectionInfo::new(
591 serde_json::json!({
592 "peer_id": new_peer_id,
593 }),
594 ),
595 };
596 app_state
597 .add_connection_to_server(server_id, conn_state)
598 .await;
599 let _ = status_tx.send("__UPDATE_UI__".to_string());
600
601 // Confirm registration. `SignalingMessage::Registered`
602 // was defined but never sent, so a client waiting for
603 // it (as the documented protocol says it may) hung.
604 let _ = Self::reply(
605 &out_tx,
606 &SignalingMessage::Registered {
607 peer_id: new_peer_id.clone(),
608 },
609 );
610 Log::new(Some(&status_tx)).info(format!(
611 "WebRTC signaling peer '{}' registered from {}",
612 new_peer_id, remote_addr
613 ));
614
615 // Fire connected event
616 let event = Event::new(
617 &WEBRTC_SIGNALING_PEER_CONNECTED_EVENT,
618 serde_json::json!({
619 "peer_id": new_peer_id,
620 "remote_addr": remote_addr.to_string(),
621 "peer_count": server_data.peer_count().await,
622 }),
623 );
624
625 match call_llm(
626 &llm_client,
627 &app_state,
628 server_id,
629 Some(conn_id),
630 &event,
631 protocol.as_ref(),
632 )
633 .await
634 {
635 Ok(result) => {
636 let subject = format!(
637 "WebRTC signaling peer '{}' \
638 webrtc_signaling_peer_connected",
639 new_peer_id
640 );
641 if Self::apply_results(
642 result, &subject, &out_tx, &status_tx,
643 ) {
644 break;
645 }
646 }
647 Err(e) => {
648 // This is the one signaling event the model can
649 // act on (`send_signaling_message`,
650 // `disconnect_peer`), so a peer may legitimately
651 // be waiting for what the model decides. Swallowed,
652 // that wait ended at the peer's own timeout. Say so
653 // in the protocol's own vocabulary instead - and
654 // never invent the reply the model did not give.
655 //
656 // The tag is NOT `fail_closed_*`, and that is
657 // deliberate: registration completed and the
658 // `registered` frame went out *before* this call,
659 // so the peer keeps every capability it had. The
660 // backend failing denied it nothing. Calling that
661 // fail-closed would put a line that refused
662 // nothing in front of every
663 // `grep decision=fail_closed`, and would hide the
664 // real property: this peer was admitted without
665 // the model ever being consulted.
666 Log::new(Some(&status_tx)).error(format!(
667 "WebRTC signaling peer '{}' \
668 webrtc_signaling_peer_connected \
669 decision=llm_error_peer_admitted \
670 category={}: LLM call failed ({}) - sent \
671 error frame, registration stands",
672 new_peer_id,
673 Self::failure_category(&e),
674 e
675 ));
676 let _ = Self::reply(
677 &out_tx,
678 &SignalingMessage::Error {
679 message: format!(
680 "Server-side handler for peer '{}' \
681 failed; registration stands but no \
682 handler response follows",
683 new_peer_id
684 ),
685 },
686 );
687 }
688 }
689 }
690 Err(e) => {
691 // The protocol refused this registration — an empty or
692 // over-long id, a duplicate, or a full registry. No model
693 // was consulted, so this is neither a model decision nor a
694 // backend failure; `protocol_error` says which.
695 Log::new(Some(&status_tx)).warn(format!(
696 "WebRTC signaling refused registration of '{}' from {} \
697 decision=protocol_error: {}",
698 new_peer_id, remote_addr, e
699 ));
700 let _ = Self::reply(
701 &out_tx,
702 &SignalingMessage::Error {
703 message: e.to_string(),
704 },
705 );
706 }
707 }
708 }
709 SignalingMessage::Offer { .. }
710 | SignalingMessage::Answer { .. }
711 | SignalingMessage::IceCandidate { .. }
712 | SignalingMessage::Relay { .. } => {
713 let (kind, claimed_from, to) = match &message {
714 SignalingMessage::Offer { from, to, .. } => {
715 ("offer", from.clone(), to.clone())
716 }
717 SignalingMessage::Answer { from, to, .. } => {
718 ("answer", from.clone(), to.clone())
719 }
720 SignalingMessage::IceCandidate { from, to, .. } => {
721 ("ice_candidate", from.clone(), to.clone())
722 }
723 SignalingMessage::Relay { from, to, .. } => {
724 ("relay", from.clone(), to.clone())
725 }
726 _ => unreachable!(),
727 };
728
729 // A sender must have registered. Nothing checked this: an
730 // anonymous socket that never sent `register` could inject
731 // offers, answers, ICE candidates and arbitrary `relay` JSON at
732 // any registered peer. A relay with no identity at all on the
733 // sending side is not a relay, it is an open injection point,
734 // and the peer on the far end has no way to tell the difference.
735 let Some(sender_id) = peer_id.clone() else {
736 let _ = Self::reply(
737 &out_tx,
738 &SignalingMessage::Error {
739 message: "register before sending offer, answer, \
740 ice_candidate or relay"
741 .to_string(),
742 },
743 );
744 Log::new(Some(&status_tx)).warn(format!(
745 "WebRTC signaling refused {} from unregistered {}",
746 kind, remote_addr
747 ));
748 continue;
749 };
750
751 // And `from` is this connection's registered id, not whatever
752 // the frame claimed. It was taken verbatim, so any registered
753 // peer could send an offer that both the recipient and the
754 // `webrtc_signaling_message_received` event attributed to
755 // somebody else — and the recipient would answer *that* peer,
756 // splicing a stranger into a session it is not part of.
757 //
758 // A mismatch is rewritten rather than refused: the field is
759 // redundant (the connection already identifies the sender) and
760 // clients that fill it in correctly are unaffected.
761 if claimed_from != sender_id {
762 Log::new(Some(&status_tx)).warn(format!(
763 "WebRTC signaling rewrote forged 'from' on a {}: peer '{}' \
764 claimed to be '{}'",
765 kind, sender_id, claimed_from
766 ));
767 }
768 let from = sender_id;
769 let message = Self::with_sender(message, &from);
770
771 // Relay: a signaling server is a relay, and putting a model
772 // round-trip in front of every ICE candidate would break any
773 // real browser peer.
774 let delivered = match server_data.forward_message(&to, &message).await {
775 Ok(()) => true,
776 Err(e) => {
777 warn!(
778 "Failed to forward {} from {} to {}: {}",
779 kind, from, to, e
780 );
781 let _ = Self::reply(
782 &out_tx,
783 &SignalingMessage::Error {
784 message: format!(
785 "Cannot deliver {} to {}: {}",
786 kind, to, e
787 ),
788 },
789 );
790 false
791 }
792 };
793
794 Log::new(Some(&status_tx)).debug(format!(
795 "WebRTC signaling {} {} -> {} ({})",
796 kind,
797 from,
798 to,
799 if delivered {
800 "delivered"
801 } else {
802 "undeliverable"
803 }
804 ));
805
806 // Notify the LLM out of band so observation never delays relay.
807 let event = Event::new(
808 &WEBRTC_SIGNALING_MESSAGE_RECEIVED_EVENT,
809 serde_json::json!({
810 "peer_id": from,
811 "message_type": kind,
812 "target_peer": to,
813 "delivered": delivered,
814 }),
815 );
816 let llm = llm_client.clone();
817 let state = app_state.clone();
818 let proto = protocol.clone();
819 let status = status_tx.clone();
820 let observed = format!("{} {} -> {}", kind, from, to);
821 // Tracked, not detached: stop_server must abort this task too.
822 let task_owner = app_state.clone();
823 task_owner
824 .spawn_server_task(server_id, async move {
825 if let Err(e) = call_llm(
826 &llm,
827 &state,
828 server_id,
829 None,
830 &event,
831 proto.as_ref(),
832 )
833 .await
834 {
835 // Deliberately silent on the wire, and that is the only
836 // correct answer here: `webrtc_signaling_message_received`
837 // is declared `.with_no_actions()` and fires *after* the
838 // relay has already been decided and already reported to
839 // the sender. The model cannot speak to the peer on the
840 // success path either, so an error frame on this path
841 // would announce a failure the peer's signaling did not
842 // suffer and could abort a negotiation that succeeded.
843 // The operator is who needs to know, so say it loudly
844 // there.
845 // `llm_error_notice_only`: nothing was pending on this
846 // call. The relay was decided in Rust and already
847 // reported to the sender, and the event is declared
848 // `.with_no_actions()`, so the failure refused nothing
849 // and withheld nothing the model could have sent.
850 Log::new(Some(&status)).error(format!(
851 "WebRTC signaling {} webrtc_signaling_message_received \
852 decision=llm_error_notice_only: LLM call failed ({}) - \
853 message was already relayed, no frame sent",
854 observed, e
855 ));
856 }
857 })
858 .await;
859 }
860 other => {
861 debug!("Ignoring signaling message: {:?}", other);
862 }
863 }
864 }
865 Ok(Message::Close(_)) => {
866 info!("Signaling connection closed by peer");
867 break;
868 }
869 Ok(_) => {
870 // Ignore binary, ping, pong messages
871 }
872 Err(tokio_tungstenite::tungstenite::Error::Capacity(e)) => {
873 warn!(
874 "Signaling message from {} over {} bytes \
875 decision=fail_closed_message_too_large: {}",
876 remote_addr, SIGNALING_MAX_MESSAGE_BYTES, e
877 );
878 let _ = out_tx.send(message_too_big_close());
879 break;
880 }
881 Err(e) => {
882 warn!("WebSocket error: {}", e);
883 break;
884 }
885 }
886 }
887
888 // Cleanup on disconnect
889 if let Some(pid) = peer_id {
890 server_data.unregister_peer(&pid).await;
891
892 // Fire disconnected event
893 let event = Event::new(
894 &WEBRTC_SIGNALING_PEER_DISCONNECTED_EVENT,
895 serde_json::json!({
896 "peer_id": pid,
897 "peer_count": server_data.peer_count().await,
898 }),
899 );
900
901 if let Err(e) = call_llm(
902 &llm_client,
903 &app_state,
904 server_id,
905 connection_id,
906 &event,
907 protocol.as_ref(),
908 )
909 .await
910 {
911 // Silence is forced here rather than chosen: this fires because the peer's
912 // socket has already gone, so there is nobody left to answer in any
913 // vocabulary. `webrtc_signaling_peer_disconnected` is declared
914 // `.with_no_actions()` for the same reason. Log it loudly and carry on with
915 // the cleanup below - the connection must still be torn down.
916 // `llm_error_notice_only` for the same reason as the relay path: the socket is
917 // already gone, the event is `.with_no_actions()`, and nothing was refused.
918 Log::new(Some(&status_tx)).error(format!(
919 "WebRTC signaling peer '{}' webrtc_signaling_peer_disconnected \
920 decision=llm_error_notice_only: LLM call failed on disconnect ({}) - peer \
921 already gone, nothing sent",
922 pid, e
923 ));
924 }
925
926 // Remove connection from server
927 if let Some(conn_id) = connection_id {
928 app_state
929 .remove_connection_from_server(server_id, conn_id)
930 .await;
931 let _ = status_tx.send("__UPDATE_UI__".to_string());
932 }
933 }
934
935 // Dropping our sender lets the writer task finish once the registry copy is gone.
936 drop(out_tx);
937 let _ = writer.await;
938
939 Ok(())
940 }
941
942 /// `"overloaded"` or `"unavailable"` for a backend failure.
943 ///
944 /// Signaling has one error frame and it carries no code, so the wire cannot tell a
945 /// saturated backend from a dead one. The log can, and this is where it does — the same
946 /// `category=` split `radius` uses next to its own decision token.
947 fn failure_category(e: &anyhow::Error) -> &'static str {
948 match crate::utils::wire_failure::WireFailure::classify(e) {
949 crate::utils::wire_failure::WireFailure::Overloaded => "overloaded",
950 crate::utils::wire_failure::WireFailure::Unavailable => "unavailable",
951 }
952 }
953
954 fn reply(out_tx: &mpsc::UnboundedSender<Message>, message: &SignalingMessage) -> Result<()> {
955 out_tx.send(Message::Text(serde_json::to_string(message)?))?;
956 Ok(())
957 }
958
959 /// Replace a relayed message's `from` with the sender's registered peer id.
960 ///
961 /// The wire field is whatever the sender typed; this connection's identity is
962 /// what it registered. Delivering the former lets any peer forge an offer from
963 /// any other, so the latter is what goes out and what the event reports.
964 fn with_sender(message: SignalingMessage, sender: &str) -> SignalingMessage {
965 match message {
966 SignalingMessage::Offer { to, sdp, .. } => SignalingMessage::Offer {
967 from: sender.to_string(),
968 to,
969 sdp,
970 },
971 SignalingMessage::Answer { to, sdp, .. } => SignalingMessage::Answer {
972 from: sender.to_string(),
973 to,
974 sdp,
975 },
976 SignalingMessage::IceCandidate { to, candidate, .. } => {
977 SignalingMessage::IceCandidate {
978 from: sender.to_string(),
979 to,
980 candidate,
981 }
982 }
983 SignalingMessage::Relay { to, data, .. } => SignalingMessage::Relay {
984 from: sender.to_string(),
985 to,
986 data,
987 },
988 other => other,
989 }
990 }
991
992 /// Execute whatever the LLM returned for a signaling event.
993 ///
994 /// Returns true if the connection should be closed.
995 ///
996 /// `subject` names the peer and event the answer belongs to, so the `decision=` line this
997 /// emits identifies a request rather than floating free.
998 fn apply_results(
999 result: crate::llm::actions::executor::ExecutionResult,
1000 subject: &str,
1001 out_tx: &mpsc::UnboundedSender<Message>,
1002 status_tx: &mpsc::UnboundedSender<String>,
1003 ) -> bool {
1004 let log = Log::new(Some(status_tx));
1005 for message in &result.messages {
1006 log.info(format!("{}", message));
1007 }
1008
1009 let mut close = false;
1010 let mut sent = 0usize;
1011 let mut undecodable = 0usize;
1012 for protocol_result in result.protocol_results {
1013 match protocol_result {
1014 crate::llm::ActionResult::Output(bytes) => match String::from_utf8(bytes) {
1015 Ok(text) => {
1016 let _ = out_tx.send(Message::Text(text));
1017 sent += 1;
1018 }
1019 Err(e) => {
1020 undecodable += 1;
1021 error!("Signaling action produced non-UTF-8 output: {}", e);
1022 }
1023 },
1024 crate::llm::ActionResult::CloseConnection => close = true,
1025 _ => {}
1026 }
1027 }
1028
1029 // Exactly one `decision=` line per answered event. Without it, a model that told this
1030 // peer nothing and a model that could not be reached looked identical: neither put a
1031 // frame on the wire, and neither said anything in the log.
1032 if undecodable > 0 && sent == 0 && !close {
1033 log.error(format!(
1034 "{} decision=fail_closed_bad_action: the answer's output was not valid UTF-8, \
1035 so no signaling frame could be sent",
1036 subject
1037 ));
1038 } else if close {
1039 log.info(format!(
1040 "{} decision=model_reject: the model disconnected the peer",
1041 subject
1042 ));
1043 } else if sent > 0 {
1044 log.info(format!(
1045 "{} decision=model_answer: {} signaling frame(s) sent",
1046 subject, sent
1047 ));
1048 } else if result.raw_actions.is_empty() {
1049 log.warn(format!(
1050 "{} decision=model_silent: no action, so nothing was sent to the peer",
1051 subject
1052 ));
1053 } else if !result.failures.is_empty() {
1054 log.error(format!(
1055 "{} decision=fail_closed_bad_action: {}",
1056 subject,
1057 result
1058 .failures
1059 .iter()
1060 .map(|f| format!("{}: {}", f.action, f.error))
1061 .collect::<Vec<_>>()
1062 .join("; ")
1063 ));
1064 } else {
1065 // Actions ran and none of them addressed this peer — `show_message` and friends.
1066 log.warn(format!(
1067 "{} decision=model_silent: the answer produced no signaling frame",
1068 subject
1069 ));
1070 }
1071
1072 close
1073 }
1074}