From 235ae034890034129d813e6d387e76d6fa091c91 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 25 Aug 2026 15:16:17 +0700 Subject: [PATCH] =?UTF-8?q?feat(geo):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20IP-?= =?UTF-8?q?=D1=80=D0=B5=D0=B3=D0=B8=D0=BE=D0=BD=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .gitattributes | 1 + AGENTS.md | 9 + apps/api/scripts/ipregion/ipregion.sh | 2110 +++++++++++++++++ apps/api/scripts/ipregion/launcher.sh | 356 +++ apps/api/src/index.ts | 2 + apps/api/src/lib/permissions.test.ts | 1 + apps/api/src/lib/permissions.ts | 3 +- apps/api/src/plugins/auth.ts | 2 + apps/api/src/routes/ipregion.test.ts | 143 ++ apps/api/src/routes/ipregion.ts | 129 + apps/api/src/routes/launcher.test.ts | 44 + apps/api/src/routes/launcher.ts | 67 +- apps/api/src/services/backup-full.ts | 2 + .../src/services/ipregion/normalize.test.ts | 66 + apps/api/src/services/ipregion/normalize.ts | 52 + .../censorcheck/blocking-snapshots.ts | 28 +- apps/web/src/components/global-search.tsx | 5 + .../ipregion/country-matrix-cell.tsx | 85 + .../components/ipregion/geo-filters.test.ts | 159 ++ .../src/components/ipregion/geo-filters.ts | 278 +++ apps/web/src/components/ipregion/geo-grid.tsx | 192 ++ apps/web/src/components/ipregion/geo-page.tsx | 301 +++ .../src/components/ipregion/geo-run-sheet.tsx | 133 ++ apps/web/src/components/ipregion/types.ts | 89 + apps/web/src/components/layout/app-shell.tsx | 2 + .../reui-kit/data-grid-kit-defaults.test.ts | 8 + apps/web/src/lib/api-client.ts | 31 + apps/web/src/lib/auth.ts | 1 + apps/web/src/queries/ipregion.ts | 40 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/_auth/geo.tsx | 10 + packages/db/src/repositories/ipregion.ts | 340 +++ packages/db/src/repositories/spaces.ts | 2 + packages/db/src/runtime-migrate.ts | 32 + packages/db/src/schema/index.ts | 50 + packages/db/src/test-setup.ts | 29 + packages/shared/src/contracts/ipregion.ts | 149 ++ 37 files changed, 4938 insertions(+), 34 deletions(-) create mode 100644 apps/api/scripts/ipregion/ipregion.sh create mode 100644 apps/api/scripts/ipregion/launcher.sh create mode 100644 apps/api/src/routes/ipregion.test.ts create mode 100644 apps/api/src/routes/ipregion.ts create mode 100644 apps/api/src/services/ipregion/normalize.test.ts create mode 100644 apps/api/src/services/ipregion/normalize.ts create mode 100644 apps/web/src/components/ipregion/country-matrix-cell.tsx create mode 100644 apps/web/src/components/ipregion/geo-filters.test.ts create mode 100644 apps/web/src/components/ipregion/geo-filters.ts create mode 100644 apps/web/src/components/ipregion/geo-grid.tsx create mode 100644 apps/web/src/components/ipregion/geo-page.tsx create mode 100644 apps/web/src/components/ipregion/geo-run-sheet.tsx create mode 100644 apps/web/src/components/ipregion/types.ts create mode 100644 apps/web/src/queries/ipregion.ts create mode 100644 apps/web/src/routes/_auth/geo.tsx create mode 100644 packages/db/src/repositories/ipregion.ts create mode 100644 packages/shared/src/contracts/ipregion.ts diff --git a/.gitattributes b/.gitattributes index 65f38a0..22a3a25 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ apps/api/scripts/censorcheck/*.sh text eol=lf +apps/api/scripts/ipregion/*.sh text eol=lf diff --git a/AGENTS.md b/AGENTS.md index 7589018..d23ed89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,6 +100,15 @@ vps-tracker/ - **UI:** `/blocking` — текущие прогоны и история, группировка VPS / сервис - Env: `CENSORCHECK_INGEST_SECRET`, `CENSORCHECK_PUBLIC_URL`, `VPS_LAUNCHER_DOMAIN` +## GeoIP (ipregion) + +Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/ic | bash`. + +- **Vendor:** `apps/api/scripts/ipregion/ipregion.sh` (pin SHA `7d1c25c`, MIT, [vernette/ipregion](https://github.com/vernette/ipregion)) +- **Launcher:** `GET /ic` минтит HMAC ingest-токен (тот же `CENSORCHECK_INGEST_SECRET`); `GET /ic/vendor` — pinned скрипт (LF) +- **Ingest:** `POST /api/integrations/ipregion/runs` (без portal JWT) +- **UI:** `/geo` — матрица ISO-стран VPS × сервисы (primary / custom / cdn) + ## Команды ```bash diff --git a/apps/api/scripts/ipregion/ipregion.sh b/apps/api/scripts/ipregion/ipregion.sh new file mode 100644 index 0000000..cd27092 --- /dev/null +++ b/apps/api/scripts/ipregion/ipregion.sh @@ -0,0 +1,2110 @@ +#!/usr/bin/env bash + +SCRIPT_NAME="ipregion.sh" +SCRIPT_URL="https://github.com/vernette/ipregion" +USER_AGENT="Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0" +SPINNER_SERVICE_FILE=$(mktemp "${TMPDIR:-/tmp}/ipregion_spinner_XXXXXX") +DEBUG_LOG_FILE="ipregion_debug_$(date +%Y%m%d_%H%M%S)_$$.log" + +SPOTIFY_API_KEY="142b583129b2df829de3656f9eb484e6" +SPOTIFY_CLIENT_ID="9a8d2f0ce77a4e248bb71fefcb557637" +NETFLIX_API_KEY="YXNkZmFzZGxmbnNkYWZoYXNkZmhrYWxm" +TWITCH_CLIENT_ID="kimne78kx3ncx6brgo4mv6wki5h1ko" +CHATGPT_STATSIG_API_KEY="client-zUdXdSTygXJdzoE0sWTkP8GKTVsUMF2IRM7ShVO2JAG" +REDDIT_BASIC_ACCESS_TOKEN="b2hYcG9xclpZdWIxa2c6" +YOUTUBE_SOCS_COOKIE="CAISNQgDEitib3FfaWRlbnRpdHlmcm9udGVuZHVpc2VydmVyXzIwMjUwNzMwLjA1X3AwGgJlbiACGgYIgPC_xAY" +DISNEY_PLUS_API_KEY="ZGlzbmV5JmFuZHJvaWQmMS4wLjA.bkeb0m230uUhv8qrAXuNu39tbE_mD5EEhM_NAcohjyA" +DISNEY_PLUS_JSON_BODY='{"query":"\n mutation registerDevice($registerDevice: RegisterDeviceInput!) {\n registerDevice(registerDevice: $registerDevice) {\n __typename\n }\n }\n ","variables":{"registerDevice":{"applicationRuntime":"android","attributes":{"operatingSystem":"Android","operatingSystemVersion":"13"},"deviceFamily":"android","deviceLanguage":"en","deviceProfile":"phone","devicePlatformId":"android"}},"operationName":"registerDevice"}' + +VERBOSE=false +JSON_OUTPUT=false +GROUPS_TO_SHOW="all" +CURL_TIMEOUT=5 +CURL_RETRIES=1 +IPV4_ONLY=false +IPV6_ONLY=false +PROXY_ADDR="" +INTERFACE_NAME="" +DEBUG=false + +RESULT_JSON="" +ARR_PRIMARY=() +ARR_CUSTOM=() +ARR_CDN=() + +COLOR_HEADER="1;36" +COLOR_SERVICE="1;32" +COLOR_HEART="1;31" +COLOR_URL="1;90" +COLOR_ASN="1;33" +COLOR_TABLE_HEADER="1;97" +COLOR_TABLE_VALUE="1" +COLOR_NULL="0;90" +COLOR_ERROR="1;31" +COLOR_WARN="1;33" +COLOR_INFO="1;36" +COLOR_RESET="0" + +LOG_INFO="INFO" +LOG_WARN="WARNING" +LOG_ERROR="ERROR" + +STATUS_NA="N/A" +STATUS_DENIED="Denied" +STATUS_RATE_LIMIT="Rate-limit" +STATUS_SERVER_ERROR="Server error" + +declare -A DEPENDENCIES=( + [jq]="jq" + [curl]="curl" + [column]="util-linux" + [nslookup]="bind-utils" +) + +declare -A PACKAGE_MAPPING=( + ["apt:nslookup"]="dnsutils" + ["apt:column"]="bsdmainutils" + ["pacman:nslookup"]="bind" + ["dnf:nslookup"]="bind-utils" + ["yum:nslookup"]="bind-utils" + ["termux:column"]="util-linux" +) + +declare -A PRIMARY_SERVICES=( + [MAXMIND]="maxmind.com|geoip.maxmind.com|/geoip/v2.1/city/me" + [RIPE]="rdap.db.ripe.net|rdap.db.ripe.net|/ip/{ip}" + [IPINFO_IO]="ipinfo.io|ipinfo.io|/widget/demo/{ip}" + [IPREGISTRY]="ipregistry.co|api.ipregistry.co|/{ip}?hostname=true&key=sb69ksjcajfs4c" + [IPAPI_CO]="ipapi.co|ipapi.co|/{ip}/json" + [CLOUDFLARE]="cloudflare.com|speed.cloudflare.com|/meta" + [IFCONFIG_CO]="ifconfig.co|ifconfig.co|/country-iso?ip={ip}|plain" + [IP2LOCATION_IO]="ip2location.io|api.ip2location.io|/?ip={ip}" + [IPLOCATION_COM]="iplocation.com|iplocation.com" + [COUNTRY_IS]="country.is|api.country.is|/{ip}" + [GEOAPIFY_COM]="geoapify.com|api.geoapify.com|/v1/ipinfo?&ip={ip}&apiKey=b8568cb9afc64fad861a69edbddb2658" + [GEOJS_IO]="geojs.io|get.geojs.io|/v1/ip/country.json?ip={ip}" + [IPAPI_IS]="ipapi.is|api.ipapi.is|/?q={ip}" + [IPBASE_COM]="ipbase.com|api.ipbase.com|/v2/info?ip={ip}" + [IPQUERY_IO]="ipquery.io|api.ipquery.io|/{ip}" + [IPWHO_IS]="ipwho.is|ipwho.is|/{ip}" + [IPAPI_COM]="ip-api.com|demo.ip-api.com|/json/{ip}?fields=countryCode" +) + +PRIMARY_SERVICES_ORDER=( + "MAXMIND" + "RIPE" + "IPINFO_IO" + "CLOUDFLARE" + "IPREGISTRY" + "IPAPI_CO" + "IFCONFIG_CO" + "IP2LOCATION_IO" + "IPLOCATION_COM" + "COUNTRY_IS" + "GEOAPIFY_COM" + "GEOJS_IO" + "IPAPI_IS" + "IPBASE_COM" + "IPQUERY_IO" + "IPWHO_IS" + "IPAPI_COM" +) + +declare -A PRIMARY_SERVICES_CUSTOM_HANDLERS=( + [IPLOCATION_COM]="lookup_iplocation_com" +) + +declare -A SERVICE_HEADERS=( + [IPREGISTRY]="Origin: https://ipregistry.co" + [MAXMIND]="Referer: https://www.maxmind.com" + [IPAPI_COM]="Origin: https://ip-api.com" + [CLOUDFLARE]="Referer: https://speed.cloudflare.com" +) + +declare -A CUSTOM_SERVICES=( + [GOOGLE]="Google" + [YOUTUBE]="YouTube" + [TWITCH]="Twitch" + [CHATGPT]="ChatGPT" + [NETFLIX]="Netflix" + [SPOTIFY]="Spotify" + [REDDIT]="Reddit" + [DISNEY_PLUS]="Disney+" + [GEMINI_SUPPORTED]="Gemini Supported" + [REDDIT_GUEST_ACCESS]="Reddit (Guest Access)" + [YOUTUBE_PREMIUM]="YouTube Premium" + [GOOGLE_SEARCH_CAPTCHA]="Google Search Captcha" + [SPOTIFY_SIGNUP]="Spotify Signup" + [DISNEY_PLUS_ACCESS]="Disney+ Access" + [APPLE]="Apple" + [STEAM]="Steam" + [TIKTOK]="Tiktok" + [OOKLA_SPEEDTEST]="Ookla Speedtest" + [JETBRAINS]="JetBrains" + [PLAYSTATION]="PlayStation" + [MICROSOFT]="Microsoft" +) + +CUSTOM_SERVICES_ORDER=( + "GOOGLE" + "YOUTUBE" + "TWITCH" + "CHATGPT" + "NETFLIX" + "SPOTIFY" + "REDDIT" + "DISNEY_PLUS" + "GEMINI_SUPPORTED" + "REDDIT_GUEST_ACCESS" + "YOUTUBE_PREMIUM" + "GOOGLE_SEARCH_CAPTCHA" + "SPOTIFY_SIGNUP" + "DISNEY_PLUS_ACCESS" + "APPLE" + "STEAM" + "TIKTOK" + "OOKLA_SPEEDTEST" + "JETBRAINS" + "PLAYSTATION" + "MICROSOFT" +) + +declare -A CUSTOM_SERVICES_HANDLERS=( + [GOOGLE]="lookup_google" + [YOUTUBE]="lookup_youtube" + [GEMINI_SUPPORTED]="lookup_gemini_supported" + [TWITCH]="lookup_twitch" + [CHATGPT]="lookup_chatgpt" + [NETFLIX]="lookup_netflix" + [SPOTIFY]="lookup_spotify" + [REDDIT]="lookup_reddit" + [DISNEY_PLUS]="lookup_disney_plus" + [REDDIT_GUEST_ACCESS]="lookup_reddit_guest_access" + [YOUTUBE_PREMIUM]="lookup_youtube_premium" + [GOOGLE_SEARCH_CAPTCHA]="lookup_google_search_captcha" + [SPOTIFY_SIGNUP]="lookup_spotify_signup" + [DISNEY_PLUS_ACCESS]="lookup_disney_plus_access" + [APPLE]="lookup_apple" + [STEAM]="lookup_steam" + [TIKTOK]="lookup_tiktok" + [CLOUDFLARE_CDN]="lookup_cloudflare_cdn" + [YOUTUBE_CDN]="lookup_youtube_cdn" + [NETFLIX_CDN]="lookup_netflix_cdn" + [OOKLA_SPEEDTEST]="lookup_ookla_speedtest" + [JETBRAINS]="lookup_jetbrains" + [PLAYSTATION]="lookup_playstation" + [MICROSOFT]="lookup_microsoft" +) + +declare -A CDN_SERVICES=( + [CLOUDFLARE_CDN]="Cloudflare CDN" + [YOUTUBE_CDN]="YouTube CDN" + [NETFLIX_CDN]="Netflix CDN" +) + +CDN_SERVICES_ORDER=( + "CLOUDFLARE_CDN" + "YOUTUBE_CDN" + "NETFLIX_CDN" +) + +declare -A SERVICE_GROUPS=( + [primary]="${PRIMARY_SERVICES_ORDER[*]}" + [custom]="${CUSTOM_SERVICES_ORDER[*]}" + [cdn]="${CDN_SERVICES_ORDER[*]}" +) + +EXCLUDED_SERVICES=( + # "IPINFO_IO" + # "IPREGISTRY" + # "IPAPI_CO" + "GOOGLE_SEARCH_CAPTCHA" +) + +IDENTITY_SERVICES=( + "ident.me" + "ifconfig.me" + "api64.ipify.org" + "ifconfig.co" + "ifconfig.me" +) + +IPV6_OVER_IPV4_SERVICES=( + "IPINFO_IO" + "IPAPI_IS" + "IPLOCATION_COM" + "IPWHO_IS" + "IPAPI_COM" +) + +color() { + local color_name="$1" + local text="$2" + local code + + case "$color_name" in + HEADER) code="$COLOR_HEADER" ;; + SERVICE) code="$COLOR_SERVICE" ;; + HEART) code="$COLOR_HEART" ;; + URL) code="$COLOR_URL" ;; + ASN) code="$COLOR_ASN" ;; + TABLE_HEADER) code="$COLOR_TABLE_HEADER" ;; + TABLE_VALUE) code="$COLOR_TABLE_VALUE" ;; + NULL) code="$COLOR_NULL" ;; + ERROR) code="$COLOR_ERROR" ;; + WARN) code="$COLOR_WARN" ;; + INFO) code="$COLOR_INFO" ;; + RESET) code="$COLOR_RESET" ;; + *) code="$color_name" ;; + esac + + printf "\033[%sm%s\033[0m" "$code" "$text" +} + +bold() { + local text="$1" + printf "\033[1m%s\033[0m" "$text" +} + +get_timestamp() { + local format="$1" + date +"$format" +} + +log() { + local log_level="$1" + local message="${*:2}" + local timestamp + + if [[ "$VERBOSE" == true ]]; then + local color_code + + timestamp=$(get_timestamp "%d.%m.%Y %H:%M:%S") + + case "$log_level" in + "$LOG_ERROR") color_code=ERROR ;; + "$LOG_WARN") color_code=WARN ;; + "$LOG_INFO") color_code=INFO ;; + *) color_code=RESET ;; + esac + + printf "[%s] [%s]: %s\n" "$timestamp" "$(color $color_code "$log_level")" "$message" >&2 + fi +} + +error_exit() { + local message="$1" + local exit_code="${2:-1}" + printf "%s %s\n" "$(color ERROR '[ERROR]')" "$(color TABLE_HEADER "$message")" >&2 + display_help + exit "$exit_code" +} + +display_help() { + cat <&1 4>&2 + + exec 1> >(tee -a "$DEBUG_LOG_FILE" >&3) + exec 2> >(tee -a "$DEBUG_LOG_FILE" >&4) + + set -x + return 0 +} + +grep_wrapper() { + local grep_args=() + + if [[ "$1" == "--perl" ]]; then + grep_args+=("-oP") + shift + fi + + grep "${grep_args[@]}" "$@" +} + +upload_debug() { + local ip_version=4 + local user_agent="ipregion-script/1.0 (github.com/vernette/ipregion)" + + curl_wrapper POST "https://0x0.st" \ + --user-agent "$user_agent" \ + --form "file=@$DEBUG_LOG_FILE" \ + --form "secret=" \ + --form "expires=24" \ + --ip-version "$ip_version" +} + +cleanup_debug() { + local debug_url + + if [[ ! -f "$DEBUG_LOG_FILE" ]]; then + return 1 + fi + + set +x + exec 1>&3 2>&4 3>&- 4>&- + + debug_url="$(upload_debug)" + + printf "\n%s\n %s\n %s\n\n%s\n%s\n\n%s\n" \ + "$(color WARN 'Debug information:')" \ + "Local file: $DEBUG_LOG_FILE" \ + "Remote URL: $debug_url" \ + "$(color INFO 'PRIVACY NOTICE: This file is uploaded to 0x0.st - a public file hoster.')" \ + "$(color INFO 'The file will be automatically deleted in 24 hours.')" \ + "$(color INFO 'If you open a GitHub Issue, please download the log and attach it')" +} + +is_command_available() { + local cmd="$1" + command -v "$cmd" >/dev/null 2>&1 +} + +detect_distro() { + if [[ -f /etc/os-release ]]; then + source /etc/os-release + distro="$ID" + elif [[ -f /etc/redhat-release ]]; then + distro="rhel" + elif [[ -d /data/data/com.termux ]]; then + distro="termux" + fi +} + +detect_package_manager() { + local pkg_manager + + case "$distro" in + ubuntu | debian | termux) + pkg_manager="apt" + ;; + arch | manjaro) + pkg_manager="pacman" + ;; + fedora) + pkg_manager="dnf" + ;; + centos | rhel) + if is_command_available "dnf"; then + pkg_manager="dnf" + else + pkg_manager="yum" + fi + ;; + opensuse*) + pkg_manager="zypper" + ;; + alpine) + pkg_manager="apk" + ;; + *) + error_exit "Unknown distro: $distro" + ;; + esac + + echo "$pkg_manager" +} + +get_missing_commands() { + local missing=() + + for cmd in "${!DEPENDENCIES[@]}"; do + if ! is_command_available "$cmd"; then + missing+=("$cmd") + fi + done + + printf '%s\n' "${missing[@]}" +} + +get_package_name() { + local pkg_manager="$1" + local command="$2" + local mapping_key="${pkg_manager}:${command}" + + if [[ -n "${PACKAGE_MAPPING[$mapping_key]}" ]]; then + echo "${PACKAGE_MAPPING[$mapping_key]}" + return + fi + + echo "${DEPENDENCIES[$command]:-$command}" +} + +is_sudo_required() { + if [[ "${EUID:-$(id -u)}" -eq 0 || "$distro" == "termux" ]]; then + return 1 + fi + + return 0 +} + +get_install_args() { + local pkg_manager="$1" + local install_args + + case "$pkg_manager" in + apt) + install_args=("install" "-y") + ;; + pacman) + install_args=("-Sy" "--noconfirm") + ;; + dnf | yum | zypper) + install_args=("install" "-y") + ;; + apk) + install_args=("add" "--no-cache") + ;; + esac + + echo "${install_args[@]}" +} + +install_packages() { + local pkg_manager="$1" + shift + local packages=("$@") + local cmd_prefix=() + local install_cmd=() + + if is_sudo_required; then + cmd_prefix=("sudo") + log "$LOG_INFO" "Running as non-root user, using sudo" + fi + + cmd_prefix+=("$pkg_manager") + + if [[ "$pkg_manager" == "apt" ]]; then + log "$LOG_INFO" "Updating package lists" + if ! "${cmd_prefix[@]}" update; then + error_exit "Error occurred while updating package lists" + fi + fi + + read -ra install_args <<<"$(get_install_args "$pkg_manager")" + install_cmd+=("${cmd_prefix[@]}" "${install_args[@]}" "${packages[@]}") + + log "$LOG_INFO" "Running: ${install_cmd[*]}" + + if ! "${install_cmd[@]}"; then + error_exit "Error occurred while installing packages" + fi +} + +prompt_for_installation() { + local missing=("$@") + local response + local formatted_deps="" + + for dep in "${missing[@]}"; do + formatted_deps+=" $dep\n" + done + + printf "\n%s\n%b\n%s " \ + "$(color WARN 'Missing dependencies:')" \ + "$formatted_deps" \ + "$(color INFO 'Do you want to install them? [y/N]:')" + + read -r response + response=${response,,} + + case "$response" in + y | yes) + return 0 + ;; + *) + return 1 + ;; + esac +} + +install_dependencies() { + local missing_dependencies=() + local missing_commands pkg_manager package_name + + log "$LOG_INFO" "Checking dependencies" + + mapfile -t missing_commands < <(get_missing_commands) + + if [[ "${missing_commands[*]}" =~ ^[[:space:]]*$ ]]; then + log "$LOG_INFO" "All dependencies are installed" + return 0 + fi + + log "$LOG_INFO" "Missing commands: ${missing_commands[*]}" + + pkg_manager=$(detect_package_manager) + + log "$LOG_INFO" "Detected package manager: $pkg_manager" + + for cmd in "${missing_commands[@]}"; do + package_name=$(get_package_name "$pkg_manager" "$cmd") + missing_dependencies+=("$package_name") + done + + log "$LOG_INFO" "Missing dependencies: ${missing_dependencies[*]}" + + if ! prompt_for_installation "${missing_dependencies[@]}" /dev/null 2>&1 <<<"$json" +} + +process_json() { + local json="$1" + local jq_filter="$2" + + if is_status_string "$json"; then + echo "$json" + return + fi + + jq -r "$jq_filter" <<<"$json" +} + +format_value() { + local value="$1" + + case "$value" in + "$STATUS_NA") + color NULL "$value" + ;; + "$STATUS_DENIED" | "$STATUS_SERVER_ERROR") + color ERROR "$value" + ;; + "$STATUS_RATE_LIMIT") + color WARN "$value" + ;; + *) + bold "$value" + ;; + esac +} + +print_value_or_colored() { + local value="$1" + local color_name="$2" + + if [[ "$JSON_OUTPUT" == true ]]; then + echo "$value" + return + fi + + color "$color_name" "$value" +} + +mask_ipv4() { + local ip="$1" + echo "${ip%.*.*}.*.*" +} + +mask_ipv6() { + local ip="$1" + echo "$ip" | awk -F: '{ + for(i=1;i<=NF;i++) if($i=="") $i="0"; + while(NF<8) for(i=1;i<=8;i++) if($i=="0"){NF++; break;} + printf "%s:%s:%s::\n", $1, $2, $3 + }' +} + +parse_arguments() { + while [[ $# -gt 0 ]]; do + case $1 in + -h | --help) + display_help + exit 0 + ;; + -v | --verbose) + VERBOSE=true + shift + ;; + -d | --debug) + DEBUG=true + shift + ;; + -j | --json) + JSON_OUTPUT=true + shift + ;; + -g | --group) + GROUPS_TO_SHOW="$2" + shift 2 + ;; + -t | --timeout) + if [[ "$2" =~ ^[0-9]+$ ]]; then + CURL_TIMEOUT="$2" + else + error_exit "Invalid timeout value: $2. Timeout must be a positive integer" + fi + shift 2 + ;; + -4 | --ipv4) + IPV4_ONLY=true + shift + ;; + -6 | --ipv6) + if ! check_ip_support 6; then + error_exit "IPv6 is not supported on this system" + fi + + IPV6_ONLY=true + shift + ;; + -p | --proxy) + PROXY_ADDR="$2" + log "$LOG_INFO" "Using SOCKS5 proxy: $PROXY_ADDR" + shift 2 + ;; + -i | --interface) + INTERFACE_NAME="$2" + log "$LOG_INFO" "Using interface: $INTERFACE_NAME" + shift 2 + ;; + *) + error_exit "Unknown option: $1" + ;; + esac + done +} + +is_status_string() { + local value="$1" + + case "$value" in + "$STATUS_DENIED" | "$STATUS_SERVER_ERROR" | "$STATUS_RATE_LIMIT" | "$STATUS_NA") + return 0 + ;; + *) + return 1 + ;; + esac +} + +status_from_http_code() { + local code="$1" + + case "$code" in + 403) + echo "$STATUS_DENIED" + ;; + 429) + echo "$STATUS_RATE_LIMIT" + ;; + 5*) + echo "$STATUS_SERVER_ERROR" + ;; + 4*) + echo "$STATUS_NA" + ;; + *) + echo "" + ;; + esac +} + +get_ping_command() { + local version="$1" + local ping_cmd + + if [[ "$version" == "4" ]]; then + if is_command_available "ping"; then + ping_cmd="ping" + fi + else + if is_command_available "ping6"; then + ping_cmd="ping6" + elif is_command_available "ping"; then + ping_cmd="ping -6" + fi + fi + + if [[ -n "$ping_cmd" ]]; then + echo "$ping_cmd" + return 0 + else + return 1 + fi +} + +check_ip_interfaces() { + local version="$1" + + log "$LOG_INFO" "Checking for IPv${version} interfaces" + + if [[ -n $(ip -"${version}" addr show scope global 2>/dev/null) ]]; then + log "$LOG_INFO" "IPv${version} global interfaces found" + return 0 + fi + + log "$LOG_ERROR" "No global IPv${version} addresses found on interfaces" + return 1 +} + +check_ip_connectivity() { + local version="$1" + local test_hosts_v4=("8.8.8.8" "8.8.4.4" "1.1.1.1" "1.0.0.1" "9.9.9.9") + local test_hosts_v6=("2001:4860:4860::8888" "2001:4860:4860::8844" "2606:4700:4700::1111" "2606:4700:4700::1001" "2620:fe::9") + local timeout=3 + local count=1 + local test_hosts ping_cmd + + log "$LOG_INFO" "Checking IPv${version} connectivity" + + ping_cmd=($(get_ping_command "$version")) + + if [[ ${#ping_cmd[@]} -eq 0 ]]; then + log "$LOG_ERROR" "Ping command for IPv${version} is not available" + return 1 + fi + + if [[ "$version" == "4" ]]; then + test_hosts=("${test_hosts_v4[@]}") + else + test_hosts=("${test_hosts_v6[@]}") + fi + + for host in "${test_hosts[@]}"; do + if "${ping_cmd[@]}" -c "$count" -W "$timeout" "$host" >/dev/null 2>&1; then + log "$LOG_INFO" "IPv${version} connectivity confirmed via $host" + return 0 + fi + done + + log "$LOG_ERROR" "IPv${version} connectivity test failed" + return 1 +} + +check_ip_dns() { + local version="$1" + local test_domain="google.com" + local record_type + + log "$LOG_INFO" "Checking IPv${version} DNS resolution" + + if [[ "$version" == "4" ]]; then + record_type="A" + else + record_type="AAAA" + fi + + if nslookup -type="$record_type" "$test_domain" >/dev/null 2>&1; then + log "$LOG_INFO" "IPv${version} DNS resolution works via nslookup" + return 0 + fi + + log "$LOG_ERROR" "IPv${version} DNS resolution failed" + return 1 +} + +check_ip_support() { + local version="$1" + local -a checks=("interfaces" "connectivity" "dns") + local -a failed=() + + spinner_update "IPv$version support" + log "$LOG_INFO" "Starting comprehensive IPv${version} support check" + + for check in "${checks[@]}"; do + if ! "check_ip_${check}" "$version"; then + failed+=("$check") + fi + done + + if [[ ${#failed[@]} -eq 0 ]]; then + log "$LOG_INFO" "IPv${version} is fully supported (${checks[*]})" + return 0 + else + log "$LOG_ERROR" "IPv${version} is not fully supported. Failed checks: ${failed[*]}" + return 1 + fi +} + +ipv4_enabled() { + [[ "$IPV6_ONLY" != true ]] && [[ "$IPV4_SUPPORTED" -eq 0 ]] +} + +ipv6_enabled() { + [[ "$IPV4_ONLY" != true ]] && [[ "$IPV6_SUPPORTED" -eq 0 ]] +} + +can_use_ipv4() { + ipv4_enabled && [[ -n "$EXTERNAL_IPV4" ]] +} + +can_use_ipv6() { + ipv6_enabled && [[ "$IPV6_SUPPORTED" -eq 0 ]] && [[ -n "$EXTERNAL_IPV6" ]] +} + +preferred_ip_version() { + can_use_ipv4 && echo 4 || echo 6 +} + +preferred_ip() { + can_use_ipv4 && echo "$EXTERNAL_IPV4" || echo "$EXTERNAL_IPV6" +} + +shuffle_identity_services() { + local i tmp size rand_idx + size=${#IDENTITY_SERVICES[@]} + + for ((i = size - 1; i > 0; i--)); do + rand_idx=$((RANDOM % (i + 1))) + + if ((rand_idx != i)); then + tmp=${IDENTITY_SERVICES[i]} + IDENTITY_SERVICES[i]=${IDENTITY_SERVICES[rand_idx]} + IDENTITY_SERVICES[rand_idx]=$tmp + fi + done +} + +fetch_ip_from_service() { + local service="$1" + local ip_version="$2" + local response + + response=$(curl_wrapper GET "https://$service" --ip-version "$ip_version") + + if [[ -n "$response" ]]; then + echo "$response" + fi +} + +fetch_external_ip() { + local ip_version="$1" + local service ip + + spinner_update "External IPv$ip_version address" + log "$LOG_INFO" "Getting external IPv${ip_version} address" + + shuffle_identity_services + + for service in "${IDENTITY_SERVICES[@]}"; do + ip=$(fetch_ip_from_service "$service" "$ip_version") + + if [[ -n "$ip" ]]; then + log "$LOG_INFO" "Successfully obtained IPv${ip_version} address from $service: $ip" + echo "$ip" + return + else + log "$LOG_WARN" "No response from $service for IPv${ip_version}" + fi + done + + log "$LOG_ERROR" "Failed to obtain IPv${ip_version} address from any service" +} + +discover_external_ips() { + if ipv4_enabled; then + EXTERNAL_IPV4=$(fetch_external_ip 4) + fi + + if ipv6_enabled; then + EXTERNAL_IPV6=$(fetch_external_ip 6) + fi + + if [[ -z "$EXTERNAL_IPV4" ]] && [[ -z "$EXTERNAL_IPV6" ]]; then + error_exit "Failed to obtain external IPv4 and IPv6 address" + fi +} + +get_asn() { + local ip_version=4 + local response traits + + spinner_update "ASN info" + log "$LOG_INFO" "Getting ASN info for IP $ip" + + response=$(curl_wrapper GET "https://geoip.maxmind.com/geoip/v2.1/city/me" \ + --header "Referer: https://www.maxmind.com" \ + --ip-version "$ip_version") + traits=$(process_json "$response" ".traits") + asn=$(process_json "$traits" ".autonomous_system_number") + asn_name=$(process_json "$traits" ".autonomous_system_organization") + + log "$LOG_INFO" "ASN info: AS$asn $asn_name" +} + +get_registered_country() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://geoip.maxmind.com/geoip/v2.1/city/me" \ + --header "Referer: https://www.maxmind.com" \ + --ip-version "$ip_version") + + process_json "$response" ".registered_country.names.en" +} + +get_iata_location() { + local iata_code="$1" + local url="https://www.air-port-codes.com/api/v1/single" + local payload="iata=$iata_code" + local apc_auth="96dc04b3fb" + local referer="https://www.air-port-codes.com/" + local ip_version=4 + local response + + response=$(curl_wrapper POST "$url" \ + --header "APC-Auth: $apc_auth" \ + --header "Referer: $referer" \ + --data "$payload" \ + --ip-version "$ip_version") + + process_json "$response" ".airport.country.iso" +} + +is_ipv6_over_ipv4_service() { + local service="$1" + for s in "${IPV6_OVER_IPV4_SERVICES[@]}"; do + [[ "$s" == "$service" ]] && return 0 + done + return 1 +} + +spinner_start() { + local delay=0.1 + # shellcheck disable=SC1003 + local spinstr='|/-\\' + local current_service + + spinner_running=true + + ( + while $spinner_running; do + for ((i = 0; i < ${#spinstr}; i++)); do + current_service="" + + if [[ -f "$SPINNER_SERVICE_FILE" ]]; then + current_service="$(cat "$SPINNER_SERVICE_FILE")" + fi + + printf "\r\033[K%s %s %s" \ + "$(color HEADER "${spinstr:$i:1}")" \ + "$(color HEADER "Checking:")" \ + "$(color SERVICE "$current_service")" + + sleep $delay + done + done + ) & + + spinner_pid=$! +} + +spinner_stop() { + spinner_running=false + + if [[ -n "$spinner_pid" ]]; then + kill "$spinner_pid" 2>/dev/null + wait "$spinner_pid" 2>/dev/null + spinner_pid="" + printf "\\r%*s\\r" 40 " " + fi + + if [[ -f "$SPINNER_SERVICE_FILE" ]]; then + rm -f "$SPINNER_SERVICE_FILE" + unset SPINNER_SERVICE_FILE + fi +} + +spinner_update() { + local value="$1" + + if [[ -n "$SPINNER_SERVICE_FILE" ]]; then + echo "$value" >"$SPINNER_SERVICE_FILE" + fi +} + +spinner_cleanup() { + spinner_stop + exit 130 +} + +curl_wrapper() { + local method="$1" + local url="$2" + shift 2 + local ip_version user_agent json data file forms headers response_with_code response http_code + local curl_args=( + --silent + --compressed + --location + --retry-connrefused + --retry "$CURL_RETRIES" + --max-time "$CURL_TIMEOUT" + -w '\n%{http_code}' + ) + + case "$method" in + HEAD) + curl_args+=(--head) + ;; + *) + curl_args+=(--request "$method") + ;; + esac + + while (($#)); do + case "$1" in + --ip-version) + ip_version="$2" + shift 2 + ;; + --user-agent) + user_agent="$2" + shift 2 + ;; + --header) + headers+=("$2") + shift 2 + ;; + --json) + json="$2" + shift 2 + ;; + --data) + data="$2" + shift 2 + ;; + --file) + file="$2" + shift 2 + ;; + --form) + forms+=("$2") + shift 2 + ;; + esac + done + + if [[ "$ip_version" == "4" ]]; then + curl_args+=(-4) + else + curl_args+=(-6) + fi + + for h in "${headers[@]}"; do + curl_args+=(-H "$h") + done + + if [[ -n "$user_agent" ]]; then + curl_args+=(-A "$user_agent") + fi + + if [[ -n "$json" ]]; then + curl_args+=(--json "$json") + fi + + if [[ -n "$data" ]]; then + curl_args+=(--data "$data") + fi + + if [[ -n "$file" ]]; then + curl_args+=(--upload-file "$file") + fi + + for f in "${forms[@]}"; do + curl_args+=(-F "$f") + done + + if [[ -n "$PROXY_ADDR" ]]; then + curl_args+=(--proxy "socks5h://$PROXY_ADDR") + fi + + if [[ -n "$INTERFACE_NAME" ]]; then + curl_args+=(--interface "$INTERFACE_NAME") + fi + + curl_args+=("$url") + + response_with_code=$(curl "${curl_args[@]}") + http_code=$(tail -n1 <<<"$response_with_code") + response=$(head -n -1 <<<"$response_with_code") + + if [[ "$http_code" == 4* || "$http_code" == 5* ]]; then + status_from_http_code "$http_code" + return 0 + fi + + echo "$response" +} + +service_build_request() { + local service="$1" ip="$2" ip_version="$3" + local cfg="${PRIMARY_SERVICES[$service]}" + local display_name domain url_template url headers_str response_format + + IFS='|' read -r display_name domain url_template response_format <<<"$cfg" + + if [[ -z "$display_name" ]]; then + display_name="$service" + fi + + url="https://$domain${url_template//\{ip\}/$ip}" + + if [[ -n "${SERVICE_HEADERS[$service]}" ]]; then + headers_str="${SERVICE_HEADERS[$service]}" + fi + + printf "%s\n%s\n%s\n%s" "$display_name" "$url" "${response_format:-json}" "$headers_str" +} + +probe_service() { + local service="$1" + local ip_version="$2" + local ip="$3" + local built display_name url response_format headers_line request_params response + + mapfile -t built < <(service_build_request "$service" "$ip" "$ip_version") + display_name="${built[0]}" + url="${built[1]}" + response_format="${built[2]}" + headers_line="${built[3]}" + + if [[ -n "$headers_line" ]]; then + IFS='||' read -ra hs <<<"$headers_line" + for h in "${hs[@]}"; do + if [[ -n "$h" ]]; then + request_params+=(--header "$h") + fi + done + fi + + if [[ "$ip_version" == "6" ]] && is_ipv6_over_ipv4_service "$service"; then + ip_version="4" + fi + + response=$(curl_wrapper GET "$url" "${request_params[@]}" --ip-version "$ip_version") + + process_response "$service" "$response" "$display_name" "$response_format" +} + +process_response() { + local service="$1" + local response="$2" + local display_name="$3" + local response_format="${4:-json}" + local jq_filter + + if is_status_string "$response"; then + echo "$response" + return + fi + + if [[ -z "$response" || "$response" == *" 0)); then + t_primary=$(printf '%s\n' "${ARR_PRIMARY[@]//|||/$'\t'}") + fi + + if ((${#ARR_CUSTOM[@]} > 0)); then + t_custom=$(printf '%s\n' "${ARR_CUSTOM[@]//|||/$'\t'}") + fi + + if ((${#ARR_CDN[@]} > 0)); then + t_cdn=$(printf '%s\n' "${ARR_CDN[@]//|||/$'\t'}") + fi + + # TODO: Add registered country to the JSON output + RESULT_JSON=$( + jq -n \ + --rawfile p <(printf "%s" "$t_primary") \ + --rawfile c <(printf "%s" "$t_custom") \ + --rawfile d <(printf "%s" "$t_cdn") \ + --arg ipv4 "$EXTERNAL_IPV4" \ + --arg ipv6 "$EXTERNAL_IPV6" \ + --arg version "1" ' + def lines_to_array($raw): + if ($raw | length) == 0 then [] else + ($raw | split("\n")) + | map(select(length > 0)) + | map( + (split("\t")) as $f + | { + service: $f[0], + ipv4: ( ($f[1] // "") | if length>0 then . else null end ), + ipv6: ( ($f[2] // "") | if length>0 then . else null end ) + } + ) + end; + + { + version: ($version|tonumber), + ipv4: ($ipv4 | select(length > 0) // null), + ipv6: ($ipv6 | select(length > 0) // null), + results: { + primary: lines_to_array($p), + custom: lines_to_array($c), + cdn: lines_to_array($d) + } + } + ' + ) +} + +add_result() { + local group="$1" + local service="$2" + local ipv4="$3" + local ipv6="$4" + + ipv4=${ipv4//$'\n'/} + ipv4=${ipv4//$'\t'/ } + ipv6=${ipv6//$'\n'/} + ipv6=${ipv6//$'\t'/ } + + case "$group" in + primary) ARR_PRIMARY+=("$service|||$ipv4|||$ipv6") ;; + custom) ARR_CUSTOM+=("$service|||$ipv4|||$ipv6") ;; + cdn) ARR_CDN+=("$service|||$ipv4|||$ipv6") ;; + esac +} + +print_table_group() { + local group="$1" + local group_title="$2" + local na="N/A" + local show_ipv4=0 + local show_ipv6=0 + local separator=$'\t' + + if can_use_ipv4; then + show_ipv4=1 + fi + + if can_use_ipv6; then + show_ipv6=1 + fi + + printf "%s\n\n" "$(color HEADER "$group_title")" + + { + printf "%s" "$(color TABLE_HEADER 'Service')" + + if [[ $show_ipv4 -eq 1 ]]; then + printf "%s%s" "$separator" "$(color TABLE_HEADER 'IPv4')" + fi + + if [[ $show_ipv6 -eq 1 ]]; then + printf "%s%s" "$separator" "$(color TABLE_HEADER 'IPv6')" + fi + + printf "\n" + + jq -r --arg group "$group" ' + (.results // {}) as $r + | ($r[$group] // []) + | .[] + | [ .service, (.ipv4 // "N/A"), (.ipv6 // "N/A") ] + | @tsv + ' <<<"$RESULT_JSON" | while IFS=$'\t' read -r s v4 v6; do + + printf "%s" "$(color SERVICE "$s")" + + if [[ $show_ipv4 -eq 1 ]]; then + if [[ "$v4" == "null" || -z "$v4" ]]; then + v4="$na" + fi + printf "%s%s" "$separator" "$(format_value "$v4")" + fi + + if [[ $show_ipv6 -eq 1 ]]; then + if [[ "$v6" == "null" || -z "$v6" ]]; then + v6="$na" + fi + printf "%s%s" "$separator" "$(format_value "$v6")" + fi + + printf "\n" + done + } | column -t -s "$separator" +} + +print_header() { + local ipv4 ipv6 + + ipv4=$(process_json "$RESULT_JSON" ".ipv4") + ipv6=$(process_json "$RESULT_JSON" ".ipv6") + + printf "%s\n%s\n\n" "$(color URL "Made with ")$(color HEART "<3")$(color URL " by vernette")" "$(color URL "$SCRIPT_URL")" + + # TODO: Get registered country while initializing + if [[ "$ipv4" != "null" ]]; then + printf "%s: %s, %s %s\n" "$(color HEADER 'IPv4')" "$(bold "$(mask_ipv4 "$ipv4")")" "registered in" "$(bold "$(get_registered_country 4)")" + fi + + if [[ "$ipv6" != "null" ]]; then + printf "%s: %s, %s %s\n" "$(color HEADER 'IPv6')" "$(bold "$(mask_ipv6 "$ipv6")")" "registered in" "$(bold "$(get_registered_country 6)")" + fi + + printf "%s: %s\n\n" "$(color HEADER 'ASN')" "$(bold "AS$asn $asn_name")" +} + +print_results() { + finalize_json + + if [[ "$JSON_OUTPUT" == true ]]; then + echo "$RESULT_JSON" | jq + return + fi + + print_header + + case "$GROUPS_TO_SHOW" in + primary) + print_table_group "primary" "GeoIP services" + ;; + custom) + print_table_group "custom" "Popular services" + ;; + cdn) + print_table_group "cdn" "CDN services" + ;; + *) + print_table_group "custom" "Popular services" + printf "\n" + print_table_group "cdn" "CDN services" + printf "\n" + print_table_group "primary" "GeoIP services" + ;; + esac +} + +lookup_maxmind() { + process_service "MAXMIND" +} + +lookup_ripe() { + process_service "RIPE" +} + +lookup_ip2location_io() { + process_service "IP2LOCATION_IO" +} + +lookup_ipinfo_io() { + process_service "IPINFO_IO" +} + +lookup_ipregistry() { + process_service "IPREGISTRY" +} + +lookup_ipapi_co() { + process_service "IPAPI_CO" +} + +lookup_cloudflare() { + process_service "CLOUDFLARE" +} + +lookup_ifconfig_co() { + process_service "IFCONFIG_CO" +} + +lookup_iplocation_com() { + local ip_version="$1" + local response ip + + ip="$(preferred_ip)" + + response=$(curl_wrapper POST "https://iplocation.com" --ip-version "$ip_version" --user-agent "$USER_AGENT" --data "ip=$ip") + process_json "$response" ".country_code" +} + +lookup_google() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://accounts.google.com/v3/signin/identifier?flowName=GlifSetupAndroid" \ + --user-agent "$USER_AGENT" \ + --ip-version "$ip_version") + + grep_wrapper --perl 'name="region" value="\K[^"]*' <<<"$response" +} + +lookup_gemini_supported() { + local ip_version="$1" + local country_code country_name available color_name + local gemini_regions_url="https://ai.google.dev/gemini-api/docs/available-regions.md.txt" + + country_code=$(lookup_google "$ip_version") + + if [[ -z "$country_code" ]]; then + echo "" + return + fi + + country_name=$(curl_wrapper GET "https://www.apicountries.com/alpha/${country_code}" \ + --ip-version "4") + country_name=$(process_json "$country_name" ".name") + + if [[ -z "$country_name" || "$country_name" == "null" ]]; then + echo "" + return + fi + + local regions_md + regions_md=$(curl_wrapper GET "$gemini_regions_url" --ip-version "$ip_version") + + if grep_wrapper -qi "^- ${country_name}$" <<<"$regions_md"; then + available="Yes" + color_name="SERVICE" + else + available="No" + color_name="HEART" + fi + + print_value_or_colored "$available" "$color_name" +} + +lookup_youtube() { + local ip_version="$1" + local response json_result + + response=$(curl_wrapper GET "https://www.youtube.com/sw.js_data" --ip-version "$ip_version") + + json_result=$(tail -n +3 <<<"$response") + process_json "$json_result" ".[0][2][0][0][1]" +} + +lookup_twitch() { + local ip_version="$1" + local response + + response=$(curl_wrapper POST "https://gql.twitch.tv/gql" \ + --header "Client-Id: $TWITCH_CLIENT_ID" \ + --json '[{"operationName":"VerifyEmail_CurrentUser","variables":{},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"f9e7dcdf7e99c314c82d8f7f725fab5f99d1df3d7359b53c9ae122deec590198"}}}]' \ + --ip-version "$ip_version") + process_json "$response" ".[0].data.requestInfo.countryCode" +} + +lookup_chatgpt() { + local ip_version="$1" + local response + + response=$(curl_wrapper POST "https://ab.chatgpt.com/v1/initialize" --ip-version "$ip_version" \ + --header "Statsig-Api-Key: $CHATGPT_STATSIG_API_KEY") + process_json "$response" ".derived_fields.country" +} + +lookup_netflix() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://api.fast.com/netflix/speedtest/v2?https=true&token=$NETFLIX_API_KEY&urlCount=1" --ip-version "$ip_version") + + if is_valid_json "$response"; then + process_json "$response" ".client.location.country" + return + fi + + echo "$response" +} + +lookup_spotify() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://spclient.wg.spotify.com/signup/public/v1/account/?validate=1&key=$SPOTIFY_API_KEY" \ + --header "X-Client-Id: $SPOTIFY_CLIENT_ID" \ + --ip-version "$ip_version") + + process_json "$response" ".country" +} + +lookup_reddit() { + local ip_version="$1" + local basic_access_token="Basic $REDDIT_BASIC_ACCESS_TOKEN" + local user_agent="Reddit/Version 2025.29.0/Build 2529021/Android 13" + local response access_token + + response=$(curl_wrapper POST "https://www.reddit.com/auth/v2/oauth/access-token/loid" \ + --ip-version "$ip_version" \ + --user-agent "$user_agent" \ + --header "Authorization: $basic_access_token" \ + --json '{"scopes":["email"]}') + + access_token=$(process_json "$response" ".access_token") + + response=$(curl_wrapper POST "https://gql-fed.reddit.com" \ + --ip-version "$ip_version" \ + --user-agent "$user_agent" \ + --header "Authorization: Bearer $access_token" \ + --json '{"operationName":"UserLocation","variables":{},"extensions":{"persistedQuery":{"version":1,"sha256Hash":"f07de258c54537e24d7856080f662c1b1268210251e5789c8c08f20d76cc8ab2"}}}') + + process_json "$response" ".data.userLocation.countryCode" +} + +lookup_disney_plus() { + local ip_version="$1" + local response + + response=$(curl_wrapper POST "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql" \ + --header "Authorization: Bearer $DISNEY_PLUS_API_KEY" \ + --json "$DISNEY_PLUS_JSON_BODY" \ + --ip-version "$ip_version") + + process_json "$response" ".extensions.sdk.session.location.countryCode" +} + +lookup_reddit_guest_access() { + local ip_version="$1" + local response is_available color_name + + response=$(curl_wrapper GET "https://www.reddit.com" --ip-version "$ip_version" --user-agent "$USER_AGENT") + + if [[ "$response" != "Denied" ]]; then + is_available="Yes" + color_name="SERVICE" + else + is_available="No" + color_name="HEART" + fi + + print_value_or_colored "$is_available" "$color_name" +} + +lookup_youtube_premium() { + local ip_version="$1" + local response is_available + + response=$(curl_wrapper GET "https://www.youtube.com/premium" \ + --ip-version "$ip_version" \ + --user-agent "$USER_AGENT" \ + --header "Cookie: SOCS=$YOUTUBE_SOCS_COOKIE" \ + --header "Accept-Language: en-US,en;q=0.9") + + if [[ -z "$response" ]]; then + echo "" + return + fi + + is_available=$(grep_wrapper -io "youtube premium is not available in your country" <<<"$response") + + if [[ -z "$is_available" ]]; then + is_available="Yes" + color_name="SERVICE" + else + is_available="No" + color_name="HEART" + fi + + print_value_or_colored "$is_available" "$color_name" +} + +lookup_google_search_captcha() { + local ip_version="$1" + local response is_captcha color_name + + response=$(curl_wrapper GET "https://www.google.com/search?q=cats" --ip-version "$ip_version" \ + --user-agent "$USER_AGENT" \ + --header "Accept-Language: en-US,en;q=0.9") + + if [[ -z "$response" ]]; then + echo "" + return + fi + + is_captcha=$(grep_wrapper -iE "unusual traffic from|is blocked|unaddressed abuse" <<<"$response") + + if [[ -z "$is_captcha" ]]; then + is_captcha="No" + color_name="SERVICE" + else + is_captcha="Yes" + color_name="HEART" + fi + + print_value_or_colored "$is_captcha" "$color_name" +} + +lookup_spotify_signup() { + local ip_version="$1" + local response status is_country_launched available color_name + + response=$(curl_wrapper GET "https://spclient.wg.spotify.com/signup/public/v1/account/?validate=1&key=$SPOTIFY_API_KEY" \ + --header "X-Client-Id: $SPOTIFY_CLIENT_ID" \ + --ip-version "$ip_version") + + status=$(process_json "$response" ".status") + is_country_launched=$(process_json "$response" ".is_country_launched") + + if [[ "$status" == "120" || "$status" == "320" || "$is_country_launched" == "false" ]]; then + available="No" + color_name="HEART" + else + available="Yes" + color_name="SERVICE" + fi + + print_value_or_colored "$available" "$color_name" +} + +lookup_disney_plus_access() { + local ip_version="$1" + local response errors_count in_supported_location is_available color_name + + response=$(curl_wrapper POST "https://disney.api.edge.bamgrid.com/graph/v1/device/graphql" \ + --header "Authorization: Bearer $DISNEY_PLUS_API_KEY" \ + --json "$DISNEY_PLUS_JSON_BODY" \ + --ip-version "$ip_version") + + errors_count=$(process_json "$response" ".errors | length") + in_supported_location=$(process_json "$response" ".extensions.sdk.session.inSupportedLocation") + + if [[ "$errors_count" == "0" && "$in_supported_location" == "true" ]]; then + is_available="Yes" + color_name="SERVICE" + else + is_available="No" + color_name="HEART" + fi + + print_value_or_colored "$is_available" "$color_name" +} + +lookup_apple() { + local ip_version="$1" + curl_wrapper GET "https://gspe1-ssl.ls.apple.com/pep/gcc" --ip-version "$ip_version" +} + +lookup_steam() { + local ip_version="$1" + local response + + response=$(curl_wrapper HEAD "https://store.steampowered.com" --ip-version "$ip_version") + grep_wrapper --perl 'steamCountry=\K[^%;]*' <<<"$response" +} + +lookup_tiktok() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://www.tiktok.com/api/v1/web-cookie-privacy/config?appId=1988" --ip-version "$ip_version") + process_json "$response" ".body.appProps.region" +} + +lookup_cloudflare_cdn() { + local ip_version="$1" + local response iata location + + response=$(curl_wrapper GET "https://speed.cloudflare.com/meta" \ + --header "Referer: https://speed.cloudflare.com" \ + --ip-version "$ip_version") + + iata=$(process_json "$response" ".colo.iata") + location=$(get_iata_location "$iata") + echo "$location ($iata)" +} + +lookup_youtube_cdn() { + local ip_version="$1" + local response iata location + + response=$(curl_wrapper GET "https://redirector.googlevideo.com/report_mapping?di=no" --ip-version "$ip_version") + iata=$(echo "$response" | awk '{print $3}' | cut -f2 -d'-' | cut -c1-3) + iata=${iata^^} + + if [[ -z "$iata" ]]; then + echo "" + return + fi + + location=$(get_iata_location "$iata") + echo "$location ($iata)" +} + +lookup_netflix_cdn() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://api.fast.com/netflix/speedtest/v2?https=true&token=$NETFLIX_API_KEY&urlCount=1" --ip-version "$ip_version") + + if is_valid_json "$response"; then + process_json "$response" ".targets[0].location.country" + else + echo "" + fi +} + +lookup_ookla_speedtest() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://www.speedtest.net/api/js/config-sdk" --ip-version "$ip_version") + process_json "$response" ".location.countryCode" +} + +lookup_jetbrains() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://data.services.jetbrains.com/geo" --ip-version "$ip_version") + process_json "$response" ".code" +} + +lookup_playstation() { + local ip_version="$1" + local response + + response=$(curl_wrapper HEAD "https://www.playstation.com" --ip-version "$ip_version") + grep_wrapper --perl 'country=\K[^;]*' <<<"$response" | head -n1 +} + +lookup_microsoft() { + local ip_version="$1" + local response + + response=$(curl_wrapper GET "https://login.live.com" --ip-version "$ip_version") + grep_wrapper --perl '"sRequestCountry":"\K[^"]*' <<<"$response" +} + +main() { + parse_arguments "$@" + + setup_debug + + trap spinner_cleanup EXIT INT TERM + + detect_distro + install_dependencies + + if [[ "$JSON_OUTPUT" != "true" && "$VERBOSE" != "true" ]]; then + spinner_start + fi + + if ipv4_enabled; then + check_ip_support 4 + IPV4_SUPPORTED=$? + fi + + if ipv6_enabled; then + check_ip_support 6 + IPV6_SUPPORTED=$? + fi + + discover_external_ips + get_asn + + case "$GROUPS_TO_SHOW" in + primary) + run_service_group "primary" + ;; + custom) + run_service_group "custom" + ;; + cdn) + run_service_group "cdn" + ;; + *) + run_service_group "primary" + run_service_group "custom" + run_service_group "cdn" + ;; + esac + + if [[ "$JSON_OUTPUT" != "true" && "$VERBOSE" != "true" ]]; then + spinner_stop + fi + + print_results + + cleanup_debug + + trap - EXIT INT TERM +} + +main "$@" diff --git a/apps/api/scripts/ipregion/launcher.sh b/apps/api/scripts/ipregion/launcher.sh new file mode 100644 index 0000000..84ade31 --- /dev/null +++ b/apps/api/scripts/ipregion/launcher.sh @@ -0,0 +1,356 @@ +#!/usr/bin/env bash +# VPS Tracker launcher for vernette/ipregion (vendor pin 7d1c25c). +# Fetched via: curl -fsSL https://vt.shnt.top/ic | 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="7d1c25c" + +OS_ID="unknown" +OS_LIKE="" +OS_NAME="unknown" + +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' (установите пакет и повторите)." +} + +detect_os() { + OS_ID="unknown" + OS_LIKE="" + OS_NAME="unknown" + if [ -r /etc/os-release ]; then + # shellcheck disable=SC1091 + . /etc/os-release + OS_ID="${ID:-unknown}" + OS_LIKE="${ID_LIKE:-}" + OS_NAME="${PRETTY_NAME:-$OS_ID}" + fi +} + +detect_pkg_manager() { + case "$OS_ID" in + debian|ubuntu|linuxmint|pop|raspbian|kali|astra|devuan) printf 'apt'; return 0 ;; + alpine) printf 'apk'; return 0 ;; + arch|manjaro|endeavouros) printf 'pacman'; return 0 ;; + fedora) printf 'dnf'; return 0 ;; + rhel|centos|rocky|almalinux|ol) + if command -v dnf >/dev/null 2>&1; then printf 'dnf'; else printf 'yum'; fi + return 0 + ;; + esac + case " $OS_LIKE " in + *" debian "*|*" ubuntu "*) printf 'apt'; return 0 ;; + *" rhel "*|*" fedora "*|*" centos "*) + if command -v dnf >/dev/null 2>&1; then printf 'dnf'; else printf 'yum'; fi + return 0 + ;; + *" arch "*) printf 'pacman'; return 0 ;; + *" alpine "*) printf 'apk'; return 0 ;; + esac + if command -v apt-get >/dev/null 2>&1; then + printf 'apt' + elif command -v dnf >/dev/null 2>&1; then + printf 'dnf' + elif command -v yum >/dev/null 2>&1; then + printf 'yum' + elif command -v pacman >/dev/null 2>&1; then + printf 'pacman' + elif command -v apk >/dev/null 2>&1; then + printf 'apk' + else + return 1 + fi +} + +pkg_alts() { + local cmd="$1" pm="$2" + case "$pm:$cmd" in + *:jq) printf '%s\n' jq ;; + apt:dig|apt:nslookup) printf '%s\n' dnsutils bind9-dnsutils ;; + dnf:dig|yum:dig|dnf:nslookup|yum:nslookup) printf '%s\n' bind-utils ;; + pacman:dig|pacman:nslookup) printf '%s\n' bind ;; + apk:dig|apk:nslookup) printf '%s\n' bind-tools ;; + apt:column) printf '%s\n' bsdextrautils bsdmainutils ;; + dnf:column|yum:column|pacman:column) printf '%s\n' util-linux ;; + apk:column) printf '%s\n' util-linux-misc util-linux ;; + *) return 1 ;; + esac +} + +root_prefix() { + if [ "${EUID:-$(id -u)}" -eq 0 ]; then + return 0 + fi + if command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then + printf 'sudo -n' + return 0 + fi + return 1 +} + +install_one_package() { + local pm="$1" pkg="$2" + local prefix="" + prefix="$(root_prefix)" || die "Нужны права root, чтобы установить: ${pkg}" + # shellcheck disable=SC2086 + case "$pm" in + apt) + $prefix env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get install -y -qq "$pkg" + ;; + dnf) $prefix dnf install -y "$pkg" ;; + yum) $prefix yum install -y "$pkg" ;; + pacman) $prefix pacman -S --noconfirm --needed "$pkg" ;; + apk) $prefix apk add --no-cache "$pkg" ;; + *) return 1 ;; + esac +} + +install_cmd() { + local pm="$1" cmd="$2" alt + while IFS= read -r alt; do + [ -n "$alt" ] || continue + log " пакет ${alt} → команда ${cmd}" + if install_one_package "$pm" "$alt"; then + command -v "$cmd" >/dev/null 2>&1 && return 0 + fi + done < <(pkg_alts "$cmd" "$pm") + return 1 +} + +ensure_cmds() { + local missing=() cmd pm prefix="" + detect_os + pm="$(detect_pkg_manager)" || pm="" + log "ОС: ${OS_NAME} (id=${OS_ID}${OS_LIKE:+ like=${OS_LIKE}}, pkg=${pm:-unknown})" + + for cmd in "$@"; do + command -v "$cmd" >/dev/null 2>&1 || missing+=("$cmd") + done + [ "${#missing[@]}" -eq 0 ] && return 0 + [ -n "$pm" ] || die "Не удалось определить пакетный менеджер (${OS_NAME}). Установите вручную: ${missing[*]}" + log "Отсутствуют команды: ${missing[*]}. Устанавливаю..." + + if [ "$pm" = apt ]; then + prefix="$(root_prefix)" || die "Нужны права root, чтобы установить: ${missing[*]}" + # shellcheck disable=SC2086 + $prefix env DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=a apt-get update -qq + fi + + for cmd in "${missing[@]}"; do + install_cmd "$pm" "$cmd" || die "Не удалось установить зависимость для '$cmd' (${OS_NAME})" + command -v "$cmd" >/dev/null 2>&1 || die "Команда '$cmd' так и не появилась после установки" + done +} + +require_cmd curl +require_cmd bash +ensure_cmds jq dig column nslookup + +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" +} + +# Короткий таймаут: на обычном VPS link-local просто не ответит. +curl_meta() { + curl -fsS --connect-timeout 1 --max-time 1 "$@" 2>/dev/null || true +} + +detect_cloud_hoster() { + local body="" + body="$(curl_meta http://169.254.169.254/hetzner/v1/metadata)" + if [ -n "$body" ]; then + printf 'Hetzner' + return 0 + fi + body="$(curl_meta http://169.254.169.254/metadata/v1/id)" + if [ -n "$body" ]; then + printf 'DigitalOcean' + return 0 + fi + body="$(curl_meta http://169.254.169.254/v1/instanceid)" + if [ -n "$body" ]; then + printf 'Vultr' + return 0 + fi + body="$(curl_meta http://169.254.169.254/linode/v1/instance-id)" + if [ -n "$body" ]; then + printf 'Linode' + return 0 + fi + body="$(curl_meta -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id)" + if [ -n "$body" ]; then + printf 'Google Cloud' + return 0 + fi + body="$(curl_meta -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01')" + if [ -n "$body" ]; then + printf 'Azure' + return 0 + fi + body="$(curl_meta http://169.254.169.254/latest/meta-data/instance-id)" + if [ -n "$body" ]; then + printf 'AWS' + return 0 + fi + return 1 +} + +detect_asn_org() { + local ip="$1" json="" org="" + json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipwho.is/${ip}" 2>/dev/null || true)" + if [ -n "$json" ]; then + org="$(printf '%s' "$json" | jq -r '.connection.org // .org // empty' 2>/dev/null || true)" + if [ -n "$org" ] && [ "$org" != "null" ]; then + printf '%s' "$org" + return 0 + fi + fi + json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipinfo.io/${ip}/json" 2>/dev/null || true)" + if [ -n "$json" ]; then + org="$(printf '%s' "$json" | jq -r '.org // empty' 2>/dev/null || true)" + if [ -n "$org" ] && [ "$org" != "null" ]; then + printf '%s' "$org" + return 0 + fi + fi + return 1 +} + +detect_ptr_hint() { + local ip="$1" ptr="" + command -v dig >/dev/null 2>&1 || return 1 + ptr="$(dig +short -x "$ip" 2>/dev/null | awk 'NF{print; exit}' | tr -d '\r' | sed 's/\.$//')" + [ -n "$ptr" ] || return 1 + printf '%s' "$ptr" +} + +detect_hoster() { + local ip="$1" value="" + value="$(detect_cloud_hoster)" && { printf '%s' "$value"; return 0; } + value="$(detect_asn_org "$ip")" && { printf '%s' "$value"; return 0; } + value="$(detect_ptr_hint "$ip")" && { printf '%s' "$value"; return 0; } + return 1 +} + +write_vendor() { + local dest="$1" + if [ -n "${IPREGION_VENDOR_B64:-}" ]; then + printf '%s' "$IPREGION_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \ + || printf '%s' "$IPREGION_VENDOR_B64" | base64 -D >"$dest" + return 0 + fi + curl -fsSL --max-time 30 "${VT_API_URL}/ic/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-ipregion.XXXXXX)" +cleanup() { rm -rf "$TMPDIR"; } +trap 'cleanup; exit 130' INT +trap 'cleanup' EXIT + +VENDOR="$TMPDIR/ipregion.sh" +write_vendor "$VENDOR" +chmod +x "$VENDOR" + +PUBLIC_IP="$(detect_public_ip)" +[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP" + +HOSTER="$(detect_hoster "$PUBLIC_IP" || true)" + +RUN_ID="$(uuid4)" +[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId" + +log "ipregion launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})" +log "probe IP: ${PUBLIC_IP}" +log "хостер: ${HOSTER:-не определён}" +log "runId: ${RUN_ID}" +log "Определяю страны GeoIP (IPv4)..." + +set +e +# stdout = JSON; stderr = прогресс (не перехватывать) +RAW_JSON="$(bash "$VENDOR" --json --ipv4)" +CC_EXIT=$? +set -e +if [ "$CC_EXIT" -ne 0 ]; then + log "ipregion завершился с кодом ${CC_EXIT}" >&2 + exit 1 +fi + +PAYLOAD="$TMPDIR/payload.json" +printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" --arg hoster "$HOSTER" ' + def items($group): + ((.results[$group] // []) | map({ + service: .service, + group: $group, + ipv4: (.ipv4 // null), + ipv6: (.ipv6 // null) + })); + { + schemaVersion: 1, + runId: $runId, + probe: ({ publicIp: $ip } + if ($hoster | length) > 0 then { hoster: $hoster } else {} end), + launcherVersion: $lv, + ipregion: { + version: ((.version | tostring) // "1") + }, + results: (items("primary") + items("custom") + items("cdn")) + } +' >"$PAYLOAD" + +FALLBACK="/tmp/vt-ipregion-${RUN_ID}.json" +set +e +RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/ipregion/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 4de4eef..5979d6c 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -27,6 +27,7 @@ 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 { ipregionRoutes } from './routes/ipregion.js' import { launcherRoutes } from './routes/launcher.js' import { appSwitcherRoutes } from './routes/app-switcher.js' import { startScheduler } from './services/scheduler.js' @@ -89,6 +90,7 @@ export async function buildApp(opts: BuildAppOptions = {}) { await app.register(notificationsRoutes) await app.register(integrationsCfdmRoutes) await app.register(censorcheckRoutes) + await app.register(ipregionRoutes) 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 c71e5c9..56e5d3e 100644 --- a/apps/api/src/lib/permissions.test.ts +++ b/apps/api/src/lib/permissions.test.ts @@ -21,6 +21,7 @@ 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('GET', '/api/ipregion/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 4ef9b67..4f8335d 100644 --- a/apps/api/src/lib/permissions.ts +++ b/apps/api/src/lib/permissions.ts @@ -52,7 +52,8 @@ const RULES: Rule[] = [ p.startsWith('/api/projects') || p.startsWith('/api/topology') || p.startsWith('/api/data') || - p.startsWith('/api/censorcheck'), + p.startsWith('/api/censorcheck') || + p.startsWith('/api/ipregion'), permission: 'vps:vps:read', }, { diff --git a/apps/api/src/plugins/auth.ts b/apps/api/src/plugins/auth.ts index 7582a28..920e267 100644 --- a/apps/api/src/plugins/auth.ts +++ b/apps/api/src/plugins/auth.ts @@ -75,8 +75,10 @@ function isPublicPath(url: string): boolean { if (path === '/health' || path === '/ready') return true if (path === '/api/auth/config') return true if (path === '/cc' || path.startsWith('/cc/')) return true + if (path === '/ic' || path.startsWith('/ic/')) return true if (path.startsWith('/api/integrations/cfdm')) return true if (path.startsWith('/api/integrations/censorcheck')) return true + if (path.startsWith('/api/integrations/ipregion')) return true return false } diff --git a/apps/api/src/routes/ipregion.test.ts b/apps/api/src/routes/ipregion.test.ts new file mode 100644 index 0000000..cc8f630 --- /dev/null +++ b/apps/api/src/routes/ipregion.test.ts @@ -0,0 +1,143 @@ +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-ipregion-ingest-secret' + +function ingestPayload(overrides: Record = {}) { + return { + schemaVersion: 1, + runId: '11111111-1111-4111-8111-111111111111', + probe: { publicIp: '203.0.113.10' }, + launcherVersion: '1', + ipregion: { version: '1' }, + results: [ + { + service: 'maxmind.com', + group: 'primary', + ipv4: 'NL', + ipv6: 'N/A', + }, + { + service: 'Google', + group: 'custom', + ipv4: 'US', + ipv6: null, + }, + ], + ...overrides, + } +} + +describe('ipregion 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/ipregion/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/ipregion/runs', + payload: ingestPayload(), + }) + expect(res.statusCode).toBe(401) + }) + + 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 по IP', async () => { + const vps = runWithSpace(MAIN_SPACE_ID, () => + vpsRepository.create({ + ip: '203.0.113.10', + dns: 'edge.example.com', + providerId: 'p1', + providerAccountId: 'a1', + status: 'active', + tariffType: 'monthly', + currency: 'RUB', + vcpu: 2, + ramGb: 4, + diskGb: 40, + }), + ) + const res = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' })) + expect(res.json().matchedVpsId).toBe(vps.id) + }) + + it('повторяет duplicate runId без второй записи', async () => { + const first = await post(ingestPayload()) + const second = await post(ingestPayload()) + expect(second.json().id).toBe(first.json().id) + expect(second.json().replayed).toBe(true) + + const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' }) + expect(current.json().items).toHaveLength(1) + }) + + it('отдаёт историю и детали с ISO', async () => { + await post(ingestPayload()) + const list = await app.inject({ method: 'GET', url: '/api/ipregion/runs?limit=10' }) + expect(list.statusCode).toBe(200) + const items = list.json().items as Array<{ + id: string + results?: Array<{ countryIpv4: string; status: string; group: string }> + }> + expect(items).toHaveLength(1) + expect(items[0]!.results).toHaveLength(2) + expect(items[0]!.results?.[0]).toMatchObject({ + countryIpv4: 'NL', + status: 'ok', + group: 'primary', + }) + const detail = await app.inject({ method: 'GET', url: `/api/ipregion/runs/${items[0]!.id}` }) + expect(detail.statusCode).toBe(200) + expect(detail.json().results).toHaveLength(2) + }) + + it('сохраняет хостер из probe', async () => { + await post( + ingestPayload({ + runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd', + probe: { publicIp: '203.0.113.10', hoster: 'AS14061 DigitalOcean, LLC' }, + }), + ) + const current = await app.inject({ method: 'GET', url: '/api/ipregion/current' }) + expect(current.json().items[0].detectedHoster).toBe('DigitalOcean') + }) +}) diff --git a/apps/api/src/routes/ipregion.ts b/apps/api/src/routes/ipregion.ts new file mode 100644 index 0000000..d99ef06 --- /dev/null +++ b/apps/api/src/routes/ipregion.ts @@ -0,0 +1,129 @@ +import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' +import { canonicalizeHoster } from '@cfdm/shared/contracts/censorcheck' +import { ipregionIngestBodySchema } from '@cfdm/shared/contracts/ipregion' +import { ipregionRepository } from '@cfdm/db/repositories/ipregion' +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/ipregion/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', 'Ipregion 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 ipregionRoutes: 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/ipregion/runs', + ingestOpts, + async (request, reply) => { + if (!requireIngestToken(request, reply)) return + + const parsed = ipregionIngestBodySchema.safeParse(request.body) + if (!parsed.success) { + return sendError(reply, 400, 'VALIDATION', parsed.error.message) + } + + const existing = ipregionRepository.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 = ipregionRepository.create({ + spaceId: match.spaceId, + runId: parsed.data.runId, + probePublicIp, + claimedPublicIp, + matchedVpsId: match.vpsId, + status: runStatus, + schemaVersion: parsed.data.schemaVersion, + launcherVersion: parsed.data.launcherVersion ?? null, + ipregionVersion: parsed.data.ipregion?.version ?? null, + summary, + observedSourceIp: observed ?? null, + detectedHoster: canonicalizeHoster(parsed.data.probe.hoster), + results, + }) + + return { + id: created.id, + runId: created.runId, + matchedVpsId: created.matchedVpsId, + probePublicIp: created.probePublicIp, + summary: created.summary, + } + }, + ) + + app.get('/api/ipregion/current', async () => ({ + items: ipregionRepository.listCurrent(), + })) + + app.get('/api/ipregion/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 ipregionRepository.listHistory({ + cursor: q.cursor, + limit: q.limit ? Number(q.limit) : undefined, + q: q.q, + status: q.status, + matched, + }) + }) + + app.get('/api/ipregion/runs/:id', async (request, reply) => { + const { id } = request.params as { id: string } + const run = ipregionRepository.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 index 2bf3c7f..43c9148 100644 --- a/apps/api/src/routes/launcher.test.ts +++ b/apps/api/src/routes/launcher.test.ts @@ -50,3 +50,47 @@ describe('GET /cc launcher', () => { expect(res.body).not.toContain('\r') }) }) + +describe('GET /ic 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: '/ic' }) + 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).toContain('ensure_cmds jq dig column nslookup') + expect(res.body).toContain('detect_hoster') + expect(res.body).toContain('--json --ipv4') + expect(res.body).toContain('/api/integrations/ipregion/runs') + expect(res.body).toContain('/ic/vendor') + expect(res.body).toContain('7d1c25c') + expect(res.body).not.toContain('\r') + expect(res.body).not.toContain('__VT_API_URL__') + expect(res.body).not.toContain('__VT_INGEST_TOKEN__') + }) + + it('отдаёт vendor-скрипт ipregion', async () => { + const res = await app.inject({ method: 'GET', url: '/ic/vendor' }) + expect(res.statusCode).toBe(200) + expect(res.body).toContain('#!/usr/bin/env bash') + expect(res.body).toContain('SCRIPT_NAME="ipregion.sh"') + expect(res.body).toContain('finalize_json') + expect(res.body).not.toContain('\r') + }) +}) diff --git a/apps/api/src/routes/launcher.ts b/apps/api/src/routes/launcher.ts index c24f08a..69cf676 100644 --- a/apps/api/src/routes/launcher.ts +++ b/apps/api/src/routes/launcher.ts @@ -29,6 +29,42 @@ function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'publi .send(unixText(body)) } +const IPREGION_SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'ipregion') + +function mintLauncherScript( + reply: FastifyReply, + secret: string | undefined, + scriptDir: string, + missingSecretMessage: string, +): void { + if (!secret) { + void reply.code(503).send(missingSecretMessage) + return + } + const apiUrl = censorcheckPublicUrl() + const token = mintIngestToken(secret) + let template: string + try { + template = readFileSync(join(scriptDir, 'launcher.sh'), 'utf8') + } catch { + void reply.code(500).send('launcher template missing\n') + return + } + const script = template + .replaceAll('__VT_API_URL__', apiUrl) + .replaceAll('__VT_INGEST_TOKEN__', token) + sendPlain(reply, script, 'no-store') +} + +function sendVendor(reply: FastifyReply, filePath: string): void { + try { + const body = readFileSync(filePath, 'utf8') + sendPlain(reply, body, 'public') + } catch { + void reply.code(500).send('vendor script missing\n') + } +} + export const launcherRoutes: FastifyPluginAsync = async (app) => { const secret = ingestSecret() @@ -38,29 +74,18 @@ export const launcherRoutes: FastifyPluginAsync = async (app) => { : { 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') + mintLauncherScript(reply, secret, SCRIPT_DIR, 'censorcheck ingest is not configured\n') }) 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') - } + sendVendor(reply, join(SCRIPT_DIR, 'censorcheck.sh')) + }) + + app.get('/ic', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => { + mintLauncherScript(reply, secret, IPREGION_SCRIPT_DIR, 'ipregion ingest is not configured\n') + }) + + app.get('/ic/vendor', async (_request, reply) => { + sendVendor(reply, join(IPREGION_SCRIPT_DIR, 'ipregion.sh')) }) } diff --git a/apps/api/src/services/backup-full.ts b/apps/api/src/services/backup-full.ts index cb298e3..a8187fa 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', + 'ipregion_results', + 'ipregion_runs', 'censorcheck_results', 'censorcheck_runs', 'notification_log', diff --git a/apps/api/src/services/ipregion/normalize.test.ts b/apps/api/src/services/ipregion/normalize.test.ts new file mode 100644 index 0000000..94f93c6 --- /dev/null +++ b/apps/api/src/services/ipregion/normalize.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' +import { + canonicalizeCountryValue, + inferIpregionGroup, +} from '@cfdm/shared/contracts/ipregion' +import { normalizeIngestResult, summarizeResults } from './normalize.js' + +describe('canonicalizeCountryValue', () => { + it('мапит ISO в ok', () => { + expect(canonicalizeCountryValue('RU')).toEqual({ status: 'ok', country: 'RU' }) + expect(canonicalizeCountryValue(' de ')).toEqual({ status: 'ok', country: 'DE' }) + }) + + it('мапит статусы ipregion', () => { + expect(canonicalizeCountryValue('N/A').status).toBe('na') + expect(canonicalizeCountryValue('Denied').status).toBe('denied') + expect(canonicalizeCountryValue('Rate-limit').status).toBe('rate_limit') + expect(canonicalizeCountryValue('Rate limit').status).toBe('rate_limit') + expect(canonicalizeCountryValue('Server error').status).toBe('server_error') + }) +}) + +describe('ipregion normalize', () => { + it('нормализует сервис и группу', () => { + const row = normalizeIngestResult({ + service: 'Maxmind.com', + ipv4: 'NL', + ipv6: 'N/A', + }) + expect(row.serviceKey).toBe('maxmind.com') + expect(row.group).toBe('primary') + expect(row.status).toBe('ok') + expect(row.countryIpv4).toBe('NL') + expect(row.countryIpv6).toBeNull() + }) + + it('берёт IPv6 если IPv4 N/A', () => { + const row = normalizeIngestResult({ + service: 'YouTube CDN', + group: 'cdn', + ipv4: 'N/A', + ipv6: 'US', + }) + expect(row.status).toBe('ok') + expect(row.countryIpv6).toBe('US') + expect(row.group).toBe('cdn') + }) + + it('считает summary и partial', () => { + const { summary, runStatus } = summarizeResults([ + { status: 'ok' }, + { status: 'na' }, + { status: 'denied' }, + ]) + expect(summary.total).toBe(3) + expect(summary.ok).toBe(1) + expect(summary.denied).toBe(1) + expect(runStatus).toBe('partial') + }) + + it('угадывает группу по имени', () => { + expect(inferIpregionGroup('cloudflare.com')).toBe('primary') + expect(inferIpregionGroup('Netflix')).toBe('custom') + expect(inferIpregionGroup('YouTube CDN')).toBe('cdn') + }) +}) diff --git a/apps/api/src/services/ipregion/normalize.ts b/apps/api/src/services/ipregion/normalize.ts new file mode 100644 index 0000000..a35022e --- /dev/null +++ b/apps/api/src/services/ipregion/normalize.ts @@ -0,0 +1,52 @@ +import { + canonicalizeCountryValue, + emptyIpregionSummary, + inferIpregionGroup, + type IpregionGroup, + type IpregionIngestResult, + type IpregionRunStatus, + type IpregionStatus, + type IpregionSummary, +} from '@cfdm/shared/contracts/ipregion' + +export type NormalizedIpregionResult = { + serviceKey: string + serviceLabel: string + group: IpregionGroup + countryIpv4: string | null + countryIpv6: string | null + status: IpregionStatus +} + +export function normalizeIngestResult(item: IpregionIngestResult): NormalizedIpregionResult { + const serviceLabel = item.service.trim() + const serviceKey = serviceLabel.toLowerCase() + const ipv4 = canonicalizeCountryValue(item.ipv4) + const ipv6 = canonicalizeCountryValue(item.ipv6) + const status = ipv4.status !== 'na' ? ipv4.status : ipv6.status + + return { + serviceKey, + serviceLabel, + group: item.group ?? inferIpregionGroup(serviceKey), + countryIpv4: ipv4.country, + countryIpv6: ipv6.country, + status, + } +} + +export function summarizeResults(results: { status: IpregionStatus }[]): { + summary: IpregionSummary + runStatus: IpregionRunStatus +} { + const summary = emptyIpregionSummary() + summary.total = results.length + for (const row of results) { + summary[row.status] += 1 + } + const runStatus: IpregionRunStatus = + summary.denied > 0 || summary.rate_limit > 0 || summary.server_error > 0 + ? 'partial' + : 'complete' + return { summary, runStatus } +} diff --git a/apps/web/src/components/censorcheck/blocking-snapshots.ts b/apps/web/src/components/censorcheck/blocking-snapshots.ts index 8686f96..a35d5fa 100644 --- a/apps/web/src/components/censorcheck/blocking-snapshots.ts +++ b/apps/web/src/components/censorcheck/blocking-snapshots.ts @@ -1,4 +1,8 @@ -import type { CensorcheckRunDto } from './types' +export type SnapshotRun = { + id: string + createdAt: string + probePublicIp: string +} export type BlockingSnapshotTick = { key: string @@ -24,18 +28,18 @@ export function formatSnapshotTickLabel(dayKey: string): string { }) } -export function mergeCensorcheckRuns( - current: CensorcheckRunDto[], - history: CensorcheckRunDto[], -): CensorcheckRunDto[] { - const map = new Map() +export function mergeCensorcheckRuns( + current: T[], + history: T[], +): T[] { + const map = new Map() for (const run of history) map.set(run.id, run) for (const run of current) map.set(run.id, run) return [...map.values()] } -export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapshotTick[] { - const byDay = new Map() +export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] { + const byDay = new Map() for (const run of runs) { const key = snapshotDayKey(run.createdAt) const list = byDay.get(key) @@ -56,12 +60,12 @@ export function collectSnapshotTicks(runs: CensorcheckRunDto[]): BlockingSnapsho } /** Latest run per probe IP at or before `asOf`. */ -export function latestRunsAsOf( - runs: CensorcheckRunDto[], +export function latestRunsAsOf( + runs: T[], asOf: string, -): CensorcheckRunDto[] { +): T[] { if (!asOf) return [] - const byIp = new Map() + const byIp = new Map() for (const run of runs) { if (run.createdAt > asOf) continue const previous = byIp.get(run.probePublicIp) diff --git a/apps/web/src/components/global-search.tsx b/apps/web/src/components/global-search.tsx index dbf08f7..c1629ab 100644 --- a/apps/web/src/components/global-search.tsx +++ b/apps/web/src/components/global-search.tsx @@ -9,6 +9,7 @@ import { LayoutDashboardIcon, SearchIcon, ShieldAlertIcon, + GlobeIcon, } from 'lucide-react' import { @@ -69,6 +70,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { Статус блокировок + go('/geo')}> + + GeoIP + diff --git a/apps/web/src/components/ipregion/country-matrix-cell.tsx b/apps/web/src/components/ipregion/country-matrix-cell.tsx new file mode 100644 index 0000000..d0e5180 --- /dev/null +++ b/apps/web/src/components/ipregion/country-matrix-cell.tsx @@ -0,0 +1,85 @@ +import { Badge } from '@/components/reui/badge' +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@cfdm/ui/components/tooltip' +import { COUNTRY_BY_CODE } from '@cfdm/shared/geo' +import { IPREGION_STATUS_LABELS, formatCheckedAt } from './types' + +const STATUS_VARIANT: Record< + string, + 'success-light' | 'outline' | 'destructive-outline' | 'warning-light' | 'warning-outline' +> = { + ok: 'success-light', + na: 'outline', + denied: 'destructive-outline', + rate_limit: 'warning-light', + server_error: 'warning-outline', +} + +function countryLabel(code: string | null | undefined): string { + if (!code) return '' + return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code +} + +/** Compact ISO cell — preview: https://reui.io/docs/components/base/badge · data-grid-base-4 */ +export function CountryMatrixCell({ + status, + countryIpv4, + countryIpv6, + serviceLabel, + vpsLabel, + checkedAt, + onSelect, +}: { + status?: string | null + countryIpv4?: string | null + countryIpv6?: string | null + serviceLabel: string + vpsLabel: string + checkedAt?: string + onSelect?: () => void +}) { + const iso = countryIpv4 || countryIpv6 || null + const statusLabel = status ? (IPREGION_STATUS_LABELS[status] ?? status) : 'Нет результата' + const display = status === 'ok' && iso ? iso : status ? (IPREGION_STATUS_LABELS[status] ?? status) : '—' + const variant = status ? (STATUS_VARIANT[status] ?? 'outline') : 'outline' + const tip = [ + serviceLabel, + vpsLabel, + iso ? `${iso}${countryLabel(iso) ? ` · ${countryLabel(iso)}` : ''}` : statusLabel, + countryIpv4 ? `IPv4 ${countryIpv4}` : null, + countryIpv6 ? `IPv6 ${countryIpv6}` : null, + checkedAt ? formatCheckedAt(checkedAt) : null, + ] + .filter(Boolean) + .join(' · ') + + const badge = ( + + {display} + + ) + + return ( + + { + if (!onSelect) return + event.stopPropagation() + onSelect() + }} + /> + } + > + {badge} + + {tip} + + ) +} diff --git a/apps/web/src/components/ipregion/geo-filters.test.ts b/apps/web/src/components/ipregion/geo-filters.test.ts new file mode 100644 index 0000000..ca0aa70 --- /dev/null +++ b/apps/web/src/components/ipregion/geo-filters.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from 'vitest' +import type { Filter } from '@/components/reui/filters' +import { + collectServiceColumns, + filterIpregionRuns, + isGeoMismatch, + uniqueCountries, +} from './geo-filters' +import { runHosterLabel, type IpregionRunDto } from './types' + +const run = (overrides: Partial = {}): IpregionRunDto => ({ + id: 'iprun-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', + ipregionVersion: '1', + summary: { + total: 2, + ok: 2, + na: 0, + denied: 0, + rate_limit: 0, + server_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: 'iprun-1', + serviceKey: 'maxmind.com', + serviceLabel: 'maxmind.com', + group: 'primary', + countryIpv4: 'NL', + countryIpv6: null, + status: 'ok', + }, + { + id: 'r2', + runId: 'iprun-1', + serviceKey: 'google', + serviceLabel: 'Google', + group: 'custom', + countryIpv4: 'US', + countryIpv6: null, + status: 'ok', + }, + ], + ...overrides, +}) + +describe('filterIpregionRuns', () => { + it('фильтрует по ISO страны', () => { + const filters: Filter[] = [ + { id: '1', field: 'country', operator: 'contains', values: ['NL'] }, + ] + expect(filterIpregionRuns([run()], filters)).toHaveLength(1) + expect( + filterIpregionRuns([run()], [ + { id: '1', field: 'country', operator: 'contains', values: ['JP'] }, + ]), + ).toHaveLength(0) + }) +}) + +describe('runHosterLabel', () => { + it('предпочитает имя из инвентаря', () => { + expect(runHosterLabel(run())).toBe('Hoster') + }) +}) + +describe('collectServiceColumns', () => { + it('ставит primary, затем custom, затем cdn', () => { + const cols = collectServiceColumns([run()]) + expect(cols[0]?.key).toBe('maxmind.com') + expect(cols[0]?.group).toBe('primary') + const google = cols.find((col) => col.key === 'google') + const maxmind = cols.find((col) => col.key === 'maxmind.com') + const cdn = cols.find((col) => col.key === 'cloudflare cdn') + expect(google).toBeDefined() + expect(cdn).toBeDefined() + expect(cols.indexOf(maxmind!)).toBeLessThan(cols.indexOf(google!)) + expect(cols.indexOf(google!)).toBeLessThan(cols.indexOf(cdn!)) + }) +}) + +describe('uniqueCountries / mismatch', () => { + it('собирает уникальные ISO', () => { + expect(uniqueCountries([run()]).sort()).toEqual(['NL', 'US']) + }) + + it('считает расхождение с инвентарём', () => { + expect( + isGeoMismatch( + run({ + results: [ + { + id: 'r1', + runId: 'iprun-1', + serviceKey: 'maxmind.com', + serviceLabel: 'maxmind.com', + group: 'primary', + countryIpv4: 'US', + countryIpv6: null, + status: 'ok', + }, + { + id: 'r2', + runId: 'iprun-1', + serviceKey: 'google', + serviceLabel: 'Google', + group: 'custom', + countryIpv4: 'US', + countryIpv6: null, + status: 'ok', + }, + ], + }), + ), + ).toBe(true) + expect( + isGeoMismatch( + run({ + results: [ + { + id: 'r1', + runId: 'iprun-1', + serviceKey: 'maxmind.com', + serviceLabel: 'maxmind.com', + group: 'primary', + countryIpv4: 'NL', + countryIpv6: null, + status: 'ok', + }, + ], + }), + ), + ).toBe(false) + }) +}) diff --git a/apps/web/src/components/ipregion/geo-filters.ts b/apps/web/src/components/ipregion/geo-filters.ts new file mode 100644 index 0000000..5396c5c --- /dev/null +++ b/apps/web/src/components/ipregion/geo-filters.ts @@ -0,0 +1,278 @@ +import { COUNTRY_BY_CODE, COUNTRY_BY_NAME_RU } from '@cfdm/shared/geo' +import { + IPREGION_CDN_SERVICES, + IPREGION_CUSTOM_SERVICES, + IPREGION_PRIMARY_SERVICES, +} from '@cfdm/shared/contracts/ipregion' +import { getActiveFilters } from '@/components/reui-kit' +import type { Filter } from '@/components/reui/filters' +import { + runSearchText, + type IpregionResultDto, + type IpregionRunDto, +} from './types' + +const GROUP_RANK: Record = { primary: 0, custom: 1, cdn: 2 } + +export function inventoryCountryCode(run: IpregionRunDto): string | null { + const raw = run.vps?.country?.trim() ?? '' + if (!raw) return null + if (/^[A-Za-z]{2}$/.test(raw)) return raw.toUpperCase() + return COUNTRY_BY_NAME_RU[raw.toLowerCase()]?.code ?? COUNTRY_BY_CODE[raw.toUpperCase()]?.code ?? null +} + +export function countryName(code: string | null | undefined): string { + if (!code) return '' + return COUNTRY_BY_CODE[code.toUpperCase()]?.name ?? code +} + +export function majorityCountry(run: IpregionRunDto): string | null { + const counts = new Map() + for (const row of run.results ?? []) { + const code = row.countryIpv4 || row.countryIpv6 + if (row.status !== 'ok' || !code) continue + counts.set(code, (counts.get(code) ?? 0) + 1) + } + let best: string | null = null + let bestCount = 0 + for (const [code, count] of counts) { + if (count > bestCount) { + best = code + bestCount = count + } + } + return best +} + +export function isGeoMismatch(run: IpregionRunDto): boolean { + const inventory = inventoryCountryCode(run) + const geo = majorityCountry(run) + if (!inventory || !geo) return false + return inventory !== geo +} + +export function uniqueCountries(runs: IpregionRunDto[]): string[] { + const set = new Set() + for (const run of runs) { + for (const row of run.results ?? []) { + if (row.status === 'ok' && row.countryIpv4) set.add(row.countryIpv4) + if (row.status === 'ok' && row.countryIpv6) set.add(row.countryIpv6) + } + } + return [...set].sort() +} + +export function filterIpregionRuns(runs: IpregionRunDto[], filters: Filter[]): IpregionRunDto[] { + 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.toLowerCase()), + ) + if (!hit) return false + continue + } + if (filter.field === 'hoster') { + const name = `${run.vps?.providerName ?? ''} ${run.detectedHoster ?? ''}`.toLowerCase() + const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase()) + if (!hit) return false + continue + } + if (filter.field === 'country') { + const codes = [ + inventoryCountryCode(run) ?? '', + majorityCountry(run) ?? '', + ...(run.results ?? []).flatMap((row) => [row.countryIpv4 ?? '', row.countryIpv6 ?? '']), + ] + .join(' ') + .toLowerCase() + const names = countryName(majorityCountry(run)).toLowerCase() + const hay = `${codes} ${names} ${(run.vps?.country ?? '').toLowerCase()}` + const hit = values.some((value) => hay.includes(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 GeoServiceRow = { + id: string + serviceKey: string + serviceLabel: string + group: string + probes: Array<{ + runId: string + probePublicIp: string + matchedVpsId: string | null + dns: string + country: string | null + status: string + countryIpv4: string | null + countryIpv6: string | null + createdAt: string + vpsId: string | null + }> +} + +export function groupRunsByService(runs: IpregionRunDto[]): GeoServiceRow[] { + 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: result.countryIpv4, + status: result.status, + countryIpv4: result.countryIpv4, + countryIpv6: result.countryIpv6, + 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, + group: result.group, + probes: [probe], + }) + } + } + } + return [...map.values()].sort((a, b) => { + const rank = (GROUP_RANK[a.group] ?? 9) - (GROUP_RANK[b.group] ?? 9) + if (rank !== 0) return rank + return a.serviceKey.localeCompare(b.serviceKey) + }) +} + +export type MatrixColumn = { + key: string + label: string + title: string + group: string +} + +export function shortServiceLabel(value: string): string { + const host = value.trim() + if (host.length <= 14) return host + return host.replace(/\.(com|org|net|io|co)$/i, '') +} + +function canonicalKeys(): Array<{ key: string; group: string; label: string }> { + return [ + ...IPREGION_PRIMARY_SERVICES.map((key) => ({ key, group: 'primary', label: key })), + ...IPREGION_CUSTOM_SERVICES.map((key) => ({ key, group: 'custom', label: key })), + ...IPREGION_CDN_SERVICES.map((key) => ({ key, group: 'cdn', label: key })), + ] +} + +export function collectServiceColumns(runs: IpregionRunDto[]): MatrixColumn[] { + const canonical = canonicalKeys() + const canonicalSet = new Set(canonical.map((item) => item.key)) + const extras: MatrixColumn[] = [] + for (const run of runs) { + for (const result of run.results ?? []) { + if (canonicalSet.has(result.serviceKey)) continue + if (extras.some((col) => col.key === result.serviceKey)) continue + extras.push({ + key: result.serviceKey, + label: shortServiceLabel(result.serviceLabel), + title: result.serviceLabel, + group: result.group, + }) + } + } + extras.sort((a, b) => { + const rank = (GROUP_RANK[a.group] ?? 9) - (GROUP_RANK[b.group] ?? 9) + if (rank !== 0) return rank + return a.key.localeCompare(b.key) + }) + const extrasByGroup = { + primary: extras.filter((col) => col.group === 'primary'), + custom: extras.filter((col) => col.group === 'custom'), + cdn: extras.filter((col) => col.group === 'cdn'), + } + const fromCanonical = (group: string, keys: readonly string[]) => + keys.map((key) => ({ + key, + label: shortServiceLabel(key), + title: key, + group, + })) + return [ + ...fromCanonical('primary', IPREGION_PRIMARY_SERVICES), + ...extrasByGroup.primary, + ...fromCanonical('custom', IPREGION_CUSTOM_SERVICES), + ...extrasByGroup.custom, + ...fromCanonical('cdn', IPREGION_CDN_SERVICES), + ...extrasByGroup.cdn, + ] +} + +export function collectProbeColumns(runs: IpregionRunDto[]): MatrixColumn[] { + return runs.map((run) => { + const title = run.vps?.dns || run.probePublicIp + return { + key: run.id, + label: shortServiceLabel(title), + title, + group: 'probe', + } + }) +} + +export function resultByService( + run: IpregionRunDto, + serviceKey: string, +): IpregionResultDto | undefined { + return (run.results ?? []).find((row) => row.serviceKey === serviceKey) +} + +export function serviceMatrixRows(runs: IpregionRunDto[]): GeoServiceRow[] { + const grouped = new Map(groupRunsByService(runs).map((row) => [row.serviceKey, row])) + return collectServiceColumns(runs).map((col) => { + const existing = grouped.get(col.key) + if (existing) return existing + return { + id: col.key, + serviceKey: col.key, + serviceLabel: col.title, + group: col.group, + probes: [], + } + }) +} diff --git a/apps/web/src/components/ipregion/geo-grid.tsx b/apps/web/src/components/ipregion/geo-grid.tsx new file mode 100644 index 0000000..adba13a --- /dev/null +++ b/apps/web/src/components/ipregion/geo-grid.tsx @@ -0,0 +1,192 @@ +import { useMemo, type ReactNode } from 'react' +import { Link } from '@tanstack/react-router' +import { GlobeIcon, ServerIcon } from 'lucide-react' + +import type { DataGridColumn } from '@/components/data-grid-types' +import { dataGridCellStack } from '@/components/data-grid-cells' +import { columnDefFromDataGrid, FrameDataGrid } from '@/components/reui-kit' +import { + collectProbeColumns, + collectServiceColumns, + resultByService, + type GeoServiceRow, +} from './geo-filters' +import { CountryMatrixCell } from './country-matrix-cell' +import { runHosterLabel, type IpregionRunDto } from './types' + +/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */ +export const GEO_MATRIX_GRID = { + tableWidth: 'auto' as const, + horizontalScroll: true, +} + +const MATRIX_CELL = 'w-16 min-w-16 px-1 text-center' + +function vpsIdentityColumn(): DataGridColumn { + return { + key: 'vps', + header: 'VPS / IP', + headerTitle: 'VPS / IP', + icon: ServerIcon, + enableHiding: false, + enablePinning: true, + size: 240, + minSize: 200, + sortValue: (row) => row.vps?.dns || row.probePublicIp, + cell: (row) => { + const title = row.vps?.dns || row.probePublicIp + const ip = row.probePublicIp + const hoster = runHosterLabel(row) + const secondary = hoster ? `${ip} · ${hoster}` : ip + const link = row.matchedVpsId ? ( + event.stopPropagation()} + > + {title} + + ) : ( + Unknown VPS + ) + return dataGridCellStack(link, secondary) + }, + } +} + +export function GeoVpsGrid({ + runs, + onRowClick, + emptyAction, +}: { + runs: IpregionRunDto[] + onRowClick: (run: IpregionRunDto) => void + emptyAction?: ReactNode +}) { + const serviceCols = useMemo(() => collectServiceColumns(runs), [runs]) + const columns = useMemo((): DataGridColumn[] => { + return [ + vpsIdentityColumn(), + ...serviceCols.map( + (svc): DataGridColumn => ({ + key: `svc:${svc.key}`, + header: svc.label, + headerTitle: svc.title, + className: MATRIX_CELL, + headerClassName: MATRIX_CELL, + size: 72, + minSize: 64, + sortable: true, + sortValue: (row) => resultByService(row, svc.key)?.countryIpv4 ?? resultByService(row, svc.key)?.status ?? '', + cell: (row) => { + const item = resultByService(row, svc.key) + return ( + + ) + }, + }), + ), + ] + }, [serviceCols]) + + return ( + row.id} + dense + pagination={runs.length > 10} + pinLeftColumnIds={['vps']} + {...GEO_MATRIX_GRID} + emptyTitle="Нет проверок" + emptyDescription="Запустите launcher на VPS, чтобы увидеть страны GeoIP." + emptyAction={emptyAction} + onRowClick={onRowClick} + /> + ) +} + +export function GeoServiceGrid({ + groups, + runs, + onProbeClick, + emptyAction, +}: { + groups: GeoServiceRow[] + runs: IpregionRunDto[] + onProbeClick: (run: IpregionRunDto) => void + emptyAction?: ReactNode +}) { + const probeCols = useMemo(() => collectProbeColumns(runs), [runs]) + const runById = useMemo(() => new Map(runs.map((row) => [row.id, row])), [runs]) + + const columns = useMemo((): DataGridColumn[] => { + return [ + { + key: 'service', + header: 'Сервис', + headerTitle: 'Сервис', + icon: GlobeIcon, + enableHiding: false, + enablePinning: true, + size: 180, + minSize: 140, + sortValue: (row) => row.serviceKey, + cell: (row) => dataGridCellStack(row.serviceLabel, row.group), + }, + ...probeCols.map( + (probe): DataGridColumn => ({ + key: `probe:${probe.key}`, + header: probe.label, + headerTitle: probe.title, + className: MATRIX_CELL, + headerClassName: MATRIX_CELL, + size: 72, + minSize: 64, + sortable: true, + sortValue: (row) => + row.probes.find((item) => item.runId === probe.key)?.countryIpv4 ?? + row.probes.find((item) => item.runId === probe.key)?.status ?? + '', + cell: (row) => { + const item = row.probes.find((probeRow) => probeRow.runId === probe.key) + const run = runById.get(probe.key) + return ( + onProbeClick(run) : undefined} + /> + ) + }, + }), + ), + ] + }, [onProbeClick, probeCols, runById]) + + return ( + row.id} + dense + pagination={groups.length > 10} + pinLeftColumnIds={['service']} + {...GEO_MATRIX_GRID} + emptyTitle="Нет сервисов" + emptyAction={emptyAction} + /> + ) +} diff --git a/apps/web/src/components/ipregion/geo-page.tsx b/apps/web/src/components/ipregion/geo-page.tsx new file mode 100644 index 0000000..951b70d --- /dev/null +++ b/apps/web/src/components/ipregion/geo-page.tsx @@ -0,0 +1,301 @@ +import { useMemo, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { + CopyIcon, + GlobeIcon, + MapPinIcon, + 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 { + ipregionCurrentQueryOptions, + ipregionHistoryQueryOptions, +} from '@/queries/ipregion' +import { StatusBadge } from '@/components/status-badge' +import type { DataGridColumn } from '@/components/data-grid-types' +import { BlockingSnapshotScrubber } from '@/components/censorcheck/blocking-snapshot-scrubber' +import { + collectSnapshotTicks, + latestRunsAsOf, + mergeCensorcheckRuns, + resolveSnapshotIndex, +} from '@/components/censorcheck/blocking-snapshots' +import { GeoServiceGrid, GeoVpsGrid } from './geo-grid' +import { GeoRunSheet } from './geo-run-sheet' +import { + filterIpregionRuns, + isGeoMismatch, + serviceMatrixRows, + uniqueCountries, +} from './geo-filters' +import { + IPREGION_STATUS_LABELS, + LAUNCHER_CMD, + formatCheckedAt, + type IpregionRunDto, +} from './types' + +type GroupMode = 'vps' | 'service' +type TabId = 'current' | 'history' + +const FILTER_FIELDS: FilterFieldConfig[] = [ + { key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, ISO' }, + { + key: 'status', + label: 'Статус', + type: 'multiselect', + defaultOperator: 'is_any_of', + options: [ + { value: 'ok', label: 'Страна' }, + { value: 'na', label: 'N/A' }, + { value: 'denied', label: 'Отказ' }, + { value: 'rate_limit', label: 'Лимит' }, + { value: 'server_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: 'OK / всего', + sortValue: (row) => row.summary.ok, + sortingFn: 'basic', + cell: (row) => `${row.summary.ok} / ${row.summary.total}`, + }, + { + key: 'createdAt', + header: 'Проверено', + sortValue: (row) => row.createdAt, + cell: (row) => formatCheckedAt(row.createdAt), + }, +] + +export function GeoPage() { + const { spaceId } = useSpaceId() + const [tab, setTab] = useState('current') + const [group, setGroup] = useState('vps') + const [filters, setFilters] = useState([]) + const [selected, setSelected] = useState(null) + const [snapshotIndex, setSnapshotIndex] = useState(null) + + const currentQuery = useQuery(ipregionCurrentQueryOptions(spaceId)) + const historyQuery = useQuery(ipregionHistoryQueryOptions({ limit: 200 }, spaceId)) + + const allRuns = useMemo( + () => mergeCensorcheckRuns(currentQuery.data?.items ?? [], historyQuery.data?.items ?? []), + [currentQuery.data?.items, historyQuery.data?.items], + ) + const ticks = useMemo(() => collectSnapshotTicks(allRuns), [allRuns]) + const resolvedIndex = resolveSnapshotIndex(ticks.length, snapshotIndex) + const snapshotRuns = useMemo(() => { + const asOf = ticks[resolvedIndex]?.asOf + if (!asOf) return currentQuery.data?.items ?? [] + return latestRunsAsOf(allRuns, asOf) + }, [allRuns, currentQuery.data?.items, resolvedIndex, ticks]) + const filtered = useMemo( + () => filterIpregionRuns(snapshotRuns, filters), + [snapshotRuns, filters], + ) + const serviceGroups = useMemo(() => serviceMatrixRows(filtered), [filtered]) + + const matched = filtered.filter((row) => row.matchedVpsId).length + const countries = uniqueCountries(filtered).length + const mismatches = filtered.filter(isGeoMismatch).length + + const copyLauncher = ( + + ) + + return ( + + + , + }, + { + id: 'matched', + label: 'Известные VPS', + value: matched, + icon: , + }, + { + id: 'countries', + label: 'Уникальные страны', + value: countries, + icon: , + }, + { + id: 'mismatch', + label: 'Расхождения GeoIP', + value: mismatches, + icon: , + variant: mismatches > 0 ? 'warning' : '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/ipregion/geo-run-sheet.tsx b/apps/web/src/components/ipregion/geo-run-sheet.tsx new file mode 100644 index 0000000..64f3951 --- /dev/null +++ b/apps/web/src/components/ipregion/geo-run-sheet.tsx @@ -0,0 +1,133 @@ +import { Link } from '@tanstack/react-router' +import { Building2Icon, 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 { Badge } from '@/components/reui/badge' +import { ipregionRunQueryOptions } from '@/queries/ipregion' +import { countryName } from './geo-filters' +import { + IPREGION_STATUS_LABELS, + formatCheckedAt, + formatVpsResources, + runHosterLabel, + type IpregionRunDto, +} from './types' + +interface GeoRunSheetProps { + run: IpregionRunDto | null + open: boolean + onOpenChange: (open: boolean) => void +} + +export function GeoRunSheet({ run, open, onOpenChange }: GeoRunSheetProps) { + const needFetch = Boolean(run && !run.results) + const { data: fetched } = useQuery({ + ...ipregionRunQueryOptions(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: 'hoster', + icon: , + label: 'Хостер', + description: runHosterLabel(detail) || '—', + }, + { + 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.group} +
+ {item.status === 'ok' && item.countryIpv4 ? ( + + {item.countryIpv4} + {countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''} + + ) : ( + + )} +
+ ))} +
+
+
+ ) : ( +
+ + Нет данных прогона +
+ )} +
+
+ ) +} diff --git a/apps/web/src/components/ipregion/types.ts b/apps/web/src/components/ipregion/types.ts new file mode 100644 index 0000000..ebda039 --- /dev/null +++ b/apps/web/src/components/ipregion/types.ts @@ -0,0 +1,89 @@ +import type { IpregionSummary } from '@cfdm/shared/contracts/ipregion' + +export type IpregionResultDto = { + id: string + runId: string + serviceKey: string + serviceLabel: string + group: string + countryIpv4: string | null + countryIpv6: string | null + status: string +} + +export type IpregionVpsInfo = { + id: string + ip: string + dns: string + providerId: string + providerName: string + country: string + city: string + datacenter: string + vcpu: number + ramGb: number + diskGb: number +} + +export type IpregionRunDto = { + id: string + spaceId: string + runId: string + probePublicIp: string + claimedPublicIp: string | null + matchedVpsId: string | null + status: string + schemaVersion: number + launcherVersion: string | null + ipregionVersion: string | null + summary: IpregionSummary + createdAt: string + completedAt: string + observedSourceIp: string | null + detectedHoster?: string | null + vps: IpregionVpsInfo | null + results?: IpregionResultDto[] +} + +export const IPREGION_STATUS_LABELS: Record = { + ok: 'Страна', + na: 'N/A', + denied: 'Отказ', + rate_limit: 'Лимит', + server_error: 'Ошибка', + complete: 'Полный', + partial: 'Частичный', +} + +export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/ic | 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 runHosterLabel(run: IpregionRunDto): string { + const inventory = run.vps?.providerName?.trim() ?? '' + if (inventory) return inventory + return run.detectedHoster?.trim() ?? '' +} + +export function runSearchText(run: IpregionRunDto): string { + const parts = [ + run.probePublicIp, + run.claimedPublicIp ?? '', + run.vps?.dns ?? '', + run.vps?.providerName ?? '', + run.detectedHoster ?? '', + run.vps?.country ?? '', + ...(run.results ?? []).map( + (row) => `${row.serviceKey} ${row.serviceLabel} ${row.countryIpv4 ?? ''} ${row.countryIpv6 ?? ''}`, + ), + ] + return parts.join(' ').toLowerCase() +} diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx index 894544f..d04c25a 100644 --- a/apps/web/src/components/layout/app-shell.tsx +++ b/apps/web/src/components/layout/app-shell.tsx @@ -15,6 +15,7 @@ import { UsersIcon, Network, ShieldAlert, + Globe, } from 'lucide-react' import { @@ -84,6 +85,7 @@ const NAV_GROUPS: NavGroup[] = [ items: [ { to: '/vps', label: 'VPS', icon: Server }, { to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert }, + { to: '/geo', label: 'GeoIP', icon: Globe }, { to: '/topology', label: 'Схема', icon: Network }, { to: '/tariffs', label: 'Активные тарифы', icon: ServerCog }, { to: '/providers', label: 'Хостеры', icon: Building2 }, diff --git a/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts index df7c05d..4a63e69 100644 --- a/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts +++ b/apps/web/src/components/reui-kit/data-grid-kit-defaults.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { kitDataGridTableLayout } from './frame-data-grid' import { BLOCKING_MATRIX_GRID } from '../censorcheck/blocking-grid' +import { GEO_MATRIX_GRID } from '../ipregion/geo-grid' describe('kitDataGridTableLayout', () => { it('CRUD defaults: без bg-muted header и width fixed', () => { @@ -29,3 +30,10 @@ describe('BLOCKING_MATRIX_GRID', () => { expect(BLOCKING_MATRIX_GRID.horizontalScroll).toBe(true) }) }) + +describe('GEO_MATRIX_GRID', () => { + it('data-grid-base-4: auto + horizontal scroll', () => { + expect(GEO_MATRIX_GRID.tableWidth).toBe('auto') + expect(GEO_MATRIX_GRID.horizontalScroll).toBe(true) + }) +}) diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index dfffb36..cae55cf 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -448,6 +448,37 @@ export const api = { fetchApi( `/api/censorcheck/runs/${encodeURIComponent(id)}`, ), + + fetchIpregionCurrent: () => + fetchApi<{ items: import('@/components/ipregion/types').IpregionRunDto[] }>( + '/api/ipregion/current', + ), + + fetchIpregionRuns: (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/ipregion/types').IpregionRunDto[] + nextCursor: string | null + }>(`/api/ipregion/runs${qs ? `?${qs}` : ''}`) + }, + + fetchIpregionRun: (id: string) => + fetchApi( + `/api/ipregion/runs/${encodeURIComponent(id)}`, + ), } export type { diff --git a/apps/web/src/lib/auth.ts b/apps/web/src/lib/auth.ts index f2d9973..156809b 100644 --- a/apps/web/src/lib/auth.ts +++ b/apps/web/src/lib/auth.ts @@ -235,6 +235,7 @@ export function permissionForPath(pathname: string): string | null { if ( pathname.startsWith('/vps') || pathname.startsWith('/blocking') || + pathname.startsWith('/geo') || pathname.startsWith('/topology') || pathname.startsWith('/tariffs') || pathname.startsWith('/projects') || diff --git a/apps/web/src/queries/ipregion.ts b/apps/web/src/queries/ipregion.ts new file mode 100644 index 0000000..09d6605 --- /dev/null +++ b/apps/web/src/queries/ipregion.ts @@ -0,0 +1,40 @@ +import { queryClient } from '../lib/queryClient' +import { api } from '../lib/api-client' +import { getStoredSpaceId } from '../lib/space' + +export const ipregionKeys = { + all: ['ipregion'] as const, + current: (spaceId: string | null) => ['ipregion', 'current', spaceId ?? 'default'] as const, + history: (spaceId: string | null, params: Record) => + ['ipregion', 'history', spaceId ?? 'default', params] as const, + detail: (id: string) => ['ipregion', 'run', id] as const, +} + +export const ipregionCurrentQueryOptions = (spaceId?: string | null) => { + const id = spaceId === undefined ? getStoredSpaceId() : spaceId + return { + queryKey: ipregionKeys.current(id), + queryFn: () => api.fetchIpregionCurrent(), + staleTime: 15_000, + } +} + +export const ipregionHistoryQueryOptions = ( + params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {}, + spaceId?: string | null, +) => { + const id = spaceId === undefined ? getStoredSpaceId() : spaceId + return { + queryKey: ipregionKeys.history(id, params), + queryFn: () => api.fetchIpregionRuns({ limit: 50, ...params }), + staleTime: 15_000, + } +} + +export const ipregionRunQueryOptions = (id: string | null) => ({ + queryKey: ipregionKeys.detail(id ?? ''), + queryFn: () => api.fetchIpregionRun(id!), + enabled: Boolean(id), +}) + +export { queryClient } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 0215904..46dad30 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -23,6 +23,7 @@ import { Route as AuthRenewalsRouteImport } from './routes/_auth/renewals' 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 AuthGeoRouteImport } from './routes/_auth/geo' import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard' import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking' import { Route as AuthBalanceRouteImport } from './routes/_auth/balance' @@ -105,6 +106,11 @@ const AuthPaymentsRoute = AuthPaymentsRouteImport.update({ path: '/payments', getParentRoute: () => AuthRoute, } as any) +const AuthGeoRoute = AuthGeoRouteImport.update({ + id: '/geo', + path: '/geo', + getParentRoute: () => AuthRoute, +} as any) const AuthDashboardRoute = AuthDashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', @@ -176,6 +182,7 @@ export interface FileRoutesByFullPath { '/balance': typeof AuthBalanceRoute '/blocking': typeof AuthBlockingRoute '/dashboard': typeof AuthDashboardRoute + '/geo': typeof AuthGeoRoute '/payments': typeof AuthPaymentsRoute '/projects': typeof AuthProjectsRouteWithChildren '/providers': typeof AuthProvidersRoute @@ -202,6 +209,7 @@ export interface FileRoutesByTo { '/balance': typeof AuthBalanceRoute '/blocking': typeof AuthBlockingRoute '/dashboard': typeof AuthDashboardRoute + '/geo': typeof AuthGeoRoute '/payments': typeof AuthPaymentsRoute '/projects': typeof AuthProjectsRouteWithChildren '/providers': typeof AuthProvidersRoute @@ -231,6 +239,7 @@ export interface FileRoutesById { '/_auth/balance': typeof AuthBalanceRoute '/_auth/blocking': typeof AuthBlockingRoute '/_auth/dashboard': typeof AuthDashboardRoute + '/_auth/geo': typeof AuthGeoRoute '/_auth/payments': typeof AuthPaymentsRoute '/_auth/projects': typeof AuthProjectsRouteWithChildren '/_auth/providers': typeof AuthProvidersRoute @@ -260,6 +269,7 @@ export interface FileRouteTypes { | '/balance' | '/blocking' | '/dashboard' + | '/geo' | '/payments' | '/projects' | '/providers' @@ -286,6 +296,7 @@ export interface FileRouteTypes { | '/balance' | '/blocking' | '/dashboard' + | '/geo' | '/payments' | '/projects' | '/providers' @@ -314,6 +325,7 @@ export interface FileRouteTypes { | '/_auth/balance' | '/_auth/blocking' | '/_auth/dashboard' + | '/_auth/geo' | '/_auth/payments' | '/_auth/projects' | '/_auth/providers' @@ -440,6 +452,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthPaymentsRouteImport parentRoute: typeof AuthRoute } + '/_auth/geo': { + id: '/_auth/geo' + path: '/geo' + fullPath: '/geo' + preLoaderRoute: typeof AuthGeoRouteImport + parentRoute: typeof AuthRoute + } '/_auth/dashboard': { id: '/_auth/dashboard' path: '/dashboard' @@ -574,6 +593,7 @@ interface AuthRouteChildren { AuthBalanceRoute: typeof AuthBalanceRoute AuthBlockingRoute: typeof AuthBlockingRoute AuthDashboardRoute: typeof AuthDashboardRoute + AuthGeoRoute: typeof AuthGeoRoute AuthPaymentsRoute: typeof AuthPaymentsRoute AuthProjectsRoute: typeof AuthProjectsRouteWithChildren AuthProvidersRoute: typeof AuthProvidersRoute @@ -594,6 +614,7 @@ const AuthRouteChildren: AuthRouteChildren = { AuthBalanceRoute: AuthBalanceRoute, AuthBlockingRoute: AuthBlockingRoute, AuthDashboardRoute: AuthDashboardRoute, + AuthGeoRoute: AuthGeoRoute, AuthPaymentsRoute: AuthPaymentsRoute, AuthProjectsRoute: AuthProjectsRouteWithChildren, AuthProvidersRoute: AuthProvidersRoute, diff --git a/apps/web/src/routes/_auth/geo.tsx b/apps/web/src/routes/_auth/geo.tsx new file mode 100644 index 0000000..32c4dde --- /dev/null +++ b/apps/web/src/routes/_auth/geo.tsx @@ -0,0 +1,10 @@ +import { createFileRoute } from '@tanstack/react-router' + +import { GeoPage } from '@/components/ipregion/geo-page' +import { ipregionCurrentQueryOptions } from '@/queries/ipregion' + +export const Route = createFileRoute('/_auth/geo')({ + loader: ({ context: { queryClient } }) => + queryClient.ensureQueryData(ipregionCurrentQueryOptions()), + component: GeoPage, +}) diff --git a/packages/db/src/repositories/ipregion.ts b/packages/db/src/repositories/ipregion.ts new file mode 100644 index 0000000..7031371 --- /dev/null +++ b/packages/db/src/repositories/ipregion.ts @@ -0,0 +1,340 @@ +import { and, desc, eq, isNotNull, isNull, like, or, sql } from 'drizzle-orm' +import type { + IpregionGroup, + IpregionRunStatus, + IpregionStatus, + IpregionSummary, +} from '@cfdm/shared/contracts/ipregion' +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.ipregionRuns.$inferSelect +type ResultRow = typeof schema.ipregionResults.$inferSelect + +export type IpregionResultDto = { + id: string + runId: string + serviceKey: string + serviceLabel: string + group: IpregionGroup + countryIpv4: string | null + countryIpv6: string | null + status: IpregionStatus +} + +export type IpregionVpsInfo = { + id: string + ip: string + dns: string + providerId: string + providerName: string + country: string + city: string + datacenter: string + vcpu: number + ramGb: number + diskGb: number +} + +export type IpregionRunDto = { + id: string + spaceId: string + runId: string + probePublicIp: string + claimedPublicIp: string | null + matchedVpsId: string | null + status: IpregionRunStatus + schemaVersion: number + launcherVersion: string | null + ipregionVersion: string | null + summary: IpregionSummary + createdAt: string + completedAt: string + observedSourceIp: string | null + detectedHoster: string | null + vps: IpregionVpsInfo | null + results?: IpregionResultDto[] +} + +export type IpregionInsertResult = { + serviceKey: string + serviceLabel: string + group: IpregionGroup + countryIpv4: string | null + countryIpv6: string | null + status: IpregionStatus +} + +export type IpregionInsertRun = { + spaceId: string + runId: string + probePublicIp: string + claimedPublicIp: string | null + matchedVpsId: string | null + status: IpregionRunStatus + schemaVersion: number + launcherVersion: string | null + ipregionVersion: string | null + summary: IpregionSummary + observedSourceIp: string | null + detectedHoster: string | null + results: IpregionInsertResult[] +} + +export type IpregionHistoryQuery = { + cursor?: string + limit?: number + q?: string + status?: string + matched?: boolean +} + +function parseSummary(raw: string | null | undefined): IpregionSummary { + try { + const parsed = raw ? (JSON.parse(raw) as Partial) : {} + return { + total: Number(parsed.total) || 0, + ok: Number(parsed.ok) || 0, + na: Number(parsed.na) || 0, + denied: Number(parsed.denied) || 0, + rate_limit: Number(parsed.rate_limit) || 0, + server_error: Number(parsed.server_error) || 0, + } + } catch { + return { + total: 0, + ok: 0, + na: 0, + denied: 0, + rate_limit: 0, + server_error: 0, + } + } +} + +function toResultDto(row: ResultRow): IpregionResultDto { + return { + id: row.id, + runId: row.runId, + serviceKey: row.serviceKey, + serviceLabel: row.serviceLabel, + group: row.serviceGroup as IpregionGroup, + countryIpv4: row.countryIpv4 ?? null, + countryIpv6: row.countryIpv6 ?? null, + status: row.status as IpregionStatus, + } +} + +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): IpregionVpsInfo | 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): IpregionRunDto { + const dto: IpregionRunDto = { + id: row.id, + spaceId: row.spaceId, + runId: row.runId, + probePublicIp: row.probePublicIp, + claimedPublicIp: row.claimedPublicIp ?? null, + matchedVpsId: row.matchedVpsId ?? null, + status: row.status as IpregionRunStatus, + schemaVersion: row.schemaVersion, + launcherVersion: row.launcherVersion ?? null, + ipregionVersion: row.ipregionVersion ?? null, + summary: parseSummary(row.summaryJson), + createdAt: row.createdAt, + completedAt: row.completedAt, + observedSourceIp: row.observedSourceIp ?? null, + detectedHoster: row.detectedHoster ?? null, + vps: hydrateVps(row.matchedVpsId ?? null), + } + if (includeResults) { + dto.results = listResults(row.id) + } + return dto +} + +function listResults(internalRunId: string): IpregionResultDto[] { + return getDb() + .select() + .from(schema.ipregionResults) + .where(eq(schema.ipregionResults.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 ipregionRepository = { + getByClientRunId(runId: string): IpregionRunDto | undefined { + const row = getDb() + .select() + .from(schema.ipregionRuns) + .where(eq(schema.ipregionRuns.runId, runId)) + .get() + return row ? toRunDto(row, true) : undefined + }, + + getById(id: string): IpregionRunDto | undefined { + const spaceId = getCurrentSpaceId() + const row = getDb() + .select() + .from(schema.ipregionRuns) + .where(and(eq(schema.ipregionRuns.id, id), eq(schema.ipregionRuns.spaceId, spaceId))) + .get() + return row ? toRunDto(row, true) : undefined + }, + + create(input: IpregionInsertRun): IpregionRunDto { + const db = getDb() + const now = new Date().toISOString() + const id = generateId('iprun') + db.transaction(() => { + db.insert(schema.ipregionRuns) + .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, + ipregionVersion: input.ipregionVersion, + summaryJson: JSON.stringify(input.summary), + createdAt: now, + completedAt: now, + observedSourceIp: input.observedSourceIp, + detectedHoster: input.detectedHoster, + }) + .run() + for (const result of input.results) { + db.insert(schema.ipregionResults) + .values({ + id: generateId('ipres'), + runId: id, + serviceKey: result.serviceKey, + serviceLabel: result.serviceLabel, + serviceGroup: result.group, + countryIpv4: result.countryIpv4, + countryIpv6: result.countryIpv6, + status: result.status, + }) + .run() + } + }) + return this.getByClientRunId(input.runId)! + }, + + listCurrent(): IpregionRunDto[] { + const spaceId = getCurrentSpaceId() + const sqlite = getSqlite() + const rows = sqlite + .prepare( + `SELECT * FROM ipregion_runs r + WHERE r.spaceId = ? + AND r.id = ( + SELECT r2.id FROM ipregion_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: IpregionHistoryQuery = {}): { + items: IpregionRunDto[] + 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.ipregionRuns.spaceId, spaceId)] + + if (q) { + const pattern = `%${q}%` + clauses.push( + or( + like(schema.ipregionRuns.probePublicIp, pattern), + like(schema.ipregionRuns.claimedPublicIp, pattern), + like(schema.ipregionRuns.runId, pattern), + like(schema.ipregionRuns.detectedHoster, pattern), + )!, + ) + } + if (query.status) { + clauses.push(eq(schema.ipregionRuns.status, query.status)) + } + if (query.matched === true) { + clauses.push(isNotNull(schema.ipregionRuns.matchedVpsId)) + } else if (query.matched === false) { + clauses.push(isNull(schema.ipregionRuns.matchedVpsId)) + } + + const cursor = query.cursor ? decodeCursor(query.cursor) : null + if (cursor) { + clauses.push( + sql`(${schema.ipregionRuns.createdAt} < ${cursor.createdAt} OR (${schema.ipregionRuns.createdAt} = ${cursor.createdAt} AND ${schema.ipregionRuns.id} < ${cursor.id}))`, + ) + } + + const rows = getDb() + .select() + .from(schema.ipregionRuns) + .where(and(...clauses)) + .orderBy(desc(schema.ipregionRuns.createdAt), desc(schema.ipregionRuns.id)) + .limit(limit + 1) + .all() + + const page = rows.slice(0, limit) + const last = page[page.length - 1] + return { + items: page.map((row) => toRunDto(row, true)), + nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null, + } + }, +} diff --git a/packages/db/src/repositories/spaces.ts b/packages/db/src/repositories/spaces.ts index 1b81428..1e8153f 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', + 'ipregion_results', + 'ipregion_runs', 'censorcheck_results', 'censorcheck_runs', 'notification_log', diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index 1bdba3b..4eb8ba4 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -316,6 +316,38 @@ const CORE_TABLE_MIGRATIONS: string[] = [ `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)`, + `CREATE TABLE IF NOT EXISTS ipregion_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, + ipregionVersion TEXT, + summaryJson TEXT NOT NULL DEFAULT '{}', + createdAt TEXT NOT NULL, + completedAt TEXT NOT NULL, + observedSourceIp TEXT, + detectedHoster TEXT + )`, + `CREATE TABLE IF NOT EXISTS ipregion_results ( + id TEXT PRIMARY KEY, + runId TEXT NOT NULL REFERENCES ipregion_runs(id) ON DELETE CASCADE, + serviceKey TEXT NOT NULL, + serviceLabel TEXT NOT NULL, + "group" TEXT NOT NULL, + countryIpv4 TEXT, + countryIpv6 TEXT, + status TEXT NOT NULL + )`, + `CREATE INDEX IF NOT EXISTS ipregion_runs_probe_created ON ipregion_runs(probePublicIp, createdAt)`, + `CREATE INDEX IF NOT EXISTS ipregion_runs_matched_created ON ipregion_runs(matchedVpsId, createdAt)`, + `CREATE INDEX IF NOT EXISTS ipregion_runs_created ON ipregion_runs(createdAt)`, + `CREATE INDEX IF NOT EXISTS ipregion_results_runId ON ipregion_results(runId)`, + `CREATE INDEX IF NOT EXISTS ipregion_results_service_status ON ipregion_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 c9842c8..520deda 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -415,4 +415,54 @@ export const censorcheckResults = sqliteTable( }), ) +export const ipregionRuns = sqliteTable( + 'ipregion_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'), + ipregionVersion: text('ipregionVersion'), + summaryJson: text('summaryJson').notNull().default('{}'), + createdAt: text('createdAt').notNull(), + completedAt: text('completedAt').notNull(), + observedSourceIp: text('observedSourceIp'), + detectedHoster: text('detectedHoster'), + }, + (t) => ({ + runIdUniq: uniqueIndex('ipregion_runs_runId').on(t.runId), + probeCreated: index('ipregion_runs_probe_created').on(t.probePublicIp, t.createdAt), + matchedCreated: index('ipregion_runs_matched_created').on(t.matchedVpsId, t.createdAt), + created: index('ipregion_runs_created').on(t.createdAt), + }), +) + +export const ipregionResults = sqliteTable( + 'ipregion_results', + { + id: text('id').primaryKey(), + runId: text('runId') + .notNull() + .references(() => ipregionRuns.id, { onDelete: 'cascade' }), + serviceKey: text('serviceKey').notNull(), + serviceLabel: text('serviceLabel').notNull(), + serviceGroup: text('group').notNull(), + countryIpv4: text('countryIpv4'), + countryIpv6: text('countryIpv6'), + status: text('status').notNull(), + }, + (t) => ({ + runIdx: index('ipregion_results_runId').on(t.runId), + serviceStatus: index('ipregion_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 c86b401..0d43393 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -314,6 +314,35 @@ CREATE TABLE IF NOT EXISTS censorcheck_results ( detail TEXT, rawJson TEXT ); + +CREATE TABLE IF NOT EXISTS ipregion_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, + ipregionVersion TEXT, + summaryJson TEXT NOT NULL DEFAULT '{}', + createdAt TEXT NOT NULL, + completedAt TEXT NOT NULL, + observedSourceIp TEXT, + detectedHoster TEXT +); + +CREATE TABLE IF NOT EXISTS ipregion_results ( + id TEXT PRIMARY KEY, + runId TEXT NOT NULL, + serviceKey TEXT NOT NULL, + serviceLabel TEXT NOT NULL, + "group" TEXT NOT NULL, + countryIpv4 TEXT, + countryIpv6 TEXT, + status TEXT NOT NULL +); ` export function resetTestDb(): void { diff --git a/packages/shared/src/contracts/ipregion.ts b/packages/shared/src/contracts/ipregion.ts new file mode 100644 index 0000000..f9a620c --- /dev/null +++ b/packages/shared/src/contracts/ipregion.ts @@ -0,0 +1,149 @@ +import { z } from 'zod' + +export const IPREGION_STATUSES = [ + 'ok', + 'na', + 'denied', + 'rate_limit', + 'server_error', +] as const + +export type IpregionStatus = (typeof IPREGION_STATUSES)[number] + +export const IPREGION_GROUPS = ['primary', 'custom', 'cdn'] as const + +export type IpregionGroup = (typeof IPREGION_GROUPS)[number] + +export const IPREGION_RUN_STATUSES = ['complete', 'partial'] as const + +export type IpregionRunStatus = (typeof IPREGION_RUN_STATUSES)[number] + +/** GeoIP-сервисы vernette/ipregion (pin 7d1c25c), display names из JSON. */ +export const IPREGION_PRIMARY_SERVICES = [ + 'maxmind.com', + 'rdap.db.ripe.net', + 'ipinfo.io', + 'cloudflare.com', + 'ipregistry.co', + 'ipapi.co', + 'ifconfig.co', + 'ip2location.io', + 'iplocation.com', + 'country.is', + 'geoapify.com', + 'geojs.io', + 'ipapi.is', + 'ipbase.com', + 'ipquery.io', + 'ipwho.is', + 'ip-api.com', +] as const + +export const IPREGION_CUSTOM_SERVICES = [ + 'google', + 'youtube', + 'twitch', + 'chatgpt', + 'netflix', + 'spotify', + 'reddit', + 'disney+', + 'gemini supported', + 'reddit (guest access)', + 'youtube premium', + 'google search captcha', + 'spotify signup', + 'disney+ access', + 'apple', + 'steam', + 'tiktok', + 'ookla speedtest', + 'jetbrains', + 'playstation', + 'microsoft', +] as const + +export const IPREGION_CDN_SERVICES = [ + 'cloudflare cdn', + 'youtube cdn', + 'netflix cdn', +] as const + +const PRIMARY_SET = new Set(IPREGION_PRIMARY_SERVICES) +const CUSTOM_SET = new Set(IPREGION_CUSTOM_SERVICES) +const CDN_SET = new Set(IPREGION_CDN_SERVICES) + +export function inferIpregionGroup(serviceKey: string): IpregionGroup { + const key = serviceKey.trim().toLowerCase() + if (PRIMARY_SET.has(key)) return 'primary' + if (CDN_SET.has(key)) return 'cdn' + if (CUSTOM_SET.has(key)) return 'custom' + return 'custom' +} + +export const ipregionStatusSchema = z.enum(IPREGION_STATUSES) +export const ipregionGroupSchema = z.enum(IPREGION_GROUPS) + +export const ipregionIngestResultSchema = z.object({ + service: z.string().trim().min(1).max(253), + group: ipregionGroupSchema.optional(), + ipv4: z.string().trim().max(64).nullish(), + ipv6: z.string().trim().max(64).nullish(), +}) + +export const ipregionIngestBodySchema = 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), + hoster: z.string().trim().max(160).optional(), + }), + ipregion: z + .object({ + version: z.string().trim().max(32).optional(), + }) + .optional(), + launcherVersion: z.string().trim().max(32).optional(), + results: z.array(ipregionIngestResultSchema).min(1).max(200), +}) + +export type IpregionIngestBody = z.infer +export type IpregionIngestResult = z.infer + +export type IpregionSummary = { + total: number + ok: number + na: number + denied: number + rate_limit: number + server_error: number +} + +export function emptyIpregionSummary(): IpregionSummary { + return { + total: 0, + ok: 0, + na: 0, + denied: 0, + rate_limit: 0, + server_error: 0, + } +} + +const ISO_RE = /^[A-Z]{2}$/ + +/** ISO `RU` → ok; иначе статусы ipregion (`N/A`, `Denied`, `Rate-limit`, `Server error`). */ +export function canonicalizeCountryValue( + raw: string | null | undefined, +): { status: IpregionStatus; country: string | null } { + const value = raw?.replace(/\s+/g, ' ').trim() ?? '' + if (!value || value === 'null' || /^n\/a$/i.test(value)) { + return { status: 'na', country: null } + } + if (/^denied$/i.test(value)) return { status: 'denied', country: null } + if (/^rate[- ]?limit$/i.test(value)) return { status: 'rate_limit', country: null } + if (/^server error$/i.test(value)) return { status: 'server_error', country: null } + const iso = value.toUpperCase() + if (ISO_RE.test(iso)) return { status: 'ok', country: iso } + return { status: 'server_error', country: null } +}