Что именно сломалось: разбираем блокировки РКН/ТСПУ по слоям сетевого стека. Rkn Block Checker

от автора

Когда сайт не открывается, браузер показывает «Не удалось установить соединение». Это всё, что он знает. Но «не открывается» — это десяток разных историй. ISP подменил DNS-ответ. Провайдер режет TCP по IP. ТСПУ читает SNI в TLS ClientHello и сбрасывает соединение. Сайт открывается, но возвращает 200 OK с заглушкой «доступ ограничен». Каждый случай требует своих действий — и, что важнее, означает разные вещи о том, где именно стоит фильтр.

В статье разберу, как именно работают четыре основных способа блокировки, и покажу маленький CLI на Python, который проверяет их по очереди и говорит «у тебя сломан слой N». Инструмент — на гитхабе, под MIT, поставить можно через pip install rkn-block-checker. Но интереснее не сам он, а то, что под капотом.

Зачем вообще диагностировать, а не обходить

Сразу проговорю, чего здесь нет. Это не про обход блокировок. Не про VPN, не про fronting, не про DPI-evasion типа zapret или GoodbyeDPI. Это про диагностику: понять, что именно сломано, чтобы знать, что чинить.

Польза от такой диагностики неочевидна, пока не попадёшь в ситуацию, когда «у меня не работает» одновременно у пяти разных людей в комнате — и причины у всех разные. У одного отравленный DNS (лечится сменой DNS-резолвера), у другого DPI на SNI (DNS не поможет, нужен fronting или VPN), у третьего вообще ISP вернул заглушку через HTTP (значит, до сайта пакеты доходят, проблема выше). Без понимания «где» — ткнуться можно в любую сторону и не угадать.

Второй сценарий — проверка качества канала. Если вы переезжаете на новую квартиру с новым провайдером, полезно за 30 секунд понять, какие именно блокировки тут активны: только DPI на SNI? Плюс DNS-poisoning? Заглушки? Это влияет на выбор стратегии (DoH хватит или нужен полный туннель).

Существующие альтернативы вроде OONI Probe делают много больше — собирают измерения в публичную базу для долгосрочного анализа. Но для вопроса «что у меня сейчас сломано» это перебор: тяжёлый клиент, обязательная регистрация измерений, непростой вывод. Хотелось чего-то размером в один pip install, что выдаёт вердикт за полминуты.

Четыре слоя, четыре способа сломать

HTTPS-запрос к сайту — это четыре независимых этапа, каждый из которых может быть атакован отдельно. Я буду идти снизу вверх.

Слой 1: DNS

Самый старый и дешёвый способ заблокировать сайт — заставить DNS соврать. Когда вы вводите protonvpn.com, ваш компьютер спрашивает у DNS-резолвера (обычно — у того, что выдал DHCP провайдера) IP-адрес. Если резолвер врёт — например, возвращает 0.0.0.0 или адрес заглушки — браузер никогда никуда не сходит. Этот метод не требует от провайдера никакого DPI-оборудования, только подкручивать свой DNS.

Распознать DNS-блокировку легко, если есть с чем сравнить. Берём результат от системного резолвера (тот, который провайдер контролирует) и сравниваем с DNS-over-HTTPS — например, https://cloudflare-dns.com/dns-query. ISP не может перехватить DoH-запрос, потому что он ходит внутри обычного HTTPS-соединения с Cloudflare. Если системный DNS сказал «не знаю такого хоста», а DoH спокойно вернул IP — это смокинг ган.

В коде это выглядит так:

import socketimport requestsdef resolve_system(host: str) -> str | None:    try:        return socket.gethostbyname(host)    except socket.gaierror:        return Nonedef resolve_doh(host: str) -> str | None:    r = requests.get(        "https://cloudflare-dns.com/dns-query",        params={"name": host, "type": "A"},        headers={"accept": "application/dns-json"},        timeout=5,    )    for ans in r.json().get("Answer", []):        if ans.get("type") == 1:  # A record            return ans.get("data")    return None

Логика вердикта:

  • система вернула IP, DoH вернул тот же IP — DNS чистый;

  • система вернула IP, DoH вернул другой IP — есть подозрение на manipulation, но не факт (это может быть просто CDN с разной геолокацией);

  • система вернула None, DoH вернул IP — DNS-блокировка, лечится сменой DNS на 1.1.1.1 или 8.8.8.8 (или DoH на постоянной основе);

  • оба вернули None — сайт реально лежит или его нет.

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

Слой 2: TCP

