Иногда стандартная схема site-to-site VPN перестаёт быть стандартной уже на первом шаге. Требовалось объединить несколько удалённых площадок в единую маршрутизируемую сеть — но прямой WireGuard между частью узлов работал нестабильно или блокировался на сетевом пути.

Дополнительные требования заметно усложняли задачу: один из узлов работал на достаточно старом Linux, отдельные внутренние серверы должны были быть доступны по специально выделенным виртуальным IP-адресам, а исходящий интернет-трафик одного конкретного сервера требовалось направлять через другой офис. При этом старые входящие подключения через публичный IP Linux-шлюза должны были продолжать работать без изменения адреса источника выхода.

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

Данные вымышлены

Все названия, публичные адреса и внутренние сети заменены. Для публичных IP используются документальные диапазоны RFC 5737, которые не маршрутизируются в реальном интернете.

Исходные условия

УзелПубличный IPЛокальная сетьVPN IP
Router-A192.0.2.1010.20.10.0/24172.20.180.1
Router-B198.51.100.2010.20.20.0/24172.20.180.2
Router-C203.0.113.3010.20.30.0/24
10.20.31.0/24
172.20.180.3
Linux-GW192.0.2.20010.20.40.0/24172.20.180.4

Linux-шлюз имеет WAN 192.0.2.200 и LAN 10.20.40.1. Внутри его сети находятся серверы 10.20.40.3 и 10.20.40.4. Задача — предоставить к ним доступ из других офисов по виртуальным адресам, а сервер 10.20.40.4 вывести в интернет через Router-A.

              Internet
                 │
   ┌─────────────┼─────────────┐
Router-A      Router-B      Router-C
   └───── WireGuard mesh ──────┘
                 │
            IPIP underlay
                 │
             Linux-GW
                 │
           Internal LAN
Общая архитектура: mesh между офисами + IPIP до Linux-шлюза

Почему не просто WireGuard

В обычной ситуации достаточно построить WireGuard между всеми площадками. Но если UDP WireGuard фильтруется или распознаётся на промежуточном участке сети, handshake может вообще не происходить.

Для связи с Linux-шлюзом был выбран другой подход: WireGuard использует в качестве endpoint не публичный IP удалённого устройства, а адрес внутри IPIP-туннеля. Снаружи получается IP protocol 4 — IP-in-IP. Уже внутри него находится UDP-трафик WireGuard.

Ключевая идея

WireGuard кладётся внутрь IPIP. На физическом WAN виден только IP-in-IP (protocol 4), а UDP/51820 работает уровнем ниже — там, где его уже никто не фильтрует.

IPIP как транспортный слой

Для каждого маршрутизатора был создан отдельный IPIP до Linux-GW. Использовались служебные сети 172.31.200.0/30, 172.31.200.4/30, 172.31.200.8/30.

RouterOS — Router-A
/interface ipip
add name=ipip-linux \
    local-address=192.0.2.10 \
    remote-address=192.0.2.200 \
    clamp-tcp-mss=yes \
    keepalive=10s,10

/ip address
add address=172.31.200.1/30 interface=ipip-linux
Linux-GW
ip tunnel add ipip-router-a mode ipip \
    local 192.0.2.200 \
    remote 192.0.2.10 \
    ttl 64

ip addr add 172.31.200.2/30 dev ipip-router-a
ip link set ipip-router-a up
Первый контрольный тест

ping 172.31.200.1 — пока IPIP не работает стабильно, переходить к WireGuard бессмысленно.

WireGuard внутри IPIP

Основная WireGuard-сеть — 172.20.180.0/24. Важное отличие от обычной конфигурации — endpoint. Linux-GW обращается к Router-A не по 192.0.2.10:51820, а по 172.31.200.1:51820. То есть пакет WireGuard сначала маршрутизируется в IPIP.

Linux-GW — wg0
[Interface]
Address = 172.20.180.4/24
ListenPort = 51820
PrivateKey = <PRIVATE_KEY>
MTU = 1380

