nirc-rs/src/protocols/adc.rs

1315 lines
53 KiB
Rust
Executable File

//! 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: <https://adc.sourceforge.net/ADC.html>
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<char> for AdcMsgType {
type Error = ();
fn try_from(c: char) -> Result<Self, Self::Error> {
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<String>,
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<String> {
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: `<type><3-letter-cmd> [SID] <param1> <param2> ...`
/// The 4-character SID is present only on B/D/E/F/U message types.
pub fn parse_adc_message(line: &str) -> Option<AdcMessage> {
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<String> = 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<String, Instant>,
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::<std::net::IpAddr>()
.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<V> {
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<V: Clone> {
entries: HashMap<String, CacheEntry<V>>,
ttl: Duration,
}
impl<V: Clone> TtlCache<V> {
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<V> {
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<String>,
share_size: Option<u64>,
client: Option<String>,
/// IPv4 address from BINF I4 field (e.g. "192.168.1.5").
ip4: Option<String>,
/// TCP port for C-C connections from BINF U4 field.
port: Option<u16>,
}
// ─── Config ─────────────────────────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct AdcConfig {
pub hub_host: String,
pub hub_port: u16,
pub nickname: String,
pub description: Option<String>,
pub password: Option<String>,
/// Client identifier advertised in BINF (e.g. "nirc-rs/0.9.0").
pub client_tag: Option<String>,
pub tx: mpsc::Sender<ChatMessage>,
}
// ─── 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 <our_sid> <target_sid>` (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 <path> <offset>`.
/// 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<AdcCommand>) -> 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<String, TrackedUser> = 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<String> = TtlCache::new(SEARCH_CACHE_TTL);
let mut userlist_cache: TtlCache<String> = TtlCache::new(USERLIST_CACHE_TTL);
// Track in-flight search queries for request coalescing.
let mut pending_searches: HashMap<String, Instant> = 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<String> = 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<ChatMessage>,
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<String, TrackedUser>,
handshake_done: &mut bool,
) -> anyhow::Result<Option<CacheAction>> {
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 <salt> — 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::<u64>().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::<u16>().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 <sid> <results...>
// 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::<u64>().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 <sid> 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<String> = 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<String> = 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"),
}
}
}