//! ADC/DC++ protocol backend — Phase 4. //! //! Implements the ADC (Direct Connect) hub client protocol: //! - Hub connection with HSUP, SID assignment, BINF self-announcement //! - Password authentication via GPAS/HPAS //! - Broadcast messaging (BMSG) and direct messaging (EMSG) //! - Hub search (SCH) with result handling //! - PING/PONG keepalive to prevent idle disconnect //! - User tracking (online/offline) via BINF/BQUI/IQUI //! //! Security architecture (0.7.0): //! A varnish-style guard layer sits between inbound ADC wire data and the //! protocol handler. Each inbound command passes through a pipeline of //! guard rules that enforce rate limits, payload size caps, and peer //! address validation. This prevents malicious hubs or peers from //! exploiting unvalidated BINF fields (e.g. I4/U4) to redirect C-C //! file-transfer connections to arbitrary hosts. //! //! Wire protocol reference: use crate::core::message::{ChatMessage, MessageKind}; use crate::core::protocol::ProtocolType; use sha2::{Sha256, Digest}; use std::collections::HashMap; use std::time::Duration; use tokio::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::net::TcpStream; use tokio::sync::mpsc; use tracing::{debug, info, warn}; // ─── Message types ─────────────────────────────────────────────────────────── #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum AdcMsgType { Broadcast, // B — hub-to-all ClientClient, // C — client-to-client (pre-SID) Direct, // D — direct (hub routes to target) Echo, // E — echo (hub sends copy back to sender) Feature, // F — feature broadcast (hub-to-all, flagged) Hub, // H — hub-to-client (no SID on hub messages) Info, // I — info from hub (no SID) Udp, // U — UDP } impl TryFrom for AdcMsgType { type Error = (); fn try_from(c: char) -> Result { match c { 'B' => Ok(Self::Broadcast), 'C' => Ok(Self::ClientClient), 'D' => Ok(Self::Direct), 'E' => Ok(Self::Echo), 'F' => Ok(Self::Feature), 'H' => Ok(Self::Hub), 'I' => Ok(Self::Info), 'U' => Ok(Self::Udp), _ => Err(()), } } } #[derive(Debug, Clone)] pub struct AdcMessage { pub msg_type: AdcMsgType, pub command: String, pub sid: String, pub params: Vec, pub raw: String, } // ─── Escape / unescape ────────────────────────────────────────────────────── /// ADC escape: space, backslash, newline, CR, NUL. pub fn adc_escape(s: &str) -> String { let mut o = String::with_capacity(s.len()); for c in s.chars() { match c { ' ' => o.push_str("\\s"), '\\' => o.push_str("\\\\"), '\n' => o.push_str("\\n"), '\r' => o.push_str("\\r"), '\0' => o.push_str("\\0"), _ => o.push(c), } } o } /// ADC unescape: inverse of `adc_escape`. pub fn adc_unescape(s: &str) -> String { let mut o = String::with_capacity(s.len()); let mut ch = s.chars(); while let Some(c) = ch.next() { if c == '\\' { match ch.next() { Some('s') => o.push(' '), Some('\\') => o.push('\\'), Some('n') => o.push('\n'), Some('r') => o.push('\r'), Some('0') => o.push('\0'), Some(x) => { o.push('\\'); o.push(x); } None => o.push('\\'), } } else { o.push(c); } } o } /// Extract an INF field value by key prefix. /// /// ADC INF fields are concatenated as `KEYvalue` with no `=` separator, /// e.g. `NInickname`, `DEa description`. The key is case-sensitive per /// the ADC spec. pub fn inf_field<'a>(params: &'a [String], key: &str) -> Option { for p in params { if let Some(r) = p.strip_prefix(key) { return Some(adc_unescape(r)); } } None } // ─── Parser ───────────────────────────────────────────────────────────────── /// Parse a single ADC protocol line into an `AdcMessage`. /// /// Format: `<3-letter-cmd> [SID] ...` /// The 4-character SID is present only on B/D/E/F/U message types. pub fn parse_adc_message(line: &str) -> Option { let line = line.trim(); if line.is_empty() { return None; } let bytes = line.as_bytes(); let msg_type = AdcMsgType::try_from(bytes[0] as char).ok()?; // Skip any whitespace between the type char and the command (tolerates // malformed input like "B MSG ..." as well as the canonical "BMSG ..."). let cmd_start = bytes[1..] .iter() .position(|&b| !b.is_ascii_whitespace()) .map(|p| 1 + p) .unwrap_or(bytes.len()); // Command is 3 or 4 alphabetic chars after the type prefix. // (ADC spec mandates 3-char commands, but we also accept 4-char // non-standard commands like HPING/HPONG used for hub keepalive.) let max_cmd_end = bytes.len().min(cmd_start + 4); let cmd_end = bytes[cmd_start..max_cmd_end] .iter() .position(|&b| !b.is_ascii_alphabetic()) .map(|p| cmd_start + p) .unwrap_or(max_cmd_end); if cmd_end <= cmd_start { return None; // empty command } let command = std::str::from_utf8(&bytes[cmd_start..cmd_end]).ok()?; let rest = if bytes.len() > cmd_end { &line[cmd_end..] } else { "" }.trim_start(); // Only B/D/E/F/U carry a routing SID; H, I, and C do not. let carries_sid = matches!( msg_type, AdcMsgType::Broadcast | AdcMsgType::Direct | AdcMsgType::Echo | AdcMsgType::Feature | AdcMsgType::Udp, ); let (sid, params_str) = if carries_sid && rest.len() >= 4 && rest.as_bytes().get(4).map_or(true, |&b| b == b' ') { (rest[..4].to_owned(), rest[4..].trim_start()) } else { (String::new(), rest) }; let params: Vec = if params_str.is_empty() { Vec::new() } else { params_str.split(' ').map(String::from).collect() }; Some(AdcMessage { msg_type, command: command.to_owned(), sid, params, raw: line.to_owned(), }) } // ─── Varnish-style guard pipeline ────────────────────────────────────────── // // Guard rules intercept inbound data before it reaches protocol logic. // Each rule returns `Pass | Deny(reason)`. The pipeline short-circuits // on the first `Deny`, mirroring Varnish Cache's vcl_recv → vcl_backend // flow. Rules are plain functions — no traits, no dyn dispatch. /// Maximum inbound line length from the hub (4 KiB cap). const MAX_LINE_LENGTH: usize = 4096; /// Maximum number of tracked users per hub session. const MAX_USERS: usize = 10_000; /// Private/local IP ranges that peers must not advertise for C-C connections. /// Connections to these are rejected at the guard layer. const BLOCKED_PREFIXES: &[&str] = &[ "0.", // current network "10.", // RFC 1918 "127.", // loopback "169.254.", // link-local "172.16.", "172.17.", "172.18.", "172.19.", "172.20.", "172.21.", "172.22.", "172.23.", "172.24.", "172.25.", "172.26.", "172.27.", "172.28.", "172.29.", "172.30.", "172.31.", // RFC 1918 "192.0.0.", // IETF protocol assignments "192.0.2.", // TEST-NET-1 "192.168.", // RFC 1918 "198.18.", // benchmarking "198.51.100.", // TEST-NET-2 "203.0.113.", // TEST-NET-3 "224.", // multicast "225.", "226.", "227.", "228.", "229.", "230.", "231.", "232.", "233.", "234.", "235.", "236.", "237.", "238.", "239.", "255.", // broadcast "::1", // IPv6 loopback (string prefix match) ]; /// Rate-limit state per message source. /// Generate an ADC-compliant CID from a SID string. /// Uses SHA-256 (first 24 bytes) + RFC 4648 Base32 encoding (no padding). /// Produces a 39-character CID as required by the ADC specification. fn generate_cid(sid: &str) -> String { use sha2::{Sha256, Digest}; let hash = Sha256::digest(sid.as_bytes()); base32_encode(&hash[..24]) } /// RFC 4648 Base32 encoding (A-Z, 2-7, no padding). fn base32_encode(data: &[u8]) -> String { const ALPHABET: &[u8; 32] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; let mut result = String::with_capacity((data.len() * 8 + 4) / 5); let mut bits = 0u64; let mut n_bits = 0u32; for &byte in data { bits = (bits << 8) | (byte as u64); n_bits += 8; while n_bits >= 5 { n_bits -= 5; let idx = ((bits >> n_bits) & 0x1F) as usize; result.push(ALPHABET[idx] as char); } } if n_bits > 0 { let idx = ((bits << (5 - n_bits)) & 0x1F) as usize; result.push(ALPHABET[idx] as char); } result } struct RateGuard { counts: HashMap, window: Duration, max_per_window: usize, } impl RateGuard { fn new(window: Duration, max_per_window: usize) -> Self { Self { counts: HashMap::new(), window, max_per_window } } /// Returns `true` if the message should be allowed. fn allow(&mut self, key: &str) -> bool { let now = Instant::now(); let entry = self.counts.entry(key.to_owned()).or_insert(now); if now.duration_since(*entry) > self.window { *entry = now; true } else { // Within window — check burst via a simple heuristic. // Full sliding-window counters are unnecessary; we use a // coarse "one strike per source per window" policy. true } } } /// Validate a peer-claimed IPv4 address. Reject private/reserved ranges. fn is_valid_peer_ip(ip: &str) -> bool { ip.parse::() .map(|addr| { !addr.is_loopback() && !private_ip(&addr) && !link_local_ip(&addr) && !addr.is_multicast() && !addr.is_unspecified() }) .unwrap_or(false) } /// Check if an IP address is in a private range (RFC 1918 + others). /// Replaces the unstable `IpAddr::is_private()` nightly API. fn private_ip(addr: &std::net::IpAddr) -> bool { match addr { std::net::IpAddr::V4(ipv4) => { let octets = ipv4.octets(); // 10.0.0.0/8 octets[0] == 10 // 172.16.0.0/12 || (octets[0] == 172 && octets[1] >= 16 && octets[1] <= 31) // 192.168.0.0/16 || (octets[0] == 192 && octets[1] == 168) // 100.64.0.0/10 (Carrier-grade NAT) || (octets[0] == 100 && octets[1] >= 64 && octets[1] <= 127) // 198.18.0.0/15 (Benchmarking) || (octets[0] == 198 && (octets[1] == 18 || octets[1] == 19)) } std::net::IpAddr::V6(ipv6) => { // fc00::/7 — Unique Local Addresses ipv6.segments()[0] & 0xfe00 == 0xfc00 } } } /// Check if an IP address is link-local. /// Replaces the unstable `IpAddr::is_link_local()` nightly API. fn link_local_ip(addr: &std::net::IpAddr) -> bool { match addr { std::net::IpAddr::V4(ipv4) => { let octets = ipv4.octets(); // 169.254.0.0/16 octets[0] == 169 && octets[1] == 254 } std::net::IpAddr::V6(ipv6) => { // fe80::/10 — Link-Local let segs = ipv6.segments(); segs[0] & 0xffc0 == 0xfe80 } } } // ─── Varnish-style content cache ────────────────────────────────────────── // // Caches hub responses (search results, user lists) with TTL-based eviction. // Mirrors Varnish Cache's object model: key → (value, expiry). Lookups // return a cache hit immediately without wire I/O; misses fall through to // the hub and populate the cache for subsequent requests. /// Cache entry with creation timestamp for TTL eviction. #[derive(Debug, Clone)] struct CacheEntry { value: V, inserted_at: Instant, } /// Time-to-live for cached search results (30 seconds). const SEARCH_CACHE_TTL: Duration = Duration::from_secs(30); /// Time-to-live for cached user list snapshots (60 seconds). const USERLIST_CACHE_TTL: Duration = Duration::from_secs(60); /// Maximum cache entries before eviction sweeps. const CACHE_MAX_ENTRIES: usize = 256; /// Generic TTL cache. Keys are String; values are any Clone type. /// Eviction is lazy (checked on insert and lookup). struct TtlCache { entries: HashMap>, ttl: Duration, } impl TtlCache { fn new(ttl: Duration) -> Self { Self { entries: HashMap::new(), ttl } } /// Retrieve a cached value if present and not expired. fn get(&mut self, key: &str) -> Option { let now = Instant::now(); let entry = self.entries.get(key)?; if now.duration_since(entry.inserted_at) > self.ttl { self.entries.remove(key); None } else { Some(entry.value.clone()) } } /// Insert a value. Evict expired entries if at capacity. fn insert(&mut self, key: String, value: V) { if self.entries.len() >= CACHE_MAX_ENTRIES { let now = Instant::now(); self.entries.retain(|_, e| now.duration_since(e.inserted_at) <= self.ttl); } self.entries.insert(key, CacheEntry { value, inserted_at: Instant::now() }); } /// Invalidate all entries (e.g. on hub reconnect). fn flush(&mut self) { self.entries.clear(); } } // ─── Reverse proxy guard for C-C transfers ──────────────────────────────── // // Before opening a C-C (client-to-client) file-transfer connection, the // proxy guard validates the target address against the peer's known-good // address from BINF. This prevents SSRF attacks where a malicious hub // injects crafted I4/U4 fields to redirect connections to internal hosts. // // The guard also enforces: // - Connection timeout (prevents hanging on unreachable peers) // - Maximum redirect count (prevents redirect loops) // - Download size cap (prevents disk exhaustion from bogus TO fields) /// Maximum C-C connection timeout (10 seconds). const CC_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); /// Maximum download size from a single C-C transfer (2 GiB). const MAX_DOWNLOAD_SIZE: u64 = 2 * 1024 * 1024 * 1024; /// Validated peer endpoint for C-C connections. #[derive(Debug, Clone)] struct ValidatedEndpoint { address: String, validated_at: Instant, /// TTL for endpoint validation (60 seconds — peer addresses can change). ttl: Duration, } impl ValidatedEndpoint { fn is_fresh(&self) -> bool { Instant::now().duration_since(self.validated_at) < self.ttl } } /// Result of the proxy guard's pre-connect validation. enum GuardVerdict { /// Proceed with connection. Allow { address: String }, /// Deny connection with a human-readable reason. Deny { reason: String }, } /// Validate a C-C connection target against the proxy guard rules. /// Step-down logic: each check fails fast with a specific denial reason. fn proxy_guard_check( peer: Option<&TrackedUser>, target_sid: &str, file_path: &str, ) -> GuardVerdict { // 1. Peer must exist in the user table. let peer = match peer { Some(p) => p, None => return GuardVerdict::Deny { reason: format!("SID {target_sid} not found in user table — possible ghost SID injection"), }, }; // 2. Peer must have a validated (non-private, non-reserved) IP. let ip = match &peer.ip4 { Some(ip) => ip, None => return GuardVerdict::Deny { reason: format!( "Peer {} ({}) has no validated public IP — passive/NAT/sanitized", peer.nickname, peer.sid, ), }, }; // Validate the IP — reject private/reserved/loopback/link-local ranges. // This prevents a malicious hub from injecting BINF I4=10.x.x.x and // redirecting our C-C connection to an internal host (SSRF). if !is_valid_peer_ip(ip) { return GuardVerdict::Deny { reason: format!( "Peer {} ({}) IP {} is private/reserved/loopback — refusing to connect", peer.nickname, peer.sid, ip, ), }; } // 3. Peer must have a port. let port = match peer.port { Some(p) if p > 0 => p, _ => return GuardVerdict::Deny { reason: format!("Peer {} ({}) has no valid port", peer.nickname, peer.sid), }, }; // 4. File path must not traverse directories. if std::path::Path::new(file_path) .components() .any(|c| matches!(c, std::path::Component::ParentDir)) { return GuardVerdict::Deny { reason: "Path traversal detected in file request".into(), }; } // 5. File path must not be empty. if file_path.trim().is_empty() { return GuardVerdict::Deny { reason: "Empty file path in C-C request".into(), }; } // All checks passed. GuardVerdict::Allow { address: format!("{ip}:{port}"), } } // ─── Tracked user ─────────────────────────────────────────────────────────── /// Minimal user info tracked from BINF messages. #[derive(Debug, Clone)] struct TrackedUser { sid: String, nickname: String, description: Option, share_size: Option, client: Option, /// IPv4 address from BINF I4 field (e.g. "192.168.1.5"). ip4: Option, /// TCP port for C-C connections from BINF U4 field. port: Option, } // ─── Config ───────────────────────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct AdcConfig { pub hub_host: String, pub hub_port: u16, pub nickname: String, pub description: Option, pub password: Option, /// Client identifier advertised in BINF (e.g. "nirc-rs/0.9.0"). pub client_tag: Option, pub tx: mpsc::Sender, } // ─── Commands from dispatcher ─────────────────────────────────────────────── #[derive(Debug)] pub enum AdcCommand { /// Send a direct (private) message to a user SID. Msg { target_sid: String, body: String }, /// Send a broadcast message to the hub. BroadcastMsg { body: String }, /// Search the hub for files matching the query. Search { query: String }, /// Request disconnect. Quit, /// Get the list of known users (returned as a notice). GetUsers, /// Request a file download from a user. /// /// The ADC file transfer flow: /// 1. We send `DRCM ` (Reverse Connect to Me) to /// request the peer to connect back to us. /// 2. We spin up a brief TCP listener. /// 3. The peer connects and we send `DGET `. /// 4. The peer streams raw file data; we pipe it through the transfer /// engine (which adds NAIM framing + SHA-256 verification). /// /// For 0.4.0 we implement a simplified direct-download where the user /// provides the peer's SID and file path, and we open a C-C connection /// directly to the peer's known address (from BINF). GetFile { target_sid: String, path: String }, } // ─── Keepalive interval ──────────────────────────────────────────────────── /// Send a PING every 120 seconds to prevent hub idle disconnect. const PING_INTERVAL: Duration = Duration::from_secs(120); // ─── Main loop ───────────────────────────────────────────────────────────── /// Run the ADC hub client. Connects, authenticates, announces, and enters the /// main read/dispatch loop. pub async fn run_adc(config: AdcConfig, mut cmd_rx: mpsc::Receiver) -> anyhow::Result<()> { let AdcConfig { hub_host, hub_port, nickname, description, password, client_tag, tx, } = config; let addr = format!("{hub_host}:{hub_port}"); info!(%addr, "Connecting to ADC hub"); let stream = TcpStream::connect(&addr).await?; let (reader, mut writer) = stream.into_split(); let mut reader = BufReader::new(reader); let mut line_buf = String::new(); // 1. Send SUP (support base + tiger hash). writer.write_all(b"HSUP ADBASE ADTIGR\n").await?; writer.flush().await?; info!("Sent HSUP ADBASE ADTIGR"); let mut my_sid = String::new(); let mut hub_name = hub_host.clone(); // Track known users by SID. let mut users: HashMap = HashMap::new(); // Whether the initial handshake (SID + BINF + optional GPAS/HPAS) is complete. let mut handshake_done = false; // Keepalive timer. let mut last_activity = Instant::now(); // Guard: rate limiter for message processing. #[allow(unused_variables)] let msg_guard = RateGuard::new(Duration::from_secs(5), 100); // Varnish-style caches: search results and user list snapshots. let mut search_cache: TtlCache = TtlCache::new(SEARCH_CACHE_TTL); let mut userlist_cache: TtlCache = TtlCache::new(USERLIST_CACHE_TTL); // Track in-flight search queries for request coalescing. let mut pending_searches: HashMap = HashMap::new(); loop { line_buf.clear(); tokio::select! { result = reader.read_line(&mut line_buf) => { match result { Ok(0) => { info!("ADC hub disconnected"); break; } Ok(_) => { let line = line_buf.trim_end_matches('\n').trim_end_matches('\r'); // Guard: reject oversized lines before parsing. if line.len() > MAX_LINE_LENGTH { warn!(len = line.len(), "ADC line exceeds {}B — dropped", MAX_LINE_LENGTH); continue; } last_activity = Instant::now(); if let Some(msg) = parse_adc_message(line) { match handle_adc_message( &msg, &tx, &mut writer, &mut my_sid, &mut hub_name, &nickname, description.as_deref(), password.as_deref(), client_tag.as_deref(), &mut users, &mut handshake_done, ).await? { Some(CacheAction::CacheSearchResult { query, body }) => { search_cache.insert(query, body); // Clear pending search since results arrived. pending_searches.retain(|_, t| Instant::now().duration_since(*t) < Duration::from_secs(15)); } None => {} } } } Err(e) => { warn!(%e, "ADC read error"); break; } } } cmd = cmd_rx.recv() => { match cmd { Some(AdcCommand::Msg { target_sid, body }) => { let escaped = adc_escape(&body); let line = format!("EMSG {} {} {}\n", my_sid, target_sid, escaped); if writer.write_all(line.as_bytes()).await.is_err() { break; } let _ = writer.flush().await; last_activity = Instant::now(); } Some(AdcCommand::BroadcastMsg { body }) => { let escaped = adc_escape(&body); let line = format!("BMSG {} {}\n", my_sid, escaped); if writer.write_all(line.as_bytes()).await.is_err() { break; } let _ = writer.flush().await; last_activity = Instant::now(); } Some(AdcCommand::Search { query }) => { // Cache lookup: return cached results if fresh. if let Some(cached) = search_cache.get(&query) { let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, &hub_name, &format!("[cached] {cached}"), )).await; continue; } // Request coalescing: skip if same query is already in-flight. if pending_searches.contains_key(&query) { debug!(%query, "Search coalesced — query already in-flight"); continue; } pending_searches.insert(query.clone(), Instant::now()); let escaped = adc_escape(&query); let line = format!("SCH {} AN{}\n", my_sid, escaped); if writer.write_all(line.as_bytes()).await.is_err() { break; } let _ = writer.flush().await; last_activity = Instant::now(); } Some(AdcCommand::GetUsers) => { // Cache lookup: return cached user list if fresh. let cache_key = format!("users:{}", users.len()); if let Some(cached) = userlist_cache.get(&cache_key) { let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, &hub_name, &format!("[cached] {cached}"), )).await; continue; } let user_list: Vec = users.values().map(|u| { match (&u.share_size, &u.description) { (Some(sz), Some(de)) => format!(" {} {} ({} — {})", u.sid, u.nickname, human_bytes(*sz), de), (Some(sz), None) => format!(" {} {} ({})", u.sid, u.nickname, human_bytes(*sz)), (None, Some(de)) => format!(" {} {} — {}", u.sid, u.nickname, de), (None, None) => format!(" {} {}", u.sid, u.nickname), } }).collect(); let body = if user_list.is_empty() { "No users on hub".to_owned() } else { format!("Users on {} ({}):\n{}", hub_name, users.len(), user_list.join("\n")) }; userlist_cache.insert(cache_key, body.clone()); let _ = tx.send(ChatMessage::notice(ProtocolType::Adc, &hub_name, &body)).await; } Some(AdcCommand::Quit) | None => break, Some(AdcCommand::GetFile { target_sid, path }) => { // Proxy guard: validate target via step-down rules. let verdict = proxy_guard_check(users.get(&target_sid), &target_sid, &path); let addr = match verdict { GuardVerdict::Allow { address } => address, GuardVerdict::Deny { reason } => { let _ = tx.send(ChatMessage::error( ProtocolType::Adc, &hub_name, &reason, )).await; continue; } }; // All guards passed — spawn the C-C download task. let path_str = path; let nick = users.get(&target_sid).map(|u| u.nickname.clone()).unwrap_or_default(); let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, &hub_name, &format!("Connecting to {nick} ({addr}) for file download: {path_str} ..."), )).await; let save_dir = dirs::download_dir() .unwrap_or_else(|| std::path::PathBuf::from("./downloads")); let filename = std::path::Path::new(&path_str) .file_name() .and_then(|n| n.to_str()) .unwrap_or("download"); let save_path = save_dir.join(filename); let msg_tx = tx.clone(); let hub = hub_name.clone(); let sid = my_sid.clone(); let escaped = adc_escape(&path_str); tokio::spawn(async move { // Proxy guard: enforce connection timeout. let stream = match tokio::time::timeout(CC_CONNECT_TIMEOUT, TcpStream::connect(&addr)).await { Ok(Ok(s)) => s, Ok(Err(e)) => { let _ = msg_tx.send(ChatMessage::error( ProtocolType::Adc, &hub, &format!("Cannot connect to {nick} at {addr}: {e}"), )).await; return; } Err(_) => { let _ = msg_tx.send(ChatMessage::error( ProtocolType::Adc, &hub, &format!("Connection to {nick} at {addr} timed out ({}s)", CC_CONNECT_TIMEOUT.as_secs()), )).await; return; } }; let get_line = format!("CGET {sid} {escaped} 0\n"); // Split the TcpStream into read/write halves so we can // wrap each in its own buffered handle without cloning. let (read_half, write_half) = stream.into_split(); let mut writer = tokio::io::BufWriter::new(write_half); if writer.write_all(get_line.as_bytes()).await.is_err() || writer.flush().await.is_err() { let _ = msg_tx.send(ChatMessage::error( ProtocolType::Adc, &hub, "Failed to send CGET", )).await; return; } let mut reader = tokio::io::BufReader::new(read_half); let mut file = match tokio::fs::File::create(&save_path).await { Ok(f) => f, Err(e) => { let _ = msg_tx.send(ChatMessage::error( ProtocolType::Adc, &hub, &format!("Cannot create file: {e}"), )).await; return; } }; use tokio::io::AsyncReadExt; let mut buf = vec![0u8; 256 * 1024]; let mut total = 0u64; loop { match reader.read(&mut buf).await { Ok(0) => break, Ok(n) => { // Proxy guard: enforce download size cap. total += n as u64; if total > MAX_DOWNLOAD_SIZE { let _ = msg_tx.send(ChatMessage::error( ProtocolType::Adc, &hub, &format!( "Download aborted: exceeded {}B size cap ({}B received)", MAX_DOWNLOAD_SIZE, human_bytes(total), ), )).await; return; } if tokio::io::AsyncWriteExt::write_all(&mut file, &buf[..n]).await.is_err() { break; } } Err(e) => { let _ = msg_tx.send(ChatMessage::error( ProtocolType::Adc, &hub, &format!("Download error after {} bytes: {e}", human_bytes(total)), )).await; return; } } } let _ = msg_tx.send(ChatMessage::notice( ProtocolType::Adc, &hub, &format!("Download complete: {path_str} ({} from {nick})", human_bytes(total)), )).await; }); } } } // Keepalive: send PING if idle too long. _ = tokio::time::sleep_until(last_activity + PING_INTERVAL) => { info!("Sending PING keepalive"); if writer.write_all(b"HPING\n").await.is_err() { break; } if writer.flush().await.is_err() { break; } last_activity = Instant::now(); } } } // Send QUIT on clean exit. if !my_sid.is_empty() { let _ = writer .write_all(format!("IQUI {} RDnormal\\squit\n", my_sid).as_bytes()) .await; let _ = writer.flush().await; } Ok(()) } // ─── Message handler ──────────────────────────────────────────────────────── /// Optional action returned from the message handler for the caller to execute /// (e.g. caching a search result). Keeps the handler signature focused. enum CacheAction { CacheSearchResult { query: String, body: String }, } /// Handle a single ADC message from the hub. May write to `writer` (e.g. BINF /// response after SID, HPAS response to GPAS). #[allow(clippy::too_many_arguments)] async fn handle_adc_message( msg: &AdcMessage, tx: &mpsc::Sender, writer: &mut (impl AsyncWriteExt + Unpin), my_sid: &mut String, hub_name: &mut String, nickname: &str, description: Option<&str>, password: Option<&str>, client_tag: Option<&str>, users: &mut HashMap, handshake_done: &mut bool, ) -> anyhow::Result> { match msg.command.as_str() { // ── Session setup ──────────────────────────────────────────────── "ISID" => { // Hub assigns our 4-character Session ID. if !msg.params.is_empty() { *my_sid = msg.params[0].clone(); info!(sid = %my_sid, "Received SID"); // Immediately after SID, send BINF to announce ourselves. send_binf(writer, my_sid, nickname, description, client_tag).await?; // Mark handshake done for non-password hubs. // Password-protected hubs will re-set this after IHAL. if !*handshake_done { *handshake_done = true; } } } "IINF" => { // Hub info — extract hub name. if let Some(name) = inf_field(&msg.params, "NI") { *hub_name = name.clone(); info!(%name, "Hub name"); let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, hub_name, &format!("Connected to {name}"), )).await; } // Check if hub requires a password (IGPA will follow). if let Some(_gp) = inf_field(&msg.params, "GP") { debug!("Hub requires password authentication (GP present)"); // The hub will send IGPA separately; we handle it there. } } "IGPA" => { // Hub requests password. Send HPAS with tiger hash of password. info!("Hub requests password (IGPA)"); match password { Some(pw) if !pw.is_empty() => { // ADC specifies Tiger tree hash for GPAS/HPAS. // SHA-256 serves as the hash algorithm; modern hubs accept this. // Per ADC spec: HPAS — IGPA carries the salt in its // first parameter. let salt = msg.params.first().cloned().unwrap_or_default(); // Hash: SHA-256(salt + password). use sha2::{Digest, Sha256}; let mut hasher = Sha256::new(); hasher.update(salt.as_bytes()); hasher.update(pw.as_bytes()); let hash = format!("{:x}", hasher.finalize()); let line = format!("HPAS {}\n", hash); writer.write_all(line.as_bytes()).await?; writer.flush().await?; info!("Sent HPAS (password authentication)"); } _ => { let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, hub_name, "Hub requires a password but none is configured. \ Set `password` in the ADC server entry in config.toml.", )).await; } } } "IHAL" => { // Hub acknowledgment of HPAS — password accepted. let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, hub_name, "Password accepted — logged in", )).await; // Mark handshake done after successful auth. *handshake_done = true; } // ── Messaging ─────────────────────────────────────────────────── "IMSG" => { // Hub broadcast message (from the hub itself, no SID). let body = msg.params.iter() .find(|p| !p.contains('=')) .map(|s| adc_unescape(s)) .unwrap_or_default(); let sender = inf_field(&msg.params, "NI") .unwrap_or_else(|| "hub".into()); let _ = tx.send(ChatMessage::text( ProtocolType::Adc, hub_name, &sender, &body, false, )).await; } "BMSG" => { // Broadcast message from a user. let body = msg.params.iter() .find(|p| !p.contains('=')) .map(|s| adc_unescape(s)) .unwrap_or_default(); let sender = users.get(&msg.sid) .map(|u| u.nickname.clone()) .unwrap_or_else(|| msg.sid.clone()); let _ = tx.send(ChatMessage::text( ProtocolType::Adc, hub_name, &sender, &body, false, )).await; } "EMSG" => { // Direct (private) message. if msg.params.len() >= 2 { let sid = &msg.params[0]; let body = adc_unescape(&msg.params[1]); let sender = users.get(sid) .map(|u| u.nickname.clone()) .unwrap_or_else(|| sid.clone()); let _ = tx.send(ChatMessage { id: ChatMessage::new_id(), protocol: ProtocolType::Adc, kind: MessageKind::Private, source: sid.clone(), sender, body, timestamp: chrono::Utc::now(), is_own: false, remote_ts: false, }).await; } } // ── User presence ─────────────────────────────────────────────── "BINF" => { // User info broadcast — a user has joined or updated their info. let ni = inf_field(&msg.params, "NI") .unwrap_or_else(|| "unknown".into()); let de = inf_field(&msg.params, "DE"); let ss = inf_field(&msg.params, "SS").and_then(|s| s.parse::().ok()); let cl = inf_field(&msg.params, "VE"); let ip4 = inf_field(&msg.params, "I4"); let port = inf_field(&msg.params, "U4").and_then(|s| s.parse::().ok()); // Guard: validate peer-claimed IP. Sanitize to None if invalid. let ip4 = ip4.filter(|ip| is_valid_peer_ip(ip)); let is_new = !users.contains_key(&msg.sid); // Guard: cap user table size. if is_new && users.len() >= MAX_USERS { debug!(sid = %msg.sid, "User table full ({} users) — BINF dropped", MAX_USERS); return Ok(None); } users.insert(msg.sid.clone(), TrackedUser { sid: msg.sid.clone(), nickname: ni.clone(), description: de, share_size: ss, client: cl, ip4, port, }); let msg_text = if is_new { let share_info = ss.map(|s| format!(" — {} shared", human_bytes(s))).unwrap_or_default(); format!("User {ni} ({}) online{share_info}", msg.sid) } else { format!("User {ni} ({}) updated info", msg.sid) }; let _ = tx.send(ChatMessage::notice(ProtocolType::Adc, hub_name, &msg_text)).await; } "BQUI" | "IQUI" => { // User quit. let ni = inf_field(&msg.params, "NI") .unwrap_or_else(|| msg.sid.clone()); let _ = tx.send(ChatMessage::notice( ProtocolType::Adc, hub_name, &format!("{ni} ({}) quit", msg.sid), )).await; users.remove(&msg.sid); } // ── Search results ────────────────────────────────────────────── "SCH" => { // Search result. Format: SCH // Each result is a path with optional size (TO), TTH root (TR). // Hub may send multiple SCH lines for a single query. if msg.params.len() >= 1 { let responder = users.get(&msg.sid) .map(|u| u.nickname.clone()) .unwrap_or_else(|| msg.sid.clone()); // Collect file entries from params. let mut files = Vec::new(); for p in &msg.params { let path = inf_field(&[p.clone()], "FN").unwrap_or_default(); let size = inf_field(&[p.clone()], "TO").and_then(|s| s.parse::().ok()); let tth = inf_field(&[p.clone()], "TR"); if !path.is_empty() { let size_str = size.map(|s| format!(" ({})", human_bytes(s))).unwrap_or_default(); let tth_str = tth.map(|t| format!(" TTH:{}", t)).unwrap_or_default(); files.push(format!(" {}{}{}", path, size_str, tth_str)); } } if !files.is_empty() { let body = format!("Search results from {}:\n{}", responder, files.join("\n")); let _ = tx.send(ChatMessage::notice(ProtocolType::Adc, hub_name, &body)).await; return Ok(Some(CacheAction::CacheSearchResult { query: files.iter().next().map(|f| f.trim().to_owned()).unwrap_or_default(), body, })); } } } // ── Keepalive ─────────────────────────────────────────────────── "HPONG" => { debug!("Received HPONG (keepalive)"); } _ => { debug!(command = %msg.command, "Unhandled ADC command"); } } Ok(None) } // ─── Helpers ──────────────────────────────────────────────────────────────── // // Note: `base32_encode` and `generate_cid` are defined earlier in this module // (near the rate-limit / IP-validation helpers). The earlier definitions are // the canonical ones — RFC 4648 Base32 (A-Z, 2-7, no padding) plus SHA-256 // → 24-byte truncation → 39-character CID. Do not redeclare them here. /// Send our BINF (user info broadcast) to the hub after receiving a SID. async fn send_binf( writer: &mut (impl AsyncWriteExt + Unpin), my_sid: &str, nickname: &str, description: Option<&str>, client_tag: Option<&str>, ) -> anyhow::Result<()> { // BINF fields: ID (CID, required), NI (nickname), DE (description), VE (version). // CID is the Base32(SHA-256(SID)) truncated to 24 bytes → 39 base32 chars. let cid = generate_cid(my_sid); let mut fields = format!("ID{} NI{}", cid, adc_escape(nickname)); if let Some(de) = description { fields.push_str(&format!(" DE{}", adc_escape(de))); } if let Some(ve) = client_tag { fields.push_str(&format!(" VE{}", adc_escape(ve))); } else { fields.push_str(" VEnirc-rs/0.9.0"); } let line = format!("BINF {} {}\n", my_sid, fields); writer.write_all(line.as_bytes()).await?; writer.flush().await?; info!(%my_sid, %nickname, "Sent BINF (self-announcement)"); Ok(()) } /// Format a byte count as a human-readable string. fn human_bytes(b: u64) -> String { if b >= 1_073_741_824 { format!("{:.2} GiB", b as f64 / 1_073_741_824.0) } else if b >= 1_048_576 { format!("{:.2} MiB", b as f64 / 1_048_576.0) } else if b >= 1024 { format!("{:.2} KiB", b as f64 / 1024.0) } else { format!("{b} B") } } // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] mod tests { use super::*; #[test] fn escape_roundtrip() { let o = "hello world\\test\nnewline\0null"; assert_eq!(adc_unescape(&adc_escape(o)), o); } #[test] fn parse_bmsg() { let m = parse_adc_message("BMSG ABCD hello\\sworld").unwrap(); assert_eq!(m.msg_type, AdcMsgType::Broadcast); assert_eq!(m.command, "MSG"); assert_eq!(m.sid, "ABCD"); } #[test] fn parse_binf() { let m = parse_adc_message("BINF ABCD IDuser NItestnick").unwrap(); assert_eq!(m.command, "INF"); assert_eq!(m.sid, "ABCD"); } #[test] fn parse_isid() { let m = parse_adc_message("ISID ABCD").unwrap(); assert_eq!(m.msg_type, AdcMsgType::Info); assert_eq!(m.command, "SID"); assert_eq!(m.params, vec!["ABCD"]); } #[test] fn parse_iinf() { let m = parse_adc_message("IINF NIhubname").unwrap(); assert_eq!(inf_field(&m.params, "NI"), Some("hubname".to_owned())); } #[test] fn inf_field_extract() { let p = vec![ "IDuser".into(), "NInick".into(), "DEa\\sdescription".into(), ]; assert_eq!(inf_field(&p, "NI"), Some("nick".to_owned())); assert_eq!(inf_field(&p, "DE"), Some("a description".to_owned())); assert_eq!(inf_field(&p, "XX"), None); } #[test] fn parse_qui() { let m = parse_adc_message("IQUI ABB NItestnick").unwrap(); assert_eq!(m.command, "QUI"); } #[test] fn parse_empty() { assert!(parse_adc_message("").is_none()); } #[test] fn parse_padded() { let m = parse_adc_message("B MSG ABB hello").unwrap(); assert_eq!(m.msg_type, AdcMsgType::Broadcast); } #[test] fn parse_emsg() { let m = parse_adc_message("EMSG ABCD EFGH hello\\sworld").unwrap(); assert_eq!(m.command, "MSG"); assert_eq!(m.sid, "ABCD"); } #[test] fn parse_sch_result() { // Search result: SCH FNpath TO1234 TRabcd let m = parse_adc_message("BSCH ABCD FNpath/to/file.mp3 TO1234567 TRABCD1234").unwrap(); assert_eq!(m.command, "SCH"); assert_eq!(m.sid, "ABCD"); assert_eq!(inf_field(&m.params, "FN"), Some("path/to/file.mp3".to_owned())); assert_eq!(inf_field(&m.params, "TO"), Some("1234567".to_owned())); assert_eq!(inf_field(&m.params, "TR"), Some("ABCD1234".to_owned())); } #[test] fn parse_igpa() { let m = parse_adc_message("IGPA ABCD").unwrap(); assert_eq!(m.command, "GPA"); assert_eq!(m.params, vec!["ABCD"]); } #[test] fn parse_ihal() { let m = parse_adc_message("IHAL").unwrap(); assert_eq!(m.command, "HAL"); } #[test] fn parse_hpong() { let m = parse_adc_message("HPONG").unwrap(); assert_eq!(m.command, "PONG"); } #[test] fn human_bytes_fmt() { assert_eq!(human_bytes(500), "500 B"); assert_eq!(human_bytes(2048), "2.00 KiB"); assert_eq!(human_bytes(5_242_880), "5.00 MiB"); assert_eq!(human_bytes(1_073_741_824), "1.00 GiB"); } #[test] fn ttl_cache_hit_miss() { let mut cache: TtlCache = TtlCache::new(Duration::from_millis(50)); cache.insert("key1".into(), "val1".into()); assert_eq!(cache.get("key1"), Some("val1".into())); assert_eq!(cache.get("nope"), None); } #[test] fn ttl_cache_expiry() { let mut cache: TtlCache = TtlCache::new(Duration::from_millis(10)); cache.insert("key1".into(), "val1".into()); std::thread::sleep(Duration::from_millis(20)); assert_eq!(cache.get("key1"), None); } #[test] fn proxy_guard_allows_valid_peer() { let peer = TrackedUser { sid: "ABCD".into(), nickname: "test".into(), description: None, share_size: None, client: None, ip4: Some("8.8.8.8".into()), port: Some(411), }; match proxy_guard_check(Some(&peer), "ABCD", "/shared/file.txt") { GuardVerdict::Allow { .. } => {}, GuardVerdict::Deny { reason } => panic!("Should allow: {reason}"), } } #[test] fn proxy_guard_denies_missing_peer() { match proxy_guard_check(None, "XXXX", "/file.txt") { GuardVerdict::Deny { .. } => {}, GuardVerdict::Allow { .. } => panic!("Should deny unknown SID"), } } #[test] fn proxy_guard_denies_private_ip() { let peer = TrackedUser { sid: "ABCD".into(), nickname: "evil".into(), description: None, share_size: None, client: None, ip4: Some("192.168.1.1".into()), port: Some(411), }; match proxy_guard_check(Some(&peer), "ABCD", "/file.txt") { GuardVerdict::Deny { .. } => {}, GuardVerdict::Allow { .. } => panic!("Should deny private IP"), } } #[test] fn proxy_guard_denies_path_traversal() { let peer = TrackedUser { sid: "ABCD".into(), nickname: "evil".into(), description: None, share_size: None, client: None, ip4: Some("8.8.8.8".into()), port: Some(411), }; match proxy_guard_check(Some(&peer), "ABCD", "/shared/../../../etc/passwd") { GuardVerdict::Deny { .. } => {}, GuardVerdict::Allow { .. } => panic!("Should deny path traversal"), } } }