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}