[Peer]
PublicKey = <ROUTER_A_PUBLIC_KEY>
Endpoint = 172.31.200.1:51820
AllowedIPs = 172.20.180.1/32,10.20.10.0/24
PersistentKeepalive = 25
RouterOS — peer Linux-GW
/interface wireguard peers
add interface=wg-mesh \
    public-key="<LINUX_PUBLIC_KEY>" \
    endpoint-address=172.31.200.2 \
    endpoint-port=51820 \
    allowed-address=172.20.180.4/32 \
    persistent-keepalive=25
  WireGuard UDP/51820
          ↓
     172.31.200.x
          ↓
        IPIP
          ↓
    public network
Инкапсуляция: на WAN прямого UDP/51820 между узлами нет

Полный mesh между офисами

Три RouterOS-площадки могут иметь прямой WireGuard mesh и общаться напрямую. Соединения Linux-GW с каждым из них идут через отдельный IPIP. Так отказ одной площадки не превращает её в обязательный транзитный узел для остальных.

            Router-A
           172.20.180.1
            /        \
           /          \
Router-B ───────────── Router-C
172.20.180.2          172.20.180.3
     \                    /
      \    IPIP + WG     /
       \                /
           Linux-GW
          172.20.180.4
Гибрид: прямой mesh между офисами, IPIP только до Linux-GW

Виртуальные адреса серверов

Следующей задачей было скрыть реальные адреса серверов Linux-площадки. Виртуальные адреса назначаются Linux-GW:

Linux-GW
ip addr add 10.254.100.254/32 dev lo
ip addr add 10.254.100.251/32 dev lo

На удалённых маршрутизаторах эти адреса относятся к peer Linux-GW, и к ним создаются маршруты:

RouterOS
allowed-address=172.20.180.4/32,10.254.100.254/32,10.254.100.251/32

/ip route
add dst-address=10.254.100.254/32 gateway=wg-mesh
add dst-address=10.254.100.251/32 gateway=wg-mesh

Теперь офисный компьютер обращается к 10.254.100.254 вместо реального 10.20.40.3 — знать настоящий адрес сервера ему не требуется.

DNAT виртуального RDP

На Linux-GW трафик к виртуальным адресам разворачивается на реальные серверы:

iptables — nat
iptables -t nat -A PREROUTING -i wg0 \
    -d 10.254.100.254 -p tcp --dport 3389 \
    -j DNAT --to-destination 10.20.40.3:3389

iptables -t nat -A PREROUTING -i wg0 \
    -d 10.254.100.251 -p tcp --dport 3389 \
    -j DNAT --to-destination 10.20.40.4:3389

# для RDP имеет смысл пропустить и UDP 3389
iptables -t nat -A PREROUTING -i wg0 \
    -d 10.254.100.251 -p udp --dport 3389 \
    -j DNAT --to-destination 10.20.40.4:3389

В итоге пользователь просто вводит 10.254.100.251, а реальный адрес сервера остаётся скрыт.

Отдельный интернет-выход для одного сервера

Самая интересная часть. Сервер 10.20.40.4 должен выходить в интернет через Router-A, но весь Linux-GW и остальные серверы должны продолжать использовать обычный WAN. То есть нельзя просто заменить default route.

Для этого создан второй WireGuard — wg-exit — со своей сетью 172.20.181.0/30. Он тоже идёт внутри уже существующего IPIP Router-A ↔ Linux-GW. Отдельный интерфейс чётко разделяет функции: wg0 — межофисная связь, wg-exit — только интернет-трафик выбранного сервера.

Linux-GW — wg-exit
[Interface]
Address = 172.20.181.2/30
PrivateKey = <PRIVATE_KEY>
ListenPort = 51821
MTU = 1380
Table = off

[Peer]
PublicKey = <ROUTER_A_EXIT_PUBLIC_KEY>
Endpoint = 172.31.200.1:51821
AllowedIPs = 0.0.0.0/0
PersistentKeepalive = 25
Table = off — критично

