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

netflow

Обложка

Четвёртая часть исследования 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

Обложка

Третья часть исследования NetFlow и странных деградаций доступа. В первой части мы научились видеть массовый silent SYN-drop на публичных CGNAT. Во второй — разобрались, почему у ТСПУ нет одного сетевого следа: три соединения, 16 КБ и «сибирская блокировка».

Во второй части у нас оставалась неприятная методологическая дыра. NetFlow хорошо показывал, что с публичным egress происходит что-то странное: росли syn_no_ack, короткие flows, появлялись односторонние обращения к контрольным ресурсам. Но сам по себе этот след всё ещё не отвечал на главный вопрос:

ресурс действительно недоступен именно с этого публичного NAT-адреса — или мы просто неправильно интерпретируем телеметрию?

За следующие два дня мы сильно продвинулись именно здесь. К пассивному наблюдению добавили активную проверку: заставляем сам MikroTik устанавливать соединение с конкретного публичного src-address, а затем прогоняем один и тот же набор целей через весь пул SNAT.

И вот здесь картина стала намного интереснее.


Почему обычного curl с сервера недостаточно

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

За одним edge-роутером может быть несколько десятков публичных адресов, используемых для SNAT. И мы уже видели ситуацию, когда два соседних адреса одного пула ведут себя совершенно по-разному:

NAT A → Google timeout
NAT A → Cloudflare OK

NAT B → Google OK
NAT B → Cloudflare OK

Маршрут тот же. Роутер тот же. Аплинк тот же. Destination тот же. Меняется только исходный публичный IP.

Поэтому проверку нужно выполнять именно так:

один GW
одна цель
одинаковый способ подключения
разные src-address

На RouterOS это можно сделать штатным /tool fetch с явным src-address.


Минимальная проба на MikroTik

В самом простом виде тест выглядит так:

/tool fetch \
    url="http://connectivitycheck.gstatic.com/generate_204" \
    src-address=203.0.113.x \
    duration=2s \
    keep-result=no

Если fetch завершается, путь с этого source IP до цели существует. Если соседний SNAT тем же способом стабильно получает timeout, уже появляется интересная фактура.

Но при массовом прогоне обнаружилась RouterOS-грабля: failed /tool fetch, запущенный через SSH, пишет в лог:

script,error executing script from sshd failed, please check it manually

Когда таких проверок десятки тысяч, лог роутера превращается в мусор. Поэтому рабочий вариант пробы у нас теперь такой:

:do {
    :local x [/tool fetch \
        url="http://connectivitycheck.gstatic.com/generate_204" \
        src-address=203.0.113.x \
        duration=2s \
        keep-result=no \
        as-value]
    :put ("STATUS=".$x->"status")
} on-error={
    :put "WRAP_FAIL"
};
:put "DONE"

as-value + on-error позволяют получить машинно разбираемый результат и при этом не засыпать системный log сообщениями script,error на каждом timeout.

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


Откуда берём публичные адреса для проверки

Мы не хотим тестировать произвольный список адресов из Excel. Скрипт сам забирает с MikroTik две вещи:

/ip address print where disabled=no
/ip firewall nat print detail where chain=srcnat and disabled=no

Дальше строится пересечение:

local = parse_local_ips(addr_raw, wan)
nat_tos = parse_srcnat_tos(nat_raw)
srcs = sorted(local & nat_tos)

То есть в матрицу попадает адрес, который одновременно:

  1. реально присутствует на данном маршрутизаторе;
  2. реально используется в srcnat to-addresses.

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

Скрипт умеет разворачивать и диапазоны to-addresses=A-B, поэтому вручную перечислять пул не нужно.

Полный рабочий вариант лежит в scripts/mt_cc_full_matrix_probe.py.


403 цели × весь SNAT-пул

Вторую половину матрицы мы собираем из нашего connect-check.

Отдельный скрипт build_cc_probe_urls.py объединяет:

  • resources.conf;
  • встроенный каталог connect-check;
  • captive-check URL разных ОС;
  • DoH/AI/IoT/update/game endpoints;
  • контрольные зарубежные и российские сервисы.

На текущей версии получилось 403 цели.

Дальше для каждого gateway строится декартово произведение:

каждый src-nat × каждая цель

В последнем полном прогоне:

GW SNAT source Проб OK FAIL
ot1 42 16 926 5 934 10 992
ot2 20 8 060 2 834 5 226
ot3 39 15 717 5 736 9 981
Всего 101 40 703 14 504 26 199

То есть одним запуском мы получаем не «у меня не открылся сайт», а полноценную матрицу:

             NAT-1   NAT-2   NAT-3   NAT-4  ...
T-Банк         FAIL     OK      OK     FAIL
PyPI             OK     OK    FAIL       OK
Azure portal   FAIL   FAIL      OK     FAIL
Google 204     FAIL     OK      OK       OK
...

Результат дописывается в matrix.tsv по мере выполнения. Если процесс прервался, скрипт читает уже готовые пары (src,url) и продолжает с оставшихся. Поэтому сорокатысячный прогон не приходится начинать заново после каждого SSH timeout или рестарта.


Самое ценное — не FAIL, а Selective FAIL

Грубая статистика 26 тысяч FAIL из 40 тысяч сама по себе почти бесполезна.

В каталоге есть цели, которые плохо подходят для /tool fetch: UDP/QUIC, нестандартные протоколы, endpoints с anti-bot, географическими ограничениями или специфичной HTTP-логикой. Поэтому строка «не работает со всех NAT» ещё ничего не доказывает.

Например, в текущем отчёте есть 225 tags с FAIL на всех 101 source IP. В эту группу попадают YouTube, Telegram, Docker Registry, Apple update endpoints, QUIC-тесты и многое другое. Часть такого результата может быть реальным ограничением, а часть — просто несовпадением метода проверки с протоколом/endpoint.

Гораздо сильнее другой класс результата:

одна и та же цель одновременно FAIL с части SNAT и OK с остальных.

В текущей матрице, например:

Azure portal       FAIL 85 / OK 16
Т-Банк             FAIL 82 / OK 19
Ubuntu archive     FAIL 67 / OK 34
PyPI               FAIL 33 / OK 68
Microsoft          FAIL 24 / OK 77
OpenAI             FAIL 22 / OK 79

Здесь origin точно не может быть просто «мёртв для всех»: в ту же минуту часть наших публичных адресов до него достучалась.

Именно поэтому Selective FAIL стал для нас гораздо более полезной фактурой, чем попытка объявить любой timeout «блокировкой ТСПУ».


Три уровня уверенности

После этих экспериментов мы фактически пришли к трёхслойной модели.

1. NetFlow: AT RISK

Пассивная телеметрия видит необычный сетевой след:

syn_no_ack ↑
short flows ↑
много односторонних соединений
CC-маркеры выглядят нездоровыми

Это повод присмотреться к NAT, но ещё не доказательство недоступности.

2. MikroTik active probe: IMPACT

Тот же destination:

с NAT A → FAIL
с NAT B → OK

Это уже подтверждённая зависимость доступности от source IP.

3. Абонентский connect-check: пользовательская фактура

Если тот же публичный NAT одновременно даёт массовые expected_block=0 failures у реального клиента, мы получаем третью независимую точку подтверждения.

Именно комбинация этих трёх слоёв оказалась намного полезнее любого единственного TSPU_SCORE.


Situation Room: что теперь видно в Grafana

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

Situation Room — общий вид

Снимок Situation Room. Цифры — состояние конкретного 15-минутного окна, а не постоянные характеристики сети.

На этом снимке сверху видны основные KPI.

Flows/sec

Текущая интенсивность NetFlow. В кадре — около 4638 flows/sec.

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

CC здоровье egress %

На снимке 16.3%.

Очень важно: это не означает, что работает только 16% Интернета.

Сейчас KPI считается как:

cc_healthy_ok / cc_flows_ok

То есть только среди flow, относящихся к whitelist-каталогу connect-check. Раньше знаменателем ошибочно был весь трафик сети, и плитка почти всегда показывала 1–2%, создавая ложное ощущение тотальной катастрофы.

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

Количество публичных NAT, которые попали под passive-модель silent_syn_drop.

На снимке — 88.

Это именно кандидаты AT RISK, а не 88 доказанно заблокированных адресов.

Карантин: watch / elevated / HOT

Три стадии soft-quarantine для внутренних клиентов:

s1 watch      — наблюдаем
s2 elevated   — повышенный риск
s3 HOT        — сильный/устойчивый источник шума

На снимке соответственно 915 / 67 / 976.

Здесь важно не путать сущности: карантин относится к inside, который создаёт scan/amp/vpn/malware-like активность. Он не означает, что публичный SNAT уже заблокирован.

Исходящие угрозы (NAT)

Число NAT, за которыми есть активные scanner/amplifier/VPN/malware scores выше порога. На конкретном скриншоте панель показывает No data — это не «угроз нет», а отсутствие результата этой выборки в данном кадре.

Входящие атаки на нас

Количество наших публичных активов, которые сами сейчас являются целями внешних scan/amp/malware-паттернов. На снимке — 406.

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


H45/H46: отдельный профиль состояния публичного NAT

Вторая строка Situation Room уже ближе к нашему исследованию:

