1315 lines
53 KiB
Rust
Executable File
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"),
|
|
}
|
|
}
|
|
} |