feat(geo): добавить раздел IP-регионов
Docker / build (push) Failing after 26s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-25 15:16:17 +07:00
co-authored by Cursor
parent 60a22b9450
commit 235ae03489
37 changed files with 4938 additions and 34 deletions
+1
View File
@@ -1 +1,2 @@
apps/api/scripts/censorcheck/*.sh text eol=lf
apps/api/scripts/ipregion/*.sh text eol=lf
+9
View File
@@ -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
+356
View File
@@ -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
+2
View File
@@ -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')
+1
View File
@@ -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')
})
+2 -1
View File
@@ -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',
},
{
+2
View File
@@ -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
}
+143
View File
@@ -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')
})
})
+129
View File
@@ -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
})
}
+44
View File
@@ -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')
})
})
+46 -21
View File
@@ -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'))
})
}
+2
View File
@@ -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>
)
}
+89
View File
@@ -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)
})
})
+31
View File
@@ -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 {
+1
View File
@@ -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') ||
+40
View File
@@ -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 }
+21
View File
@@ -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,
+10
View File
@@ -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,
})
+340
View File
@@ -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,
}
},
}
+2
View File
@@ -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',
+32
View File
@@ -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. */
+50
View File
@@ -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'))`
+29
View File
@@ -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 {
+149
View File
@@ -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 }
}