diff --git a/AGENTS.md b/AGENTS.md index c7636d6..2dec31f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -90,6 +90,16 @@ vps-tracker/ - **Журнал:** таблица `notification_log`, API `GET /api/notifications/log` - **Дедупликация:** `notification_state` — daily / fingerprint / state_transition +## Статус блокировок (censorcheck) + +Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/cc | bash` (тот же контейнер, Traefik dual Host). + +- **Vendor:** `apps/api/scripts/censorcheck/censorcheck.sh` (pin SHA `12c5839`, MIT) +- **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); `GET /cc/vendor` — скрипт +- **Ingest:** `POST /api/integrations/censorcheck/runs` (без portal JWT) +- **UI:** `/blocking` — текущие прогоны и история, группировка VPS / сервис +- Env: `CENSORCHECK_INGEST_SECRET`, `CENSORCHECK_PUBLIC_URL`, `VPS_LAUNCHER_DOMAIN` + ## Команды ```bash diff --git a/apps/api/package.json b/apps/api/package.json index f1a6d91..17e6434 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,6 +16,7 @@ "@cfdm/shared": "workspace:*", "@fastify/cors": "^11.0.1", "@fastify/jwt": "^10.2.0", + "@fastify/rate-limit": "^11.2.0", "@fastify/sensible": "^6.0.3", "@fastify/static": "^8.2.0", "better-sqlite3": "^11.10.0", diff --git a/apps/api/scripts/censorcheck/VENDOR.md b/apps/api/scripts/censorcheck/VENDOR.md new file mode 100644 index 0000000..e39a2f9 --- /dev/null +++ b/apps/api/scripts/censorcheck/VENDOR.md @@ -0,0 +1,7 @@ +Vendor pin of https://github.com/vernette/censorcheck + +- File: `censorcheck.sh` +- Commit: `12c5839` (2026-08-11) +- License: MIT (see upstream repository) + +Do not fetch this script from GitHub at runtime — some probe networks block github.com. diff --git a/apps/api/scripts/censorcheck/censorcheck.sh b/apps/api/scripts/censorcheck/censorcheck.sh new file mode 100644 index 0000000..ba172b1 --- /dev/null +++ b/apps/api/scripts/censorcheck/censorcheck.sh @@ -0,0 +1,1114 @@ +#!/usr/bin/env bash + +readonly SCRIPT_NAME=$(basename "$0") +readonly DEPENDENCIES=("curl" "dig" "jq" "column") + +readonly COLOR_WHITE="\033[97m" +readonly COLOR_RED="\033[31m" +readonly COLOR_GREEN="\033[32m" +readonly COLOR_BLUE="\033[36m" +readonly COLOR_ORANGE="\033[33m" +readonly COLOR_RESET="\033[0m" +readonly CURL_SEPARATOR="--UNIQUE-SEPARATOR--" + +readonly DNS_SERVERS=("1.1.1.1" "1.0.0.1" "8.8.8.8" "8.8.4.4" "9.9.9.9" "9.9.9.10" "77.88.8.8" "77.88.8.1") +readonly ENCRYPTED_DNS_SERVERS=( + "Cloudflare|1.1.1.1" + "Cloudflare|1.0.0.1" + "Google|8.8.8.8" + "Google|8.8.4.4" + "Quad9|9.9.9.9" + "Quad9|9.9.9.10" + "Yandex|77.88.8.8" + "Yandex|77.88.8.1" +) + +AVAILABLE_DOH=() +AVAILABLE_DOT=() + +# Default values +TIMEOUT=5 +RETRIES=2 +MODE="both" +USER_AGENT="Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0" +DOMAINS_FILE="" +IP_VERSION="4" +PROXY="" +SINGLE_DOMAIN="" +PROTOCOL="both" +JSON_OUTPUT=false +DNS_CHECK=true +SHOW_HEADER=true + +readonly DPI_BLOCKED_SITES=( + "youtube.com" + "redirector.googlevideo.com/report_mapping?di=no" + "discord.com" + "instagram.com" + "facebook.com" + "x.com" + "linkedin.com" + "rutracker.org" + "digitalocean.com" + # "ntc.party" + "amnezia.org" + "getoutline.org" + "mailfence.com" + "flibusta.is" + "rezka.ag" + "api.telegram.org" + "play.google.com" +) + +readonly GEO_BLOCKED_SITES=( + "spotify.com" + "netflix.com" + "patreon.com" + "swagger.io" + "snyk.io" + "mongodb.com" + "autodesk.com" + "graylog.org" + "redis.io" + "copilot.microsoft.com" +) + +readonly MSG_AVAILABLE="Available" +readonly MSG_BLOCKED="Blocked" +readonly MSG_TIMEOUT="$MSG_BLOCKED / Timeout" +readonly MSG_BLOCKED_BY_IP="$MSG_BLOCKED by IP" +readonly MSG_BLOCKED_BY_PORT="$MSG_BLOCKED by port" +readonly MSG_REDIRECT="Redirected" +readonly MSG_ACCESS_DENIED="Denied" +readonly MSG_OTHER="Responded with status code" + +declare -a TEXT_RESULTS=() + +error_exit() { + local message="$1" + local exit_code="${2:-1}" + printf "[%b%s%b] %b%s%b\n" "$COLOR_RED" "ERROR" "$COLOR_RESET" "$COLOR_WHITE" "$message" "$COLOR_RESET" >&2 + exit "$exit_code" +} + +show_progress() { + local current=$1 + local total=$2 + local domain=$3 + + if ! $JSON_OUTPUT; then + printf "\r\033[K%b[%d/%d] Checking:%b %b%s%b" \ + "$COLOR_BLUE" \ + "$current" \ + "$total" \ + "$COLOR_RESET" \ + "$COLOR_WHITE" \ + "$domain" \ + "$COLOR_RESET" >&2 + fi +} + +clear_progress() { + if ! $JSON_OUTPUT; then + printf "\r%80s\r" " " >&2 + fi +} + +cleanup() { + clear_progress + exit 130 +} + +display_help() { + cat < /dev/null 2>&1 +} + +check_missing_dependencies() { + local missing_pkgs=() + + for pkg in "${DEPENDENCIES[@]}"; do + if ! is_installed "$pkg"; then + missing_pkgs+=("$pkg") + fi + done + + echo "${missing_pkgs[@]}" +} + +prompt_for_installation() { + local missing_pkgs=("$@") + + echo "Missing dependencies: ${missing_pkgs[*]}" + read -r -p "Do you want to install them? [y/N]: " answer + answer=${answer,,} + + case "${answer,,}" in + y | yes) + return 0 + ;; + *) + exit 0 + ;; + esac +} + +get_package_manager() { + # Check if the script is running in Termux + if [[ -d /data/data/com.termux ]]; then + echo "termux" + return + fi + + if [[ -f /etc/os-release ]]; then + . /etc/os-release + case "$ID" in + debian | ubuntu) + echo "apt" + ;; + arch) + echo "pacman" + ;; + fedora) + echo "dnf" + ;; + centos | rhel | rocky | almalinux) + if command -v dnf >/dev/null 2>&1; then echo "dnf"; else echo "yum"; fi + ;; + *) + error_exit "Unknown distribution: $ID. Please install dependencies manually." + ;; + esac + else + error_exit "File /etc/os-release not found, unable to determine distribution. Please install dependencies manually." + fi +} + +install_with_package_manager() { + local pkg_manager="$1" + local use_sudo="" + local packages=() + shift + + for dep in "$@"; do + case "$pkg_manager" in + apt | termux) + case "$dep" in + dig) packages+=("dnsutils") ;; + column) packages+=("bsdextrautils") ;; + *) packages+=("$dep") ;; + esac + ;; + pacman) + case "$dep" in + dig) packages+=("bind") ;; + column) packages+=("util-linux") ;; + *) packages+=("$dep") ;; + esac + ;; + dnf | yum) + case "$dep" in + dig) packages+=("bind-utils") ;; + column) packages+=("util-linux") ;; + *) packages+=("$dep") ;; + esac + ;; + *) + packages+=("$dep") + ;; + esac + done + + if [[ "${EUID:-$(id -u)}" -ne 0 ]]; then + use_sudo="sudo" + fi + + case "$pkg_manager" in + apt) + $use_sudo apt update + $use_sudo env NEEDRESTART_MODE=a apt install -y "${packages[@]}" + ;; + pacman) + $use_sudo pacman -Syy --noconfirm "${packages[@]}" + ;; + dnf) + $use_sudo dnf install -y "${packages[@]}" + ;; + yum) + $use_sudo yum install -y "${packages[@]}" + ;; + termux) + apt update + apt install -y "${packages[@]}" + ;; + *) + error_exit "Unknown package manager: $pkg_manager" + ;; + esac +} + +install_dependencies() { + local missing_packages + local pkg_manager + + read -r -a missing_packages <<<"$(check_missing_dependencies)" + + if [[ ${#missing_packages[@]} -eq 0 ]]; then + return 0 + fi + + if $JSON_OUTPUT; then + echo "Missing dependencies: ${missing_packages[*]}" >&2 + exit 1 + fi + + prompt_for_installation "${missing_packages[@]}" + + pkg_manager=$(get_package_manager) + install_with_package_manager "$pkg_manager" "${missing_packages[@]}" +} + +check_ipv6_support() { + if [[ -n $(ip -6 addr show scope global 2>/dev/null) ]]; then + return 0 + fi + + return 1 +} + +parse_arguments() { + while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) + display_help + exit 0 + ;; + -m | --mode) + if [[ $2 == "dpi" || $2 == "geoblock" || $2 == "dns" || $2 == "both" ]]; then + MODE=$2 + else + error_exit "Invalid mode: $2. Valid modes are: dpi, geoblock, dns, both" + fi + shift 2 + ;; + -t | --timeout) + if [[ "$2" =~ ^[0-9]+$ ]]; then + TIMEOUT=$2 + else + error_exit "Invalid timeout value: $2. Timeout must be a positive integer" + fi + shift 2 + ;; + -r | --retries) + if [[ "$2" =~ ^[0-9]+$ ]]; then + RETRIES=$2 + else + error_exit "Invalid retries value: $2. Retry count must be a positive integer" + fi + shift 2 + ;; + -u | --user-agent) + if [[ -n "$2" ]]; then + USER_AGENT=$2 + else + error_exit "User-Agent cannot be empty" + fi + shift 2 + ;; + -f | --file) + if [[ -n "${2:-}" ]]; then + if [[ -f "$2" ]]; then + DOMAINS_FILE="$2" + else + error_exit "File '$2' does not exist" + fi + else + error_exit "File path cannot be empty" + fi + shift 2 + ;; + -6 | --ipv6) + if ! check_ipv6_support; then + error_exit "IPv6 is not supported on this system" + fi + + IP_VERSION="6" + shift + ;; + -p | --proxy) + if [[ -n "${2:-}" ]]; then + PROXY="$2" + else + error_exit "Proxy address cannot be empty" + fi + shift 2 + ;; + -d | --domain) + if [[ -n "$2" ]]; then + SINGLE_DOMAIN="$2" + else + error_exit "Domain cannot be empty" + fi + shift 2 + ;; + --http-only) + PROTOCOL="http" + shift + ;; + --https-only) + PROTOCOL="https" + shift + ;; + --no-dns) + DNS_CHECK=false + shift + ;; + --no-header) + SHOW_HEADER=false + shift + ;; + -j | --json) + JSON_OUTPUT=true + shift + ;; + *) + error_exit "Unknown option: $1" + ;; + esac + done + + if [[ "$MODE" == "dns" ]] && ! $DNS_CHECK; then + error_exit "--no-dns cannot be used with --mode dns" + fi +} + +print_header() { + local mode + + printf "\nTimeout set to: %b%ss%b\n" "$COLOR_WHITE" "$TIMEOUT" "$COLOR_RESET" + + case $MODE in + dpi) + mode="DPI" + ;; + geoblock) + mode="Geoblock" + ;; + dns) + mode="DNS" + ;; + both) + mode="DPI and Geoblock" + ;; + esac + + if [[ -z "$DOMAINS_FILE" ]] && [[ -z "$SINGLE_DOMAIN" ]]; then + printf "Mode set to: %b%s%b\n" "$COLOR_WHITE" "$mode" "$COLOR_RESET" + fi + + if [[ "$MODE" == "dns" ]]; then + return + fi + + printf "Retries set to: %b%s%b\n" "$COLOR_WHITE" "$RETRIES" "$COLOR_RESET" + printf "User-Agent set to: %b%s%b\n" "$COLOR_WHITE" "$USER_AGENT" "$COLOR_RESET" + + if [[ -n "$DOMAINS_FILE" ]]; then + printf "Domain mode set to: %buser domains from %s%b\n" "$COLOR_WHITE" "$DOMAINS_FILE" "$COLOR_RESET" + elif [[ -n "$SINGLE_DOMAIN" ]]; then + printf "Checking single domain: %b%s%b\n" "$COLOR_WHITE" "$SINGLE_DOMAIN" "$COLOR_RESET" + else + printf "Domain mode set to: %bpredefined domains%b\n" "$COLOR_WHITE" "$COLOR_RESET" + fi + + printf "IP version set to: %bIPv%s%b\n" "$COLOR_WHITE" "$IP_VERSION" "$COLOR_RESET" + + if [ -n "$PROXY" ]; then + printf "SOCKS5 proxy set to: %b%s%b\n" "$COLOR_WHITE" "$PROXY" "$COLOR_RESET" + fi + + case $PROTOCOL in + http) + printf "Protocol set to: %bHTTP only%b\n" "$COLOR_WHITE" "$COLOR_RESET" + ;; + https) + printf "Protocol set to: %bHTTPS only%b\n" "$COLOR_WHITE" "$COLOR_RESET" + ;; + both) + printf "Protocol set to: %bHTTP and HTTPS%b\n" "$COLOR_WHITE" "$COLOR_RESET" + ;; + esac +} + +run_dns_checks() { + check_encrypted_dns_servers + check_dns_hijacking +} + +read_domains_from_file() { + local file=$1 + local domains=() + + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ -n "$line" && ! "$line" =~ ^[[:space:]]*# ]]; then + line=$(echo "$line" | xargs) + if [[ -n "$line" ]]; then + domains+=("$line") + fi + fi + done <"$file" + + echo "${domains[@]}" +} + +execute_curl() { + local url=$1 + local protocol=$2 + local follow_redirects=$3 + local ip_version_to_use=${4:-$IP_VERSION} + local curl_output + local curl_opts=( + -s + --compressed + -o /dev/null + -w "%{http_code}${CURL_SEPARATOR}%{redirect_url}" + --retry-connrefused + --retry-all-errors + --retry "$RETRIES" + --connect-timeout "$TIMEOUT" + --max-time "$((TIMEOUT * (RETRIES + 1)))" + -"$ip_version_to_use" + -A "$USER_AGENT" + -H "Sec-Fetch-Site: none" + -H "Accept-Language: en-US,en;q=0.5" + ) + + if [ -n "$PROXY" ]; then + curl_opts+=(--proxy "socks5h://$PROXY") + fi + + if [ "$follow_redirects" = true ]; then + curl_opts+=(-L) + fi + + if curl_output=$(curl "${curl_opts[@]}" "${protocol}://${url}"); then + echo "$curl_output" + else + echo "000${CURL_SEPARATOR}" + fi +} + +get_domains_to_check() { + if [[ -n "$SINGLE_DOMAIN" ]]; then + echo "$SINGLE_DOMAIN" + elif [[ -z "$DOMAINS_FILE" ]]; then + case $MODE in + # TODO: Replace echo with function + dpi) echo "${DPI_BLOCKED_SITES[@]}" ;; + geoblock) echo "${GEO_BLOCKED_SITES[@]}" ;; + both) echo "${DPI_BLOCKED_SITES[@]}" "${GEO_BLOCKED_SITES[@]}" ;; + esac + else + read_domains_from_file "$DOMAINS_FILE" + fi +} + +get_single_check_result() { + local domain=$1 + local protocol=$2 + local follow_redirects=$3 + local ip_version=$4 + local response status_code redirect_url + + local port + [[ "${protocol,,}" == "https" ]] && port=443 || port=80 + + if [[ -z "$PROXY" ]]; then + local ip + ip=$(get_domain_ip "$domain" "$ip_version") + + if [[ -n "$ip" ]] && ! is_port_open "$ip" "$port"; then + jq -n '{"status": -1, "redirect_url": null}' + return + fi + fi + + response=$(execute_curl "$domain" "$protocol" "$follow_redirects" "$ip_version") + status_code="${response%%$CURL_SEPARATOR*}" + redirect_url="${response#*$CURL_SEPARATOR}" + + jq -n \ + --argjson status "${status_code:-0}" \ + --arg redirect_url "${redirect_url:-}" \ + ' + { + "status": ($status|tonumber), + "redirect_url": (if $redirect_url == "" then null else $redirect_url end) + } + ' +} + +gather_single_domain_result() { + local domain=$1 + local ipv6_supported + local http_ipv4=null http_ipv6=null https_ipv4=null https_ipv6=null + + check_ipv6_support && ipv6_supported=true || ipv6_supported=false + + if [[ "$PROTOCOL" == "both" || "$PROTOCOL" == "http" ]]; then + http_ipv4=$(get_single_check_result "$domain" "HTTP" false 4) + if $ipv6_supported; then + http_ipv6=$(get_single_check_result "$domain" "HTTP" false 6) + fi + fi + if [[ "$PROTOCOL" == "both" || "$PROTOCOL" == "https" ]]; then + https_ipv4=$(get_single_check_result "$domain" "HTTPS" true 4) + if $ipv6_supported; then + https_ipv6=$(get_single_check_result "$domain" "HTTPS" true 6) + fi + fi + + jq -n \ + --arg service "$(get_domain_host "$domain")" \ + --argjson http_ipv4 "$http_ipv4" \ + --argjson http_ipv6 "$http_ipv6" \ + --argjson https_ipv4 "$https_ipv4" \ + --argjson https_ipv6 "$https_ipv6" \ + ' + { + "service": $service, + "http": { + "ipv4": $http_ipv4, + "ipv6": $http_ipv6 + }, + "https": { + "ipv4": $https_ipv4, + "ipv6": $https_ipv6 + } + } + ' +} + +get_domain_host() { + printf "%s" "${1%%/*}" +} + +get_record_type() { + local ip_version=${1:-$IP_VERSION} + [[ "$ip_version" == "6" ]] && echo "AAAA" || echo "A" +} + +get_domain_ip() { + local domain + domain=$(get_domain_host "$1") + local ip_version=${2:-$IP_VERSION} + + dig +short "$domain" "$(get_record_type "$ip_version")" 2>/dev/null | + awk '/^[0-9a-fA-F.:]+$/ {print; exit}' || true +} + +domain_exists() { + local domain + domain=$(get_domain_host "$1") + local rcode + + rcode=$(dig +noall +comments "$domain" A 2>/dev/null | + awk -F'status: ' '/status:/ {split($2, parts, ","); print parts[1]; exit}') + + [[ "$rcode" != "NXDOMAIN" ]] +} + +get_domain_ips_via_dns() { + local domain=$1 + local server=$2 + + dig +short @"$server" "$domain" "$(get_record_type)" \ + +timeout="$TIMEOUT" +tries=1 2>/dev/null | + awk '/^[0-9a-fA-F.:]+$/' || true +} + +resolve_via_dig() { + local domain=$1 + local server_ip=$2 + local transport=$3 + + dig +short +"$transport" @"$server_ip" "$domain" "$(get_record_type)" \ + +timeout="$TIMEOUT" +tries=1 2>/dev/null | + awk '/^[0-9a-fA-F.:]+$/' +} + +have_ip_intersection() { + local -n first_ips=$1 + local -n second_ips=$2 + + declare -A ip_set=() + local ip + + for ip in "${first_ips[@]}"; do + ip_set["$ip"]=1 + done + + for ip in "${second_ips[@]}"; do + [[ -n ${ip_set["$ip"]+1} ]] && return 0 + done + + return 1 +} + +probe_resolver_transport() { + if [[ -n "$(resolve_via_dig "$1" "$2" "$3")" ]]; then + echo "available" + else + echo "blocked" + fi +} + +check_encrypted_dns_servers() { + local test_domain="rutracker.org" + local entry name ip doh_status dot_status + local table_rows=() + local i tmp_dir + + AVAILABLE_DOH=() + AVAILABLE_DOT=() + + tmp_dir=$(mktemp -d) + + for i in "${!ENCRYPTED_DNS_SERVERS[@]}"; do + ip="${ENCRYPTED_DNS_SERVERS[$i]##*|}" + probe_resolver_transport "$test_domain" "$ip" "https" >"$tmp_dir/${i}_https" & + probe_resolver_transport "$test_domain" "$ip" "tls" >"$tmp_dir/${i}_tls" & + done + wait + + for i in "${!ENCRYPTED_DNS_SERVERS[@]}"; do + entry="${ENCRYPTED_DNS_SERVERS[$i]}" + name="${entry%%|*}" + ip="${entry##*|}" + + if [[ "$(<"$tmp_dir/${i}_https")" == "available" ]]; then + AVAILABLE_DOH+=("$entry") + doh_status="$MSG_AVAILABLE" + else + doh_status="$MSG_BLOCKED" + fi + + if [[ "$(<"$tmp_dir/${i}_tls")" == "available" ]]; then + AVAILABLE_DOT+=("$entry") + dot_status="$MSG_AVAILABLE" + else + dot_status="$MSG_BLOCKED" + fi + + table_rows+=("$(printf "%s%b\t%s\t%s\t%s" \ + "$name" "$COLOR_RESET" "$ip" \ + "$(colorize_summary "$doh_status")" \ + "$(colorize_summary "$dot_status")")") + done + + rm -rf "$tmp_dir" + + printf "\n%bEncrypted DNS (DoH/DoT) availability:%b\n\n" "$COLOR_WHITE" "$COLOR_RESET" + + { + printf "\033[1m%b%s\t%s\t%s\t%s%b\033[0m\n" \ + "$COLOR_WHITE" "Resolver" "IP" "DoH" "DoT" "$COLOR_RESET" + printf "%s\n" "${table_rows[@]}" + } | column -t -s $'\t' +} + +get_reference_resolver() { + if [[ ${#AVAILABLE_DOH[@]} -gt 0 ]]; then + printf "%s\thttps" "${AVAILABLE_DOH[0]}" + elif [[ ${#AVAILABLE_DOT[@]} -gt 0 ]]; then + printf "%s\ttls" "${AVAILABLE_DOT[0]}" + fi +} + +check_dns_hijacking() { + local test_domains=("rutracker.org" "linkedin.com" "flibusta.is") + local regular_dns_ips=() + local encrypted_ips=() + local hijacked_domain="" + local hijacked_ip="" + + local reference reference_entry reference_transport reference_name reference_ip + reference=$(get_reference_resolver) + + if [[ -z "$reference" ]]; then + printf "\n%b%s%b\n" \ + "$COLOR_ORANGE" \ + "Cannot verify DNS spoofing: no DoH/DoT server reachable, skipping check" \ + "$COLOR_RESET" + return 0 + fi + + reference_entry="${reference%%$'\t'*}" + reference_transport="${reference##*$'\t'}" + reference_name="${reference_entry%%|*}" + reference_ip="${reference_entry##*|}" + + for test_domain in "${test_domains[@]}"; do + regular_dns_ips=() + encrypted_ips=() + + for dns_server in "${DNS_SERVERS[@]}"; do + mapfile -t regular_dns_ips < <(get_domain_ips_via_dns "$test_domain" "$dns_server") + [[ ${#regular_dns_ips[@]} -gt 0 ]] && break + done + + mapfile -t encrypted_ips < <(resolve_via_dig "$test_domain" "$reference_ip" "$reference_transport") + + [[ ${#regular_dns_ips[@]} -eq 0 ]] || [[ ${#encrypted_ips[@]} -eq 0 ]] && continue + + if ! have_ip_intersection regular_dns_ips encrypted_ips; then + hijacked_domain="$test_domain" + hijacked_ip="${regular_dns_ips[0]}" + break + fi + done + + if [[ -n "$hijacked_domain" ]]; then + printf "\n%b%s%b %s %b%s%b %s %b%s%b\n" \ + "$COLOR_RED" "DNS hijacking detected!" "$COLOR_RESET" \ + "ISP redirects" "$COLOR_WHITE" "$hijacked_domain" "$COLOR_RESET" "to" \ + "$COLOR_RED" "$hijacked_ip" "$COLOR_RESET" + + printf "%bReference resolver: %s (%s) over %s%b\n\n" \ + "$COLOR_WHITE" "$reference_name" "$reference_ip" "${reference_transport^^}" "$COLOR_RESET" + + printf "%b%s%b\n%b%s%b\n" \ + "$COLOR_ORANGE" "DNS hijacking may affect the accuracy of this check" "$COLOR_RESET" \ + "$COLOR_ORANGE" "Configure encrypted DNS (DoH/DoT) on your system" "$COLOR_RESET" + else + printf "\n%b%s%b %b(reference: %s over %s)%b\n" \ + "$COLOR_GREEN" "Good news, no DNS hijacking detected!" "$COLOR_RESET" \ + "$COLOR_WHITE" "$reference_name" "${reference_transport^^}" "$COLOR_RESET" + fi +} + +is_port_open() { + local ip="$1" + local port="$2" + timeout "$TIMEOUT" bash -c "(echo >/dev/tcp/$ip/$port)" 2>/dev/null +} + +is_ip_reachable() { + local ip="$1" + is_port_open "$ip" 80 || is_port_open "$ip" 443 +} + +make_json_error() { + local domain="$1" + local error_code="$2" + local error_message="$3" + + jq -n \ + --arg service "$domain" \ + --arg code "$error_code" \ + --arg error "$error_message" \ + ' + { + "service": $service, + "error": $error, + "error_code": $code, + "http": null, + "https": null + } + ' +} + +summarize_status_description() { + local status_code=$1 + local redirect_url=$2 + local msg + + if [[ "$status_code" -eq -1 ]]; then + msg="$MSG_BLOCKED_BY_PORT" + elif [[ -z "$status_code" || "$status_code" = "000" || "$status_code" -eq 0 ]]; then + msg="$MSG_TIMEOUT" + elif [[ "$status_code" -ge 300 && "$status_code" -lt 400 ]]; then + [[ -z "$redirect_url" ]] && redirect_url=" " + msg=$(printf "%s (%s) -> %s" "$MSG_REDIRECT" "$status_code" "$redirect_url") + elif [[ "$status_code" -eq 200 ]]; then + msg="$MSG_AVAILABLE ($status_code)" + elif [[ "$status_code" -eq 403 ]]; then + msg="$MSG_ACCESS_DENIED ($status_code)" + else + msg="$MSG_OTHER $status_code" + fi + + echo "$msg" +} + +colorize_summary() { + local message="$1" + local first_word rest first_word_color + + first_word="${message%% *}" + if [[ "$first_word" == "$message" ]]; then + rest="" + else + rest="${message#* }" + fi + + case "$first_word" in + Blocked) + first_word_color=$COLOR_RED + ;; + Available) + first_word_color=$COLOR_GREEN + ;; + Redirected) + first_word_color=$COLOR_BLUE + ;; + Denied) + first_word_color=$COLOR_RED + ;; + *) + first_word_color=$COLOR_ORANGE + ;; + esac + + if [[ -z "$rest" ]]; then + printf "%b%s%b" "$first_word_color" "$first_word" "$COLOR_RESET" + else + printf "%b%s%b %s" "$first_word_color" "$first_word" "$COLOR_RESET" "$rest" + fi +} + +summarize_protocol_result() { + local result_json=$1 + local protocol=$2 + + if [[ "$PROTOCOL" != "both" && "$PROTOCOL" != "$protocol" ]]; then + echo "Skipped" + return + fi + + local data + data=$(jq -c --arg protocol "$protocol" --arg ip_version "$IP_VERSION" ' + if $ip_version == "6" then .[$protocol].ipv6 + else .[$protocol].ipv4 + end + ' <<<"$result_json") + + if [[ "$data" == "null" || -z "$data" ]]; then + echo "N/A" + return + fi + + local status redirect + status=$(jq -r '.status' <<<"$data") + redirect=$(jq -r '.redirect_url // ""' <<<"$data") + summarize_status_description "$status" "$redirect" +} + +add_text_result_row() { + local service=$1 + local ip=$2 + local http_cell=$3 + local https_cell=$4 + + TEXT_RESULTS+=("$( + jq -n \ + --arg service "$service" \ + --arg ip "$ip" \ + --arg http "$http_cell" \ + --arg https "$https_cell" \ + '{service: $service, ip: $ip, http: $http, https: $https}' + )") +} + +add_text_result_from_json() { + local result_json=$1 + local ip=$2 + local service + service=$(echo "$result_json" | jq -r '.service') + local http_cell https_cell + + http_cell=$(summarize_protocol_result "$result_json" "http") + https_cell=$(summarize_protocol_result "$result_json" "https") + + add_text_result_row "$service" "$ip" "$http_cell" "$https_cell" +} + +print_table_results() { + printf "\n" + { + printf "\033[1m%b%s\t%s\t%s\t%s%b\033[0m\n" \ + "$COLOR_WHITE" \ + "Service" \ + "IP" \ + "HTTP" \ + "HTTPS" \ + "$COLOR_RESET" + + for row_json in "${TEXT_RESULTS[@]}"; do + local service http https + service=$(jq -r '.service' <<<"$row_json") + ip=$(jq -r '.ip' <<<"$row_json") + http=$(jq -r '.http' <<<"$row_json") + https=$(jq -r '.https' <<<"$row_json") + + printf "%s%b\t%s\t%s\t%s\n" \ + "$service" \ + "$COLOR_RESET" \ + "$ip" \ + "$(colorize_summary "$http")" \ + "$(colorize_summary "$https")" + done + } | column -t -s $'\t' +} + +run_checks_and_print() { + local domains + local all_results_json="[]" + + if [[ "$MODE" == "dns" ]]; then + if $JSON_OUTPUT; then + error_exit "JSON output is not supported for dns mode" + fi + + if $SHOW_HEADER; then + print_header + fi + + run_dns_checks + return + fi + + read -r -a domains <<<"$(get_domains_to_check)" + + local total_domains=${#domains[@]} + + local current_index=0 + + TEXT_RESULTS=() + + if ! $JSON_OUTPUT; then + if $SHOW_HEADER; then + print_header + fi + if $DNS_CHECK; then + run_dns_checks + fi + if $SHOW_HEADER || $DNS_CHECK; then + printf "\n" + fi + fi + + for domain in "${domains[@]}"; do + ((++current_index)) + show_progress "$current_index" "$total_domains" "$domain" + + local host ip_address + + host=$(get_domain_host "$domain") + ip_address=$(get_domain_ip "$domain") + + if [[ -z "$ip_address" ]]; then + local error_code error_message + + if domain_exists "$domain"; then + error_code="no_dns_record" + error_message="No $(get_record_type) record" + else + error_code="nxdomain" + error_message="Domain does not exist" + fi + + if $JSON_OUTPUT; then + all_results_json=$(echo "$all_results_json" | jq --argjson item "$(make_json_error "$host" "$error_code" "$error_message")" '. + [$item]') + else + add_text_result_row "$host" "N/A" "$error_message" "$error_message" + fi + continue + fi + + if [[ -z "$PROXY" ]] && ! is_ip_reachable "$ip_address"; then + if $JSON_OUTPUT; then + all_results_json=$(echo "$all_results_json" | jq --argjson item "$(make_json_error "$host" blocked_by_ip "$MSG_BLOCKED_BY_IP")" '. + [$item]') + else + add_text_result_row "$host" "$ip_address" "$MSG_BLOCKED_BY_IP" "$MSG_BLOCKED_BY_IP" + fi + continue + fi + + local domain_result_json + domain_result_json=$(gather_single_domain_result "$domain") + + if $JSON_OUTPUT; then + all_results_json=$(echo "$all_results_json" | jq --argjson item "$domain_result_json" '. + [$item]') + else + add_text_result_from_json "$domain_result_json" "$ip_address" + fi + done + + clear_progress + + if $JSON_OUTPUT; then + local ipv6_supported + check_ipv6_support && ipv6_supported=true || ipv6_supported=false + + local ip_version_param_val + if $ipv6_supported; then + ip_version_param_val="IPv4 & IPv6" + else + ip_version_param_val="IPv4" + fi + + local params_json + + params_json=$( + jq -n \ + --arg timeout "${TIMEOUT}s" \ + --arg retries "$RETRIES" \ + --arg mode "${MODE^^}" \ + --arg user_agent "$USER_AGENT" \ + --arg domain_mode "$(if [[ -n "$DOMAINS_FILE" ]]; then echo "user domains from $DOMAINS_FILE"; elif [[ -n "$SINGLE_DOMAIN" ]]; then echo "single domain"; else echo "predefined domains"; fi)" \ + --arg ip_version "$ip_version_param_val" \ + --arg protocol "$(if [[ "$PROTOCOL" == "both" ]]; then echo "HTTP and HTTPS"; elif [[ "$PROTOCOL" == "http" ]]; then echo "HTTP only"; else echo "HTTPS only"; fi)" \ + '[ + {"key":"timeout", "value":$timeout}, + + {"key":"retries", "value":$retries}, + {"key":"mode", "value":$mode}, + {"key":"user_agent", "value":$user_agent}, + {"key":"domain_mode", "value":$domain_mode}, + {"key":"ip_version", "value":$ip_version}, + {"key":"protocol", "value":$protocol} + ]' + ) + + jq -n \ + --argjson params "$params_json" \ + --argjson results "$all_results_json" \ + '{ + "version": 1, + "params": $params, + "results": $results + }' + + return + fi + + print_table_results +} + +main() { + set -euo pipefail + + trap cleanup INT TERM + + parse_arguments "$@" + install_dependencies + run_checks_and_print + + if ! $JSON_OUTPUT; then + printf "\n" + fi + + trap - INT TERM +} + +main "$@" diff --git a/apps/api/scripts/censorcheck/launcher.sh b/apps/api/scripts/censorcheck/launcher.sh new file mode 100644 index 0000000..a108a5d --- /dev/null +++ b/apps/api/scripts/censorcheck/launcher.sh @@ -0,0 +1,139 @@ +#!/usr/bin/env bash +# VPS Tracker launcher for vernette/censorcheck (vendor pin 12c5839). +# Fetched via: curl -fsSL https://vt.shnt.top/cc | bash +set -euo pipefail + +VT_API_URL="${VT_API_URL:-__VT_API_URL__}" +VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}" +LAUNCHER_VERSION="1" +VENDOR_SHA="12c5839" + +trap 'exit 130' INT + +log() { printf '%s\n' "$*"; } +die() { printf 'error: %s\n' "$*" >&2; exit 1; } + +require_cmd() { + command -v "$1" >/dev/null 2>&1 || die "Нужна команда '$1' (установите пакет и повторите)." +} + +require_cmd curl +require_cmd jq +require_cmd bash + +detect_public_ip() { + local ip="" + ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)" + if [ -z "$ip" ]; then + ip="$(curl -fsS --max-time 8 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true)" + fi + if [ -z "$ip" ] && command -v dig >/dev/null 2>&1; then + ip="$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null | tr -d '[:space:]' || true)" + fi + printf '%s' "$ip" +} + +write_vendor() { + local dest="$1" + if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then + printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \ + || printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -D >"$dest" + return 0 + fi + curl -fsSL --max-time 30 "${VT_API_URL}/cc/vendor" -o "$dest" +} + +uuid4() { + if [ -r /proc/sys/kernel/random/uuid ]; then + tr -d '[:space:]' /dev/null 2>&1; then + python3 -c 'import uuid; print(uuid.uuid4())' + return + fi + openssl rand -hex 16 +} + +VT_API_URL="${VT_API_URL%/}" +[ -n "$VT_API_URL" ] || die "VT_API_URL пуст" +[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст" + +TMPDIR="$(mktemp -d /tmp/vt-censorcheck.XXXXXX)" +cleanup() { rm -rf "$TMPDIR"; } +trap 'cleanup; exit 130' INT +trap 'cleanup' EXIT + +VENDOR="$TMPDIR/censorcheck.sh" +write_vendor "$VENDOR" +chmod +x "$VENDOR" + +PUBLIC_IP="$(detect_public_ip)" +[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP" + +RUN_ID="$(uuid4)" +[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId" + +log "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})" +log "probe IP: ${PUBLIC_IP}" +log "runId: ${RUN_ID}" + +set +e +RAW_JSON="$(bash "$VENDOR" --mode both --json --no-header --no-dns 2>/tmp/vt-censorcheck-err.$$)" +CC_EXIT=$? +set -e +if [ "$CC_EXIT" -ne 0 ]; then + log "censorcheck завершился с кодом ${CC_EXIT}" >&2 + if [ -s /tmp/vt-censorcheck-err.$$ ]; then + cat /tmp/vt-censorcheck-err.$$ >&2 || true + fi + rm -f /tmp/vt-censorcheck-err.$$ + exit 1 +fi +rm -f /tmp/vt-censorcheck-err.$$ + +PAYLOAD="$TMPDIR/payload.json" +printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" ' + { + schemaVersion: 1, + runId: $runId, + probe: { publicIp: $ip }, + launcherVersion: $lv, + censorcheck: { + version: ((.version | tostring) // "1"), + mode: "both" + }, + results: ((.results // []) | map({ + service: .service, + raw: . + })) + } +' >"$PAYLOAD" + +FALLBACK="/tmp/vt-censorcheck-${RUN_ID}.json" +set +e +RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/censorcheck/runs" \ + -H "Authorization: Bearer ${VT_INGEST_TOKEN}" \ + -H "Content-Type: application/json" \ + --data-binary @"$PAYLOAD")" +POST_EXIT=$? +set -e + +if [ "$POST_EXIT" -ne 0 ]; then + cp "$PAYLOAD" "$FALLBACK" + log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2 + exit 2 +fi + +CHECK_ID="$(printf '%s' "$RESP" | jq -r '.id // empty')" +MATCHED="$(printf '%s' "$RESP" | jq -r '.matchedVpsId // "unmatched"')" +if [ -z "$CHECK_ID" ]; then + cp "$PAYLOAD" "$FALLBACK" + log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2 + log "$RESP" >&2 + exit 2 +fi + +log "Check ID: ${CHECK_ID}" +log "VPS: ${MATCHED}" +exit 0 diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 876b0eb..4de4eef 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -2,6 +2,7 @@ import Fastify from 'fastify' import cors from '@fastify/cors' import sensible from '@fastify/sensible' import staticPlugin from '@fastify/static' +import rateLimit from '@fastify/rate-limit' import { existsSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -25,6 +26,8 @@ import { dashboardRoutes } from './routes/dashboard.js' import { auditRoutes } from './routes/audit.js' import { notificationsRoutes } from './routes/notifications.js' import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js' +import { censorcheckRoutes } from './routes/censorcheck.js' +import { launcherRoutes } from './routes/launcher.js' import { appSwitcherRoutes } from './routes/app-switcher.js' import { startScheduler } from './services/scheduler.js' import { authPlugin } from './plugins/auth.js' @@ -43,18 +46,28 @@ export async function buildApp(opts: BuildAppOptions = {}) { if (opts.dbPath) process.env.DB_PATH = opts.dbPath getDb() + const trustProxy = + process.env.TRUST_PROXY === '1' || + process.env.TRUST_PROXY === 'true' || + (process.env.NODE_ENV === 'production' && + process.env.TRUST_PROXY !== '0' && + process.env.TRUST_PROXY !== 'false') + const app = Fastify({ logger: { level: process.env.LOG_LEVEL ?? 'info', }, + trustProxy, }) await app.register(cors, { origin: true }) await app.register(sensible) + await app.register(rateLimit, { global: false }) await app.register(authPlugin) await app.register(spacePlugin) app.get('/health', async () => ({ ok: true })) + await app.register(launcherRoutes) await app.register(spacesRoutes) await app.register(portalUsersRoutes) @@ -75,6 +88,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { await app.register(auditRoutes) await app.register(notificationsRoutes) await app.register(integrationsCfdmRoutes) + await app.register(censorcheckRoutes) await app.register(appSwitcherRoutes) const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist') diff --git a/apps/api/src/lib/permissions.test.ts b/apps/api/src/lib/permissions.test.ts index 4f48cc6..c71e5c9 100644 --- a/apps/api/src/lib/permissions.test.ts +++ b/apps/api/src/lib/permissions.test.ts @@ -20,6 +20,7 @@ describe('hasPermission hierarchy', () => { describe('permissionForRequest', () => { it('maps vps CRUD', () => { expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read') + expect(permissionForRequest('GET', '/api/censorcheck/current')).toBe('vps:vps:read') expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write') expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write') }) diff --git a/apps/api/src/lib/permissions.ts b/apps/api/src/lib/permissions.ts index 9ddb9e0..4ef9b67 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -51,7 +51,8 @@ const RULES: Rule[] = [ p.startsWith('/api/vps/') || p.startsWith('/api/projects') || p.startsWith('/api/topology') || - p.startsWith('/api/data'), + p.startsWith('/api/data') || + p.startsWith('/api/censorcheck'), permission: 'vps:vps:read', }, { diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 6c59bbf..7582a28 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -74,7 +74,9 @@ function isPublicPath(url: string): boolean { const path = url.split('?')[0] ?? url if (path === '/health' || path === '/ready') return true if (path === '/api/auth/config') return true + if (path === '/cc' || path.startsWith('/cc/')) return true if (path.startsWith('/api/integrations/cfdm')) return true + if (path.startsWith('/api/integrations/censorcheck')) return true return false } diff --git a/apps/api/src/routes/censorcheck.test.ts b/apps/api/src/routes/censorcheck.test.ts new file mode 100644 index 0000000..b0b645b --- /dev/null +++ b/apps/api/src/routes/censorcheck.test.ts @@ -0,0 +1,163 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db' +import { vpsRepository } from '@cfdm/db/repositories/vps' +import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup' +import { buildApp } from '../index.js' +import { mintIngestToken } from '../services/censorcheck/ingest-token.js' + +const SECRET = 'test-censorcheck-ingest-secret' + +function ingestPayload(overrides: Record = {}) { + return { + schemaVersion: 1, + runId: '11111111-1111-4111-8111-111111111111', + probe: { publicIp: '203.0.113.10' }, + launcherVersion: '1', + censorcheck: { version: '1', mode: 'both' }, + results: [ + { + service: 'youtube.com', + raw: { https: { ipv4: { status: 200 } } }, + }, + ], + ...overrides, + } +} + +describe('censorcheck ingest + reads', () => { + let app: Awaited> + + beforeEach(async () => { + process.env.CENSORCHECK_INGEST_SECRET = SECRET + process.env.CENSORCHECK_RATE_LIMIT = '0' + process.env.TRUST_PROXY = '1' + resetTestDb() + seedTestProvider('p1') + seedTestProviderAccount('a1', 'p1') + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + delete process.env.TRUST_PROXY + }) + + async function post(body: unknown, token?: string, headers: Record = {}) { + return app.inject({ + method: 'POST', + url: '/api/integrations/censorcheck/runs', + headers: { + authorization: `Bearer ${token ?? mintIngestToken(SECRET)}`, + 'content-type': 'application/json', + ...headers, + }, + payload: body as object, + }) + } + + it('отклоняет запрос без токена', async () => { + const res = await app.inject({ + method: 'POST', + url: '/api/integrations/censorcheck/runs', + payload: ingestPayload(), + }) + expect(res.statusCode).toBe(401) + }) + + it('отклоняет просроченный токен', async () => { + const token = mintIngestToken(SECRET, 60, Date.now() - 120_000) + const res = await post(ingestPayload(), token) + expect(res.statusCode).toBe(401) + }) + + it('отклоняет невалидный payload', async () => { + const res = await post({ schemaVersion: 1, runId: 'short' }) + expect(res.statusCode).toBe(400) + }) + + it('принимает прогон и оставляет unmatched', async () => { + const res = await post(ingestPayload()) + expect(res.statusCode).toBe(200) + const json = res.json() as { matchedVpsId: string | null; probePublicIp: string } + expect(json.matchedVpsId).toBeNull() + expect(json.probePublicIp).toBe('203.0.113.10') + }) + + it('матчит VPS по IPv4 и IPv6', async () => { + const vps = runWithSpace(MAIN_SPACE_ID, () => + vpsRepository.create({ + ip: '203.0.113.10', + ipv6: '2001:db8::55', + dns: 'edge.example.com', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 2, + ramGb: 4, + diskGb: 40, + }), + ) + const v4 = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })) + expect(v4.json().matchedVpsId).toBe(vps.id) + + const v6 = await post( + ingestPayload({ + runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb', + probe: { publicIp: '2001:db8::55' }, + }), + ) + expect(v6.json().matchedVpsId).toBe(vps.id) + }) + + it('повторяет duplicate runId без второй записи', async () => { + const first = await post(ingestPayload()) + const second = await post(ingestPayload()) + expect(first.statusCode).toBe(200) + expect(second.statusCode).toBe(200) + expect(second.json().id).toBe(first.json().id) + expect(second.json().replayed).toBe(true) + + const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' }) + expect(current.json().items).toHaveLength(1) + }) + + it('берёт observed XFF, а claimed сохраняет если расходится', async () => { + runWithSpace(MAIN_SPACE_ID, () => + vpsRepository.create({ + ip: '198.51.100.20', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }), + ) + const res = await post(ingestPayload({ + runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', + probe: { publicIp: '203.0.113.10' }, + }), undefined, { 'x-forwarded-for': '198.51.100.20' }) + const json = res.json() as { probePublicIp: string; matchedVpsId: string | null } + expect(json.probePublicIp).toBe('198.51.100.20') + expect(json.matchedVpsId).not.toBeNull() + + const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' }) + expect(current.json().items[0].claimedPublicIp).toBe('203.0.113.10') + }) + + it('отдаёт историю и детали', async () => { + await post(ingestPayload()) + const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' }) + expect(list.statusCode).toBe(200) + const items = list.json().items as Array<{ id: string }> + expect(items).toHaveLength(1) + const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` }) + expect(detail.statusCode).toBe(200) + expect(detail.json().results).toHaveLength(1) + }) +}) diff --git a/apps/api/src/routes/censorcheck.ts b/apps/api/src/routes/censorcheck.ts new file mode 100644 index 0000000..1442bc8 --- /dev/null +++ b/apps/api/src/routes/censorcheck.ts @@ -0,0 +1,127 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck' +import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck' +import { actorFromRequest } from '../lib/audit-actor.js' +import { + bearerToken, + ingestSecret, + verifyIngestToken, +} from '../services/censorcheck/ingest-token.js' +import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js' +import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js' + +const BODY_LIMIT = 512 * 1024 + +function sendError(reply: FastifyReply, status: number, code: string, message: string) { + return reply.code(status).send({ error: { code, message } }) +} + +function requireIngestToken(request: FastifyRequest, reply: FastifyReply): boolean { + const secret = ingestSecret() + if (!secret) { + void sendError(reply, 503, 'UNAVAILABLE', 'Censorcheck ingest не настроен') + return false + } + const token = bearerToken(request.headers.authorization) + if (!token || !verifyIngestToken(token, secret)) { + void sendError(reply, 401, 'UNAUTHORIZED', 'Недействительный ingest-токен') + return false + } + return true +} + +export const censorcheckRoutes: FastifyPluginAsync = async (app) => { + const ingestOpts = { + bodyLimit: BODY_LIMIT, + ...(process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0' + ? {} + : { config: { rateLimit: { max: 6, timeWindow: '1 minute' } } }), + } + + app.post( + '/api/integrations/censorcheck/runs', + ingestOpts, + async (request, reply) => { + if (!requireIngestToken(request, reply)) return + + const parsed = censorcheckIngestBodySchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 400, 'VALIDATION', parsed.error.message) + } + + const existing = censorcheckRepository.getByClientRunId(parsed.data.runId) + if (existing) { + return { + id: existing.id, + runId: existing.runId, + matchedVpsId: existing.matchedVpsId, + probePublicIp: existing.probePublicIp, + summary: existing.summary, + replayed: true, + } + } + + const claimed = parsed.data.probe.publicIp + const observed = actorFromRequest(request).ip ?? request.ip + const probePublicIp = resolveProbeIp(observed, claimed) + const claimedPublicIp = + claimed && claimed !== probePublicIp ? claimed : null + const match = matchVpsByPublicIp(probePublicIp) + const results = parsed.data.results.map(normalizeIngestResult) + const { summary, runStatus } = summarizeResults(results) + + const created = censorcheckRepository.create({ + spaceId: match.spaceId, + runId: parsed.data.runId, + probePublicIp, + claimedPublicIp, + matchedVpsId: match.vpsId, + status: runStatus, + schemaVersion: parsed.data.schemaVersion, + launcherVersion: parsed.data.launcherVersion ?? null, + censorcheckVersion: parsed.data.censorcheck?.version ?? null, + summary, + observedSourceIp: observed ?? null, + results, + }) + + return { + id: created.id, + runId: created.runId, + matchedVpsId: created.matchedVpsId, + probePublicIp: created.probePublicIp, + summary: created.summary, + } + }, + ) + + app.get('/api/censorcheck/current', async () => ({ + items: censorcheckRepository.listCurrent(), + })) + + app.get('/api/censorcheck/runs', async (request) => { + const q = request.query as Record + const matched = + q.matched === '1' || q.matched === 'true' + ? true + : q.matched === '0' || q.matched === 'false' + ? false + : undefined + return censorcheckRepository.listHistory({ + cursor: q.cursor, + limit: q.limit ? Number(q.limit) : undefined, + q: q.q, + status: q.status, + matched, + }) + }) + + app.get('/api/censorcheck/runs/:id', async (request, reply) => { + const { id } = request.params as { id: string } + const run = censorcheckRepository.getById(id) + if (!run) { + return sendError(reply, 404, 'NOT_FOUND', 'Прогон не найден') + } + return run + }) +} diff --git a/apps/api/src/routes/launcher.test.ts b/apps/api/src/routes/launcher.test.ts new file mode 100644 index 0000000..1070afa --- /dev/null +++ b/apps/api/src/routes/launcher.test.ts @@ -0,0 +1,39 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb } from '@cfdm/db' +import { resetTestDb } from '@cfdm/db/test-setup' +import { buildApp } from '../index.js' + +describe('GET /cc launcher', () => { + let app: Awaited> + + beforeEach(async () => { + process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key' + process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top' + process.env.CENSORCHECK_RATE_LIMIT = '0' + resetTestDb() + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + closeDb() + }) + + it('отдаёт bash-скрипт с токеном и no-store', async () => { + const res = await app.inject({ method: 'GET', url: '/cc' }) + expect(res.statusCode).toBe(200) + expect(res.headers['content-type']).toMatch(/text\/plain/) + expect(res.headers['cache-control']).toMatch(/no-store/) + expect(res.body).toContain('https://vt.shnt.top') + expect(res.body).toContain('VT_INGEST_TOKEN') + expect(res.body).not.toContain('__VT_API_URL__') + expect(res.body).not.toContain('__VT_INGEST_TOKEN__') + }) + + it('отдаёт vendor-скрипт', async () => { + const res = await app.inject({ method: 'GET', url: '/cc/vendor' }) + expect(res.statusCode).toBe(200) + expect(res.body).toContain('#!/usr/bin/env bash') + expect(res.body).toContain('SCRIPT_NAME') + }) +}) diff --git a/apps/api/src/routes/launcher.ts b/apps/api/src/routes/launcher.ts new file mode 100644 index 0000000..4496b88 --- /dev/null +++ b/apps/api/src/routes/launcher.ts @@ -0,0 +1,62 @@ +import { readFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import { ingestSecret, mintIngestToken } from '../services/censorcheck/ingest-token.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'censorcheck') + +export function censorcheckPublicUrl(env: NodeJS.ProcessEnv = process.env): string { + if (env.CENSORCHECK_PUBLIC_URL?.trim()) { + return env.CENSORCHECK_PUBLIC_URL.replace(/\/$/, '') + } + const host = env.VPS_LAUNCHER_DOMAIN || env.VPS_DOMAIN || 'vt.shnt.top' + return `https://${host}` +} + +function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'public'): void { + void reply + .header('Content-Type', 'text/plain; charset=utf-8') + .header( + 'Cache-Control', + cache === 'no-store' ? 'no-store, no-cache, must-revalidate' : 'public, max-age=3600', + ) + .send(body) +} + +export const launcherRoutes: FastifyPluginAsync = async (app) => { + const secret = ingestSecret() + + const ccOpts = + process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0' + ? {} + : { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } } + + app.get('/cc', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => { + if (!secret) { + return reply.code(503).send('censorcheck ingest is not configured\n') + } + const apiUrl = censorcheckPublicUrl() + const token = mintIngestToken(secret) + let template: string + try { + template = readFileSync(join(SCRIPT_DIR, 'launcher.sh'), 'utf8') + } catch { + return reply.code(500).send('launcher template missing\n') + } + const script = template + .replaceAll('__VT_API_URL__', apiUrl) + .replaceAll('__VT_INGEST_TOKEN__', token) + sendPlain(reply, script, 'no-store') + }) + + app.get('/cc/vendor', async (_request, reply) => { + try { + const body = readFileSync(join(SCRIPT_DIR, 'censorcheck.sh'), 'utf8') + sendPlain(reply, body, 'public') + } catch { + return reply.code(500).send('vendor script missing\n') + } + }) +} diff --git a/apps/api/src/services/backup-full.ts b/apps/api/src/services/backup-full.ts index 2868098..cb298e3 100644 --- a/apps/api/src/services/backup-full.ts +++ b/apps/api/src/services/backup-full.ts @@ -6,6 +6,8 @@ import { const TABLE_ORDER_DELETE = [ 'vps_grants', + 'censorcheck_results', + 'censorcheck_runs', 'notification_log', 'notification_state', 'vps_health_checks', diff --git a/apps/api/src/services/censorcheck/ingest-token.test.ts b/apps/api/src/services/censorcheck/ingest-token.test.ts new file mode 100644 index 0000000..3183d50 --- /dev/null +++ b/apps/api/src/services/censorcheck/ingest-token.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest' +import { mintIngestToken, verifyIngestToken } from './ingest-token.js' + +describe('censorcheck ingest token', () => { + const secret = 'test-ingest-secret-key' + + it('принимает свежий токен', () => { + const token = mintIngestToken(secret) + expect(verifyIngestToken(token, secret)).toBe(true) + }) + + it('отклоняет просроченный токен', () => { + const token = mintIngestToken(secret, 20 * 60, Date.now() - 21 * 60 * 1000) + expect(verifyIngestToken(token, secret)).toBe(false) + }) + + it('отклоняет подпись с другим секретом', () => { + const token = mintIngestToken(secret) + expect(verifyIngestToken(token, 'other-secret-key')).toBe(false) + }) + + it('отклоняет мусор', () => { + expect(verifyIngestToken('not-a-token', secret)).toBe(false) + expect(verifyIngestToken('', secret)).toBe(false) + }) +}) diff --git a/apps/api/src/services/censorcheck/ingest-token.ts b/apps/api/src/services/censorcheck/ingest-token.ts new file mode 100644 index 0000000..4bb9b1f --- /dev/null +++ b/apps/api/src/services/censorcheck/ingest-token.ts @@ -0,0 +1,62 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto' + +const TTL_SEC = 20 * 60 + +export function ingestSecret(env: NodeJS.ProcessEnv = process.env): string { + return ( + env.CENSORCHECK_INGEST_SECRET || + env.AUTH_JWT_SECRET || + env.JWT_SECRET || + (env.NODE_ENV === 'production' ? '' : 'dev-secret-change-me') + ) +} + +export function mintIngestToken( + secret: string, + ttlSec = TTL_SEC, + nowMs = Date.now(), +): string { + const payload = Buffer.from( + JSON.stringify({ + jti: randomBytes(16).toString('hex'), + exp: Math.floor(nowMs / 1000) + ttlSec, + }), + 'utf8', + ).toString('base64url') + const sig = createHmac('sha256', secret).update(payload).digest('base64url') + return `${payload}.${sig}` +} + +export function verifyIngestToken( + token: string, + secret: string, + nowMs = Date.now(), +): boolean { + if (!secret || !token) return false + const [payload, sig] = token.split('.') + if (!payload || !sig) return false + const expected = createHmac('sha256', secret).update(payload).digest() + let given: Buffer + try { + given = Buffer.from(sig, 'base64url') + } catch { + return false + } + if (expected.length !== given.length || !timingSafeEqual(expected, given)) return false + try { + const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as { + exp?: unknown + } + if (typeof data.exp !== 'number') return false + return data.exp * 1000 > nowMs + } catch { + return false + } +} + +export function bearerToken(header: string | string[] | undefined): string | null { + const raw = Array.isArray(header) ? header[0] : header + if (!raw) return null + const match = /^Bearer\s+(.+)$/i.exec(raw.trim()) + return match?.[1]?.trim() || null +} diff --git a/apps/api/src/services/censorcheck/match-ip.test.ts b/apps/api/src/services/censorcheck/match-ip.test.ts new file mode 100644 index 0000000..97c6af3 --- /dev/null +++ b/apps/api/src/services/censorcheck/match-ip.test.ts @@ -0,0 +1,49 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db' +import { vpsRepository } from '@cfdm/db/repositories/vps' +import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup' +import { matchVpsByPublicIp, resolveProbeIp } from './match-ip.js' + +describe('censorcheck match-ip', () => { + beforeEach(() => { + resetTestDb() + seedTestProvider('p1') + seedTestProviderAccount('a1', 'p1') + }) + + afterEach(() => { + closeDb() + }) + + it('берёт claimed если observed приватный', () => { + expect(resolveProbeIp('127.0.0.1', '203.0.113.10')).toBe('203.0.113.10') + expect(resolveProbeIp('203.0.113.55', '203.0.113.10')).toBe('203.0.113.55') + }) + + it('матчит VPS по IPv4 и отдаёт spaceId', () => { + const vps = runWithSpace(MAIN_SPACE_ID, () => + vpsRepository.create({ + ip: '203.0.113.10', + ipv6: '2001:db8::aa', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 1, + ramGb: 1, + diskGb: 10, + }), + ) + const created = Array.isArray(vps) ? vps[0]! : vps + expect(matchVpsByPublicIp('203.0.113.10')).toEqual({ + vpsId: created.id, + spaceId: MAIN_SPACE_ID, + }) + expect(matchVpsByPublicIp('2001:db8::aa').vpsId).toBe(created.id) + expect(matchVpsByPublicIp('198.51.100.1')).toEqual({ + vpsId: null, + spaceId: MAIN_SPACE_ID, + }) + }) +}) diff --git a/apps/api/src/services/censorcheck/match-ip.ts b/apps/api/src/services/censorcheck/match-ip.ts new file mode 100644 index 0000000..6b31e19 --- /dev/null +++ b/apps/api/src/services/censorcheck/match-ip.ts @@ -0,0 +1,20 @@ +import { MAIN_SPACE_ID } from '@cfdm/db' +import { findVpsIdByIps, isPrivateOrLoopbackIp } from '@cfdm/db/repositories/ip-match' +import { vpsRepository } from '@cfdm/db/repositories/vps' + +export { isPrivateOrLoopbackIp } + +export function resolveProbeIp(observed: string | null | undefined, claimed: string): string { + const obs = observed?.trim() ?? '' + const claim = claimed.trim() + if (obs && !isPrivateOrLoopbackIp(obs)) return obs + return claim +} + +export function matchVpsByPublicIp(ip: string): { vpsId: string | null; spaceId: string } { + const all = vpsRepository.listAllSpaces() + const vpsId = findVpsIdByIps(all, [ip]) + if (!vpsId) return { vpsId: null, spaceId: MAIN_SPACE_ID } + const vps = all.find((row) => row.id === vpsId) + return { vpsId, spaceId: vps?.spaceId ?? MAIN_SPACE_ID } +} diff --git a/apps/api/src/services/censorcheck/normalize.test.ts b/apps/api/src/services/censorcheck/normalize.test.ts new file mode 100644 index 0000000..77f9746 --- /dev/null +++ b/apps/api/src/services/censorcheck/normalize.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import { + normalizeIngestResult, + statusFromErrorCode, + statusFromHttpCode, + summarizeResults, +} from './normalize.js' + +describe('censorcheck normalize', () => { + it('мапит HTTP-коды HTTPS IPv4', () => { + expect(statusFromHttpCode(200)).toBe('available') + expect(statusFromHttpCode(403)).toBe('denied') + expect(statusFromHttpCode(301)).toBe('redirected') + expect(statusFromHttpCode(0)).toBe('timeout') + expect(statusFromHttpCode(-1)).toBe('blocked') + }) + + it('мапит error_code', () => { + expect(statusFromErrorCode('blocked_by_ip')).toBe('blocked') + expect(statusFromErrorCode('nxdomain')).toBe('error') + expect(statusFromErrorCode('no_dns_record')).toBe('error') + }) + + it('берёт HTTPS IPv4 как primary', () => { + const row = normalizeIngestResult({ + service: 'YouTube.com', + raw: { + http: { ipv4: { status: 403 } }, + https: { ipv4: { status: 200 } }, + }, + }) + expect(row.serviceKey).toBe('youtube.com') + expect(row.category).toBe('dpi') + expect(row.status).toBe('available') + expect(row.httpStatus).toBe(200) + }) + + it('нормализует geoblock и timeout 000', () => { + const row = normalizeIngestResult({ + service: 'netflix.com', + raw: { https: { ipv4: { status: '000' } } }, + }) + expect(row.category).toBe('geoblock') + expect(row.status).toBe('timeout') + expect(row.httpStatus).toBe(0) + }) + + it('считает summary и partial при timeout', () => { + const { summary, runStatus } = summarizeResults([ + { status: 'available' }, + { status: 'timeout' }, + { status: 'blocked' }, + ]) + expect(summary.total).toBe(3) + expect(summary.available).toBe(1) + expect(summary.timeout).toBe(1) + expect(summary.blocked).toBe(1) + expect(runStatus).toBe('partial') + }) +}) diff --git a/apps/api/src/services/censorcheck/normalize.ts b/apps/api/src/services/censorcheck/normalize.ts new file mode 100644 index 0000000..58853b2 --- /dev/null +++ b/apps/api/src/services/censorcheck/normalize.ts @@ -0,0 +1,125 @@ +import { + emptyCensorcheckSummary, + inferCensorcheckCategory, + type CensorcheckCategory, + type CensorcheckIngestResult, + type CensorcheckRunStatus, + type CensorcheckStatus, + type CensorcheckSummary, +} from '@cfdm/shared/contracts/censorcheck' + +function parseHttpStatus(raw: unknown): number | null { + if (typeof raw === 'number' && Number.isFinite(raw)) return raw + if (typeof raw === 'string' && raw.trim() !== '') { + const n = Number(raw) + return Number.isFinite(n) ? n : null + } + return null +} + +function protocolStatus(proto: unknown): { httpStatus: number | null; redirectUrl?: string } { + if (!proto || typeof proto !== 'object') return { httpStatus: null } + const rec = proto as { status?: unknown; redirect_url?: unknown } + const redirectUrl = typeof rec.redirect_url === 'string' ? rec.redirect_url : undefined + return { httpStatus: parseHttpStatus(rec.status), redirectUrl } +} + +function pickPrimaryProtocol(raw: Record): { + httpStatus: number | null + redirectUrl?: string +} { + const https = raw.https + if (https && typeof https === 'object') { + const ipv4 = (https as { ipv4?: unknown }).ipv4 + if (ipv4) return protocolStatus(ipv4) + } + const http = raw.http + if (http && typeof http === 'object') { + const ipv4 = (http as { ipv4?: unknown }).ipv4 + if (ipv4) return protocolStatus(ipv4) + } + return { httpStatus: null } +} + +export function statusFromHttpCode(code: number | null): CensorcheckStatus { + if (code == null) return 'error' + if (code === 200) return 'available' + if (code === 403) return 'denied' + if (code >= 300 && code < 400) return 'redirected' + if (code === 0) return 'timeout' + if (code === -1) return 'blocked' + if (code >= 400) return 'denied' + return 'error' +} + +export function statusFromErrorCode(code: string | undefined): CensorcheckStatus { + if (code === 'blocked_by_ip') return 'blocked' + return 'error' +} + +export type NormalizedServiceResult = { + serviceKey: string + serviceLabel: string + category: CensorcheckCategory + status: CensorcheckStatus + httpStatus: number | null + detail: string | null + rawJson: string | null +} + +function compactRaw(raw: Record): string | null { + try { + const compact: Record = {} + if (raw.service != null) compact.service = raw.service + if (raw.error != null) compact.error = raw.error + if (raw.error_code != null) compact.error_code = raw.error_code + if (raw.http != null) compact.http = raw.http + if (raw.https != null) compact.https = raw.https + return JSON.stringify(compact) + } catch { + return null + } +} + +export function normalizeIngestResult(item: CensorcheckIngestResult): NormalizedServiceResult { + const serviceKey = item.service.trim().toLowerCase() + const raw = item.raw ?? {} + const errorCode = typeof raw.error_code === 'string' ? raw.error_code : undefined + const errorText = typeof raw.error === 'string' ? raw.error : undefined + const primary = pickPrimaryProtocol(raw) + + let status: CensorcheckStatus + let httpStatus = primary.httpStatus + let detail: string | null = primary.redirectUrl ?? errorText ?? null + + if (errorCode || (raw.http == null && raw.https == null && errorText)) { + status = statusFromErrorCode(errorCode) + if (!detail) detail = errorCode ?? errorText ?? null + } else { + status = statusFromHttpCode(httpStatus) + } + + return { + serviceKey, + serviceLabel: item.service.trim(), + category: item.category ?? inferCensorcheckCategory(serviceKey), + status, + httpStatus, + detail, + rawJson: compactRaw(raw), + } +} + +export function summarizeResults(results: { status: CensorcheckStatus }[]): { + summary: CensorcheckSummary + runStatus: CensorcheckRunStatus +} { + const summary = emptyCensorcheckSummary() + summary.total = results.length + for (const row of results) { + summary[row.status] += 1 + } + const runStatus: CensorcheckRunStatus = + summary.timeout > 0 || summary.error > 0 ? 'partial' : 'complete' + return { summary, runStatus } +} diff --git a/apps/web/src/components/censorcheck/blocking-filters.test.ts b/apps/web/src/components/censorcheck/blocking-filters.test.ts new file mode 100644 index 0000000..48796d9 --- /dev/null +++ b/apps/web/src/components/censorcheck/blocking-filters.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest' +import type { Filter } from '@/components/reui/filters' +import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters' +import type { CensorcheckRunDto } from './types' + +const run = (overrides: Partial = {}): CensorcheckRunDto => ({ + id: 'ccrun-1', + spaceId: 'space-main', + runId: '11111111-1111-4111-8111-111111111111', + probePublicIp: '203.0.113.10', + claimedPublicIp: null, + matchedVpsId: 'vps-1', + status: 'complete', + schemaVersion: 1, + launcherVersion: '1', + censorcheckVersion: '1', + summary: { + total: 2, + available: 1, + redirected: 0, + denied: 0, + blocked: 1, + timeout: 0, + error: 0, + }, + createdAt: '2026-08-22T00:00:00.000Z', + completedAt: '2026-08-22T00:00:00.000Z', + observedSourceIp: '203.0.113.10', + vps: { + id: 'vps-1', + ip: '203.0.113.10', + dns: 'edge.example.com', + providerId: 'p1', + providerName: 'Hoster', + country: 'Нидерланды', + city: 'Amsterdam', + datacenter: 'AMS', + vcpu: 2, + ramGb: 4, + diskGb: 40, + }, + results: [ + { + id: 'r1', + runId: 'ccrun-1', + serviceKey: 'youtube.com', + serviceLabel: 'youtube.com', + category: 'dpi', + status: 'blocked', + httpStatus: -1, + detail: null, + }, + { + id: 'r2', + runId: 'ccrun-1', + serviceKey: 'netflix.com', + serviceLabel: 'netflix.com', + category: 'geoblock', + status: 'available', + httpStatus: 200, + detail: null, + }, + ], + ...overrides, +}) + +describe('filterCensorcheckRuns', () => { + it('фильтрует по статусу сервиса', () => { + const filters: Filter[] = [ + { id: '1', field: 'status', operator: 'is_any_of', values: ['blocked'] }, + ] + expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1) + expect( + filterCensorcheckRuns([run()], [ + { id: '1', field: 'status', operator: 'is_any_of', values: ['timeout'] }, + ]), + ).toHaveLength(0) + }) + + it('ищет по IP и DNS', () => { + const filters: Filter[] = [ + { id: '1', field: 'q', operator: 'contains', values: ['edge.example'] }, + ] + expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1) + expect( + filterCensorcheckRuns([run()], [ + { id: '1', field: 'q', operator: 'contains', values: ['missing'] }, + ]), + ).toHaveLength(0) + }) +}) + +describe('groupRunsByService', () => { + it('собирает пробы по сервису', () => { + const groups = groupRunsByService([run()]) + expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com']) + expect(groups[1]?.probes[0]?.status).toBe('blocked') + }) +}) diff --git a/apps/web/src/components/censorcheck/blocking-filters.ts b/apps/web/src/components/censorcheck/blocking-filters.ts new file mode 100644 index 0000000..ab9598f --- /dev/null +++ b/apps/web/src/components/censorcheck/blocking-filters.ts @@ -0,0 +1,105 @@ +import { getActiveFilters } from '@/components/reui-kit' +import type { Filter } from '@/components/reui/filters' +import { runSearchText, type CensorcheckRunDto } from './types' + +export function filterCensorcheckRuns( + runs: CensorcheckRunDto[], + filters: Filter[], +): CensorcheckRunDto[] { + const active = getActiveFilters(filters) + if (active.length === 0) return runs + + return runs.filter((run) => { + for (const filter of active) { + const values = filter.values.map((value) => String(value)) + if (filter.field === 'status') { + const statuses = (run.results ?? []).map((row) => row.status) + const hit = values.some((value) => statuses.includes(value)) + if (filter.operator === 'is_not_any_of' ? hit : !hit) return false + continue + } + if (filter.field === 'service') { + const hay = (run.results ?? []) + .map((row) => `${row.serviceKey} ${row.serviceLabel}`) + .join(' ') + .toLowerCase() + const hit = values.some((value) => + hay.includes(value.toLowerCase()) || (run.results ?? []).some((row) => row.serviceKey === value), + ) + if (!hit) return false + continue + } + if (filter.field === 'hoster') { + const name = (run.vps?.providerName ?? '').toLowerCase() + const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase()) + if (!hit) return false + continue + } + if (filter.field === 'country') { + const country = (run.vps?.country ?? '').toLowerCase() + const hit = values.some((value) => country.includes(value.toLowerCase()) || country === value.toLowerCase()) + if (!hit) return false + continue + } + if (filter.field === 'matched') { + const matched = run.matchedVpsId ? 'matched' : 'unmatched' + if (!values.includes(matched)) return false + continue + } + if (filter.field === 'q') { + const hay = runSearchText(run) + const hit = values.some((token) => hay.includes(token.toLowerCase())) + if (!hit) return false + } + } + return true + }) +} + +export type BlockingServiceRow = { + id: string + serviceKey: string + serviceLabel: string + category: string + probes: Array<{ + runId: string + probePublicIp: string + matchedVpsId: string | null + dns: string + country: string + status: string + createdAt: string + vpsId: string | null + }> +} + +export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRow[] { + const map = new Map() + for (const run of runs) { + for (const result of run.results ?? []) { + const existing = map.get(result.serviceKey) + const probe = { + runId: run.id, + probePublicIp: run.probePublicIp, + matchedVpsId: run.matchedVpsId, + dns: run.vps?.dns ?? '', + country: run.vps?.country ?? '', + status: result.status, + createdAt: run.createdAt, + vpsId: run.matchedVpsId, + } + if (existing) { + existing.probes.push(probe) + } else { + map.set(result.serviceKey, { + id: result.serviceKey, + serviceKey: result.serviceKey, + serviceLabel: result.serviceLabel, + category: result.category, + probes: [probe], + }) + } + } + } + return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey)) +} diff --git a/apps/web/src/components/censorcheck/blocking-grid.tsx b/apps/web/src/components/censorcheck/blocking-grid.tsx new file mode 100644 index 0000000..1021a46 --- /dev/null +++ b/apps/web/src/components/censorcheck/blocking-grid.tsx @@ -0,0 +1,217 @@ +import type { ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react' + +import type { DataGridColumn } from '@/components/data-grid-types' +import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells' +import { CountryFlag } from '@/components/country-flag' +import { StatusBadge } from '@/components/status-badge' +import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit' +import { Badge } from '@/components/reui/badge' +import { + CENSORCHECK_STATUS_LABELS, + formatCheckedAt, + formatVpsResources, + type CensorcheckRunDto, +} from './types' +import type { BlockingServiceRow } from './blocking-filters' + +function SummaryBadges({ run }: { run: CensorcheckRunDto }) { + const { summary } = run + return ( +
+ {summary.available > 0 ? ( + {summary.available} ок + ) : null} + {summary.blocked > 0 ? ( + {summary.blocked} блок + ) : null} + {summary.denied > 0 ? ( + {summary.denied} отказ + ) : null} + {summary.timeout > 0 ? ( + {summary.timeout} timeout + ) : null} + {summary.error > 0 ? ( + {summary.error} err + ) : null} +
+ ) +} + +function NestedList({ + rows, +}: { + rows: Array<{ key: string; primary: string; secondary?: string; status: string }> +}) { + return ( +
+ {rows.map((row) => ( +
+
+ {row.primary} + {row.secondary ? ( + {row.secondary} + ) : null} +
+ +
+ ))} +
+ ) +} + +const vpsColumns: DataGridColumn[] = [ + { + key: 'vps', + header: 'VPS / IP', + icon: ServerIcon, + sortValue: (row) => row.vps?.dns || row.probePublicIp, + cell: (row) => { + const title = row.vps?.dns || row.probePublicIp + const ip = row.probePublicIp + const link = row.matchedVpsId ? ( + event.stopPropagation()} + > + {title} + + ) : ( + Unknown VPS + ) + return dataGridCellStack(link, ip) + }, + }, + { + key: 'dns', + header: 'DNS', + icon: GlobeIcon, + sortValue: (row) => row.vps?.dns ?? '', + cell: (row) => row.vps?.dns || '—', + }, + { + key: 'hoster', + header: 'Хостер', + sortValue: (row) => row.vps?.providerName ?? '', + cell: (row) => row.vps?.providerName || '—', + }, + { + key: 'country', + header: 'Страна', + icon: MapPinIcon, + sortValue: (row) => row.vps?.country ?? '', + cell: (row) => + row.vps?.country + ? dataGridCellWithFlag(, row.vps.country) + : '—', + }, + { + key: 'resources', + header: 'Ресурсы', + sortValue: (row) => row.vps?.vcpu ?? 0, + cell: (row) => + row.vps + ? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb) + : '—', + }, + { + key: 'summary', + header: 'Сводка', + cell: (row) => , + }, + { + key: 'checked', + header: 'Проверено', + sortValue: (row) => row.createdAt, + cell: (row) => formatCheckedAt(row.createdAt), + }, +] + +const serviceColumns: DataGridColumn[] = [ + { + key: 'service', + header: 'Сервис', + icon: ShieldAlertIcon, + sortValue: (row) => row.serviceKey, + cell: (row) => dataGridCellStack(row.serviceLabel, row.category), + }, + { + key: 'probes', + header: 'Пробы', + sortValue: (row) => row.probes.length, + sortingFn: 'basic', + cell: (row) => row.probes.length, + }, +] + +export function BlockingVpsGrid({ + runs, + onRowClick, + emptyAction, +}: { + runs: CensorcheckRunDto[] + onRowClick: (run: CensorcheckRunDto) => void + emptyAction?: ReactNode +}) { + return ( + row.id} + dense + pagination={runs.length > 10} + emptyTitle="Нет проверок" + emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок." + emptyAction={emptyAction} + onRowClick={onRowClick} + getRowCanExpand={(row) => (row.results?.length ?? 0) > 0} + expandedContent={(row) => ( + ({ + key: item.id, + primary: item.serviceLabel, + secondary: item.category, + status: item.status, + }))} + /> + )} + /> + ) +} + +export function BlockingServiceGrid({ + groups, + emptyAction, +}: { + groups: BlockingServiceRow[] + emptyAction?: ReactNode +}) { + return ( + row.id} + dense + pagination={groups.length > 10} + emptyTitle="Нет сервисов" + emptyAction={emptyAction} + getRowCanExpand={(row) => row.probes.length > 0} + expandedContent={(row) => ( + ({ + key: `${probe.runId}-${probe.probePublicIp}`, + primary: probe.dns || probe.probePublicIp, + secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`, + status: probe.status, + }))} + /> + )} + /> + ) +} diff --git a/apps/web/src/components/censorcheck/blocking-page.tsx b/apps/web/src/components/censorcheck/blocking-page.tsx new file mode 100644 index 0000000..aacdac1 --- /dev/null +++ b/apps/web/src/components/censorcheck/blocking-page.tsx @@ -0,0 +1,268 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + BanIcon, + CopyIcon, + GlobeIcon, + ServerIcon, + ShieldAlertIcon, +} from 'lucide-react' + +import { PageShell } from '@/components/page-shell' +import { PageHeader } from '@/components/page-header' +import { Button } from '@cfdm/ui/components/button' +import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group' +import { CountedLineTabs } from '@/components/counted-line-tabs' +import { KpiStatGrid, ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit' +import { Filters, type Filter, type FilterFieldConfig } from '@/components/reui/filters' +import { QueryState } from '@/components/query-state' +import { TableSkeleton } from '@/components/skeletons' +import { copyText } from '@/lib/clipboard' +import { useSpaceId } from '@/lib/space' +import { + censorcheckCurrentQueryOptions, + censorcheckHistoryQueryOptions, +} from '@/queries/censorcheck' +import { StatusBadge } from '@/components/status-badge' +import type { DataGridColumn } from '@/components/data-grid-types' +import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid' +import { CheckRunSheet } from './check-run-sheet' +import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters' +import { + CENSORCHECK_STATUS_LABELS, + LAUNCHER_CMD, + formatCheckedAt, + type CensorcheckRunDto, +} from './types' + +type GroupMode = 'vps' | 'service' +type TabId = 'current' | 'history' + +const FILTER_FIELDS: FilterFieldConfig[] = [ + { key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, сервис' }, + { + key: 'status', + label: 'Статус', + type: 'multiselect', + defaultOperator: 'is_any_of', + options: [ + { value: 'available', label: 'Доступен' }, + { value: 'blocked', label: 'Заблокирован' }, + { value: 'denied', label: 'Отказ' }, + { value: 'timeout', label: 'Таймаут' }, + { value: 'redirected', label: 'Редирект' }, + { value: 'error', label: 'Ошибка' }, + ], + }, + { key: 'service', label: 'Сервис', type: 'text', defaultOperator: 'is_any_of' }, + { key: 'hoster', label: 'Хостер', type: 'text', defaultOperator: 'contains' }, + { key: 'country', label: 'Страна', type: 'text', defaultOperator: 'contains' }, + { + key: 'matched', + label: 'Привязка', + type: 'select', + defaultOperator: 'is', + options: [ + { value: 'matched', label: 'Известный VPS' }, + { value: 'unmatched', label: 'Unknown VPS' }, + ], + }, +] + +const historyColumns: DataGridColumn[] = [ + { + key: 'ip', + header: 'IP', + sortValue: (row) => row.probePublicIp, + cell: (row) => row.probePublicIp, + }, + { + key: 'vps', + header: 'VPS', + sortValue: (row) => row.vps?.dns ?? '', + cell: (row) => row.vps?.dns || (row.matchedVpsId ? row.matchedVpsId : 'Unknown VPS'), + }, + { + key: 'status', + header: 'Статус', + cell: (row) => ( + + ), + }, + { + key: 'summary', + header: 'Блок / всего', + sortValue: (row) => row.summary.blocked, + sortingFn: 'basic', + cell: (row) => `${row.summary.blocked} / ${row.summary.total}`, + }, + { + key: 'createdAt', + header: 'Проверено', + sortValue: (row) => row.createdAt, + cell: (row) => formatCheckedAt(row.createdAt), + }, +] + +export function BlockingPage() { + const { spaceId } = useSpaceId() + const [tab, setTab] = useState('current') + const [group, setGroup] = useState('vps') + const [filters, setFilters] = useState([]) + const [selected, setSelected] = useState(null) + + const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId)) + const historyQuery = useQuery({ + ...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId), + enabled: tab === 'history', + }) + + const runs = currentQuery.data?.items ?? [] + const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters]) + const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered]) + + const matched = filtered.filter((row) => row.matchedVpsId).length + const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0) + + const copyLauncher = ( + + ) + + return ( + + + , + }, + { + id: 'matched', + label: 'Известные VPS', + value: matched, + icon: , + }, + { + id: 'unmatched', + label: 'Unknown VPS', + value: filtered.length - matched, + icon: , + }, + { + id: 'blocked', + label: 'Блокировки', + value: blocked, + icon: , + variant: blocked > 0 ? 'destructive' : 'default', + }, + ]} + isLoading={currentQuery.isLoading} + /> + + setTab(value as TabId)} + /> + + {tab === 'current' ? ( +
+
+ + Фильтры + + } + /> + { + const selectedMode = next[0] + if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode) + }} + aria-label="Группировка" + > + По VPS + По сервису + +
+ void currentQuery.refetch()} + empty={filtered.length === 0} + emptyTitle="Пока нет проверок" + emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`} + emptyAction={copyLauncher} + skeleton={} + > + {(rows) => + group === 'vps' ? ( + + ) : ( + + ) + } + +
+ ) : ( + row.id} + isLoading={historyQuery.isLoading} + isError={historyQuery.isError} + error={historyQuery.error instanceof Error ? historyQuery.error : null} + onRetry={() => void historyQuery.refetch()} + onRowClick={setSelected} + emptyState={{ + title: 'История пуста', + description: `На VPS выполните: ${LAUNCHER_CMD}`, + action: copyLauncher, + }} + /> + )} + + { + if (!open) setSelected(null) + }} + /> +
+ ) +} diff --git a/apps/web/src/components/censorcheck/check-run-sheet.tsx b/apps/web/src/components/censorcheck/check-run-sheet.tsx new file mode 100644 index 0000000..6bb877a --- /dev/null +++ b/apps/web/src/components/censorcheck/check-run-sheet.tsx @@ -0,0 +1,117 @@ +import { Link } from '@tanstack/react-router' +import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react' +import { useQuery } from '@tanstack/react-query' + +import { + Sheet, + SheetContent, + SheetDescription, + SheetHeader, + SheetTitle, +} from '@cfdm/ui/components/sheet' +import { DetailPanel } from '@/components/reui-kit/detail-panel' +import { StatusBadge } from '@/components/status-badge' +import { censorcheckRunQueryOptions } from '@/queries/censorcheck' +import { + CENSORCHECK_STATUS_LABELS, + formatCheckedAt, + formatVpsResources, + type CensorcheckRunDto, +} from './types' + +interface CheckRunSheetProps { + run: CensorcheckRunDto | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) { + const needFetch = Boolean(run && !run.results) + const { data: fetched } = useQuery({ + ...censorcheckRunQueryOptions(needFetch ? run?.id ?? null : null), + }) + const detail = run?.results ? run : fetched ?? run + const title = detail?.vps?.dns || detail?.probePublicIp || 'Проверка' + + return ( + + + + {title} + + {detail ? formatCheckedAt(detail.createdAt) : 'Загрузка…'} + + + {detail ? ( + + , + label: 'IP', + description: detail.probePublicIp, + }, + { + id: 'vps', + icon: , + label: 'VPS', + description: detail.matchedVpsId ? detail.vps?.dns || detail.matchedVpsId : 'Unknown VPS', + footer: detail.matchedVpsId ? ( + + Открыть карточку + + ) : undefined, + }, + { + id: 'geo', + icon: , + label: 'Локация', + description: detail.vps?.country || '—', + }, + ]} + /> + +
+ + {detail.vps ? ( + + {formatVpsResources(detail.vps.vcpu, detail.vps.ramGb, detail.vps.diskGb)} + + ) : null} +
+
+ +
+ {(detail.results ?? []).map((item) => ( +
+
+ {item.serviceLabel} + {item.category} +
+ +
+ ))} +
+
+
+ ) : ( +
+ + Нет данных прогона +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/censorcheck/types.ts b/apps/web/src/components/censorcheck/types.ts new file mode 100644 index 0000000..4b34d6b --- /dev/null +++ b/apps/web/src/components/censorcheck/types.ts @@ -0,0 +1,80 @@ +import type { CensorcheckSummary } from '@cfdm/shared/contracts/censorcheck' + +export type CensorcheckResultDto = { + id: string + runId: string + serviceKey: string + serviceLabel: string + category: string + status: string + httpStatus: number | null + detail: string | null +} + +export type CensorcheckVpsInfo = { + id: string + ip: string + dns: string + providerId: string + providerName: string + country: string + city: string + datacenter: string + vcpu: number + ramGb: number + diskGb: number +} + +export type CensorcheckRunDto = { + id: string + spaceId: string + runId: string + probePublicIp: string + claimedPublicIp: string | null + matchedVpsId: string | null + status: string + schemaVersion: number + launcherVersion: string | null + censorcheckVersion: string | null + summary: CensorcheckSummary + createdAt: string + completedAt: string + observedSourceIp: string | null + vps: CensorcheckVpsInfo | null + results?: CensorcheckResultDto[] +} + +export const CENSORCHECK_STATUS_LABELS: Record = { + available: 'Доступен', + redirected: 'Редирект', + denied: 'Отказ', + blocked: 'Заблокирован', + timeout: 'Таймаут', + error: 'Ошибка', + complete: 'Полный', + partial: 'Частичный', +} + +export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash' + +export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string { + return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB` +} + +export function formatCheckedAt(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) return iso + return date.toLocaleString('ru-RU') +} + +export function runSearchText(run: CensorcheckRunDto): string { + const parts = [ + run.probePublicIp, + run.claimedPublicIp ?? '', + run.vps?.dns ?? '', + run.vps?.providerName ?? '', + run.vps?.country ?? '', + ...(run.results ?? []).map((row) => `${row.serviceKey} ${row.serviceLabel}`), + ] + return parts.join(' ').toLowerCase() +} diff --git a/apps/web/src/components/global-search.tsx b/apps/web/src/components/global-search.tsx index 16978ad..dbf08f7 100644 --- a/apps/web/src/components/global-search.tsx +++ b/apps/web/src/components/global-search.tsx @@ -8,6 +8,7 @@ import { FolderKanbanIcon, LayoutDashboardIcon, SearchIcon, + ShieldAlertIcon, } from 'lucide-react' import { @@ -64,6 +65,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { Все VPS + go('/blocking')}> + + Статус блокировок + diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 9405cf2..269b1ce 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -14,6 +14,7 @@ import { HistoryIcon, UsersIcon, Network, + ShieldAlert, } from 'lucide-react' import { @@ -82,6 +83,7 @@ const NAV_GROUPS: NavGroup[] = [ label: 'Инфраструктура', items: [ { to: '/vps', label: 'VPS', icon: Server }, + { to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert }, { to: '/topology', label: 'Схема', icon: Network }, { to: '/tariffs', label: 'Активные тарифы', icon: ServerCog }, { to: '/providers', label: 'Хостеры', icon: Building2 }, diff --git a/apps/web/src/components/reui-kit/expandable-resource-grid.tsx b/apps/web/src/components/reui-kit/expandable-resource-grid.tsx new file mode 100644 index 0000000..8fca541 --- /dev/null +++ b/apps/web/src/components/reui-kit/expandable-resource-grid.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react' + +import { + FrameDataGrid, + type FrameDataGridProps, +} from './frame-data-grid' + +/** + * Frame + DataGrid with expandable rows. + * Preview: https://reui.io/preview/base/components/c-data-grid-8 + * Docs: https://reui.io/docs/components/base/data-grid + */ +export function ExpandableResourceGrid({ + expandedContent, + getRowCanExpand, + ...props +}: FrameDataGridProps & { + expandedContent: (row: TData) => ReactNode + getRowCanExpand?: (row: TData) => boolean +}) { + return ( + + ) +} diff --git a/apps/web/src/components/reui-kit/frame-data-grid.tsx b/apps/web/src/components/reui-kit/frame-data-grid.tsx index 4dc756d..3196098 100644 --- a/apps/web/src/components/reui-kit/frame-data-grid.tsx +++ b/apps/web/src/components/reui-kit/frame-data-grid.tsx @@ -4,14 +4,16 @@ import { getCoreRowModel, getSortedRowModel, getPaginationRowModel, + getExpandedRowModel, flexRender, type ColumnDef, type SortingState, type RowSelectionState, type VisibilityState, + type ExpandedState, type OnChangeFn, } from '@tanstack/react-table' -import { Columns3Icon } from 'lucide-react' +import { ChevronDownIcon, ChevronRightIcon, Columns3Icon } from 'lucide-react' import { Checkbox } from '@cfdm/ui/components/checkbox' import { Button } from '@cfdm/ui/components/button' @@ -125,6 +127,9 @@ export interface FrameDataGridProps { /** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */ initialColumnVisibility?: VisibilityState className?: string + /** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */ + expandedContent?: (row: TData) => ReactNode + getRowCanExpand?: (row: TData) => boolean } function DataGridSectionHeader({ @@ -251,9 +256,12 @@ export function FrameDataGrid({ columnVisibilityStorageKey, initialColumnVisibility, className, + expandedContent, + getRowCanExpand, }: FrameDataGridProps) { const [sorting, setSorting] = useState(initialSorting ?? []) const [rowSelection, setRowSelection] = useState({}) + const [expanded, setExpanded] = useState({}) const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => { const stored = columnVisibilityStorageKey ? loadStoredColumnVisibility(columnVisibilityStorageKey) @@ -295,7 +303,42 @@ export function FrameDataGrid({ meta: { cellClassName: 'w-10' }, } - const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns + const expandColumn: ColumnDef = { + id: 'expand', + header: () => null, + cell: ({ row }) => + row.getCanExpand() ? ( + + ) : null, + enableSorting: false, + enableHiding: false, + meta: { + cellClassName: 'w-10', + expandedContent, + }, + } + + const tableColumns = [ + ...(expandedContent ? [expandColumn] : []), + ...(enableRowSelection ? [selectColumn] : []), + ...columns, + ] const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : '' @@ -307,9 +350,11 @@ export function FrameDataGrid({ state: { sorting, columnVisibility, + expanded, ...(enableRowSelection ? { rowSelection } : {}), }, onSortingChange: setSorting, + onExpandedChange: setExpanded, onColumnVisibilityChange: setColumnVisibility, onRowSelectionChange: enableRowSelection ? (updater) => { @@ -325,6 +370,7 @@ export function FrameDataGrid({ : undefined, getCoreRowModel: getCoreRowModel(), getSortedRowModel: getSortedRowModel(), + getExpandedRowModel: expandedContent ? getExpandedRowModel() : undefined, getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined, initialState: { ...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}), @@ -333,6 +379,9 @@ export function FrameDataGrid({ getRowId: rowId ? (row, index) => rowId(row, index) : undefined, + getRowCanExpand: expandedContent + ? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true) + : undefined, enableColumnPinning: pinLastColumn, enableRowSelection, enableHiding: enableColumnVisibility, diff --git a/apps/web/src/components/reui-kit/index.ts b/apps/web/src/components/reui-kit/index.ts index 68faa74..2378f39 100644 --- a/apps/web/src/components/reui-kit/index.ts +++ b/apps/web/src/components/reui-kit/index.ts @@ -20,6 +20,7 @@ export { type FrameDataGridProps, type DataGridColumnVisibilityOption, } from './frame-data-grid' +export { ExpandableResourceGrid } from './expandable-resource-grid' export { OpsDashboard } from './ops-dashboard' export { DetailPanel, type DetailMetricCard } from './detail-panel' export { SettingsShell, type SettingsTabConfig } from './settings-shell' diff --git a/apps/web/src/components/status-badge.tsx b/apps/web/src/components/status-badge.tsx index 5b19e6e..9a2f7aa 100644 --- a/apps/web/src/components/status-badge.tsx +++ b/apps/web/src/components/status-badge.tsx @@ -8,12 +8,19 @@ const STATUS_VARIANT: Record = { active: 'success', ok: 'success', paid: 'success', + available: 'success', + complete: 'success', paused: 'secondary', archived: 'outline', error: 'destructive', + denied: 'destructive', + blocked: 'destructive', running: 'info', overdue: 'warning', stale: 'warning', + timeout: 'warning', + redirected: 'warning', + partial: 'warning', } export function StatusBadge({ status, label }: { status: string; label?: string }) { diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 277f45e..dfffb36 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -417,6 +417,37 @@ export const api = { actorUserId?: string | null createdAt: string }>>(`/api/audit?limit=${limit}`), + + fetchCensorcheckCurrent: () => + fetchApi<{ items: import('@/components/censorcheck/types').CensorcheckRunDto[] }>( + '/api/censorcheck/current', + ), + + fetchCensorcheckRuns: (params: { + cursor?: string + limit?: number + q?: string + status?: string + matched?: boolean + } = {}) => { + const search = new URLSearchParams() + if (params.cursor) search.set('cursor', params.cursor) + if (params.limit) search.set('limit', String(params.limit)) + if (params.q) search.set('q', params.q) + if (params.status) search.set('status', params.status) + if (params.matched === true) search.set('matched', '1') + if (params.matched === false) search.set('matched', '0') + const qs = search.toString() + return fetchApi<{ + items: import('@/components/censorcheck/types').CensorcheckRunDto[] + nextCursor: string | null + }>(`/api/censorcheck/runs${qs ? `?${qs}` : ''}`) + }, + + fetchCensorcheckRun: (id: string) => + fetchApi( + `/api/censorcheck/runs/${encodeURIComponent(id)}`, + ), } export type { diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index 2b65228..f2d9973 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -234,6 +234,7 @@ export function permissionForPath(pathname: string): string | null { if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read' if ( pathname.startsWith('/vps') || + pathname.startsWith('/blocking') || pathname.startsWith('/topology') || pathname.startsWith('/tariffs') || pathname.startsWith('/projects') || diff --git a/apps/web/src/queries/censorcheck.ts b/apps/web/src/queries/censorcheck.ts new file mode 100644 index 0000000..745be85 --- /dev/null +++ b/apps/web/src/queries/censorcheck.ts @@ -0,0 +1,40 @@ +import { queryClient } from '../lib/queryClient' +import { api } from '../lib/api-client' +import { getStoredSpaceId } from '../lib/space' + +export const censorcheckKeys = { + all: ['censorcheck'] as const, + current: (spaceId: string | null) => ['censorcheck', 'current', spaceId ?? 'default'] as const, + history: (spaceId: string | null, params: Record) => + ['censorcheck', 'history', spaceId ?? 'default', params] as const, + detail: (id: string) => ['censorcheck', 'run', id] as const, +} + +export const censorcheckCurrentQueryOptions = (spaceId?: string | null) => { + const id = spaceId === undefined ? getStoredSpaceId() : spaceId + return { + queryKey: censorcheckKeys.current(id), + queryFn: () => api.fetchCensorcheckCurrent(), + staleTime: 15_000, + } +} + +export const censorcheckHistoryQueryOptions = ( + params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {}, + spaceId?: string | null, +) => { + const id = spaceId === undefined ? getStoredSpaceId() : spaceId + return { + queryKey: censorcheckKeys.history(id, params), + queryFn: () => api.fetchCensorcheckRuns({ limit: 50, ...params }), + staleTime: 15_000, + } +} + +export const censorcheckRunQueryOptions = (id: string | null) => ({ + queryKey: censorcheckKeys.detail(id ?? ''), + queryFn: () => api.fetchCensorcheckRun(id!), + enabled: Boolean(id), +}) + +export { queryClient } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 1cbd580..0215904 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -24,6 +24,7 @@ import { Route as AuthProvidersRouteImport } from './routes/_auth/providers' import { Route as AuthProjectsRouteImport } from './routes/_auth/projects' import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments' import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard' +import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking' import { Route as AuthBalanceRouteImport } from './routes/_auth/balance' import { Route as AuthAuditRouteImport } from './routes/_auth/audit' import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts' @@ -109,6 +110,11 @@ const AuthDashboardRoute = AuthDashboardRouteImport.update({ path: '/dashboard', getParentRoute: () => AuthRoute, } as any) +const AuthBlockingRoute = AuthBlockingRouteImport.update({ + id: '/blocking', + path: '/blocking', + getParentRoute: () => AuthRoute, +} as any) const AuthBalanceRoute = AuthBalanceRouteImport.update({ id: '/balance', path: '/balance', @@ -168,6 +174,7 @@ export interface FileRoutesByFullPath { '/accounts': typeof AuthAccountsRoute '/audit': typeof AuthAuditRoute '/balance': typeof AuthBalanceRoute + '/blocking': typeof AuthBlockingRoute '/dashboard': typeof AuthDashboardRoute '/payments': typeof AuthPaymentsRoute '/projects': typeof AuthProjectsRouteWithChildren @@ -193,6 +200,7 @@ export interface FileRoutesByTo { '/accounts': typeof AuthAccountsRoute '/audit': typeof AuthAuditRoute '/balance': typeof AuthBalanceRoute + '/blocking': typeof AuthBlockingRoute '/dashboard': typeof AuthDashboardRoute '/payments': typeof AuthPaymentsRoute '/projects': typeof AuthProjectsRouteWithChildren @@ -221,6 +229,7 @@ export interface FileRoutesById { '/_auth/accounts': typeof AuthAccountsRoute '/_auth/audit': typeof AuthAuditRoute '/_auth/balance': typeof AuthBalanceRoute + '/_auth/blocking': typeof AuthBlockingRoute '/_auth/dashboard': typeof AuthDashboardRoute '/_auth/payments': typeof AuthPaymentsRoute '/_auth/projects': typeof AuthProjectsRouteWithChildren @@ -249,6 +258,7 @@ export interface FileRouteTypes { | '/accounts' | '/audit' | '/balance' + | '/blocking' | '/dashboard' | '/payments' | '/projects' @@ -274,6 +284,7 @@ export interface FileRouteTypes { | '/accounts' | '/audit' | '/balance' + | '/blocking' | '/dashboard' | '/payments' | '/projects' @@ -301,6 +312,7 @@ export interface FileRouteTypes { | '/_auth/accounts' | '/_auth/audit' | '/_auth/balance' + | '/_auth/blocking' | '/_auth/dashboard' | '/_auth/payments' | '/_auth/projects' @@ -435,6 +447,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthDashboardRouteImport parentRoute: typeof AuthRoute } + '/_auth/blocking': { + id: '/_auth/blocking' + path: '/blocking' + fullPath: '/blocking' + preLoaderRoute: typeof AuthBlockingRouteImport + parentRoute: typeof AuthRoute + } '/_auth/balance': { id: '/_auth/balance' path: '/balance' @@ -553,6 +572,7 @@ interface AuthRouteChildren { AuthAccountsRoute: typeof AuthAccountsRoute AuthAuditRoute: typeof AuthAuditRoute AuthBalanceRoute: typeof AuthBalanceRoute + AuthBlockingRoute: typeof AuthBlockingRoute AuthDashboardRoute: typeof AuthDashboardRoute AuthPaymentsRoute: typeof AuthPaymentsRoute AuthProjectsRoute: typeof AuthProjectsRouteWithChildren @@ -572,6 +592,7 @@ const AuthRouteChildren: AuthRouteChildren = { AuthAccountsRoute: AuthAccountsRoute, AuthAuditRoute: AuthAuditRoute, AuthBalanceRoute: AuthBalanceRoute, + AuthBlockingRoute: AuthBlockingRoute, AuthDashboardRoute: AuthDashboardRoute, AuthPaymentsRoute: AuthPaymentsRoute, AuthProjectsRoute: AuthProjectsRouteWithChildren, diff --git a/apps/web/src/routes/_auth/blocking.tsx b/apps/web/src/routes/_auth/blocking.tsx new file mode 100644 index 0000000..fcd5fb1 --- /dev/null +++ b/apps/web/src/routes/_auth/blocking.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { BlockingPage } from '@/components/censorcheck/blocking-page' +import { censorcheckCurrentQueryOptions } from '@/queries/censorcheck' + +export const Route = createFileRoute('/_auth/blocking')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(censorcheckCurrentQueryOptions()), + component: BlockingPage, +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index b71930e..6af838f 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -1,7 +1,6 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "baseUrl": ".", "paths": { "@/*": ["./src/*"], "@cfdm/ui/components/*": ["../../packages/ui/src/components/*"], diff --git a/deploy/docker-compose.traefik.yml b/deploy/docker-compose.traefik.yml index 160fbc2..6785fb8 100644 --- a/deploy/docker-compose.traefik.yml +++ b/deploy/docker-compose.traefik.yml @@ -66,6 +66,11 @@ services: AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-} AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top} AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top} + TRUST_PROXY: "1" + CENSORCHECK_INGEST_SECRET: ${CENSORCHECK_INGEST_SECRET:-} + CENSORCHECK_PUBLIC_URL: ${CENSORCHECK_PUBLIC_URL:-} + VPS_DOMAIN: ${VPS_DOMAIN:-vps.shnt.top} + VPS_LAUNCHER_DOMAIN: ${VPS_LAUNCHER_DOMAIN:-vt.shnt.top} # Import SQLite/JSON via UI (default in app: 100 MiB). Raise if DB is larger. BACKUP_BODY_LIMIT_BYTES: ${BACKUP_BODY_LIMIT_BYTES:-104857600} volumes: @@ -75,7 +80,7 @@ services: labels: - traefik.enable=true - traefik.docker.network=vps-tracker - - traefik.http.routers.vps-tracker.rule=Host(`${VPS_DOMAIN:-vps.shnt.top}`) + - traefik.http.routers.vps-tracker.rule=Host(`${VPS_DOMAIN:-vps.shnt.top}`) || Host(`${VPS_LAUNCHER_DOMAIN:-vt.shnt.top}`) - traefik.http.routers.vps-tracker.entrypoints=websecure - traefik.http.routers.vps-tracker.tls=true - traefik.http.routers.vps-tracker.tls.certresolver=letsencrypt diff --git a/deploy/env.traefik.example b/deploy/env.traefik.example index e360bca..9781256 100644 --- a/deploy/env.traefik.example +++ b/deploy/env.traefik.example @@ -5,6 +5,8 @@ CF_DNS_API_TOKEN= LETSENCRYPT_EMAIL=admin@example.com VPS_DOMAIN=vps.example.com +# Короткий хост для launcher: curl -fsSL https://vt.example.com/cc | bash +VPS_LAUNCHER_DOMAIN=vt.example.com # TRAEFIK_IMAGE_TAG=v3.7 # TRAEFIK_HTTP_PORT=80 # TRAEFIK_HTTPS_PORT=443 @@ -23,5 +25,10 @@ AUTH_JWT_SECRET= AUTH_ISSUER=https://auth.shnt.top AUTH_PORTAL_URL=https://auth.shnt.top +# --- Censorcheck launcher (GET /cc, ingest HMAC) --- +# CENSORCHECK_INGEST_SECRET= +# CENSORCHECK_PUBLIC_URL=https://vt.example.com +# TRUST_PROXY is set to 1 in compose (Traefik X-Forwarded-For). + # --- Backup import (POST /api/backup/*). Fastify default is 1 MiB → 413 Content Too Large. # BACKUP_BODY_LIMIT_BYTES=104857600 diff --git a/docs/deploy-traefik.md b/docs/deploy-traefik.md index b059209..3cccad6 100644 --- a/docs/deploy-traefik.md +++ b/docs/deploy-traefik.md @@ -48,6 +48,7 @@ Internet → :80/:443 (Traefik) → vps-tracker:3001 | Type | Name | Content | Proxy | |------|------|---------|-------| | `A` / `AAAA` | `vps` (или нужный поддомен) | IP вашего VPS | **DNS only** (серое облако) | +| `A` / `AAAA` | `vt` (launcher `curl \| bash`) | тот же IP | **DNS only** (серое облако) | ```bash dig +short vps.example.com A @@ -82,7 +83,8 @@ nano .env # заполнить секреты и домен |------------|------------| | `CF_DNS_API_TOKEN` | Cloudflare token для ACME DNS-01 (env контейнера **Traefik**) | | `LETSENCRYPT_EMAIL` | Email для Let's Encrypt | -| `VPS_DOMAIN` | Хост в Traefik `Host(…)` (например `vps.example.com`) | +| `VPS_DOMAIN` | Хост UI в Traefik `Host(…)` (например `vps.example.com`) | +| `VPS_LAUNCHER_DOMAIN` | Короткий хост launcher (`vt.example.com` → тот же контейнер, `GET /cc`) | Опционально: @@ -91,6 +93,8 @@ nano .env # заполнить секреты и домен | `VPS_TRACKER_IMAGE` / `VPS_TRACKER_IMAGE_TAG` | Образ и тег | | `TRAEFIK_IMAGE_TAG`, `TRAEFIK_HTTP_PORT`, `TRAEFIK_HTTPS_PORT` | Версия Traefik и порты | | `AUTH_REQUIRED`, `AUTH_JWT_SECRET`, `AUTH_ISSUER`, `AUTH_PORTAL_URL` | SSO через auth-portal | +| `CENSORCHECK_INGEST_SECRET` | HMAC-секрет одноразовых токенов `GET /cc` (fallback: `AUTH_JWT_SECRET`) | +| `CENSORCHECK_PUBLIC_URL` | URL в скрипте launcher, обычно `https://vt.example.com` | Для SSO с auth-portal: @@ -136,6 +140,9 @@ docker compose logs -f --tail=100 curl -fsS https://vps.example.com/health # {"ok":true} +curl -fsS https://vt.example.com/cc | head +# bash launcher (Cache-Control: no-store) + echo | openssl s_client -connect vps.example.com:443 -servername vps.example.com 2>/dev/null \ | openssl x509 -noout -issuer -dates -subject diff --git a/packages/db/src/repositories/censorcheck.ts b/packages/db/src/repositories/censorcheck.ts new file mode 100644 index 0000000..2463e7b --- /dev/null +++ b/packages/db/src/repositories/censorcheck.ts @@ -0,0 +1,341 @@ +import { and, desc, eq, isNotNull, isNull, like, or, sql } from 'drizzle-orm' +import type { + CensorcheckCategory, + CensorcheckRunStatus, + CensorcheckStatus, + CensorcheckSummary, +} from '@cfdm/shared/contracts/censorcheck' +import { getDb, getSqlite, schema } from '../index.js' +import { getCurrentSpaceId } from '../space-context.js' +import { generateId } from './utils.js' +import { vpsRepository } from './vps.js' + +type RunRow = typeof schema.censorcheckRuns.$inferSelect +type ResultRow = typeof schema.censorcheckResults.$inferSelect + +export type CensorcheckResultDto = { + id: string + runId: string + serviceKey: string + serviceLabel: string + category: CensorcheckCategory + status: CensorcheckStatus + httpStatus: number | null + detail: string | null + rawJson: string | null +} + +export type CensorcheckVpsInfo = { + id: string + ip: string + dns: string + providerId: string + providerName: string + country: string + city: string + datacenter: string + vcpu: number + ramGb: number + diskGb: number +} + +export type CensorcheckRunDto = { + id: string + spaceId: string + runId: string + probePublicIp: string + claimedPublicIp: string | null + matchedVpsId: string | null + status: CensorcheckRunStatus + schemaVersion: number + launcherVersion: string | null + censorcheckVersion: string | null + summary: CensorcheckSummary + createdAt: string + completedAt: string + observedSourceIp: string | null + vps: CensorcheckVpsInfo | null + results?: CensorcheckResultDto[] +} + +export type CensorcheckInsertResult = { + serviceKey: string + serviceLabel: string + category: CensorcheckCategory + status: CensorcheckStatus + httpStatus: number | null + detail: string | null + rawJson: string | null +} + +export type CensorcheckInsertRun = { + spaceId: string + runId: string + probePublicIp: string + claimedPublicIp: string | null + matchedVpsId: string | null + status: CensorcheckRunStatus + schemaVersion: number + launcherVersion: string | null + censorcheckVersion: string | null + summary: CensorcheckSummary + observedSourceIp: string | null + results: CensorcheckInsertResult[] +} + +export type CensorcheckHistoryQuery = { + cursor?: string + limit?: number + q?: string + status?: string + matched?: boolean +} + +function parseSummary(raw: string | null | undefined): CensorcheckSummary { + try { + const parsed = raw ? (JSON.parse(raw) as Partial) : {} + return { + total: Number(parsed.total) || 0, + available: Number(parsed.available) || 0, + redirected: Number(parsed.redirected) || 0, + denied: Number(parsed.denied) || 0, + blocked: Number(parsed.blocked) || 0, + timeout: Number(parsed.timeout) || 0, + error: Number(parsed.error) || 0, + } + } catch { + return { + total: 0, + available: 0, + redirected: 0, + denied: 0, + blocked: 0, + timeout: 0, + error: 0, + } + } +} + +function toResultDto(row: ResultRow): CensorcheckResultDto { + return { + id: row.id, + runId: row.runId, + serviceKey: row.serviceKey, + serviceLabel: row.serviceLabel, + category: row.category as CensorcheckCategory, + status: row.status as CensorcheckStatus, + httpStatus: row.httpStatus ?? null, + detail: row.detail ?? null, + rawJson: row.rawJson ?? null, + } +} + +function providerNameById(providerId: string): string { + if (!providerId) return '' + const row = getDb() + .select({ name: schema.providers.name }) + .from(schema.providers) + .where(eq(schema.providers.id, providerId)) + .get() + return row?.name ?? '' +} + +function hydrateVps(matchedVpsId: string | null): CensorcheckVpsInfo | null { + if (!matchedVpsId) return null + const vps = vpsRepository.getAnySpace(matchedVpsId) + if (!vps) return null + return { + id: vps.id, + ip: vps.ip ?? '', + dns: vps.dns ?? '', + providerId: vps.providerId ?? '', + providerName: providerNameById(vps.providerId ?? ''), + country: vps.country ?? '', + city: vps.city ?? '', + datacenter: vps.datacenter ?? '', + vcpu: Number(vps.vcpu) || 0, + ramGb: Number(vps.ramGb) || 0, + diskGb: Number(vps.diskGb) || 0, + } +} + +function toRunDto(row: RunRow, includeResults = false): CensorcheckRunDto { + const dto: CensorcheckRunDto = { + id: row.id, + spaceId: row.spaceId, + runId: row.runId, + probePublicIp: row.probePublicIp, + claimedPublicIp: row.claimedPublicIp ?? null, + matchedVpsId: row.matchedVpsId ?? null, + status: row.status as CensorcheckRunStatus, + schemaVersion: row.schemaVersion, + launcherVersion: row.launcherVersion ?? null, + censorcheckVersion: row.censorcheckVersion ?? null, + summary: parseSummary(row.summaryJson), + createdAt: row.createdAt, + completedAt: row.completedAt, + observedSourceIp: row.observedSourceIp ?? null, + vps: hydrateVps(row.matchedVpsId ?? null), + } + if (includeResults) { + dto.results = listResults(row.id) + } + return dto +} + +function listResults(internalRunId: string): CensorcheckResultDto[] { + return getDb() + .select() + .from(schema.censorcheckResults) + .where(eq(schema.censorcheckResults.runId, internalRunId)) + .all() + .map(toResultDto) +} + +function encodeCursor(createdAt: string, id: string): string { + return Buffer.from(`${createdAt}|${id}`, 'utf8').toString('base64url') +} + +function decodeCursor(cursor: string): { createdAt: string; id: string } | null { + try { + const raw = Buffer.from(cursor, 'base64url').toString('utf8') + const idx = raw.indexOf('|') + if (idx <= 0) return null + return { createdAt: raw.slice(0, idx), id: raw.slice(idx + 1) } + } catch { + return null + } +} + +export const censorcheckRepository = { + getByClientRunId(runId: string): CensorcheckRunDto | undefined { + const row = getDb() + .select() + .from(schema.censorcheckRuns) + .where(eq(schema.censorcheckRuns.runId, runId)) + .get() + return row ? toRunDto(row, true) : undefined + }, + + getById(id: string): CensorcheckRunDto | undefined { + const spaceId = getCurrentSpaceId() + const row = getDb() + .select() + .from(schema.censorcheckRuns) + .where(and(eq(schema.censorcheckRuns.id, id), eq(schema.censorcheckRuns.spaceId, spaceId))) + .get() + return row ? toRunDto(row, true) : undefined + }, + + create(input: CensorcheckInsertRun): CensorcheckRunDto { + const db = getDb() + const now = new Date().toISOString() + const id = generateId('ccrun') + db.transaction(() => { + db.insert(schema.censorcheckRuns) + .values({ + id, + spaceId: input.spaceId, + runId: input.runId, + probePublicIp: input.probePublicIp, + claimedPublicIp: input.claimedPublicIp, + matchedVpsId: input.matchedVpsId, + status: input.status, + schemaVersion: input.schemaVersion, + launcherVersion: input.launcherVersion, + censorcheckVersion: input.censorcheckVersion, + summaryJson: JSON.stringify(input.summary), + createdAt: now, + completedAt: now, + observedSourceIp: input.observedSourceIp, + }) + .run() + for (const result of input.results) { + db.insert(schema.censorcheckResults) + .values({ + id: generateId('ccres'), + runId: id, + serviceKey: result.serviceKey, + serviceLabel: result.serviceLabel, + category: result.category, + status: result.status, + httpStatus: result.httpStatus, + detail: result.detail, + rawJson: result.rawJson, + }) + .run() + } + }) + return this.getByClientRunId(input.runId)! + }, + + listCurrent(): CensorcheckRunDto[] { + const spaceId = getCurrentSpaceId() + const sqlite = getSqlite() + const rows = sqlite + .prepare( + `SELECT * FROM censorcheck_runs r + WHERE r.spaceId = ? + AND r.id = ( + SELECT r2.id FROM censorcheck_runs r2 + WHERE r2.spaceId = r.spaceId AND r2.probePublicIp = r.probePublicIp + ORDER BY r2.createdAt DESC, r2.id DESC + LIMIT 1 + ) + ORDER BY r.createdAt DESC`, + ) + .all(spaceId) as RunRow[] + return rows.map((row) => toRunDto(row, true)) + }, + + listHistory(query: CensorcheckHistoryQuery = {}): { + items: CensorcheckRunDto[] + nextCursor: string | null + } { + const spaceId = getCurrentSpaceId() + const limit = Math.min(Math.max(query.limit ?? 50, 1), 200) + const q = query.q?.trim() + const clauses = [eq(schema.censorcheckRuns.spaceId, spaceId)] + + if (q) { + const pattern = `%${q}%` + clauses.push( + or( + like(schema.censorcheckRuns.probePublicIp, pattern), + like(schema.censorcheckRuns.claimedPublicIp, pattern), + like(schema.censorcheckRuns.runId, pattern), + )!, + ) + } + if (query.status) { + clauses.push(eq(schema.censorcheckRuns.status, query.status)) + } + if (query.matched === true) { + clauses.push(isNotNull(schema.censorcheckRuns.matchedVpsId)) + } else if (query.matched === false) { + clauses.push(isNull(schema.censorcheckRuns.matchedVpsId)) + } + + const cursor = query.cursor ? decodeCursor(query.cursor) : null + if (cursor) { + clauses.push( + sql`(${schema.censorcheckRuns.createdAt} < ${cursor.createdAt} OR (${schema.censorcheckRuns.createdAt} = ${cursor.createdAt} AND ${schema.censorcheckRuns.id} < ${cursor.id}))`, + ) + } + + const rows = getDb() + .select() + .from(schema.censorcheckRuns) + .where(and(...clauses)) + .orderBy(desc(schema.censorcheckRuns.createdAt), desc(schema.censorcheckRuns.id)) + .limit(limit + 1) + .all() + + const page = rows.slice(0, limit) + const last = page[page.length - 1] + return { + items: page.map((row) => toRunDto(row, false)), + nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null, + } + }, +} diff --git a/packages/db/src/repositories/ip-match.test.ts b/packages/db/src/repositories/ip-match.test.ts new file mode 100644 index 0000000..7f5ed78 --- /dev/null +++ b/packages/db/src/repositories/ip-match.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { + collectVpsIps, + findVpsIdByIps, + isPrivateOrLoopbackIp, + normalizeIp, +} from './ip-match.js' + +describe('ip-match', () => { + it('собирает ipv4, ipv6 и additionalIps', () => { + expect( + collectVpsIps({ + ip: '203.0.113.10', + ipv6: '2001:db8::1', + additionalIps: ['198.51.100.2'], + }), + ).toEqual(['203.0.113.10', '2001:db8::1', '198.51.100.2']) + }) + + it('матчит ровно один VPS по IPv6', () => { + const all = [ + { id: 'a', ip: '203.0.113.1', ipv6: '2001:db8::10', additionalIps: [] }, + { id: 'b', ip: '203.0.113.2', ipv6: '2001:db8::20', additionalIps: [] }, + ] + expect(findVpsIdByIps(all, ['2001:DB8::10'])).toBe('a') + }) + + it('не матчит при двух совпадениях', () => { + const all = [ + { id: 'a', ip: '203.0.113.10', additionalIps: [] }, + { id: 'b', ip: '', additionalIps: ['203.0.113.10'] }, + ] + expect(findVpsIdByIps(all, ['203.0.113.10'])).toBeNull() + }) + + it('считает loopback и RFC1918 приватными', () => { + expect(isPrivateOrLoopbackIp('127.0.0.1')).toBe(true) + expect(isPrivateOrLoopbackIp('10.1.2.3')).toBe(true) + expect(isPrivateOrLoopbackIp('192.168.0.1')).toBe(true) + expect(isPrivateOrLoopbackIp('::1')).toBe(true) + expect(isPrivateOrLoopbackIp('203.0.113.10')).toBe(false) + expect(normalizeIp(' 203.0.113.10 ')).toBe('203.0.113.10') + }) +}) diff --git a/packages/db/src/repositories/ip-match.ts b/packages/db/src/repositories/ip-match.ts new file mode 100644 index 0000000..702b1bd --- /dev/null +++ b/packages/db/src/repositories/ip-match.ts @@ -0,0 +1,80 @@ +export function normalizeIp(ip: string): string { + return ip.trim().toLowerCase().split('%')[0] ?? '' +} + +export function isIpLiteral(value: string): boolean { + const v = value.trim() + if (!v) return false + if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(v)) { + return v.split('.').every((p) => { + const n = Number(p) + return Number.isInteger(n) && n >= 0 && n <= 255 + }) + } + return v.includes(':') && !v.includes(' ') +} + +type VpsIpFields = { + id: string + ip?: string | null + ipv6?: string | null + additionalIps?: string[] +} + +export function collectVpsIps(vps: { + ip?: string | null + ipv6?: string | null + additionalIps?: string[] +}): string[] { + const ips: string[] = [] + if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip)) + if (vps.ipv6?.trim()) ips.push(normalizeIp(vps.ipv6)) + for (const raw of vps.additionalIps ?? []) { + if (raw?.trim()) ips.push(normalizeIp(raw)) + } + return ips +} + +export function findVpsIdByIps(allVps: T[], ips: string[]): string | null { + const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))] + if (normalized.length === 0) return null + + const matches: string[] = [] + for (const v of allVps) { + const vips = collectVpsIps(v) + if (normalized.some((ip) => vips.includes(ip))) { + matches.push(v.id) + } + } + if (matches.length === 1) return matches[0]! + return null +} + +function ipv4Octets(ip: string): number[] | null { + const parts = ip.split('.') + if (parts.length !== 4) return null + const nums = parts.map((p) => Number(p)) + if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null + return nums +} + +export function isPrivateOrLoopbackIp(ip: string): boolean { + const value = normalizeIp(ip) + if (!value) return true + const octets = ipv4Octets(value) + if (octets) { + const [a, b] = octets + if (a === 0 || a === 10 || a === 127) return true + if (a === 169 && b === 254) return true + if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true + if (a === 192 && b === 168) return true + return false + } + if (value.includes(':')) { + if (value === '::' || value === '::1') return true + if (value.startsWith('fe80:')) return true + if (value.startsWith('fc') || value.startsWith('fd')) return true + return false + } + return true +} diff --git a/packages/db/src/repositories/spaces.ts b/packages/db/src/repositories/spaces.ts index 68b1d4b..1b81428 100644 --- a/packages/db/src/repositories/spaces.ts +++ b/packages/db/src/repositories/spaces.ts @@ -25,6 +25,8 @@ const ROLE_RANK: Record = { /** Tables with spaceId column — purge order (children first). */ const SPACE_DATA_TABLES = [ 'vps_grants', + 'censorcheck_results', + 'censorcheck_runs', 'notification_log', 'notification_state', 'vps_health_checks', diff --git a/packages/db/src/repositories/vps-domains.ts b/packages/db/src/repositories/vps-domains.ts index c16b9f7..6ec9e71 100644 --- a/packages/db/src/repositories/vps-domains.ts +++ b/packages/db/src/repositories/vps-domains.ts @@ -4,62 +4,17 @@ import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfd import { getDb, schema } from '../index.js' import { getCurrentSpaceId } from '../space-context.js' import { generateId } from './utils.js' +import { findVpsIdByIps, isIpLiteral } from './ip-match.js' import { vpsRepository } from './vps.js' type Row = typeof schema.vpsDomains.$inferSelect export type VpsDomainDto = Row -function normalizeIp(ip: string): string { - return ip.trim().toLowerCase() -} - function normalizeHost(host: string): string { return host.trim().toLowerCase().replace(/\.+$/, '') } -function isIpLiteral(value: string): boolean { - const v = value.trim() - if (!v) return false - if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(v)) { - return v.split('.').every((p) => { - const n = Number(p) - return Number.isInteger(n) && n >= 0 && n <= 255 - }) - } - // грубый IPv6 — отсекает hostname вроде ihome.rkns.top - return v.includes(':') && !v.includes(' ') -} - -function collectVpsIps(vps: { ip?: string | null; additionalIps?: string[] }): string[] { - const ips: string[] = [] - if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip)) - for (const raw of vps.additionalIps ?? []) { - if (raw?.trim()) ips.push(normalizeIp(raw)) - } - return ips -} - -function findVpsIdByIps( - allVps: ReturnType, - ips: string[], -): string | null { - const normalized = [ - ...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip))), - ] - if (normalized.length === 0) return null - - const matches: string[] = [] - for (const v of allVps) { - const vips = collectVpsIps(v) - if (normalized.some((ip) => vips.includes(ip))) { - matches.push(v.id) - } - } - if (matches.length === 1) return matches[0]! - return null -} - /** Точное совпадение hostname с полем VPS.dns. */ function findVpsIdByDns( allVps: ReturnType, diff --git a/packages/db/src/repositories/vps.ts b/packages/db/src/repositories/vps.ts index f4ffc79..49577d7 100644 --- a/packages/db/src/repositories/vps.ts +++ b/packages/db/src/repositories/vps.ts @@ -157,6 +157,11 @@ export const vpsRepository = { return rows.map((r) => toDto(r)!) as VpsDto[] }, + listAllSpaces(): VpsDto[] { + const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all() + return rows.map((r) => toDto(r)!) as VpsDto[] + }, + get(id: string): VpsDto | undefined { const row = getDb() .select() diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index a7f912c..cdea61a 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -283,6 +283,38 @@ const CORE_TABLE_MIGRATIONS: string[] = [ createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL )`, + `CREATE TABLE IF NOT EXISTS censorcheck_runs ( + id TEXT PRIMARY KEY, + spaceId TEXT NOT NULL DEFAULT 'space-main' REFERENCES spaces(id), + runId TEXT NOT NULL UNIQUE, + probePublicIp TEXT NOT NULL, + claimedPublicIp TEXT, + matchedVpsId TEXT REFERENCES vps(id) ON DELETE SET NULL, + status TEXT NOT NULL, + schemaVersion INTEGER NOT NULL DEFAULT 1, + launcherVersion TEXT, + censorcheckVersion TEXT, + summaryJson TEXT NOT NULL DEFAULT '{}', + createdAt TEXT NOT NULL, + completedAt TEXT NOT NULL, + observedSourceIp TEXT + )`, + `CREATE TABLE IF NOT EXISTS censorcheck_results ( + id TEXT PRIMARY KEY, + runId TEXT NOT NULL REFERENCES censorcheck_runs(id) ON DELETE CASCADE, + serviceKey TEXT NOT NULL, + serviceLabel TEXT NOT NULL, + category TEXT NOT NULL, + status TEXT NOT NULL, + httpStatus INTEGER, + detail TEXT, + rawJson TEXT + )`, + `CREATE INDEX IF NOT EXISTS censorcheck_runs_probe_created ON censorcheck_runs(probePublicIp, createdAt)`, + `CREATE INDEX IF NOT EXISTS censorcheck_runs_matched_created ON censorcheck_runs(matchedVpsId, createdAt)`, + `CREATE INDEX IF NOT EXISTS censorcheck_runs_created ON censorcheck_runs(createdAt)`, + `CREATE INDEX IF NOT EXISTS censorcheck_results_runId ON censorcheck_results(runId)`, + `CREATE INDEX IF NOT EXISTS censorcheck_results_service_status ON censorcheck_results(serviceKey, status)`, ] /** Additive columns for DBs created before spaces / notifications / etc. */ diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 7064ab3..4500fae 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,4 +1,4 @@ -import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core' +import { sqliteTable, text, integer, real, uniqueIndex, index } from 'drizzle-orm/sqlite-core' import { sql } from 'drizzle-orm' export const spaces = sqliteTable('spaces', { @@ -364,4 +364,54 @@ export const topologyDiagrams = sqliteTable('topology_diagrams', { updatedAt: text('updatedAt').notNull(), }) +export const censorcheckRuns = sqliteTable( + 'censorcheck_runs', + { + id: text('id').primaryKey(), + spaceId: text('spaceId') + .notNull() + .default('space-main') + .references(() => spaces.id), + runId: text('runId').notNull(), + probePublicIp: text('probePublicIp').notNull(), + claimedPublicIp: text('claimedPublicIp'), + matchedVpsId: text('matchedVpsId').references(() => vps.id, { onDelete: 'set null' }), + status: text('status').notNull(), + schemaVersion: integer('schemaVersion').notNull().default(1), + launcherVersion: text('launcherVersion'), + censorcheckVersion: text('censorcheckVersion'), + summaryJson: text('summaryJson').notNull().default('{}'), + createdAt: text('createdAt').notNull(), + completedAt: text('completedAt').notNull(), + observedSourceIp: text('observedSourceIp'), + }, + (t) => ({ + runIdUniq: uniqueIndex('censorcheck_runs_runId').on(t.runId), + probeCreated: index('censorcheck_runs_probe_created').on(t.probePublicIp, t.createdAt), + matchedCreated: index('censorcheck_runs_matched_created').on(t.matchedVpsId, t.createdAt), + created: index('censorcheck_runs_created').on(t.createdAt), + }), +) + +export const censorcheckResults = sqliteTable( + 'censorcheck_results', + { + id: text('id').primaryKey(), + runId: text('runId') + .notNull() + .references(() => censorcheckRuns.id, { onDelete: 'cascade' }), + serviceKey: text('serviceKey').notNull(), + serviceLabel: text('serviceLabel').notNull(), + category: text('category').notNull(), + status: text('status').notNull(), + httpStatus: integer('httpStatus'), + detail: text('detail'), + rawJson: text('rawJson'), + }, + (t) => ({ + runIdx: index('censorcheck_results_runId').on(t.runId), + serviceStatus: index('censorcheck_results_service_status').on(t.serviceKey, t.status), + }), +) + export const now = sql`(datetime('now'))` diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index 5fae67c..f3f7fdf 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -284,6 +284,35 @@ CREATE TABLE IF NOT EXISTS vps_health_checks ( latencyMs INTEGER, error TEXT ); + +CREATE TABLE IF NOT EXISTS censorcheck_runs ( + id TEXT PRIMARY KEY, + spaceId TEXT NOT NULL DEFAULT 'space-main', + runId TEXT NOT NULL UNIQUE, + probePublicIp TEXT NOT NULL, + claimedPublicIp TEXT, + matchedVpsId TEXT, + status TEXT NOT NULL, + schemaVersion INTEGER NOT NULL DEFAULT 1, + launcherVersion TEXT, + censorcheckVersion TEXT, + summaryJson TEXT NOT NULL DEFAULT '{}', + createdAt TEXT NOT NULL, + completedAt TEXT NOT NULL, + observedSourceIp TEXT +); + +CREATE TABLE IF NOT EXISTS censorcheck_results ( + id TEXT PRIMARY KEY, + runId TEXT NOT NULL, + serviceKey TEXT NOT NULL, + serviceLabel TEXT NOT NULL, + category TEXT NOT NULL, + status TEXT NOT NULL, + httpStatus INTEGER, + detail TEXT, + rawJson TEXT +); ` export function resetTestDb(): void { diff --git a/packages/shared/src/contracts/censorcheck.ts b/packages/shared/src/contracts/censorcheck.ts new file mode 100644 index 0000000..cc71480 --- /dev/null +++ b/packages/shared/src/contracts/censorcheck.ts @@ -0,0 +1,114 @@ +import { z } from 'zod' + +export const CENSORCHECK_STATUSES = [ + 'available', + 'redirected', + 'denied', + 'blocked', + 'timeout', + 'error', +] as const + +export type CensorcheckStatus = (typeof CENSORCHECK_STATUSES)[number] + +export const CENSORCHECK_CATEGORIES = ['dpi', 'geoblock', 'custom'] as const + +export type CensorcheckCategory = (typeof CENSORCHECK_CATEGORIES)[number] + +export const CENSORCHECK_RUN_STATUSES = ['complete', 'partial'] as const + +export type CensorcheckRunStatus = (typeof CENSORCHECK_RUN_STATUSES)[number] + +/** DPI-сервисы из vernette/censorcheck (pin 12c5839). */ +export const CENSORCHECK_DPI_HOSTS = [ + 'youtube.com', + 'redirector.googlevideo.com', + 'discord.com', + 'instagram.com', + 'facebook.com', + 'x.com', + 'linkedin.com', + 'rutracker.org', + 'digitalocean.com', + 'amnezia.org', + 'getoutline.org', + 'mailfence.com', + 'flibusta.is', + 'rezka.ag', + 'api.telegram.org', + 'play.google.com', +] as const + +/** Геоблок-сервисы из vernette/censorcheck (pin 12c5839). */ +export const CENSORCHECK_GEOBLOCK_HOSTS = [ + 'spotify.com', + 'netflix.com', + 'patreon.com', + 'swagger.io', + 'snyk.io', + 'mongodb.com', + 'autodesk.com', + 'graylog.org', + 'redis.io', + 'copilot.microsoft.com', +] as const + +const DPI_SET = new Set(CENSORCHECK_DPI_HOSTS) +const GEO_SET = new Set(CENSORCHECK_GEOBLOCK_HOSTS) + +export function inferCensorcheckCategory(serviceKey: string): CensorcheckCategory { + const key = serviceKey.trim().toLowerCase() + if (DPI_SET.has(key)) return 'dpi' + if (GEO_SET.has(key)) return 'geoblock' + return 'custom' +} + +export const censorcheckStatusSchema = z.enum(CENSORCHECK_STATUSES) +export const censorcheckCategorySchema = z.enum(CENSORCHECK_CATEGORIES) + +export const censorcheckIngestResultSchema = z.object({ + service: z.string().trim().min(1).max(253), + category: censorcheckCategorySchema.optional(), + raw: z.record(z.unknown()).default({}), +}) + +export const censorcheckIngestBodySchema = z.object({ + schemaVersion: z.literal(1), + runId: z.string().trim().min(8).max(80), + probe: z.object({ + publicIp: z.string().trim().min(1).max(64), + }), + censorcheck: z + .object({ + version: z.string().trim().max(32).optional(), + mode: z.string().trim().max(32).optional(), + }) + .optional(), + launcherVersion: z.string().trim().max(32).optional(), + results: z.array(censorcheckIngestResultSchema).min(1).max(200), +}) + +export type CensorcheckIngestBody = z.infer +export type CensorcheckIngestResult = z.infer + +export type CensorcheckSummary = { + total: number + available: number + redirected: number + denied: number + blocked: number + timeout: number + error: number +} + +export function emptyCensorcheckSummary(): CensorcheckSummary { + return { + total: 0, + available: 0, + redirected: 0, + denied: 0, + blocked: 0, + timeout: 0, + error: 0, + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34ba4bc..0ed2fff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: '@fastify/jwt': specifier: ^10.2.0 version: 10.2.0 + '@fastify/rate-limit': + specifier: ^11.2.0 + version: 11.2.0 '@fastify/sensible': specifier: ^6.0.3 version: 6.0.4 @@ -945,6 +948,9 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@fastify/rate-limit@11.2.0': + resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==} + '@fastify/send@4.1.0': resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==} @@ -2554,6 +2560,10 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + ip-address@10.5.0: + resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -4069,6 +4079,13 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@fastify/rate-limit@11.2.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 6.0.0 + ip-address: 10.5.0 + toad-cache: 3.7.1 + '@fastify/send@4.1.0': dependencies: '@lukeed/ms': 2.0.2 @@ -5678,6 +5695,8 @@ snapshots: internmap@2.0.3: {} + ip-address@10.5.0: {} + ipaddr.js@1.9.1: {} ipaddr.js@2.4.0: {}