Стабильная работа туннеля: anti-RST, --fix-gro, QUIC-блок, автозагрузка

Проверено на живом стенде: 0% потерь на 10.66.66.1 и 8.8.8.8, выход через
сервер (curl -> 95.174.102.77).

Транспорт (устранение разрывов faketcp):
- anti-RST вписан в конфиги вместо хрупкого -a+flush:
  клиент — первой строкой chain input (ip saddr 95.174.102.77 tcp sport 25565 drop),
  сервер — в таблице wgfwd (ip daddr 192.168.31.163 tcp dport 25565 drop, переживает ufw reload).
  Причина: RST от ядра убивал CGNAT-маппинг оператора -> direction timeout и смена порта.
- --fix-gro на обоих концах: чинит дроп GRO «супер-пакетов» (huge packet > max_data_len).

Клиент:
- nftables.conf: flush ruleset (убирает дубли), reject QUIC/UDP 443 (откат браузера на TCP).
- udp2raw-client.service: After=nftables.service (anti-RST применяется до старта udp2raw).

Автозагрузка/порядок (обе службы udp2raw):
- After=network-online.target, Before=wg-quick@wg0.service.

README: раздел 5 «Известные нюансы» (anti-RST vs flush, CGNAT/RST, GRO),
сниппеты и таблицы приведены в соответствие.
This commit is contained in:
Maxim
2026-07-08 11:35:46 +03:00
parent 888fd86dfa
commit 538414f2c8
5 changed files with 67 additions and 21 deletions

View File

