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

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


  1. Shaman_RSHU
    07.05.2026 12:02

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


  1. Baton34
    07.05.2026 12:02

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


  1. Runnin
    07.05.2026 12:02

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


  1. dyadyaSerezha
    07.05.2026 12:02

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