H45 full blackout NAT      63
H45 selective NAT          32
H44 UNI→CC IMPACT NAT      23
Escalate IMPACT            78
Profiled NAT              111
H45 clean NAT               2

H45 full blackout

Плохими выглядят и основные probe-маркеры, и контроль Cloudflare.

В текущей модели маркеры — Google-family, Apple, Quad9, Blizzard. Если хотя бы один маркер достаточно представлен и имеет healthy <=25%, а Cloudflare тоже <=25%, профиль становится full.

Это модель по NetFlow, а не результат полного /tool fetch-прогона.

H45 selective

Маркер выглядит плохо, но Cloudflare остаётся живым либо данных по нему мало.

Именно этот профиль особенно похож на то, что мы увидели активными тестами: один набор destination перестаёт работать с конкретного source IP, другой продолжает открываться.

H44 UNI→CC IMPACT

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

Это ещё один независимый passive-сигнал.

Escalate IMPACT

Эта плитка считается жёстче:

silent_syn_drop
AND
(
    CC suspect
    OR full/selective profile
    OR UNI→CC impact
)

То есть одного silent уже недостаточно — NAT должен одновременно подтвердиться ещё одним слоем.

Profiled NAT и clean NAT

Profiled — адреса, по которым в текущем окне вообще хватило маркерного трафика для классификации.

clean — NAT, где Google-family имеет достаточно samples и здоровый процент (>=40%).

На снимке clean всего 2, что само по себе показывает: текущая пассивная модель ещё требует калибровки и не должна использоваться как «истина без активной проверки».


График SYN/RST тоже никуда не делся

Под плитками остаётся базовая временная диаграмма:

flows/sec
SYN/sec
RST/sec

Она нужна для контекста. Если одновременно с ухудшением reachability NAT резко растёт SYN-rate, но RST остаётся почти на нуле, это очень похоже на уже знакомую картину незавершённого handshake и ретраев.

Но теперь мы не пытаемся по одному этому графику сказать «это ТСПУ». График только объясняет, какой сетевой след сопровождает подтверждённый active-probe FAIL.


Drill-down: из плохого NAT — к конкретному inside

Ниже Situation Room появились две таблицы.

NAT egress и виновники внутри NAT

Таблица NAT egress — silent + profile + UNI

В ней сводится всё, что известно про публичный адрес:

Поле Что означает
nat_ip публичный SNAT
escalate_impact сработало ли усиленное условие silent AND дополнительное подтверждение
status_class итоговый класс: full, selective, cc_suspect, uni_cc, silent_only, watch
block_profile H45-профиль full/selective/clean/mixed/unknown
silent_triage балл passive-модели тихого SYN-drop
silent_tag текстовая причина попадания в silent-кандидаты
google_healthy_pct здоровье Google-family маркеров
apple_healthy_pct здоровье Apple-маркеров
quad9_healthy_pct здоровье Quad9
blizzard_healthy_pct здоровье Battle.net/Blizzard
cf_healthy_pct контроль Cloudflare
uni_cc_impact найден ли устойчивый односторонний поток к CC-целям
uni_flows сколько таких flows увидели
on_cc_suspect попал ли NAT в connect-check passive suspect
profile_tag человекочитаемое объяснение профиля

Например, строка

203.0.113.37
escalate_impact=1
status_class=full
block_profile=full
silent_triage=30.1
silent_tag=silent_syn_drop

означает не «мы доказали блокировку», а гораздо аккуратнее:

этот SNAT одновременно выглядит как silent-drop по NetFlow и имеет плохой маркерный egress-профиль; его нужно поднять в active probe.

И это принципиально другой уровень аккуратности вывода.


cause_score: кто внутри NAT создаёт шум

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

Поля:

stage
stage_label
nat_ip
inside_ip
cause_score
cause_class
cause_tag

cause_score — не «вероятность блокировки». Это сумма нескольких компонентов поведения inside:

scan_comp
+ amp_comp
+ vpn_comp
+ mal_comp

с отдельным p2p_dampen, чтобы обычный BitTorrent не превращался автоматически в «портсканер».

cause_class выбирает наиболее характерный профиль:

port-scan
host-scan
amp-probe
ssh-vpn-fanout
malware-ports
syn-volume
mixed-noise

На текущем снимке самые высокие строки имеют cause_class=port-scan и score около 70–75.

Смысл таблицы очень практический: сначала мы находим плохой публичный NAT, потом проваливаемся внутрь и смотрим, кто за ним создаёт основную аномальную активность.

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


Следующий шаг: не ждать естественного трафика

Пассивная H45-модель имеет фундаментальное ограничение: если с конкретного NAT прямо сейчас никто не обращается к Apple, Quad9 или Google, у нас просто нет samples и профиль становится unknown.

Логичный следующий этап — создавать небольшой контролируемый фон активных проб именно с проблемных NAT.

Не полный прогон 403×101 каждые пять минут — это было бы бессмысленной нагрузкой. Достаточно короткого набора стабильных маркеров.

Например:

control:
  Cloudflare

markers:
  Google generate_204
  Apple
  Quad9
  Battle.net

incident target:
  URL, на котором уже поймали selective FAIL

Когда Situation Room переводит адрес в full/selective/escalate, внешний scheduler может поставить временное задание:

NAT 203.0.113.x
каждые 5 минут
в течение 2 часов
6–10 probe destinations

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

ts, gw, nat_ip, probe, result

И дальше появляется временная шкала:

12:00  NAT A  google FAIL  CF OK
12:05  NAT A  google FAIL  CF OK
12:10  NAT A  google FAIL  CF OK
12:15  NAT A  google OK    CF OK

Это даёт то, чего нам до сих пор не хватало: время входа в деградацию и время восстановления конкретного публичного IP.

Причём такую задачу лучше создавать на jump/collector, а MikroTik оставлять только исполнителем короткого /tool fetch src-address=.... Так мы не раздуваем scheduler на edge и можем централизованно ограничить число одновременных проб.


Как это теперь выглядит целиком

Мы начинали с очень простой схемы:

жалоба
→ смотрим NetFlow
→ видим много SYN
→ наверное, ТСПУ

Сейчас рабочий pipeline уже другой:

NetFlow
  ↓
AT-RISK NAT
  ↓
H45/H44/CC correlation
  ↓
active /tool fetch с того же src-address
  ↓
сравнение с соседним clean NAT
  ↓
если нужно — абонентский connect-check
  ↓
inside attribution по cause_score
  ↓
повторные probes до восстановления

Это заметно скучнее красивой кнопки «Определить блокировку ТСПУ».

Зато гораздо ближе к нормальной инженерной диагностике.


Что мы можем утверждать уже сейчас

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

Первое. Reachability действительно бывает привязана к конкретному публичному SNAT: один и тот же ресурс одновременно доступен с одних наших адресов и недоступен с других.

Второе. Это воспроизводится не на одном «сломавшемся» MikroTik. Selective FAIL мы получили на нескольких edge, причём набор проблемных NAT у каждого свой.

Третье. Пассивный NetFlow полезен для поиска кандидатов, но заметно переоценивает full/selective, если использовать его без активной проверки. Поэтому silent, H45 и cause_score — это триггеры расследования, а не финальный вердикт.

Четвёртое. Самым сильным операционным тестом оказался не абсолютный FAIL, а дифференциальный:

одна цель
одно время
один GW
NAT A = FAIL
NAT B = OK

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


И что всё ещё нельзя утверждать

Даже 40 703 probes не дают нам права написать:

26 199 соединений заблокировал ТСПУ

Мы не видим внутреннего правила фильтра и не знаем причину каждого отдельного timeout.

На результат влияют:

  • сам destination;
  • anti-bot/CDN;
  • географические политики;
  • несовпадение /tool fetch с реальным протоколом;
  • временные сетевые ошибки;
  • DPI/ТСПУ;
  • внешние системы защиты, которые тоже принимают решения по публичному IP.

Но теперь у нас есть способ убрать из уравнения значительную часть этих переменных: одновременно сравнивать несколько source IP на одном и том же пути.

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

Мы всё ещё смотрим на тень чёрного ящика.

Просто теперь можем подсветить её сразу со ста разных адресов.


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

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

mt_cc_full_matrix_probe.py автоматически:

  1. получает локальные адреса MikroTik;
  2. извлекает srcnat to-addresses;
  3. строит допустимый набор source IP;
  4. запускает /tool fetch для каждой пары src × URL;
  5. пишет инкрементальный matrix.tsv;
  6. умеет продолжать прерванный прогон;
  7. строит matrix_summary.csv и fail-rate по каждому SNAT.

Дисклеймер. Это эксплуатационное исследование сетевой доступности на стороне оператора связи. Активные probes выполняются к обычным публичным ресурсам с собственных адресов оператора и используются для диагностики. Наличие source-dependent FAIL само по себе не доказывает применение конкретного правила ТСПУ: вывод делается только как наблюдение сетевого поведения.

#network #monitoring #netflow

Обложка

Вторая часть исследования NetFlow и странных деградаций доступа. Первая часть была про то, как мы вообще научились видеть массовый silent SYN-drop на публичных CGNAT и искать внутри «шумных» абонентов. После неё нашлось несколько новых кусков документации и полевых исследований, которые заставили заметно усложнить модель.

