Yet another Rust VPN (TUN-QUIC-TUN)

от автора

На поделке из предыдущей статьи вдохновение не закончилось, решил написать VPN. Был опыт разработки shadowsocks сервера, что не понравилось: под каждое TCP соединение, открываемое каким-нибудь браузеров, VPN клиент открывает отдельное TCP cоединение к серверу, а тот в свою очередь столько же до конечной точки + пришлось руками шифровать/дешифровать payload. Захотелось сделать что-то более простое + самим написать и клиент.

Дисклеймер: проект и статья исключительно в образовательных целях, разобраться в сетях и попрактиковаться на Rust, ничего не рекламирую, ни к чему не призываю.

Поток данных

После небольшого ресерча остановился на следующей схеме:

Трафик до сайта в интернете

Трафик до сайта в интернете

Давайте разбираться, что здесь происходит.

В моем случае клиент – Mac OS, сервер – Ubuntu, в основном это влияет лишь на то, какими инструментами будут настроены правила маршрутизации (route, nftables, etc).

Со стороны клиента, VPN:

  • Поднимает TUN интерфейс utun1

  • Добавляет маршршуты:

    • route add -net 0.0.0.0/1 -interface utun1 + route add -net 128.0.0.0/1 -interface utun1 заворачивают весь исходящий трафик через созданный интерфейс (два маршрута, чтобы не трогать дефолтный 0.0.0.0/0)

    • route add -host 1.1.1.1 {default_gw} позволяет трафик до VPN сервера напрямую, чтобы не зацикливаться на utun1 (вместо default_gw подставляется адрес шлюза по умолчанию)

  • Перенаправляет весь получаемый на utun1 трафик на VPN сервер, инкапсулируя в QUIC датаграммы, а получаемый по QUIC трафик обратно в utun1

Сервер:

  • Поднимает TUN интерфейс utun1

  • Чтобы utun1 мог ходить в интернет, будто он en0 (дефолтный интерфейс), добавляет следующий ruleset в nftables (вместо vpn_net подставляется соответствующая подсеть, подробнее про нее будет ниже):

    table ip vpn_natflush table ip vpn_nattable ip vpn_nat {{    chain postrouting {{        type nat hook postrouting priority 100;        ip saddr {vpn_net} oifname en0 masquerade    }}}}
  • Перенаправляет весь получаемый по QUIC трафик в utun1, а из него обратно по QUIC

Нюансы

Трафик между VPN клиентом и сервером отправляем через QUIC датаграммы, а значит гарантий доставки нет (зато есть TLS). Это не проблема для клиентского трафика, т.к. ретраи будет обеспечивать инкапсулированный протокол (если поддерживает). Однако для контрольного трафика потери критичны, например, для аутентификации, здесь воспользуемся QUIC bidirectional stream.

Сервер и клиент никак не меняют инкапсулированный трафик, т.е. source IP у пользовательских пакетов будет от их TUN интерфейса. Так как мы заранее загружаем ruleset для nftables, необходимо, чтобы этот IP был из подсети vpn_net. Помимо этого, когда сервер получит обратный трафик, по destination IP пакета надо будет определить, кому из подключенных клиентов его направить. Вывод: IP TUN интерфейсов должны быть уникальны, принадлежать одной подсети + сервер должен знать соответствие клиент — IP. Самое простое решение – статическая конфигурация:

// на сервереtun = "10.0.0.1/24"client1 = "10.0.0.2"client2 = "10.0.0.3"...// на стороне client1tun = "10.0.0.2/24"// на стороне client2tun = "10.0.0.3/24"

Хотелось бы поменьше ручной работы, поэтому вместо статики добавим IPAM (IP Address Management): при подключении клиента выдаем ему IP из соответствующей подсети, учитывая ее размер и выданные до этого адреса, при отключении – «освобождаем» адрес. Клиент в свою очередь поднимает TUN интерфейс именно с полученным от сервера IP.

Основные зависимости

  • tokio – async runtime

  • tun-rs – манипуляции с TUN интерфейсами

  • quinn – реализация QUIC протокола

Клиент

Т.к. для сервера используется self signed сертификат, его необходимо подгрузить и при создание QUIC эндпоинта на клиенте.