Шаг сложнее: блокировать по IP. ISP может слать RST на любой пакет, идущий на определённый адрес, или просто молча дропать. Это делается на маршрутизаторе провайдера и не требует разбора содержимого пакетов — достаточно ACL.

Проверяется тривиально: пробуем установить TCP-соединение на порт 443 (HTTPS) и смотрим, что происходит.

import socketimport timedef check_tcp(host: str, port: int = 443, timeout: float = 5.0):    start = time.monotonic()    try:        with socket.create_connection((host, port), timeout=timeout):            return True, (time.monotonic() - start) * 1000, None    except socket.timeout:        return False, None, "timeout"    except ConnectionResetError:        return False, None, "connection reset"    except OSError as e:        return False, None, f"{type(e).__name__}: {e}"

Три исхода:

  • Всё ок, TCP-handshake завершился — переходим к TLS;

  • Connection reset на стадии handshake — IP-уровневая блокировка, провайдер шлёт RST. Сейчас редкость, потому что массовый RST по IP неудобен (CDN, общие хостинги). Применяется обычно к точечным целям;

  • Timeout — пакеты молча дропаются. Опять же, для отдельных IP-адресов, а не для целых сайтов.

На практике в 2026 году чистый TCP-RST по IP встречается редко — провайдерам выгоднее работать выше по стеку. Но для отдельных серверов (например, выходных нод Tor) это до сих пор актуально.

Слой 3: TLS

Здесь начинается самое интересное. Современное ТСПУ-оборудование не блокирует TCP. Оно пропускает SYN, SYN-ACK, ACK — соединение открывается. И только когда клиент шлёт первый TLS-пакет (ClientHello), middlebox разбирает его, читает поле SNI и принимает решение.

Server Name Indication — это расширение TLS, в котором клиент в открытом виде сообщает серверу, к какому хосту он обращается. Нужно это для того, чтобы один IP мог обслуживать сотни сайтов: сервер должен знать, какой именно сертификат предъявить. ClientHello отправляется до того, как соединение зашифровано, поэтому SNI читается всеми, кто стоит на пути.

Дальше middlebox делает одно из двух: шлёт RST обеим сторонам, или просто перестаёт пропускать пакеты. С точки зрения клиента это выглядит так: TCP-соединение установилось чисто (пинг-понг успешен), отправили ClientHello, и тут — либо ConnectionResetError, либо socket.timeout.

Это и есть отпечаток DPI на SNI:

TCP_OK + TLS_FAILED  →  скорее всего, ТСПУ

Никакая другая комбинация так не выглядит. Если бы блокировка была на уровне DNS, мы бы не дошли до TCP. Если бы по IP — TCP не открылся бы. А вот «соединение есть, но как только сказал кому именно — всё рвётся» — это конкретно про инспекцию SNI.

Код проверки:

import socketimport sslimport timedef check_tls(host: str, port: int = 443, timeout: float = 5.0):    ctx = ssl.create_default_context()    start = time.monotonic()    try:        with socket.create_connection((host, port), timeout=timeout) as sock:            with ctx.wrap_socket(sock, server_hostname=host) as ssock:                return True, (time.monotonic() - start) * 1000, None    except socket.timeout:        return False, None, "timeout"    except ssl.SSLError as e:        return False, None, f"SSLError: {e.reason}"    except ConnectionResetError:        return False, None, "connection reset during TLS"

Важный момент: server_hostname=host — это и есть передача SNI. Без него (или с подменённым SNI) middlebox не увидит запрещённое имя и пропустит. На этом построены некоторые техники обхода: domain fronting, ECH (Encrypted Client Hello), фрагментация ClientHello. Но это уже про другую статью.

В TLS 1.3 был шанс убить SNI как атрибут — придумали ECH, который шифрует ClientHello целиком. Но deployment его пока скорее экспериментальный, и middleboxes научились реагировать на сам факт ECH (например, рвать соединение, если видят ECH-расширение). Пока что SNI остаётся главной точкой инспекции.

Слой 4: HTTP

Иногда блокировка пропускает всё — DNS, TCP, TLS — но возвращает не то, что должна. Это два сценария.

HTTP 451. Код «Unavailable For Legal Reasons», добавленный в RFC 7725 специально для таких случаев. По задумке — честный способ сказать «доступ закрыт по решению суда». На практике встречается редко, но если встретился — это явный маркер.

ISP stub-page. ISP перехватывает HTTPS, выдаёт свой сертификат (что вызвало бы ошибку TLS, но — нет, обычно делают это только для не-HTTPS-запросов или подменяют DNS, чтобы вы пришли на их сервер) и отдаёт страницу с текстом вроде «Доступ ограничен по решению Роскомнадзора» со статусом 200 OK. Браузер показывает её как обычную страницу — никакой ошибки нет, просто содержимое не то.