В первой части у нас получилась довольно удобная картинка.

Есть публичный NAT. За ним сидят десятки или сотни абонентов. В какой-то момент часть нормальных ресурсов перестаёт открываться. На edge NetFlow одновременно растёт доля незавершённых TCP-сессий, коротких flows и повторных SYN. RST почти нет.

Мы назвали этот профиль silent_syn_drop и начали искать, кто внутри CGNAT создаёт особенно много новых соединений, destination и портов.

На тот момент это уже было сильно полезнее, чем классическое:

«Пинг идёт, значит у нас всё нормально».

Но почти сразу появилась следующая проблема.

Одинаковая снаружи “тишина” может возникать по совершенно разным причинам.

И вот здесь началась вторая часть исследования.


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

Вчера мы много говорили про short3 — долю flows, в которых было не больше трёх пакетов.

Эта метрика действительно оказалась очень полезной. Когда клиент отправляет SYN, не получает нормального ответа и соединение умирает, NetFlow часто оставляет именно крошечную запись.

Но теперь стало понятно, что опасно превращать short3 в магическую кнопку:

short3 высокий = ТСПУ

Так не работает.

Короткий flow может появиться из-за:

  • SYN без ответа;
  • RST;
  • обычного сканирования;
  • отказа сервера;
  • P2P;
  • firewall;
  • timeout;
  • mid-flow блокировки после уже установленного TCP;
  • и ещё десятка совершенно нормальных причин.

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

И оказалось, что открытые источники довольно хорошо раскладываются минимум на четыре разных класса механизмов.


Четыре разных механизма вместо одного «ТСПУ блокирует»

Для себя мы сейчас используем такую рабочую модель.

Класс Что происходит Что может увидеть NetFlow
A. Списки ЦСУ / protocol block DPI сначала распознаёт протокол, потом ЦСУ формирует IP+port list RST или silent SYN-drop, изменение через минуты
B. AS/CIDR + whitelist + mid-flow направление попадает под ограничение, отдельные домены разрешаются TCP устанавливается, потом поток обрывается примерно на первых килобайтах
C. Session-state / «сибирская» решение зависит от количества соединений к одному направлению/SNI и TLS fingerprint первые соединения живут, следующие замирают; характерный burst по destination
D. Protocol capacity не полный block, а вероятностный drop пакетов деградация, retransmit, длинные/рваные flows вместо чистого бинарного отказа

Это уже совсем другая картина.

То есть вопрос:

«Как выглядит блокировка ТСПУ в NetFlow?»

на самом деле поставлен неправильно.

Правильнее:

«Какой именно класс ограничения мы сейчас наблюдаем?»


Класс A: почему отсутствие RST оказалось вполне нормальным

В первой части мы упоминали реконструкцию документации ТСПУ tspu-docs.

После публикации мы внимательнее прошлись по разделу настройки DPI и нашли очень полезную деталь.

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

send RST off

Причём в тексте прямо сказано, что в проекте он должен быть выключен.

Логика понятная даже без знания внутренностей ТСПУ.

Если фильтр отвечает приложению TCP Reset:

SYN →
    ← RST

клиент немедленно понимает, что соединение не получилось, и может тут же попробовать снова.

Получается очень бодрый генератор новых сессий:

SYN → RST
SYN → RST
SYN → RST
SYN → RST
...

Если же Reset не отправлять, картина другая:

SYN →
     тишина
     timeout

Клиент тоже ретраит, но медленнее.

И это практически один в один похоже на то, что мы видели ещё в июне в Wireshark и потом в сентябре на NetFlow: много SYN, много незавершённых flow, RST около нуля.

То есть наш silent_syn_drop перестал выглядеть как «какая-то странность конкретной площадки» и получил вполне разумное архитектурное объяснение.

Но дальше оказалось ещё интереснее.


Ignore сегодня, block через 5–15 минут

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

Сначала DPI-лист работает в режиме распознавания:

behavior = ignore

То есть сессия пока проходит, но факт распознавания логируется.

Дальше:

DPI
  ↓
протокольные логи
  ↓
SPFS
  ↓
ЦСУ
  ↓
анализ и очистка ложных срабатываний
  ↓
IP + port
  ↓
block

Для тестовой зоны в документации указан полный цикл порядка 5–15 минут.

Это очень хорошо объясняет ещё одну вещь, которую раньше было неудобно интерпретировать.

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

Не потому, что «DPI долго думал над одним пакетом», а потому что первая стадия вообще могла ничего не блокировать.

Для нашего исследования это означает, что особенно интересен не момент, когда connect-check уже красный.

Интереснее 10–20 минут перед ним.

Именно там надо искать изменение:

uniq_dst
uniq_dst_port
SYN/sec
flows/sec
burst к конкретному destination

То есть мы немного поменяли идеологию.

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

Вторая всё больше становится про прелюдию.


Класс B: те самые 16 КБ — и почему это не short3

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

В феврале 2026 на Habr вышел большой полевой разбор ограничений на зарубежных CDN и хостинговых сетях.

Наблюдаемый эффект там описывался примерно так:

TCP connect            OK
TLS                    OK
начало передачи данных OK
примерно 16 КБ         STOP

То есть соединение не умирает на SYN.

Оно успевает установиться и передать кусок данных.

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

Для пользователя выглядит особенно издевательски:

страница вроде начала открываться — и зависла.

Это уже совсем не тот класс, который мы ловили через чистый syn_no_ack.

И здесь важна терминология.

short3 — это число пакетов

У нас:

short3 = packets <= 3

«16 КБ» — это объём до mid-flow cut

Такой flow вполне может содержать:

10 пакетов
20 пакетов
30 пакетов

в зависимости от MSS, ACK, TLS и направления учёта.

Поэтому нельзя сказать:

«16K-блок = short3».

Нет.

Но он всё равно может увеличивать долю коротких по байтам и времени сессий.

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

bytes_per_flow
packets_per_flow
duration
midflow_10_20k

То есть short3 остаётся хорошей метрикой для half-open/silent класса, а диапазон условно 10–20 КБ становится отдельной охотой за mid-flow cut.

Это, пожалуй, одна из самых полезных поправок ко вчерашнему тексту.


Почему история с 16 КБ важна оператору

Она показывает, насколько плохо обычные проверки описывают реальную доступность.

Например:

ping host        OK
tcp/443 connect  OK
TLS handshake    OK
HTTP headers     OK
скачивание       FAIL после первых KB

Любой мониторинг, который заканчивается на TCP connect, скажет:

всё прекрасно.

А реальное приложение не работает.

Это ещё одна причина, почему в connect-check у нас есть не только connect, но и реальные HTTP/HTTPS операции, скачивание канареек и разные классы сервисов.


Класс C: «сибирская блокировка»

А потом мы вернулись к статье, которую раньше читали скорее как любопытную особенность отдельных операторов.

И оказалось, что она очень хорошо ложится на нашу историю с fan-out и количеством соединений.

В конце 2025 года исследователь описал эффект, который получил название «сибирская блокировка».

В исходном наблюдении примерно 12 TLS-соединений с SNI к одному серверу за короткий промежуток приводили к тому, что новые соединения с тем же отпечатком переставали устанавливаться примерно на две минуты.

Причём TLS без SNI этим эффектом тогда не затрагивался.

Это уже принципиально другая логика.

Не:

IP плохой → drop

а скорее:

кто + куда + сколько раз + какой TLS profile

То есть фильтр хранит состояние.


К июню порог стал заметно жёстче

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

Условие выглядело так:

  • направление относится к «подозрительной» сети/AS;
  • TLS fingerprint входит в интересующий набор;
  • используется один SNI;
  • за последние 60 секунд происходит более трёх быстрых попыток;
  • задержка между ними порядка 350–400 мс или меньше.

После этого новые TLS-попытки к направлению могут «замораживаться» примерно на 120 секунд.

Особенно любопытно, что автор отдельно проверял TCP-порт: по его наблюдениям, конкретный номер порта для этого класса ограничения роли не играл.

Это важный момент.

Мы изначально много смотрели на классические порты:

22
443
500
4500
1194
51820
...

Они всё ещё полезны для понимания поведения абонента.

Но session-rate механизм может быть вообще port-agnostic.

То есть считать только dst_port недостаточно.

Нужны:

src
nat_ip
dst_ip
временное окно
число новых TCP sessions

А SNI в обычном NetFlow у нас, разумеется, нет.


Очень забавная эволюция: 12 → 4

Если положить рядом два полевых исследования, получается интересная картинка.

Конец 2025:

~12 быстрых TLS-соединений

Июнь 2026:

4-е быстрое соединение уже может стать лишним

В актуальной конфигурации проекта dpi-checkers для теста Siberian тоже используется:

siberian-conn-count: 4

Это не означает, что «федеральный порог ТСПУ теперь официально равен четырём».

Такого вывода делать нельзя.

Полевые параметры отличаются между операторами, площадками и датами.

Но нам как оператору сам принцип гораздо интереснее конкретной цифры:

несколько быстрых соединений к одному destination — это отдельный класс сигнала.


И тут наш uniq_dst оказался недостаточен

До этого мы много смотрели на fan-out:

