Inquirer2 dead in creek after suspected Ilocos Norte motorcycle crashThe Jerusalem PostOn this day: Prime minister Yitzhak Rabin assassinated after peace rally by far-right activistDaily MaverickSPOTLIGHT: Menopausal women want answers, not ‘old granny’ labelsPunchTeenager pushed from two-storey building dies in ImoZDF heuteEntdecken Sie das ZDF-NachrichtenstudioColliderQuentin Tarantino's First Sequel in 22 Years Officially Adds 2 Huge NamesVilaWebMetges de Catalunya veu positiva la reducció de les jornades de guàrdia dels MIR a 17 horesWirtualna PolskaTusk o spotkaniu z prezydentem. "Reszta jest tajemnicą"Antara NewsIndonesia to highlight climate bills at COP31 summit in TurkiyeThe South AfricanEastern Cape faces severe thunderstorms and damaging winds on ThursdaykickerAngekommen in den Top 5 der Liga: Pieper als Teil des Werder-AufschwungsStraits Times SportWilson and Rodon return to Wales squad for Nations League fixtures
The Daily Newsstand · Free, Always
Wednesday, November 4, 2026

UFW говорит «всё закрыто», а Postgres торчит наружу: пишем аудит портов для docker compose на Python

Translate

На этой неделе в топе снова всплыла старая история: свежий 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 sys
import yaml

SAFE_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 problems


if __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 -> 8000
OPEN   db       0.0.0.0:5432 -> 5432
ok     redis    127.0.0.1:6379 -> 6379
OPEN   adminer  0.0.0.0:8081 -> 8080
RANDOM metrics  0.0.0.0:? -> 9100

проблем: 3
exit=1

Три проблемы из пяти сервисов, и это довольно типичная картина для «быстро поднять, потом поправлю». Redis правильно привязан к loopback, API честно публичный, остальное надо чинить.

Ненулевой код возврата здесь главное. Благодаря ему скрипт без доработок встаёт в pre-commit или в шаг CI перед деплоем: файл с OPEN или RANDOM просто не пройдёт.

Как парсер справляется с IPv6, UDP и диапазонами

Проверил разбор короткой формы на нескольких неочевидных строках:

from compose_audit import parse_short

for 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 ipaddress
import socket
import struct


def 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-файла — на вопрос «что мы сейчас откроем». Второй вопрос дешевле задать заранее.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.