Пакет контейнера никогда не доходит до tun0 , и дополнительные возможности контейнера этого не изменят. Между гостем и туннелем стоят два поиска маршрута: один внутри сетевого пространства имён гостя, один на хосте после того, как кадр попадёт на мост, и второй обращается к другой таблице с другими правилами совпадения. Моя эталонная схема — хост Linux с OpenVPN клиентом, который держит 10.8.0.10 на tun0 , мостом Docker на 172.18.0.0/16 , и приватным адресом, до которого может добраться только сторона туннеля. Цель остаётся узкой: эта гостевая подсеть выходит через туннель, а хост сохраняет обычный uplink.

Запускать VPN-клиент внутри контейнера — другая архитектура , а не обходной путь. Тогда контейнер владеет своим туннелем и учётными данными, а работа переезжает в Dockerfile вместо того, чтобы оставаться в iptables.

Пересылка выбирает маршрут по назначению, затем NAT переписывает источник

Если задан net.ipv4.ip_forward=1 , пересылаемый пакет маршрутизируется только по назначению, в главной таблице. Источник не играет в этом решения никакой роли. Только после выбора маршрута ядро запускает POSTROUTING , и именно там стоит собственное правило Docker: -s 172.18.0.0/16 ! -o br-appnet -j MASQUERADE . Всё, что покидает мост в сторону другого интерфейса, транслируется, и адрес, которым оно становится, зависит от интерфейса, куда его отправил маршрут.

Это правило объясняет большинство симптомов, о которых меня спрашивают. Если в главной таблице нет маршрута к приватному назначению, пакет уходит через uplink с физическим адресом хоста. Провайдер отбрасывает назначение, или удалённый сайт отбрасывает источник RFC 1918, которого не ждал, и контейнер получает тайм-аут, пока туннель стоит и здоров. Запись на tun0 ничего не показывает, и это признак. Если маршрут всё же указывает на tun0 , MASQUERADE переписывает источник на 10.8.0.10 , и тот же контейнер работает, не трогая ничего.

Запросите поиск, который называет источник гостя. Без from и iif хост отвечает за свой собственный сокет и сообщает путь, которым пересылаемый пакет никогда не пойдёт:

ip route get 192.168.11.222 from 172.18.0.5 iif br-appnet

Если этот ответ называет uplink, туннель не в игре, и никакая настройка на стороне туннеля не поможет.

Перенесите гостя на собственный адрес туннеля

Наименьшее работающее изменение — явная перезапись источника на выходе. Вставьте её, а не добавляйте: Docker уже сидит во главе POSTROUTING , поэтому правило, добавленное через -A , оценивается после правила Docker и никогда не срабатывает.

iptables -t nat -I POSTROUTING 1 -s 172.18.0.0/16 -o tun0 -j SNAT --to-source 10.8.0.10

Два условия решают, удержится ли это. Адрес должен быть тем, который клиент действительно держит, поэтому закрепите его записью для каждого клиента, содержащей ifconfig-push 10.8.0.10 255.255.255.0 ; правило, называющее адрес, который никогда не выдавался, даёт тихие отбрасывания, а не ошибку. И удалённая сторона должна принимать этот источник и направлять ответы назад, поэтому список разрешённых там называет адрес туннеля, а не гостевую подсеть. Когда адрес клиента динамический, -j MASQUERADE с -o tun0 следует за тем, что держит интерфейс, ценой удалённой политики, против которой нельзя написать фиксированное значение.

Фильтрация — вторая половина. Docker ставит политику FORWARD в drop и очищает цепочки, которыми владеет, при каждом перезапуске демона, поэтому поместите локальную политику в DOCKER-USER , которая выживает:

iptables -I DOCKER-USER -i br-appnet -o tun0 -j ACCEPT
iptables -I DOCKER-USER -i tun0 -o br-appnet -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT

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

Держите хост прямым правилом источника

Когда весь мост должен использовать туннель, а больше ничего на хосте — нет, пропустите NAT и дайте этому источнику собственную таблицу. Адреса гостя тогда остаются видимыми от начала до конца.

echo "200 vpnbox" >> /etc/iproute2/rt_tables
ip rule add from 172.18.0.0/16 lookup vpnbox pref 1000
ip route add default dev tun0 table vpnbox

Правила просматриваются до главной таблицы, и from совпадает с источником. Собственные внешние пакеты туннеля несут физический адрес хоста, поэтому они никогда не совпадают с from 172.18.0.0/16 , и туннель не может маршрутизировать себя обратно в себя. Именно это самоисключение делает схему устойчивой.

Обратному пути теперь нужна помощь. Удалённая сторона видит непереведённый 172.18.0.x и не имеет причин знать, где он живёт. На сервере файлу для каждого клиента нужен iroute 172.18.0.0 255.255.0.0 , чтобы внутренняя таблица сервера отдавала этот префикс этому клиенту, и протолкнутый route 172.18.0.0 255.255.0.0 , чтобы другие клиенты знали, что он существует.

Две проверки, прежде чем доверять. Запустите ip route show default : с redirect-gateway def1 таблица держит 0.0.0.0/1 и 128.0.0.0/1 через tun0 , и хост вовсе не прямой, потому что эти половинные умолчания побеждают, не заменяя default . Правила, добавленные через ip rule add , тоже исчезают при перезагрузке, поэтому носите их в up-скрипте.

Размер пакета — вторая неисправность, с которой вы столкнётесь

Мост на 1500 байт даёт туннелю больше, чем он может нести, когда добавляются внешние заголовки UDP и IP. Малые обмены проходят, поэтому DNS разрешается и рукопожатие TLS завершается, а затем большая загрузка зависает без единой ошибки. Отправитель повторяет тот же размер сегмента, потому что сообщение ICMP о необходимости фрагментации, которое сказало бы уменьшить, никогда не приходит.

Исправьте это на интерфейсе, на MSS или на обоих. Зажим не требует участия гостя:

ip link set dev tun0 mtu 1400
iptables -t mangle -A FORWARD -o tun0 -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

Зажим переписывает только TCP. UDP, включая QUIC, требует вместо этого пониженного интерфейса гостя, с --opt com.docker.network.driver.mtu=1400 при создании сети или соответствующим ключом compose.

DNS и проверки, которые стоит повторять

Разрешение имён идёт тем же путём. Встроенный резолвер Docker на 127.0.0.11 передаёт настроенным серверам хоста, поэтому контейнер наследует то, что использует хост. Когда приватную зону отвечает только резолвер, доступный через туннель, направьте гостя на этот адрес, и проверяйте через getent hosts внутри контейнера, а не на хосте.

  • Запустите сбойный запрос внутри гостя; проверка на стороне хоста использует другой адрес источника и скрывает проблему.
  • Читайте счётчики на добавленных правилах NAT и фильтра. Правило без единого совпадения — ошибка маршрутизации, а не политики.
  • Убедитесь, что адрес туннеля в вашем правиле SNAT — тот, который клиент действительно держит.
  • Понизьте MTU гостя или зажмите MSS, прежде чем винить сервер в зависании.

Пройдите список и остановитесь на первой неисправности. Именно этот слой нужно исправлять; всё, что добавляется ниже, лишь скрывает ломающуюся границу.

Похожие статьи