один inside → 500 разных dst

Это хорошо ловит сканеры, P2P и заражённых клиентов.

Но Siberian-подобная логика может сработать на прямо противоположном поведении:

один inside
   ↓
ОДИН dst
   ↓
много быстрых новых sessions

То есть старый показатель:

uniq_dst высокий

может вообще не загореться.

После этого у нас появились отдельные кандидаты:

burst_3_dst
burst_4_dst
rate_per_dst
new_tcp_per_dst

Смысл очень простой.

Нас теперь интересует не только:

«Сколько разных серверов трогает клиент?»

но и:

«Сколько раз он за короткое окно стучится в один и тот же сервер?»


Три TCP нормально, четвёртый — уже нет

В более свежей полевой дискуссии конца августа встретилось ещё более жёсткое наблюдение: два соединения проходят, а на третьем/следующем handshake уже появляется тишина.

Пока мы относим это к категории кандидатов, а не установленной нормы.

Причины простые:

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

Но для NetFlow hunt это очень хороший повод поставить пассивный датчик на burst-3 / burst-4.

Не блокировать.

Просто наблюдать.


Мы поставили такой наблюдатель прямо на MikroTik

NetFlow хорош тем, что позволяет смотреть сеть массово.

Но для очень коротких временных burst в несколько сотен миллисекунд минутный агрегат уже грубоват.

Поэтому мы добавили на edge отдельный observe-only слой.

Логика примерно такая:

если один source быстро создаёт
3–4 новых TCP к одному destination
→ записать событие
→ ничего не блокировать

MikroTik отдаёт событие в syslog, а дальше мы связываем его с NetFlow:

MikroTik burst event
        +
NetFlow src/nat/dst
        ↓
ClickHouse
        ↓
кто / какой NAT / какой destination / что было потом

Так появился ещё один важный принцип проекта:

firewall здесь работает не как цензор, а как осциллограф.

Сначала измеряем.

Потом уже решаем, есть ли вообще что ограничивать.


Класс D: оказывается, фильтр умеет не только «разрешить/запретить»

Ещё одна новая находка в документации — protocols capacity.

В открытом описании это шкала от 0 до 100:

0   = полный block
100 = полный pass
1–99 = вероятностный drop

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

Для TCP это даёт совершенно другую внешнюю картину.

Не обязательно:

SYN → тишина

Вместо этого может быть:

handshake OK
часть data OK
loss
retransmit
ещё loss
приложение еле ползёт

И опять пользователь говорит:

«Интернет какой-то мёртвый».

А ping продолжает ходить.

Из этого следует неприятный вывод: бинарный FAIL/OK тоже не всегда достаточно описывает проблему.

Нужны ещё время выполнения, объём переданных данных и профиль retransmission — последние уже лучше смотреть PCAP, а не одним NetFlow.


Вчера у нас был один silent-drop. Сегодня — уже четыре класса

Если сильно упростить, сейчас карта выглядит так.

                         ┌──────────────┐
                         │  жалоба / CC │
                         └──────┬───────┘
                                │
              ┌─────────────────┼─────────────────┐
              │                 │                 │
              ▼                 ▼                 ▼
         SYN не живёт      TCP живёт,       первые sessions
         handshake         data обрывается   живут, потом freeze
              │                 │                 │
              ▼                 ▼                 ▼
        silent / RST        ~10–20 KB         per-dst burst
          class A             class B             class C
              │
              └───────────┐
                          ▼
                    случайный loss
                      class D

Это гораздо полезнее, чем пытаться втиснуть всё в один tspu_score.


Что теперь означают наши метрики

syn_no_ack

Хороший признак того, что новая TCP-сессия не развивается.

Сильнее всего связан с классом A и некоторыми stateful freeze.

short3

Хороший массовый индикатор очень коротких flows.

Но сам по себе не говорит, почему flow короткий.

midflow_10_20k

Новая отдельная гипотеза для класса B.

Ищем соединения, которые успели передать не ноль, а небольшой устойчивый объём и затем закончились/зависли.

burst_3/4_dst

Кандидат на класс C.

Особенно если затем по этому destination у того же egress меняется success rate.

rst_pct

Не выкидываем.

RST-heavy класс у нас уже встречался, просто он оказался не главным.

flows_sec / SYN_sec / uniq_dst

Остаются хорошими показателями общей «шумности» и поиском inside-вкладчиков.


И здесь CGNAT снова делает всё интереснее

Siberian-подобный механизм особенно неприятно выглядит рядом с CGNAT.

Пусть у нас за одним публичным адресом 180 клиентов.

Каждый делает совершенно нормальные четыре HTTPS-соединения.

Для пользователя это ничего необычного.

Но снаружи мы имеем один общий egress.

В зависимости от того, по какому ключу middlebox хранит state, shared IP потенциально может усиливать частоту наблюдаемых сессий.

Мы пока не утверждаем, что конкретная реализация суммирует разных CGNAT-inside именно так.

Для этого у нас нет внутренней телеметрии фильтра.

Но именно поэтому публичный NAT остаётся главной единицей нашего эксперимента.

Мы смотрим:

public NAT
   ↓
симптом
   ↓
какие inside дали вклад

а не наоборот.


Что показал сегодняшний эксперимент с «шумными» inside

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

Если внутри CGNAT есть несколько источников, которые дают очень большой fan-out, scan/amp-профиль и поток новых сессий — можно ли убрать именно этот шум, не отключая абонента полностью?

Для этого сделали soft quarantine.

Не бан.

Не drop all.

А частичные ограничения на явно аномальную новую активность, при сохранении established и обычного пользовательского трафика.

На пилотном сегменте после применения к небольшой группе самых заметных culprits получили примерно:

общий flows/sec:  530 → 383   (-28%)
SYN/sec:          101 →  62   (-39%)

У нескольких конкретных host-scan профилей fan-out упал на десятки процентов.

У одного источника число destination сократилось примерно:

224 → 79

То есть сама идея «найти шумный inside и подрезать именно шум» работает технически.

Но дальше произошло как раз самое интересное.


Публичный NAT не «очистился» мгновенно

Несмотря на заметное снижение flows и SYN, через несколько минут общий egress всё ещё выглядел как проблемный:

short3 ~82%
cc_healthy ~35–36%

То есть:

снизили шум ≠ мгновенно восстановили доступность.

Это очень хороший результат, даже несмотря на то, что он не такой эффектный.

Он означает, что простая причинная модель:

слишком много SYN
   ↓
block
   ↓
урезали SYN
   ↓
сразу unblock

слишком примитивна.

У состояния может быть cooldown.

Может работать загруженный IP+port list.

Может быть другой класс ограничения.

Может существовать несколько шумных inside сразу.

Может быть вообще внешний rate-limit/CDN reputation, а не ТСПУ.

Именно поэтому мы не делаем автоматический вывод «виновник найден» по одному score.


А потом мы получили очень полезный контрпример

И вот здесь новая фактура заставила нас ещё раз поправить собственную модель.

На одном из публичных SNAT наш NetFlow-детектор уверенно показывал silent_syn_drop:

short3      ~80–82%
syn_no_ack  ~21%
RST         ~1%

То есть по нашим прежним правилам адрес выглядел очень знакомо: много коротких flows, заметная доля незавершённых SYN, почти нет RST.

За тем же SNAT действительно нашлись шумные insides. Один из них выглядел как выраженный port-scan профиль.

Казалось бы, вот он — ещё один «проблемный NAT».

Но мы запустили свежий connect-check с реального CPE за этим egress и получили:

FAIL      2
WARNING  55
OK      464

То есть массовой пользовательской деградации не было.

Из действительно красного — один captive-check и 502 у «Ведомостей». Это вообще не похоже на тот случай, где у клиента разваливается половина Интернета.

Получился очень хороший отрицательный результат.

silent_syn_drop на публичном NAT не равен «абонент заблокирован».

Он означает другое:

на этом egress сейчас есть сетевой профиль, похожий на тот, который мы уже видели около проблемных событий.

Но это пока только риск пула.

Пользовательский impact надо подтверждать отдельно.

После этого мы окончательно развели два слоя.

Слой A — AT RISK

NetFlow говорит:

silent profile
short flows
syn_no_ack
burst/fan-out
шумные insides

Это повод открыть NAT и посмотреть, что с ним происходит.

Слой B — IMPACT

Есть независимая фактура:

connect-check массово красный
жалоба абонента
labeled event
контрольный сервис реально не работает

И только когда эти слои сходятся, можно говорить о подтверждённой деградации, а не просто о «грязном» egress.

Схематично теперь так:

        NetFlow anomaly
              │
              ▼
           AT RISK
              │
       ┌──────┴──────┐
       │             │
   CC healthy     CC broken
       │             │
       ▼             ▼
 наблюдаем       IMPACT
   дальше      расследуем

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

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


И на масштабе всего CGNAT-флота всё оказалось ещё менее линейно

На локальном пилоте soft-quarantine действительно красиво уменьшал SYN и fan-out отдельных culprits.

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

В одном из контрольных срезов примерно через час после применения soft-q:

silent NATs:        68 → 83
avg triage:       17.6 → 18.7
sum flows/sec
по silent NAT:    4377 → 3277

