Skip to main content

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}