На этой неделе в топе снова всплыла старая история: свежий VPS, ufw default deny incoming, наружу только 22, 80 и 443, а потом в compose-файле появляется ports: - "5432:5432" — и через пару дней в базе лежит записка с биткойн-кошельком. Почему так происходит, уже разобрали подробно. Я хочу про другое: как сделать так, чтобы такой compose-файл вообще не доехал до сервера.
Ниже — два небольших скрипта на Python. Первый проверяет compose-файлы до деплоя и падает с ненулевым кодом, если порт опубликован на все интерфейсы. Второй смотрит, что реально слушает на машине, без ss и netstat, прямо из /proc. Весь код я запускал на Python 3.13 и PyYAML 6.0.3, вывод в статье — настоящий.
Почему UFW тут не участвует
Коротко, чтобы дальше было понятно, что мы ловим. В документации Docker прямо написано, что Docker и ufw несовместимы: трафик к опубликованным портам контейнера перенаправляется в таблице nat, то есть раньше, чем пакеты доходят до цепочек INPUT и OUTPUT, с которыми работает ufw. Правила ufw для такого трафика просто не применяются.

Отсюда простой вывод: фаервол на хосте — не та линия обороны, на которую стоит рассчитывать для контейнеров. Надёжнее не публиковать лишнее вообще, а то, что нужно только вам, вешать на 127.0.0.1 и ходить через SSH-туннель.
Ещё одна деталь из той же документации: если в маппинге порта не указан адрес хоста, Docker публикует порт на все адреса — и 0.0.0.0, и [::]. То есть "5432:5432" и "0.0.0.0:5432:5432" по смыслу одно и то же.
Какие бывают записи портов
Compose понимает две формы. Короткая — строка вида [host_ip:][published:]target[/protocol]. Длинная — словарь с ключами target, published, host_ip, protocol. Нам интересны три случая:
-
адрес хоста — loopback (
127.0.0.1,::1). Это нормально; -
адреса нет, порт явно разрешён к публикации (80, 443). Это осознанное решение;
-
адреса нет, и порт не из белого списка. Это то, что надо ловить.
Отдельный неприятный случай — запись из одного числа, например "9100". Её часто пишут, думая, что это «просто объявить порт». На деле Compose опубликует контейнерный порт 9100 на случайный порт хоста, причём на всех интерфейсах.
Скрипт 1: аудит compose-файла
Тестовый файл, на котором будем проверять:
services: api: image: myapp/api:1.4 ports: - "80:8000" db: image: postgres:17 ports: - "5432:5432" redis: image: redis:7 ports: - "127.0.0.1:6379:6379" adminer: image: adminer ports: - target: 8080 published: 8081 metrics: image: prom/node-exporter ports: - "9100"
Сам скрипт:
import sysimport yamlSAFE_HOSTS = {"127.0.0.1", "::1", "localhost"}ALLOWED_PUBLIC = {80, 443}def parse_short(spec: str): """'[host_ip:][published:]target[/proto]' -> (host_ip, published, target)""" spec = str(spec).split("/")[0] if spec.startswith("["): # IPv6: [::1]:5432:5432 host, rest = spec[1:].split("]:", 1) parts = [host] + rest.split(":") else: parts = spec.split(":") if len(parts) == 1: return "0.0.0.0", None, parts[0] if len(parts) == 2: return "0.0.0.0", parts[0], parts[1] return parts[0], parts[1], parts[2]def parse_long(item: dict): return (item.get("host_ip", "0.0.0.0"), item.get("published"), item.get("target"))def audit(path: str) -> int: with open(path, encoding="utf-8") as f: data = yaml.safe_load(f) problems = 0 for name, svc in (data.get("services") or {}).items(): for p in svc.get("ports") or []: host, published, target = (parse_long(p) if isinstance(p, dict) else parse_short(p)) if host in SAFE_HOSTS: status = "ok " elif published is None: status = "RANDOM" problems += 1 elif int(str(published).split("-")[0]) in ALLOWED_PUBLIC: status = "public" else: status = "OPEN " problems += 1 print(f"{status} {name:<8} {host}:{published or '?'} -> {target}") return problemsif __name__ == "__main__": n = audit(sys.argv[1] if len(sys.argv) > 1 else "compose.yaml") print(f"\nпроблем: {n}") sys.exit(1 if n else 0)
Запуск:
$ python3 compose_audit.py; echo "exit=$?"public api 0.0.0.0:80 -> 8000OPEN db 0.0.0.0:5432 -> 5432ok redis 127.0.0.1:6379 -> 6379OPEN adminer 0.0.0.0:8081 -> 8080RANDOM metrics 0.0.0.0:? -> 9100проблем: 3exit=1