То есть суммарный поток в проблемном классе снизился примерно на четверть, а количество NAT с silent-профилем при этом не уменьшилось.

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

Это ещё раз показывает: нельзя использовать soft-quarantine как лабораторную кнопку

ограничили culprit → значит ТСПУ обязан сразу отпустить IP

Нет.

Мы меняем только то, что контролируем сами — форму исходящего трафика.

А дальше на результат могут влиять:

  • TTL внешнего state;
  • уже сформированный IP+port list;
  • другой шумный inside;
  • session-rate state;
  • CDN/WAF reputation;
  • вообще другой механизм деградации;
  • или просто наши собственные слишком широкие критерии silent.

Именно последний пункт новый connect-check как раз очень хорошо подсветил.

Поэтому KPI soft-quarantine теперь тоже другой.

Не:

«сколько NAT перестали называться silent».

А в первую очередь:

сколько шума убрали
какие culprits перестали fan-out'ить
упал ли SYN/new-conn rate
изменилась ли реальная доступность у клиентов

Это намного скучнее красивого зелёного индикатора «ТСПУ снят».

Зато это уже нормальная инженерия.


Зато теперь у нас появился нормальный эксперимент

Следующий шаг уже можно поставить вполне научно.

Берём публичный NAT с подтверждённым connect-check fail.

Фиксируем:

T0

Дальше:

1. baseline до события
2. per-dst burst
3. fan-out и SYN вклад каждого inside
4. soft remediation culprits
5. CC каждые N минут
6. NetFlow AFTER
7. время восстановления

И повторяем это на нескольких NAT.

После десятка таких экспериментов можно будет уже сравнивать:

класс A: восстановление через X
класс B: восстановление через Y
класс C: cooldown около Z

Сейчас таких данных ещё мало.

Но теперь хотя бы понятно, что именно измерять.


Что мы перестали делать после сегодняшних находок

Мы больше не пытаемся сделать одну формулу вида:

TSPU_SCORE = 0.87

и объявить её истиной.

Вместо этого хотим выдавать оператору классификацию:

A: half-open / silent
B: mid-flow ~16K
C: per-dst session freeze
D: degradation/loss
+ threat context

Причём один NAT может одновременно попасть в несколько классов.

Например, noisy inside может провоцировать session-rate историю, а параллельно часть destination уже находится в списках L3/L4.

Для пользователя это всё сольётся в одну жалобу:

«Не открывается половина Интернета».

Для NOC это должны быть разные сценарии расследования.


Самая важная находка второй части

Наверное, она даже не техническая.

Мы всё время искали «механизм блокировки» в единственном числе.

А его, похоже, просто нет.

Есть набор разных механизмов, которые могут работать одновременно:

DPI protocol recognition
центральные IP+port lists
L3/L4 block на Eco Highway
AS/CIDR ограничения
domain whitelist
mid-flow cut
session-rate state
TLS fingerprint
probabilistic protocol degradation

И у каждого немного другой сетевой почерк.

Поэтому лучший детектор — не тот, который громче всех пишет:

ТСПУ!!!

а тот, который спокойно говорит:

на этом egress:
- TCP handshake массово не завершается;
- RST нет;
- short3 84%;
- burst к одному dst вырос;
- CC expected_block=0 падает;
- основной вклад дают 3 inside;
- началось 7 минут назад.

Это уже информация, с которой инженер может работать.


Куда идём дальше

Следующая итерация у нас теперь довольно очевидная.

Нужно одновременно накапливать четыре типа labeled events и не смешивать их:

  1. half-open / silent;
  2. RST-heavy;
  3. mid-flow 10–20 KB;
  4. per-destination burst/freeze.

А рядом держать контрольные NAT, где похожий трафик есть, а проблемы нет.

И только после этого имеет смысл строить пороги.

Пока все цифры вроде 4 соединения, 16 КБ, 5–15 минут — это не универсальные константы ТСПУ.

Это наблюдаемые параметры конкретных механизмов в конкретных исследованиях.

Именно так мы их и будем использовать.

Не как инструкцию.

Как подсказку, куда направить измерительный прибор.


Вместо заключения

Вчера картина была довольно простой:

много SYN
+
нет ACK
+
короткие flows
=
похоже на silent drop

Сегодня она стала сложнее.

И это хорошо.

Теперь мы знаем, что похожая пользовательская жалоба может означать совершенно разные вещи:

соединение не установилось вообще
соединение оборвалось после ~16 КБ
первые три соединения прошли, следующие замёрзли
протокол просто теряет часть пакетов

Пинг во всех четырёх случаях может быть зелёным.

Поэтому наша исходная идея не изменилась.

Просто измерительный контур стал умнее.

Мы не пытаемся смотреть внутрь чёрного ящика.

Мы смотрим на его тень.

Но теперь уже знаем, что у этой тени несколько разных форм.


Источники и материалы

Первая часть исследования — «Пинг есть. TCP — не очень: как мы учимся видеть след ТСПУ по NetFlow»: https://articles.clr58.ru/netflow-tspu

Предыстория — «Две недели “интернета нет” при зелёных пингах»: https://articles.clr58.ru/dve-nedeli-net-interneta

Наш connect-check: https://github.com/cooler58/connect-check

Открытая реконструкция архитектуры ТСПУ: https://github.com/DanielLavrushin/tspu-docs

send RST off и protocols capacity: https://github.com/DanielLavrushin/tspu-docs/blob/main/chapters/17.md

Двухстадийная схема ignore → ЦСУ → IP+port, цикл ~5–15 минут: https://github.com/DanielLavrushin/tspu-docs/blob/main/chapters/08.md

Habr — «РКН и “сибирская блокировка”»: https://habr.com/ru/articles/1010336/

Habr — «О схеме ограничений РКН в июне 2026-го»: https://habr.com/ru/articles/1044396/

Habr — белые списки AS и эффект ~16 КБ: https://habr.com/ru/articles/997088/

dpi-checkers: https://github.com/hyperion-cs/dpi-checkers


Дисклеймер. Это эксплуатационное исследование сетевых симптомов на стороне оператора связи. Мы не имеем доступа к внутренним решениям ЦСУ/ТСПУ и не утверждаем, что конкретный flow, IP или абонент был обработан конкретным правилом. Полевые статьи и открытая реконструкция документации используются для формирования проверяемых гипотез. Материал не является инструкцией по обходу ограничений.


#network #monitoring #netflow

Обложка

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

Есть довольно мерзкий тип сетевой аварии: почти всё, что обычно смотрит мониторинг, зелёное. Линк поднят, маршрутизация есть, DNS отвечает, пинг идёт, speedtest иногда даже показывает вполне приличную скорость. Но при этом Windows пишет «без доступа к Интернету», телефон решает, что Wi‑Fi плохой, игра не может авторизоваться, IoT теряет облако, часть HTTPS-сайтов открывается, часть висит до тайм-аута, а некоторые системные connectivity-check вообще перестают проходить.

Для пользователя диагноз простой:

Интернет сломался.

Для оператора всё значительно веселее: по классическим метрикам он как будто не сломался.

Мы впервые плотно столкнулись с этим летом 2026 года. Началось всё с одного домашнего подключения и Wireshark, а к сентябрю выросло в отдельный операторский контур из NetFlow v9, GoFlow2, ClickHouse, Grafana и собственной системы проверок доступности. Вместе с контуром поменялся и главный вопрос. Сначала он звучал так:

Почему у одного клиента «нет Интернета», когда пинг есть?

Теперь так:

Можно ли на уровне оператора увидеть сетевой след фильтрации, понять, какой публичный CGNAT уже деградирует, и найти внутри него абонента, который генерирует особенно аномальный трафик?

Спойлер: прочитать внутреннее правило ТСПУ по NetFlow нельзя. Но увидеть последствия происходящего — вполне.


С чего всё началось: июньский Wireshark

Вот один из дампов, снятых ещё в июне.

Wireshark: повторные SYN к TCP/443 при живом остальном трафике

На этом скриншоте интереснее всего не отдельная строка, а соседство совершенно разных картин. В одних TCP-сессиях видны нормальные ACK и keepalive, внизу живёт DNS — то есть интерфейс не отвалился, IP-маршрутизация не исчезла, стек TCP в целом работает. А рядом к нескольким другим адресам на TCP/443 идут:

SYN →
...
SYN Retransmission →
...
SYN Retransmission →

И сессия так и не переходит в нормальный handshake. Нет ни ожидаемого:

SYN →
    ← SYN-ACK
ACK →

ни явного отказа:

SYN →
    ← RST

Просто тишина и повторные SYN.

Наблюдение оказалось важным: оно объясняло, почему «пинг есть» вообще ничего не гарантирует. ICMP может прекрасно проходить, DNS может отвечать, часть уже установленных соединений может жить, а новые TCP-сессии к определённым направлениям при этом не устанавливаются. Для приложения это выглядит как обычный сетевой тайм-аут: оно ждёт, повторяет попытку, снова ждёт. Для пользователя — как «Интернет то есть, то нет». Для простого ICMP-мониторинга — как отсутствие аварии.

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


Из той истории появился connect-check

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

