#!/usr/bin/env bash # # censorcheck — проверка блокировок для ноды. # wget -qO- censor.nurvps.com | bash # # Два направления: # 1. Наружу — что с этого сервера недоступно (DNS, TCP, TLS, HTTP). # 2. Внутрь — «радар ТСПУ»: доступен ли этот IP из сетей РФ-провайдеров. # Считается зондами RIPE Atlas. Ключ живёт на censor.nurvps.com, # в этот скрипт он не попадает, мерить можно только собственный IP. # # Флаги: -q быстрый радар · --no-tspu без радара · --sni HOST · --json · -d отладка set -uo pipefail API="${CENSORCHECK_API:-https://censor.nurvps.com}" TIMEOUT=5 MAX_PARALLEL=10 SNI="max.ru" PROFILE="full" DO_TSPU=true AS_JSON=false DEBUG=false while [[ $# -gt 0 ]]; do case "$1" in -q|--quick) PROFILE="quick" ;; --no-tspu) DO_TSPU=false ;; --sni) SNI="${2:-max.ru}"; shift ;; --json) AS_JSON=true ;; -d|--debug) DEBUG=true ;; -h|--help) sed -n '2,12p' "$0" | sed 's/^# \?//' exit 0 ;; esac shift done if [[ -t 1 ]] && ! $AS_JSON; then RED=$'\033[31m'; GREEN=$'\033[32m'; YELLOW=$'\033[33m' CYAN=$'\033[36m'; DIM=$'\033[2;90m'; BOLD=$'\033[1m'; RESET=$'\033[0m' else RED=""; GREEN=""; YELLOW=""; CYAN=""; DIM=""; BOLD=""; RESET="" fi say() { $AS_JSON || echo -e "$@"; } dbg() { $DEBUG && echo -e "${DIM}[debug] $*${RESET}" >&2; return 0; } # googlevideo.com намеренно нет: голый домен не отдаёт HTTP, # проверка всегда красная. YouTube покрыт youtube.com. DOMAINS=( youtube.com instagram.com facebook.com x.com discord.com telegram.org api.telegram.org web.whatsapp.com signal.org chatgpt.com claude.ai gemini.google.com grok.com github.com cloudflare.com speedtest.net netflix.com twitch.tv rutracker.org torproject.org amnezia.org proton.me ) # Заглушки РКН: если домен резолвится сюда — это подмена DNS RKN_STUBS="195.208.4.1 195.208.5.1 188.186.157.35 80.93.183.168 213.87.154.141 92.101.255.255 62.112.201.34" need() { command -v "$1" >/dev/null 2>&1; } for tool in curl openssl; do need "$tool" || { echo "нужен $tool, поставь и повтори"; exit 1; } done # python3 нужен только на разбор ответа радара и на --json. # Без него проверки доменов всё равно работают — не падаем. HAVE_PY=false need python3 && HAVE_PY=true if $AS_JSON && ! $HAVE_PY; then echo "для --json нужен python3" >&2; exit 1 fi TMPDIR_CC=$(mktemp -d) trap 'rm -rf "$TMPDIR_CC"' EXIT # ---------------------------------------------------------------- резолв resolve_a() { local domain="$1" if need getent; then getent ahostsv4 "$domain" 2>/dev/null | awk '{print $1}' | sort -u elif need dig; then dig +short +time=3 +tries=1 A "$domain" 2>/dev/null | grep -E '^[0-9.]+$' fi } is_stub() { local ip="$1" stub for stub in $RKN_STUBS; do [[ "$ip" == "$stub" ]] && return 0; done return 1 } # ------------------------------------------------- проверка одного домена # Пишет строку `домен|вердикт|причина|детали` в файл результатов. check_domain() { local domain="$1" out="$2" local ips ip first_ip="" tcp_ok=false tls_ok=false code="" ips=$(resolve_a "$domain") if [[ -z "$ips" ]]; then echo "$domain|BLOCKED|DNS|домен не резолвится" >> "$out"; return fi for ip in $ips; do if is_stub "$ip"; then echo "$domain|BLOCKED|DNS-SPOOF|подмена на заглушку $ip" >> "$out"; return fi [[ -z "$first_ip" ]] && first_ip="$ip" done # TCP 443 for ip in $ips; do if timeout "$TIMEOUT" bash -c "exec 3<>/dev/tcp/$ip/443" 2>/dev/null; then tcp_ok=true; first_ip="$ip"; break fi done if ! $tcp_ok; then echo "$domain|BLOCKED|TCP|443 не открывается ($first_ip)" >> "$out"; return fi # TLS с настоящим SNI — здесь и ловится DPI по SNI local tls tls=$(timeout "$TIMEOUT" openssl s_client -connect "$first_ip:443" -servername "$domain" \ -verify_return_error < /dev/null 2>&1) grep -q "Verify return code: 0 (ok)" <<<"$tls" && tls_ok=true code=$(curl -s -o /dev/null -w '%{http_code}' --connect-timeout "$TIMEOUT" \ --max-time $((TIMEOUT * 2)) -L "https://$domain" 2>/dev/null) if ! $tls_ok; then local why="хендшейк не прошёл" grep -qi "sslv3 alert\|tlsv1 alert\|reset by peer" <<<"$tls" && why="сброс на хендшейке (DPI по SNI)" grep -qi "self.signed\|unable to verify" <<<"$tls" && why="чужой сертификат (MITM)" echo "$domain|BLOCKED|TLS|$why" >> "$out"; return fi case "$code" in 000) echo "$domain|BLOCKED|HTTP|TLS живой, но ответа нет" >> "$out" ;; 2*|3*) echo "$domain|OK||HTTP $code" >> "$out" ;; 403|451) echo "$domain|PARTIAL|GEO|HTTP $code — режет сам сайт, не ТСПУ" >> "$out" ;; 4*|5*) echo "$domain|PARTIAL|HTTP|HTTP $code" >> "$out" ;; *) echo "$domain|PARTIAL|HTTP|неясный ответ '$code'" >> "$out" ;; esac } # ------------------------------------------------------------------ старт start_ts=$(date +%s) MY_IP=$(curl -s -4 --max-time 8 https://api.ipify.org 2>/dev/null) MY_ORG=$(curl -s --max-time 8 "https://ipinfo.io/${MY_IP}/org" 2>/dev/null | tr -d '\r\n') say "${BOLD}censorcheck${RESET} ${DIM}· censor.nurvps.com${RESET}" say "${DIM}$(printf '%.0s─' {1..64})${RESET}" say "Сервер: ${CYAN}${MY_IP:-неизвестен}${RESET} ${DIM}${MY_ORG}${RESET}" say "" # --------------------------------------------------- 1. блокировки наружу RESULTS="$TMPDIR_CC/results" : > "$RESULTS" say "${BOLD}Доступность снаружи${RESET} ${DIM}(${#DOMAINS[@]} доменов)${RESET}" running=0 for domain in "${DOMAINS[@]}"; do check_domain "$domain" "$RESULTS" & running=$((running + 1)) if (( running >= MAX_PARALLEL )); then wait -n 2>/dev/null || wait; running=$((running - 1)); fi done wait ok=0; partial=0; blocked=0 while IFS='|' read -r domain verdict reason detail; do [[ -z "$domain" ]] && continue case "$verdict" in OK) ok=$((ok + 1)); say " ${GREEN}✓${RESET} $(printf '%-22s' "$domain") ${DIM}${detail}${RESET}" ;; PARTIAL) partial=$((partial + 1)); say " ${YELLOW}!${RESET} $(printf '%-22s' "$domain") ${YELLOW}${reason}${RESET} ${DIM}${detail}${RESET}" ;; *) blocked=$((blocked + 1)); say " ${RED}✗${RESET} $(printf '%-22s' "$domain") ${RED}${reason}${RESET} ${DIM}${detail}${RESET}" ;; esac done < <(sort "$RESULTS") say "" say " ${GREEN}доступно ${ok}${RESET} · ${YELLOW}частично ${partial}${RESET} · ${RED}блок ${blocked}${RESET}" say "" # ------------------------------------------------------- 2. радар ТСПУ TSPU_JSON="" if $DO_TSPU; then say "${BOLD}Радар ТСПУ${RESET} ${DIM}(зонды RIPE Atlas → ${MY_IP}:443, SNI ${SNI})${RESET}" if ! ss -tuln 2>/dev/null | grep -qE "(0\.0\.0\.0|\[::\]|\*|${MY_IP}):443\b"; then say " ${YELLOW}пропущено${RESET} ${DIM}— на :443 никто не слушает, мерить нечего${RESET}" say " ${DIM}запусти ноду (Xray/Caddy) и повтори${RESET}" else say " ${DIM}заказываю замер, это ~40 секунд…${RESET}" TSPU_JSON=$(curl -s --max-time 180 "${API}/tspu?sni=${SNI}&profile=${PROFILE}" 2>/dev/null) dbg "ответ API: $TSPU_JSON" if [[ -z "$TSPU_JSON" ]]; then say " ${YELLOW}радар недоступен${RESET} ${DIM}— ${API} не ответил${RESET}" elif ! $HAVE_PY; then say " ${DIM}нет python3, показываю ответ как есть:${RESET}" say " $TSPU_JSON" else python3 - "$TSPU_JSON" "$GREEN" "$YELLOW" "$RED" "$DIM" "$CYAN" "$RESET" <<'PY' import json, sys raw, GREEN, YELLOW, RED, DIM, CYAN, RESET = sys.argv[1:8] try: d = json.loads(raw) except Exception: print(f" {YELLOW}радар вернул мусор{RESET}"); raise SystemExit if not d.get("ok"): print(f" {YELLOW}радар не отработал{RESET} {DIM}— {d.get('error')}{RESET}"); raise SystemExit pct, total, good, bad = d["percent"], d["total"], d["success"], d["blocked"] if pct == 100: color, verdict = GREEN, "полный доступ из РФ" elif pct >= 70: color, verdict = YELLOW, "частичная блокировка — режут отдельные провайдеры" elif pct > 0: color, verdict = RED, "тяжёлая блокировка — большинство сетей РФ не доходит" else: color, verdict = RED, "IP заблокирован полностью" print(f" ответило зондов {CYAN}{total}{RESET} · пробились {GREEN}{good}{RESET} · срезаны {RED}{bad}{RESET}") print(f" {color}{pct}% — {verdict}{RESET}") problems = [(n, s) for n, s in d.get("by_asn", {}).items() if s["blocked"]] if problems: problems.sort(key=lambda x: -x[1]["blocked"]) line = ", ".join(f"{RED}{n}{RESET} {DIM}{s['blocked']}/{s['ok'] + s['blocked']}{RESET}" for n, s in problems) print(f" режут: {line}") names = {"timeout": "тихий дроп (таймаут)", "reset": "активный сброс (RST)", "unreachable": "сеть недоступна", "other": "прочее"} reasons = d.get("reasons") or {} if reasons: print(" характер: " + ", ".join(f"{names.get(k, k)} {v}" for k, v in reasons.items())) print(f" {DIM}замер {d.get('msm_id')} · потрачено {d.get('credits_spent')} кредитов{RESET}") PY fi fi say "" fi # ------------------------------------------------------------------ итог elapsed=$(( $(date +%s) - start_ts )) if $AS_JSON; then python3 - "$RESULTS" "$MY_IP" "$MY_ORG" "$TSPU_JSON" "$elapsed" <<'PY' import json, sys path, ip, org, tspu_raw, elapsed = sys.argv[1:6] domains = [] with open(path, encoding="utf-8") as fh: for line in fh: parts = line.rstrip("\n").split("|") if len(parts) == 4 and parts[0]: domains.append(dict(zip(("domain", "verdict", "reason", "detail"), parts))) try: tspu = json.loads(tspu_raw) if tspu_raw else None except Exception: tspu = None print(json.dumps({"ip": ip, "org": org, "elapsed": int(elapsed), "domains": sorted(domains, key=lambda d: d["domain"]), "tspu": tspu}, ensure_ascii=False, indent=2)) PY else say "${DIM}$(printf '%.0s─' {1..64})${RESET}" say "${DIM}готово за ${elapsed} с${RESET}" fi