← Назад в базу знаний

Куда утекает реальный IP при использовании прокси в Docker-контейнерах: разбор сетевых bridge-конфигураций

Куда утекает реальный IP при использовании прокси в Docker-контейнерах: разбор сетевых bridge-конфигураций

Почему контейнер видит не тот адрес, который вы ожидаете

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'у.

✔️Купить прокси