#!/usr/bin/env bash # # censorcheck — проверка блокировок для ноды. # wget -qO- censor.nurvps.com | bash # # Два направления: # 1. Наружу — что с этого сервера недоступно (DNS, TCP, TLS, HTTP). # 2. Внутрь — «радар ТСПУ»: доступен ли этот IP из сетей РФ-провайдеров. # Считается зондами RIPE Atlas. Ключ живёт на censor.nurvps.com, # в этот скрипт он не попадает, мерить можно только собственный IP. # # Порт радара определяется сам: 443, а если его нет — реальный инбаунд # (10001 и подобные у нод за мостом). Переопределяется через --port. # # У ноды бывает не один публичный IP (routed /32 рядом с основным). # Меряется исходящий; для второго адреса — --ip. Он должен быть внесён # в /etc/censorcheck/allowed_ips.txt на censor.nurvps.com. # # Флаги: -q быстрый · --no-tspu · --port N · --ip ADDR · --sni HOST · --json · -d # При пайпе флаги передавать так: ... | bash -s -- --port 10001 --ip 1.2.3.4 set -uo pipefail API="${CENSORCHECK_API:-https://censor.nurvps.com}" TIMEOUT=5 MAX_PARALLEL=10 SNI="max.ru" PROFILE="full" PORT="" # пусто = определить самостоятельно TARGET_IP="" # пусто = исходящий адрес; нужен для routed /32 и вторых IP DO_TSPU=true AS_JSON=false DEBUG=false while [[ $# -gt 0 ]]; do case "$1" in -q|--quick) PROFILE="quick" ;; --no-tspu) DO_TSPU=false ;; --port) PORT="${2:-}"; shift ;; --ip) TARGET_IP="${2:-}"; shift ;; --sni) SNI="${2:-max.ru}"; shift ;; --json) AS_JSON=true ;; -d|--debug) DEBUG=true ;; -h|--help) sed -n '2,20p' "$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 ) UA_BROWSER="Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15" # Заглушки РКН: если домен резолвится сюда — это подмена 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 } # ------------------------------------------------------- какой порт мерить # У нод бывает не только :443 — за мостом инбаунд висит на 10001 и подобных. # Порты, которые точно не инбаунд. SERVICE_PORTS=" 22 25 53 80 111 123 161 465 587 631 3306 5432 9090 9100 10050 " listening_ports() { if need ss; then ss -tlnH 2>/dev/null | awk '{print $4}' elif need netstat; then netstat -tln 2>/dev/null | awk '/^tcp/ {print $4}' fi | grep -vE '^(127\.|\[::1\])' | sed 's/.*://' | grep -E '^[0-9]+$' | sort -un } # Публичные IPv4 самой машины. У ноды с routed /32 их больше одного, # и наружу клиенты ходят вовсе не обязательно на исходящий. local_public_ips() { ip -4 -o addr show scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -vE '^(10\.|127\.|169\.254\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' | sort -u } pick_port() { local ports p ports=$(listening_ports) [[ -z "$ports" ]] && return 1 grep -qx 443 <<<"$ports" && { echo 443; return 0; } for p in 8443 2053 2083 2087 2096 10001 10002 10003 10004; do grep -qx "$p" <<<"$ports" && { echo "$p"; return 0; } done for p in $ports; do [[ "$SERVICE_PORTS" == *" $p "* ]] && continue (( p < 1024 )) && continue echo "$p"; 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 # Запас втрое: 10 доменов качаются параллельно, и если тяжёлая страница # не успевает докачаться, curl отдаёт код ПРЕДЫДУЩЕГО ответа — 301 вместо 200. local hdr="$TMPDIR_CC/hdr.$domain" code=$(curl -s -o /dev/null -D "$hdr" -w '%{http_code}' --connect-timeout "$TIMEOUT" \ --max-time $((TIMEOUT * 3)) -L -A "$UA_BROWSER" "https://$domain" 2>/dev/null) # Cloudflare отдаёт 403 с JS-челленджем всем, кто без браузера, — это НЕ блокировка. # Признак: заголовок cf-mitigated: challenge (у chatgpt.com, claude.ai и др.). # Раньше скрипт красил их в «режет сам сайт», хотя связность в порядке. if [[ "$code" == "403" || "$code" == "503" ]] && grep -qi '^cf-mitigated:[[:space:]]*challenge' "$hdr" 2>/dev/null; then # Сам челлендж доказывает только связность. Подтверждаем её отдельно: # /cdn-cgi/trace челленджем не закрыт и отдаёт регион глазами Cloudflare. local trace loc trace=$(curl -s -4 --max-time "$TIMEOUT" -A "$UA_BROWSER" "https://$domain/cdn-cgi/trace" 2>/dev/null) loc=$(grep -oE '^loc=[A-Z]{2}' <<<"$trace" | cut -d= -f2) if [[ -n "$loc" ]]; then echo "$domain|OK||челлендж CF только для curl, регион ${loc}" >> "$out" else echo "$domain|PARTIAL|CF|челлендж CF, но /cdn-cgi/trace молчит — связность не подтверждена" >> "$out" fi return fi 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 — режет сам сайт, не ТСПУ (не челлендж CF)" >> "$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 PORT_NOTE="" if [[ -z "$PORT" ]]; then if PORT=$(pick_port); then [[ "$PORT" != "443" ]] && PORT_NOTE="нестандартный порт, выбран автоматически" else PORT=443 PORT_NOTE="слушающих портов не видно, пробую 443 наугад" fi fi RADAR_IP="${TARGET_IP:-$MY_IP}" say "${BOLD}Радар ТСПУ${RESET} ${DIM}(зонды RIPE Atlas → ${RADAR_IP}:${PORT}, SNI ${SNI})${RESET}" [[ -n "$PORT_NOTE" ]] && say " ${DIM}${PORT_NOTE}; свой можно задать через --port${RESET}" # Молча мерить не тот адрес — худшее, что тут можно сделать. if [[ -z "$TARGET_IP" ]]; then others=$(local_public_ips | grep -v "^${MY_IP}$" | tr '\n' ' ') if [[ -n "${others// /}" ]]; then say " ${YELLOW}у машины есть и другие адреса:${RESET} ${others}" say " ${DIM}мерится исходящий ${MY_IP}; для другого — --ip <адрес>${RESET}" fi fi if ! [[ "$PORT" =~ ^[0-9]+$ ]] || (( PORT < 1 || PORT > 65535 )); then say " ${YELLOW}пропущено${RESET} ${DIM}— порт '${PORT}' невалиден${RESET}" else say " ${DIM}заказываю замер, это ~40 секунд…${RESET}" TSPU_URL="${API}/tspu?sni=${SNI}&profile=${PROFILE}&port=${PORT}" [[ -n "$TARGET_IP" ]] && TSPU_URL="${TSPU_URL}&target=${TARGET_IP}" TSPU_JSON=$(curl -s --max-time 180 "$TSPU_URL" 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