Самописный socks-сервер на Rust
Ранее мы познакомились с основами использования Tokio, однако примитивы для работы с сетью остались за кадром. Заполним данный пробел, написав какое-нибудь сетевое приложение. Одним из простейших сетевых протоколов является socks5, поэтому писать будем socks5-сервер.
Примечание: Также обратите внимание на статью Пишем простой REST-сервис на Rust, если вдруг вы ее пропустили. Впрочем, при написании REST-сервиса с сетью за нас работал фреймворк Axum.
Протокол socks5 описан в RFC 1928. Пересказывать RFC лишено особого смысла, ведь он предельно прост и понятен. Из протокола реализуем лишь создание TCP-соединений с использованием IPv4 и IPv6, поскольку эти возможности нужны чаще всего. Читателям, заинтересованных в поддержке UDP, доменных имен и аутентификации по логину и паролю, предлагается реализовать их в качестве упражнения.
Основной цикл программы выглядит так:
let listener = TcpListener::bind((listen_addr, listen_port)).await?;
loop {
let (client, peer_addr) = listener.accept().await?;
tokio::spawn(async move {
if let Err(e) = handle_client(client).await {
eprintln!("...");
}
});
}
Все как обычно – делаем bind() на заданном интерфейсе и порту. Вызываем accept() в цикле. На каждого клиента запускаем по задаче. Заострять внимание на типах мне не хотелось бы. Проще открыть полную версию кода в каком-нибудь Zed и изучить все типы.
А вот реализация handle_client():
async fn handle_client(mut client: TcpStream) -> Result<(), Error> {
// Формат запроса: VER | NMETHODS | METHODS...
let mut greeting = [0u8; 2];
client.read_exact(&mut greeting).await?;
if greeting[0] != SOCKS5_VERSION {
return Err(Error::other("unsupported SOCKS version"));
}
let nmethods = greeting[1] as usize;
let mut methods = vec![0u8; nmethods];
client.read_exact(&mut methods).await?;
if !methods.contains(&SOCKS5_NO_AUTH) {
client.write_all(&[SOCKS5_VERSION, SOCKS5_NO_ACCEPTABLE_METHODS])
.await?;
return Err(Error::other("client does not support no-auth"));
}
// Всегда выбираем SOCKS5_NO_AUTH
client.write_all(&[SOCKS5_VERSION, SOCKS5_NO_AUTH]).await?;
// Формат запроса: VER | CMD | RSV | ATYP | DST.ADDR | DST.PORT
let mut header = [0u8; 4];
client.read_exact(&mut header).await?;
if header[0] != SOCKS5_VERSION {
return Err(Error::other("SOCKS5 version mismatch in request"));
}
if header[1] != SOCKS5_CMD_CONNECT {
reply_error(&mut client, SOCKS5_REPLY_CMD_NOT_SUPPORTED).await;
return Err(Error::other("unsupported SOCKS5 command"));
}
let dst_addr = match header[3] {
SOCKS5_ATYP_IPV4 => {
let mut addr = [0u8; 4];
client.read_exact(&mut addr).await?;
IpAddr::from(addr)
}
SOCKS5_ATYP_IPV6 => {
let mut addr = [0u8; 16];
client.read_exact(&mut addr).await?;
IpAddr::from(addr)
}
atyp => {
reply_error(&mut client, SOCKS5_REPLY_ATYP_NOT_SUPPORTED).await;
return Err(Error::other("unsupported address type"));
}
};
let mut port_buf = [0u8; 2];
client.read_exact(&mut port_buf).await?;
let dst_port = u16::from_be_bytes(port_buf);
let mut upstream = match TcpStream::connect((dst_addr, dst_port)).await {
Ok(stream) => stream,
Err(e) => {
reply_error(&mut client, SOCKS5_REPLY_HOST_UNREACHABLE).await;
return Err(Error::other("connection failed"));
}
};
// Соединение установлено; не раскрываем реальный адрес привязки
client
.write_all(&[
SOCKS5_VERSION,
SOCKS5_REPLY_SUCCESS,
0x00,
SOCKS5_ATYP_IPV4,
0, 0, 0, 0, 0, 0,
])
.await?;
tokio::io::copy_bidirectional(&mut client, &mut upstream).await?;
Ok(())
}
Как видите, все довольно просто. Чтобы принять заданное количество байт от клиента, вызываем client.read_exact(). Для отправки данных используем client.write_all(). Для создания TCP-соединения применяем TcpStream::connect(). Чтобы соединить пару TCP-труб, говорим tokio::io::copy_bidirectional(). Все остальное – лишь логика протокола и обработки ошибок.
Заметьте, что таймаутов не предусмотрено. Нужны ли они в прокси-сервере – вопрос дискуссионный. Мы же не знаем, что за приложение использует прокси. Может быть, слать один байт в неделю является для приложения нормальным. В общем же случае таймауты реализуются через TimeoutStream или tokio::time::timeout.
Поскольку мы пишем на Rust, то не забудем закрыть сокет. Все, что должно закрываться, закрывается автоматически при помощи трейта Drop. Кроме того, Tokio реализует корректную обработку типичных ошибок сетевого кода. Можно не думать о частичном чтении / записи, EINTR и SIGPIPE.
Архив с полной версией исходников лежит здесь. В нем вы найдете два приложения. Первое представляет собой socks-сервер, описанный выше. Второе приложение демонстрирует дальнейшее развитие идеи. Прокси-сервер имеет файл конфигурации в формате TOML. По умолчанию сервер направляет трафик в еще один socks-сервер, указанный в конфиге. Однако для IP и подсетей из списка exceptions трафик идет напрямую.
Само собой разумеется, можно придумать и более сложные манипуляции с трафиком. Socks5 удобен тем, что его поддерживают все популярные приложения.