Цифровой дворник

Практические заметки о сетях, self-hosting, виртуализации, мониторинге, автоматизации и локальном ИИ.

Telegram: @digclean

Цикл «Не светим лишнего». Выпуск 10.

Basic Auth хорош, пока пользователей трое. Потом нужен четвёртый, один увольняется, другой забывает пароль, руководитель просит TOTP, а безопасник — журнал по именам. В этот момент лучше перестать улучшать htpasswd.

Авторизующий reverse proxy отправляет пользователя к нормальному провайдеру идентичности: Keycloak, Authentik, Authelia, Entra ID или другому OIDC-совместимому сервису. После входа прокси получает подтверждённую личность и решает, можно ли пропустить запрос к конкретному приложению.

Несколько новых слов

IdP, Identity Provider — система, которая аутентифицирует пользователя и сообщает другим сервисам, кто это.

OIDC, OpenID Connect — распространённый протокол входа поверх OAuth 2.0. Пользователь логинится у IdP, а приложение получает подписанные данные о его личности.

SSO — единый вход. Один раз прошли корпоративную авторизацию и используем несколько разрешённых приложений без отдельных паролей.

Identity-aware proxy — прокси, который принимает решение не только по IP, но и по пользователю, группе, MFA и другим признакам.

Как идёт запрос

Пользователь открывает внутренний hostname. Proxy видит, что действующей сессии нет, и отправляет его на страницу IdP. После пароля, passkey или TOTP пользователь возвращается с подтверждением. Proxy создаёт защищённую cookie и пропускает запрос к backend.

Внутреннее приложение может само ничего не знать про OIDC. Если ему нужна личность, proxy передаёт проверенные заголовки вроде X-User или X-Email.

Плюсы

  • одна корпоративная учётная запись;
  • MFA и passkey без переделки каждого приложения;
  • доступ по группам;
  • отзыв пользователя в одном месте;
  • приложение не получает сетевой доступ ко всей подсети;
  • понятный аудит входов;
  • удобно публиковать старые веб-интерфейсы.

Минусы

  • применимо в первую очередь к HTTP/HTTPS;
  • IdP и proxy становятся критичными компонентами;
  • не все приложения корректно работают за внешней авторизацией;
  • нужно управлять cookie, токенами, redirect URI и секретами клиентов;
  • ошибка общей политики может открыть сразу много приложений.

Где можно больно ошибиться

Backend доверяет заголовкам от клиента. Внешний пользователь сам присылает X-User: admin. Proxy обязан удалять такие заголовки и записывать собственные. Backend должен принимать трафик только от proxy.

Неправильно определён реальный IP. OAuth2 Proxy отдельно предупреждает: X-Forwarded-* надо принимать только от доверенных адресов reverse proxy. Иначе IP можно подделать строкой заголовка.

Слишком длинная cookie. Человек ушёл с общего компьютера, а сессия живёт неделю. Нужны разумный срок, logout и повторная сильная авторизация для опасных действий.

Нет защиты origin. Если backend доступен напрямую, вся identity-aware-логика обходится одним прямым запросом.

Proxy стал единственной защитой. На самом приложении всё равно оставляем минимальные роли и обновления. Для критичной админки можно совместить IdP с IP allow-list или клиентским сертификатом.

Что этот метод не решает

Он отлично защищает веб-приложения, но не открывает обычный SSH-клиент, RDP, WinBox или подключение к базе. Для произвольных протоколов понадобится bastion, VPN или динамический firewall.

Следующая наша схема как раз про динамический firewall: пользователь вводит TOTP на портале, а его текущий IP временно попадает в нужный access-list.

Ранее в цикле

Документация и источники

#network

Цикл «Не светим лишнего». Выпуск 9.

Есть внутренняя панель, написанная когда-то давно. Она умеет показывать графики, но не умеет пользователей. Разработчика рядом нет, а доступ двум сотрудникам нужен завтра.

Самый быстрый аккуратный вариант — поставить перед ней reverse proxy и включить HTTP Basic Authentication.

Что делает reverse proxy

Внешний клиент соединяется только с NGINX, Caddy, Traefik или другим прокси. Прокси завершает TLS, проверяет пользователя и отправляет разрешённый запрос внутреннему backend.

Внутренний сервис может жить на приватном адресе и вообще не иметь прямого DNAT. На одном внешнем IP удобно публиковать несколько hostname: monitoring.example.ru, dev.example.ru, reports.example.ru.

Basic Auth — стандартная HTTP-схема, в которой браузер отправляет имя пользователя и пароль в заголовке Authorization. Сами данные кодируются Base64, а не шифруются. Защиту канала даёт только HTTPS.

NGINX хранит проверочные значения в htpasswd-файле и может ограничивать отдельные location. Можно одновременно потребовать и разрешённый IP, и пароль либо разрешить один из этих вариантов — зависит от политики.

Плюсы

  • разворачивается быстро;
  • старое приложение не надо переделывать;
  • работает в обычном браузере;
  • backend остаётся во внутренней сети;
  • легко добавить TLS и журнал запросов;
  • можно закрывать разные сайты разными файлами пользователей.

Минусы

  • только HTTP/HTTPS;
  • неудобное управление большим числом пользователей;
  • нет красивого входа, MFA и восстановления учётки;
  • браузер может надолго сохранить пароль;
  • общие пароли быстро расходятся по чатам;
  • не все приложения хорошо живут за изменившимся hostname и префиксом пути.

Где можно больно ошибиться

Basic Auth без HTTPS. Base64 легко декодируется. В открытом HTTP пароль уходит практически как текст.

Один пароль на отдел. В журнале будет одна учётка, отозвать конкретного человека невозможно.

Backend доступен в обход. Если внутренний сервис всё ещё опубликован отдельным DNAT или слушает доступный внешний интерфейс, пользователь обойдёт proxy и Basic Auth.

Слабый TLS и забытые сертификаты. Прокси становится внешней точкой, поэтому обновления, сертификаты и безопасная конфигурация обязательны.

Доверие к X-Forwarded-For от всех. Прокси должен перезаписывать служебные заголовки. Backend — принимать их только от адреса прокси.

Отсутствие защиты от перебора. Basic Auth вызывает окно входа снова и снова. Rate limit, fail2ban или внешний firewall никто не отменял.

WebSocket и длинные запросы. Некоторые панели используют WebSocket, SSE или большие загрузки. Для них на прокси нужны отдельные timeout и Upgrade-заголовки.

Когда Basic Auth достаточно

Для двух-трёх технических пользователей, временной панели или read-only-отчёта — вполне. Особенно если дополнительно ограничить доступ по IP.

Но когда появляются десятки людей, увольнения, группы, MFA и аудит, htpasswd начинает изображать систему управления доступом. В следующем выпуске поставим перед proxy нормального провайдера идентичности.

Ранее в цикле

Документация и источники

#network

Цикл «Не светим лишнего». Выпуск 8.

Captive portal большинство видело в гостинице: подключились к Wi‑Fi, открылась страница, приняли правила или ввели номер комнаты — появился Интернет.

Для корпоративной сети интереснее другой сценарий. До авторизации клиенту доступны только DHCP, DNS и портал. После входа он получает не просто «Интернет есть», а конкретную роль firewall.

Один SSID, разные права

Например, на объекте есть технологический Wi‑Fi.

  • Гостевой ваучер даёт только Интернет.
  • Сотрудник получает внутренний портал и телефонию.
  • Инженер видит мониторинг и сетевое оборудование.
  • Подрядчик по камерам — только CCTV-сегмент на четыре часа.
  • Аварийный профиль открывает расширенный доступ и сразу пишет событие в журнал.

Пользовательский интерфейс один, а политика выбирается по учётной записи, ваучеру, группе RADIUS или дополнительному коду.

Captive portal — это веб-страница, через которую сеть проводит пользователя до выдачи обычного доступа. На MikroTik такую модель реализует HotSpot. RADIUS может хранить пользователей, срок сессии и дополнительные атрибуты политики.

Как проходит трафик

Неавторизованный клиент получает адрес, но firewall разрешает ему только минимальный набор. Попытка открыть сайт приводит к captive portal detection или редиректу на страницу входа. После успешной авторизации устройство появляется среди активных клиентов, а правила начинают пропускать нужные направления.

Важно: современный HTTPS нельзя безопасно «подменить» редиректом на чужой сертификат. Поэтому нормальный портал полагается на специальные проверки captive portal в ОС, доступ к своему hostname и аккуратный walled garden — список адресов, доступных до входа.

Плюсы

  • пользователю не нужно ставить приложение;
  • удобно выдавать временные ваучеры;
  • можно различать группы доступа;
  • сессия и timeout управляются централизованно;
  • хорошо работает в контролируемой локальной сети;
  • RADIUS даёт единый учёт и accounting.

Минусы

  • механизм привязан к конкретному сегменту и шлюзу;
  • не все устройства красиво открывают портал;
  • HTTPS и приложения без браузера могут выглядеть как «Интернет не работает»;
  • MAC-адреса на телефонах рандомизируются;
  • общая учётка плохо различает людей;
  • открытый Wi‑Fi до портала сам по себе не шифрует радиообмен.

Где можно больно ошибиться

Считать MAC надёжной личностью. MAC-cookie удобна для повторного входа, но адрес можно подменить, а ОС может использовать private MAC. Для чувствительной роли нужна пользовательская авторизация.

Не включить client isolation. Два гостя в одном Wi‑Fi не должны свободно ходить друг к другу на SMB и RDP.

Открыть слишком широкий walled garden. До авторизации разрешают CDN или домен, который умеет проксировать произвольный трафик, и портал превращается в декорацию.

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

Смешать гостевой и технологический трафик без сегментации. Роль firewall не отменяет VLAN, отдельные подсети и базовую изоляцию.

Не продумать отказ RADIUS. Надо заранее выбрать fail closed или ограниченный аварийный профиль. Выдавать всем полный доступ при недоступности сервера — плохой fallback.

Дополнительная авторизация внутри Wi‑Fi

Интересный вариант: базовый доступ у сотрудника уже есть, но для входа в технологическую группу он открывает отдельную страницу и подтверждает действие TOTP. Firewall на короткое время добавляет его текущий адрес в расширенный список. Это уже мостик к нашему отдельному TOTP-порталу, который сможет работать не только в Wi‑Fi, но и из Интернета.

Ранее в цикле

Документация и источники

#network

Обложка

Как заставить сервис отвечать через тот же канал, по которому пришёл клиент

Это очередная практическая часть цикла о собственных сетевых инструментах и управлении исходящим трафиком.

Сегодня разберём ситуацию, которая поначалу выглядит почти мистически. У Linux-сервера два сетевых интерфейса. На одном — публичный адрес, SSH, VPN и обычный доступ в интернет. На втором — ещё один адрес, через который должен быть доступен отдельный сервис, например SOCKS-прокси для офисной сети.

Пакет от клиента приходит. Порт Docker опубликован. В tcpdump виден SYN. Сервер даже отправляет SYN-ACK. Но полноценное соединение не устанавливается или обрывается сразу после первых байтов.

Никакой мистики тут нет. Запрос вошёл через одну дверь, а ответ попытался выйти через другую.

Исходная схема

Возьмём сервер с двумя интерфейсами:

  • ethPUB — публичная сторона сервера, адрес 203.0.113.10/29, шлюз 203.0.113.1;
  • ethLOC — второй провайдерский или локальный канал, адрес 198.51.100.35/28, шлюз 198.51.100.33.

На сервере работает Docker. Обычная контейнерная сеть — docker0, 172.17.0.0/16. Дополнительно может быть ещё один bridge, например amn0 с сетью 172.29.172.0/24.