@@ -1,6 +1,6 @@
# Схема WireGuard + udp2raw
Проект содержит конфигурации для развёртывания отказоустойчивого VPN-туннеля между **удалённым сервером (Ubuntu 24.04)** и **Raspberry Pi 4**.
Проект содержит конфигурации для развёртывания VPN-туннеля между **удалённым сервером (Ubuntu 24.04)** и **Raspberry Pi 4**.
Решение маскирует UDP-трафик WireGuard под обычные TCP-пакеты (режим `faketcp`), что позволяет обходить ограничения провайдеров, блокирующих или урезающих UDP на интерфейсе `usbnet`.
@@ -32,7 +32,7 @@
- Downstream: `wlan0 = 192.168.77.0/24` — трафик клиентов уходит в интернет через туннель.
- Прочие интерфейсы (`eth0 10.1.1.33`, `eth1 192.168.2.10`) в схеме VPN не участвуют.
### ⚠️ Обязательные предусловия окружения
### Обязательные предусловия окружения
| # | Требование | Где | Проверка |
|---|---|---|---|
@@ -113,6 +113,9 @@ PostUp = nft add chain ip wgfwd postrouting { type nat hook postrouting priority
PostUp = nft add rule ip wgfwd postrouting ip saddr 10.66.66.0/24 oifname "enp2s0" masquerade
PostUp = nft add chain ip wgfwd mangle { type filter hook forward priority -150 \; policy accept \; }
PostUp = nft add rule ip wgfwd mangle oifname "wg0" tcp flags syn tcp option maxseg size set rt mtu
# udp2raw anti-RST (дубль к флагу -a, переживает ufw reload). Матч по своему IP+порту.
PostUp = nft add chain ip wgfwd prerst { type filter hook input priority -300 \; policy accept \; }
PostUp = nft add rule ip wgfwd prerst ip daddr 192.168.31.163 tcp dport 25565 drop
PostDown = nft delete table ip wgfwd
[Peer]
@@ -131,7 +134,7 @@ After=network.target
Type=simple
User=root
# Слушать КОНКРЕТНЫЙ IP 192.168.31.163 (на 0.0.0.0 — bind fail из-за docker-бриджей)
ExecStart=/usr/local/bin/udp2raw -s -l 192.168.31.163:25565 -r 127.0.0.1:51820 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp -a
ExecStart=/usr/local/bin/udp2raw -s -l 192.168.31.163:25565 -r 127.0.0.1:51820 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp --fix-gro -a
Restart=always
RestartSec=5
@@ -186,7 +189,11 @@ sudo nft -f /etc/nftables.conf # применяется с flush (без д
Ключевые строки, которые должны присутствовать:
```nft
# table inet filter, chain input — ПЕРВЫМ правилом (выше ct-accept):
ip saddr 95.174.102.77 tcp sport 25565 drop # udp2raw anti-RST
# table inet filter, chain forward:
iifname "wlan0" oifname "wg0" udp dport 443 reject # блок QUIC → откат на TCP
iifname "wlan0" oifname "wg0" accept
iifname "wg0" oifname "wlan0" ct state established,related accept
iifname "wlan0" oifname "wg0" tcp flags syn tcp option maxseg size set rt mtu # MSS-clamp
@@ -195,7 +202,7 @@ iifname "wlan0" oifname "wg0" tcp flags syn tcp option maxseg size set rt mtu
ip saddr 192.168.77.0/24 oifname "wg0" masquerade
```
> `nft -f` применяет файл с очисткой (flush) — это важно, чтобы не накапливались дубли правил при повторном запуске.
> **`flush ruleset` + anti-RST.** Файл начинается с `flush ruleset` (убирает дубли при повторном `nft -f`). Но flush сносит и правило, которое udp2raw ставит флагом `-a` (drop входящего faketcp, иначе ядро шлёт RST и туннель рвётся при живом хендшейке). Поэтому anti-RST вписан прямо в `chain input` **первой строкой** — тогда `nft -f` атомарно восстанавливает его, и порядок применения перестаёт быть хрупким.
### Шаг 3.4. Конфигурации
@@ -229,7 +236,7 @@ After=network.target
[Service]
Type=simple
User=root
ExecStart=/usr/local/bin/udp2raw -c -l 127.0.0.1:51821 -r 95.174.102.77:25565 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp -a
ExecStart=/usr/local/bin/udp2raw -c -l 127.0.0.1:51821 -r 95.174.102.77:25565 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp --fix-gro -a
Restart=always
RestartSec=5
@@ -263,18 +270,19 @@ sudo wg show
## 5. Известные нюансы и тюнинг
- **Автозагрузка + порядок.** Для боевого режима на обоих концах: `sudo systemctl enable udp2raw-<role> wg-quick@wg0`. Чтобы после ребута WG стартовал строго за udp2raw, добавить drop-in `sudo systemctl edit wg-quick@wg0`:
```ini
[Unit]
After=udp2raw-client.service
Wants=udp2raw-client.service
- **⚠️ anti-RST vs `flush ruleset` (главная ловушка).** udp2raw в faketcp работает через AF_PACKET, а TCP-стек ядра видит «левые» пакеты на транспортном порту и шлёт **RST**. На мобильном канале это фатально вдвойне: RST уходит через CGNAT оператора → оператор считает TCP-поток закрытым и **убивает NAT-трансляцию** → пакеты клиент↔сервер начинают дропаться → udp2raw ловит `client-->server direction timeout` и переустанавливает faketcp **с новым source-портом** (виден в логе: `using port 14050`, `47011`…). Итог: туннель «поднят» (хендшейк WG есть), но данные идут пачками с 30100% потерь, у выживших идеальный RTT. Флаг `-a` гасит это drop-правилом при старте udp2raw — **но `flush ruleset` его сносит**, и любой `nft -f` на живом туннеле убивает связь. Поэтому anti-RST вписан прямо в конфиги (идемпотентно к flush):
- клиент — первой строкой `chain input`: `ip saddr 95.174.102.77 tcp sport 25565 drop`;
- сервер — в таблице `wgfwd` (PostUp): `ip daddr 192.168.31.163 tcp dport 25565 drop` (переживает `ufw reload`).
Флаг `-a` в службах оставлен как подстраховка (закрывает окно до применения nft-правил). Порядок на клиенте: `nftables.service``udp2raw-client` (задано `After=nftables.service`; убедитесь, что `nftables.service` включён: `systemctl is-enabled nftables`).
- **GRO / «huge packet ... dropped».** Если в логе udp2raw мелькает `huge packet, data_len NNNN > 1800(max_data_len) dropped` — стек склеивает входящие faketcp-сегменты в «супер-пакеты» больше `max_data_len`, и udp2raw их роняет (потери на крупном трафике). Лечится флагом `--fix-gro` в обеих службах (уже добавлен в `.service`). Альтернатива — выключить GRO на аплинке: `sudo ethtool -K usbnet gro off` (на сервере — `enp2s0`).
- **Автозагрузка + порядок.** Порядок уже зашит в юниты udp2raw (`Before=wg-quick@wg0.service` + `After=network-online.target nftables.service`), отдельный drop-in не нужен. Для боевого режима достаточно включить обе службы на каждом конце:
```bash
sudo systemctl enable udp2raw-server wg-quick@wg0 # на сервере
sudo systemctl enable udp2raw-client wg-quick@wg0 # на клиенте
```
(на сервере — `udp2raw-server.service`).
После ребута udp2raw гарантированно поднимется раньше туннеля.
- **«Не все сайты открываются» при рабочем туннеле** — почти всегда MTU/PMTUD:
- Многие сайты используют HTTP/3 (QUIC поверх UDP 443), который **не подчиняется MSS-clamp**. Лечится либо снижением MTU туннеля (пробовать `1200 → 1160 → 1120` на обоих концах симметрично), либо блокировкой QUIC, чтобы браузер откатывался на TCP:
```nft
# в chain forward клиента, до accept-правил wg0:
iifname "wlan0" oifname "wg0" udp dport 443 reject
```
- Многие сайты используют HTTP/3 (QUIC поверх UDP 443), который **не подчиняется MSS-clamp**. В `client/nftables.conf` уже добавлено правило `iifname "wlan0" oifname "wg0" udp dport 443 reject` — браузер откатывается на TCP/HTTPS (который зажат MSS-clamp'ом и пролезает). Если этого мало — симметрично снижать MTU туннеля (`1200 → 1160 → 1120` на обоих концах).
- Проверка pMTU напрямую: с клиента `ping -M do -s 1172 10.66.66.1` (1172+28=1200). Если не проходит — MTU занижаем.
- **Дубли правил nftables.** Если `nft list ruleset` показывает удвоенные строки — файл применяли `nft -f` поверх без flush. Лечится одним `sudo nft -f /etc/nftables.conf` со свежего файла (он начинается с flush) или ребутом.

View File

@@ -1,4 +1,7 @@
#!/usr/sbin/nft -f
# RASPBERRY PI 4: /etc/nftables.conf
# flush обязателен: без него повторный `nft -f` накапливает дубли правил.
flush ruleset
table ip nat {
chain postrouting {
@@ -11,6 +14,15 @@ table ip nat {
table inet filter {
chain input {
type filter hook input priority filter; policy drop;
# udp2raw faketcp anti-RST (КРИТИЧНО, должно стоять ПЕРВЫМ — выше ct-accept):
# обратный faketcp от сервера попадает под ct established и доходит до TCP-стека
# ядра, а тот шлёт RST на «левый» TCP -> шторм RST -> туннель встаёт, но данные
# не идут. Прячем эти пакеты от стека; udp2raw читает их через AF_PACKET (до
# netfilter), поэтому drop ему не мешает. Аналог правила, которое ставит флаг -a.
# Держим правило ЗДЕСЬ (а не через -a), чтобы flush ruleset его не стирал.
ip saddr 95.174.102.77 tcp sport 25565 drop
ct state established,related accept
iifname "lo" accept
ip protocol icmp accept
@@ -28,6 +40,11 @@ table inet filter {
iifname "wlan0" oifname "usbnet" accept
iifname "usbnet" oifname "wlan0" ct state established,related accept
# Блокируем QUIC/HTTP-3 (UDP 443) в туннель: он НЕ подчиняется MSS-clamp,
# крупные датаграммы теряются при MTU 1200 и сайты «висят».
# reject (а не drop) → браузер сразу откатывается на TCP/HTTPS. Ставить ДО accept.
iifname "wlan0" oifname "wg0" udp dport 443 reject
iifname "wlan0" oifname "wg0" accept
iifname "wg0" oifname "wlan0" ct state established,related accept

View File

@@ -1,13 +1,21 @@
[Unit]
Description=udp2raw Client (FakeTCP)
After=network.target
# network-online (а не network.target): нужен поднятый uplink usbnet до старта.
Wants=network-online.target
# nftables.service должен примениться ДО udp2raw: в /etc/nftables.conf лежит anti-RST
# правило (ip saddr 95.174.102.77 tcp sport 25565 drop). Требует enabled nftables.service.
After=network-online.target nftables.service
# Порядок автозагрузки: udp2raw поднимается ДО туннеля WireGuard (Endpoint=127.0.0.1:51821).
Before=wg-quick@wg0.service
[Service]
Type=simple
User=root
# Слушает локальный UDP-порт 51821 и транслирует в TCP на внешний вход сервера 95.174.102.77:25565
# ВАЖНО: Пароль в параметре -k должен строго совпадать с паролем на сервере!
ExecStart=/usr/local/bin/udp2raw -c -l 127.0.0.1:51821 -r 95.174.102.77:25565 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp -a
# --fix-gro: стек склеивает входящие faketcp-сегменты в «супер-пакеты» >1800,
# udp2raw их дропает ("huge packet ... dropped") — флаг это чинит.
ExecStart=/usr/local/bin/udp2raw -c -l 127.0.0.1:51821 -r 95.174.102.77:25565 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp --fix-gro -a
Restart=always
RestartSec=5

View File

@@ -1,6 +1,11 @@
[Unit]
Description=udp2raw Server (FakeTCP)
After=network.target
# network-online (а не network.target): нужен реально поднятый enp2s0 с адресом
# 192.168.31.163, иначе bind на конкретный IP упадёт при загрузке.
Wants=network-online.target
After=network-online.target
# Порядок автозагрузки: udp2raw поднимается ДО туннеля WireGuard.
Before=wg-quick@wg0.service
[Service]
Type=simple
@@ -12,7 +17,8 @@ User=root
# иначе из-за множества docker-бриджей udp2raw падает с "bind fail".
# Если LAN-адрес сервера сменится — поправить здесь И в пробросе на роутере.
# ВАЖНО #2: Замените "MY_CONFIDENTIAL_PASS" на ваш секретный пароль для udp2raw
ExecStart=/usr/local/bin/udp2raw -s -l 192.168.31.163:25565 -r 127.0.0.1:51820 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp -a
# --fix-gro: чинит дроп «супер-пакетов» GRO (huge packet > max_data_len). Ставим на обоих концах.
ExecStart=/usr/local/bin/udp2raw -s -l 192.168.31.163:25565 -r 127.0.0.1:51820 -k "MY_CONFIDENTIAL_PASS" --raw-mode faketcp --fix-gro -a
Restart=always
RestartSec=5

View File

@@ -22,6 +22,13 @@ PostUp = nft add rule ip wgfwd postrouting ip saddr 10.66.66.0/24 oifname "enp2s
# чтобы large-TCP не «залипал» из-за MTU 1200 и заблокированного PMTUD.
PostUp = nft add chain ip wgfwd mangle { type filter hook forward priority -150 \; policy accept \; }
PostUp = nft add rule ip wgfwd mangle oifname "wg0" tcp flags syn tcp option maxseg size set rt mtu
# udp2raw faketcp anti-RST (дубль к флагу -a): прячем входящий транспорт от TCP-стека,
# иначе ядро шлёт RST на faketcp -> туннель рвётся. Матч по СВОЕМУ IP+порту (стабильно,
# не зависит от динамического мобильного IP клиента). Приоритет -300 = раньше ufw/docker.
# udp2raw читает через AF_PACKET (до netfilter), так что drop ему не мешает.
# В своей таблице wgfwd — переживает ufw reload (в отличие от правила -a в table ip filter).
PostUp = nft add chain ip wgfwd prerst { type filter hook input priority -300 \; policy accept \; }
PostUp = nft add rule ip wgfwd prerst ip daddr 192.168.31.163 tcp dport 25565 drop
PostDown = nft delete table ip wgfwd
[Peer]