Client QUIC endpoint
use anyhow::Error;use quinn::{ClientConfig, Endpoint, MtuDiscoveryConfig, TransportConfig, congestion::BbrConfig};use rustls::pki_types::CertificateDer;use std::sync::Arc;use crate::config::QuicConfig;pub fn make_client_endpoint(config: &QuicConfig) -> Result<Endpoint, Error> {    let mut client_cfg = configure_client(config)?;    client_cfg.transport_config(Arc::new(build_transport_config(config)));    let mut endpoint = Endpoint::client(config.endpoint_address)?;    endpoint.set_default_client_config(client_cfg);    Ok(endpoint)}fn configure_client(config: &QuicConfig) -> Result<ClientConfig, Error> {    // читаем сертификат с диска    let cert_bytes = std::fs::read(&config.server_cert)?;    let server_cert = CertificateDer::from(cert_bytes);    let mut certs = rustls::RootCertStore::empty();    certs.add(server_cert)?;    Ok(ClientConfig::with_root_certificates(Arc::new(certs))?)}fn build_transport_config(quic_config: &QuicConfig) -> TransportConfig {    let mut transport = TransportConfig::default();    transport.mtu_discovery_config(Some(MtuDiscoveryConfig::default()));    transport.datagram_receive_buffer_size(Some(quic_config.receive_buffer_size_kb * 1024));    transport.datagram_send_buffer_size(quic_config.send_buffer_size_kb * 1024);    transport.max_concurrent_uni_streams(0u32.into());    transport.max_concurrent_bidi_streams(10u32.into());    transport.congestion_controller_factory(Arc::new(BbrConfig::default()));    transport}

В основном цикле просто подключаемся к серверу и запускаем туннель

tokio::spawn(async move {    loop {        let connecting = match endpoint.connect(server_address, &server_name) {            Ok(c) => c,            Err(e) => {                error!("connect failed: {e:#}, retrying in {backoff:?}");                sleep(backoff).await;                continue;            }        };        let connection = match connecting.await {            Ok(c) => c,            Err(e) => {                error!("handshake failed: {e:#}, retrying in {backoff:?}");                sleep(backoff).await;                continue;            }        };        info!("connected to server {}", connection.remote_address());        match run_tunnel(connection.clone(), &config).await {            Ok(_) => error!("session ended, reconnecting..."),            Err(e) => error!("error running tunnel: {e:#}"),        };        connection.close(CLOSE_CODE_NORMAL, &[]);        sleep(backoff).await;    }})

Обмен трафиком начинается с handshake: клиент отправляет свои credentials, сервер проверяет их и в случае успеха назначает IP адрес

pub struct ClientHello {    pub user: String,    pub secret: String,}pub struct ServerHello {    pub addr: Ipv4Addr,    pub prefix: u8,}async fn handshake(    connection: &Connection,    config: &VpnConfig,) -> Result<ServerHello, anyhow::Error> {    // QUIC stream, чтобы не терять трафик    let (mut send, mut recv) = connection.open_bi().await?;    // отправляем данные для аутентификации    let client_hello_bytes: Vec<u8> = ClientHello {        user: config.user.name.clone(),        secret: config.user.secret.clone(),    }    .into();    info!("sending client hello");    send.write_all(&client_hello_bytes).await?;    send.finish()?;    let server_hello_bytes = recv.read_to_end(MAX_HANDSHAKE_DATA).await?;    let server_hello = ServerHello::from(server_hello_bytes);    info!("authentication succeeded");    // получили адрес для TUN    Ok(server_hello)}

После handshake создаем TUN интерфейс, настраиваем маршрутизацию

pub async fn run_tunnel(connection: Connection, config: &VpnConfig) -> Result<()> {    let handshake = handshake(&connection, config).await?;    let addr: Ipv4Addr = handshake.addr;    let prefix: u8 = handshake.prefix;    info!("registring tun {addr}/{prefix}");    let device_name = &config.tun.name;    let server_address = &config.quic.server_address.ip().to_string();    let mtu = config.tun.mtu;    let device = Arc::new(        DeviceBuilder::new()            .name(device_name)            .ipv4(addr, prefix, None)            .mtu(mtu)            .build_async()?,    );    // добавляем маршруты, drop _guard их почистит    let _guard = if config.tun.setup_routes {        Some(setup_vpn_routes(server_address, device_name)?)    } else {        None    };    // задача на чтение из TUN и отправку по QUIC    let mut device_recv_task = {        let device = device.clone();        let connection = connection.clone();        tokio::spawn(async move {            let mut buf = vec![0u8; READ_BUF_SIZE];            loop {                let n = device.recv(&mut buf).await?;                connection.send_datagram(Bytes::copy_from_slice(&buf[..n]))?;            }            #[allow(unreachable_code)]            Ok::<(), anyhow::Error>(())        })    };    // задача на чтение по QUIC и отправку в TUN    let mut connection_read_task = {        let connection = connection.clone();        if let Some(max_datagram_size) = connection.max_datagram_size() {            info!("connection max_datagram_size = {max_datagram_size}");        }        let device = device.clone();        tokio::spawn(async move {            loop {                let data = connection.read_datagram().await?;                device.send(&data).await?;            }            #[allow(unreachable_code)]            Ok::<(), anyhow::Error>(())        })    };    let result = tokio::select! {        res = &mut device_recv_task => res.unwrap_or_else(|e| Err(e.into())),        res = &mut connection_read_task => res.unwrap_or_else(|e| Err(e.into())),    };    device_recv_task.abort();    connection_read_task.abort();    result}

Сервер

pub struct VpnServerState {    // конфигурация    config: VpnConfig,    // TUN интерфейс    device: AsyncDevice,    // QUIC endpoint, к которому подключаются клиенты    endpoint: Endpoint,    // IPAM    ip_pool: IpPool,    // Выделенный адрес -> клиент, для маршрутизации обратного трафика    connections: DashMap<Ipv4Addr, Connection>,    // Для graceful shutdown    cancellation_token: CancellationToken,    task_tracker: TaskTracker,}
IPAM
use std::collections::VecDeque;use std::net::Ipv4Addr;use std::sync::Mutex;pub struct IpPool {    pub subnet_base: Ipv4Addr,    pub subnet_prefix: u8,    available: Mutex<VecDeque<Ipv4Addr>>,}impl IpPool {    pub fn new(subnet_base: Ipv4Addr, subnet_prefix: u8) -> Self {        let base_u32 = u32::from(subnet_base);        let host_bits = 32 - subnet_prefix as u32;        let num_addrs = 1u32 << host_bits;        // пропускаем 0, 1 и последний адрес        let mut queue = VecDeque::new();        for i in 2..(num_addrs - 1) {            queue.push_back(Ipv4Addr::from(base_u32 + i));        }        IpPool {            subnet_base,            subnet_prefix,            available: Mutex::new(queue),        }    }    pub fn server_ip(&self) -> Ipv4Addr {        let base_u32 = u32::from(self.subnet_base);        return Ipv4Addr::from(base_u32 + 1);    }    pub fn allocate(&self) -> Option<Ipv4Addr> {        self.available.lock().unwrap().pop_front()    }    pub fn release(&self, ip: Ipv4Addr) {        self.available.lock().unwrap().push_back(ip);    }}

При создании QUIC endpoint подгружаем существующий или генерируем новый self signed сертификат

Server QUIC endpoint
use anyhow::Result;use quinn::congestion::BbrConfig;use quinn::{Endpoint, MtuDiscoveryConfig, ServerConfig, TransportConfig};use rustls::pki_types::{CertificateDer, PrivateKeyDer, PrivatePkcs8KeyDer};use tracing::info;use std::fs;use std::path::Path;use std::sync::Arc;use crate::config::QuicConfig;pub fn make_server_endpoint(config: &QuicConfig) -> Result<Endpoint> {    let server_config = configure_server(config)?;    let endpoint = Endpoint::server(server_config, config.endpoint_address)?;    Ok(endpoint)}fn configure_server(config: &QuicConfig) -> Result<ServerConfig> {    let (cert_der, key_der) = load_or_generate_cert(config)?;    let mut server_config = ServerConfig::with_single_cert(vec![cert_der.clone()], key_der)?;    server_config.transport_config(Arc::new(build_transport_config(config)));    Ok(server_config)}fn load_or_generate_cert(    quic_config: &QuicConfig,) -> Result<(CertificateDer<'static>, PrivateKeyDer<'static>)> {    let cert_path = &quic_config.server_cert;    let key_path = &quic_config.server_key;    if Path::new(cert_path).exists() && Path::new(key_path).exists() {        info!("loading existing cert + key");        let cert_bytes = fs::read(cert_path)?;        let key_bytes = fs::read(key_path)?;        let cert = CertificateDer::from(cert_bytes);        let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_bytes));        Ok((cert, key))    } else {        info!("generating new cert + key");        let subject_alt_names = vec![quic_config.server_name.clone().into()];        let cert = rcgen::generate_simple_self_signed(subject_alt_names)?;        let cert_der = cert.cert.der().to_vec();        let key_der = cert.signing_key.serialize_der();        fs::write(cert_path, &cert_der)?;        fs::write(key_path, &key_der)?;        let cert = CertificateDer::from(cert_der);        let key = PrivateKeyDer::Pkcs8(PrivatePkcs8KeyDer::from(key_der));        Ok((cert, key))    }}fn build_transport_config(quic_config: &QuicConfig) -> TransportConfig {    let mut transport = TransportConfig::default();    transport.mtu_discovery_config(Some(MtuDiscoveryConfig::default()));    transport.datagram_receive_buffer_size(Some(quic_config.receive_buffer_size_kb * 1024));    transport.datagram_send_buffer_size(quic_config.send_buffer_size_kb * 1024);    transport.max_concurrent_uni_streams(0u32.into());    transport.max_concurrent_bidi_streams(10u32.into());    transport.congestion_controller_factory(Arc::new(BbrConfig::default()));    transport}

Логика в целом похожа на клиентскую, тоже две задачи, одна – обработка новых QUIC подключений, другая – чтение из TUN и запись в QUIC

pub async fn run(&self) -> Result<()> {    let subnet = format!(        "{}/{}",        self.ip_pool.subnet_base, self.ip_pool.subnet_prefix    );    // настраиваем nftables, drop _nat_guard почистит настройки    let _nat_guard = if self.config.tun.setup_nat {        Some(apply_vpn_nat(&subnet)?)    } else {        None    };    let device_worker = {        let server = self.clone();        self.task_tracker            .spawn(async move { listen_device(server).await })    };    let connection_worker = {        let server = self.clone();        self.task_tracker            .spawn(async move { accept_connections(server).await })    };    tokio::select! {        r = device_worker => r.unwrap_or_else(|e| Err(e.into())),        r = connection_worker => r.map_err(|e| anyhow::anyhow!(e)),    }}

При появлении нового соединения, запускаем отдельную задачу, выполняем handshake и в запускаем цикл отправки трафика connection -> device

async fn register(&self, connection: &Connection) -> Result<Session> {    let ip = self        .ip_pool        .allocate()        .ok_or_else(|| anyhow!("ip pool exhausted"))?;    self.connections.insert(ip, connection.clone());    // drop session приведет к освобождению адреса и удалению из connections    let session = Session::new(ip, connection.clone(), self.clone());    self.handshake(connection, ip).await?;    Ok(session)}async fn handle_session(&self, session: Session) -> Result<()> {    let mut consecutive_errors = 0;    loop {        let read = session.read().await?;        if source_match(session.ip, &read) {            match self.device.send(&read).await {                Ok(_sent) => {                    consecutive_errors = 0;                }                Err(e) => {                    consecutive_errors += 1;                    if consecutive_errors > MAX_CONSECUTIVE_ERRORS {                        return Err(e.into());                    }                    sleep(Duration::from_millis(                        ERROR_BACKOFF_BASE_MS * consecutive_errors,                    ))                    .await;                }            }        }    }}

Тестируем

Запускаем отечественный интернетометр, тестируем до включения и после (через Амстердам)

До

До
После

После

Наверняка есть, что подкрутить в настройках QUIC + не самый показательный тест, но все равно неплохо.

Еще немного деталей

MTU (Maximum Transmission Unit) TUN интерфейсов и QUIC соединений должны быть согласованы, дабы не пытаться отправить по QUIC пакет, не влезающий в MTU, и избежать мороки с частичной отправкой.

При получении клиентского трафика необходимо сверять IP пакета с присвоенным клиенту IP, чтобы не выло возможности мимикрировать под других клиентов.

И для клиента, и для сервера реализован graceful shutdown, чтобы не оставлять после себя настройки маршрутизации (nftables/route)

await_shutdown()
pub async fn await_shutdown() {    let ctrl_c = async {        signal::ctrl_c()            .await            .expect("Failed to install Ctrl+C handler");    };    #[cfg(unix)]    let terminate = async {        signal::unix::signal(signal::unix::SignalKind::terminate())            .expect("Failed to install SIGTERM handler")            .recv()            .await;    };    #[cfg(not(unix))]    let terminate = std::future::pending::<()>();    tokio::select! {        _ = ctrl_c => {            info!("received SIGINT, initiating shutdown...");        }        _ = terminate => {            info!("received SIGTERM, initiating shutdown...");        }    }}

Проект создан в образовательных целях, поэтому точно есть, что доработать:

  • сделать более безопасный обмен секретами

  • добавить rate limiter для клиентских подключений

  • улучшить производительность

  • и т.д.

Ссылки

ссылка на оригинал статьи https://habr.com/ru/articles/1058104/