Адреса в статье взяты из тестовых диапазонов RFC 5737. Они не предназначены для реальной сети: в командах нужно подставить свои адреса, интерфейсы, шлюзы и сети Docker.

Задача выглядит так:

  1. Всё, что относится к публичному адресу 203.0.113.10, должно отвечать через ethPUB.
  2. Всё, что пришло на 198.51.100.35, должно отвечать через ethLOC.
  3. Это правило должно работать и для опубликованных портов Docker.

Почему обычного правила from недостаточно

Для самого Linux-хоста решение кажется очевидным:

ip rule add from 198.51.100.35 lookup loc35

То есть: если пакет отправляется с адреса .35, ищи маршрут не в основной таблице, а в отдельной таблице loc35.

Для процессов, которые работают прямо на хосте, этого часто хватает. Но у ответа из Docker в момент выбора маршрута источник ещё другой.

Порядок получается примерно такой:

  1. Контейнер отправляет ответ с адреса 172.17.0.4.
  2. Linux выбирает маршрут, пока source всё ещё равен 172.17.0.4.
  3. Правило from 198.51.100.35 не срабатывает.
  4. Система берёт default route из таблицы main — через публичный ethPUB.
  5. Уже позже, в POSTROUTING, conntrack подменяет источник на 198.51.100.35.

На проводе появляется пакет с источником .35, но физически он выходит через публичный интерфейс. В лучшем случае удалённая сторона его не принимает. В худшем — часть соединения работает, часть нет, а администратор успевает проверить всё, кроме порядка обработки пакета.

Ключевой момент: routing выполняется раньше финальной подмены адреса источника. Поэтому правило, которое смотрит только на внешний IP, не узнаёт ответ контейнера вовремя.

Что будем делать

Нам нужно запомнить, через какой адрес пришло конкретное соединение, а затем использовать эту информацию при маршрутизации ответа контейнера.

Для этого понадобятся три сущности:

  • CONNMARK — метка всего соединения в conntrack. Она сохраняется между пакетами и действует в обе стороны;
  • fwmark, или skb mark — метка конкретного пакета, которую умеет видеть ip rule;
  • отдельная таблица маршрутизации loc35, где default route направлен через ethLOC.

Логика будет такой:

  1. Пакет приходит на 198.51.100.35 — соединению присваивается CONNMARK 0xc0.
  2. Контейнер формирует ответ.
  3. На пакете, который пришёл от Docker bridge, сохранённая метка соединения восстанавливается в fwmark.
  4. Правило ip rule fwmark 0xc0 отправляет пакет в таблицу loc35.
  5. Ответ выходит через 198.51.100.33 и ethLOC.

Основная таблица main при этом остаётся простой: её default route ведёт только через публичный интерфейс.

Шаг 1. Добавляем отдельную таблицу маршрутизации

В /etc/iproute2/rt_tables добавим:

192 loc35

Число 192 — идентификатор таблицы. Для удобства используем такую же метку в шестнадцатеричном виде: 0xc0.

Совпадение номера таблицы и метки не обязательно. Это просто помогает через полгода понять собственную конфигурацию без археологической экспедиции по shell history.

Шаг 2. Создаём идемпотентный скрипт

Идемпотентный означает, что скрипт можно запускать повторно: он приводит систему к нужному состоянию и не плодит одинаковые правила при каждом старте.

Создадим /usr/local/sbin/dual-nic-pbr.sh:

#!/bin/bash
set -euo pipefail

PUB_IP=203.0.113.10
PUB_GW=203.0.113.1
PUB_DEV=ethPUB

LOC_IP=198.51.100.35
LOC_GW=198.51.100.33
LOC_DEV=ethLOC
LOC_NET=198.51.100.32/28

TABLE=192
MARK=0xc0
RULE_MARK_PRIO=99
RULE_FROM_PRIO=100

grep -qE "^[[:space:]]*${TABLE}[[:space:]]+loc35[[:space:]]*$" \
  /etc/iproute2/rt_tables \
  || echo "${TABLE} loc35" >> /etc/iproute2/rt_tables

# В main оставляем default только через публичный интерфейс.
ip route replace default via "${PUB_GW}" dev "${PUB_DEV}"

# Отдельная таблица для второго адреса.
ip route replace "${LOC_NET}" dev "${LOC_DEV}" \
  src "${LOC_IP}" table "${TABLE}"
ip route replace 172.17.0.0/16 dev docker0 \
  table "${TABLE}" 2>/dev/null || true
ip route replace 172.29.172.0/24 dev amn0 \
  table "${TABLE}" 2>/dev/null || true
ip route replace default via "${LOC_GW}" dev "${LOC_DEV}" \
  table "${TABLE}"

# Удаляем старые копии правил и создаём их заново.
while ip rule del from "${LOC_IP}" table "${TABLE}" \
  2>/dev/null; do :; done
while ip rule del fwmark "${MARK}" table "${TABLE}" \
  2>/dev/null; do :; done

ip rule add fwmark "${MARK}" lookup "${TABLE}" \
  priority "${RULE_MARK_PRIO}"
ip rule add from "${LOC_IP}" lookup "${TABLE}" \
  priority "${RULE_FROM_PRIO}"

ensure_jump() {
  local hook="$1" chain="$2"
  iptables -t mangle -C "${hook}" -j "${chain}" 2>/dev/null \
    || iptables -t mangle -A "${hook}" -j "${chain}"
}

iptables -t mangle -N DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -N DUALNIC_OUT 2>/dev/null || true
iptables -t mangle -F DUALNIC_PRE
iptables -t mangle -F DUALNIC_OUT
ensure_jump PREROUTING DUALNIC_PRE
ensure_jump OUTPUT DUALNIC_OUT

# На входе к вторичному IP помечаем соединение.
# Метку самого входящего пакета здесь не восстанавливаем.
iptables -t mangle -A DUALNIC_PRE -d "${LOC_IP}/32" \
  -j CONNMARK --set-mark "${MARK}/0xffffffff"

# Восстанавливаем fwmark только на ответах от контейнеров.
iptables -t mangle -A DUALNIC_PRE -i docker0 \
  -j CONNMARK --restore-mark \
  --nfmask 0xffffffff --ctmask 0xffffffff
iptables -t mangle -A DUALNIC_PRE -i amn0 \
  -j CONNMARK --restore-mark \
  --nfmask 0xffffffff --ctmask 0xffffffff

# И на локально порождённых пакетах связанного соединения.
iptables -t mangle -A DUALNIC_OUT \
  -j CONNMARK --restore-mark \
  --nfmask 0xffffffff --ctmask 0xffffffff

Делаем скрипт исполняемым и запускаем:

chmod 755 /usr/local/sbin/dual-nic-pbr.sh
/usr/local/sbin/dual-nic-pbr.sh

Если у Docker есть пользовательские bridge-сети с именами вроде br-xxxxxxxxxxxx, их тоже нужно добавить в таблицу loc35 и в правила восстановления метки. Проверить реальные интерфейсы можно так:

docker network ls
ip -br address

Важно также не смешивать iptables-legacy и iptables-nft: правила должны попадать в тот же firewall backend, которым пользуется Docker.

Самое важное место во всей схеме

CONNMARK --restore-mark нельзя бездумно ставить на все пакеты в PREROUTING.

Если восстановить метку на входящем пакете клиента, получится следующее:

  1. Клиентский пакет приходит на .35.
  2. У соединения уже есть CONNMARK 0xc0.
  3. Эта метка восстанавливается в fwmark входящего пакета.
  4. После DNAT адрес назначения становится контейнерным, например 172.17.0.4.
  5. Routing видит fwmark 0xc0 и смотрит в loc35.
  6. Вместо доставки в Docker пакет уходит по default route через локальный шлюз.

Снаружи это выглядит особенно убедительно: SYN дошёл, SYN-ACK пришёл, но дальше сервер снова и снова повторяет SYN-ACK, а полезные данные до контейнера не добираются.

Поэтому правило простое:

  • CONNMARK --set-mark — на входящем соединении к вторичному IP;
  • CONNMARK --restore-mark — только на пакетах, пришедших из Docker bridge, и при необходимости в OUTPUT;
  • не восстанавливать метку на всём входящем трафике подряд.

Шаг 3. Переживаем перезагрузку

Для Debian с ifupdown скрипт можно вызвать через post-up в конфигурации нужного интерфейса:

post-up /usr/local/sbin/dual-nic-pbr.sh
pre-down ip rule del from 198.51.100.35 table 192 2>/dev/null || true

Дополнительно удобно создать systemd unit /etc/systemd/system/dual-nic-pbr.service:

[Unit]
Description=Dual-NIC PBR for secondary IP
After=network-online.target docker.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/dual-nic-pbr.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

Включаем:

systemctl daemon-reload
systemctl enable --now dual-nic-pbr.service

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

conntrack -D -d 198.51.100.35 --dport 46238 2>/dev/null || true
conntrack -D -s 198.51.100.35 --sport 46238 2>/dev/null || true

Команда оборвёт существующие соединения на этом адресе и порту, поэтому запускать её лучше осознанно, а не «для красоты».

Как проверить результат

Сначала смотрим правила и обе таблицы:

ip rule list
ip route show
ip route show table 192
iptables -t mangle -S DUALNIC_PRE
iptables -t mangle -S DUALNIC_OUT

Ожидаем увидеть:

99:  from all fwmark 0xc0 lookup loc35
100: from 198.51.100.35 lookup loc35

В main default route должен вести через ethPUB. В loc35 — через 198.51.100.33 и ethLOC, плюс там должны быть подключённая сеть второго интерфейса и используемые Docker-сети.

Проверяем выбор маршрута без реального соединения:

ip route get 8.8.8.8 from 203.0.113.10
# via 203.0.113.1 dev ethPUB

ip route get 8.8.8.8 from 198.51.100.35
# via 198.51.100.33 dev ethLOC table loc35

Главная проверка — два tcpdump одновременно:

tcpdump -ni ethPUB host 198.51.100.35 and tcp port 46238
tcpdump -ni ethLOC host 198.51.100.35 and tcp port 46238

Правильный результат:

  • на ethLOC видны и входящие пакеты к .35, и исходящие ответы от .35;
  • на ethPUB нет пакетов этого соединения;
  • соединение проходит дальше TCP handshake и реально передаёт данные.

Проверяем метку соединения:

conntrack -L | grep 198.51.100.35 | grep dport=46238

У сессий к вторичному адресу ожидаем mark=192 — это десятичная запись 0xc0.

Для SOCKS5 можно сделать простой smoke test:

python3 - <<'PY'
import socket

for host in ("127.0.0.1", "198.51.100.35", "203.0.113.10"):
    s = socket.create_connection((host, 46238), 2)
    s.sendall(b"\x05\x01\x00")
    print(host, s.recv(16).hex())
    s.close()
PY

Ответ 0500 означает, что SOCKS5 принял вариант без аутентификации. Это уже проверка не просто открытого порта, а начала прикладного диалога.

Где можно больно ошибиться

1. Восстановить метку на всём PREROUTING

Это самая неприятная ошибка. Входящий пакет получает fwmark, попадает в таблицу второго интерфейса и после DNAT уходит мимо Docker. Restore делаем только с контейнерных bridge-интерфейсов.

2. Оставить офисные маршруты в main

Статические /22, /24 и другие «короткие» маршруты через ethLOC могут снова создать асимметрию — уже для ответов с публичного адреса. Маршруты, относящиеся ко второму каналу, лучше держать в его отдельной таблице.

3. Забыть про дополнительный Docker bridge

У Amnezia, Compose и пользовательских Docker networks могут быть свои интерфейсы. Если реальный ответ приходит не через docker0, правило восстановления метки его не увидит.

4. Включить строгий rp_filter

