Skip to main content

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}