//! Native, cancellation-safe IRC/local-grid chat bridge. use clap::Parser; use libremetaverse::types::UUID; use libremetaverse::types::compat::{CancellationToken, CancellationTokenSource, Subscription}; use libremetaverse::{ AgentManager, ChatAudibleLevel, ChatEventArgs, ChatType, GridClient, InstantMessageDialog, InstantMessageEventArgs, LoginParams, LoginProgressEventArgs, NetworkManager, }; use std::collections::VecDeque; use std::fmt; use std::fs::File; use std::io::{self, BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::ExitCode; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, MutexGuard}; use std::time::{Duration, Instant}; use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt, BufReader as AsyncBufReader}; use tokio::net::TcpStream; use tokio::sync::mpsc; pub const EXIT_SUCCESS: u8 = 0; pub const EXIT_USAGE: u8 = 2; pub const EXIT_INPUT: u8 = 3; pub const EXIT_CLIENT: u8 = 4; const MAX_SCRIPT_BYTES: u64 = 1024 * 1024; const MAX_SCRIPT_EVENTS: usize = 4096; const MAX_NAME_BYTES: usize = 256; const MAX_MESSAGE_BYTES: usize = 4096; const EVENT_QUEUE_CAPACITY: usize = 256; const OUTBOUND_QUEUE_CAPACITY: usize = 128; const BRIDGE_QUEUE_CAPACITY: usize = 64; const IRC_LINE_BYTES: usize = 512; const MAX_IRC_INPUT_BYTES: usize = 8192; const DUPLICATE_WINDOW_MS: u64 = 2_000; const LOOP_WINDOW_MS: u64 = 30_000; const MAX_RECENT_MESSAGES: usize = 256; const TICK_MS: u64 = 100; #[derive(Parser)] #[command( name = "irc-gateway", version, about = "Bridge one IRC channel and LibreMetaverse local chat", long_about = None, arg_required_else_help = true, after_help = "Live mode preserves the upstream positional interface. Offline scripts use tab-separated events: irc-connect, irc-disconnect, irc, grid-chat, grid-login, teleport, advance, and shutdown." )] struct Cli { /// Avatar first name. May also be supplied as `GRID_FIRST_NAME`. #[arg(value_name = "FIRSTNAME")] first_name: Option, /// Avatar last name. May also be supplied as `GRID_LAST_NAME`. #[arg(value_name = "LASTNAME")] last_name: Option, /// Avatar password. May also be supplied as `GRID_PASSWORD`. #[arg(value_name = "PASSWORD")] password: Option, /// UUID whose teleport lures are accepted. May also be `GRID_MASTER_UUID`. #[arg(value_name = "MASTER_UUID")] master_uuid: Option, /// IRC server host. May also be `IRC_HOST`. #[arg(value_name = "IRC_HOST")] irc_host: Option, /// IRC server port. May also be `IRC_PORT`. #[arg(value_name = "IRC_PORT")] irc_port: Option, /// IRC channel, including its leading '#'. May also be `IRC_CHANNEL`. #[arg(value_name = "#CHANNEL")] channel: Option, /// Run a deterministic, bounded offline bridge script. #[arg(long, value_name = "FILE")] fake_script: Option, /// Channel used in fake mode when the positional channel is absent. #[arg(long, default_value = "#metacrate", value_name = "#CHANNEL")] fake_channel: String, /// Master UUID used in fake mode when the positional UUID is absent. #[arg( long, default_value = "11111111-2222-3333-4444-555555555555", value_name = "UUID" )] fake_master_uuid: String, /// IRC nickname. #[arg(long, default_value = "SLGateway", value_name = "NICK")] nickname: String, /// IRC real name sent during registration. #[arg(long, default_value = "Second Life Gateway", value_name = "TEXT")] real_name: String, /// Override the grid login endpoint; `GRID_LOGIN_URL` is the fallback. #[arg(long, value_name = "URL")] login_uri: Option, /// Maximum time allowed for grid login. #[arg(long, default_value_t = 30, value_name = "SECONDS", value_parser = clap::value_parser!(u64).range(1..=300))] login_timeout_seconds: u64, /// Per-direction bridge rate, in messages per second. #[arg(long, default_value_t = 4, value_name = "COUNT", value_parser = clap::value_parser!(u32).range(1..=100))] messages_per_second: u32, /// Per-direction burst capacity. #[arg(long, default_value_t = 4, value_name = "COUNT", value_parser = clap::value_parser!(u32).range(1..=100))] burst: u32, } struct LiveConfig { first_name: String, last_name: String, password: String, master: UUID, irc_host: String, irc_port: u16, bridge: BridgeConfig, real_name: String, login_uri: Option, login_timeout: Duration, } #[derive(Clone)] struct BridgeConfig { channel: String, nickname: String, messages_per_second: u32, burst: u32, } #[derive(Debug)] enum ProgramError { Usage(&'static str), Io { action: String, source: io::Error }, InvalidScript { line: usize, reason: &'static str }, Client(&'static str), LoginFailed, LoginTimedOut, Signal, } impl ProgramError { const fn exit_code(&self) -> u8 { match self { Self::Usage(_) => EXIT_USAGE, Self::Io { .. } | Self::InvalidScript { .. } => EXIT_INPUT, Self::Client(_) | Self::LoginFailed | Self::LoginTimedOut | Self::Signal => EXIT_CLIENT, } } } impl fmt::Display for ProgramError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Usage(message) => f.write_str(message), Self::Io { action, source } => write!(f, "{action}: {source}"), Self::InvalidScript { line, reason } => { write!(f, "invalid bridge script at line {line}: {reason}") } Self::Client(operation) => write!(f, "native client operation failed: {operation}"), Self::LoginFailed => f.write_str("grid login failed"), Self::LoginTimedOut => f.write_str("grid login timed out"), Self::Signal => f.write_str("could not install the Ctrl-C handler"), } } } #[derive(Clone, Debug)] enum BridgeEvent { IrcConnected, IrcDisconnected, IrcMessage { target: String, nick: String, message: String, }, GridChat { source_id: UUID, name: String, message: String, normal: bool, fully_audible: bool, }, GridLogin(String), OwnGridId(UUID), Teleport { source_id: UUID, session_id: UUID, }, } trait BridgeTransport { fn join_irc(&mut self, channel: &str) -> Result<(), &'static str>; fn send_irc(&mut self, channel: &str, message: &str) -> Result<(), &'static str>; fn send_grid(&mut self, message: &str) -> Result<(), &'static str>; fn accept_teleport(&mut self, source: UUID, session: UUID) -> Result<(), &'static str>; } #[derive(Clone, Copy, Eq, PartialEq)] enum Direction { ToIrc, ToGrid, } struct PendingMessage { direction: Direction, message: String, } struct RecentMessage { direction: Direction, message: String, at_ms: u64, } struct TokenBucket { milli_tokens: u64, last_ms: u64, rate: u64, burst_milli: u64, } impl TokenBucket { fn new(rate: u32, burst: u32) -> Self { Self { milli_tokens: u64::from(burst) * 1_000, last_ms: 0, rate: u64::from(rate), burst_milli: u64::from(burst) * 1_000, } } fn take(&mut self, now_ms: u64) -> bool { let elapsed = now_ms.saturating_sub(self.last_ms); self.last_ms = now_ms; self.milli_tokens = self .milli_tokens .saturating_add(elapsed.saturating_mul(self.rate)) .min(self.burst_milli); if self.milli_tokens >= 1_000 { self.milli_tokens -= 1_000; true } else { false } } } struct Bridge { config: BridgeConfig, master: UUID, own_grid_id: UUID, irc_connected: bool, pending: VecDeque, recent: VecDeque, inbound_recent: VecDeque, irc_rate: TokenBucket, grid_rate: TokenBucket, dropped: usize, } impl Bridge { fn new(config: BridgeConfig, master: UUID, own_grid_id: UUID) -> Self { Self { irc_rate: TokenBucket::new(config.messages_per_second, config.burst), grid_rate: TokenBucket::new(config.messages_per_second, config.burst), config, master, own_grid_id, irc_connected: false, pending: VecDeque::new(), recent: VecDeque::new(), inbound_recent: VecDeque::new(), dropped: 0, } } fn handle( &mut self, event: BridgeEvent, now_ms: u64, transport: &mut dyn BridgeTransport, log: &dyn BridgeLogger, ) { self.expire_recent(now_ms); match event { BridgeEvent::IrcConnected => { self.irc_connected = true; if let Err(error) = transport.join_irc(&self.config.channel) { log.line(format!("IRC join failed: {error}")); } else { log.line(format!("IRC connected; joined {}", self.config.channel)); } } BridgeEvent::IrcDisconnected => { self.irc_connected = false; log.line("IRC disconnected; reconnect scheduled".into()); } BridgeEvent::IrcMessage { target, nick, message, } => { if !irc_eq(&target, &self.config.channel) || irc_eq(&nick, &self.config.nickname) || !valid_message(&message) { return; } let message = clean_message(&message); if self.is_loop(Direction::ToGrid, &message, now_ms) { log.line("Suppressed duplicate/loop IRC message".into()); return; } let formatted = format!("<{}> {}", map_name(&nick), message); if self.is_duplicate(Direction::ToGrid, &formatted, now_ms) { log.line("Suppressed duplicate/loop IRC message".into()); return; } self.remember_inbound(Direction::ToGrid, &formatted, now_ms); self.enqueue(Direction::ToGrid, formatted); } BridgeEvent::GridChat { source_id, name, message, normal, fully_audible, } => { if source_id == self.own_grid_id || !normal || !fully_audible || !valid_message(&message) { return; } let message = clean_message(&message); if self.is_loop(Direction::ToIrc, &message, now_ms) { log.line("Suppressed duplicate/loop grid message".into()); return; } let formatted = format!("<{}> {}", map_name(&name), message); if self.is_duplicate(Direction::ToIrc, &formatted, now_ms) { log.line("Suppressed duplicate/loop grid message".into()); return; } self.remember_inbound(Direction::ToIrc, &formatted, now_ms); self.enqueue(Direction::ToIrc, formatted); } BridgeEvent::GridLogin(message) => { if valid_message(&message) { self.enqueue(Direction::ToIrc, redact_text(&clean_message(&message))); } } BridgeEvent::OwnGridId(agent_id) => self.own_grid_id = agent_id, BridgeEvent::Teleport { source_id, session_id, } => { if source_id == self.master { if let Err(error) = transport.accept_teleport(source_id, session_id) { log.line(format!("Teleport response failed: {error}")); } else { log.line("Accepted teleport lure from configured master".into()); } } } } self.flush(now_ms, transport, log); } fn tick(&mut self, now_ms: u64, transport: &mut dyn BridgeTransport, log: &dyn BridgeLogger) { self.expire_recent(now_ms); self.flush(now_ms, transport, log); } fn enqueue(&mut self, direction: Direction, message: String) { if self.pending.len() == BRIDGE_QUEUE_CAPACITY { self.pending.pop_front(); self.dropped += 1; } self.pending .push_back(PendingMessage { direction, message }); } fn flush(&mut self, now_ms: u64, transport: &mut dyn BridgeTransport, log: &dyn BridgeLogger) { let mut retained = VecDeque::with_capacity(self.pending.len()); while let Some(item) = self.pending.pop_front() { let permitted = match item.direction { Direction::ToIrc => self.irc_connected && self.irc_rate.take(now_ms), Direction::ToGrid => self.grid_rate.take(now_ms), }; if !permitted { retained.push_back(item); continue; } let result = match item.direction { Direction::ToIrc => transport.send_irc(&self.config.channel, &item.message), Direction::ToGrid => transport.send_grid(&item.message), }; match result { Ok(()) => { self.recent.push_back(RecentMessage { direction: item.direction, message: item.message.clone(), at_ms: now_ms, }); log.line(format!( "[{}] {}", if item.direction == Direction::ToIrc { "Grid->IRC" } else { "IRC->Grid" }, item.message )); } Err(error) => { log.line(format!("Bridge send failed: {error}")); retained.push_front(item); break; } } } retained.append(&mut self.pending); self.pending = retained; while self.recent.len() > MAX_RECENT_MESSAGES { self.recent.pop_front(); } } fn is_loop(&self, direction: Direction, message: &str, now_ms: u64) -> bool { let opposite = if direction == Direction::ToIrc { Direction::ToGrid } else { Direction::ToIrc }; self.recent.iter().any(|recent| { recent.direction == opposite && recent.message == message && now_ms.saturating_sub(recent.at_ms) <= LOOP_WINDOW_MS }) } fn is_duplicate(&self, direction: Direction, message: &str, now_ms: u64) -> bool { self.inbound_recent.iter().any(|recent| { recent.direction == direction && recent.message == message && now_ms.saturating_sub(recent.at_ms) <= DUPLICATE_WINDOW_MS }) } fn remember_inbound(&mut self, direction: Direction, message: &str, now_ms: u64) { self.inbound_recent.push_back(RecentMessage { direction, message: message.into(), at_ms: now_ms, }); while self.inbound_recent.len() > MAX_RECENT_MESSAGES { self.inbound_recent.pop_front(); } } fn expire_recent(&mut self, now_ms: u64) { self.recent .retain(|entry| now_ms.saturating_sub(entry.at_ms) <= LOOP_WINDOW_MS); self.inbound_recent .retain(|entry| now_ms.saturating_sub(entry.at_ms) <= DUPLICATE_WINDOW_MS); } } trait BridgeLogger: Send + Sync { fn line(&self, line: String); } struct ConsoleLogger; impl BridgeLogger for ConsoleLogger { fn line(&self, line: String) { println!("{}", redact_text(&line)); } } #[derive(Default)] struct ScriptTransport { calls: Vec, } impl BridgeTransport for ScriptTransport { fn join_irc(&mut self, channel: &str) -> Result<(), &'static str> { self.calls.push(format!("CALL irc-join {channel}")); Ok(()) } fn send_irc(&mut self, channel: &str, message: &str) -> Result<(), &'static str> { self.calls .push(format!("CALL irc-privmsg {channel} {message}")); Ok(()) } fn send_grid(&mut self, message: &str) -> Result<(), &'static str> { self.calls .push(format!("CALL grid-chat channel=0 type=Normal {message}")); Ok(()) } fn accept_teleport(&mut self, source: UUID, session: UUID) -> Result<(), &'static str> { self.calls.push(format!( "CALL teleport-lure-respond {source} {session} accept=true" )); Ok(()) } } struct LiveGrid { client: Mutex, network: NetworkManager, subscriptions: Mutex>, shutdown: AtomicBool, } impl LiveGrid { fn new(client: GridClient, network: NetworkManager) -> Self { Self { client: Mutex::new(client), network, subscriptions: Mutex::new(Vec::new()), shutdown: AtomicBool::new(false), } } fn with_agent(&self, action: impl FnOnce(&mut AgentManager) -> T) -> T { action(lock(&self.client).self_()) } fn install(self: &Arc, sender: &mpsc::Sender, dropped: &Arc) { let chat_sender = sender.clone(); let chat_dropped = Arc::clone(dropped); let chat = self.with_agent(|agent| { agent.subscribe_chat_from_simulator(Arc::new(move |event: ChatEventArgs| { try_event( &chat_sender, BridgeEvent::GridChat { source_id: event.source_id(), name: event.from_name(), message: event.message(), normal: event.type_() == ChatType::Normal, fully_audible: event.audible_level() == ChatAudibleLevel::Fully, }, &chat_dropped, ); })) }); let im_sender = sender.clone(); let im_dropped = Arc::clone(dropped); let im = self.with_agent(|agent| { agent.subscribe_im(Arc::new(move |event: InstantMessageEventArgs| { let im = event.im(); if im.dialog == InstantMessageDialog::RequestTeleport { try_event( &im_sender, BridgeEvent::Teleport { source_id: im.from_agent_id, session_id: im.im_session_id, }, &im_dropped, ); } })) }); let progress_sender = sender.clone(); let progress_dropped = Arc::clone(dropped); let progress = self.network.subscribe_login_progress(Arc::new( move |event: LoginProgressEventArgs| { try_event( &progress_sender, BridgeEvent::GridLogin(event.message()), &progress_dropped, ); }, )); *lock(&self.subscriptions) = vec![chat, im, progress]; } fn shutdown(&self) { if self.shutdown.swap(true, Ordering::AcqRel) { return; } lock(&self.subscriptions).clear(); let _ = self.network.logout_with_method(); let mut client = lock(&self.client); let _ = client.self_().dispose(); let _ = client.dispose_with_method(); } } struct LiveTransport { grid: Arc, irc: mpsc::Sender, } impl BridgeTransport for LiveTransport { fn join_irc(&mut self, channel: &str) -> Result<(), &'static str> { self.irc .try_send(IrcCommand::Join(channel.into())) .map_err(|_| "IRC command queue full or closed") } fn send_irc(&mut self, channel: &str, message: &str) -> Result<(), &'static str> { self.irc .try_send(IrcCommand::Message(channel.into(), message.into())) .map_err(|_| "IRC command queue full or closed") } fn send_grid(&mut self, message: &str) -> Result<(), &'static str> { self.grid .with_agent(|agent| agent.chat(message.into(), 0, ChatType::Normal, Some(false))) .map_err(|_| "grid chat") } fn accept_teleport(&mut self, source: UUID, session: UUID) -> Result<(), &'static str> { self.grid .with_agent(|agent| agent.teleport_lure_respond(source, session, true)) .map_err(|_| "teleport lure response") } } enum IrcCommand { Join(String), Message(String, String), } struct IrcRuntime { host: String, port: u16, nick: String, real_name: String, commands: mpsc::Receiver, events: mpsc::Sender, dropped: Arc, cancellation: CancellationToken, } #[must_use] pub fn main_entry() -> ExitCode { let cli = Cli::parse(); let Ok(runtime) = tokio::runtime::Builder::new_multi_thread() .enable_all() .build() else { eprintln!("irc-gateway: could not initialize the async runtime"); return ExitCode::from(EXIT_CLIENT); }; match runtime.block_on(run(cli)) { Ok(()) => ExitCode::from(EXIT_SUCCESS), Err(error) => { eprintln!("irc-gateway: {error}"); ExitCode::from(error.exit_code()) } } } async fn run(cli: Cli) -> Result<(), ProgramError> { if let Some(path) = cli.fake_script.clone() { return run_fake(&cli, &path); } run_live(resolve_live(cli)?).await } fn resolve_live(cli: Cli) -> Result { let first_name = required( cli.first_name, "GRID_FIRST_NAME", "FIRSTNAME is required (or set GRID_FIRST_NAME)", )?; let last_name = required( cli.last_name, "GRID_LAST_NAME", "LASTNAME is required (or set GRID_LAST_NAME)", )?; let password = required( cli.password, "GRID_PASSWORD", "PASSWORD is required (or set GRID_PASSWORD)", )?; let master_text = required( cli.master_uuid, "GRID_MASTER_UUID", "MASTER_UUID is required (or set GRID_MASTER_UUID)", )?; let master = UUID::new_with_string(master_text) .map_err(|_| ProgramError::Usage("MASTER_UUID is invalid"))?; let irc_host = required(cli.irc_host, "IRC_HOST", "IRC_HOST is required")?; let irc_port = cli .irc_port .or_else(|| std::env::var("IRC_PORT").ok()?.parse().ok()) .ok_or(ProgramError::Usage( "IRC_PORT is required and must be 1..65535", ))?; if irc_port == 0 { return Err(ProgramError::Usage( "IRC_PORT is required and must be 1..65535", )); } let channel = required(cli.channel, "IRC_CHANNEL", "#CHANNEL is required")?; validate_channel(&channel)?; validate_nick(&cli.nickname)?; Ok(LiveConfig { first_name, last_name, password, master, irc_host, irc_port, bridge: BridgeConfig { channel, nickname: cli.nickname, messages_per_second: cli.messages_per_second, burst: cli.burst, }, real_name: clean_message(&cli.real_name), login_uri: cli .login_uri .or_else(|| std::env::var("GRID_LOGIN_URL").ok()) .filter(|value| !value.is_empty()), login_timeout: Duration::from_secs(cli.login_timeout_seconds), }) } fn required( value: Option, env: &str, message: &'static str, ) -> Result { value .or_else(|| std::env::var(env).ok()) .filter(|value| !value.is_empty()) .ok_or(ProgramError::Usage(message)) } async fn run_live(mut config: LiveConfig) -> Result<(), ProgramError> { let client = GridClient::new().map_err(|_| ProgramError::Client("construct GridClient"))?; let network = client.network(); let mut login = network .default_login_params( std::mem::take(&mut config.first_name), std::mem::take(&mut config.last_name), std::mem::take(&mut config.password), "IRCGateway".into(), env!("CARGO_PKG_VERSION").into(), ) .map_err(|_| ProgramError::Client("build login parameters"))?; if let Some(uri) = config.login_uri.take() { login.uri = uri; } let grid = Arc::new(LiveGrid::new(client, network.clone())); let own_grid_id = grid.with_agent(|agent| agent.agent_id()); let dropped = Arc::new(AtomicUsize::new(0)); let (event_tx, event_rx) = mpsc::channel(EVENT_QUEUE_CAPACITY); let (irc_tx, irc_rx) = mpsc::channel(OUTBOUND_QUEUE_CAPACITY); grid.install(&event_tx, &dropped); let cancellation = CancellationTokenSource::new(); let irc_worker = tokio::spawn(irc_loop(IrcRuntime { host: config.irc_host.clone(), port: config.irc_port, nick: config.bridge.nickname.clone(), real_name: config.real_name.clone(), commands: irc_rx, events: event_tx.clone(), dropped: Arc::clone(&dropped), cancellation: cancellation.token(), })); let bridge_worker = tokio::spawn(bridge_loop( Bridge::new(config.bridge.clone(), config.master, own_grid_id), LiveTransport { grid: Arc::clone(&grid), irc: irc_tx.clone(), }, event_rx, Arc::new(ConsoleLogger), cancellation.token(), )); let result = match await_grid_login(&network, login, config.login_timeout).await { Ok(Some(true)) => { try_event( &event_tx, BridgeEvent::OwnGridId(grid.with_agent(|agent| agent.agent_id())), &dropped, ); tokio::signal::ctrl_c() .await .map_err(|_| ProgramError::Signal) } Ok(Some(false)) => Err(ProgramError::LoginFailed), Ok(None) => Ok(()), Err(error) => Err(error), }; finish_live( grid, event_tx, irc_tx, cancellation, bridge_worker, irc_worker, dropped, ) .await; result } async fn await_grid_login( network: &NetworkManager, login: LoginParams, timeout: Duration, ) -> Result, ProgramError> { let cancellation = CancellationTokenSource::new(); tokio::select! { result = network.login_with_login_params_cancellation_token(login, Some(cancellation.token())) => { result.map(Some).map_err(|_| ProgramError::LoginFailed) }, () = tokio::time::sleep(timeout) => { cancellation.cancel(); let _ = network.abort_login(); Err(ProgramError::LoginTimedOut) }, signal = tokio::signal::ctrl_c() => { cancellation.cancel(); let _ = network.abort_login(); signal.map_err(|_| ProgramError::Signal)?; Ok(None) } } } async fn finish_live( grid: Arc, event_tx: mpsc::Sender, irc_tx: mpsc::Sender, cancellation: CancellationTokenSource, bridge: tokio::task::JoinHandle, irc: tokio::task::JoinHandle<()>, dropped: Arc, ) { cancellation.cancel(); drop(event_tx); drop(irc_tx); let finished = bridge.await.ok(); let _ = irc.await; grid.shutdown(); let total_dropped = dropped.load(Ordering::Acquire) + finished.map_or(0, |bridge| bridge.dropped); if total_dropped > 0 { eprintln!("irc-gateway: dropped {total_dropped} messages because bounded queues were full"); } } async fn bridge_loop( mut bridge: Bridge, mut transport: T, mut events: mpsc::Receiver, logger: Arc, cancellation: CancellationToken, ) -> Bridge { let start = Instant::now(); let mut interval = tokio::time::interval(Duration::from_millis(TICK_MS)); loop { tokio::select! { () = cancellation.cancelled() => break, _ = interval.tick() => bridge.tick(elapsed_ms(start), &mut transport, logger.as_ref()), event = events.recv() => match event { Some(event) => bridge.handle(event, elapsed_ms(start), &mut transport, logger.as_ref()), None => break, } } } events.close(); bridge } async fn irc_loop(mut runtime: IrcRuntime) { let mut backoff = Duration::from_secs(1); loop { let connection = tokio::select! { () = runtime.cancellation.cancelled() => break, result = TcpStream::connect((runtime.host.as_str(), runtime.port)) => result, }; match connection { Ok(stream) => { backoff = Duration::from_secs(1); if run_irc_connection( stream, &runtime.nick, &runtime.real_name, &mut runtime.commands, &runtime.events, &runtime.dropped, runtime.cancellation.clone(), ) .await .is_err() { try_event( &runtime.events, BridgeEvent::IrcDisconnected, &runtime.dropped, ); } } Err(_) => try_event( &runtime.events, BridgeEvent::IrcDisconnected, &runtime.dropped, ), } tokio::select! { () = runtime.cancellation.cancelled() => break, () = tokio::time::sleep(backoff) => {} } backoff = (backoff * 2).min(Duration::from_secs(30)); } } async fn run_irc_connection( stream: TcpStream, nick: &str, real_name: &str, commands: &mut mpsc::Receiver, events: &mpsc::Sender, dropped: &AtomicUsize, cancellation: CancellationToken, ) -> io::Result<()> { let (reader, mut writer) = stream.into_split(); write_irc(&mut writer, &format!("USER {nick} 0 * :{real_name}")).await?; write_irc(&mut writer, &format!("NICK {nick}")).await?; let mut reader = AsyncBufReader::new(reader); loop { tokio::select! { () = cancellation.cancelled() => return Ok(()), command = commands.recv() => match command { Some(IrcCommand::Join(channel)) => write_irc(&mut writer, &format!("JOIN {}", sanitize_irc_atom(&channel))).await?, Some(IrcCommand::Message(target, message)) => { let prefix = format!("PRIVMSG {} :", sanitize_irc_atom(&target)); let message = truncate_utf8(&clean_message(&message), IRC_LINE_BYTES - 2 - prefix.len()); write_irc(&mut writer, &format!("{prefix}{message}")).await?; } None => return Ok(()), }, line = read_irc_line(&mut reader) => { let Some(line) = line? else { return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "IRC disconnected")); }; if let Some(message) = parse_irc_line(&line) { match message.command.as_str() { "PING" => { let payload = message.trailing.or_else(|| message.params.first().cloned()).unwrap_or_default(); write_irc(&mut writer, &format!("PONG :{}", clean_message(&payload))).await?; } "001" => try_event(events, BridgeEvent::IrcConnected, dropped), "PRIVMSG" => { if let (Some(prefix), Some(target), Some(text)) = (message.prefix, message.params.first(), message.trailing) { let nick = prefix.split('!').next().unwrap_or(&prefix).to_owned(); try_event(events, BridgeEvent::IrcMessage { target: target.clone(), nick, message: text }, dropped); } } _ => {} } } } } } } async fn read_irc_line(reader: &mut R) -> io::Result> { let mut bytes = Vec::with_capacity(IRC_LINE_BYTES); loop { let available = reader.fill_buf().await?; if available.is_empty() { return if bytes.is_empty() { Ok(None) } else { Ok(Some(String::from_utf8_lossy(&bytes).into_owned())) }; } let newline = available.iter().position(|byte| *byte == b'\n'); let consumed = newline.map_or(available.len(), |index| index + 1); if bytes.len().saturating_add(consumed) > MAX_IRC_INPUT_BYTES { return Err(io::Error::new( io::ErrorKind::InvalidData, "oversized IRC line", )); } bytes.extend_from_slice(&available[..consumed]); reader.consume(consumed); if newline.is_some() { return Ok(Some(String::from_utf8_lossy(&bytes).into_owned())); } } } async fn write_irc(writer: &mut tokio::net::tcp::OwnedWriteHalf, line: &str) -> io::Result<()> { let line = truncate_utf8(&clean_message(line), IRC_LINE_BYTES - 2); writer.write_all(line.as_bytes()).await?; writer.write_all(b"\r\n").await } struct IrcMessage { prefix: Option, command: String, params: Vec, trailing: Option, } fn parse_irc_line(line: &str) -> Option { let line = line.trim_matches(['\r', '\n']); if line.is_empty() || line.as_bytes().contains(&0) { return None; } let line = if let Some(tagged) = line.strip_prefix('@') { tagged.split_once(' ')?.1.trim_start() } else { line }; let (prefix, rest) = if let Some(rest) = line.strip_prefix(':') { let (prefix, rest) = rest.split_once(' ')?; (Some(prefix.to_owned()), rest.trim_start()) } else { (None, line) }; let (middle, trailing) = if let Some((middle, trailing)) = rest.split_once(" :") { (middle, Some(trailing.to_owned())) } else { (rest, None) }; let mut words = middle.split_whitespace(); let command = words.next()?.to_ascii_uppercase(); Some(IrcMessage { prefix, command, params: words.map(str::to_owned).collect(), trailing, }) } fn run_fake(cli: &Cli, path: &Path) -> Result<(), ProgramError> { validate_channel(&cli.fake_channel)?; validate_nick(&cli.nickname)?; let master_text = cli.master_uuid.as_deref().unwrap_or(&cli.fake_master_uuid); let master = parse_uuid(master_text, 0, "master UUID is invalid")?; let own = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()) .map_err(|_| ProgramError::Client("construct fake agent UUID"))?; let config = BridgeConfig { channel: cli .channel .clone() .unwrap_or_else(|| cli.fake_channel.clone()), nickname: cli.nickname.clone(), messages_per_second: cli.messages_per_second, burst: cli.burst, }; validate_channel(&config.channel)?; let events = read_script(path)?; let mut bridge = Bridge::new(config, master, own); let mut transport = ScriptTransport::default(); let logger = ScriptLogger::default(); let mut now_ms = 0; let mut shutdown = false; for event in events { match event { ScriptEvent::Bridge(event) => bridge.handle(event, now_ms, &mut transport, &logger), ScriptEvent::Advance(milliseconds) => { now_ms = now_ms.saturating_add(milliseconds); bridge.tick(now_ms, &mut transport, &logger); } ScriptEvent::Shutdown => { shutdown = true; break; } } } bridge.tick(now_ms.saturating_add(60_000), &mut transport, &logger); let stdout = io::stdout(); let mut output = stdout.lock(); for line in logger.lines.into_inner().unwrap_or_default() { writeln!(output, "{line}").map_err(|source| io_error("writing standard output", source))?; } for call in transport.calls { writeln!(output, "{}", redact_text(&call)) .map_err(|source| io_error("writing standard output", source))?; } writeln!(output, "STATE irc-connected={} queued={} dropped={} active-transports=0 active-tasks=0 shutdown={shutdown}", bridge.irc_connected, bridge.pending.len(), bridge.dropped) .map_err(|source| io_error("writing standard output", source))?; Ok(()) } #[derive(Default)] struct ScriptLogger { lines: Mutex>, } impl BridgeLogger for ScriptLogger { fn line(&self, line: String) { lock(&self.lines).push(redact_text(&line)); } } enum ScriptEvent { Bridge(BridgeEvent), Advance(u64), Shutdown, } fn read_script(path: &Path) -> Result, ProgramError> { let file = File::open(path) .map_err(|source| io_error(format!("opening {}", path.display()), source))?; if file .metadata() .map_err(|source| io_error("reading script metadata", source))? .len() > MAX_SCRIPT_BYTES { return Err(ProgramError::InvalidScript { line: 0, reason: "script exceeds 1 MiB", }); } let mut bounded = file.take(MAX_SCRIPT_BYTES + 1); let mut bytes = Vec::new(); bounded .read_to_end(&mut bytes) .map_err(|source| io_error("reading bridge script", source))?; if bytes.len() as u64 > MAX_SCRIPT_BYTES { return Err(ProgramError::InvalidScript { line: 0, reason: "script exceeds 1 MiB", }); } let reader = BufReader::new(bytes.as_slice()); let mut events = Vec::new(); for (index, line) in reader.lines().enumerate() { let line_number = index + 1; let line = line.map_err(|source| io_error("decoding bridge script as UTF-8", source))?; if line.trim().is_empty() || line.trim_start().starts_with('#') { continue; } if events.len() == MAX_SCRIPT_EVENTS { return Err(ProgramError::InvalidScript { line: line_number, reason: "script has more than 4,096 events", }); } events.push(parse_script_line(&line, line_number)?); } Ok(events) } fn parse_script_line(line: &str, number: usize) -> Result { let fields: Vec<_> = line.split('\t').collect(); let invalid = || ProgramError::InvalidScript { line: number, reason: "expected irc-connect, irc-disconnect, irctargetnickmessage, grid-chatuuidnamenormal|otherfull|othermessage, grid-loginmessage, teleportsource-uuidsession-uuid, advancemilliseconds, or shutdown", }; match fields.as_slice() { ["irc-connect"] => Ok(ScriptEvent::Bridge(BridgeEvent::IrcConnected)), ["irc-disconnect"] => Ok(ScriptEvent::Bridge(BridgeEvent::IrcDisconnected)), ["irc", target, nick, message] if target.len() <= MAX_NAME_BYTES && nick.len() <= MAX_NAME_BYTES && message.len() <= MAX_MESSAGE_BYTES => { Ok(ScriptEvent::Bridge(BridgeEvent::IrcMessage { target: (*target).into(), nick: (*nick).into(), message: (*message).into(), })) } ["grid-chat", uuid, name, kind, audible, message] if name.len() <= MAX_NAME_BYTES && message.len() <= MAX_MESSAGE_BYTES => { Ok(ScriptEvent::Bridge(BridgeEvent::GridChat { source_id: parse_uuid(uuid, number, "grid source UUID is invalid")?, name: (*name).into(), message: (*message).into(), normal: *kind == "normal", fully_audible: *audible == "full", })) } ["grid-login", message] if message.len() <= MAX_MESSAGE_BYTES => Ok(ScriptEvent::Bridge( BridgeEvent::GridLogin((*message).into()), )), ["teleport", source, session] => Ok(ScriptEvent::Bridge(BridgeEvent::Teleport { source_id: parse_uuid(source, number, "teleport source UUID is invalid")?, session_id: parse_uuid(session, number, "teleport session UUID is invalid")?, })), ["advance", milliseconds] => Ok(ScriptEvent::Advance( milliseconds.parse().map_err(|_| invalid())?, )), ["shutdown"] => Ok(ScriptEvent::Shutdown), _ => Err(invalid()), } } fn validate_channel(channel: &str) -> Result<(), ProgramError> { if channel.starts_with('#') && channel.len() > 1 && channel.len() <= 200 && !channel .chars() .any(|ch| ch.is_control() || ch == ' ' || ch == ',') { Ok(()) } else { Err(ProgramError::Usage( "IRC channel must begin with '#' and contain no spaces, commas, or controls", )) } } fn validate_nick(nick: &str) -> Result<(), ProgramError> { if !nick.is_empty() && nick.len() <= 32 && nick.chars().all(is_nick_char) { Ok(()) } else { Err(ProgramError::Usage( "IRC nickname must be 1..32 portable IRC nickname characters", )) } } fn is_nick_char(ch: char) -> bool { ch.is_ascii_alphanumeric() || "-_[]{}\\`^|".contains(ch) } fn irc_eq(left: &str, right: &str) -> bool { left.chars().map(irc_fold).eq(right.chars().map(irc_fold)) } fn irc_fold(ch: char) -> char { match ch { 'A'..='Z' => ch.to_ascii_lowercase(), '[' | '{' => '{', ']' | '}' => '}', '\\' | '|' => '|', '^' | '~' => '~', _ => ch, } } fn map_name(name: &str) -> String { let mapped: String = name .chars() .filter(|ch| !ch.is_control()) .map(|ch| if ch == '<' || ch == '>' { '_' } else { ch }) .take(64) .collect(); let mapped = mapped.split_whitespace().collect::>().join(" "); if mapped.is_empty() { "unknown".into() } else { mapped } } fn valid_message(message: &str) -> bool { !message.trim().is_empty() && message.len() <= MAX_MESSAGE_BYTES && !message.as_bytes().contains(&0) } fn clean_message(message: &str) -> String { message .chars() .map(|ch| { if ch == '\r' || ch == '\n' || ch == '\0' { ' ' } else { ch } }) .collect::() .trim() .to_owned() } fn sanitize_irc_atom(value: &str) -> String { value .chars() .filter(|ch| !ch.is_control() && *ch != ' ' && *ch != ',' && *ch != ':') .collect() } fn truncate_utf8(value: &str, max: usize) -> String { if value.len() <= max { return value.into(); } let mut end = max; while !value.is_char_boundary(end) { end -= 1; } value[..end].into() } fn redact_text(value: &str) -> String { let lower = value.to_ascii_lowercase(); if [ "password", "passwd", "authorization", "capability", "token=", "sasl", "oauth", ] .iter() .any(|marker| lower.contains(marker)) { return "".into(); } value .split_whitespace() .map(|word| { if word.contains("://") { "" } else { word } }) .collect::>() .join(" ") } fn elapsed_ms(start: Instant) -> u64 { u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX) } fn try_event(sender: &mpsc::Sender, event: BridgeEvent, dropped: &AtomicUsize) { if sender.try_send(event).is_err() { dropped.fetch_add(1, Ordering::Relaxed); } } fn parse_uuid(value: &str, line: usize, reason: &'static str) -> Result { UUID::new_with_string(value.into()).map_err(|_| ProgramError::InvalidScript { line, reason }) } fn io_error(action: impl Into, source: io::Error) -> ProgramError { ProgramError::Io { action: action.into(), source, } } fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { mutex .lock() .unwrap_or_else(std::sync::PoisonError::into_inner) } #[cfg(test)] mod tests { use super::*; struct DropProbe(Arc); impl Drop for DropProbe { fn drop(&mut self) { self.0.store(false, Ordering::Release); } } impl BridgeTransport for DropProbe { fn join_irc(&mut self, _channel: &str) -> Result<(), &'static str> { Ok(()) } fn send_irc(&mut self, _channel: &str, _message: &str) -> Result<(), &'static str> { Ok(()) } fn send_grid(&mut self, _message: &str) -> Result<(), &'static str> { Ok(()) } fn accept_teleport(&mut self, _source: UUID, _session: UUID) -> Result<(), &'static str> { Ok(()) } } #[test] fn parses_partial_protocol_boundaries() { let ping = parse_irc_line("PING :server.example").unwrap(); assert_eq!(ping.command, "PING"); assert_eq!(ping.trailing.as_deref(), Some("server.example")); let message = parse_irc_line(":Alice!user@host PRIVMSG #room :hello UTF-8 ☃").unwrap(); assert_eq!(message.prefix.as_deref(), Some("Alice!user@host")); assert_eq!(message.params, ["#room"]); assert_eq!(message.trailing.as_deref(), Some("hello UTF-8 ☃")); let tagged = parse_irc_line("@time=now :Bob!u@h PRIVMSG #room :tagged").unwrap(); assert_eq!(tagged.command, "PRIVMSG"); assert_eq!(tagged.trailing.as_deref(), Some("tagged")); assert!(parse_irc_line(":broken").is_none()); } #[tokio::test] async fn line_reader_handles_fragments_and_rejects_unbounded_input() { let (mut writer, reader) = tokio::io::duplex(MAX_IRC_INPUT_BYTES * 2); let sender = tokio::spawn(async move { writer.write_all(b":nick PRIV").await.unwrap(); writer.write_all(b"MSG #room :hello\r\n").await.unwrap(); }); let mut reader = AsyncBufReader::new(reader); assert_eq!( read_irc_line(&mut reader).await.unwrap().as_deref(), Some(":nick PRIVMSG #room :hello\r\n") ); sender.await.unwrap(); let bytes = vec![b'x'; MAX_IRC_INPUT_BYTES + 1]; let mut reader = AsyncBufReader::new(bytes.as_slice()); assert_eq!( read_irc_line(&mut reader).await.unwrap_err().kind(), io::ErrorKind::InvalidData ); } #[test] fn utf8_truncation_never_splits_a_scalar() { assert_eq!(truncate_utf8("ab☃cd", 4), "ab"); assert_eq!(truncate_utf8("ab☃cd", 5), "ab☃"); } #[test] fn duplicate_and_loop_detection_are_directional() { let master = UUID::new_with_string("11111111-2222-3333-4444-555555555555".into()).unwrap(); let own = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()).unwrap(); let other = UUID::new_with_string("bbbbbbbb-cccc-dddd-eeee-ffffffffffff".into()).unwrap(); let mut bridge = Bridge::new( BridgeConfig { channel: "#room".into(), nickname: "SLGateway".into(), messages_per_second: 10, burst: 10, }, master, own, ); let mut transport = ScriptTransport::default(); let logger = ScriptLogger::default(); bridge.handle(BridgeEvent::IrcConnected, 0, &mut transport, &logger); let event = BridgeEvent::GridChat { source_id: other, name: "Alice".into(), message: "hello".into(), normal: true, fully_audible: true, }; bridge.handle(event.clone(), 1, &mut transport, &logger); bridge.handle(event, 2, &mut transport, &logger); bridge.handle( BridgeEvent::IrcMessage { target: "#room".into(), nick: "Relay".into(), message: " hello".into(), }, 3, &mut transport, &logger, ); assert_eq!( transport .calls .iter() .filter(|call| call.contains("irc-privmsg")) .count(), 1 ); assert!( !transport .calls .iter() .any(|call| call.contains("grid-chat")) ); } #[test] fn rate_limit_queues_and_drains() { let master = UUID::new_with_string("11111111-2222-3333-4444-555555555555".into()).unwrap(); let own = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()).unwrap(); let mut bridge = Bridge::new( BridgeConfig { channel: "#room".into(), nickname: "SLGateway".into(), messages_per_second: 1, burst: 1, }, master, own, ); let mut transport = ScriptTransport::default(); let logger = ScriptLogger::default(); bridge.handle(BridgeEvent::IrcConnected, 0, &mut transport, &logger); bridge.handle( BridgeEvent::GridLogin("one".into()), 0, &mut transport, &logger, ); bridge.handle( BridgeEvent::GridLogin("two".into()), 0, &mut transport, &logger, ); assert_eq!(bridge.pending.len(), 1); bridge.tick(1_000, &mut transport, &logger); assert!(bridge.pending.is_empty()); } #[test] fn bounded_bridge_queue_drops_oldest_messages() { let master = UUID::new_with_string("11111111-2222-3333-4444-555555555555".into()).unwrap(); let own = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()).unwrap(); let mut bridge = Bridge::new( BridgeConfig { channel: "#room".into(), nickname: "SLGateway".into(), messages_per_second: 1, burst: 1, }, master, own, ); for index in 0..=BRIDGE_QUEUE_CAPACITY { bridge.enqueue(Direction::ToIrc, format!("message-{index}")); } assert_eq!(bridge.pending.len(), BRIDGE_QUEUE_CAPACITY); assert_eq!(bridge.dropped, 1); assert_eq!(bridge.pending.front().unwrap().message, "message-1"); } #[test] fn irc_channel_comparison_uses_rfc1459_case_mapping() { assert!(irc_eq("#Rust[Bridge]", "#rUST{bRIDGE}")); assert!(irc_eq("Nick\\Name", "nICK|nAME")); assert!(!irc_eq("#one", "#two")); } #[tokio::test] async fn cancellation_joins_workers_and_drops_transport() { let master = UUID::new_with_string("11111111-2222-3333-4444-555555555555".into()).unwrap(); let own = UUID::new_with_string("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee".into()).unwrap(); let config = BridgeConfig { channel: "#room".into(), nickname: "SLGateway".into(), messages_per_second: 1, burst: 1, }; let alive = Arc::new(AtomicBool::new(true)); let bridge_cancel = CancellationTokenSource::new(); let (event_tx, event_rx) = mpsc::channel(1); let bridge = tokio::spawn(bridge_loop( Bridge::new(config, master, own), DropProbe(Arc::clone(&alive)), event_rx, Arc::new(ScriptLogger::default()), bridge_cancel.token(), )); bridge_cancel.cancel(); drop(event_tx); tokio::time::timeout(Duration::from_secs(1), bridge) .await .expect("bridge stopped before timeout") .expect("bridge worker did not panic"); assert!(!alive.load(Ordering::Acquire)); let irc_cancel = CancellationTokenSource::new(); let (command_tx, command_rx) = mpsc::channel(1); let (events, _event_rx) = mpsc::channel(1); let irc = tokio::spawn(irc_loop(IrcRuntime { host: "127.0.0.1".into(), port: 0, nick: "SLGateway".into(), real_name: "test".into(), commands: command_rx, events, dropped: Arc::new(AtomicUsize::new(0)), cancellation: irc_cancel.token(), })); irc_cancel.cancel(); drop(command_tx); tokio::time::timeout(Duration::from_secs(1), irc) .await .expect("IRC worker stopped before timeout") .expect("IRC worker did not panic"); } }