Strict reverse path filtering не любит многоканальные схемы и может отбрасывать вполне законные пакеты. На время настройки обычно используют rp_filter=0 или loose mode 2, а затем проверяют поведение отдельно для каждого интерфейса.

5. Проверить только открытие порта

Увидеть SYN и SYN-ACK недостаточно. Плохая схема иногда успешно начинает handshake, а ломается на ACK или первых байтах приложения. Проверять нужно весь путь: интерфейсы, conntrack, метку и прикладной ответ.

6. Забыть про старые соединения

Новые правила не перепишут историю уже созданной conntrack-сессии. После исправления политики тестируйте новым соединением или аккуратно удалите старую запись.

Плюсы и минусы схемы

Плюсы:

  • публичные сервисы продолжают использовать обычный default route;
  • второй IP получает симметричный путь без россыпи маршрутов в main;
  • схема работает с опубликованными портами Docker;
  • решение можно проверить по шагам и быстро откатить;
  • тот же принцип масштабируется на другие адреса и каналы.

Минусы:

  • нужно понимать порядок mangle, routing, DNAT и SNAT;
  • каждый Docker bridge необходимо учитывать явно;
  • конфигурацию нужно синхронизировать с сетевым менеджером и firewall backend;
  • при добавлении третьего канала появятся новая таблица, новая метка и ещё один повод вести документацию.

Как откатить

Если нужно вернуть исходное состояние:

ip rule del from 198.51.100.35 table 192 2>/dev/null || true
ip rule del fwmark 0xc0 table 192 2>/dev/null || true
ip route flush table 192

iptables -t mangle -D PREROUTING -j DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -D OUTPUT -j DUALNIC_OUT 2>/dev/null || true
iptables -t mangle -F DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -F DUALNIC_OUT 2>/dev/null || true
iptables -t mangle -X DUALNIC_PRE 2>/dev/null || true
iptables -t mangle -X DUALNIC_OUT 2>/dev/null || true

systemctl disable --now dual-nic-pbr.service 2>/dev/null || true

После этого нужно убрать post-up из /etc/network/interfaces и вернуть штатную сетевую конфигурацию, если она менялась.

Короткий итог

Если у сервера два интерфейса, одного правила from <вторичный IP> для Docker может быть недостаточно. В момент выбора маршрута ответ контейнера ещё имеет внутренний адрес, поэтому Linux отправляет его через обычный default route.

Рабочая схема состоит из четырёх частей:

  1. В main остаётся default через публичный интерфейс.
  2. Для второго адреса создаётся отдельная таблица маршрутизации.
  3. Входящее соединение к этому адресу получает CONNMARK.
  4. На ответе из Docker метка восстанавливается в fwmark, и ip rule выбирает нужную таблицу.

А главный контрольный вопрос звучит так: видим ли мы пакеты с источником второго IP на первом интерфейсе? Если видим — симметрии ещё нет. Если не видим, conntrack содержит правильную метку, а прикладной тест проходит — можно выдыхать.

#network

Обложка

Цикл «Виды связности и SDN», часть 6. Предыдущая часть: «Кто управляет сетью: контроллер, BGP и локальные агенты».

Ось: топология.

Топология отвечает не на вопрос «чем шифруем», а на вопрос «через кого проходит пакет».

Point-to-point

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

Проблемы начинаются, когда появляется третья, десятая и сотая площадка. Ручное добавление каждой пары быстро превращается в отдельную профессию.

Hub-and-spoke

Все spokes подключаются к центральному хабу. Филиалу нужен один или два туннеля, политики сосредоточены в одном месте, легко организовать общий выход и журналирование.

Недостатки:

  • трафик филиал—филиал делает крюк;
  • производительность ограничена хабом;
  • отказ центра влияет на всех;
  • растёт задержка;
  • приходится резервировать не только сервер, но и каналы, адреса и маршруты до него.

Два хаба улучшают ситуацию, но добавляют выбор активного маршрута и риск асимметрии.

Full mesh

Каждый узел имеет прямую связь с каждым. Количество пар:

N × (N − 1) / 2.

Для десяти участников — 45, для ста — 4950, для тысячи — 499 500 потенциальных пар.

Это не обязательно означает полмиллиона постоянно активных сокетов. Современный контроллер может выдавать endpoint и ключи по политике, а data plane поднимать связь при появлении трафика.

Но каждому узлу всё равно нужно знать разрешённых участников, их ключи, адреса или способ запроса этой информации. Масштабируется не только трафик, но и состояние.

Partial mesh

Прямые связи создаются только для реально общающихся групп.

Например:

  • серверы резервного копирования имеют прямые пути к ЦОДам;
  • офисы ходят к приложениям через ближайший региональный хаб;
  • администраторы подключаются напрямую к разрешённым узлам;
  • случайные филиалы не образуют туннели друг с другом.

Partial mesh часто оказывается практичнее полного: меньше состояния и радиус аварии при сохранении прямых путей там, где они дают пользу.

Региональная иерархия

Несколько локальных хабов соединяются между собой mesh или магистралью. Пользователь подключается к ближайшему региону, а межрегиональный трафик проходит по контролируемому backbone.

Такая схема удобна, когда:

  • много филиалов;
  • есть несколько ЦОДов;
  • Интернет неоднороден;
  • нужно локализовать отказ;
  • требуется единая точка выхода в конкретном регионе.

Relay не всегда хаб

Relay может использоваться только тогда, когда два участника не установили прямое соединение из-за NAT или firewall. Остальные пары продолжают общаться напрямую.

Поэтому наличие relay не делает всю сеть hub-and-spoke. Но для конкретной проблемной пары relay временно становится транзитной точкой со всеми последствиями: задержкой, ограничением полосы и требованиями к резервированию.

Кольцо

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

Если поверх кольца работает BGP/OSPF и корректно выбирает путь, это уже routed overlay с выбором пути.

Как выбирать

Hub-and-spoke хорош для простого удалённого доступа и централизованного выхода.

Full mesh подходит для небольшого числа равноправных узлов с интенсивным взаимным обменом.

Partial mesh — для большинства неоднородных инфраструктур.

Региональная схема — когда узлов и географии уже достаточно, чтобы один центр стал проблемой.

Решение нужно принимать по реальной матрице потоков. Если из ста площадок девяносто девять ходят только к двум ЦОДам, полный mesh существует главным образом ради красивой схемы.

Где это ломается

  • Хаб становится единственной точкой отказа и узким местом, хотя «все туннели зелёные».
  • Full mesh рисуют для трёх узлов или, наоборот, для тысячи без учёта состояния.
  • Relay стоит далеко и тихо превращает проблемные пары в скрытый hub-and-spoke.
  • Два хаба без продуманного выбора пути дают асимметрию и обрыв сессий.
  • Топологию выбирают по красивой картинке, а не по матрице реальных потоков.

В следующей части добавим NAT, CGNAT и фильтрацию. Именно там выяснится, что нарисованная прямая линия между узлами иногда является пожеланием, а фактический пакет тихо едет через relay.

Предыдущая часть: «Кто управляет сетью: контроллер, BGP и локальные агенты»

Оглавление цикла: «SDN без магии: карта видов связности»

Следующая часть: «NAT, CGNAT и ограниченные сети: где заканчивается прямая связность»

Схема

#network #selfhosting

Обложка

🔧 Исходные данные

Роутер и сервер у меня в разных сетях: сервер живёт за чужим NAT без белого IP, а у роутера на даче белый IP есть. Все домашние сервисы наружу торчали через SSH reverse-туннель — sshd на роутере, keepalive каждые 30 секунд, dstnat 443→8443 и 8080→8080. Работало, но жило своей жизнью: одно длинное TCP-соединение, которое надо держать живым, ключ от роутера лежит на сервере, а TCP-поверх-TCP на потере пакетов ведёт себя так себе.

Как и что делали

Завёл WireGuard — и упёрся в три грабли.

Грабли 1: интерфейс «есть», а трафика нет. Поднял wg0, handshake 0, пакеты не идут. Оказалось, интерфейс висел без приватного ключа — туннель формально создался, но обменяться ключами не мог.

Грабли 2: wg-quick в LXC падает в segfault. Непривилегированный контейнер без /dev/net/tun. Поднимаю руками через wg setconf + свой systemd-юнит:

wg setconf wg0 /etc/wireguard/wg0.conf
ip link set wg0 up
ip addr add 10.100.0.2/30 dev wg0

Грабли 3: wg setconf не создаёт маршрут до LAN. Туннель поднялся, но до локальной сети за роутером пакеты не доезжали. Добавил маршрут руками:

ip route add 10.0.0.0/24 dev wg0

Отдельный сюрприз — реальные IP клиентов. Снял masquerade на роутере, чтобы видеть, кто стучится, — и всё легло. WG резал входящие пакеты, чей source не в AllowedIPs. Лечение — расширить AllowedIPs до 0.0.0.0/0 и добавить policy-routing, чтобы ответы Caddy уходили обратно в туннель:

ip rule add from 10.100.0.2 lookup 200
ip route add default via 10.100.0.1 dev wg0 table 200

На роутере — peer и dstnat прямо в туннель:

/interface wireguard peers add interface=wg-lab allowed-address=10.100.0.2/32
/ip firewall nat add chain=dstnat protocol=tcp dst-port=443 action=dst-nat to-addresses=10.100.0.2

Что получили

  • SSH-reverse снесён: ни sshd на роутере, ни keepalive, ни dstnat 443→8443/8080→8080
  • Порты на роутере теперь dstnat-ятся прямо в туннель: 443→10.100.0.2:443
  • Сайт отвечает за ~100 мс, реальные IP клиентов видны в логах, ACL работают
  • Один сервис вместо трёх, ноль keepalive, UDP вместо TCP-поверх-TCP

Мораль

WireGuard — это просто, пока не начнёшь. Но когда выкидываешь костыль из трёх сервисов и получаешь один файл конфига — оно того стоит.

А у тебя туннели до сих пор на SSH-reverse, или уже переехал на WireGuard?

#network #selfhosting #openclaw

Обложка

Когда уехали Visa и Mastercard, я полез оплачивать ChatGPT, GitHub и пару других зарубежных сервисов — и упёрся в стену. СБП внутри РФ работает, а наружу не пускает. Решил попробовать виртуальную карту от Pipl (t.me/pipl) — сервис, о котором много говорят в околоайтишных чатах. Рассказываю, как это устроено на самом деле, со всеми граблями.

🔧 Как это работает

  1. Всё делается через Telegram Mini App бота t.me/pipl
  2. Регистрация с KYC по гос. документу — заняла пару минут
  3. Выпуск карты — от 2 минут после оплаты
  4. Пополняешь через СБП, платишь картой как обычной

По сути это предоплаченная карта Visa/Mastercard: номер, срок, CVV. Есть виртуальные и пластиковые, часть — с Apple Pay/Google Pay. Никакого банковского счёта за этим не стоит.

📊 Цифры, которые стоит знать ДО

  • Выпуск — от 860 ₽ (от 10 USD)
  • Обслуживание — 5$ в месяц, капает всегда
  • Пополнение — комиссия 3,5%
  • Каждая транзакция — 0,5 USD. Даже отклонённая
  • Лимиты — до 50 000 USD за транзакцию, 200 000 в день

💡 Личный опыт

Пользуюсь давно. Работает так:

  • Пополнение по СБП — реально без проблем, деньги приходят быстро
  • Платежи в большинстве сервисов проходят нормально
  • Если сервис отклоняет карту — завожу новую, за пару минут
  • Переводы на карту принять нельзя — только платить с неё