Без этого параметра wg-quick с AllowedIPs = 0.0.0.0/0 автоматически перехватит основной default route Linux-GW и уронит весь остальной трафик шлюза.

Policy routing

Для специального маршрута создаётся отдельная таблица. В /etc/iproute2/rt_tables добавляется 251 exit-a, внутри неё — default route через выходной туннель, а трафик сервера направляется по источнику:

Linux — policy routing
ip route add default dev wg-exit table exit-a

ip rule add priority 200 \
    from 10.20.40.4/32 \
    lookup exit-a

Для внешних ресурсов трафик сервера 10.20.40.4 теперь выглядит как исходящий с публичного IP Router-A.

Проблема асимметрии

У Linux-GW уже существовал старый внешний DNAT 192.0.2.200:11514 → 10.20.40.4:3389. Клиент подключается на публичный IP Linux-GW, DNAT разворачивает пакет на сервер. Но ответный пакет имеет source 10.20.40.4, а policy rule говорит «from 10.20.40.4 → exit-a». Поэтому SYN-ACK уходит через Router-A, с другого публичного IP — и TCP-соединение не устанавливается.

Запрос:  client ──→ 192.0.2.200 (Linux-GW WAN)
                        │ DNAT
                        ▼
                   10.20.40.4

Ответ:   10.20.40.4 ──→ Router-A ──→ другой public IP  ✗
Асимметрия: ответ уходит не тем путём, каким пришёл запрос

Решение: connection mark

Входящие соединения через физический WAN Linux-GW нужно отличать от обычного исходящего трафика сервера. При входе помечаем connection, для ответов восстанавливаем mark и даём ему более приоритетное правило маршрутизации:

iptables — mangle + ip rule
# помечаем входящее соединение через WAN
iptables -t mangle -I PREROUTING 1 -i eth-wan \
    -d 192.0.2.200 -p tcp --dport 11514 \
    -m conntrack --ctstate NEW \
    -j CONNMARK --set-mark 0x1

# восстанавливаем mark на ответных пакетах
iptables -t mangle -I PREROUTING 2 -i br0 \
    -s 10.20.40.4 \
    -m conntrack --ctstate ESTABLISHED,RELATED \
    -j CONNMARK --restore-mark

# помеченный трафик идёт в main (физический WAN)
ip rule add priority 50 fwmark 0x1 lookup main

Теперь обычный новый трафик 10.20.40.4 идёт в exit-a, а ответ на соединение, пришедшее через WAN Linux-GW, — в main, то есть обратно через физический WAN.

Порядок правил policy routing

Важно также гарантировать, что межофисный трафик не уйдёт через exit-туннель. Полная последовательность правил, от высшего приоритета к низшему:

Linux — ip rule (итоговый порядок)
40   from 10.20.40.4 to 198.51.100.100  → main   # админ-исключение
50   fwmark 0x1                          → main   # ответы DNAT
100  to 10.20.10.0/24                    → main   # офисные сети
101  to 10.20.20.0/24                    → main
102  to 10.20.30.0/24                    → main
103  to 10.20.31.0/24                    → main
105  to 172.20.180.0/24                  → main   # WG mesh
106  to 172.31.200.0/24                  → main   # IPIP
107  to 10.254.100.0/24                  → main   # виртуальные IP
200  from 10.20.40.4                     → exit-a # интернет-выход

Такая последовательность одновременно сохраняет старые входящие сервисы и меняет обычный исходящий интернет-маршрут сервера.

rp_filter и forwarding

При policy routing и потенциально асимметричных путях Linux Reverse Path Filtering может начать уничтожать вполне корректные пакеты. Поэтому его отключаем, а forwarding — включаем:

sysctl
net.ipv4.conf.all.rp_filter=0
net.ipv4.conf.default.rp_filter=0
net.ipv4.ip_forward=1

NAT на выходном маршрутизаторе

