От пассивного NetFlow к активной матрице: проверяем каждый NAT отдельно

Обложка

Третья часть исследования 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 объединяет:

На текущей версии получилось 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.

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

Но теперь у нас есть способ убрать из уравнения значительную часть этих переменных: одновременно сравнивать несколько 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