Нужен был набор независимых проверок разных классов:

  • connectivity-check Android, Windows и Apple;
  • российские сайты;
  • банки и государственные сервисы;
  • зарубежные HTTPS-ресурсы;
  • игровые платформы;
  • AI-сервисы;
  • DoH и DoT;
  • QUIC;
  • NTP;
  • MQTT и IoT;
  • push;
  • CDN;
  • почтовые сервисы.

Так появился наш connect-check:

https://github.com/cooler58/connect-check

Он выполняет сотни проверок и складывает результат в один HTML-отчёт. Но для нынешнего исследования особенно важна одна вещь: в каталоге есть признак expected_block.

Условно:

expected_block = 1

означает: недоступность этого ресурса сама по себе сейчас не доказывает локальную проблему нашего egress.

А:

expected_block = 0

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

Это избавляет нас от очень удобной, но неправильной логики: «увидели высокий score в NetFlow — значит, ТСПУ заблокировал адрес». Высокий score — только гипотеза. Событием для калибровки становится независимое наблюдение: например, на известном публичном egress одновременно перестают проходить обычные российские сервисы и системные проверки, которые падать не должны.


Почему домашнего tcpdump стало мало

PCAP отлично отвечает на вопрос, что происходит с конкретным клиентом прямо сейчас. Но у оператора задача другая: за одним публичным IPv4 через CGNAT могут сидеть десятки или сотни абонентов:

10.20.55.10  ─┐
10.20.55.11  ─┤
10.20.55.12  ─┤
10.20.55.13  ─┤
      ...      ├── CGNAT ── PUBLIC IPv4 ── Internet
10.20.55.184 ─┤
      ...      │
10.20.55.240 ─┘

Для внешнего мира вся эта компания — один адрес. И отсюда вырастает уже операторский вопрос:

Если внешний фильтр, WAF, антифрод или какая-то другая stateful-система принимает решение на уровне публичного IPv4, может ли один особенно «шумный» inside испортить жизнь соседям по NAT?

Сам по себе такой collateral effect давно известен и вообще не уникален для ТСПУ. Например, Cloudflare в 2025 году показал, что CGNAT-адреса попадали под rate limiting примерно втрое чаще обычных IP, хотя медианный bot-rate у CGNAT и non-CGNAT был почти одинаковым. Причина банальна: за одним адресом смешивается поведение множества разных людей и устройств.

Источник: Cloudflare — One IP address, many users: detecting CGNAT to reduce collateral effects.

Поэтому наша гипотеза с самого начала была шире формулировки «ТСПУ банит NAT». Нас интересует shared-IP collateral: может ли аномальная активность одного или нескольких insides менять сетевую судьбу всего публичного egress.


Операторский контур: NetFlow вместо тотального PCAP

Можно было зеркалировать весь трафик и писать PCAP. На небольшой лаборатории это прекрасная идея, а на операторской сети она довольно быстро превращается в отдельный проект по хранению, производительности, приватности и поиску по терабайтам захватов. Поэтому первый массовый слой мы сделали на NetFlow v9.

Схема получилась такой:

MikroTik / edge routers
        │
        │ NetFlow v9
        ▼
     GoFlow2
        │
        ▼
 Python ingest
 batch + disk spool
        │
        ▼
   ClickHouse
 raw + 1m/5m aggregates
        │
        ├── threat views
        ├── TSPU/silent-drop views
        ├── connect-check views
        └── BEFORE/EVENT/AFTER
                 │
                 ▼
              Grafana

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


Что NetFlow видит, а чего не видит

NetFlow не превращается в DPI только потому, что рядом появился ClickHouse. Мы не видим:

  • содержимое TLS;
  • HTTP payload;
  • точное SNI каждой сессии;
  • последовательность отдельных TCP-пакетов;
  • retransmission так же подробно, как в PCAP;
  • внутреннюю метку ТСПУ «эта сессия заблокирована»;
  • конкретное правило, принявшее решение.

Зато видим форму трафика:

src / dst
src_port / dst_port
protocol
tcp_flags
packets
bytes
duration
NAT fields
exporter
timestamp

А из неё уже можно считать:

  • flows/sec;
  • SYN flows;
  • SYN без ACK;
  • RST flows;
  • долю очень коротких flows;
  • число уникальных destination;
  • число destination ports;
  • число внутренних адресов за NAT;
  • UDP/443 и TCP/443;
  • изменение всего перечисленного относительно обычного baseline.

И вот этого неожиданно хватило, чтобы увидеть очень характерные профили.


Нюанс, на котором легко сломать всю аналитику: TCP flags

В нашем NetFlow tcp_flags — это OR флагов за жизнь flow. Поэтому мы сознательно говорим:

SYN flow, а не SYN packet.

Если в записи присутствует SYN, это значит, что внутри flow был SYN, но не обязательно, что запись состояла из одного-единственного SYN-пакета. На первый взгляд мелочь, а на практике без этой оговорки очень легко нарисовать красивую, но физически неправильную историю.


Первая серьёзная ошибка: мы смотрели на inside, а надо было на egress

Когда жалуется клиент 10.x.x.x, рука сама тянется строить аналитику вокруг него. Для CGNAT это неправильная единица исследования: внешняя система не знает, какой именно 10.20.55.xxx сейчас создаёт соединение, она видит публичный SNAT. Поэтому модель пришлось перевернуть:

PUBLIC NAT
   │
   ├── inside A
   ├── inside B
   ├── inside C
   └── inside N

Сначала мы отвечаем, что происходит с публичным egress, и только потом — кто внутри внёс максимальный вклад в его профиль. Это, пожалуй, самый важный архитектурный вывод всего эксперимента.


Вторая ошибка: postNATSource не всегда означает наш NAT

Стоило начать считать по публичным адресам, как у MikroTik NetFlow v9 обнаружилась занятная ловушка. NAT-поля присутствуют и на входящем трафике, поэтому без дополнительной проверки в топы nat_ip внезапно начинали попадать чужие внешние peer-адреса. Получалось очень убедительно и совершенно бессмысленно.

Реальный egress пришлось определять жёстче:

src = private / CGNAT
nat_src = public
nat_src != src
nat_src принадлежит нашей адресации

Только после этого аналитика по публичным NAT стала пригодна для сравнения.


Третья ошибка: BitTorrent прекрасно притворяется сканером

Следующая ловушка ждала уже в самих эвристиках. Наивный скан-детектор выглядит примерно так:

uniq_dst ↑
uniq_dst_port ↑
flows ↑

Проблема в том, что хороший активный P2P-клиент выглядит примерно так же: сотни peers, множество портов, масса коротких UDP flows, постоянные новые destination. Если просто поставить порог на fan-out, половина «злоумышленников» окажется торрентами. Поэтому в threat-профиль пришлось добавить отдельный p2p_like и dampen: если трафик похож на BitTorrent/DHT, высокий fan-out сам по себе не превращается в port-scan.

Отсюда общее правило для подобных исследований:

Одна высокая метрика почти никогда ничего не доказывает.


Что говорят открытые материалы про ТСПУ

Здесь нужна важная оговорка. У нас нет доступа к внутренним логам ТСПУ или ЦСУ. Для понимания возможной архитектуры мы используем открытый проект DanielLavrushin/tspu-docs — подробную реконструкцию по материалам лекции. Мы не считаем её официальной нормативной документацией и используем только как источник технических гипотез. Но несколько деталей из неё очень хорошо рифмуются с нашими наблюдениями.

1. Для protocol-block описан send RST off

В главе 17 описан параметр send RST off: при его выключенном состоянии TCP Reset при блокировке распознанных протоколов не отправляется, соединение молча отбрасывается и клиент дожидается тайм-аута. Мотивация там тоже понятна: немедленный RST заставляет приложение быстро создавать новую попытку и может породить ещё больше сессий.

Для нас это важно не как «доказательство внутренней настройки конкретной площадки», а как объяснение, почему отсутствие RST вообще не противоречит модели фильтрации. То, что в июне выглядело как повторные SYN в Wireshark, а в сентябре — как высокий syn_no_ack в NetFlow, физически вполне согласуется с silent drop.

2. Для протоколов описана двухстадийная схема

В главе 8 описана схема:

DPI recognition
behavior = ignore
       │
       ▼
protocol logs
       │
       ▼
SPFS / ЦСУ
очистка false positives
       │
       ▼
IP + port lists
       │
       ├── filter: block
       └── Eco Highway: L3/L4 block

То есть предварительное распознавание шифрованного протокола само по себе не обязано сразу рвать сессию. Сначала результаты собираются, затем централизованно очищаются, после чего формируются списки IP+порт. Там же для тестовой зоны приведён ориентир полного цикла порядка 5–15 минут — от обнаружения нового протокольного endpoint до загрузки очищенного списка. Для нашей аналитики это подсказка смотреть не только момент уже случившегося fail, но и 5–30 минут до него.

3. Разные механизмы могут оставлять разные следы

Открытая реконструкция описывает и DPI-фильтры, и второй эшелон, где готовые IP+port-списки могут применяться на L3/L4. Для NetFlow это означает неприятную, но полезную вещь:

не надо требовать одного универсального «отпечатка блокировки».

В разных сценариях мы можем получить:

  • RST-heavy;
  • silent SYN drop;
  • короткие оборванные flows;
  • изменение TCP/443 ↔ UDP/443;
  • частичную, а не полную деградацию.

