Когда сайт не открывается, браузер показывает «Не удалось установить соединение». Это всё, что он знает. Но «не открывается» - это десяток разных историй. 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 socket
import requests

def resolve_system(host: str) -> str | None:
    try:
        return socket.gethostbyname(host)
    except socket.gaierror:
        return None

def 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 socket
import time

def 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 socket
import ssl
import time

def 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)
   ↓ ok
DNS resolve (DoH)
   ↓ совпадает
TCP connect :443
   ↓ ok
TLS handshake (с SNI)
   ↓ ok
HTTP 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 ThreadPoolExecutor

def 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_completed
from typing import Iterator

def 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 или, наоборот, ложно-зелёные сайты, которые по факту не работают.

Комментарии (7)


  1. Shaman_RSHU
    07.05.2026 12:02

    Посмотрите bol-van/zapret2. Толмуд там конечно поменьше, но само решение работает намного эффективнее.


  1. Baton34
    07.05.2026 12:02

    а как же блокировка по длинне 16kB для tls трафика? У меня один впс попал под такое, при этом тест iperf3 проходит в обе стороны без замедления.


    1. tdrz
      07.05.2026 12:02

      По факту сейчас это основная блокировка для большинства CDN.
      т.е. DPI маскирует свое присутствие, пропуская первые 16-20кб трафика.
      В то время как провайдеры могут отчитываться что "сайт работает", т.е. проблема не на их стороне.

      Блокируют все без разбору и решений суда - от недружественного Cloudflare до дружественной китайской Алибабы (AliCloud).
      Сайты с драйверами, обновлений ПО, игр и т.д.
      То есть все, откуда идет маломальски большой трафик, улетает в блок.

      У меня подозрение, что по этому параметру выскобюджетный ИИ от РКН и вычисляет "сайты-нарушители".
      "много трафика = опасность = надо бы заблокировать на всякий случай"


  1. Runnin
    07.05.2026 12:02

    ThreadPool использовать менее эффективно, чем обычные асинхронные запросы


  1. dyadyaSerezha
    07.05.2026 12:02

    Один маленький вопрос. Чем отличается статус working для whitelist от статуса open для blacklist? Если вдруг ничем, то почему разные названия?


  1. DandyDan
    07.05.2026 12:02

    Странно. Пробую numpy.org - говорит ОК. А в браузере не открывается.


  1. Nemoumbra
    07.05.2026 12:02

    ISP перехватывает HTTPS, выдаёт свой сертификат (что вызвало бы ошибку TLS, но - нет, обычно делают это только для не-HTTPS-запросов или подменяют DNS, чтобы вы пришли на их сервер) и отдаёт страницу

    Что?! Какое-то странное предложение, которое противоречит само себе. Любой митм на уровне TLS детектится. Не важно, редирект случился через DNS или in-line перехват - браузер увидит и предупредит.

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

    Во время хэндшейка браузеры уже сейчас высылают ECH, просто потому что могут. Другой вопрос, что сервер может не понять, так что прикладывается и обычный SNI. А когда всё-таки происходит ECH, то там и фейковый SNI подставляется вместо настоящего, а уже этот SNI вызывает блокировку. Чтобы не отправлять SNI вообще... ну, тут уже ossification.