Skip to main content

netget/server/xmpp/
mod.rs

1//! XMPP server implementation
2pub mod actions;
3
4use crate::llm::action_helper::call_llm;
5use crate::llm::actions::protocol_trait::ActionResult;
6use crate::llm::ollama_client::OllamaClient;
7use crate::logging::emit::Log;
8use crate::protocol::Event;
9use crate::server::connection::ConnectionId;
10use crate::state::app_state::AppState;
11use actions::{XmppProtocol, XMPP_DATA_RECEIVED_EVENT};
12use anyhow::Result;
13use std::net::SocketAddr;
14use std::sync::Arc;
15use tokio::io::{AsyncReadExt, AsyncWriteExt};
16use tokio::sync::mpsc;
17
18/// Upper bound on the un-consumed XML a single connection may accumulate.
19///
20/// `wait_for_more` deliberately keeps the buffer across events, so without a ceiling a peer
21/// that never completes a stanza - or a model that answers `wait_for_more` forever - grows it
22/// without limit, and every subsequent event re-sends the whole thing to the model.
23pub const MAX_XMPP_BUFFER_BYTES: usize = 256 * 1024;
24
25/// How long a peer that has connected and sent nothing at all may hold this connection.
26///
27/// XMPP is unambiguously initiator-speaks-first: RFC 6120 §4.2 has the initiating entity send
28/// the opening `<stream:stream>` header before the server may say anything, and this server
29/// cannot even answer until it has one, because its first event is the peer's own bytes.
30/// `tokio-xmpp` — what NetGet's own XMPP client is built on — sends that header from inside its
31/// connect path, so NetGet's client is the "speaks inside `connect()`" case and never the
32/// connected-and-silent one that made `tcp`, `telnet`, `ldap`, `whois` and `redis` settle on
33/// 300 seconds. (It is in practice not a peer here at all: `XmppClient::new` dials the JID's
34/// domain via SRV, not the `127.0.0.1:<ephemeral>` the dashboard pre-fills.)
35///
36/// Thirty seconds is also the number the XMPP servers this one imitates already use for the
37/// same window: ejabberd's `negotiation_timeout` defaults to 30 seconds for completing stream
38/// negotiation, and Prosody bounds the pre-authentication phase the same way. A peer that has
39/// not sent a stream header has not begun a stream, which is the state an unauthenticated
40/// flood lives in.
41const FIRST_BYTE_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
42
43/// How long an established stream may carry no further bytes.
44///
45/// Deliberately far longer than the first bound, because **a silent XMPP session is the normal
46/// case, not a broken one**: a connected person who is not typing sends nothing, and that is
47/// what the protocol is for. Neither ejabberd nor Prosody closes an authenticated session for
48/// idleness at all, so there is no upstream number to copy — the bound here exists only so the
49/// socket, the task and the `AppState` row are not held forever, not to enforce a session
50/// policy.
51///
52/// Fifteen minutes, which is above every keepalive interval real clients use: RFC 6120 §4.6.1
53/// whitespace keepalives and XEP-0199 pings are sent on the order of one to five minutes
54/// (Conversations pings at roughly five, most desktop clients at one), so any client that
55/// intends to stay connected refreshes this bound several times over before it can fire.
56///
57/// The deadline wraps the read alone. The model round-trip that answers a stanza, and a
58/// `manual` rule parking that stanza for a human at the dashboard, both happen after the read
59/// has returned — so a slow answer can never close the connection the answer belongs to.
60const IDLE_BETWEEN_STANZAS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(900);
61
62/// Concurrent streams this server admits.
63///
64/// [`accept_bounded::DEFAULT_MAX_CONNECTIONS`](crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS).
65/// Each admitted connection may accumulate [`MAX_XMPP_BUFFER_BYTES`] of un-consumed XML, so the
66/// cap is what turns that 256 KiB per-connection bound into a 64 MiB total one — and with a
67/// fifteen-minute idle bound above, a cap is the only thing standing between this server and
68/// `900s × accept rate` simultaneous silent streams.
69const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
70
71/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
72///
73/// A stream error in XMPP's own vocabulary, and the one the XSF defines for exactly this:
74/// `<policy-violation/>` (RFC 6120 §4.9.3.14 — "the entity has violated some local service
75/// policy"), which is what a connection limit is. RFC 6120 §4.9.1.1 requires the error to be
76/// preceded by an opening stream tag and followed by the closing one even when the server is
77/// the first to speak, so the whole exchange is synthesised here; this is the same shape
78/// [`stream_error_frame`] builds for an LLM failure on a stream that never opened.
79///
80/// No `from` attribute: the refusal is written by the accept loop, which has the listener's
81/// domain but no parsed stream to attribute it to, and §4.7.1 makes `from` optional on a
82/// server's opening tag. Every XMPP client parses this and reports a refusal rather than a
83/// transport fault, which is the entire point of saying it out loud.
84const CONNECTION_CAP_REFUSAL: &[u8] = b"<?xml version='1.0'?>\
85<stream:stream xmlns='jabber:client' xmlns:stream='http://etherx.jabber.org/streams' \
86version='1.0'><stream:error>\
87<policy-violation xmlns='urn:ietf:params:xml:ns:xmpp-streams'/>\
88<text xmlns='urn:ietf:params:xml:ns:xmpp-streams' xml:lang='en'>\
89connection limit reached</text></stream:error></stream:stream>";
90
91/// A fatal stream error to send when netget itself cannot answer, per RFC 6120 §4.9.
92///
93/// The peer gets a *category*, never a diagnosis: the two `WireFailure` variants map onto two
94/// distinct defined stream conditions so a client can tell "come back later" from "this server
95/// is broken" —
96///
97/// * `WireFailure::Overloaded` → `<resource-constraint/>` (§4.9.3.17, "the server lacks the
98///   system resources necessary to service the stream"), which clients treat as retryable and
99///   back off on;
100/// * `WireFailure::Unavailable` → `<internal-server-error/>` (§4.9.3.10).
101///
102/// The `<text>` is `WireFailure::text`, a `&'static str` — nothing derived from the error can
103/// reach it. The error itself goes to the log and the status stream.
104///
105/// `stream_opened` covers the case where the failure happens on the very first event, before
106/// the model has ever answered with a stream header: RFC 6120 §4.9.1.1 requires an opening tag
107/// to precede the error, so one is synthesised. `domain` is operator-supplied startup config,
108/// not peer data, but it is escaped anyway so a stray `&` cannot produce a malformed frame.
109fn stream_error_frame(
110    failure: crate::utils::WireFailure,
111    stream_opened: bool,
112    domain: &str,
113) -> String {
114    const NS: &str = "urn:ietf:params:xml:ns:xmpp-streams";
115    let condition = match failure {
116        crate::utils::WireFailure::Overloaded => "resource-constraint",
117        crate::utils::WireFailure::Unavailable => "internal-server-error",
118    };
119
120    let mut frame = String::new();
121    if !stream_opened {
122        frame.push_str(&format!(
123            "<?xml version='1.0'?><stream:stream xmlns='jabber:client' \
124             xmlns:stream='http://etherx.jabber.org/streams' version='1.0' from='{}'>",
125            actions::xml_escape(domain)
126        ));
127    }
128    frame.push_str(&format!(
129        "<stream:error><{condition} xmlns='{NS}'/>\
130         <text xmlns='{NS}' xml:lang='en'>{}</text></stream:error></stream:stream>",
131        actions::xml_escape(failure.text())
132    ));
133    frame
134}
135
136/// XMPP server that forwards XML stanzas to LLM
137pub struct XmppServer;
138
139impl XmppServer {
140    /// Spawn XMPP server with integrated LLM actions
141    pub async fn spawn_with_llm_actions(
142        listen_addr: SocketAddr,
143        llm_client: OllamaClient,
144        app_state: Arc<AppState>,
145        status_tx: mpsc::UnboundedSender<String>,
146        server_id: crate::state::ServerId,
147        domain: String,
148        first_byte_timeout_secs: Option<u64>,
149        idle_timeout_secs: Option<u64>,
150    ) -> Result<SocketAddr> {
151        // Both bounds are tunable because their right value is a property of who is on the
152        // other end, which only the operator knows. The defaults are argued beside the
153        // constants.
154        let first_byte_timeout = first_byte_timeout_secs
155            .map(std::time::Duration::from_secs)
156            .unwrap_or(FIRST_BYTE_READ_TIMEOUT);
157        let idle_timeout = idle_timeout_secs
158            .map(std::time::Duration::from_secs)
159            .unwrap_or(IDLE_BETWEEN_STANZAS_TIMEOUT);
160        let listener =
161            crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
162        let local_addr = listener.local_addr()?;
163        Log::new(Some(&status_tx)).info(format!(
164            "XMPP server (action-based) listening on {} for domain {}",
165            local_addr, domain
166        ));
167
168        // Kept alongside the protocol so a connection that fails before the model has ever
169        // answered can still synthesise the opening stream tag a stream error must follow.
170        let stream_domain = domain.clone();
171        let protocol = Arc::new(XmppProtocol::with_domain(domain));
172
173        let task_registrar = app_state.clone();
174        let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
175        let accept_handle = tokio::spawn(async move {
176            loop {
177                match crate::server::accept_bounded::accept_bounded(
178                    &listener,
179                    &limiter,
180                    CONNECTION_CAP_REFUSAL,
181                    "XMPP",
182                    Some(&status_tx),
183                )
184                .await
185                {
186                    Ok((stream, remote_addr, permit)) => {
187                        let connection_id =
188                            ConnectionId::new(app_state.get_next_unified_id().await);
189                        let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
190                        let llm_clone = llm_client.clone();
191                        let state_clone = app_state.clone();
192                        let status_clone = status_tx.clone();
193                        let protocol_clone = protocol.clone();
194                        let domain_clone = stream_domain.clone();
195
196                        Log::new(Some(&status_clone)).debug(format!(
197                            "XMPP connection {} from {}",
198                            connection_id, remote_addr
199                        ));
200
201                        // Tracked, not detached: stop_server must abort this task too.
202                        let task_owner = app_state.clone();
203                        task_owner.spawn_server_task(server_id, async move {
204                            // The permit is released when this task ends, which is what makes
205                            // MAX_CONNECTIONS a cap on live streams rather than on the accept
206                            // rate.
207                            let _permit = permit;
208                            let (read_half, write_half) = tokio::io::split(stream);
209                            let write_half_arc = Arc::new(tokio::sync::Mutex::new(write_half));
210
211                            // Add connection to ServerInstance
212                            use crate::state::server::{
213                                ConnectionState as ServerConnectionState, ConnectionStatus,
214                                ProtocolConnectionInfo,
215                            };
216                            let now = crate::utils::clock::Instant::now();
217                            let conn_state = ServerConnectionState {
218                                id: connection_id,
219                                remote_addr,
220                                local_addr: local_addr_conn,
221                                bytes_sent: 0,
222                                bytes_received: 0,
223                                packets_sent: 0,
224                                packets_received: 0,
225                                last_activity: now,
226                                status: ConnectionStatus::Active,
227                                status_changed_at: now,
228                                protocol_info: ProtocolConnectionInfo::empty(),
229                            };
230                            state_clone
231                                .add_connection_to_server(server_id, conn_state)
232                                .await;
233                            let _ = status_clone.send("__UPDATE_UI__".to_string());
234
235                            // Peer messaging: the dashboard's "message this peer" /
236                            // "disconnect this peer" inject actions into THIS connection through
237                            // the same executor the LLM path uses. Registered before the first
238                            // LLM call because a manual `*` rule can park it for minutes and the
239                            // operator must be able to reach the connection while it waits. Every
240                            // XMPP wire verb returns Output / Multiple / CloseConnection, so the
241                            // generic peer task covers the whole vocabulary (no Custom gap).
242                            let peer_rx = crate::server::peer_support::register_peer_channel(
243                                &state_clone,
244                                server_id,
245                                connection_id.as_u32(),
246                            )
247                            .await;
248                            crate::server::peer_support::spawn_peer_command_task(
249                                peer_rx,
250                                protocol_clone.clone(),
251                                state_clone.clone(),
252                                server_id,
253                                connection_id.as_u32(),
254                                write_half_arc.clone(),
255                                status_clone.clone(),
256                            );
257
258                            // Create XML buffer for streaming parsing
259                            let mut read_half = read_half;
260                            let mut buffer = Vec::new();
261                            let mut temp_buf = vec![0u8; 4096];
262                            // Has anything at all been written to this peer yet? A stream error
263                            // must be preceded by an opening stream tag, and on this server the
264                            // opening tag is the model's answer to the first event.
265                            let mut stream_opened = false;
266
267                            loop {
268                                // The deadline wraps this read and nothing else. Everything
269                                // that legitimately takes time — the model round-trip that
270                                // answers a stanza, a `manual` rule parking it for a human —
271                                // happens below, after the read has already returned, so none
272                                // of it can be timed out from under itself.
273                                //
274                                // `stream_opened` is the switch between the two bounds because
275                                // it is exactly the right claim: it is set the moment this
276                                // server writes its first byte to the peer, which on this
277                                // server is the model's answer to the peer's stream header. A
278                                // peer that has not got that far has not established a stream,
279                                // whatever it has sent.
280                                let read_timeout = if stream_opened {
281                                    idle_timeout
282                                } else {
283                                    first_byte_timeout
284                                };
285                                let read_result = match tokio::time::timeout(
286                                    read_timeout,
287                                    read_half.read(&mut temp_buf),
288                                )
289                                .await
290                                {
291                                    Ok(result) => result,
292                                    Err(_) => {
293                                        Log::new(Some(&status_clone)).info(format!(
294                                            "XMPP connection {} sent nothing for {}s; closing \
295                                             idle connection \
296                                             decision=fail_closed_read_timeout",
297                                            connection_id,
298                                            read_timeout.as_secs()
299                                        ));
300                                        break;
301                                    }
302                                };
303                                match read_result {
304                                    Ok(0) => {
305                                        Log::new(Some(&status_clone)).debug(format!(
306                                            "XMPP connection {} closed by client",
307                                            connection_id
308                                        ));
309                                        break;
310                                    }
311                                    Ok(n) => {
312                                        buffer.extend_from_slice(&temp_buf[..n]);
313
314                                        // Live ↓ counter + last_activity for the dashboard.
315                                        state_clone
316                                            .update_connection_stats(
317                                                server_id,
318                                                connection_id,
319                                                Some(n as u64),
320                                                None,
321                                                Some(1),
322                                                None,
323                                            )
324                                            .await;
325
326                                        if buffer.len() > MAX_XMPP_BUFFER_BYTES {
327                                            Log::new(Some(&status_clone)).error(format!(
328                                                "XMPP connection {} buffered {} bytes without \
329                                                 completing a stanza (limit {}), closing",
330                                                connection_id,
331                                                buffer.len(),
332                                                MAX_XMPP_BUFFER_BYTES
333                                            ));
334                                            break;
335                                        }
336
337                                        // Byte-count summary and full XML are FileOnly.
338                                        let log = Log::new(Some(&status_clone));
339                                        log.debug(format!(
340                                            "XMPP received {} bytes on connection {}",
341                                            n, connection_id
342                                        ));
343                                        log.trace(format!(
344                                            "XMPP data (XML): {}",
345                                            String::from_utf8_lossy(&buffer)
346                                        ));
347
348                                        // Try to parse XML stanzas from buffer
349                                        // For simplicity, we'll pass the entire buffer to LLM for parsing
350                                        // A more sophisticated implementation would parse individual stanzas
351
352                                        let xml_data = String::from_utf8_lossy(&buffer).to_string();
353
354                                        // Create event for LLM
355                                        let event = Event::new(
356                                            &XMPP_DATA_RECEIVED_EVENT,
357                                            serde_json::json!({
358                                                "xml_data": xml_data
359                                            }),
360                                        );
361
362                                        log.debug(format!(
363                                            "XMPP calling LLM for connection {}",
364                                            connection_id
365                                        ));
366
367                                        match call_llm(
368                                            &llm_clone,
369                                            &state_clone,
370                                            server_id,
371                                            Some(connection_id),
372                                            &event,
373                                            protocol_clone.as_ref(),
374                                        )
375                                        .await
376                                        {
377                                            Ok(execution_result) => {
378                                                for message in &execution_result.messages {
379                                                    log.info(format!("{}", message));
380                                                }
381
382                                                log.debug(format!(
383                                                    "XMPP got {} protocol results",
384                                                    execution_result.protocol_results.len()
385                                                ));
386
387                                                let mut should_close = false;
388                                                let mut wait_for_more = false;
389                                                let mut wrote_any = false;
390
391                                                for protocol_result in
392                                                    execution_result.protocol_results
393                                                {
394                                                    match protocol_result {
395                                                        ActionResult::Output(data) => {
396                                                            let xml_str =
397                                                                String::from_utf8_lossy(&data);
398                                                            let mut write =
399                                                                write_half_arc.lock().await;
400                                                            let _ = write.write_all(&data).await;
401                                                            let _ = write.flush().await;
402                                                            drop(write);
403                                                            stream_opened = true;
404                                                            wrote_any = true;
405
406                                                            // Live ↑ counter + last_activity.
407                                                            state_clone
408                                                                .update_connection_stats(
409                                                                    server_id,
410                                                                    connection_id,
411                                                                    None,
412                                                                    Some(data.len() as u64),
413                                                                    None,
414                                                                    Some(1),
415                                                                )
416                                                                .await;
417
418                                                            // Byte-count summary and full XML FileOnly.
419                                                            log.debug(format!("XMPP sent {} bytes on connection {}", data.len(), connection_id));
420                                                            log.trace(format!(
421                                                                "XMPP sent (XML): {}",
422                                                                xml_str
423                                                            ));
424                                                        }
425                                                        ActionResult::CloseConnection => {
426                                                            should_close = true;
427                                                        }
428                                                        ActionResult::WaitForMore => {
429                                                            // Keep buffer and wait for more data
430                                                            wait_for_more = true;
431                                                            log.debug("XMPP waiting for more data");
432                                                        }
433                                                        _ => {}
434                                                    }
435                                                }
436
437                                                // Three outcomes stay distinguishable in the
438                                                // log: the model closed the stream, the model
439                                                // answered with nothing, and the LLM call
440                                                // errored (`decision=fail_closed_llm_error`,
441                                                // below). Only the last one is netget failing.
442                                                if should_close {
443                                                    log.debug(format!(
444                                                        "XMPP connection {connection_id} closed \
445                                                         by model: decision=model_close"
446                                                    ));
447                                                    break;
448                                                }
449
450                                                if !wrote_any && !wait_for_more {
451                                                    log.debug(format!(
452                                                        "XMPP no stanza for connection \
453                                                         {connection_id}: \
454                                                         decision=model_no_actions"
455                                                    ));
456                                                }
457
458                                                // Only consume the buffer once the model has
459                                                // acted on it. Clearing unconditionally made
460                                                // `wait_for_more` a no-op: the partial stanza
461                                                // it asked to hold on to was thrown away, and
462                                                // the continuation arrived without its opening
463                                                // tag.
464                                                if !wait_for_more {
465                                                    buffer.clear();
466                                                }
467                                            }
468                                            Err(e) => {
469                                                // This branch used to log and loop, so the peer
470                                                // sat blocked on a stanza that was never going
471                                                // to be answered until its own timeout fired -
472                                                // the "reset and write nothing" shape CLAUDE.md
473                                                // flags. XMPP has a defined frame for exactly
474                                                // this, so use it: a fatal stream error, then
475                                                // close.
476                                                let failure =
477                                                    crate::utils::WireFailure::classify(&e);
478                                                let class = if failure.is_overloaded() {
479                                                    "overloaded"
480                                                } else {
481                                                    "unavailable"
482                                                };
483                                                // The full error goes to the log and the status
484                                                // stream only. Never to the peer.
485                                                log.warn(format!(
486                                                    "XMPP LLM call failed on connection \
487                                                     {connection_id}: \
488                                                     decision=fail_closed_llm_error \
489                                                     class={class} error={e}"
490                                                ));
491
492                                                let frame = stream_error_frame(
493                                                    failure,
494                                                    stream_opened,
495                                                    &domain_clone,
496                                                );
497                                                let mut write = write_half_arc.lock().await;
498                                                let _ = write.write_all(frame.as_bytes()).await;
499                                                let _ = write.flush().await;
500                                                drop(write);
501
502                                                state_clone
503                                                    .update_connection_stats(
504                                                        server_id,
505                                                        connection_id,
506                                                        None,
507                                                        Some(frame.len() as u64),
508                                                        None,
509                                                        Some(1),
510                                                    )
511                                                    .await;
512
513                                                // A stream error is unrecoverable (RFC 6120
514                                                // §4.9): both sides close after it.
515                                                break;
516                                            }
517                                        }
518                                    }
519                                    Err(e) => {
520                                        Log::new(Some(&status_clone)).error(format!(
521                                            "XMPP read error on connection {}: {}",
522                                            connection_id, e
523                                        ));
524                                        break;
525                                    }
526                                }
527                            }
528
529                            // Every exit path - EOF, buffer overflow, close_stream, read error -
530                            // breaks out of the loop and lands here. Drop the peer handle so the
531                            // dashboard stops offering to message a dead connection; idempotent
532                            // with the peer task's own removal on an injected close.
533                            state_clone
534                                .remove_peer_handle(server_id, connection_id.as_u32())
535                                .await;
536
537                            // Connection closed - mark as closed
538                            state_clone
539                                .close_connection_on_server(server_id, connection_id)
540                                .await;
541                            let _ = status_clone.send("__UPDATE_UI__".to_string());
542                        }).await;
543                    }
544                    Err(e) => {
545                        Log::new(Some(&status_tx))
546                            .error(format!("Failed to accept XMPP connection: {}", e));
547                        break;
548                    }
549                }
550            }
551        });
552
553        task_registrar
554            .register_server_task(server_id, accept_handle)
555            .await;
556
557        Ok(local_addr)
558    }
559}