Проверка по-прежнему простая: сделать GET на нужный URL и посмотреть, что в теле. Если там встречаются маркеры заглушек — значит, заглушка.

STUB_MARKERS = (    "доступ ограничен",    "решению роскомнадзора",    "решением суда",    "заблокирован",    "blocked by",    "rkn.gov.ru",    "единый реестр",)def looks_like_stub(body: str) -> bool:    body_lower = body.lower()    return any(marker in body_lower for marker in STUB_MARKERS)

Точность — не 100%. Теоретически можно представить сайт, который случайно содержит фразу «доступ ограничен» в обычном контексте. На практике false-positive я ни разу не видел, но в продакшене такой эвристике я бы не доверил критичные решения.

Как из этого собирается вердикт

Логика «обхода» по слоям получается прямая: идём снизу вверх и останавливаемся на первом сломанном.

DNS resolve (system)   ↓ okDNS resolve (DoH)   ↓ совпадаетTCP connect :443   ↓ okTLS handshake (с SNI)   ↓ okHTTP GET   ↓ статус 200 + не заглушка= OK

На каждом шаге, если что-то сломалось, выдаём свой вердикт:

  • DNS система failed, DoH ok → DNS_BLOCK

  • TCP RST → TCP_RESET

  • TCP timeout → TIMEOUT

  • TCP ok, TLS RST/timeout → TLS_BLOCK (отпечаток DPI)

  • HTTP 451 или маркеры в теле → HTTP_STUB

  • Всё ок → OK

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

  • Whitelist (контрольный) — сайты, которые точно должны открываться: gosuslugi, yandex, sberbank, vk, ozon, mos.ru, и так далее. Если они не открываются — у вас не блокировка, у вас сломан интернет.

  • Blacklist — сайты, заблокированные в РФ: Instagram, X (Twitter), LinkedIn, Discord, Tor Project, ProtonVPN, Patreon, rutracker и пр.

Если whitelist открывается на 100%, а blacklist — больше чем на 70% не открывается, выдаём «вы в блокированной сети, и вот разбивка по типам блокировок».

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

Первая версия CLI делала проверки последовательно, и это было неприятно: 36 сайтов × среднее время на одну пробу — минута и больше. Очевидное решение — параллелизм через ThreadPoolExecutor:

from concurrent.futures import ThreadPoolExecutordef check_urls_parallel(urls, max_workers=10, timeout=5.0):    with ThreadPoolExecutor(max_workers=max_workers) as pool:        return list(pool.map(            lambda kv: check_url(kv[0], kv[1], timeout),            urls.items()        ))

Стало быстрее в 10 раз — но появилась другая проблема. pool.map() возвращает результаты только когда все задачи завершены. То есть юзер запускает CLI, видит шапку «RKN Block Checker», а потом 10 секунд тишины — и потом сразу вся стена результатов. UX так себе.

Починилось одним переключением с pool.map() на as_completed() — функция-генератор yield-ит результаты сразу, как они приходят:

from concurrent.futures import ThreadPoolExecutor, as_completedfrom typing import Iteratordef iter_check_urls(urls, max_workers=10, timeout=5.0) -> Iterator[CheckResult]:    with ThreadPoolExecutor(max_workers=max_workers) as pool:        futures = [            pool.submit(check_url, name, url, timeout)            for name, url in urls.items()        ]        for fut in as_completed(futures):            yield fut.result()

В CLI цикл стал такой:

print_section("Whitelist (should always work)")for r in iter_check_urls(WHITE_URLS, workers, timeout):    print_result(r)    sys.stdout.flush()  # важно - иначе питон буферизует stdout

Один тонкий момент: as_completed отдаёт результаты в порядке готовности, не в порядке входа. Для интерактивного вывода это нормально (быстрые сайты сверху, медленные — внизу), а вот для --json режима, где ожидается стабильный порядок ради воспроизводимости, я оставил отдельный wrapper:

def check_urls_parallel(urls, max_workers=10, timeout=5.0) -> list[CheckResult]:    name_order = list(urls.keys())    by_name = {r.name: r for r in iter_check_urls(urls, max_workers, timeout)}    return [by_name[name] for name in name_order if name in by_name]

Вторая хитрость — обе группы (whitelist и blacklist) запускаются в одном пуле потоков сразу. Пока выводятся whitelist-строки, blacklist уже параллельно работает в фоне. Когда whitelist допечатается, blacklist уже либо готов, либо почти готов — секция blacklist наливается практически мгновенно. Общее время не выросло, а воспринимается всё ощутимо живее.