Именно это мы в итоге увидели на живых данных.


Июнь и сентябрь: один симптом в двух масштабах

Июньский PCAP показывал локально:

новый TCP/443
SYN
тишина
повторный SYN
тишина

В сентябре на части публичных NAT мы увидели то же самое уже статистически:

syn ≈ syn_no_ack
short3 ↑
RST ≈ low

То есть большая доля TCP flows содержит SYN, но не показывает признаков нормального развития handshake, а сами записи очень короткие. Мы назвали этот класс:

silent_syn_drop

Это название наблюдаемого эффекта, а не утверждение «мы доказали конкретное внутреннее правило ТСПУ».


Фактура 8 сентября: один особенно интересный CGNAT

Первое событие, где статистика и клиентская фактура сошлись по времени, мы получили 8 сентября — от абонента за одним из публичных NAT. В публикации адрес обезличим.

inside:  10.20.55.xxx
egress:  203.0.113.xxx

Сводка connect-check:

FAIL     209
WARNING  113
OK       198

То есть это не «чёрный экран и полный обрыв Интернета». Почти двести проверок продолжали работать — и именно поэтому проблема выглядела так неприятно: часть Интернета есть, часть нет, часть отвечает нестабильно. Среди неожиданных fail были ресурсы с expected_block=0, в том числе Госуслуги и Ведомости; параллельно массово ломались системные connectivity-check и разные зарубежные HTTPS/IoT-сервисы. Это уже хорошая метка времени: на данном egress действительно наблюдалась заметная частичная деградация, которая не сводилась к обычному списку ожидаемо ограниченных ресурсов.

Теперь смотрим NetFlow того же публичного NAT примерно в тот же период. В одном из срезов:

Метрика Наблюдение
внутренних адресов за NAT ~186
short flows до 3 пакетов ~88%
RST ~0.4%
уникальных destination ~2682
уникальных destination ports ~790
flow rate ~266 flows/s

На raw-окне порядка 15 минут только TCP/443 было около 145 тыс. flows примерно к 3,8 тыс. destination. Отдельно бросался в глаза fan-out по TCP/22: около 1,8 тыс. flows примерно к 322 адресам назначения.

Сами по себе цифры «виноват ТСПУ» не доказывают, но профиль откровенно шумный и по составу совпадает с описанным выше silent-классом — теперь на масштабе целого NAT. И одновременно независимый connect-check говорит: на этом же публичном адресе уже плохо работают сервисы, которые должны быть доступны. Вот такое совпадение двух независимых слоёв интереснее любого отдельно взятого score.


Но у этого кейса есть неприятная деталь — и мы её не прячем

В HTML connect-check того же клиента присутствовала проблема локальной среды: тест показывал 100% loss до Wi‑Fi gateway с некорректно определённым 0.0.0.0. Если бы нашей целью была эффектная история, эту строчку проще всего было бы не замечать, но для исследования это плохая привычка. Поэтому кейс нельзя честно назвать однозначным доказательством блокировки именно ТСПУ.

Он сильно поддерживает рабочую модель, потому что одновременно есть:

  • unexpected fail обычных ресурсов;
  • известный публичный egress;
  • silent SYN pattern;
  • очень высокий short-flow ratio;
  • низкий RST;
  • около 186 insides на одном NAT;
  • выраженный fan-out.

Но для чистого подтверждения нужен контрольный connect-check с Ethernet либо другой клиент на том же egress. Без таких оговорок очень быстро получается не исследование, а коллекция подтверждений заранее любимой теории.


А RST всё-таки бывает

После июньского PCAP и первых сентябрьских данных можно было легко уйти в другую крайность и решить, что RST нам вообще не интересен и всё всегда silent. Тоже нет. На другом публичном NAT в сегодняшнем срезе обнаружился совершенно иной профиль:

RST ≈ 43%
short3 ≈ 99%

То есть почти учебниковая RST-heavy картина. Конкретный адрес мы сознательно не публикуем, но сам факт важен:

на одной и той же операторской сети существуют как минимум два наблюдаемых класса деградации.

Условно:

Класс A — silent

syn_no_ack ↑
short3 ↑
RST low

Класс B — RST-heavy

RST ↑↑
short3 ↑↑

Это хорошо отрезвляет: искать один универсальный индикатор «ТСПУ=1» бессмысленно.


Масштаб: это не один странный NAT

Оставался вопрос, насколько сентябрьский кейс единичен. После того как детектор silent-drop перестроили с RST-first на syn_no_ack + short3, он начал находить похожие публичные egress по всей выборке.

На снимке 8 сентября около 16:40:

~65 публичных NAT

попадали в v_silent_drop_nat, и порядка:

~67 NAT

были помечены connect-check-ориентированным view как кандидаты на тихую деградацию.

Здесь очень важно слово «кандидаты»: порог пока исследовательский, а confirmed-фактуры у нас значительно меньше. Именно поэтому следующим этапом мы не собираемся «повышать чувствительность алерта», а хотим получить несколько независимых connect-check с разных NAT и сравнить их с контрольной группой.


Может ли «шумный» inside мешать соседям?

Это один из самых интересных вопросов всего проекта. Возьмём тот же CGNAT:

                       ┌── обычный абонент A
                       ├── обычный абонент B
                       ├── обычный абонент C
                       │
PUBLIC IPv4 ◄──────────┼── noisy inside
                       │    ├── много SYN
                       │    ├── сотни dst
                       │    ├── сотни ports
                       │    └── short flows
                       │
                       ├── обычный абонент Y
                       └── обычный абонент Z

Для любого внешнего механизма, который опирается на IP reputation, rate, поведенческий профиль или состояние большого числа сессий, всё это — один источник, и технически идея collateral damage на CGNAT совершенно реалистична (см. упомянутые выше данные Cloudflare). Но применительно именно к нашим событиям ТСПУ причинность пока не доказана.

Мы видим корреляцию:

аномальный NAT
+
noisy insides
+
неожиданные fail
+
характерный TCP-след

Чтобы сказать:

«вот этот конкретный inside своей активностью вызвал деградацию публичного адреса»

нужна повторяемая временная последовательность. Именно поэтому теперь основная единица анализа — не EVENT, а BEFORE → EVENT → AFTER.


BEFORE / EVENT / AFTER: как не перепутать причину со следствием

Высокий SYN может означать две противоположные вещи.

Вариант 1. SYN был причиной аномального профиля

Например, устройство действительно:

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

Вариант 2. SYN уже является следствием проблемы

Соединения перестали устанавливаться, приложения начинают ретраи, и количество SYN растёт после начала деградации. Если смотреть только на EVENT, эти два сценария легко перепутать.

Поэтому каждое подтверждённое внешним тестом событие теперь режем на три окна:

        BEFORE             EVENT              AFTER
───────────────┬─────────────────────┬────────────────
   до fail     │ connect-check fail  │ после события
───────────────┴─────────────────────┴────────────────

Для каждого окна считаем:

flows_sec
syn_sec
syn_no_ack_pct
rst_pct
short3_pct
uniq_dst
uniq_dst_port
uniq_inside
tcp443
udp443
scan_score
amp_score
p2p_like

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


Почему здесь интересны 5–15 минут

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

Если в будущем несколько независимых кейсов будут выглядеть так:

T-20m  на NAT появляется необычный fan-out
T-15m  резко растут новые сессии
T-10m  выделяется один noisy inside
T0     expected_block=0 начинает FAIL
T+...  профиль меняется или доступность восстанавливается

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


Что именно считаем «шумным» профилем

Мы постепенно пришли к трём основным осям.

1. Порты

uniq_dst_port

Много разных destination ports за короткий период может быть признаком port-scan, сервисного перебора или просто очень необычного приложения. Сам по себе показатель слабый, но вместе с высоким SYN и short-flow ratio уже интереснее.

2. Destination

uniq_dst
uniq_dst_pair

Насколько широко источник размазывает соединения по Интернету. Современный браузер, CDN и P2P могут давать большой fan-out совершенно легально, поэтому контекст обязателен.

3. Незавершённые TCP-сессии

syn_no_ack_pct
short3_pct

Особенно интересна комбинация:

syn_no_ack high
short3 high
RST low

Именно она лучше всего соответствует нашему наблюдаемому silent-классу.

Дополнительные оси:

  • flows_sec;
  • tcp443 / udp443;
  • amp-порты вроде 53/123/1900/11211;
  • P2P dampen;
  • отклонение от собственного baseline.

Baseline важнее абсолютного числа

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

100 SYN flows/sec

Само это число ни о чём не говорит: для офиса на десять компьютеров подозрительно, а для большого CGNAT, хостинга или игрового сегмента — возможно, совершенно нормально. Поэтому абсолютные пороги остаются только первым слоем, а для каждого объекта полезнее хранить собственную историю:

average
median
p95
p99
max
stddev

И смотреть не только:

сейчас = 100

а:

сейчас = 100
обычно = 8
p99 = 17

Вот это уже настоящая аномалия конкретного объекта.


Почему мы пока не делаем auto-ban

