netget/server/irc/mod.rs
1//! IRC server implementation
2pub mod actions;
3pub(crate) mod wire;
4
5use crate::server::connection::ConnectionId;
6use anyhow::Result;
7use std::net::SocketAddr;
8use std::sync::Arc;
9use std::time::Duration;
10use tokio::io::{AsyncWriteExt, BufReader};
11use tokio::sync::mpsc;
12use tracing::{debug, error, info, trace};
13
14use crate::llm::action_helper::call_llm;
15use crate::llm::actions::protocol_trait::ActionResult;
16use crate::llm::ollama_client::OllamaClient;
17use crate::protocol::Event;
18use crate::server::irc::wire::{read_irc_line, IrcLine, MAX_IRC_READ_LINE};
19use crate::server::IrcProtocol;
20use crate::state::app_state::AppState;
21use actions::IRC_MESSAGE_RECEIVED_EVENT;
22
23/// How long to wait for the first message from a peer that has only connected.
24///
25/// IRC is client-speaks-first and the first thing a client sends is its registration — `NICK`
26/// and `USER`, or `CAP LS` before them — inside its own dial path. Every real ircd bounds
27/// exactly this: InspIRCd's connect timeout and charybdis/ratbox's registration timeout both
28/// default to 30 seconds, so a minute is twice the strictest thing a real client is already
29/// built to satisfy, and a peer that has not registered has authenticated nothing.
30const FIRST_MESSAGE_READ_TIMEOUT: Duration = Duration::from_secs(60);
31
32/// How long to wait for a *further* message once the peer has sent one.
33///
34/// Half an hour, because IRC is long-lived by nature: a registered client may sit in a channel
35/// for a whole working day without typing, and closing that is breaking the protocol's own use
36/// rather than defending it. The number is still an order of magnitude more generous than any
37/// ircd's own liveness bound — InspIRCd and UnrealIRCd ping an idle client every 120 seconds
38/// and drop it after roughly twice that — so nothing a real client does comes near it, while an
39/// unauthenticated socket still cannot be held forever.
40///
41/// The LLM round-trip and a `manual` rule parking a message for a human
42/// (`src/state/intercepts.rs`, 300s by default) both happen after a line has already been read,
43/// so they sit outside this deadline by construction.
44const IDLE_BETWEEN_MESSAGES_TIMEOUT: Duration = Duration::from_secs(1800);
45
46/// Concurrent connections this server admits.
47///
48/// [`crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS`]. Each connection may buffer up to
49/// [`MAX_IRC_READ_LINE`], so this is the multiplier that turns that per-connection bound into a
50/// total one.
51const MAX_CONNECTIONS: usize = crate::server::accept_bounded::DEFAULT_MAX_CONNECTIONS;
52
53/// What a peer over [`MAX_CONNECTIONS`] is told before the socket closes.
54///
55/// `ERROR :Closing Link:` is IRC's own way of ending a link before registration, and it is
56/// verbatim what a real ircd sends when it is at its connection limit. This file already uses
57/// the same form for an over-long line. Fixed text, so there is no placeholder an internal
58/// error could reach.
59const CONNECTION_CAP_REFUSAL: &[u8] = b"ERROR :Closing Link: too many connections\r\n";
60
61/// IRC server that forwards messages to LLM
62pub struct IrcServer;
63
64impl IrcServer {
65 /// Spawn IRC server with integrated LLM actions
66 pub async fn spawn_with_llm_actions(
67 listen_addr: SocketAddr,
68 llm_client: OllamaClient,
69 app_state: Arc<AppState>,
70 status_tx: mpsc::UnboundedSender<String>,
71 server_id: crate::state::ServerId,
72 ) -> Result<SocketAddr> {
73 let listener =
74 crate::server::socket_helpers::create_reusable_tcp_listener(listen_addr).await?;
75 let local_addr = listener.local_addr()?;
76 info!("IRC server (action-based) listening on {}", local_addr);
77 let _ = status_tx.send(format!("IRC server listening on {}", local_addr));
78
79 let protocol = Arc::new(IrcProtocol::new());
80
81 let task_registrar = app_state.clone();
82 let limiter = crate::server::accept_bounded::ConnectionLimiter::new(MAX_CONNECTIONS);
83 let accept_handle = tokio::spawn(async move {
84 loop {
85 match crate::server::accept_bounded::accept_bounded(
86 &listener,
87 &limiter,
88 CONNECTION_CAP_REFUSAL,
89 "IRC",
90 Some(&status_tx),
91 )
92 .await
93 {
94 Ok((stream, remote_addr, permit)) => {
95 let connection_id =
96 ConnectionId::new(app_state.get_next_unified_id().await);
97 let local_addr_conn = stream.local_addr().unwrap_or(local_addr);
98 let llm_clone = llm_client.clone();
99 let state_clone = app_state.clone();
100 let status_clone = status_tx.clone();
101 let protocol_clone = protocol.clone();
102
103 // Tracked, not detached: stop_server must abort this task too.
104 let task_owner = app_state.clone();
105 task_owner.spawn_server_task(server_id, async move {
106 // Held for the life of the connection: releasing it here would cap
107 // the accept rate rather than the number of live connections.
108 let _permit = permit;
109 let (read_half, write_half) = tokio::io::split(stream);
110 let write_half_arc = Arc::new(tokio::sync::Mutex::new(write_half));
111
112 // Add connection to ServerInstance
113 use crate::state::server::{
114 ConnectionState as ServerConnectionState, ConnectionStatus,
115 ProtocolConnectionInfo,
116 };
117 let now = crate::utils::clock::Instant::now();
118 let conn_state = ServerConnectionState {
119 id: connection_id,
120 remote_addr,
121 local_addr: local_addr_conn,
122 bytes_sent: 0,
123 bytes_received: 0,
124 packets_sent: 0,
125 packets_received: 0,
126 last_activity: now,
127 status: ConnectionStatus::Active,
128 status_changed_at: now,
129 protocol_info: ProtocolConnectionInfo::empty(),
130 };
131 state_clone
132 .add_connection_to_server(server_id, conn_state)
133 .await;
134 let _ = status_clone.send("__UPDATE_UI__".to_string());
135
136 // Peer messaging: the dashboard's "message this peer" /
137 // "disconnect this peer" inject actions into THIS connection
138 // through the same executor the LLM path uses. Registered
139 // before the first read, so a manual `*` rule parking the
140 // registration exchange still leaves the operator able to
141 // reach the connection.
142 let peer_rx = crate::server::peer_support::register_peer_channel(
143 &state_clone,
144 server_id,
145 connection_id.as_u32(),
146 )
147 .await;
148 crate::server::peer_support::spawn_peer_command_task(
149 peer_rx,
150 protocol_clone.clone(),
151 state_clone.clone(),
152 server_id,
153 connection_id.as_u32(),
154 write_half_arc.clone(),
155 status_clone.clone(),
156 );
157
158 let mut reader = BufReader::new(read_half);
159 let mut answered_one = false;
160
161 loop {
162 // The deadline wraps this read and nothing else: the model
163 // round-trip below, and a `manual` rule parking a message for
164 // a human, happen only after a line has been read.
165 let read_timeout = if answered_one {
166 IDLE_BETWEEN_MESSAGES_TIMEOUT
167 } else {
168 FIRST_MESSAGE_READ_TIMEOUT
169 };
170 // Bounded: `read_line` grows its buffer until it finds a
171 // newline, so an unauthenticated peer that connects and
172 // streams bytes with no `\n` was a one-connection OOM. IRC
173 // messages are 512 bytes by RFC 1459 and 8704 with IRCv3
174 // tags, so nothing real is refused by the ceiling.
175 let framed = tokio::time::timeout(
176 read_timeout,
177 read_irc_line(&mut reader, MAX_IRC_READ_LINE),
178 )
179 .await;
180 let (read, n) = match framed {
181 Err(_) => {
182 info!(
183 "IRC connection {} from {} sent nothing for {}s; \
184 closing idle connection",
185 connection_id,
186 remote_addr,
187 read_timeout.as_secs()
188 );
189 break;
190 }
191 Ok(Ok(v)) => v,
192 Ok(Err(e)) => {
193 debug!(
194 "IRC read error on connection {}: {}",
195 connection_id, e
196 );
197 break;
198 }
199 };
200 answered_one = true;
201 let line = match read {
202 IrcLine::Line(line) => line,
203 IrcLine::Eof => break,
204 IrcLine::TooLong => {
205 // No resynchronisation point: the peer is mid-line
206 // and its prefix is gone. ERROR + close is IRC's own
207 // way of ending a link, and it names the limit so a
208 // real client's operator can see what happened.
209 // Refused by the protocol before any model call, so
210 // it is neither the model's answer nor a backend
211 // failure and carries its own token.
212 error!(
213 "IRC connection {} sent {} bytes with no newline \
214 (limit {}), closing decision=refused_body_too_large",
215 connection_id, n, MAX_IRC_READ_LINE
216 );
217 let _ = status_clone.send(format!(
218 "[ERROR] IRC connection {} exceeded {} bytes with no \
219 newline decision=refused_body_too_large",
220 connection_id, MAX_IRC_READ_LINE
221 ));
222 let reply = format!(
223 "ERROR :Closing link: message exceeds {MAX_IRC_READ_LINE} bytes\r\n"
224 );
225 let mut write = write_half_arc.lock().await;
226 let _ = write.write_all(reply.as_bytes()).await;
227 let _ = write.flush().await;
228 let _ = write.shutdown().await;
229 break;
230 }
231 };
232 state_clone
233 .update_connection_stats(
234 server_id,
235 connection_id,
236 Some(n as u64),
237 None,
238 Some(1),
239 None,
240 )
241 .await;
242
243 // DEBUG: Log summary with text preview.
244 // `truncate_for_log` cuts on a char boundary. Slicing with
245 // `&line[..100]` panicked here on any IRC line longer than 100
246 // bytes whose 100th byte fell inside a multi-byte character -
247 // i.e. on ordinary non-ASCII chat text - and a panic in this
248 // task is silent while the server still reads as Running.
249 let preview = crate::utils::truncate_for_log(&line, 100);
250 debug!(
251 "IRC received {} bytes on connection {}: {}",
252 n,
253 connection_id,
254 preview.trim()
255 );
256 let _ = status_clone.send(format!(
257 "[DEBUG] IRC received {} bytes on connection {}: {}",
258 n,
259 connection_id,
260 preview.trim()
261 ));
262
263 // TRACE: Log full text payload
264 trace!("IRC data (text): {:?}", line.trim());
265 let _ = status_clone
266 .send(format!("[TRACE] IRC data (text): {:?}", line.trim()));
267
268 let event = Event::new(
269 &IRC_MESSAGE_RECEIVED_EVENT,
270 serde_json::json!({
271 "message": line.trim()
272 }),
273 );
274
275 debug!("IRC calling LLM for connection {}", connection_id);
276 let _ = status_clone.send(format!(
277 "[DEBUG] IRC calling LLM for connection {}",
278 connection_id
279 ));
280
281 match call_llm(
282 &llm_clone,
283 &state_clone,
284 server_id,
285 Some(connection_id),
286 &event,
287 protocol_clone.as_ref(),
288 )
289 .await
290 {
291 Ok(execution_result) => {
292 for message in &execution_result.messages {
293 info!("{}", message);
294 let _ =
295 status_clone.send(format!("[INFO] {}", message));
296 }
297
298 debug!(
299 "IRC got {} protocol results",
300 execution_result.protocol_results.len()
301 );
302 let _ = status_clone.send(format!(
303 "[DEBUG] IRC got {} protocol results",
304 execution_result.protocol_results.len()
305 ));
306
307 // Which terminal outcome this answer was. Computed
308 // here rather than inferred from the log later,
309 // because "the model sent a numeric", "the model
310 // asked to hang up" and "the model said nothing" are
311 // three different things that all end with the server
312 // going back to reading.
313 let mut wrote_output = false;
314 let mut asked_to_close = false;
315 let failures = execution_result.failures.len();
316 for protocol_result in execution_result.protocol_results {
317 match protocol_result {
318 ActionResult::Output(data) => {
319 wrote_output = true;
320 let response = String::from_utf8_lossy(&data);
321 let formatted = if response.ends_with("\r\n") {
322 response.to_string()
323 } else if response.ends_with('\n') {
324 format!("{response}\r")
325 } else {
326 format!("{response}\r\n")
327 };
328 {
329 let mut write = write_half_arc.lock().await;
330 let _ = write
331 .write_all(formatted.as_bytes())
332 .await;
333 let _ = write.flush().await;
334 }
335 state_clone
336 .update_connection_stats(
337 server_id,
338 connection_id,
339 None,
340 Some(formatted.len() as u64),
341 None,
342 Some(1),
343 )
344 .await;
345
346 // Same char-boundary hazard as the inbound
347 // preview above, but on model output.
348 let preview = crate::utils::truncate_for_log(
349 &formatted, 100,
350 );
351 debug!(
352 "IRC sent {} bytes on connection {}: {}",
353 formatted.len(),
354 connection_id,
355 preview.trim()
356 );
357 let _ = status_clone.send(format!("[DEBUG] IRC sent {} bytes on connection {}: {}", formatted.len(), connection_id, preview.trim()));
358
359 // TRACE: Log full text payload
360 trace!(
361 "IRC sent (text): {:?}",
362 formatted.trim()
363 );
364 let _ = status_clone.send(format!(
365 "[TRACE] IRC sent (text): {:?}",
366 formatted.trim()
367 ));
368 }
369 ActionResult::CloseConnection => {
370 asked_to_close = true;
371 break;
372 }
373 _ => {}
374 }
375 }
376
377 // One decision line per request. `model_reject` is the
378 // model choosing to end the link rather than answer;
379 // `model_silent` is an answer with nothing usable in
380 // it; `fail_closed_bad_action` is an answer the
381 // executor refused. All three leave the peer with no
382 // reply, so the log is the only place they differ.
383 let decision = if wrote_output {
384 "model_answer"
385 } else if asked_to_close {
386 "model_reject"
387 } else if failures == 0 {
388 "model_silent"
389 } else {
390 "fail_closed_bad_action"
391 };
392 let summary = format!(
393 "IRC {} on connection {} from {} decision={} ({} failed action(s))",
394 irc_command_token(&line),
395 connection_id,
396 remote_addr,
397 decision,
398 failures
399 );
400 if decision == "fail_closed_bad_action" {
401 error!("{}", summary);
402 let _ =
403 status_clone.send(format!("[ERROR] {}", summary));
404 } else if decision == "model_silent" {
405 tracing::warn!("{}", summary);
406 let _ =
407 status_clone.send(format!("[WARN] {}", summary));
408 } else {
409 info!("{}", summary);
410 let _ =
411 status_clone.send(format!("[INFO] {}", summary));
412 }
413
414 // Actually end the link the model asked to end.
415 //
416 // The `break` that sets `asked_to_close` leaves the
417 // `for` over `protocol_results` — it stops executing
418 // further actions, which is right — but it does not
419 // leave the read loop, so for a long time
420 // `close_connection` logged a hang-up and then went
421 // back to reading the next line. This break is the one
422 // that closes, and it is placed after the decision
423 // line so a refusal is still logged before the link
424 // goes.
425 if asked_to_close {
426 break;
427 }
428 }
429 Err(e) => {
430 // Silence here is worse than it looks: a client that
431 // has sent NICK/USER blocks until it gives up, because
432 // registration only completes on numeric 001. 400
433 // (ERR_UNKNOWNERROR) is the numeric reserved for
434 // exactly this - a command the server understood and
435 // could not carry out - and it can never be mistaken
436 // for a registration, a JOIN or a PRIVMSG.
437 // Saturation keeps the link (the client may retry);
438 // anything else closes it. Both look like a 400 to the
439 // client, so the token carries the distinction, and
440 // the error goes only here — never into the numeric's
441 // trailing parameter, which a real IRC client prints
442 // verbatim to a human.
443 let token = if crate::utils::WireFailure::classify(&e)
444 .is_overloaded()
445 {
446 "fail_closed_llm_overloaded"
447 } else {
448 "fail_closed_llm_error"
449 };
450 let command = irc_command_token(&line);
451 error!(
452 "IRC {} on connection {} from {} decision={}: {}",
453 command, connection_id, remote_addr, token, e
454 );
455 let _ = status_clone.send(format!(
456 "[ERROR] IRC {} on connection {} from {} decision={}",
457 command, connection_id, remote_addr, token
458 ));
459 let (reply, close) = irc_failure_reply(&command, &e);
460 let _ = status_clone.send(format!(
461 "[ERROR] IRC connection {} replying: {}",
462 connection_id,
463 reply.trim_end()
464 ));
465 {
466 let mut write = write_half_arc.lock().await;
467 let _ = write.write_all(reply.as_bytes()).await;
468 let _ = write.flush().await;
469 if close {
470 let _ = write.shutdown().await;
471 }
472 }
473 state_clone
474 .update_connection_stats(
475 server_id,
476 connection_id,
477 None,
478 Some(reply.len() as u64),
479 None,
480 Some(1),
481 )
482 .await;
483 if close {
484 break;
485 }
486 }
487 }
488 }
489
490 // Connection closed - every exit path (EOF, read error,
491 // close_connection, backend-unavailable ERROR) lands here.
492 state_clone
493 .remove_peer_handle(server_id, connection_id.as_u32())
494 .await;
495 state_clone
496 .close_connection_on_server(server_id, connection_id)
497 .await;
498 let _ = status_clone.send("__UPDATE_UI__".to_string());
499 }).await;
500 }
501 Err(e) => {
502 error!("Failed to accept IRC connection: {}", e);
503 break;
504 }
505 }
506 }
507 });
508
509 task_registrar
510 .register_server_task(server_id, accept_handle)
511 .await;
512
513 Ok(local_addr)
514 }
515}
516
517/// The command token of an IRC message, uppercased, for echoing back in a numeric.
518///
519/// Handles the optional `:prefix` clients are allowed to send, and falls back to `*` for a
520/// line with nothing usable in it - a numeric with a missing parameter is worse than a vague
521/// one, because it changes the arity the client parses.
522fn irc_command_token(line: &str) -> String {
523 let trimmed = line.trim();
524 let without_prefix = if let Some(rest) = trimmed.strip_prefix(':') {
525 rest.split_once(' ').map(|(_, r)| r).unwrap_or("")
526 } else {
527 trimmed
528 };
529 let token: String = without_prefix
530 .split_whitespace()
531 .next()
532 .unwrap_or("*")
533 .chars()
534 .filter(|c| c.is_ascii_alphanumeric())
535 .collect();
536 if token.is_empty() {
537 "*".to_string()
538 } else {
539 token.to_uppercase()
540 }
541}
542
543/// What to send an IRC client when the LLM backend fails, and whether to close the link.
544///
545/// Numeric 400 (`ERR_UNKNOWNERROR`) is the code reserved for "an error the server has no more
546/// specific numeric for", carrying the offending command as a parameter. It is not 421
547/// (`ERR_UNKNOWNCOMMAND`), which would tell the client the command does not exist and stop it
548/// ever retrying, and it is not any of the registration numerics, so a client waiting on 001
549/// cannot mistake it for a completed registration.
550///
551/// A capacity failure is transient, so the link survives and the client may try again. Any
552/// other failure means the server cannot answer anything at all, and IRC's honest way to say
553/// that is `ERROR` followed by closing the link - which is also what unblocks a client that is
554/// mid-registration instead of leaving it on a socket that will never speak again.
555fn irc_failure_reply(command: &str, err: &anyhow::Error) -> (String, bool) {
556 // The text is a category, never the error itself (`crate::utils::wire_failure`). A numeric
557 // is one CRLF-terminated line whose trailing parameter runs to end of line, so an embedded
558 // newline in an error would have forged a second message.
559 let failure = crate::utils::WireFailure::classify(err);
560 let text = failure.prefixed_text();
561 if failure.is_overloaded() {
562 (format!(":netget 400 * {command} :{text}\r\n"), false)
563 } else {
564 (
565 format!(
566 ":netget 400 * {command} :{text}\r\nERROR :Closing link: netget backend unavailable\r\n"
567 ),
568 true,
569 )
570 }
571}