Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1 +1,2 @@
|
||||
apps/api/scripts/censorcheck/*.sh text eol=lf
|
||||
apps/api/scripts/ipregion/*.sh text eol=lf
|
||||
|
||||
@@ -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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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:]' </proc/sys/kernel/random/uuid
|
||||
return
|
||||
fi
|
||||
if command -v python3 >/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
|
||||
@@ -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')
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown> = {}) {
|
||||
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<ReturnType<typeof buildApp>>
|
||||
|
||||
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<string, string> = {}) {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -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<string, string | undefined>
|
||||
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
|
||||
})
|
||||
}
|
||||
@@ -50,3 +50,47 @@ describe('GET /cc launcher', () => {
|
||||
expect(res.body).not.toContain('\r')
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /ic launcher', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
|
||||
const TABLE_ORDER_DELETE = [
|
||||
'vps_grants',
|
||||
'ipregion_results',
|
||||
'ipregion_runs',
|
||||
'censorcheck_results',
|
||||
'censorcheck_runs',
|
||||
'notification_log',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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<string, CensorcheckRunDto>()
|
||||
export function mergeCensorcheckRuns<T extends { id: string }>(
|
||||
current: T[],
|
||||
history: T[],
|
||||
): T[] {
|
||||
const map = new Map<string, T>()
|
||||
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<string, CensorcheckRunDto[]>()
|
||||
export function collectSnapshotTicks(runs: SnapshotRun[]): BlockingSnapshotTick[] {
|
||||
const byDay = new Map<string, SnapshotRun[]>()
|
||||
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<T extends SnapshotRun>(
|
||||
runs: T[],
|
||||
asOf: string,
|
||||
): CensorcheckRunDto[] {
|
||||
): T[] {
|
||||
if (!asOf) return []
|
||||
const byIp = new Map<string, CensorcheckRunDto>()
|
||||
const byIp = new Map<string, T>()
|
||||
for (const run of runs) {
|
||||
if (run.createdAt > asOf) continue
|
||||
const previous = byIp.get(run.probePublicIp)
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
GlobeIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -69,6 +70,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
||||
<ShieldAlertIcon />
|
||||
<span>Статус блокировок</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go('/geo')}>
|
||||
<GlobeIcon />
|
||||
<span>GeoIP</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="VPS">
|
||||
|
||||
@@ -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 = (
|
||||
<Badge variant={variant} size="sm" radius="full" aria-label={tip}>
|
||||
{display}
|
||||
</Badge>
|
||||
)
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex"
|
||||
onClick={(event) => {
|
||||
if (!onSelect) return
|
||||
event.stopPropagation()
|
||||
onSelect()
|
||||
}}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{badge}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tip}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
@@ -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> = {}): 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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, number> = { 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<string, number>()
|
||||
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<string>()
|
||||
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<string, GeoServiceRow>()
|
||||
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: [],
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<IpregionRunDto> {
|
||||
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 ? (
|
||||
<Link
|
||||
to="/vps/$vpsId"
|
||||
params={{ vpsId: row.matchedVpsId }}
|
||||
className="hover:text-primary font-medium"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
{title}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">Unknown VPS</span>
|
||||
)
|
||||
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<IpregionRunDto>[] => {
|
||||
return [
|
||||
vpsIdentityColumn(),
|
||||
...serviceCols.map(
|
||||
(svc): DataGridColumn<IpregionRunDto> => ({
|
||||
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 (
|
||||
<CountryMatrixCell
|
||||
status={item?.status}
|
||||
countryIpv4={item?.countryIpv4}
|
||||
countryIpv6={item?.countryIpv6}
|
||||
serviceLabel={svc.title}
|
||||
vpsLabel={row.vps?.dns || row.probePublicIp}
|
||||
checkedAt={row.createdAt}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [serviceCols])
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={runs}
|
||||
rowId={(row) => 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<GeoServiceRow>[] => {
|
||||
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<GeoServiceRow> => ({
|
||||
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 (
|
||||
<CountryMatrixCell
|
||||
status={item?.status}
|
||||
countryIpv4={item?.countryIpv4}
|
||||
countryIpv6={item?.countryIpv6}
|
||||
serviceLabel={row.serviceKey}
|
||||
vpsLabel={probe.title}
|
||||
checkedAt={item?.createdAt}
|
||||
onSelect={run ? () => onProbeClick(run) : undefined}
|
||||
/>
|
||||
)
|
||||
},
|
||||
}),
|
||||
),
|
||||
]
|
||||
}, [onProbeClick, probeCols, runById])
|
||||
|
||||
return (
|
||||
<FrameDataGrid
|
||||
columns={columnDefFromDataGrid(columns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={groups.length > 10}
|
||||
pinLeftColumnIds={['service']}
|
||||
{...GEO_MATRIX_GRID}
|
||||
emptyTitle="Нет сервисов"
|
||||
emptyAction={emptyAction}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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<IpregionRunDto>[] = [
|
||||
{
|
||||
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) => (
|
||||
<StatusBadge status={row.status} label={IPREGION_STATUS_LABELS[row.status] ?? row.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
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<TabId>('current')
|
||||
const [group, setGroup] = useState<GroupMode>('vps')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [selected, setSelected] = useState<IpregionRunDto | null>(null)
|
||||
const [snapshotIndex, setSnapshotIndex] = useState<number | null>(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 = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
|
||||
>
|
||||
<CopyIcon data-icon="inline-start" />
|
||||
Скопировать команду
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="GeoIP"
|
||||
description="Страны по GeoIP-сервисам с VPS через ipregion."
|
||||
actions={copyLauncher}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
items={[
|
||||
{
|
||||
id: 'probes',
|
||||
label: 'Пробы',
|
||||
value: filtered.length,
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Известные VPS',
|
||||
value: matched,
|
||||
icon: <ServerIcon />,
|
||||
},
|
||||
{
|
||||
id: 'countries',
|
||||
label: 'Уникальные страны',
|
||||
value: countries,
|
||||
icon: <MapPinIcon />,
|
||||
},
|
||||
{
|
||||
id: 'mismatch',
|
||||
label: 'Расхождения GeoIP',
|
||||
value: mismatches,
|
||||
icon: <ShieldAlertIcon />,
|
||||
variant: mismatches > 0 ? 'warning' : 'default',
|
||||
},
|
||||
]}
|
||||
isLoading={currentQuery.isLoading}
|
||||
/>
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'current', label: 'Текущие', count: currentQuery.data?.items.length },
|
||||
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
||||
]}
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as TabId)}
|
||||
/>
|
||||
|
||||
{tab === 'current' ? (
|
||||
<div className="flex min-w-0 w-full flex-col gap-3">
|
||||
<BlockingSnapshotScrubber
|
||||
ticks={ticks}
|
||||
index={resolvedIndex}
|
||||
onIndexChange={setSnapshotIndex}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<Filters
|
||||
filters={filters}
|
||||
fields={FILTER_FIELDS}
|
||||
onChange={setFilters}
|
||||
trigger={
|
||||
<Button type="button" variant="outline">
|
||||
Фильтры
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<ToggleGroup
|
||||
variant="outline"
|
||||
size="sm"
|
||||
spacing={0}
|
||||
value={[group]}
|
||||
onValueChange={(next) => {
|
||||
const selectedMode = next[0]
|
||||
if (selectedMode === 'vps' || selectedMode === 'service') setGroup(selectedMode)
|
||||
}}
|
||||
aria-label="Группировка"
|
||||
>
|
||||
<ToggleGroupItem value="vps">По VPS</ToggleGroupItem>
|
||||
<ToggleGroupItem value="service">По сервису</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={currentQuery.isLoading}
|
||||
isError={currentQuery.isError}
|
||||
error={currentQuery.error}
|
||||
onRetry={() => void currentQuery.refetch()}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Пока нет проверок"
|
||||
emptyDescription={`На VPS выполните: ${LAUNCHER_CMD}`}
|
||||
emptyAction={copyLauncher}
|
||||
skeleton={<TableSkeleton />}
|
||||
>
|
||||
{(rows) =>
|
||||
group === 'vps' ? (
|
||||
<GeoVpsGrid
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<GeoServiceGrid
|
||||
groups={serviceGroups}
|
||||
runs={rows}
|
||||
onProbeClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</div>
|
||||
) : (
|
||||
<ResourcePage
|
||||
title="История проверок"
|
||||
description="Все сохранённые прогоны ipregion."
|
||||
hideHeader
|
||||
columns={columnDefFromDataGrid(historyColumns)}
|
||||
data={historyQuery.data?.items ?? []}
|
||||
getRowId={(row) => 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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<GeoRunSheet
|
||||
run={selected}
|
||||
open={Boolean(selected)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null)
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>{title}</SheetTitle>
|
||||
<SheetDescription>
|
||||
{detail ? formatCheckedAt(detail.createdAt) : 'Загрузка…'}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{detail ? (
|
||||
<DetailPanel>
|
||||
<DetailPanel.Metrics
|
||||
cards={[
|
||||
{
|
||||
id: 'ip',
|
||||
icon: <GlobeIcon />,
|
||||
label: 'IP',
|
||||
description: detail.probePublicIp,
|
||||
},
|
||||
{
|
||||
id: 'vps',
|
||||
icon: <ServerIcon />,
|
||||
label: 'VPS',
|
||||
description: detail.matchedVpsId ? detail.vps?.dns || detail.matchedVpsId : 'Unknown VPS',
|
||||
footer: detail.matchedVpsId ? (
|
||||
<Link
|
||||
to="/vps/$vpsId"
|
||||
params={{ vpsId: detail.matchedVpsId }}
|
||||
className="text-primary text-xs"
|
||||
>
|
||||
Открыть карточку
|
||||
</Link>
|
||||
) : undefined,
|
||||
},
|
||||
{
|
||||
id: 'hoster',
|
||||
icon: <Building2Icon />,
|
||||
label: 'Хостер',
|
||||
description: runHosterLabel(detail) || '—',
|
||||
},
|
||||
{
|
||||
id: 'geo',
|
||||
icon: <MapPinIcon />,
|
||||
label: 'Локация',
|
||||
description: detail.vps?.country || '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<DetailPanel.Section title="Сводка">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<StatusBadge
|
||||
status={detail.status}
|
||||
label={IPREGION_STATUS_LABELS[detail.status] ?? detail.status}
|
||||
/>
|
||||
{detail.vps ? (
|
||||
<span className="text-muted-foreground text-sm">
|
||||
{formatVpsResources(detail.vps.vcpu, detail.vps.ramGb, detail.vps.diskGb)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
<DetailPanel.Section title="Сервисы">
|
||||
<div className="flex flex-col gap-2">
|
||||
{(detail.results ?? []).map((item) => (
|
||||
<div key={item.id} className="flex items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm font-medium">{item.serviceLabel}</span>
|
||||
<span className="text-muted-foreground text-xs">{item.group}</span>
|
||||
</div>
|
||||
{item.status === 'ok' && item.countryIpv4 ? (
|
||||
<Badge size="sm" variant="success-light" radius="full">
|
||||
{item.countryIpv4}
|
||||
{countryName(item.countryIpv4) ? ` · ${countryName(item.countryIpv4)}` : ''}
|
||||
</Badge>
|
||||
) : (
|
||||
<StatusBadge
|
||||
status={item.status}
|
||||
label={IPREGION_STATUS_LABELS[item.status] ?? item.status}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DetailPanel.Section>
|
||||
</DetailPanel>
|
||||
) : (
|
||||
<div className="text-muted-foreground flex items-center gap-2 p-4 text-sm">
|
||||
<ShieldAlertIcon className="size-4" />
|
||||
Нет данных прогона
|
||||
</div>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
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()
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -448,6 +448,37 @@ export const api = {
|
||||
fetchApi<import('@/components/censorcheck/types').CensorcheckRunDto>(
|
||||
`/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<import('@/components/ipregion/types').IpregionRunDto>(
|
||||
`/api/ipregion/runs/${encodeURIComponent(id)}`,
|
||||
),
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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<string, unknown>) =>
|
||||
['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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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<IpregionSummary>) : {}
|
||||
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,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -25,6 +25,8 @@ const ROLE_RANK: Record<SpaceRole, number> = {
|
||||
/** Tables with spaceId column — purge order (children first). */
|
||||
const SPACE_DATA_TABLES = [
|
||||
'vps_grants',
|
||||
'ipregion_results',
|
||||
'ipregion_runs',
|
||||
'censorcheck_results',
|
||||
'censorcheck_runs',
|
||||
'notification_log',
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'))`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string>(IPREGION_PRIMARY_SERVICES)
|
||||
const CUSTOM_SET = new Set<string>(IPREGION_CUSTOM_SERVICES)
|
||||
const CDN_SET = new Set<string>(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<typeof ipregionIngestBodySchema>
|
||||
export type IpregionIngestResult = z.infer<typeof ipregionIngestResultSchema>
|
||||
|
||||
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user