Куда утекает реальный IP при использовании прокси в Docker-контейнерах: разбор сетевых bridge-конфигураций
Содержание
- Почему контейнер видит не тот адрес, который вы ожидаете
- Что именно утекает и через какие каналы
- Как Docker назначает адреса и почему это важно
- Проверка утечек: практический скрипт
- IPv6: тихий канал утечки
- DNS через 127.0.0.11: как работает и где ломается
- Сравнение сетевых режимов Docker
- Реальный случай: nginx + upstream + X-Forwarded-For
- MTU и фрагментация как индикатор
- Прокси внутри контейнера vs снаружи
- Чеклист проверки перед продакшеном
- Что делать с найденными утечками
Почему контейнер видит не тот адрес, который вы ожидаете
Docker создаёт изолированный сетевой namespace для каждого контейнера. Внутри него — свой loopback, свой eth0, своя таблица маршрутизации. Когда приложение в контейнере открывает соединение через прокси, ядро сначала смотрит в локальную таблицу маршрутов. По умолчанию трафик уходит через bridge-интерфейс docker0 (или пользовательскую bridge-сеть), где NAT-правила iptables подменяют source address на адрес хоста.
Проблема в том, что приложение внутри контейнера может узнать свой «реальный» адрес несколькими способами — и не все из них проходят через прокси. Локальные интерфейсы, метаданные облака, DNS-запросы, ICMP — всё это живёт отдельно от HTTP-трафика, который вы заворачиваете в SOCKS5 или HTTP CONNECT.
Возьмём типичную ситуацию: Python-скрипт в контейнере ходит на внешний API через `requests` с `proxies={'https': 'socks5://...'}`. HTTP-трафик уходит через прокси. Но `socket.gethostbyname(socket.gethostname())` вернёт 172.17.0.2 — внутренний адрес bridge-сети. Если внешний сервис логирует заголовки и видит несоответствие между exit-IP прокси и какими-то другими утечками, картина складывается.
Что именно утекает и через какие каналы
Список каналов утечки шире, чем принято думать. WebRTC в браузере — очевидный кандидат, но в контейнерах он редко актуален. Чаще утекают:
- Локальный IP через заголовки `X-Forwarded-For`, если перед прокси стоит reverse proxy внутри той же сети.
- IPv6-адрес контейнера, если bridge-сеть настроена с IPv6, а прокси работает только по IPv4.
- DNS-запросы, которые идут мимо прокси напрямую к resolver'у Docker (127.0.0.11).
- Идентификаторы контейнера через `/proc/self/cgroup` — не IP, но fingerprint.
- MTU-несоответствия, по которым можно определить тип сети (1500 на хосте vs 1450 в overlay).
Каждый канал требует отдельной проверки. Заворачивание HTTP в прокси не закрывает DNS. Заворачивание DNS в прокси не закрывает IPv6. И так далее.
Как Docker назначает адреса и почему это важно
Docker bridge по умолчанию использует подсеть 172.17.0.0/16. Первый контейнер получает 172.17.0.2, второй — 172.17.0.3. Шлюз — 172.17.0.1 на интерфейсе docker0 хоста. Внутри контейнера этот шлюз доступен, и через него идёт весь исходящий трафик.
NAT-правило выглядит примерно так:
```
-A POSTROUTING -s 172.17.0.0/16 ! -o docker0 -j MASQUERADE
```
Это значит: любой пакет из контейнера, уходящий не через docker0, получает source address хоста. Внешний сервер видит IP хоста, а не контейнера. Но только если трафик действительно идёт через NAT. Если приложение открывает raw socket с `IP_TRANSPARENT` или использует `SO_BINDTODEVICE`, поведение меняется.
В пользовательских bridge-сетях (`docker network create`) подсеть задаётся вручную, например 10.89.0.0/24. Это удобнее для отладки, но принцип тот же.
Проверка утечек: практический скрипт
Вот Python-скрипт, который проверяет несколько каналов одновременно. Запускать внутри контейнера.
```python
import socket
import requests
import subprocess
def check_local_ip():
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
finally:
s.close()
def check_ipv6():
try:
return socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET6)[0][4][0]
except (socket.gaierror, IndexError):
return None
def check_external(proxy):
r = requests.get("https://api.ipify.org?format=json",
proxies={"https": proxy}, timeout=10)
return r.json()["ip"]
def check_dns_leak():
result = subprocess.run(["getent", "hosts", "example.com"],
capture_output=True, text=True)
return result.stdout.strip()
print("local ipv4:", check_local_ip())
print("local ipv6:", check_ipv6())
print("exit ip:", check_external("socks5h://proxy.example:1080"))
print("dns:", check_dns_leak())
```
Ключевой момент — `socks5h` вместо `socks5`. Буква `h` означает, что DNS-резолвинг происходит на стороне прокси, а не локально. Без неё `requests` резолвит имя через Docker DNS (127.0.0.11), и запрос уходит наружу с вашего реального resolver'а.
IPv6: тихий канал утечки
Docker с включённым IPv6 (`"ipv6": true` в `/etc/docker/daemon.json`) назначает контейнерам адреса из пула, например `fd00:dead:beef::/64`. Это ULA-адреса, они не маршрутизируются в интернет напрямую. Но если хост имеет глобальный IPv6 (2001:...), и в контейнере есть маршрут по умолчанию через `fe80::1`, трафик может уйти наружу по IPv6, минуя IPv4-прокси.
Проверить просто:
```bash
docker run --rm alpine sh -c "apk add --no-cache curl >/dev/null && curl -6 -s https://ifconfig.co"
```
Если ответ приходит с вашим реальным IPv6 — утечка есть. Прокси на 127.0.0.1:1080 внутри контейнера слушает только IPv4, и curl -6 его игнорирует.
Решение — либо отключить IPv6 в контейнере (`--sysctl net.ipv6.conf.all.disable_ipv6=1`), либо настроить прокси на dual-stack и использовать `socks5h://[::1]:1080`.
DNS через 127.0.0.11: как работает и где ломается
Docker встраивает DNS-резолвер по адресу 127.0.0.11 внутри каждого контейнера в пользовательских сетях. Он перехватывает запросы на порт 53 и перенаправляет их на DNS хоста или указанные в `--dns` серверы.
Проблема: этот резолвер работает на уровне ядра через iptables DNAT. Если ваше приложение резолвит имена через системный resolver, запрос уходит к 127.0.0.11, а оттуда — к upstream-серверу с IP хоста. Прокси в этой цепочке не участвует.
Обход — использовать `socks5h` в клиентах, либо направить DNS через прокси явно:
```bash
docker run --rm --dns 127.0.0.1 alpine sh -c "nslookup example.com"
```
Но 127.0.0.1 внутри контейнера — это loopback контейнера, не хоста. Нужен либо `--network host`, либо прокси, слушающий на bridge-адресе.
Сравнение сетевых режимов Docker
| Режим | IP контейнера | Видимость хоста | NAT | Утечка local IP |
|-------|---------------|-----------------|-----|-----------------|
| bridge (default) | 172.17.0.0/16 | нет | да | да, 172.17.x.x |
| host | IP хоста | полная | нет | нет |
| none | только lo | нет | нет | нет |
| macvlan | IP из LAN | частичная | нет | зависит от LAN |
| overlay | 10.0.x.x | нет | да | да, 10.0.x.x |
В режиме `host` контейнер использует сетевой стек хоста напрямую. Прокси на 127.0.0.1:1080 доступен как есть, DNS идёт через системный resolver хоста. Утечки local IP нет, потому что local IP = IP хоста. Минус — теряется изоляция.
В `macvlan` контейнер получает адрес из физической сети. Если это 192.168.1.0/24, внешний сервер видит ваш LAN-адрес. Прокси тут не поможет, если только не заворачивать весь трафик.
Реальный случай: nginx + upstream + X-Forwarded-For
Сервер на nginx 1.24, конфигурация:
```nginx
location /api/ {
proxy_pass http://backend:8080;
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP \$remote_addr;
}
```
Контейнеры nginx и backend в одной bridge-сети 10.89.0.0/24. Backend логирует `X-Real-IP` и получает 10.89.0.3 — адрес nginx в bridge-сети. Не IP клиента. Причина: `\$remote_addr` внутри контейнера — это адрес предыдущего хопа в Docker-сети, а не внешнего клиента.
Если перед nginx стоит ещё один прокси или балансировщик, цепочка удлиняется. Каждый хоп добавляет свой адрес в `X-Forwarded-For`. Backend видит всю цепочку, включая внутренние адреса Docker. Это не утечка в классическом смысле, но fingerprint сети — вполне.
Решение — `real_ip_module` с доверенными прокси:
```nginx
set_real_ip_from 10.89.0.0/24;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
```
Тогда `\$remote_addr` подменяется на последний недоверенный адрес из заголовка.
MTU и фрагментация как индикатор
Docker bridge по умолчанию ставит MTU 1500. Overlay-сети (Swarm, Kubernetes) — 1450 или меньше из-за VXLAN-инкапсуляции (50 байт overhead). Если приложение отправляет пакеты размером 1500 байт через overlay, они фрагментируются или дропаются.
Проверить MTU внутри контейнера:
```bash
ip link show eth0 | grep mtu
```
Если видите 1450, а прокси-сервер ожидает 1500 — возможны проблемы с TLS handshake на больших сертификатах. Это не утечка IP, но индикатор того, что трафик идёт через overlay, а не напрямую.
Прокси внутри контейнера vs снаружи
Два подхода. Первый: прокси-клиент запускается внутри контейнера, слушает на 127.0.0.1:1080, приложение ходит через него. Плюс — изоляция, минус — нужно тащить бинарник в образ.
Второй: прокси на хосте, контейнер обращается к нему через `host.docker.internal` (Docker Desktop) или через bridge-шлюз 172.17.0.1 (Linux). На Linux `host.docker.internal` не работает из коробки, нужен `--add-host=host.docker.internal:host-gateway`.
```bash
docker run --rm --add-host=host.docker.internal:host-gateway \
-e HTTPS_PROXY=socks5h://host.docker.internal:1080 \
alpine curl -s https://api.ipify.org
```
Здесь `host-gateway` резолвится в 172.17.0.1. Прокси на хосте должен слушать на 0.0.0.0:1080 или на 172.17.0.1:1080, а не на 127.0.0.1. Иначе контейнер до него не достучится.
Именно в этой схеме удобно использовать внешний IPv6-прокси — контейнеры остаются в IPv4-сети, а исходящий трафик уходит через пул адресов на стороне провайдера.
Чеклист проверки перед продакшеном
Прогоните контейнер через такой набор тестов:
```bash
docker run --rm --cap-add=NET_ADMIN alpine sh -c "
apk add --no-cache curl bind-tools >/dev/null
echo '--- ipv4 ---'
curl -4 -s --proxy socks5h://proxy:1080 https://ifconfig.co
echo '--- ipv6 ---'
curl -6 -s --proxy socks5h://proxy:1080 https://ifconfig.co || echo 'no ipv6'
echo '--- dns ---'
dig +short example.com
echo '--- local ---'
ip -4 addr show eth0 | grep inet
"
```
Если IPv4-ответ совпадает с exit-IP прокси, IPv6 не отвечает или тоже идёт через прокси, DNS-запрос уходит к ожидаемому resolver'у, а local IP — внутренний bridge-адрес, который нигде не светится наружу, — конфигурация рабочая.
Отдельно проверьте заголовки, которые отправляет ваше приложение. `curl -v` покажет всё. Ищите `X-Forwarded-For`, `X-Real-IP`, `Forwarded`, `Via`. Любой из них с внутренним адресом — потенциальная проблема.
Что делать с найденными утечками
Для DNS — `socks5h` везде, где возможно. Для приложений, которые не поддерживают proxy-DNS, — локальный DNS-форвардер внутри контейнера, заворачивающий запросы в прокси. Для IPv6 — либо отключение, либо dual-stack прокси. Для заголовков — фильтрация на стороне приложения или reverse proxy.
Универсального рецепта нет. Каждый канал закрывается отдельно. Но базовый набор — `socks5h`, отключённый IPv6, `--add-host=host.docker.internal:host-gateway` — закрывает 80% типичных случаев. Остальное — зависит от конкретного стека и того, насколько параноидально вы относитесь к fingerprinting'у.