use anyhow::{Context, Result};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tracing::{debug, error, info, warn};
use wispers_connect::{Node, NodeState, QuicStream};
use crate::proxy_common::{
CLEANUP_INTERVAL, ConnectionPool, ProxyError, REQUEST_TIMEOUT, parse_wispers_host, send_command,
};
pub async fn run(
hub_override: Option<&str>,
profile: &str,
bind_addr: &str,
egress_node: Option<i32>,
) -> Result<()> {
let storage = super::get_storage(hub_override, profile)?;
let node = super::load_node(&storage).await?;
if node.state() != NodeState::Activated {
anyhow::bail!(
"Node must be activated to use HTTP proxy. Current state: {:?}",
node.state()
);
}
let listener = TcpListener::bind(bind_addr)
.await
.with_context(|| format!("failed to bind to {}", bind_addr))?;
println!("HTTP proxy listening on {}", bind_addr);
if let Some(egress) = egress_node {
println!(" Internet egress: enabled via node {}", egress);
println!(
"Example: curl --proxy http://{} https://example.com/",
bind_addr
);
} else {
println!(" Internet egress: disabled (wispers.link only)");
println!(
"Example: curl --proxy http://{} http://3.wispers.link/",
bind_addr
);
}
let node = Arc::new(node);
let pool = ConnectionPool::new();
let cleanup_pool = pool.clone();
tokio::spawn(async move {
loop {
tokio::time::sleep(CLEANUP_INTERVAL).await;
cleanup_pool.cleanup_idle().await;
}
});
loop {
match listener.accept().await {
Ok((stream, addr)) => {
debug!(%addr, "Accepted connection");
let node = Arc::clone(&node);
let pool = pool.clone();
tokio::spawn(async move {
if let Err(e) = handle_client_connection(stream, node, pool, egress_node).await
{
warn!(error = %e, "Connection error");
}
});
}
Err(e) => {
error!(error = %e, "Accept error");
}
}
}
}
#[derive(Debug, Clone)]
enum Destination {
WispersNode { node_number: i32, port: u16 },
Internet { host: String, port: u16 },
}
#[derive(Debug)]
enum ProxyMode {
HttpRequest { path: String },
Tunnel,
}
#[derive(Debug)]
struct ProxyTarget {
destination: Destination,
mode: ProxyMode,
}
#[derive(Debug)]
struct ParsedRequest {
target: ProxyTarget,
method: String,
version: u8,
headers: Vec<(String, String)>,
keep_alive: bool,
}
async fn handle_client_connection(
mut stream: TcpStream,
node: Arc<Node>,
pool: ConnectionPool,
egress_node: Option<i32>,
) -> Result<()> {
let peer = stream.peer_addr()?;
let mut request_count = 0;
loop {
let buf = match read_request_bytes(&mut stream).await {
Ok(ReadResult::Data(buf)) => buf,
Ok(ReadResult::Closed) => {
break;
}
Err(e) => {
if request_count == 0 {
send_proxy_error(&mut stream, &e).await?;
}
break;
}
};
let request = match parse_request(&buf, egress_node) {
Ok(req) => req,
Err(e) => {
send_proxy_error(&mut stream, &e).await?;
break;
}
};
request_count += 1;
let keep_alive = request.keep_alive;
let (target_node, command, routing_via_egress) = match &request.target.destination {
Destination::WispersNode { node_number, port } => {
(*node_number, format!("FORWARD {}\n", port), false)
}
Destination::Internet { host, port } => (
egress_node.unwrap(),
format!("CONNECT {}:{}\n", host, port),
true,
),
};
match (&request.target.destination, &request.target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::HttpRequest { path }) => {
info!(
method = %request.method,
node = node_number, port, path,
keep_alive,
"HTTP request",
);
}
(Destination::WispersNode { node_number, port }, ProxyMode::Tunnel) => {
info!(node = node_number, port, "CONNECT tunnel");
}
(Destination::Internet { host, port }, ProxyMode::HttpRequest { path }) => {
info!(
method = %request.method,
host, port, path,
egress_node = target_node,
keep_alive,
"HTTP request via egress",
);
}
(Destination::Internet { host, port }, ProxyMode::Tunnel) => {
info!(
host,
port,
egress_node = target_node,
"CONNECT tunnel via egress"
);
}
}
let quic_stream =
match tokio::time::timeout(REQUEST_TIMEOUT, pool.open_stream(&node, target_node)).await
{
Ok(Ok(s)) => s,
Ok(Err(e)) => {
let msg = if routing_via_egress {
format!("failed to open stream to egress node: {}", e)
} else {
format!("failed to open stream to node: {}", e)
};
send_proxy_error(&mut stream, &ProxyError::BadGateway(msg)).await?;
break;
}
Err(_) => {
let msg = if routing_via_egress {
"open_stream to egress node timed out"
} else {
"open_stream to node timed out"
};
send_proxy_error(&mut stream, &ProxyError::GatewayTimeout(msg.to_string()))
.await?;
break;
}
};
if let Err(e) = send_command(&quic_stream, &command).await {
send_proxy_error(&mut stream, &ProxyError::BadGateway(format!("{}", e))).await?;
break;
}
match &request.target.mode {
ProxyMode::HttpRequest { path } => {
let result = tokio::time::timeout(
REQUEST_TIMEOUT,
handle_http_request(&mut stream, quic_stream, &request, path),
)
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
warn!(error = %e, "Request error");
break;
}
Err(_) => {
let err = ProxyError::GatewayTimeout("request timed out".to_string());
let _ = send_proxy_error(&mut stream, &err).await;
break;
}
}
}
ProxyMode::Tunnel => {
let result =
tokio::time::timeout(REQUEST_TIMEOUT, handle_tunnel(&mut stream, quic_stream))
.await;
match result {
Ok(Ok(())) => {}
Ok(Err(e)) => {
debug!(error = %e, "Tunnel ended with error");
}
Err(_) => {
let err = ProxyError::GatewayTimeout("tunnel setup timed out".to_string());
let _ = send_proxy_error(&mut stream, &err).await;
}
}
break;
}
}
if !keep_alive {
break;
}
}
debug!(%peer, request_count, "Connection closed");
Ok(())
}
enum ReadResult {
Data(Vec<u8>),
Closed,
}
async fn read_request_bytes(stream: &mut TcpStream) -> Result<ReadResult, ProxyError> {
let mut buf = vec![0u8; 8192];
let mut total_read = 0;
loop {
if total_read >= buf.len() {
return Err(ProxyError::BadRequest("request too large".to_string()));
}
let n = stream
.read(&mut buf[total_read..])
.await
.map_err(|e| ProxyError::BadRequest(format!("failed to read request: {}", e)))?;
if n == 0 {
if total_read == 0 {
return Ok(ReadResult::Closed);
}
return Err(ProxyError::BadRequest(
"connection closed before complete request".to_string(),
));
}
total_read += n;
if total_read >= 4 {
let data = &buf[..total_read];
if data.windows(4).any(|w| w == b"\r\n\r\n") {
buf.truncate(total_read);
return Ok(ReadResult::Data(buf));
}
}
}
}
async fn handle_http_request(
client_stream: &mut TcpStream,
quic_stream: QuicStream,
request: &ParsedRequest,
path: &str,
) -> Result<()> {
let http_request = build_http_request(request, path);
quic_stream
.write_all(http_request.as_bytes())
.await
.context("failed to send HTTP request")?;
quic_stream
.finish()
.await
.context("failed to finish request stream")?;
let mut buf = [0u8; 8192];
loop {
let n = quic_stream
.read(&mut buf)
.await
.context("failed to read from remote")?;
if n == 0 {
break;
}
client_stream
.write_all(&buf[..n])
.await
.context("failed to write to client")?;
}
Ok(())
}
async fn handle_tunnel(client_stream: &mut TcpStream, quic_stream: QuicStream) -> Result<()> {
client_stream
.write_all(b"HTTP/1.1 200 Connection Established\r\n\r\n")
.await
.context("failed to send 200 response")?;
let quic_stream = Arc::new(quic_stream);
let (mut tcp_read, mut tcp_write) = client_stream.split();
let quic_read = Arc::clone(&quic_stream);
let quic_write = Arc::clone(&quic_stream);
let tcp_to_quic = async move {
let mut buf = [0u8; 8192];
loop {
match tcp_read.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if quic_write.write_all(&buf[..n]).await.is_err() {
break;
}
}
Err(_) => break,
}
}
let _ = quic_write.finish().await;
};
let quic_to_tcp = async move {
let mut buf = [0u8; 8192];
loop {
match quic_read.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if tcp_write.write_all(&buf[..n]).await.is_err() {
break;
}
}
Err(_) => break,
}
}
let _ = tcp_write.shutdown().await;
};
tokio::join!(tcp_to_quic, quic_to_tcp);
Ok(())
}
fn build_http_request(request: &ParsedRequest, path: &str) -> String {
let mut http = String::new();
let version = if request.version == 0 { "1.0" } else { "1.1" };
http.push_str(&format!("{} {} HTTP/{}\r\n", request.method, path, version));
for (name, value) in &request.headers {
http.push_str(&format!("{}: {}\r\n", name, value));
}
http.push_str("Connection: close\r\n");
http.push_str("\r\n");
http
}
fn parse_request(buf: &[u8], egress_node: Option<i32>) -> Result<ParsedRequest, ProxyError> {
let mut headers = [httparse::EMPTY_HEADER; 64];
let mut req = httparse::Request::new(&mut headers);
let status = req
.parse(buf)
.map_err(|e| ProxyError::BadRequest(format!("failed to parse HTTP request: {}", e)))?;
if status.is_partial() {
return Err(ProxyError::BadRequest(
"incomplete HTTP request".to_string(),
));
}
let method = req
.method
.ok_or_else(|| ProxyError::BadRequest("missing method".to_string()))?
.to_string();
let path = req
.path
.ok_or_else(|| ProxyError::BadRequest("missing path".to_string()))?;
let version = req
.version
.ok_or_else(|| ProxyError::BadRequest("missing version".to_string()))?;
if method == "CONNECT" {
let target = parse_connect_target(path, egress_node)?;
return Ok(ParsedRequest {
target,
method,
version,
headers: Vec::new(),
keep_alive: false,
});
}
let target = parse_proxy_target(path, egress_node)?;
let mut parsed_headers = Vec::new();
let mut keep_alive = version == 1; let mut host_header = None;
for header in req.headers.iter() {
let name = header.name.to_lowercase();
let value = String::from_utf8_lossy(header.value).to_string();
if name == "connection" {
keep_alive = value.to_lowercase().contains("keep-alive");
continue;
}
if is_hop_by_hop_header(&name) {
continue;
}
if name == "host" {
host_header = Some(value.clone());
}
parsed_headers.push((header.name.to_string(), value));
}
if host_header.is_none()
&& let ProxyMode::HttpRequest { .. } = &target.mode
{
let host = match &target.destination {
Destination::WispersNode { node_number, port } => {
if *port == 80 {
format!("{}.wispers.link", node_number)
} else {
format!("{}.wispers.link:{}", node_number, port)
}
}
Destination::Internet { host, port } => {
if *port == 80 {
host.clone()
} else {
format!("{}:{}", host, port)
}
}
};
parsed_headers.push(("Host".to_string(), host));
}
Ok(ParsedRequest {
target,
method,
version,
headers: parsed_headers,
keep_alive,
})
}
fn parse_connect_target(target: &str, egress_node: Option<i32>) -> Result<ProxyTarget, ProxyError> {
let (host, port) = match target.rfind(':') {
Some(pos) => {
let port_str = &target[pos + 1..];
let port: u16 = port_str.parse().map_err(|_| {
ProxyError::BadRequest(format!("invalid port in CONNECT: {}", port_str))
})?;
(&target[..pos], port)
}
None => {
return Err(ProxyError::BadRequest(
"CONNECT target must be host:port".to_string(),
));
}
};
match parse_wispers_host(host) {
Ok(wispers_host) => Ok(ProxyTarget {
destination: Destination::WispersNode {
node_number: wispers_host.node_number,
port,
},
mode: ProxyMode::Tunnel,
}),
Err(None) => {
if egress_node.is_some() {
Ok(ProxyTarget {
destination: Destination::Internet {
host: host.to_string(),
port,
},
mode: ProxyMode::Tunnel,
})
} else {
Err(ProxyError::Forbidden(format!(
"CONNECT to non-wispers.link hosts requires --egress-node, got: {}",
host
)))
}
}
Err(Some(e)) => Err(e),
}
}
fn parse_proxy_target(url: &str, egress_node: Option<i32>) -> Result<ProxyTarget, ProxyError> {
let rest = match url.strip_prefix("http://") {
Some(r) => r,
None => {
return Err(ProxyError::BadRequest(
"proxy requests must use absolute URLs (http://...)".to_string(),
));
}
};
let (host_port, path) = match rest.find('/') {
Some(pos) => (&rest[..pos], &rest[pos..]),
None => (rest, "/"),
};
let (host, port) = match host_port.rfind(':') {
Some(pos) => {
let port_str = &host_port[pos + 1..];
let port: u16 = port_str
.parse()
.map_err(|_| ProxyError::BadRequest(format!("invalid port: {}", port_str)))?;
(&host_port[..pos], port)
}
None => (host_port, 80),
};
match parse_wispers_host(host) {
Ok(wispers_host) => Ok(ProxyTarget {
destination: Destination::WispersNode {
node_number: wispers_host.node_number,
port,
},
mode: ProxyMode::HttpRequest {
path: path.to_string(),
},
}),
Err(None) => {
if egress_node.is_some() {
Ok(ProxyTarget {
destination: Destination::Internet {
host: host.to_string(),
port,
},
mode: ProxyMode::HttpRequest {
path: path.to_string(),
},
})
} else {
Err(ProxyError::Forbidden(format!(
"only *.wispers.link hosts are allowed without --egress-node, got: {}",
host
)))
}
}
Err(Some(e)) => Err(e),
}
}
fn is_hop_by_hop_header(name: &str) -> bool {
matches!(
name,
"connection"
| "keep-alive"
| "proxy-authenticate"
| "proxy-authorization"
| "te"
| "trailers"
| "transfer-encoding"
| "upgrade"
)
}
async fn send_proxy_error(stream: &mut TcpStream, error: &ProxyError) -> Result<()> {
send_error(stream, error.status_code(), &error.to_string()).await
}
async fn send_error(stream: &mut TcpStream, status: u16, message: &str) -> Result<()> {
let status_text = match status {
400 => "Bad Request",
403 => "Forbidden",
502 => "Bad Gateway",
504 => "Gateway Timeout",
_ => "Error",
};
let response = format!(
"HTTP/1.1 {} {}\r\nContent-Type: text/plain\r\nConnection: close\r\n\r\n{}\n",
status, status_text, message
);
stream.write_all(response.as_bytes()).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_proxy_target_basic() {
let target = parse_proxy_target("http://3.wispers.link/", None).unwrap();
match (&target.destination, &target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(*node_number, 3);
assert_eq!(*port, 80);
assert_eq!(path, "/");
}
_ => panic!("expected WispersNode + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_with_path() {
let target = parse_proxy_target("http://42.wispers.link/api/v1/users", None).unwrap();
match (&target.destination, &target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(*node_number, 42);
assert_eq!(*port, 80);
assert_eq!(path, "/api/v1/users");
}
_ => panic!("expected WispersNode + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_with_port() {
let target = parse_proxy_target("http://5.wispers.link:8080/test", None).unwrap();
match (&target.destination, &target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(*node_number, 5);
assert_eq!(*port, 8080);
assert_eq!(path, "/test");
}
_ => panic!("expected WispersNode + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_with_query() {
let target =
parse_proxy_target("http://1.wispers.link/search?q=test&page=2", None).unwrap();
match (&target.destination, &target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(*node_number, 1);
assert_eq!(*port, 80);
assert_eq!(path, "/search?q=test&page=2");
}
_ => panic!("expected WispersNode + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_no_path() {
let target = parse_proxy_target("http://7.wispers.link", None).unwrap();
match (&target.destination, &target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(*node_number, 7);
assert_eq!(*port, 80);
assert_eq!(path, "/");
}
_ => panic!("expected WispersNode + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_invalid_no_http() {
let err = parse_proxy_target("https://3.wispers.link/", None).unwrap_err();
assert_eq!(err.status_code(), 400);
let err = parse_proxy_target("/path", None).unwrap_err();
assert_eq!(err.status_code(), 400);
}
#[test]
fn test_parse_proxy_target_forbidden_hostname() {
let err = parse_proxy_target("http://example.com/", None).unwrap_err();
assert_eq!(err.status_code(), 403);
let err = parse_proxy_target("http://google.com/", None).unwrap_err();
assert_eq!(err.status_code(), 403);
}
#[test]
fn test_parse_proxy_target_egress() {
let target = parse_proxy_target("http://example.com/path", Some(3)).unwrap();
match (&target.destination, &target.mode) {
(Destination::Internet { host, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(host, "example.com");
assert_eq!(*port, 80);
assert_eq!(path, "/path");
}
_ => panic!("expected Internet + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_egress_with_port() {
let target = parse_proxy_target("http://example.com:8080/api", Some(5)).unwrap();
match (&target.destination, &target.mode) {
(Destination::Internet { host, port }, ProxyMode::HttpRequest { path }) => {
assert_eq!(host, "example.com");
assert_eq!(*port, 8080);
assert_eq!(path, "/api");
}
_ => panic!("expected Internet + HttpRequest"),
}
}
#[test]
fn test_parse_proxy_target_invalid_node_number() {
let err = parse_proxy_target("http://abc.wispers.link/", None).unwrap_err();
assert_eq!(err.status_code(), 400);
let err = parse_proxy_target("http://0.wispers.link/", None).unwrap_err();
assert_eq!(err.status_code(), 400);
let err = parse_proxy_target("http://-1.wispers.link/", None).unwrap_err();
assert_eq!(err.status_code(), 400);
}
#[test]
fn test_parse_connect_target_internet() {
let target = parse_connect_target("example.com:443", Some(3)).unwrap();
match (&target.destination, &target.mode) {
(Destination::Internet { host, port }, ProxyMode::Tunnel) => {
assert_eq!(host, "example.com");
assert_eq!(*port, 443);
}
_ => panic!("expected Internet + Tunnel"),
}
}
#[test]
fn test_parse_connect_target_wispers() {
let target = parse_connect_target("3.wispers.link:443", None).unwrap();
match (&target.destination, &target.mode) {
(Destination::WispersNode { node_number, port }, ProxyMode::Tunnel) => {
assert_eq!(*node_number, 3);
assert_eq!(*port, 443);
}
_ => panic!("expected WispersNode + Tunnel"),
}
}
#[test]
fn test_parse_connect_target_no_egress() {
let err = parse_connect_target("example.com:443", None).unwrap_err();
assert_eq!(err.status_code(), 403);
}
#[test]
fn test_parse_connect_target_no_port() {
let err = parse_connect_target("example.com", Some(3)).unwrap_err();
assert_eq!(err.status_code(), 400);
}
#[test]
fn test_hop_by_hop_headers() {
assert!(is_hop_by_hop_header("connection"));
assert!(is_hop_by_hop_header("keep-alive"));
assert!(is_hop_by_hop_header("transfer-encoding"));
assert!(!is_hop_by_hop_header("content-type"));
assert!(!is_hop_by_hop_header("host"));
}
#[test]
fn test_build_http_request() {
let request = ParsedRequest {
target: ProxyTarget {
destination: Destination::WispersNode {
node_number: 3,
port: 80,
},
mode: ProxyMode::HttpRequest {
path: "/api/test".to_string(),
},
},
method: "GET".to_string(),
version: 1,
headers: vec![
("Host".to_string(), "3.wispers.link".to_string()),
("User-Agent".to_string(), "test/1.0".to_string()),
],
keep_alive: true,
};
let http = build_http_request(&request, "/api/test");
assert_eq!(
http,
"GET /api/test HTTP/1.1\r\nHost: 3.wispers.link\r\nUser-Agent: test/1.0\r\nConnection: close\r\n\r\n"
);
}
#[test]
fn test_build_http_request_http10() {
let request = ParsedRequest {
target: ProxyTarget {
destination: Destination::WispersNode {
node_number: 5,
port: 8080,
},
mode: ProxyMode::HttpRequest {
path: "/".to_string(),
},
},
method: "POST".to_string(),
version: 0,
headers: vec![("Host".to_string(), "5.wispers.link:8080".to_string())],
keep_alive: false,
};
let http = build_http_request(&request, "/");
assert!(http.starts_with("POST / HTTP/1.0\r\n"));
}
}