⚠️ Грабли

  • 0,5$ снимается за каждую транзакцию, даже неуспешную. Пока платил, пару раз долбил оплату повторно — и получил минус на балансе просто так
  • Обслуживание 5$/мес списывается, даже если карта лежит без дела — это не «завёл и забыл»
  • Возврат в холде — минуты, после списания — до 35 банковских дней
  • С PayPal карта не работает
  • Для оплаты внутри РФ карта бесполезна — это инструмент строго для зарубежных сервисов
  • После закрытия карты остаток выводят в крипту (до 48 ч) или переносят на новую карту

По сути это удобный, но платный костыль. Для пары подписок в месяц — ок. Держать на ней крупные суммы смысла нет.

#lifehacks

Обложка

Четвёртая часть исследования NetFlow и странных деградаций доступа. В первой части мы научились видеть массовый silent SYN-drop на публичных CGNAT. Во второй — разобрались, почему у ТСПУ нет одного сетевого следа. В третьей — перешли от пассивного NetFlow к активной матрице и научились находить Selective FAIL.

В предыдущей части мы дошли от пассивного NetFlow до активной матрицы: стали проверять один и тот же набор ресурсов с каждого публичного SNAT и получили важный для дальнейшей работы эффект — одна и та же цель в одно и то же время может быть доступна с одного нашего публичного адреса и недоступна с соседнего.

На этом месте можно было бы остановиться и объявить виноватым ТСПУ. Именно этого мы делать не стали.

Следующие несколько дней ушли на попытки сломать собственную гипотезу. Мы проверяли маршрутизацию, разные аплинки, RTT, GeoIP, публичные blocklist, текущую нагрузку абонентов, влияние самого connect-check и даже выясняли, не врёт ли нам /tool fetch на MikroTik.

В результате часть красивых объяснений пришлось выбросить. Зато оставшаяся картина стала интереснее: состояние действительно выглядит привязанным к публичному source IP, способно изменяться во времени, исчезать после простоя и возвращаться после нагрузки. Но теперь у нас гораздо лучше определены границы того, что именно мы наблюдаем и что ещё только предполагаем.

Важная оговорка на всю статью: full, selective, silent, H45, H46, cause_score и другие названия ниже — наши внутренние диагностические классы, а не термины или статусы ТСПУ. Они описывают наблюдаемое поведение сети и нужны для автоматизации расследования.


Откуда вообще появилась гипотеза про «память» адреса

Самое странное наблюдение появилось не в Grafana, а в эксплуатации.

На одном из роутеров публичного Wi‑Fi за NAT находится порядка 500–600 клиентов. Поведение повторяется циклически:

NAT-A работает
   ↓
через некоторое время часть ресурсов перестаёт открываться
   ↓
NAT-A меняем на NAT-B
   ↓
с NAT-B всё снова нормально
   ↓
через день-два NAT-B начинает деградировать
   ↓
переходим на NAT-C
   ↓
к этому моменту NAT-A, который несколько дней не использовался,
снова работает нормально

В июне 2026 года похожая массовая проблема уже была. После изменения политик фильтрации ситуация стала приемлемой. В конце августа деградация массово вернулась.

Для одиночных белых адресов юридических и физических лиц такого эффекта практически не видно. А там, где за одним публичным IP сидят десятки или сотни пользователей, адрес через какое-то время может перейти от отдельных проблем к очень тяжёлой деградации связности.

Это ещё не доказывает конкретный механизм. Но такое поведение плохо похоже на обычную статическую проблему маршрутизации: маршрут сам по себе не должен «очищаться» после 2–3 суток отсутствия абонентского трафика.

Рабочая гипотеза стала такой: где-то в тракте существует динамическое состояние, связанное с публичным source IP. Это может быть список, набор счётчиков, классификатор, профиль или комбинация механизмов. Слово «обучается» здесь лучше использовать осторожно: для наблюдаемого эффекта совершенно не обязательно машинное обучение. Достаточно обычной логики вида «наблюдать → классифицировать → добавить в список/политику → удалить по TTL».

Открытые материалы по архитектуре ТСПУ и отраслевые наблюдения допускают подобную модель: распознанные события могут попадать в централизованный контур и превращаться в очищенные IP+port-списки или другие политики фильтрации. Но публичного доказательства, что конкретно наши NAT-IP проходят именно такой pipeline, у нас нет. Поэтому ниже мы отделяем факты от интерпретации.


Короткая хроника: как менялась версия происходящего

Дата Что произошло Что это нам дало
июнь 2026 массовая деградация, затем после изменения политик ситуация заметно улучшается первый признак, что поведение может зависеть не только от нашей сети
конец августа проблема возвращается массово начинаем системное исследование
8–10 сентября NetFlow → ClickHouse → Grafana, поиск silent, short, SYN/RST, атрибуция к SNAT/inside научились находить подозрительные публичные адреса и тех, кто за ними создаёт шум
10 сентября запускаем /tool fetch прямо с нужного src-address MikroTik впервые измеряем reachability от конкретного NAT-IP
10–11 сентября полная матрица: 101 source × 403 цели = 40 703 probes обнаруживаем массовый Selective FAIL, где одна цель отличается только source IP
11 сентября, ~17:45 контроль .34 против .4 к одному connectivitycheck.gstatic.com:443 внешний наблюдатель на ТСПУ видит ICMP от обоих, но TCP/443-сессию только с рабочего source
11 сентября, ~17:57 соседняя пара .54 OK / .55 BAD к той же цели исключаем объяснение «это разные префиксы»
11 сентября, ~18:53 агрессивно прогоняем 403 цели с рабочего .54, пытаясь «сжечь» его тестом адрес не ломается; гипотеза, что сам connect-check быстро создаёт проблему, не подтверждается
11 сентября, ~19:12 находим разные RTT и traceroute для разных source к одному Google IP L3 path действительно зависит от source
11 сентября, ~19:39 отключаем BGP с аплинк-A, маршрут уходит через аплинк-B путь меняется, но selective TCP не лечится → аплинк/маршрут не root cause
11 сентября, ~20:15 сравниваем текущий трафик BAD и OK NAT находится «тихий BAD» и «шумный OK» → текущий шум не объясняет состояние сам по себе
14 сентября, утро повторяем матрицу/маркер, .55/.22/.30 снова среди худших эффект живёт дольше единичного теста
14 сентября, ~14:00 ряд адресов сообщается как «починенный», маркер на .55/.22/.30 начинает проходить наблюдаем реальный heal без замены SNAT
14 сентября, ~14:04 полный CC на 11 адресах: fail-rate падает на ~13–17 п.п. у известных BAD улучшение видно не только на одном URL
14 сентября, ~15:07 разбираем 8.8.8.8:443: fetch говорит FAIL, conntrack показывает полноценный TCP exchange исправляем важную ошибку методики: fetch FAIL ≠ TCP FAIL
14 сентября, ~17:45 .55 и .22 снова full + silent + escalate, .30 selective на этой выборке восстановление оказывается временным — часы, а не дни
14 сентября, ~17:59 проверяем GeoIP наш jump-host действительно выглядит заграницей, но основные CGNAT-пулы во всех рабочих БД RU → это не объясняет selective NAT
15 сентября уточняем реальную топологию: на исследуемом тракте ТСПУ находится после CGNAT pre-NAT объяснение для нашей площадки снимается: публичный NAT-IP непосредственно виден ТСПУ
15 сентября, день переводим GW1 с набора статических 1:1 src-nat на action=same, затем расширяем непрерывный пул до 203.0.113.2–31 впервые выравниваем число активных inside между 30 публичными IP
15 сентября, 15:12–15:28 NetFlow показывает 39–41% inside одновременно на двух NAT и сильный перекос это не same распределяет плохо — старые conntrack-маппинги ещё живы
15 сентября, ~15:35 после очистки старых conntrack: 2023 активных inside / 30 IP, dual-NAT=0%, среднее 67.4, min–max 59–76, CV=0.06 получаем чистый эксперимент с почти одинаковым числом клиентов на каждом публичном IP
15 сентября при одинаковом числе inside flows всё равно отличаются в разы: .26 ≈22.8k/5m против .9 ≈7.0k/5m «сколько абонентов за NAT» недостаточно; важен профиль нагрузки
15 сентября на GW3 один 100.64.72.64 создаёт ≈1.79 млн flows/15m, 98.4% UDP и >10 тыс. destinations через .92 один абонент способен полностью исказить профиль NAT-IP даже при обычном размере когорты
15 сентября, ~15:41 после ещё одной пачки «направили / починили» массовый поток жалоб стихает до единичных фиксируем фазу heal/штиля, не пытаясь реконструировать закрытый алгоритм фильтра

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


Самый сильный тест — не тысяча FAIL, а одна правильная пара

Полная матрица хороша для поиска кандидатов, но наиболее убедительной оказалась очень простая постановка:

один destination
один момент времени
один тип запроса
одна операторская инфраструктура
меняется только source IP

Пара №1

203.0.113.34 → connectivitycheck.gstatic.com:443 = OK
203.0.113.4  → connectivitycheck.gstatic.com:443 = FAIL

С обоих адресов были запущены ICMP-пробы и одинаковые HTTPS-попытки.

Техническая поддержка со стороны системы наблюдения сообщила важный результат: ICMP виден с обоих source, а сессии TCP/443 — только с 203.0.113.34.

Это ценно тем, что наблюдение сделано не только нашим connect-check. С нашей стороны попытки одинаковы, destination тот же, ICMP проходит у обоих, а поведение TCP различается по source.

Пара №2 — буквально соседние адреса

Чтобы дополнительно убрать разговор про «разные сети», на другом шлюзе выбрали соседей:

203.0.113.54 → connectivitycheck.gstatic.com:443 = OK
203.0.113.55 → connectivitycheck.gstatic.com:443 = FAIL

Оба адреса находятся рядом, работают через один GW, target тот же.

Именно такие пары сейчас важнее любой агрегированной цифры FAIL=64%: они уменьшают число переменных в эксперименте почти до одной.


Как мы поддерживаем постоянную активность с GOOD и BAD адресов

Для ручного расследования недостаточно один раз выполнить fetch. Нужен след во времени, чтобы сторона, наблюдающая ТСПУ, могла поймать те же пакеты и сопоставить их со своими счётчиками.

На MikroTik появились две одинаковые задачи — контрольная и проблемная. Пример для соседей .54/.55:

/system script add name=cc-probe-ok owner=admin policy=read,write,test source={
:local src "203.0.113.54"
:local dstip "connectivitycheck.gstatic.com"
:local url ("https://" . $dstip . "/generate_204")
:do {
  :local r [/tool fetch url=$url src-address=$src duration=2s keep-result=no check-certificate=no as-value]
  :log warning ("cc-probe OK src=" . $src . " dstip=" . $dstip . " status=" . ($r->"status"))
} on-error={ :log warning ("cc-probe OK src=" . $src . " dstip=" . $dstip . " FAIL") }
}

/system script add name=cc-probe-bad owner=admin policy=read,write,test source={
:local src "203.0.113.55"
:local dstip "connectivitycheck.gstatic.com"
:local url ("https://" . $dstip . "/generate_204")
:do {
  :local r [/tool fetch url=$url src-address=$src duration=2s keep-result=no check-certificate=no as-value]
  :log warning ("cc-probe BAD src=" . $src . " dstip=" . $dstip . " status=" . ($r->"status"))
} on-error={ :log warning ("cc-probe BAD src=" . $src . " dstip=" . $dstip . " FAIL") }
}

/system scheduler add name=cc-probe-ok  interval=1m on-event="/system script run cc-probe-ok"
/system scheduler add name=cc-probe-bad interval=1m on-event="/system script run cc-probe-bad"

Так в логах возникает простой временной ряд:

18:50 .54 finished   .55 connecting
18:51 .54 finished   .55 connecting
18:52 .54 finished   .55 connecting
18:57 .54 finished   .55 finished
18:59 .54 finished   .55 connecting
19:00 .54 finished   .55 finished
19:01 .54 finished   .55 connecting

Интересный нюанс: BAD не всегда абсолютно мёртвый. .55 иногда на одну попытку оживает, после чего снова возвращается в connecting. Поэтому бинарный ярлык «работает/не работает» недостаточен — нам нужен timeline и доля успешных попыток.

Следующая автоматизация очевидна: когда Situation Room переводит NAT в selective/full/escalate, создавать короткую probe-задачу автоматически и одновременно выбирать соседний clean/control source. Полную матрицу из 403 URL при этом гонять не надо — для долговременного мониторинга достаточно нескольких маркеров.


Могли ли мы сами «сжечь» адрес своим тестом?

После первых матриц возникла неприятная версия: а что, если сотни активных connect-check сами создают тот профиль трафика, после которого адрес попадает под более жёсткую фильтрацию?

Подозрение усилилось из-за .34: адрес был рабочим, затем маркер перестал проходить. Но разбор времени показал, что flip произошёл примерно за 16 минут до запуска полной матрицы с этого адреса.

Чтобы проверить идею напрямую, взяли заведомо рабочий .54 и намеренно прогнали с него все 403 URL достаточно агрессивно, шестью workers.

Результат:

до матрицы    .54 → marker = finished
во время      .54 → marker = finished
сразу после   .54 → marker = finished
T+10 минут    .54 → marker = finished

Полная матрица дала 135/403 OK_TCP, но контрольный Google marker продолжил работать.

Вывод: на горизонте этого эксперимента версия «403 probes сами быстро портят NAT-IP» не подтвердилась.

Это не означает, что любой объём probes абсолютно безопасен. Но конкретный эффект, который мы наблюдаем сутками на NAT под абонентской нагрузкой, не воспроизвёлся простой короткой матрицей.


Красивая гипотеза про маршрут — и как мы её сломали

Следующей зацепкой стал ping.

К одному и тому же connectivitycheck.gstatic.com разные source IP давали устойчиво разные RTT и traceroute. Для четырёх адресов получилось примерно так:

source роль на тот момент avg RTT
203.0.113.34 OK → позже burned 22.2 ms
203.0.113.4 BAD 26.3 ms
203.0.113.54 OK 27.4 ms
203.0.113.55 BAD 22.6 ms

Traceroute после второго hop расходился по разным веткам к Google.

Сразу родилась версия: возможно, часть source попадает на один ingress/аплинк, часть — на другой, и проблема вообще не в состоянии IP, а в неудачном пути.

На расширенной выборке правило «27 ms = OK, 22 ms = BAD» даже совпало в 13 случаях из 16. Красиво. И неправильно как универсальное объяснение: нашлись контрпримеры — быстрый OK и медленные BAD.

Жёсткий path-эксперимент: отключили аплинк-A

Чтобы не гадать по RTT, на короткое окно выключили BGP с аплинком-A.

Это сработало именно как path-тест:

до:
наша сеть → аплинк-A → Google

после:
наша сеть → аплинк-B → Google

аплинк-A исчез из traceroute у всех четырёх source. RTT перераспределился. То есть маршрут реально изменился.

Но selective TCP не исчез:

source после смены пути
.34 connecting ×3
.4 connecting ×3
.54 finished ×3
.55 один finished, затем снова connecting

Это очень полезный отрицательный результат.

Маршрутизация и source-dependent path у нас действительно есть. Но конкретная ветка через аплинк-A не является достаточным объяснением selective FAIL.

Путь может влиять на измерения и отдельные destinations, поэтому выкинуть routing из анализа нельзя. Но версия «всё из-за одного аплинка» эксперимент не пережила.


«Виноват шумный абонент за NAT» — тоже оказалось слишком просто

Изначальная практическая цель NetFlow-контура была вполне разумной: найти inside, который создаёт scan/amp/VPN-like/аномальный профиль, и понять, связан ли он с деградацией общего SNAT.

Мы для этого и строили cause_score, классы port-scan, host-scan, amp, счётчики SYN, fan-out и т.д.

Но сравнение конкретных адресов дало неприятный для простой модели результат:

SNAT состояние flows за ~2h insides заметный cause
.55 BAD ~365k 82 тяжёлые port-scan/host-scan/amp
.54 OK ~73k 61 тоже заметный host-scan/amp
.4 BAD/flaky ~184 6 нет culprits ≥18
.34 heal ~579 1 фактически только наши probes

С одной стороны, .55 действительно выглядит как отличный кандидат на «NAT испортили абоненты».

С другой — .4 был проблемным почти без текущего шума, а .54 мог быть довольно шумным и при этом оставаться рабочим.

Отсюда более аккуратная модель:

аномальный/концентрированный трафик
          ↓
может повышать вероятность изменения состояния public IP
          ↓
НО текущее состояние public IP не обязано совпадать
с текущим шумом за последние 15 минут / 2 часа

Если у состояния есть TTL, история или внешняя обработка, это как раз ожидаемо. Источник, который создал проблему вчера, сегодня уже может молчать, а метка на публичном IP ещё жить.

Поэтому cause_score остаётся очень полезным для поиска причины и remediation, но не является доказательством текущего блока.


Что сейчас показывает Grafana

Ниже — Situation Room в одном из рабочих срезов исследования.

Situation Room: flows, CC health, H45/H44, quarantine

Эта панель специально смешивает пассивные и активные признаки, но важно правильно читать поля.

Flows/sec

Обычная интенсивность NetFlow. Нужна прежде всего как sanity check: жив ли сборщик и не объясняется ли всё внезапным провалом телеметрии.

CC здоровье egress %

Это не процент работающего Интернета. Это доля healthy-попыток внутри нашего connect-check/CC-профиля. Например, 16.3% на скриншоте означает тяжёлое состояние именно выбранного набора контрольных flow, а не то, что абонентам доступно только 16% сайтов.

NAT: подозрений тихий drop

Количество публичных NAT, у которых пассивная телеметрия похожа на silent SYN-drop: много попыток без ожидаемого ответного продолжения.

Это кандидат на проверку, не приговор.

H45 full blackout NAT

NAT, для которых наш профиль маркеров выглядит как почти полный провал.

H45 selective NAT

Более интересный класс: одни маркеры/цели проходят, другие нет. Именно отсюда удобно искать пары GOOD/BAD source к одному destination.

H44 UNI→CC IMPACT NAT

Корреляция пользовательского unreachable/UNI-сигнала с проблемой в контрольных ресурсах. Это попытка связать абонентскую жалобу и сетевую фактуру.

Escalate IMPACT

Внутренний флаг «уже пора не просто наблюдать, а разбирать этот NAT»: одновременно сходятся несколько независимых признаков.

Profiled NAT

Адреса, по которым накопилось достаточно данных для классификации. Нельзя сравнивать full/selective с общим числом всех адресов, если часть из них в текущем окне просто не имела нужного трафика.

H45 clean NAT

Контрольные адреса, которые в этом же профиле выглядят здоровыми. Они особенно полезны не как KPI, а как источник контрольной пары.


Drill-down: от публичного NAT к конкретному inside

Вторая таблица отвечает уже не на вопрос «какой NAT плохой?», а на «кто за ним сейчас создаёт наиболее подозрительный профиль?».

NAT egress и top cause_score

Слева:

  • nat_ip — публичный SNAT;
  • escalate_impact — сошлись ли критерии эскалации;
  • status_class — общий внутренний класс (full, selective, cc_suspect и т.п.);
  • block_profile — агрегированный профиль активных/пассивных проверок;
  • silent_triage — численная сила silent-кандидата;
  • silent_tag — текстовая интерпретация, например silent_syn_drop.

Справа:

  • stage / stage_label — наша стадия карантина/наблюдения;
  • nat_ip — публичный адрес;
  • inside_ip — внутренний клиент за ним;
  • cause_score — скоринг поведения;
  • cause_class — что именно дало баллы: например port-scan.

Это две разные оси. Плохой cause_score у абонента не означает автоматически, что внешний адрес уже заблокирован. И наоборот: BAD NAT может какое-то время не иметь яркого текущего culprit.


14 сентября: «починили». И мы получили почти идеальный естественный эксперимент

Утром 14 сентября повторный прогон снова показывал знакомую картину. На GW2 среди худших были, в частности:

203.0.113.55   fail 77.7%
203.0.113.22   fail 76.7%
203.0.113.30   около 79% в предыдущем полном ряду

Отдельный HTTPS-marker к connectivitycheck.gstatic.com также не проходил с .55/.22/.30, тогда как соседний .54 работал.

Около 14:00 MSK появился важный внешний триггер: ряд адресов был обозначен как «починенный».

Мы тут же повторили проверку.

Маркер реально ожил

На .55, .22, .30 HTTPS к тому же connectivitycheck.gstatic.com/generate_204 стал проходить. Причём адреса не были просто удалены из src-nat: они оставались реальными активными SNAT.

Полная матрица тоже улучшилась

Через несколько минут запустили 403 URL для 11 адресов из списка. Для трёх, по которым был хороший baseline, изменение получилось заметным:

source до после изменение
.55 77.7% FAIL 62.3% −15.4 п.п.
.22 76.7–78.7% 63.3% −13…15 п.п.
.30 79.2% 62.5% −16.7 п.п.

То есть это было не просто «один удачный запрос». Улучшение видно по сотням целей.

Но через несколько часов состояние снова стало плохим

К ~17:45 тот же мониторинг показывал:

.55 → full + silent + escalate
.22 → full + silent + escalate
.30 → selective

При этом Google-family ACK% у .55/.22 оставался низким.

Для статьи это один из самых интересных результатов всей серии:

BAD
 ↓
внешнее вмешательство / heal
 ↓
marker OK + общий fail-rate заметно лучше
 ↓
несколько часов под нагрузкой
 ↓
снова full/selective/silent

Мы не знаем внутреннего действия, которое было выполнено со стороны фильтра. Поэтому нельзя писать «адрес удалили из такого-то списка». Но сам heal → re-degrade наблюдается инструментально.

И он очень хорошо рифмуется с эксплуатационной ротацией публичного Wi‑Fi, только там естественный период восстановления был порядка 2–3 дней.


Ошибка, которую полезно было поймать: fetch FAIL не равен TCP FAIL

Чем больше автоматизации, тем опаснее неправильный базовый примитив.

В каталоге был удобный IP-literal canary:

https://8.8.8.8/

MikroTik /tool fetch стабильно возвращал WRAP_FAIL. Если смотреть только на него, хочется записать «TCP/443 не проходит».

Мы полезли в conntrack и увидели совершенно другую картину:

src=203.0.113.54:38321 dst=8.8.8.8:443 tcp-state=time-wait
orig-packets=12  repl-packets=9

src=203.0.113.22:53237 dst=8.8.8.8:443 tcp-state=time-wait
orig-packets=12  repl-packets=9

То есть TCP handshake состоялся, данные в обе стороны были, соединение дошло до time-wait. Ошибка произошла выше TCP — на уровне TLS/HTTP/fetch-семантики IP-literal.

Более того, в тот же момент через те же SNAT были реальные абонентские established к 8.8.8.8:443.

После этого матричный скрипт пришлось научить разделять:

OK_FETCH       /tool fetch закончил запрос нормально
OK_TCP         fetch не закончил, но conntrack видит ответный TCP
FAIL           нет подтверждения успешного fetch или TCP-reply
FAIL_TIMEOUT   истёк probe timeout

Это важный кусок методологии: активный тест должен мерить именно тот уровень сети, о котором мы делаем вывод.