Router-A получает через wg-exit пакет src=10.20.40.4, dst=8.8.8.8. Обычный masquerade на WAN превращает source в 192.0.2.10, поэтому внешний сервис видит публичный IP Router-A:

RouterOS — Router-A
/ip firewall nat
add chain=srcnat action=masquerade out-interface-list=WAN

Проверка и диагностика

Для forwarded-трафика удобно проверять реальный путь пакета, а не только handshake:

Linux — проверка маршрута
# должен уйти через wg-exit:
ip route get 8.8.8.8 from 10.20.40.4 iif br0
#   → dev wg-exit, table exit-a

# офисный адрес — через wg0:
ip route get 10.20.10.1 from 10.20.40.4 iif br0
#   → dev wg0
Linux — tcpdump
# прямого WireGuard на WAN быть НЕ должно:
tcpdump -ni eth-wan udp port 51820

# зато виден IPIP:
tcpdump -ni eth-wan 'ip proto 4'

# а внутри IPIP — уже WireGuard:
tcpdump -ni ipip-router-a udp port 51820

Восстановление после reboot

Настроить всё командами ip и iptables недостаточно: после перезагрузки большая часть конфигурации исчезнет. Постоянными нужно сделать IPIP-интерфейсы, оба WireGuard, виртуальные /32-адреса, ip_forward, rp_filter, правила iptables, отдельную routing table, ip rule и keepalive.

Для WireGuard удобно использовать сервисы wg-quick@wg0.service и wg-quick@wg-exit.service, а policy routing поднимать через хуки — чтобы правила появлялись только после появления соответствующего интерфейса:

wg-quick — hooks
PostUp  = /usr/local/sbin/exit-policy-up.sh
PreDown = /usr/local/sbin/exit-policy-down.sh

Что получилось в итоге

          ┌──── Router-A ────┐
          │     WG mesh       │
Router-B ─┼──────────── Router-C
          │
          │ WireGuard внутри IPIP
          ▼
       Linux-GW
          │
   ┌──────┴──────┐
10.20.40.3   10.20.40.4
   │             │
   │             └─ wg-exit → Router-A → Internet
   │
   └─ virtual IP (10.254.100.x)
Финальная логическая схема

Пользователи работают с виртуальными адресами 10.254.100.254 и 10.254.100.251 и не зависят от реальной адресации серверной площадки. Сервер 10.20.40.4 использует Router-A как интернет-шлюз, но старые подключения через публичный IP Linux-GW продолжают возвращаться через Linux-GW.

Основные выводы

  • IPIP можно использовать как underlay для WireGuard, если прямой UDP WireGuard не проходит.
  • WireGuard endpoint при этом указывается как внутренний адрес IPIP, а не публичный IP.
  • Для нескольких площадок удобно сохранять полноценный WireGuard mesh, а IPIP применять только там, где он действительно необходим.
  • Виртуальные /32-адреса отделяют адрес, используемый пользователями, от реального адреса сервера.
  • Для отдельного интернет-выхода одного хоста лучше использовать отдельный WireGuard-интерфейс и policy routing.
  • Table = off не отдаёт wg-quick управление основным default route.
  • CONNMARK + ip rule fwmark решают проблему возврата DNAT-соединений при source-based policy routing.
  • При сложной маршрутизации необходимо учитывать rp_filter.
  • Проверять нужно не только handshake WireGuard, но и реальный путь пакета через tcpdump, ip rule, ip route get и счётчики iptables.
  • После настройки обязательно проверить восстановление всей схемы после reboot.

Главная ценность такой архитектуры в том, что уровни практически не зависят друг от друга. IPIP решает задачу доставки WireGuard-трафика. WireGuard формирует защищённую маршрутизируемую сеть. Виртуальные IP скрывают реальную адресацию сервисов. Policy routing управляет тем, через какую площадку конкретный сервер выходит в интернет. В результате сложная физическая сеть для пользователей и серверов выглядит как единая обычная IP-сеть.