Что получилось на выходе

Обычный запуск выглядит так:

======================================================================  RKN Block Checker======================================================================  IP:       95.165.xxx.xxx  ISP:      AS12389 Rostelecom  Location: Moscow, Moscow, RU----------------------------------------------------------------------Whitelist (should always work)  name          verdict            TCP     TLS     PLT  status  ------------------------------------------------------------  gosuslugi     ✓ OK              18ms    42ms   380ms  200  yandex        ✓ OK               8ms    25ms    95ms  200  sberbank      ✓ OK              12ms    38ms   250ms  200  ...Blacklist (RKN-restricted)  name          verdict            TCP     TLS     PLT  status  ------------------------------------------------------------  instagram     ✗ TLS BLOCK       22ms       -       -  -    └ TLS reset - DPI cutting on SNI (typical RKN/TSPU)  twitter/x     ✗ TLS BLOCK       24ms       -       -  -    └ TLS timeout - silent drop after ClientHello  rutracker     ✗ HTTP STUB       18ms    45ms   120ms  200    └ response body matches an ISP stub-page marker  protonvpn     ✗ DNS BLOCK          -       -       -  -    └ system DNS doesn't resolve, DoH does - DNS poisoning======================================================================  Summary----------------------------------------------------------------------  Whitelist: 21/21 working  Blacklist: 3/15 open, 12/15 blocked  → You ARE in an RKN-blocked zone.  Block types in the blacklist:    ✗ TLS BLOCK: 8    ✗ DNS BLOCK: 2    ✗ HTTP STUB: 2======================================================================

Важная информация компактно: какие сайты, что именно с ними не так, на каком слое сломалось. Для скриптинга есть --json — выдаёт ту же информацию, плюс полный probe trace на каждый сайт (какие IP вернули резолверы, какой сертификат пришёл, тайминги). Удобно скармливать в jq:

# имена всех заблокированных сайтовrkn-check --json | jq -r '.blacklist[] | select(.verdict != "OK") | .name'# только DPI-блокировки (TCP жив, TLS мертв)rkn-check --json | jq '.blacklist[] | select(.verdict == "TLS_BLOCK" and .tcp_ok)'

Что не сделано и почему

Чтобы предупредить вопросы в комментариях, проговорю явно.

IPv6. Не реализовано. На практике IPv6-трафик в России до сих пор обрабатывается ТСПУ менее тщательно — у некоторых провайдеров через v6 пропускают то, что блокируется на v4. Это интересный отдельный сюжет, но требует отдельной диагностики и отдельной семантики вердиктов («v4 заблокирован, v6 открыт» — это уже не бинарный ответ). Возможно, в следующей версии.

QUIC и HTTP/3. Современные сайты всё больше переходят на QUIC (UDP, порт 443). ТСПУ работает с QUIC по своим правилам — насколько мне известно, пока чаще через полную блокировку UDP/443 в моменты ужесточений, чем через DPI на содержимом. Поддержка QUIC потребовала бы своего отдельного probe-стека.

Точечные блокировки внутри одного сайта. Многие блокировки сейчас работают не на уровне «весь домен», а «конкретный URL» или «конкретные подсети CDN». Например, YouTube не заблокирован полностью — режется только определённый CDN-префикс. Эта тула такого не увидит — если главная страница открывается, то OK.

TLS 1.3 ECH. Когда (если) ECH станет массовым, текущая логика TLS_BLOCK = DPI on SNI перестанет быть точной — SNI будет зашифрован. Сейчас это не проблема, потому что ECH мало где включен по умолчанию.

Лонгитудинальный мониторинг. Один прогон — это снимок. Чтобы отслеживать «когда именно блокировка появилась/пропала», нужно гонять rkn-check --json по cron и собирать в timeseries. Возможно, имеет смысл добавить готовый docker-compose с Grafana, но это уже другой проект.

Где взять и что почитать

GitHub: github.com/MayersScott/rkn-block-checker PyPI: pip install rkn-block-checker, потом rkn-check.

По теме блокировок и DPI рекомендую:

  • GFW Report — лучший русско- и англоязычный источник про устройство DPI-блокировок (на примере Китая, но многие принципы применимы);

  • OONI — academic-grade инструмент для измерения цензуры с публичной базой данных;

  • bol-van/zapret и его Wiki — практический источник про то, как именно ТСПУ инспектирует SNI и какие техники evasion работают.

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

ссылка на оригинал статьи https://habr.com/ru/articles/1032572/