curl, fetch, браузер, TCP connect, ICMP и NetFlow отвечают на разные вопросы. Смешивать их в один бинарный OK/FAIL нельзя.


Почему «225 ресурсов не работают нигде» — плохое доказательство

В первой матрице были сотни тегов, которые FAIL на всех source.

Интуитивно хочется сказать: «вот огромный список заблокированного».

Но именно после эксперимента с 8.8.8.8 мы стали гораздо осторожнее.

Если endpoint одинаково не проходит со всех адресов, причина может быть любой:

  • специфика самого /tool fetch;
  • TLS к IP вместо hostname/SNI;
  • UDP/QUIC, который мы проверяем не тем способом;
  • anti-bot;
  • GeoIP;
  • реальная блокировка ресурса;
  • политика самого destination;
  • особенности сети конкретного GW.

Поэтому в доказательной части мы теперь ставим выше не full FAIL everywhere, а selective difference:

тот же endpoint
тот же протокол
тот же момент
source A = OK
source B = FAIL

А затем, где возможно, подтверждаем это conntrack/NetFlow/внешним наблюдателем.


GeoIP: гипотеза оказалась полезной, но не так, как ожидалось

Ещё одна версия: может быть, часть наших адресов для системы выглядит как зарубежные, и поэтому к ним применяются иные политики.

Проверили 23 наших /24 сразу по нескольким GeoIP/RIR источникам.

Результат:

  • 20/23 во всех рабочих базах выглядят как RU;
  • отдельные расхождения есть у трёх неосновных пулов;
  • основные исследуемые CGNAT-пулы — RU;
  • адреса из «починенной» группы тоже в рабочих базах RU.

То есть GeoIP не объясняет выборочную разницу .54/.55 или .34/.4.

Зато обнаружилась методологическая ловушка: наша аналитическая jump-машина сама выходит в Интернет с адреса зарубежного хостера, который внешние сервисы видят как CZ или DE. Значит, проверять с неё доступность российских ресурсов как с «нейтрального русского клиента» нельзя.

Это хороший пример того, как гипотеза может не объяснить исходную проблему, но всё равно найти ошибку в экспериментальной установке.


Топология на нашей площадке: ТСПУ находится после CGNAT

Здесь в ходе расследования появилась принципиально важная поправка к ранней версии статьи. Для конкретно исследуемого тракта наша сеть точка включения ТСПУ находится после CGNAT.

То есть фактическая последовательность такая:

абоненты / private inside
          ↓
        CGNAT
          ↓
  публичный NAT-IP
          ↓
         ТСПУ
          ↓
       Интернет

Это означает, что объяснение «ТСПУ принимает решение до NAT и публичного source вообще не видит» к нашей площадке не относится. На вход ТСПУ приходит уже пост-NAT пакет, где source — тот самый 203.0.113.x.x, который мы меняем в paired tests.

Это заметно усиливает ценность экспериментов .54/.55, .34/.4 и ротации Wi‑Fi NAT: публичный NAT-IP для ТСПУ — не просто удобная координата нашего NetFlow, а непосредственно наблюдаемый сетевой признак.

При этом важно не сделать следующий слишком большой прыжок. Из этого всё ещё нельзя вывести, что внутри ТСПУ существует именно таблица «репутации IP» и что решение ключится только по src_ip. Система может учитывать комбинацию признаков:

  • src IP × destination;
  • destination port / протокол;
  • число и частоту новых соединений;
  • SYN-rate и незавершённые рукопожатия;
  • fan-out по адресам и портам;
  • SNI/протокольные признаки;
  • временные окна и историю активности;
  • несколько разных политик одновременно.

Поэтому аккуратная формулировка такая: на нашей площадке ТСПУ видит публичный CGNAT source и агрегированный за ним профиль трафика; наши эксперименты показывают устойчивую зависимость reachability от этого source, но точный внутренний ключ и алгоритм классификации нам неизвестны.

Раннюю гипотезу H83 про pre-NAT размещение для нашей схемы мы оставили в журнале именно как отвергнутую: это хороший пример того, зачем в расследовании сверять публичные типовые схемы с реальной топологией своей сети.

У проблемы, похоже, несколько временных масштабов

Параллельный поиск по свежим обсуждениям хостеров дал ещё одну полезную альтернативу. Там встречается другой паттерн: кратковременный cooldown по IP клиента и конкретному направлению, порядка десяти минут. Клиент меняет IP или ISP — соединение снова идёт; через несколько минут исходный адрес тоже отпускает.

Это похоже на наш selective fail формой, но не длительностью. Поэтому мы больше не хотим складывать всё в один класс «IP испортился».

Для анализа ввели три временных бакета fail-окон:

≤ 15 мин       → h84_like: короткий cooldown
15–120 мин     → mid
≥ 120 мин      → h80_like: sticky / многочасовое состояние

И написали отдельный bucket_snat_fail_windows.py, который строит 5-минутный ряд по Google-family ACK% для конкретного public egress, склеивает соседние fail-бакеты и считает длительность непрерывных окон.

Первый smoke по .55 и контрольному .54 оказался особенно полезен. На .55 видны не только короткие всплески: присутствуют окна порядка 115 минут и около 245 минут. То есть наблюдаемая 14 сентября деградация больше похожа на многочасовой/sticky класс, чем на чистый десятиминутный cooldown. У .54 при этом тоже встречаются отдельные короткие и даже длинные плохие окна — ещё одно напоминание, что бинарный ярлык GOOD/BAD слишком груб.

Теперь для каждого инцидента имеет смысл хранить не только fail%, но и распределение длительностей непрерывных fail-серий. Если окажется, что короткие 5–15-минутные окна и многочасовые реблоки статистически различаются по destinations или по профилю трафика, это уже будут два разных механизма, которые раньше мы ошибочно смешивали.


Публичные DNSBL/reputation тоже не отделили GOOD от BAD

Мы проверяли и более банальное объяснение: вдруг адреса просто числятся в публичных reputation/blocklist.

Для исследуемой четвёрки публичные DNSBL не дали полезного разделения GOOD/BAD. Часть ответов была техническим policy/open-resolver ответом, часть — NXDOMAIN, но признака «вот эти два BAD опубликованы в blocklist, а эти два OK нет» не получилось.

Это не говорит, что никакой внешней репутации не существует. Это только означает, что публично проверенные списки не объясняют наблюдаемую выборку.


Что из гипотез мы сейчас считаем отвергнутым или сильно ослабленным

«Это просто маршрутизация»

Ослаблено как root cause. Source-dependent path подтверждён, но принудительная смена аплинк-A → аплинк-B selective проблему не вылечила.

«Агрессивный connect-check сам портит IP за несколько минут»

Не подтвердилось на .54: 403 URL × несколько workers не сломали маркер в наблюдаемом окне.

«BAD NAT обязательно прямо сейчас содержит самого шумного inside»

Не держится: есть quiet BAD и noisy OK. Историческое состояние важнее одной короткой выборки.

«/tool fetch FAIL означает, что TCP handshake не прошёл»

Опровергнуто. На 8.8.8.8:443 были reply packets и time-wait при WRAP_FAIL.

«Если цель FAIL со всех source, это сильное доказательство ТСПУ»

Нет. Это как раз слабый эксперимент: слишком много общих переменных.

«Основные CGNAT выглядят как зарубежные»

Не подтверждается рабочими GeoIP-базами для основных CGNAT-пулов.

«Публичный DNSBL объясняет BAD адреса»

Не подтвердилось на проверенной выборке.

«Soft quarantine должен немедленно вылечить уже плохой публичный IP»

Практика этого не показала. Quarantine полезен как remediation текущего источника шума, но уже существующее внешнее состояние SNAT от этого мгновенно не исчезает.


Какие гипотезы, наоборот, пережили проверки

1. Публичный CGNAT source — реальная координата эффекта и непосредственно виден ТСПУ

Один destination и один протокол дают разный результат при смене только публичного source. На нашей реальной топологии ТСПУ стоит после CGNAT, поэтому этот source непосредственно присутствует в пакетах на его входе. Это не доказывает, что src_ip — единственный внутренний ключ, но pre-NAT объяснение для этого тракта больше не требуется.

2. Состояние динамическое

Адрес может быть BAD, затем восстановиться, затем снова ухудшиться. Это видно и в полевой ротации Wi‑Fi NAT, и в серии 14 сентября.

3. У состояния есть инерция

Текущее поведение insides не всегда объясняет текущий статус. Quiet BAD возможен. Это согласуется с моделью, где решение зависит от истории, временного окна или TTL некоторого состояния.

4. Концентрация за NAT важна, но число пользователей само по себе недостаточно

После перехода GW1 на same мы получили почти одинаковое число активных inside на каждом из 30 публичных адресов: 59–76, CV 0.06. Но количество flows при этом различалось более чем втрое — от ~7 тыс. до ~22.8 тыс. за 5 минут.

Значит, следующий кандидат — не просто users_per_ip, а агрегированный профиль: flows/s, SYN/s, число новых соединений, uniq dst, uniq dst:port, протокольный mix и отдельные очень активные абоненты.

5. Один inside способен радикально изменить профиль всего NAT-IP

На GW3 адрес 203.0.113.92 выглядел аномально не потому, что на нём было больше пользователей, чем у соседей. Один 100.64.72.64 дал около 1.79 млн flows за 15 минут, 98.4% UDP и более 10 тыс. destinations. Это почти полностью объяснило перекос целого публичного NAT.

6. Реакция может быть destination/profile-specific

Даже «рабочий» NAT по одному Google marker не обязан быть зелёным по всей матрице. Поэтому правильнее говорить не «IP заблокирован/разблокирован», а про профиль reachability и его изменение во времени.

Текущая рабочая модель

После уточнения реальной топологии модель стала проще в одном месте и осторожнее в другом.

То, что мы знаем о тракте:

inside-клиенты
      ↓
    CGNAT
      ↓
public NAT-IP + агрегированный профиль
      ↓
    ТСПУ
      ↓
  Интернет

То, что мы наблюдаем:

одинаковый dst / один GW / одно время
             │
        меняем src IP
             │
      OK  ←→  selective FAIL
             │
   heal после простоя / вмешательства
             │
       возможный re-degrade

А вот следующий слой остаётся гипотезой:

public source + traffic profile
 (flows, SYN, fan-out, dst/port, protocol, history ...)
                    ↓
        некоторое внешнее state / policy
                    ↓
        selective → тяжёлая деградация
                    ↓
          cooldown / TTL / пересчёт / heal

Мы не знаем, один ли это механизм. Более того, измерения уже намекают как минимум на разные временные классы: краткие окна порядка 10–15 минут и многочасовые/sticky состояния, а эксплуатационная ротация Wi‑Fi даёт ещё и горизонт 2–3 суток.

Поэтому слово «память IP» в заголовке — удобная метафора наблюдаемой инерции, а не утверждение о конкретной структуре данных внутри ТСПУ.

15 сентября: выровняли CGNAT через action=same

До этого у сравнения NAT-IP оставалась фундаментальная слабость: разные белые адреса обслуживали сильно разные внутренние диапазоны. На одном могло быть несколько десятков активных клиентов, на другом — сотни. Любую разницу можно было списать на размер когорты.

Чтобы убрать эту переменную, на GW1 мы начали переводить абонентский NAT с набора статических 1:1-правил на RouterOS action=same с same-not-by-dst=yes. Пилот стартовал на .7–15, затем за счёт свободных адресов и переноса непрерывного блока пул расширили до:

203.0.113.2–203.0.113.31

То есть 30 публичных IP.

Смысл правила — не «лечить ТСПУ», а создать более чистую экспериментальную установку: один inside стабильно получает один адрес из общего пула, а клиенты распределяются между public IP значительно равномернее.

