Некоторое время назад я обнаружил, что мой доселе чистый и светлый интернет стал подвержен проблеме, которую лично я охарактеризовал как "подзатупы". Суть "подзатупов" заключается в сериях из пауз передачи пакетов длиной в 1-4 секунды (в отдельных случаях - до 10 секунд), время от времени происходящих на протяжении дня. Поскольку работать с SSH в таких условиях не очень-то комфортно, я решил переключиться на мобильный интернет от другого провайдера. Однако, оказалось, что теперь данная проблема и там имеет место быть:
$ ping 8.8.8.8
64 bytes from 8.8.8.8: icmp_seq=1 ttl=115 time=135 ms
64 bytes from 8.8.8.8: icmp_seq=2 ttl=115 time=70.3 ms
64 bytes from 8.8.8.8: icmp_seq=3 ttl=115 time=558 ms
64 bytes from 8.8.8.8: icmp_seq=4 ttl=115 time=2581 ms
64 bytes from 8.8.8.8: icmp_seq=5 ttl=115 time=1553 ms
64 bytes from 8.8.8.8: icmp_seq=6 ttl=115 time=529 ms
64 bytes from 8.8.8.8: icmp_seq=7 ttl=115 time=210 msДобавлял фрустрации тот факт, что после каждого переключения каналов мне приходилось заново переустанавливать все SSH и SFTP соединения. Хоть я и пользуюсь SSH-ключами там, где это возможно (и менеджером паролей там, где это малореально), тем не менее, мороки было немало. В принципе, на этом месте можно было бы начать затевать смену провайдера, либо прицениваться к местным "серым" продавцам Starlink. Однако, нет никакой гарантии, что у другого провайдера не будет этой же проблемы, а Starlink еще надо ухитриться затащить на крышу многоквартирного дома. Да и как раз на днях местный регулятор грозился что всех какбэ покарае, если только Маск вдруг не получит у него лицензию на оказание услуг связи.
Повыдирав волосы некоторое время, в мою голову пришла идея: а что, если дублировать IP-пакеты через оба сетевых интерфейса? Поскольку "подзатупы" разных провайдеров не коррелируют между собой, то даже если один из провайдеров в данный момент "тупит", то хотя бы одна из копий все равно наверняка должна добраться. А даже если в моменте все хорошо, мы хотя бы снижаем RTT до минимального по обоим провайдерам.
Очевидным тут является тот факт, что без внешнего сервера ничего не "выгорит": поскольку в TCP/IP используется так называемый 5-tuple для идентификации TCP-соединений, если мы будем менять source address в процессе, то ни один внешний хост нас не поймет. Поэтому я вз��л VDS в датацентре с пингом получше, и принялся за дело.
Смахиваем пыль с TUN/TAP
Я решил пойти путем, отработанным многочисленными VPN-клиентами. Мы создадим виртуальное сетевое устройство, который будет инкапсулировать приходящие IP-пакеты в UDP, и реплицировать их на VDS-ку через каждый из внешних сетевых интерфейсов. В свою очередь, серверная часть на VDS-ке будет отбрасывать "лишние" пакеты - но запоминать, с каких IP и портов они пришли, и форвардить пакеты туда. А в целях аутентификации (ну и дополнительной прослойки безопасности) мы будем шифровать исходящие пакеты по схеме AES256-GCM.
Как известно, TUN/TAP фактически разделен на 2 разных части - а именно, собственно, TUN и TAP. Обе из них реализуют концепцию виртуального сетевого устройства - однако, TUN оперирует IP-пакетами (таким образом, работая на 3-м уровне модели OSI), а TAP - Ethernet-фреймами (что соответствует 2-му уровню). Поскольку работать с Ethernet-фреймами было бы для нас избыточно, в дальнейшем речь будет идти именно о TUN, но не о TAP.
И так, добавляем крейт tun-rs, а так же pnet_packet для парсинга пакетов:
$ cargo new hello-tun && cd hello-tun/
$ cargo add tun-rs pnet_packetВ main.rs создаем виртуальное сетевое устройство и логируем поступающие из сетевого стека ОС пакеты. Для простоты сразу задаем IP-адрес, подсеть и MTU:
use tun_rs::DeviceBuilder;
use pnet_packet::ipv4::Ipv4Packet;
fn main() -> std::io::Result<()> {
let dev = DeviceBuilder::new()
.name("hellotun0")
.mtu(1500)
.ipv4("10.199.0.2", 24, None)
.build_sync()?;
println!("Created TUN interface: {}", dev.name()?);
let mut buf = vec![0u8; 65535];
loop {
let n = dev.recv(&mut buf)?;
println!("Packet bytes: {:?}", &buf[0..n]);
let Some(packet) = Ipv4Packet::new(&buf[0..n]) else { continue };
println!("Parsed packet: {:?}", packet);
}
}Далее запускаем и пробуем пропинговать любой IP-адрес в подсети 10.199.0.0/24 - кроме нашего локального адреса 10.199.0.2. Если все хорошо, то в stdout должны появиться наши логи. В качестве небольшого proof of concept можно добавить ответы на пинги:
loop {
let n = dev.recv(&mut buf)?;
println!("Packet bytes: {:?}", &buf[0..n]);
let Some(mut packet) = MutableIpv4Packet::new(&mut buf[0..n]) else { continue };
println!("Parsed packet: {:?}", packet);
let src = packet.get_source();
let dst = packet.get_destination();
packet.set_source(dst);
packet.set_destination(src);
dev.send(&buf[..n])?;
}Теперь мы можем пропинговать любой из адресов нашей виртуальной подсети - и нам будет возвращен корректный ответ.
Мучаем ChatGPT
На этом этапе я решил скормить собранные требования в качестве промпта ChatGPT. Помучав гптшку некоторое время, мне удалось получить компилирующийся и почти рабочий вариант. Вкратце разберем некоторые основные моменты выданного ChatGPT решения:
Протокол максимально простой: каждый UDP-пакет состоит из 12-байтного nonce и следующего за ним зашифрованного IP-пакета. Какие бы то ни было служебные команды и/или машины состояний полностью отсутствуют
Для отбрасывания "лишних" пакетов ChatGPT решил использовать DefaultHasher из стандартной библиотеки. Пусть остается так - хотя криптографически стойкие хэши в данном случае и не то, чтобы требуются, испытания показали, что наша производительность является более чем приемлимой. Поэтому я не стал дергаться и пытаться подключить более быстрые ahash или fxhash. В принципе, можно было быть еще эффективнее и вместо хэшей использовать sequence number из TCP-заголовка - но и ходить тогда будет только TCP.
Серверная и клиентская части кода фактически представляют из себя полный копипаст друг друга (WET) - хотя отличаются, по сути, только стратегией отправки UDP-пакетов: клиент реплицирует пакеты на IP-адрес внешнего сервера через каждый из прописанных в конфиге сетевых интерфейсов, а внешний сервер - на все IP-адреса, с которых ему приходили успешно аутентифицированные пакеты. Мне так и не удалось заставить ChatGPT зарефакторить этот момент, не сломав при этом все. Поэтому "осушаем" вручную
Для чтения из UDP-сокета использовалась весьма противная схема с переводом в неблокирующий режим с последующим опросом опросом раз в n миллисекунд - в клиентском копипасте кода, к тому же, оказавшаяся неработоспособной
Запускаем
Отполировав эти а так же еще некоторые моменты, я решил попробовать установить свое первое соединение. Прежде всего генерируем ключ для шифрования на сервере:
$ tuxburst gen-key
[2026-01-01 16:03:07][INFO] AES-256 key generated: 5543E0FF97FB85B2A5033043DF53FDA62C8B907AAF5FEB0F0C3C89F162C64F17
[2026-01-01 16:03:07][INFO] Wrote server.keyЗаполняем client.toml и server.toml:
[common]
tun_name = "tuxburst0"
tun_addr = "10.99.0.2/30"
mtu = 1400
debug = false
[client]
host = "1.2.3.4:40000"
key = "5543E0FF97FB85B2A5033043DF53FDA62C8B907AAF5FEB0F0C3C89F162C64F17"
interfaces = ["wlo1", "enxf221672bb6de"]
[client.routes]
tun_gateway = "10.99.0.1"
metric = 5[common]
tun_addr = "10.99.0.1/30"
tun_name = "tuxburst0"
debug = false
mtu = 1400
[server]
listen = "0.0.0.0:40000"Настраиваем NAT на сервере и запускаем процессы на обоих концах:
$ sysctl -w net.ipv4.ip_forward=1
$ iptables -t nat -A POSTROUTING -o ens3 -j MASQUERADE
$ iptables -A FORWARD -i tuxburst0 -o ens3 -j ACCEPT
$ iptables -A FORWARD -i ens3 -o tuxburst0 -m state --state RELATED,ESTABLISHED -j ACCEPTserver$ sudo tuxburst serve -c server.toml
client$ sudo tuxburst connect -c client.tomlПри старте мы автоматически добавляем правило route, перенаправляющиее весь трафик на гейтвей в нашей виртуальной сети по дефолту. Поскольку наше правило имеет наименьший metric, оно превалирует над аналогичными правилами, полученными при получении настроек по DHCP для остальных каналов. И наоборот - поскольку роут ассоциирован с нашим виртуальным сетевым устройством, при завершении процесса он будет "бесплатно" подчищен операционной системой - и, таким образом, прежний роутинг будет восстановлен:
$ route
Kernel IP routing table
Destination Gateway Genmask Flags Metric Ref Use Ifa
default _gateway 0.0.0.0 UG 5 0 0 tuxburst0
default _gateway 0.0.0.0 UG 100 0 0 enx62c0701601f2
default wifi-router 0.0.0.0 UG 600 0 0 wlo1
10.12.7.0 0.0.0.0 255.255.255.0 U 100 0 0 enx62c0701601f2
10.99.0.0 0.0.0.0 255.255.255.252 U 0 0 0 tuxburst0
172.17.0.0 0.0.0.0 255.255.0.0 U 0 0 0 docker0
192.168.3.0 0.0.0.0 255.255.255.0 U 600 0 0 wlo1Теперь самое время проверить соединение, запустив curl https://api.ipify.org. Как мы видим, возвращаемый IP действительно равен внешнему IP нашей внешней VDS-ки. Эксперименты с попарным отключением каждого из каналов показали устойчивость связи - а стало быть, схема работает.
Итоги
Опыт эксплуатации в течение недели показал, что мои волосы стали мягкими и шелковистыми проблема полностью решена. Я рад, что в итоге мне не потребовалось затевать смену провайдера, равно как и возиться с установкой Starlink на крышу многоквартирного дома. Отдельно бы хотелось отметить то, что TCP-соединения не прерываются даже если получилось так, что мне пришлось перезапустить процесс - поскольку мы не храним никаких состояний, которые могли бы негативно повлиять на существующие коннекты.
Если данный проект мог бы быть полезен еще кому-либо, или просто интересно глянуть код - вот ссылка на репозиторий: https://github.com/vdudouyt/tuxburst/