Три проблемы из пяти сервисов, и это довольно типичная картина для «быстро поднять, потом поправлю». Redis правильно привязан к loopback, API честно публичный, остальное надо чинить.
Ненулевой код возврата здесь главное. Благодаря ему скрипт без доработок встаёт в pre-commit или в шаг CI перед деплоем: файл с OPEN или RANDOM просто не пройдёт.
Как парсер справляется с IPv6, UDP и диапазонами
Проверил разбор короткой формы на нескольких неочевидных строках:
from compose_audit import parse_shortfor s in ["5432:5432", "127.0.0.1:6379:6379", "[::1]:5432:5432", "9100", "53:53/udp", "8000-8005:8000-8005"]: print(f"{s!r:<26} -> {parse_short(s)}")
'5432:5432' -> ('0.0.0.0', '5432', '5432')'127.0.0.1:6379:6379' -> ('127.0.0.1', '6379', '6379')'[::1]:5432:5432' -> ('::1', '5432', '5432')'9100' -> ('0.0.0.0', None, '9100')'53:53/udp' -> ('0.0.0.0', '53', '53')'8000-8005:8000-8005' -> ('0.0.0.0', '8000-8005', '8000-8005')
Протокол отбрасывается: для вопроса «торчит ли наружу» он не важен. Диапазон проверяется по первому порту — если вам нужна строгость, разверните диапазон и проверяйте каждый. Переменные вида ${DB_PORT} скрипт не подставляет; если они у вас есть, проще прогонять аудит по выводу docker compose config, где всё уже раскрыто.
Скрипт 2: что слушает на самом деле
Статическая проверка ловит ошибки в файлах, но не видит того, что запустили руками через docker run -p, или сервиса, который сам решил слушать 0.0.0.0. Поэтому вторая половина — посмотреть на живую машину.
Можно взять ss -tlnp, но мне хотелось что-то, что запускается в минимальном контейнере или на чужом сервере без лишних пакетов. Ядро и так отдаёт таблицу сокетов в /proc/net/tcp и /proc/net/tcp6: адреса в шестнадцатеричном виде, состояние 0A означает LISTEN.
import ipaddressimport socketimport structdef hex_to_ip(h: str) -> str: if len(h) == 8: # IPv4, little-endian return socket.inet_ntoa(struct.pack("<I", int(h, 16))) # IPv6: четыре 32-битных слова, каждое little-endian raw = b"".join(struct.pack("<I", int(h[i:i + 8], 16)) for i in range(0, 32, 8)) return str(ipaddress.IPv6Address(raw))def listeners(): for path in ("/proc/net/tcp", "/proc/net/tcp6"): with open(path) as f: next(f) for line in f: cols = line.split() if cols[3] != "0A": # 0A = TCP_LISTEN continue ip_hex, port_hex = cols[1].split(":") yield hex_to_ip(ip_hex), int(port_hex, 16)if __name__ == "__main__": for ip, port in sorted(set(listeners()), key=lambda x: x[1]): addr = ipaddress.ip_address(ip) scope = "локально" if addr.is_loopback else "СНАРУЖИ" print(f"{scope:<9} {ip}:{port}")
Чтобы проверить, поднял два тестовых сервера — один на всех интерфейсах, другой только на loopback:
$ python3 -m http.server 18080 --bind 0.0.0.0 >/dev/null 2>&1 &$ python3 -m http.server 18081 --bind 127.0.0.1 >/dev/null 2>&1 &$ python3 listeners.py | grep 1808СНАРУЖИ 0.0.0.0:18080локально 127.0.0.1:18081
Почему это работает и для контейнеров: по умолчанию у Docker включён userland-proxy (--userland-proxy=true), и для опубликованного порта на хосте поднимается процесс docker-proxy, который этот порт слушает. Так что 0.0.0.0:5432 вы увидите в списке. Если userland-proxy у вас выключен, сокета не будет, а NAT-правило останется — тогда смотрите в docker ps или в правила фаервола.
Почему адреса в /proc выглядят «задом наперёд»
Адрес IPv4 в /proc/net/tcp — это 32-битное число в порядке байтов хоста, а на x86 и большинстве ARM-машин это little-endian. Поэтому 127.0.0.1 записан как 0100007F. Отсюда struct.pack("<I", ...) перед inet_ntoa. С IPv6 то же самое, только адрес разбит на четыре 32-битных слова, и разворачивать нужно каждое по отдельности, а не всю строку целиком. Порт, наоборот, выводится обычным шестнадцатеричным числом, его достаточно передать в int(..., 16).
Как чинить найденное
Для каждого типа находки рецепт простой.
OPEN (база, админка, брокер). Привязать к loopback: "127.0.0.1:5432:5432". С ноутбука ходить через туннель: ssh -L 5432:127.0.0.1:5432 user@server, а DBeaver подключать к localhost:5432. Если базе нужен только соседний контейнер, секция ports не нужна вовсе: внутри одной compose-сети сервисы видят друг друга по имени.
RANDOM. Почти всегда это была попытка «объявить» порт. Для связи между контейнерами публикация не нужна — удалите строку. Если порт действительно нужен снаружи, укажите его явно и с адресом.
Подстраховка на уровне демона. Можно сменить адрес по умолчанию для публикации портов в пользовательских bridge-сетях (а compose создаёт именно их). В /etc/docker/daemon.json:
{ "default-network-opts": { "bridge": { "com.docker.network.bridge.host_binding_ipv4": "127.0.0.1" } }}
После перезапуска демона "5432:5432" без адреса будет слушать только loopback, а публичные порты придётся указывать явно, например "0.0.0.0:443:443". Учтите, что на Swarm-сервисы эта настройка не действует: по документации они всегда публикуются на 0.0.0.0. И ещё одно предупреждение оттуда же: в релизах Docker старше 28.0.0 даже порт на 127.0.0.1 мог быть доступен хостам из того же L2-сегмента. Если у вас старый Docker, это повод обновиться.

Куда это встроить
У меня оба скрипта живут в репозитории с инфраструктурой. compose_audit.py запускается в CI на каждый compose-файл, а точнее на вывод docker compose config, чтобы переменные и override-файлы были уже подставлены. listeners.py раз в сутки запускается по cron на серверах и шлёт вывод, если в нём появилась строка СНАРУЖИ с портом не из списка ожидаемых.
Это не замена внешнему сканированию портов с другой машины — оно по-прежнему лучший способ увидеть сервер глазами бота. Но оно отвечает на вопрос «что уже открыто», а аудит compose-файла — на вопрос «что мы сейчас откроем». Второй вопрос дешевле задать заранее.
ссылка на оригинал статьи https://habr.com/ru/articles/1103458/