Упрощённо правило выглядит так:

/ip firewall nat
add chain=srcnat action=same \
    src-address-list=CGNAT-POOL-TEST \
    out-interface=ether-wan \
    to-addresses=203.0.113.2-203.0.113.31 \
    same-not-by-dst=yes

Старые статические правила не удаляли — их отключили и оставили как rollback.

Грабли: старый conntrack делает same визуально «кривым»

Первые замеры выглядели плохо. В 5-минутном окне около 15:12–15:28 MSK примерно 39–41% inside одновременно наблюдались на двух публичных NAT. Отдельные адреса держали более 200 inside, тогда как другие — около 60–80.

Причина оказалась не в алгоритме same. Старые соединения сохраняли прежний 1:1 NAT в conntrack, а новые уже получали адрес из нового пула. NetFlow видел смесь двух эпох.

После удаления старых conntrack-маппингов картина буквально схлопнулась.

Чистый срез после flush

Окно 12:29–12:33 UTC (~15:33 MSK):

Метрика Результат
public IP в пуле 30/30
активных inside за 5 минут 2023
inside одновременно на >1 NAT 0 / 2023
среднее клиентов на public IP 67.4
медиана 67
min–max 59–76
max/min 1.29×
CV 0.06

Это уже почти идеальная база для дальнейшего сравнения clean → selective → full: число клиентов на каждом публичном IP близкое.

Но flows всё равно не выровнялись

И тут появился следующий важный результат. При одинаковых 59–76 inside объём flows отличался более чем втрое:

203.0.113.26   70 inside   22 805 flows / 5m
203.0.113.18   66 inside   16 574 flows / 5m
203.0.113.9    65 inside    6 974 flows / 5m

То есть users_per_ip — слишком грубая величина. Два NAT с одинаковыми 67 клиентами могут выглядеть для внешнего анализатора совершенно по-разному.

Теперь основной эксперимент становится намного чище: держим количество inside примерно одинаковым и смотрим, что лучше предсказывает ухудшение — flows/s, новые TCP, SYN-rate, fan-out, uniq dst, uniq dst:port, доля UDP, география destinations или конкретные профили отдельных клиентов.

Один абонент против всего NAT

Почти одновременно на GW3 нашёлся полезный контрпример к модели «главное — количество клиентов».

203.0.113.92 обслуживал примерно столько же inside, сколько соседние NAT, но создавал на порядок больше flows. Drill-down показал источник:

inside:    100.64.72.64
flows:     ~1 791 481 / 15 min
uniq dst:  10 256
UDP:       98.4%
traffic:   ~2.1 GB

Один inside короткими UDP-очередями последовательно бил множество destinations и почти в одиночку формировал аномальный профиль публичного .92.

Это не доказывает, что именно такой трафик вызывает исследуемую фильтрацию. Но оно доказывает более полезную вещь для следующего этапа: считать только количество пользователей за NAT недостаточно; один пользователь способен сделать профиль адреса принципиально другим.

Что этот эксперимент даст дальше

Если после выравнивания числа клиентов отдельные .2–31 всё равно начнут расходиться по reachability, мы сможем сравнить их уже без старого возражения «на плохом просто было в пять раз больше абонентов».

Если же деградация начнёт хорошо коррелировать с определёнными нагрузочными признаками, мы приблизимся к практическому ответу — какой профиль публичного CGNAT надо не допускать, независимо от того, как именно он называется внутри ТСПУ.

Важно: same здесь — инструмент эксперимента и способ равномернее распределить агрегацию, а не заявленное средство обхода или гарантированное лечение фильтрации.


Что будем мерить дальше

Теперь основная задача — не расширять количество красивых дашбордов, а измерить жизненный цикл одного NAT-IP.

1. GOOD/BAD paired probe постоянно

Для каждого инцидента сохранять хотя бы одну пару:

BAD source → marker A/B/C
GOOD source → те же marker A/B/C

с периодом 1–5 минут.

2. Фиксировать момент снятия NAT-нагрузки

Для ротации Wi‑Fi адресов особенно интересно получить строгую шкалу:

T0        IP сняли с NAT
T+6h      всё ещё BAD
T+24h     selective
T+48h     часть маркеров OK
T+60h     clean

Пока «2–3 дня» — устойчивое эксплуатационное наблюдение. Его надо превратить в таблицу.

3. Фиксировать момент повторного ввода

После восстановления вернуть адрес под контролируемую нагрузку и измерить time-to-degrade.

Это будет гораздо полезнее ещё одной разовой матрицы.

4. Разделять L4 и application probe

Новая версия mt_cc_full_matrix.py уже умеет после fetch смотреть conntrack. Для спорных endpoints должны храниться отдельно:

ICMP_OK
TCP_REPLY
TLS/HTTP_OK
FETCH_OK

5. Маленький маркерный набор вместо постоянных 403 URL

Полная матрица — диагностика/снимок. Для continuous monitoring лучше 5–10 хорошо изученных targets разных классов, у которых известна нормальная семантика теста.

6. Автоматические задания на проблемные NAT

Логика может выглядеть так:

H45 selective/full OR escalate_impact
        ↓
выбрать BAD NAT
        ↓
выбрать CLEAN NAT того же GW/префикса
        ↓
создать paired probe job на 1–5 минутный интервал
        ↓
писать timeline в ClickHouse
        ↓
при heal продолжить наблюдение ещё N часов

Именно продолжение после heal важно: 14 сентября показало, что «сейчас снова работает» ещё не означает устойчивого восстановления.

7. Бакетировать длительность fail-окон

Теперь к paired probes добавляется ещё одна ось — время непрерывного отказа. Для каждого SNAT считаем серии по 5-минутным окнам и разделяем хотя бы на:

короткие ≤15 мин
средние 15–120 мин
длинные ≥2 часов

Это должно помочь не смешивать краткие src×dst cooldown-паттерны с нашим H80-подобным многочасовым reblock. Контрольный сосед обязателен: если такие же длинные окна регулярно есть и у «чистого» адреса, метрика сама по себе недостаточна.

8. Длительное наблюдение за уже выровненным same-пулом

Сам эксперимент с распределением уже запущен: GW1 работает через 203.0.113.2–31, и после очистки старого conntrack число активных inside распределилось почти равномерно.

Теперь для каждого из 30 адресов нужно вести единый временной ряд:

inside_unique
flows/s
new TCP/s
SYN/s
uniq dst
uniq dst:port
UDP%
RU/non-RU dst
CC marker success%
H45 selective/full/clean
time_since_clean

Ключевой вопрос теперь звучит не «много ли абонентов за адресом», а почему два NAT с одинаковыми ~67 активными inside могут иметь разную нагрузку и, возможно, разную судьбу reachability.

Отдельно нужно помечать single-inside outliers наподобие 100.64.72.64, чтобы не спутать поведение когорты с поведением одного очень активного источника.


Что теперь можно утверждать достаточно уверенно

Первое. У нас есть воспроизводимые случаи, когда reachability к одному destination зависит от публичного source IP.

Второе. Эффект наблюдается в пределах одного префикса и одного GW, в том числе на соседних адресах .54/.55.

Третье. ICMP может оставаться полностью живым, когда TCP/application probe различается по source.

Четвёртое. Независимая точка наблюдения на стороне ТСПУ в одном из тестов увидела ICMP от обоих адресов, но TCP/443-сессию только от рабочего source.

Пятое. Принудительная смена upstream path аплинк-A → аплинк-B не устранила selective эффект.

Шестое. Короткая агрессивная матрица сама по себе не воспроизвела «сгорание» контрольного NAT.

Седьмое. Состояние может улучшаться и снова ухудшаться. 14 сентября после наблюдаемого heal нескольких SNAT часть из них за несколько часов вернулась в тяжёлый профиль.

Восьмое. Наш прежний бинарный fetch OK/FAIL был слишком грубым: часть FAIL происходит после успешного TCP. L4 и application-level результат нужно разделять.

Девятое. На исследуемом тракте ТСПУ находится после CGNAT, поэтому публичный NAT-IP и агрегированный за ним трафик непосредственно видны системе на входе. Это усиливает source-dependent гипотезу, но не раскрывает внутренний алгоритм.

Десятое. У отказов могут быть разные временные классы: краткие ~10–15 минут, многочасовые sticky-окна и эксплуатационный цикл восстановления порядка нескольких суток.

Одиннадцатое. После перевода GW1 на same и очистки старого conntrack 2023 активных inside распределились по 30 public IP почти равномерно: 59–76 на адрес, CV 0.06, dual-NAT=0.

Двенадцатое. Равное число клиентов не означает равный трафик: при почти одинаковом inside_unique flows различаются более чем втрое. Следовательно, для поиска триггера нужно анализировать профиль, а не только users/IP.

Тринадцатое. Один активный inside способен доминировать в профиле целого NAT: кейс 100.64.72.64 дал ~1.79 млн flows/15m и >10 тыс. destinations через .92.

И чего мы всё ещё не доказали

Мы не доказали, что конкретный механизм — это именно один определённый «список репутации ТСПУ».

Мы не знаем, какой traffic feature является главным триггером. После same стало даже понятнее, что простое число клиентов слишком грубо: одинаковые по размеру когорты дают очень разный flows/SYN/fan-out профиль.

Мы не доказали, что каждый selective FAIL создан ТСПУ: часть endpoints и часть ошибок /tool fetch имеют другие причины.

Мы не знаем, является ли публичный src_ip самостоятельным ключом, частью src×dst/profile-классификации или просто одним из нескольких признаков. Мы знаем только, что в нашей топологии он непосредственно виден ТСПУ, потому что ТСПУ расположен после CGNAT.

Мы не знаем, равны ли 2–3 дня полевого «отлёживания» TTL какого-либо списка. Это только наблюдаемый период восстановления. И пока не доказано, что многосуточный цикл Wi‑Fi, многочасовой reblock 14 сентября и короткие ~10-минутные cooldown-окна — один механизм.

Мы также не должны путать результат приложения с результатом сети: fetch FAIL может происходить уже после успешного TCP handshake.

Главный методологический итог этой части остаётся прежним: чем сильнее хочется назвать найденный паттерн доказательством, тем важнее поставить эксперимент, который может его опровергнуть. Переход на same как раз и нужен для следующего такого эксперимента — мы убрали сильный confounder в виде wildly-разного числа клиентов за разными NAT.

Вместо вывода

Начинали мы с довольно простой картинки: за NAT много пользователей, ТСПУ «не любит» адрес, он ломается.

После недели измерений картинка стала сложнее, но и полезнее.

Мы увидели:

source-dependent reachability
        +
динамический heal / re-degrade
        +
необязательная корреляция с текущим шумом
        +
устойчивость эффекта к смене uplink path
        +
независимое различие TCP при одинаковом ICMP

Это уже достаточно, чтобы перестать обсуждать проблему в терминах «у абонента иногда сайт не открывается».

Теперь это измеряемое состояние публичного egress, для которого можно строить историю, сравнивать контрольные адреса, ловить начало деградации и проверять восстановление.

А 15 сентября мы изменили уже не только измерительный контур, но и саму экспериментальную установку: вместо сильно неравномерных статических 1:1-NAT получили 30 публичных адресов с почти одинаковым количеством активных клиентов через same. Это убирает один из главных confounders предыдущих сравнений.

Следующая цель — поймать полный цикл одного из выровненных адресов от clean до selective/full, сопоставить момент деградации с flows/SYN/fan-out/profile, затем снять его с нагрузки, измерить время восстановления и снова ввести под контролируемую нагрузку.