Когда на графике красиво видно port-scan-ish, очень хочется сразу автоматизировать «лечение». Мы этого сознательно не делаем, потому что под тем же профилем могут скрываться:

  • легитимный vulnerability scanner клиента;
  • мониторинг;
  • Kubernetes;
  • P2P;
  • игровой launcher;
  • backup;
  • CDN;
  • корпоративный proxy;
  • заражённый хост.

NetFlow-эвристика должна сначала привести человека к правильному месту расследования, а не сама вынести приговор. Поэтому сейчас workflow выглядит так:

NetFlow
   ↓
метрики + score
   ↓
публичный NAT
   ↓
вкладчики inside
   ↓
hourly digest / Grafana
   ↓
человек
   ↓
connect-check / PCAP / контакт с абонентом

Это скорее инструмент NOC и security operations, чем автоматическая IDS.


Отдельно пришлось мониторить сам мониторинг

Ещё одна прекрасная возможность обмануть себя — потерять NetFlow на collector и принять дырку в данных за сетевое событие. На первом варианте установки ingest, ClickHouse, Grafana и остальные компоненты жили почти вместе; после подключения нескольких реальных exporters нагрузка быстро стала неприятной, так что роли пришлось разнести и добавить disk spool.

Теперь отдельно смотрим:

  • состояние GoFlow2;
  • UDP receive;
  • exporter sequence gaps;
  • задержку ingest;
  • ClickHouse inserts;
  • CPU/RAM/disk.

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

Если у коллектора sequence gap, сначала чините коллектор. ТСПУ подождёт.


Что получилось в Grafana

Вместо одного гигантского дашборда мы разделили задачи.

Почему этому NAT плохо?

Публичный NAT как ключ:

silent drop?
RST?
fan-out?
P2P?
сколько inside?
что происходило до события?

Scanners

Port-scan-ish и host-scan-ish профили.

Amplifiers

Активность по типичным UDP amplification ports.

Malware-ish

Долго живущие или регулярно повторяющиеся аномальные inside. Это именно эвристика, а не диагноз «найден вирус».

Infra

Здоровье самого NetFlow-контура.

Самой полезной частью в итоге оказался не красивый общий score, а возможность провалиться:

problem NAT
   ↓
insides behind NAT
   ↓
кто дал SYN?
   ↓
кто дал fan-out?
   ↓
кто дал port sweep?

То есть перейти от «плохо всему адресу» к конкретным кандидатам внутри CGNAT.


Июнь 2026 вообще был показательным

Наш локальный Wireshark-скриншот не существовал в вакууме. В июне публично обсуждались проблемы с доступом к российским облачным сервисам: Habr, ссылаясь на сообщения отрасли и публикации РБК, писал о подтверждённых сбоях у ряда российских хостеров и о том, что решения могли приниматься по косвенным признакам зашифрованных соединений — в том числе диапазонам IP и частоте подключений.

Источник: Habr — «РКН устроил проблемы с доступом российским облачным сервисам и сайтам».

Мы не используем эту публикацию как доказательство конкретно нашего июньского дампа. Но она важна как фон: collateral damage от поведенческих и IP-ориентированных механизмов в 2026 году уже наблюдался публично далеко не только нами.


QUIC тоже нельзя забывать

Современный HTTPS — это уже не только TCP/443: браузеры и приложения активно используют QUIC на UDP/443, и если UDP/443 становится недоступен или деградирует, клиент может откатиться на TCP. Поэтому мы отдельно смотрим:

udp443

tcp443

udp443 / tcp443

Пока данных недостаточно, чтобы считать это сильным индикатором. Но резкое изменение отношения на проблемном NAT вполне может оказаться полезным дополнительным признаком.


NetFlow, PCAP и connect-check отвечают на разные вопросы

В результате сложилась довольно удобная трёхслойная модель.

NetFlow

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

connect-check

Показывает, что в этот момент реально видел клиент и какие классы сервисов перестали работать.

PCAP

Показывает, что физически происходило с конкретными сессиями.

Условно:

NetFlow
   "кажется, проблема вот на этом egress"
              │
              ▼
connect-check
   "да, здесь в 15:11 уже падают unexpected ресурсы"
              │
              ▼
PCAP
   "а теперь посмотрим конкретный handshake"

Июньский Wireshark и сентябрьский NetFlow как раз замыкают эту историю.


Что мы уже можем говорить достаточно уверенно

1. «Пинг есть» больше не является достаточной проверкой доступности

Частичная TCP/TLS-деградация прекрасно сосуществует с живым ICMP, DNS и частью установленных соединений.

2. NetFlow способен показать след такой деградации

Не внутреннее решение DPI, а статистический результат на edge.

3. На нашей площадке silent SYN-drop оказался важнее RST

Рабочей оказалась silent-комбинация, описанная выше. Но RST-heavy кейсы тоже существуют, поэтому единственного fingerprint нет.

4. Для CGNAT главная сущность — публичный egress

Inside нужен для attribution, а не как первичный ключ внешнего события.

5. P2P обязательно нужно отделять от сканирования

Иначе top offenders очень быстро превращаются в «список людей с торрентами».

6. Калиброваться надо от внешней фактуры

connect-check expected_block=0 FAIL с известным egress полезнее любого самостоятельно придуманного anomaly score.

7. Самое интересное — BEFORE

Именно поведение перед событием может когда-нибудь дать нам предиктивный сигнал.


Чего мы пока не можем утверждать

Вот здесь лучше быть скучными. Мы не знаем машинного порога вида:

N SYN = блок

Мы не можем по одному NetFlow доказать, какое именно правило сработало внутри ТСПУ, и не можем утверждать, что каждый silent_syn_drop — это ТСПУ: причиной могут быть и другие middlebox, серверная сторона, маршрутизация, локальные проблемы клиента и ошибки измерительного контура.

Мы пока не доказали причинную связь:

noisy inside → решение внешнего фильтра → collateral для всего CGNAT.

Она выглядит правдоподобно и хорошо рифмуется с общей проблемой shared-IP reputation, но для конкретного ТСПУ нам нужны десятки повторяемых labeled events. И мы специально не называем «малварью» любой высокий scan_score — это только кандидат для расследования.


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

Сейчас ценнее не ещё десять панелей в Grafana, а фактура. Нужны разные классы реальных событий:

  1. Silent drop — описанный выше класс.
  2. RST-heavy — чтобы понять другой тип поведения.
  3. P2P control — высокий fan-out без деградации.
  4. Настоящие scanners — для отделения от P2P.
  5. Чистые CGNAT с сопоставимой нагрузкой, где connect-check полностью здоров.

Для каждого подтверждённого случая:

когда началось
какой public NAT
что fail
что OK
какие insides сидели за NAT
что происходило BEFORE
что происходило EVENT
что изменилось AFTER

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

P(degradation | syn_no_ack, fanout, ports, baseline_delta)

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


Возможно, главный результат проекта вообще не про ТСПУ

Начиналось всё с попытки понять странную фильтрацию. Но даже если завтра убрать из системы слово «ТСПУ», контур всё равно остаётся полезным. Он уже позволяет видеть:

  • аномальные публичные NAT;
  • массовые незавершённые TCP-сессии;
  • scanners;
  • amp-port activity;
  • P2P;
  • необычный fan-out;
  • noisy insides внутри CGNAT;
  • отклонение от собственного baseline;
  • момент, когда пользовательская доступность расходится с зелёным ICMP.

То есть из узкой попытки разобраться с одним странным сетевым эффектом получился довольно универсальный инструмент эксплуатации CGNAT.


Вместо заключения

В июне мы смотрели на Wireshark и видели повторные SYN к :443 среди совершенно живого остального трафика. В сентябре тот же класс симптомов появился уже на операторской статистике:

syn_no_ack
short flows
fan-out
публичный NAT
сотни insides

Потом рядом нашёлся и другой класс — RST-heavy. То есть простого ответа вида:

TSPU_BLOCKED = true

скорее всего, никогда и не будет. Зато можно сделать кое-что полезнее — не гадать по жалобе «опять ТСПУ или у клиента роутер завис», а собрать несколько независимых слоёв:

реальный fail клиента
+
известный public egress
+
NetFlow до/во время/после
+
вклад конкретных insides
+
при необходимости PCAP

По отдельности каждый признак слабый. Вместе они превращают фразу:

«кажется, Интернет опять как-то странно работает»

в вполне измеримое сетевое событие. А с измеримыми событиями уже можно работать.


Ссылки

Предыдущая часть истории — «Две недели “интернета нет” при зелёных пингах»:

https://articles.clr58.ru/dve-nedeli-net-interneta

connect-check:

https://github.com/cooler58/connect-check

Открытая реконструкция архитектуры ТСПУ:

https://github.com/DanielLavrushin/tspu-docs

Про send RST off:

https://github.com/DanielLavrushin/tspu-docs/blob/main/chapters/17.md

Про двухстадийное формирование списков:

https://github.com/DanielLavrushin/tspu-docs/blob/main/chapters/08.md

Cloudflare про collateral effect CGNAT:

https://blog.cloudflare.com/detecting-cgn-to-reduce-collateral-damage/

Habr про проблемы российских облачных сервисов в июне 2026:

https://habr.com/ru/amp/publications/1046025/


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

#network #monitoring #netflow