use std::collections::HashMap;
use std::fmt;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::{Mutex, OnceCell};
use tracing::{debug, info};
use wispers_connect::P2pError;
use wispers_connect::{Node, QuicConnection, QuicStream};
pub const IDLE_TIMEOUT: Duration = Duration::from_secs(60);
pub const CLEANUP_INTERVAL: Duration = Duration::from_secs(15);
pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug)]
pub enum ProxyError {
BadRequest(String),
Forbidden(String),
BadGateway(String),
GatewayTimeout(String),
}
impl fmt::Display for ProxyError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProxyError::BadRequest(msg) => write!(f, "{}", msg),
ProxyError::Forbidden(msg) => write!(f, "{}", msg),
ProxyError::BadGateway(msg) => write!(f, "{}", msg),
ProxyError::GatewayTimeout(msg) => write!(f, "{}", msg),
}
}
}
impl ProxyError {
pub fn status_code(&self) -> u16 {
match self {
ProxyError::BadRequest(_) => 400,
ProxyError::Forbidden(_) => 403,
ProxyError::BadGateway(_) => 502,
ProxyError::GatewayTimeout(_) => 504,
}
}
}
struct PooledEntry {
cell: Arc<OnceCell<Arc<QuicConnection>>>,
last_used: Instant,
}
#[derive(Clone)]
pub struct ConnectionPool {
connections: Arc<Mutex<HashMap<i32, PooledEntry>>>,
}
impl ConnectionPool {
pub fn new() -> Self {
Self {
connections: Arc::new(Mutex::new(HashMap::new())),
}
}
pub async fn open_stream(
&self,
node: &Node,
target_node: i32,
) -> Result<QuicStream, OpenStreamError> {
let mut last_stream_err: Option<P2pError> = None;
for _ in 0..2 {
let cell = {
let mut pool = self.connections.lock().await;
let entry = pool.entry(target_node).or_insert_with(|| PooledEntry {
cell: Arc::new(OnceCell::new()),
last_used: Instant::now(),
});
entry.last_used = Instant::now();
Arc::clone(&entry.cell)
};
let conn = cell
.get_or_try_init(|| async {
node.connect_quic(target_node)
.await
.map(Arc::new)
.map_err(OpenStreamError::Connect)
})
.await?
.clone();
match conn.open_stream().await {
Ok(stream) => {
return Ok(stream);
}
Err(e) => {
let mut pool = self.connections.lock().await;
if let Some(entry) = pool.get(&target_node)
&& Arc::ptr_eq(&entry.cell, &cell)
{
info!(target_node, "Evicting dead QUIC connection");
pool.remove(&target_node);
}
last_stream_err = Some(e);
}
}
}
Err(OpenStreamError::Stream(
last_stream_err.expect("loop ran at least once"),
))
}
pub async fn cleanup_idle(&self) {
let mut pool = self.connections.lock().await;
let now = Instant::now();
let before = pool.len();
pool.retain(|node, entry| {
if now.duration_since(entry.last_used) < IDLE_TIMEOUT {
return true;
}
if entry.cell.get().is_none() {
return true; }
debug!(target_node = node, "Closing idle connection");
false
});
let removed = before - pool.len();
if removed > 0 {
debug!(count = removed, "Cleaned up idle connection(s)");
}
}
}
#[derive(Debug, Clone)]
pub struct WispersHost {
pub node_number: i32,
}
pub fn parse_wispers_host(host: &str) -> Result<WispersHost, Option<ProxyError>> {
let node_str = match host.strip_suffix(".wispers.link") {
Some(s) => s,
None => {
return Err(None);
}
};
let node_number: i32 = node_str.parse().map_err(|_| {
Some(ProxyError::BadRequest(format!(
"invalid node number in hostname: {}",
node_str
)))
})?;
if node_number <= 0 {
return Err(Some(ProxyError::BadRequest(format!(
"node number must be positive, got: {}",
node_number
))));
}
Ok(WispersHost { node_number })
}
#[derive(Debug)]
pub enum OpenStreamError {
Connect(P2pError),
Stream(P2pError),
}
impl fmt::Display for OpenStreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Connect(e) => write!(f, "failed to connect to peer: {}", e),
Self::Stream(e) => write!(f, "failed to open stream: {}", e),
}
}
}
impl std::error::Error for OpenStreamError {}
#[derive(Debug)]
pub enum CommandError {
Io(P2pError),
Rejected(String),
Protocol(String),
}
impl fmt::Display for CommandError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Io(e) => write!(f, "stream I/O failed: {}", e),
Self::Rejected(msg) => write!(f, "remote rejected: {}", msg),
Self::Protocol(msg) => write!(f, "unexpected response: {}", msg),
}
}
}
impl std::error::Error for CommandError {}
pub async fn send_command(stream: &QuicStream, command: &str) -> Result<(), CommandError> {
stream
.write_all(command.as_bytes())
.await
.map_err(CommandError::Io)?;
let mut buf = [0u8; 256];
let n = stream.read(&mut buf).await.map_err(CommandError::Io)?;
let response = String::from_utf8_lossy(&buf[..n]);
let response = response.trim();
if let Some(msg) = response.strip_prefix("ERROR ") {
return Err(CommandError::Rejected(msg.to_string()));
}
if response != "OK" {
return Err(CommandError::Protocol(response.to_string()));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_wispers_host_valid() {
let host = parse_wispers_host("3.wispers.link").unwrap();
assert_eq!(host.node_number, 3);
let host = parse_wispers_host("42.wispers.link").unwrap();
assert_eq!(host.node_number, 42);
let host = parse_wispers_host("999.wispers.link").unwrap();
assert_eq!(host.node_number, 999);
}
#[test]
fn test_parse_wispers_host_non_wispers() {
let result = parse_wispers_host("example.com");
assert!(matches!(result, Err(None)));
let result = parse_wispers_host("google.com");
assert!(matches!(result, Err(None)));
let result = parse_wispers_host("localhost");
assert!(matches!(result, Err(None)));
}
#[test]
fn test_parse_wispers_host_invalid_node_number() {
let result = parse_wispers_host("abc.wispers.link");
assert!(matches!(result, Err(Some(ProxyError::BadRequest(_)))));
let result = parse_wispers_host("0.wispers.link");
assert!(matches!(result, Err(Some(ProxyError::BadRequest(_)))));
let result = parse_wispers_host("-1.wispers.link");
assert!(matches!(result, Err(Some(ProxyError::BadRequest(_)))));
}
#[test]
fn test_proxy_error_status_codes() {
assert_eq!(ProxyError::BadRequest("".to_string()).status_code(), 400);
assert_eq!(ProxyError::Forbidden("".to_string()).status_code(), 403);
assert_eq!(ProxyError::BadGateway("".to_string()).status_code(), 502);
assert_eq!(
ProxyError::GatewayTimeout("".to_string()).status_code(),
504
);
}
}