Если этот цикл окажется воспроизводимым, спор о том, «кажется нам или не кажется», закончится. Останется более интересный вопрос: какое именно поведение переводит публичный IP из одного состояния в другое и как оператору строить NAT, чтобы один активный абонент не портил связность сотне соседей.


Технические артефакты этой части

Скрипты выложены для скачивания:

Дисклеймер. Это эксплуатационное исследование сетевой доступности на собственной инфраструктуре оператора. Оно не является описанием способа обхода фильтрации и не претендует на знание внутренней реализации ТСПУ. Все выводы о механизме, выходящие за непосредственно измеренные сетевые эффекты, помечаются как гипотезы.

#network #monitoring #netflow

Обложка

У меня в лабе живёт мини-ПК на AMD Phoenix: внутри встроенная графика Radeon 760M (ядро gfx1103), 16 ГБ общей памяти, и ни одной дискретной карты. На первый взгляд — что там гонять, это же iGPU от ноутбучного кристалла. Но у него есть то, чего нет у процессора: настоящий GPU-пайплайн и прямой доступ к общему куску быстрой памяти. А у меня давно чесалось запустить локальную LLM — без аренды видеокарты в облаке и без отправки своих вопросов на чужие серверы.

Спойлер: завелось и работает. Но не так, как обещают красивые посты «локальный DeepSeek за пять минут на любом железе». Давайте по порядку — боль, решение, честные цифры и грабли.

Зачем вообще локально

Сначала про мотивацию, потому что без неё весь этот геморрой выглядит бессмысленным. Локальная модель — это три вещи. Первое: приватность, вопросы не уезжают в чужое облако. Второе: нет счётчика за токены, гоняешь сколько влезет. Третье: она не падает вместе с API-провайдером и не ловит лимиты в час пик. Для рутины и черновиков этого достаточно. А вот для vision, тяжёлых агентов и огромных контекстов — уже нет, но об этом ниже.

Боль: AMD в контейнере — отдельный вид спорта

У Nvidia в контейнерах всё отлажено годами: пробросил /dev/nvidia*, поставил nvidia-container-toolkit — поехало. У AMD за это отвечает протокол ROCm. Под дискретные карты он ещё как-то жил, а вот встроенная графика долго числилась пасынком: официально gfx1103 в ROCm не значится, поддержки «из коробки» нет.

Вторая проблема — окружение. Я держу всё в LXC-контейнерах под Proxmox, а не в голом Docker на хосте. LXC — это не виртуалка и не докер: устройств из коробки не видно, udev внутри не рулит, права на /dev надо городить руками. То есть классического «развернул и забыл» тут не будет в принципе. Каждый шаг — ручная настройка.

Проброс устройств: три ноды и никакой магии

Внутри контейнера Ollama должен увидеть три устройства:

  • /dev/dri/renderD128 — вычислительный узел рендера, именно через него идут вычисления;
  • /dev/dri/card0 — карта как устройство отображения;
  • /dev/kfd — Kernel Fusion Driver, дверь в ROCm-стек.

Проброс на Proxmox делается одной командой через pct set:

pct set 107 -dev0 /dev/dri/renderD128 -dev1 /dev/dri/card0 -dev2 /dev/kfd

Устройства в контейнере появятся, но права по умолчанию будут root. А Ollama у меня бегает не от root — и без прав на эти ноды он упадёт на первой же секунде. Внутри LXC не работает udev, поэтому выставляю права через tmpfiles.d: правило отрабатывает при старте контейнера и раздаёт владельца и группу:

# /etc/tmpfiles.d/gpu.conf
z /dev/dri/renderD128 0666 root render -
z /dev/dri/card0      0666 root video  -
z /dev/kfd           0666 root render -

Всё, сервис видит железо. Но само железо ещё «не говорит по-человечески».

ROCm-хак: выдаём gfx1103 за gfx1100

Вот ради чего пост называется «хаком». Встроенная графика в моём кристалле — gfx1103, и ROCm её официально не знает. Но архитектурно gfx1103 — почти близнец дискретного gfx1100, ядра той же линейки RDNA3. В ROCm есть переменная, которая подменяет идентификатор GPU для драйвера:

HSA_OVERRIDE_GFX_VERSION=11.0.0

«Прикинься gfx1100» — и стек начинает грузить ядра для него. Плюс вторая переменная, которая говорит Ollama, что на встроенную графику можно:

OLLAMA_IGPU_ENABLE=1

Обе вешаю через drop-in юнита, чтобы не трогать сам systemd-файл сервиса:

# /etc/systemd/system/ollama.service.d/gpu.conf
[Service]
Environment=HSA_OVERRIDE_GFX_VERSION=11.0.0
Environment=OLLAMA_IGPU_ENABLE=1

daemon-reload, рестарт сервиса — и смотрим, подхватилась ли карта.

Честные цифры: что реально получилось

Сначала убеждаюсь, что инференс реально ушёл на GPU, а не молотится на CPU втихаря. ollama ps показывает процессор, на котором крутится модель, и там красуется:

PROCESSOR  100% GPU

Это самый приятный момент во всей истории — когда после пары часов ковыряния видишь, что встроенная графика честно тянет модель, а не процессор делает вид. Теперь цифры.

gemma3:12b — основная рабочая лошадка:

  • 8,1 ГБ на диске;
  • 8,0 ГБ в видеопамяти;
  • 14,8 токена/сек;
  • контекст 131072 токена.

gemma3:4b — запасная:

  • 3,3 ГБ;
  • 23,5 токена/сек;
  • контекст 32768.

Для сравнения был ещё fp16-вариант — и он разочаровал: те же 8,1 ГБ видеопамяти, но всего 8,1 ток/сек. Вдвое дороже по ресурсам, вдвое медленнее, а прироста качества на глаз ноль. Выкинул без сожалений.

Важная деталь: у встроенной графики нет своей выделенной видеопамяти, она заимствует её у общей системной. Поэтому 8 ГБ «VRAM» — это те же 8 ГБ из 16 ГБ оперативки. Модель на 12b сюда влезает впритык, а на 16b уже не хватило бы с запасом на контекст. Это и есть главный потолок iGPU — не скорость, а память.

14–24 токена в секунду — это не «вау, летает». Это уровень «читает примерно с той же скоростью, что и я»: ответы не мгновенные, но и не бесконечные. Для локального помощника, которому задал вопрос и ждёшь абзац, — комфортно. Для потоковой генерации больших текстов — уже на грани.

Где ожидания врут

Вот тут самое важное. Картинка «завёл локальный LLM на iGPU и получил бесплатного ChatGPT» красивая, но в ней три жирные оговорки.

Вижн не работает. Ни на одной модели. CLIP-энкодер, который разбирает картинки перед подачей в модель, на ROCm gfx1100 просто падает. Картинки в этой связке — только через облачную vision-модель, и точка.

Длинные промпты роняют 12b. Скармливаешь конфиг или документ больше 10 КБ — gemma3:12b улетает с CUBLAS_STATUS_INTERNAL_ERROR. Короткие промпты стабильны, длинные — русская рулетка. Младшая gemma3:4b длинные тексты, кстати, переваривает (30 КБ тянет), но медленно и с маленьким окном контекста.

8B-модели не годятся в агентский режим. Пробовал hermes3:8b и llama3.1:8b в роли субагентов, которые должны ходить по инструментам и писать отчёты, — вместо отчёта получал мусор. Удалил с диска. Локально хорошо работает «ответь на вопрос», а не «пойди и сделай».

Итого: встроенная AMD-графика — это честный локальный инференс для приватных вопросов, черновиков и простой рутины. Но не замена облачному vision, не площадка для тяжёлых агентов и не бесконечное окно контекста. Знать это заранее — половина успеха, чтобы потом не расстраиваться.

Итог

Если у вас мини-ПК на свежем AMD и хочется попробовать локальную LLM без вложений в видеокарту — заводится. Рецепт: пробросить три устройства в контейнер, выставить права через tmpfiles.d, подменить gfx1103 на gfx1100 переменной HSA_OVERRIDE_GFX_VERSION и разрешить iGPU через OLLAMA_IGPU_ENABLE. Дальше — смотреть на PROCESSOR 100% GPU и радоваться, что вопросы больше не уезжают в чужое облако.

Просто помните: это iGPU, а не дата-центр. 15–24 токена в секунду, без вижна, с капризами на длинных промптах и потолком в 16 ГБ общей памяти. Зато бесплатно, приватно и — после пары часов мата — стабильно.

Теги: #llm #ollama #rocm #selfhosting

Обложка

Цикл «Не светим лишнего». Выпуск 7.

У классического knocking есть понятная проблема: секретная последовательность летит по сети открытым текстом. Кто увидел — тот может повторить.

Single Packet Authorization, или SPA, решает ту же задачу иначе. Клиент отправляет один специальный пакет, внутри которого находится криптографически защищённый запрос. Шлюз проверяет подлинность и только потом временно меняет firewall.

Что лежит в пакете

Конкретный формат зависит от реализации, но логика обычно включает:

  • идентификатор клиента или ключа;
  • временную метку;
  • случайное значение, nonce;
  • желаемый сервис или порт;
  • шифрование и/или HMAC для проверки целостности и подлинности.

Сервер хранит сведения о недавно принятых пакетах и не принимает тот же запрос повторно. Поэтому просто записать трафик и воспроизвести вечером уже недостаточно.

Из известных open-source реализаций есть fwknop. Он описывает SPA-пакет как зашифрованный, неповторяемый и аутентифицированный HMAC. Скрытый SSH остаётся недоступным для обычного сканирования, пока сервер не принял корректный пакет.

Чем SPA лучше knocking

Здесь нет цепочки, которую можно случайно собрать сканированием портов. Подмена содержимого ломает проверку HMAC. Временная метка и защита от повторов уменьшают риск replay. В запрос можно аккуратно вложить параметры: какой сервис требуется и на какое разумное время.

При этом сам защищаемый сервис всё ещё не выставлен постоянно.

Цена удобства

На клиентской стороне нужна утилита, ключ и конфигурация. Для системного администратора это нормально. Для случайного подрядчика, который пришёл со смартфоном, — уже нет.

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

Ещё один момент: SPA-сервер должен видеть пакет до обычного firewall drop. На Linux это понятная схема с fwknop и поддерживаемым firewall. На RouterOS готовая интеграция не такая прямая; обычно нужен внешний сервис, который проверит пакет и через API обновит MikroTik, либо SPA завершается на Linux-шлюзе.

Плюсы

  • сервис скрыт до авторизации;
  • один пакет вместо последовательности;
  • криптографическая проверка;
  • защита от простого повторного воспроизведения;
  • возможность персональных ключей;
  • удобно для скриптов и технических пользователей.

Минусы

  • клиентское ПО или собственная утилита;
  • управление ключами;
  • более сложная диагностика;
  • не очень подходит обычному офисному пользователю;
  • интеграция с конкретным firewall может потребовать отдельного контроллера.

Где можно больно ошибиться

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

Не проверять время и повторы. Подписанный, но бесконечно повторяемый пакет превращается в вечный ключ.

Открывать порт надолго. SPA подтверждает запрос в момент отправки, но не контролирует присутствие пользователя через два часа.

Хранить ключ прямо в публичном скрипте. Автоматизация должна использовать защищённое хранилище и минимальные права.

Считать источник индивидуальным после открытия. Сам SPA-пакет может быть персональным, но обычный последующий трафик firewall снова различает прежде всего по адресу. Общий NAT никуда не делся.

Когда SPA имеет смысл

Это хороший инструмент для администраторов, автоматизации и аварийного доступа, когда установка клиента допустима. Если основное требование — открыть браузер на любом компьютере и ничего не устанавливать, удобнее captive portal или TOTP-портал. К ним и переходим.

Ранее в цикле

Документация и источники

#network