feat(censorcheck): добавить статус блокировок и launcher curl | bash
Docker / build (push) Failing after 25s
Docker / build (push) Failing after 25s
Прогон с VPS через HMAC-токен матчится к существующим серверам; UI /blocking показывает текущие проверки и историю. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -90,6 +90,16 @@ vps-tracker/
|
||||
- **Журнал:** таблица `notification_log`, API `GET /api/notifications/log`
|
||||
- **Дедупликация:** `notification_state` — daily / fingerprint / state_transition
|
||||
|
||||
## Статус блокировок (censorcheck)
|
||||
|
||||
Ручная проверка с VPS: `curl -fsSL https://vt.shnt.top/cc | bash` (тот же контейнер, Traefik dual Host).
|
||||
|
||||
- **Vendor:** `apps/api/scripts/censorcheck/censorcheck.sh` (pin SHA `12c5839`, MIT)
|
||||
- **Launcher:** `GET /cc` минтит HMAC ingest-токен (TTL 20 мин); `GET /cc/vendor` — скрипт
|
||||
- **Ingest:** `POST /api/integrations/censorcheck/runs` (без portal JWT)
|
||||
- **UI:** `/blocking` — текущие прогоны и история, группировка VPS / сервис
|
||||
- Env: `CENSORCHECK_INGEST_SECRET`, `CENSORCHECK_PUBLIC_URL`, `VPS_LAUNCHER_DOMAIN`
|
||||
|
||||
## Команды
|
||||
|
||||
```bash
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"@cfdm/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/jwt": "^10.2.0",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Vendor pin of https://github.com/vernette/censorcheck
|
||||
|
||||
- File: `censorcheck.sh`
|
||||
- Commit: `12c5839` (2026-08-11)
|
||||
- License: MIT (see upstream repository)
|
||||
|
||||
Do not fetch this script from GitHub at runtime — some probe networks block github.com.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# VPS Tracker launcher for vernette/censorcheck (vendor pin 12c5839).
|
||||
# Fetched via: curl -fsSL https://vt.shnt.top/cc | bash
|
||||
set -euo pipefail
|
||||
|
||||
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
|
||||
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
|
||||
LAUNCHER_VERSION="1"
|
||||
VENDOR_SHA="12c5839"
|
||||
|
||||
trap 'exit 130' INT
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Нужна команда '$1' (установите пакет и повторите)."
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd bash
|
||||
|
||||
detect_public_ip() {
|
||||
local ip=""
|
||||
ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
|
||||
if [ -z "$ip" ]; then
|
||||
ip="$(curl -fsS --max-time 8 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
fi
|
||||
if [ -z "$ip" ] && command -v dig >/dev/null 2>&1; then
|
||||
ip="$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
fi
|
||||
printf '%s' "$ip"
|
||||
}
|
||||
|
||||
write_vendor() {
|
||||
local dest="$1"
|
||||
if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then
|
||||
printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \
|
||||
|| printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -D >"$dest"
|
||||
return 0
|
||||
fi
|
||||
curl -fsSL --max-time 30 "${VT_API_URL}/cc/vendor" -o "$dest"
|
||||
}
|
||||
|
||||
uuid4() {
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
tr -d '[:space:]' </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-censorcheck.XXXXXX)"
|
||||
cleanup() { rm -rf "$TMPDIR"; }
|
||||
trap 'cleanup; exit 130' INT
|
||||
trap 'cleanup' EXIT
|
||||
|
||||
VENDOR="$TMPDIR/censorcheck.sh"
|
||||
write_vendor "$VENDOR"
|
||||
chmod +x "$VENDOR"
|
||||
|
||||
PUBLIC_IP="$(detect_public_ip)"
|
||||
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
|
||||
|
||||
RUN_ID="$(uuid4)"
|
||||
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
|
||||
|
||||
log "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
|
||||
log "probe IP: ${PUBLIC_IP}"
|
||||
log "runId: ${RUN_ID}"
|
||||
|
||||
set +e
|
||||
RAW_JSON="$(bash "$VENDOR" --mode both --json --no-header --no-dns 2>/tmp/vt-censorcheck-err.$$)"
|
||||
CC_EXIT=$?
|
||||
set -e
|
||||
if [ "$CC_EXIT" -ne 0 ]; then
|
||||
log "censorcheck завершился с кодом ${CC_EXIT}" >&2
|
||||
if [ -s /tmp/vt-censorcheck-err.$$ ]; then
|
||||
cat /tmp/vt-censorcheck-err.$$ >&2 || true
|
||||
fi
|
||||
rm -f /tmp/vt-censorcheck-err.$$
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/vt-censorcheck-err.$$
|
||||
|
||||
PAYLOAD="$TMPDIR/payload.json"
|
||||
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" '
|
||||
{
|
||||
schemaVersion: 1,
|
||||
runId: $runId,
|
||||
probe: { publicIp: $ip },
|
||||
launcherVersion: $lv,
|
||||
censorcheck: {
|
||||
version: ((.version | tostring) // "1"),
|
||||
mode: "both"
|
||||
},
|
||||
results: ((.results // []) | map({
|
||||
service: .service,
|
||||
raw: .
|
||||
}))
|
||||
}
|
||||
' >"$PAYLOAD"
|
||||
|
||||
FALLBACK="/tmp/vt-censorcheck-${RUN_ID}.json"
|
||||
set +e
|
||||
RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/censorcheck/runs" \
|
||||
-H "Authorization: Bearer ${VT_INGEST_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @"$PAYLOAD")"
|
||||
POST_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$POST_EXIT" -ne 0 ]; then
|
||||
cp "$PAYLOAD" "$FALLBACK"
|
||||
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
CHECK_ID="$(printf '%s' "$RESP" | jq -r '.id // empty')"
|
||||
MATCHED="$(printf '%s' "$RESP" | jq -r '.matchedVpsId // "unmatched"')"
|
||||
if [ -z "$CHECK_ID" ]; then
|
||||
cp "$PAYLOAD" "$FALLBACK"
|
||||
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
|
||||
log "$RESP" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "Check ID: ${CHECK_ID}"
|
||||
log "VPS: ${MATCHED}"
|
||||
exit 0
|
||||
@@ -2,6 +2,7 @@ import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import sensible from '@fastify/sensible'
|
||||
import staticPlugin from '@fastify/static'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -25,6 +26,8 @@ import { dashboardRoutes } from './routes/dashboard.js'
|
||||
import { auditRoutes } from './routes/audit.js'
|
||||
import { notificationsRoutes } from './routes/notifications.js'
|
||||
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
|
||||
import { censorcheckRoutes } from './routes/censorcheck.js'
|
||||
import { launcherRoutes } from './routes/launcher.js'
|
||||
import { appSwitcherRoutes } from './routes/app-switcher.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
import { authPlugin } from './plugins/auth.js'
|
||||
@@ -43,18 +46,28 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
if (opts.dbPath) process.env.DB_PATH = opts.dbPath
|
||||
getDb()
|
||||
|
||||
const trustProxy =
|
||||
process.env.TRUST_PROXY === '1' ||
|
||||
process.env.TRUST_PROXY === 'true' ||
|
||||
(process.env.NODE_ENV === 'production' &&
|
||||
process.env.TRUST_PROXY !== '0' &&
|
||||
process.env.TRUST_PROXY !== 'false')
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
},
|
||||
trustProxy,
|
||||
})
|
||||
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(sensible)
|
||||
await app.register(rateLimit, { global: false })
|
||||
await app.register(authPlugin)
|
||||
await app.register(spacePlugin)
|
||||
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
await app.register(launcherRoutes)
|
||||
|
||||
await app.register(spacesRoutes)
|
||||
await app.register(portalUsersRoutes)
|
||||
@@ -75,6 +88,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(auditRoutes)
|
||||
await app.register(notificationsRoutes)
|
||||
await app.register(integrationsCfdmRoutes)
|
||||
await app.register(censorcheckRoutes)
|
||||
await app.register(appSwitcherRoutes)
|
||||
|
||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('hasPermission hierarchy', () => {
|
||||
describe('permissionForRequest', () => {
|
||||
it('maps vps CRUD', () => {
|
||||
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
|
||||
expect(permissionForRequest('GET', '/api/censorcheck/current')).toBe('vps:vps:read')
|
||||
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
|
||||
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
|
||||
})
|
||||
|
||||
@@ -51,7 +51,8 @@ const RULES: Rule[] = [
|
||||
p.startsWith('/api/vps/') ||
|
||||
p.startsWith('/api/projects') ||
|
||||
p.startsWith('/api/topology') ||
|
||||
p.startsWith('/api/data'),
|
||||
p.startsWith('/api/data') ||
|
||||
p.startsWith('/api/censorcheck'),
|
||||
permission: 'vps:vps:read',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,9 @@ function isPublicPath(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url
|
||||
if (path === '/health' || path === '/ready') return true
|
||||
if (path === '/api/auth/config') return true
|
||||
if (path === '/cc' || path.startsWith('/cc/')) return true
|
||||
if (path.startsWith('/api/integrations/cfdm')) return true
|
||||
if (path.startsWith('/api/integrations/censorcheck')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
|
||||
import { buildApp } from '../index.js'
|
||||
import { mintIngestToken } from '../services/censorcheck/ingest-token.js'
|
||||
|
||||
const SECRET = 'test-censorcheck-ingest-secret'
|
||||
|
||||
function ingestPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runId: '11111111-1111-4111-8111-111111111111',
|
||||
probe: { publicIp: '203.0.113.10' },
|
||||
launcherVersion: '1',
|
||||
censorcheck: { version: '1', mode: 'both' },
|
||||
results: [
|
||||
{
|
||||
service: 'youtube.com',
|
||||
raw: { https: { ipv4: { status: 200 } } },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('censorcheck ingest + reads', () => {
|
||||
let app: Awaited<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/censorcheck/runs',
|
||||
headers: {
|
||||
authorization: `Bearer ${token ?? mintIngestToken(SECRET)}`,
|
||||
'content-type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
payload: body as object,
|
||||
})
|
||||
}
|
||||
|
||||
it('отклоняет запрос без токена', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/integrations/censorcheck/runs',
|
||||
payload: ingestPayload(),
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('отклоняет просроченный токен', async () => {
|
||||
const token = mintIngestToken(SECRET, 60, Date.now() - 120_000)
|
||||
const res = await post(ingestPayload(), token)
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('отклоняет невалидный payload', async () => {
|
||||
const res = await post({ schemaVersion: 1, runId: 'short' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('принимает прогон и оставляет unmatched', async () => {
|
||||
const res = await post(ingestPayload())
|
||||
expect(res.statusCode).toBe(200)
|
||||
const json = res.json() as { matchedVpsId: string | null; probePublicIp: string }
|
||||
expect(json.matchedVpsId).toBeNull()
|
||||
expect(json.probePublicIp).toBe('203.0.113.10')
|
||||
})
|
||||
|
||||
it('матчит VPS по IPv4 и IPv6', async () => {
|
||||
const vps = runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '203.0.113.10',
|
||||
ipv6: '2001:db8::55',
|
||||
dns: 'edge.example.com',
|
||||
providerId: 'p1',
|
||||
providerAccountId: 'a1',
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
vcpu: 2,
|
||||
ramGb: 4,
|
||||
diskGb: 40,
|
||||
}),
|
||||
)
|
||||
const v4 = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }))
|
||||
expect(v4.json().matchedVpsId).toBe(vps.id)
|
||||
|
||||
const v6 = await post(
|
||||
ingestPayload({
|
||||
runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
probe: { publicIp: '2001:db8::55' },
|
||||
}),
|
||||
)
|
||||
expect(v6.json().matchedVpsId).toBe(vps.id)
|
||||
})
|
||||
|
||||
it('повторяет duplicate runId без второй записи', async () => {
|
||||
const first = await post(ingestPayload())
|
||||
const second = await post(ingestPayload())
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(second.statusCode).toBe(200)
|
||||
expect(second.json().id).toBe(first.json().id)
|
||||
expect(second.json().replayed).toBe(true)
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
|
||||
expect(current.json().items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('берёт observed XFF, а claimed сохраняет если расходится', async () => {
|
||||
runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '198.51.100.20',
|
||||
providerId: 'p1',
|
||||
providerAccountId: 'a1',
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
}),
|
||||
)
|
||||
const res = await post(ingestPayload({
|
||||
runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
probe: { publicIp: '203.0.113.10' },
|
||||
}), undefined, { 'x-forwarded-for': '198.51.100.20' })
|
||||
const json = res.json() as { probePublicIp: string; matchedVpsId: string | null }
|
||||
expect(json.probePublicIp).toBe('198.51.100.20')
|
||||
expect(json.matchedVpsId).not.toBeNull()
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
|
||||
expect(current.json().items[0].claimedPublicIp).toBe('203.0.113.10')
|
||||
})
|
||||
|
||||
it('отдаёт историю и детали', async () => {
|
||||
await post(ingestPayload())
|
||||
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
|
||||
expect(list.statusCode).toBe(200)
|
||||
const items = list.json().items as Array<{ id: string }>
|
||||
expect(items).toHaveLength(1)
|
||||
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
|
||||
expect(detail.statusCode).toBe(200)
|
||||
expect(detail.json().results).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
|
||||
import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck'
|
||||
import { actorFromRequest } from '../lib/audit-actor.js'
|
||||
import {
|
||||
bearerToken,
|
||||
ingestSecret,
|
||||
verifyIngestToken,
|
||||
} from '../services/censorcheck/ingest-token.js'
|
||||
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
|
||||
import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js'
|
||||
|
||||
const BODY_LIMIT = 512 * 1024
|
||||
|
||||
function sendError(reply: FastifyReply, status: number, code: string, message: string) {
|
||||
return reply.code(status).send({ error: { code, message } })
|
||||
}
|
||||
|
||||
function requireIngestToken(request: FastifyRequest, reply: FastifyReply): boolean {
|
||||
const secret = ingestSecret()
|
||||
if (!secret) {
|
||||
void sendError(reply, 503, 'UNAVAILABLE', 'Censorcheck ingest не настроен')
|
||||
return false
|
||||
}
|
||||
const token = bearerToken(request.headers.authorization)
|
||||
if (!token || !verifyIngestToken(token, secret)) {
|
||||
void sendError(reply, 401, 'UNAUTHORIZED', 'Недействительный ingest-токен')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
|
||||
const ingestOpts = {
|
||||
bodyLimit: BODY_LIMIT,
|
||||
...(process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
|
||||
? {}
|
||||
: { config: { rateLimit: { max: 6, timeWindow: '1 minute' } } }),
|
||||
}
|
||||
|
||||
app.post(
|
||||
'/api/integrations/censorcheck/runs',
|
||||
ingestOpts,
|
||||
async (request, reply) => {
|
||||
if (!requireIngestToken(request, reply)) return
|
||||
|
||||
const parsed = censorcheckIngestBodySchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
|
||||
}
|
||||
|
||||
const existing = censorcheckRepository.getByClientRunId(parsed.data.runId)
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
runId: existing.runId,
|
||||
matchedVpsId: existing.matchedVpsId,
|
||||
probePublicIp: existing.probePublicIp,
|
||||
summary: existing.summary,
|
||||
replayed: true,
|
||||
}
|
||||
}
|
||||
|
||||
const claimed = parsed.data.probe.publicIp
|
||||
const observed = actorFromRequest(request).ip ?? request.ip
|
||||
const probePublicIp = resolveProbeIp(observed, claimed)
|
||||
const claimedPublicIp =
|
||||
claimed && claimed !== probePublicIp ? claimed : null
|
||||
const match = matchVpsByPublicIp(probePublicIp)
|
||||
const results = parsed.data.results.map(normalizeIngestResult)
|
||||
const { summary, runStatus } = summarizeResults(results)
|
||||
|
||||
const created = censorcheckRepository.create({
|
||||
spaceId: match.spaceId,
|
||||
runId: parsed.data.runId,
|
||||
probePublicIp,
|
||||
claimedPublicIp,
|
||||
matchedVpsId: match.vpsId,
|
||||
status: runStatus,
|
||||
schemaVersion: parsed.data.schemaVersion,
|
||||
launcherVersion: parsed.data.launcherVersion ?? null,
|
||||
censorcheckVersion: parsed.data.censorcheck?.version ?? null,
|
||||
summary,
|
||||
observedSourceIp: observed ?? null,
|
||||
results,
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
runId: created.runId,
|
||||
matchedVpsId: created.matchedVpsId,
|
||||
probePublicIp: created.probePublicIp,
|
||||
summary: created.summary,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/censorcheck/current', async () => ({
|
||||
items: censorcheckRepository.listCurrent(),
|
||||
}))
|
||||
|
||||
app.get('/api/censorcheck/runs', async (request) => {
|
||||
const q = request.query as Record<string, string | undefined>
|
||||
const matched =
|
||||
q.matched === '1' || q.matched === 'true'
|
||||
? true
|
||||
: q.matched === '0' || q.matched === 'false'
|
||||
? false
|
||||
: undefined
|
||||
return censorcheckRepository.listHistory({
|
||||
cursor: q.cursor,
|
||||
limit: q.limit ? Number(q.limit) : undefined,
|
||||
q: q.q,
|
||||
status: q.status,
|
||||
matched,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/censorcheck/runs/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const run = censorcheckRepository.getById(id)
|
||||
if (!run) {
|
||||
return sendError(reply, 404, 'NOT_FOUND', 'Прогон не найден')
|
||||
}
|
||||
return run
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
describe('GET /cc launcher', () => {
|
||||
let app: Awaited<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: '/cc' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers['content-type']).toMatch(/text\/plain/)
|
||||
expect(res.headers['cache-control']).toMatch(/no-store/)
|
||||
expect(res.body).toContain('https://vt.shnt.top')
|
||||
expect(res.body).toContain('VT_INGEST_TOKEN')
|
||||
expect(res.body).not.toContain('__VT_API_URL__')
|
||||
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
|
||||
})
|
||||
|
||||
it('отдаёт vendor-скрипт', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/cc/vendor' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.body).toContain('#!/usr/bin/env bash')
|
||||
expect(res.body).toContain('SCRIPT_NAME')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { ingestSecret, mintIngestToken } from '../services/censorcheck/ingest-token.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'censorcheck')
|
||||
|
||||
export function censorcheckPublicUrl(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env.CENSORCHECK_PUBLIC_URL?.trim()) {
|
||||
return env.CENSORCHECK_PUBLIC_URL.replace(/\/$/, '')
|
||||
}
|
||||
const host = env.VPS_LAUNCHER_DOMAIN || env.VPS_DOMAIN || 'vt.shnt.top'
|
||||
return `https://${host}`
|
||||
}
|
||||
|
||||
function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'public'): void {
|
||||
void reply
|
||||
.header('Content-Type', 'text/plain; charset=utf-8')
|
||||
.header(
|
||||
'Cache-Control',
|
||||
cache === 'no-store' ? 'no-store, no-cache, must-revalidate' : 'public, max-age=3600',
|
||||
)
|
||||
.send(body)
|
||||
}
|
||||
|
||||
export const launcherRoutes: FastifyPluginAsync = async (app) => {
|
||||
const secret = ingestSecret()
|
||||
|
||||
const ccOpts =
|
||||
process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
|
||||
? {}
|
||||
: { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
|
||||
|
||||
app.get('/cc', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!secret) {
|
||||
return reply.code(503).send('censorcheck ingest is not configured\n')
|
||||
}
|
||||
const apiUrl = censorcheckPublicUrl()
|
||||
const token = mintIngestToken(secret)
|
||||
let template: string
|
||||
try {
|
||||
template = readFileSync(join(SCRIPT_DIR, 'launcher.sh'), 'utf8')
|
||||
} catch {
|
||||
return reply.code(500).send('launcher template missing\n')
|
||||
}
|
||||
const script = template
|
||||
.replaceAll('__VT_API_URL__', apiUrl)
|
||||
.replaceAll('__VT_INGEST_TOKEN__', token)
|
||||
sendPlain(reply, script, 'no-store')
|
||||
})
|
||||
|
||||
app.get('/cc/vendor', async (_request, reply) => {
|
||||
try {
|
||||
const body = readFileSync(join(SCRIPT_DIR, 'censorcheck.sh'), 'utf8')
|
||||
sendPlain(reply, body, 'public')
|
||||
} catch {
|
||||
return reply.code(500).send('vendor script missing\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
|
||||
const TABLE_ORDER_DELETE = [
|
||||
'vps_grants',
|
||||
'censorcheck_results',
|
||||
'censorcheck_runs',
|
||||
'notification_log',
|
||||
'notification_state',
|
||||
'vps_health_checks',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mintIngestToken, verifyIngestToken } from './ingest-token.js'
|
||||
|
||||
describe('censorcheck ingest token', () => {
|
||||
const secret = 'test-ingest-secret-key'
|
||||
|
||||
it('принимает свежий токен', () => {
|
||||
const token = mintIngestToken(secret)
|
||||
expect(verifyIngestToken(token, secret)).toBe(true)
|
||||
})
|
||||
|
||||
it('отклоняет просроченный токен', () => {
|
||||
const token = mintIngestToken(secret, 20 * 60, Date.now() - 21 * 60 * 1000)
|
||||
expect(verifyIngestToken(token, secret)).toBe(false)
|
||||
})
|
||||
|
||||
it('отклоняет подпись с другим секретом', () => {
|
||||
const token = mintIngestToken(secret)
|
||||
expect(verifyIngestToken(token, 'other-secret-key')).toBe(false)
|
||||
})
|
||||
|
||||
it('отклоняет мусор', () => {
|
||||
expect(verifyIngestToken('not-a-token', secret)).toBe(false)
|
||||
expect(verifyIngestToken('', secret)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
const TTL_SEC = 20 * 60
|
||||
|
||||
export function ingestSecret(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.CENSORCHECK_INGEST_SECRET ||
|
||||
env.AUTH_JWT_SECRET ||
|
||||
env.JWT_SECRET ||
|
||||
(env.NODE_ENV === 'production' ? '' : 'dev-secret-change-me')
|
||||
)
|
||||
}
|
||||
|
||||
export function mintIngestToken(
|
||||
secret: string,
|
||||
ttlSec = TTL_SEC,
|
||||
nowMs = Date.now(),
|
||||
): string {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
jti: randomBytes(16).toString('hex'),
|
||||
exp: Math.floor(nowMs / 1000) + ttlSec,
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64url')
|
||||
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyIngestToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
if (!secret || !token) return false
|
||||
const [payload, sig] = token.split('.')
|
||||
if (!payload || !sig) return false
|
||||
const expected = createHmac('sha256', secret).update(payload).digest()
|
||||
let given: Buffer
|
||||
try {
|
||||
given = Buffer.from(sig, 'base64url')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return false
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
||||
exp?: unknown
|
||||
}
|
||||
if (typeof data.exp !== 'number') return false
|
||||
return data.exp * 1000 > nowMs
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function bearerToken(header: string | string[] | undefined): string | null {
|
||||
const raw = Array.isArray(header) ? header[0] : header
|
||||
if (!raw) return null
|
||||
const match = /^Bearer\s+(.+)$/i.exec(raw.trim())
|
||||
return match?.[1]?.trim() || null
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
|
||||
import { matchVpsByPublicIp, resolveProbeIp } from './match-ip.js'
|
||||
|
||||
describe('censorcheck match-ip', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
seedTestProvider('p1')
|
||||
seedTestProviderAccount('a1', 'p1')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('берёт claimed если observed приватный', () => {
|
||||
expect(resolveProbeIp('127.0.0.1', '203.0.113.10')).toBe('203.0.113.10')
|
||||
expect(resolveProbeIp('203.0.113.55', '203.0.113.10')).toBe('203.0.113.55')
|
||||
})
|
||||
|
||||
it('матчит VPS по IPv4 и отдаёт spaceId', () => {
|
||||
const vps = runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '203.0.113.10',
|
||||
ipv6: '2001:db8::aa',
|
||||
providerId: 'p1',
|
||||
providerAccountId: 'a1',
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
}),
|
||||
)
|
||||
const created = Array.isArray(vps) ? vps[0]! : vps
|
||||
expect(matchVpsByPublicIp('203.0.113.10')).toEqual({
|
||||
vpsId: created.id,
|
||||
spaceId: MAIN_SPACE_ID,
|
||||
})
|
||||
expect(matchVpsByPublicIp('2001:db8::aa').vpsId).toBe(created.id)
|
||||
expect(matchVpsByPublicIp('198.51.100.1')).toEqual({
|
||||
vpsId: null,
|
||||
spaceId: MAIN_SPACE_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MAIN_SPACE_ID } from '@cfdm/db'
|
||||
import { findVpsIdByIps, isPrivateOrLoopbackIp } from '@cfdm/db/repositories/ip-match'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
|
||||
export { isPrivateOrLoopbackIp }
|
||||
|
||||
export function resolveProbeIp(observed: string | null | undefined, claimed: string): string {
|
||||
const obs = observed?.trim() ?? ''
|
||||
const claim = claimed.trim()
|
||||
if (obs && !isPrivateOrLoopbackIp(obs)) return obs
|
||||
return claim
|
||||
}
|
||||
|
||||
export function matchVpsByPublicIp(ip: string): { vpsId: string | null; spaceId: string } {
|
||||
const all = vpsRepository.listAllSpaces()
|
||||
const vpsId = findVpsIdByIps(all, [ip])
|
||||
if (!vpsId) return { vpsId: null, spaceId: MAIN_SPACE_ID }
|
||||
const vps = all.find((row) => row.id === vpsId)
|
||||
return { vpsId, spaceId: vps?.spaceId ?? MAIN_SPACE_ID }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeIngestResult,
|
||||
statusFromErrorCode,
|
||||
statusFromHttpCode,
|
||||
summarizeResults,
|
||||
} from './normalize.js'
|
||||
|
||||
describe('censorcheck normalize', () => {
|
||||
it('мапит HTTP-коды HTTPS IPv4', () => {
|
||||
expect(statusFromHttpCode(200)).toBe('available')
|
||||
expect(statusFromHttpCode(403)).toBe('denied')
|
||||
expect(statusFromHttpCode(301)).toBe('redirected')
|
||||
expect(statusFromHttpCode(0)).toBe('timeout')
|
||||
expect(statusFromHttpCode(-1)).toBe('blocked')
|
||||
})
|
||||
|
||||
it('мапит error_code', () => {
|
||||
expect(statusFromErrorCode('blocked_by_ip')).toBe('blocked')
|
||||
expect(statusFromErrorCode('nxdomain')).toBe('error')
|
||||
expect(statusFromErrorCode('no_dns_record')).toBe('error')
|
||||
})
|
||||
|
||||
it('берёт HTTPS IPv4 как primary', () => {
|
||||
const row = normalizeIngestResult({
|
||||
service: 'YouTube.com',
|
||||
raw: {
|
||||
http: { ipv4: { status: 403 } },
|
||||
https: { ipv4: { status: 200 } },
|
||||
},
|
||||
})
|
||||
expect(row.serviceKey).toBe('youtube.com')
|
||||
expect(row.category).toBe('dpi')
|
||||
expect(row.status).toBe('available')
|
||||
expect(row.httpStatus).toBe(200)
|
||||
})
|
||||
|
||||
it('нормализует geoblock и timeout 000', () => {
|
||||
const row = normalizeIngestResult({
|
||||
service: 'netflix.com',
|
||||
raw: { https: { ipv4: { status: '000' } } },
|
||||
})
|
||||
expect(row.category).toBe('geoblock')
|
||||
expect(row.status).toBe('timeout')
|
||||
expect(row.httpStatus).toBe(0)
|
||||
})
|
||||
|
||||
it('считает summary и partial при timeout', () => {
|
||||
const { summary, runStatus } = summarizeResults([
|
||||
{ status: 'available' },
|
||||
{ status: 'timeout' },
|
||||
{ status: 'blocked' },
|
||||
])
|
||||
expect(summary.total).toBe(3)
|
||||
expect(summary.available).toBe(1)
|
||||
expect(summary.timeout).toBe(1)
|
||||
expect(summary.blocked).toBe(1)
|
||||
expect(runStatus).toBe('partial')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
emptyCensorcheckSummary,
|
||||
inferCensorcheckCategory,
|
||||
type CensorcheckCategory,
|
||||
type CensorcheckIngestResult,
|
||||
type CensorcheckRunStatus,
|
||||
type CensorcheckStatus,
|
||||
type CensorcheckSummary,
|
||||
} from '@cfdm/shared/contracts/censorcheck'
|
||||
|
||||
function parseHttpStatus(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function protocolStatus(proto: unknown): { httpStatus: number | null; redirectUrl?: string } {
|
||||
if (!proto || typeof proto !== 'object') return { httpStatus: null }
|
||||
const rec = proto as { status?: unknown; redirect_url?: unknown }
|
||||
const redirectUrl = typeof rec.redirect_url === 'string' ? rec.redirect_url : undefined
|
||||
return { httpStatus: parseHttpStatus(rec.status), redirectUrl }
|
||||
}
|
||||
|
||||
function pickPrimaryProtocol(raw: Record<string, unknown>): {
|
||||
httpStatus: number | null
|
||||
redirectUrl?: string
|
||||
} {
|
||||
const https = raw.https
|
||||
if (https && typeof https === 'object') {
|
||||
const ipv4 = (https as { ipv4?: unknown }).ipv4
|
||||
if (ipv4) return protocolStatus(ipv4)
|
||||
}
|
||||
const http = raw.http
|
||||
if (http && typeof http === 'object') {
|
||||
const ipv4 = (http as { ipv4?: unknown }).ipv4
|
||||
if (ipv4) return protocolStatus(ipv4)
|
||||
}
|
||||
return { httpStatus: null }
|
||||
}
|
||||
|
||||
export function statusFromHttpCode(code: number | null): CensorcheckStatus {
|
||||
if (code == null) return 'error'
|
||||
if (code === 200) return 'available'
|
||||
if (code === 403) return 'denied'
|
||||
if (code >= 300 && code < 400) return 'redirected'
|
||||
if (code === 0) return 'timeout'
|
||||
if (code === -1) return 'blocked'
|
||||
if (code >= 400) return 'denied'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export function statusFromErrorCode(code: string | undefined): CensorcheckStatus {
|
||||
if (code === 'blocked_by_ip') return 'blocked'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export type NormalizedServiceResult = {
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
category: CensorcheckCategory
|
||||
status: CensorcheckStatus
|
||||
httpStatus: number | null
|
||||
detail: string | null
|
||||
rawJson: string | null
|
||||
}
|
||||
|
||||
function compactRaw(raw: Record<string, unknown>): string | null {
|
||||
try {
|
||||
const compact: Record<string, unknown> = {}
|
||||
if (raw.service != null) compact.service = raw.service
|
||||
if (raw.error != null) compact.error = raw.error
|
||||
if (raw.error_code != null) compact.error_code = raw.error_code
|
||||
if (raw.http != null) compact.http = raw.http
|
||||
if (raw.https != null) compact.https = raw.https
|
||||
return JSON.stringify(compact)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeIngestResult(item: CensorcheckIngestResult): NormalizedServiceResult {
|
||||
const serviceKey = item.service.trim().toLowerCase()
|
||||
const raw = item.raw ?? {}
|
||||
const errorCode = typeof raw.error_code === 'string' ? raw.error_code : undefined
|
||||
const errorText = typeof raw.error === 'string' ? raw.error : undefined
|
||||
const primary = pickPrimaryProtocol(raw)
|
||||
|
||||
let status: CensorcheckStatus
|
||||
let httpStatus = primary.httpStatus
|
||||
let detail: string | null = primary.redirectUrl ?? errorText ?? null
|
||||
|
||||
if (errorCode || (raw.http == null && raw.https == null && errorText)) {
|
||||
status = statusFromErrorCode(errorCode)
|
||||
if (!detail) detail = errorCode ?? errorText ?? null
|
||||
} else {
|
||||
status = statusFromHttpCode(httpStatus)
|
||||
}
|
||||
|
||||
return {
|
||||
serviceKey,
|
||||
serviceLabel: item.service.trim(),
|
||||
category: item.category ?? inferCensorcheckCategory(serviceKey),
|
||||
status,
|
||||
httpStatus,
|
||||
detail,
|
||||
rawJson: compactRaw(raw),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeResults(results: { status: CensorcheckStatus }[]): {
|
||||
summary: CensorcheckSummary
|
||||
runStatus: CensorcheckRunStatus
|
||||
} {
|
||||
const summary = emptyCensorcheckSummary()
|
||||
summary.total = results.length
|
||||
for (const row of results) {
|
||||
summary[row.status] += 1
|
||||
}
|
||||
const runStatus: CensorcheckRunStatus =
|
||||
summary.timeout > 0 || summary.error > 0 ? 'partial' : 'complete'
|
||||
return { summary, runStatus }
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
|
||||
import type { CensorcheckRunDto } from './types'
|
||||
|
||||
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
|
||||
id: 'ccrun-1',
|
||||
spaceId: 'space-main',
|
||||
runId: '11111111-1111-4111-8111-111111111111',
|
||||
probePublicIp: '203.0.113.10',
|
||||
claimedPublicIp: null,
|
||||
matchedVpsId: 'vps-1',
|
||||
status: 'complete',
|
||||
schemaVersion: 1,
|
||||
launcherVersion: '1',
|
||||
censorcheckVersion: '1',
|
||||
summary: {
|
||||
total: 2,
|
||||
available: 1,
|
||||
redirected: 0,
|
||||
denied: 0,
|
||||
blocked: 1,
|
||||
timeout: 0,
|
||||
error: 0,
|
||||
},
|
||||
createdAt: '2026-08-22T00:00:00.000Z',
|
||||
completedAt: '2026-08-22T00:00:00.000Z',
|
||||
observedSourceIp: '203.0.113.10',
|
||||
vps: {
|
||||
id: 'vps-1',
|
||||
ip: '203.0.113.10',
|
||||
dns: 'edge.example.com',
|
||||
providerId: 'p1',
|
||||
providerName: 'Hoster',
|
||||
country: 'Нидерланды',
|
||||
city: 'Amsterdam',
|
||||
datacenter: 'AMS',
|
||||
vcpu: 2,
|
||||
ramGb: 4,
|
||||
diskGb: 40,
|
||||
},
|
||||
results: [
|
||||
{
|
||||
id: 'r1',
|
||||
runId: 'ccrun-1',
|
||||
serviceKey: 'youtube.com',
|
||||
serviceLabel: 'youtube.com',
|
||||
category: 'dpi',
|
||||
status: 'blocked',
|
||||
httpStatus: -1,
|
||||
detail: null,
|
||||
},
|
||||
{
|
||||
id: 'r2',
|
||||
runId: 'ccrun-1',
|
||||
serviceKey: 'netflix.com',
|
||||
serviceLabel: 'netflix.com',
|
||||
category: 'geoblock',
|
||||
status: 'available',
|
||||
httpStatus: 200,
|
||||
detail: null,
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('filterCensorcheckRuns', () => {
|
||||
it('фильтрует по статусу сервиса', () => {
|
||||
const filters: Filter[] = [
|
||||
{ id: '1', field: 'status', operator: 'is_any_of', values: ['blocked'] },
|
||||
]
|
||||
expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1)
|
||||
expect(
|
||||
filterCensorcheckRuns([run()], [
|
||||
{ id: '1', field: 'status', operator: 'is_any_of', values: ['timeout'] },
|
||||
]),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('ищет по IP и DNS', () => {
|
||||
const filters: Filter[] = [
|
||||
{ id: '1', field: 'q', operator: 'contains', values: ['edge.example'] },
|
||||
]
|
||||
expect(filterCensorcheckRuns([run()], filters)).toHaveLength(1)
|
||||
expect(
|
||||
filterCensorcheckRuns([run()], [
|
||||
{ id: '1', field: 'q', operator: 'contains', values: ['missing'] },
|
||||
]),
|
||||
).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('groupRunsByService', () => {
|
||||
it('собирает пробы по сервису', () => {
|
||||
const groups = groupRunsByService([run()])
|
||||
expect(groups.map((g) => g.serviceKey)).toEqual(['netflix.com', 'youtube.com'])
|
||||
expect(groups[1]?.probes[0]?.status).toBe('blocked')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { getActiveFilters } from '@/components/reui-kit'
|
||||
import type { Filter } from '@/components/reui/filters'
|
||||
import { runSearchText, type CensorcheckRunDto } from './types'
|
||||
|
||||
export function filterCensorcheckRuns(
|
||||
runs: CensorcheckRunDto[],
|
||||
filters: Filter[],
|
||||
): CensorcheckRunDto[] {
|
||||
const active = getActiveFilters(filters)
|
||||
if (active.length === 0) return runs
|
||||
|
||||
return runs.filter((run) => {
|
||||
for (const filter of active) {
|
||||
const values = filter.values.map((value) => String(value))
|
||||
if (filter.field === 'status') {
|
||||
const statuses = (run.results ?? []).map((row) => row.status)
|
||||
const hit = values.some((value) => statuses.includes(value))
|
||||
if (filter.operator === 'is_not_any_of' ? hit : !hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'service') {
|
||||
const hay = (run.results ?? [])
|
||||
.map((row) => `${row.serviceKey} ${row.serviceLabel}`)
|
||||
.join(' ')
|
||||
.toLowerCase()
|
||||
const hit = values.some((value) =>
|
||||
hay.includes(value.toLowerCase()) || (run.results ?? []).some((row) => row.serviceKey === value),
|
||||
)
|
||||
if (!hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'hoster') {
|
||||
const name = (run.vps?.providerName ?? '').toLowerCase()
|
||||
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
|
||||
if (!hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'country') {
|
||||
const country = (run.vps?.country ?? '').toLowerCase()
|
||||
const hit = values.some((value) => country.includes(value.toLowerCase()) || country === value.toLowerCase())
|
||||
if (!hit) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'matched') {
|
||||
const matched = run.matchedVpsId ? 'matched' : 'unmatched'
|
||||
if (!values.includes(matched)) return false
|
||||
continue
|
||||
}
|
||||
if (filter.field === 'q') {
|
||||
const hay = runSearchText(run)
|
||||
const hit = values.some((token) => hay.includes(token.toLowerCase()))
|
||||
if (!hit) return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export type BlockingServiceRow = {
|
||||
id: string
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
category: string
|
||||
probes: Array<{
|
||||
runId: string
|
||||
probePublicIp: string
|
||||
matchedVpsId: string | null
|
||||
dns: string
|
||||
country: string
|
||||
status: string
|
||||
createdAt: string
|
||||
vpsId: string | null
|
||||
}>
|
||||
}
|
||||
|
||||
export function groupRunsByService(runs: CensorcheckRunDto[]): BlockingServiceRow[] {
|
||||
const map = new Map<string, BlockingServiceRow>()
|
||||
for (const run of runs) {
|
||||
for (const result of run.results ?? []) {
|
||||
const existing = map.get(result.serviceKey)
|
||||
const probe = {
|
||||
runId: run.id,
|
||||
probePublicIp: run.probePublicIp,
|
||||
matchedVpsId: run.matchedVpsId,
|
||||
dns: run.vps?.dns ?? '',
|
||||
country: run.vps?.country ?? '',
|
||||
status: result.status,
|
||||
createdAt: run.createdAt,
|
||||
vpsId: run.matchedVpsId,
|
||||
}
|
||||
if (existing) {
|
||||
existing.probes.push(probe)
|
||||
} else {
|
||||
map.set(result.serviceKey, {
|
||||
id: result.serviceKey,
|
||||
serviceKey: result.serviceKey,
|
||||
serviceLabel: result.serviceLabel,
|
||||
category: result.category,
|
||||
probes: [probe],
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...map.values()].sort((a, b) => a.serviceKey.localeCompare(b.serviceKey))
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { dataGridCellStack, dataGridCellWithFlag } from '@/components/data-grid-cells'
|
||||
import { CountryFlag } from '@/components/country-flag'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { columnDefFromDataGrid, ExpandableResourceGrid } from '@/components/reui-kit'
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import {
|
||||
CENSORCHECK_STATUS_LABELS,
|
||||
formatCheckedAt,
|
||||
formatVpsResources,
|
||||
type CensorcheckRunDto,
|
||||
} from './types'
|
||||
import type { BlockingServiceRow } from './blocking-filters'
|
||||
|
||||
function SummaryBadges({ run }: { run: CensorcheckRunDto }) {
|
||||
const { summary } = run
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{summary.available > 0 ? (
|
||||
<Badge variant="success" size="sm">{summary.available} ок</Badge>
|
||||
) : null}
|
||||
{summary.blocked > 0 ? (
|
||||
<Badge variant="destructive" size="sm">{summary.blocked} блок</Badge>
|
||||
) : null}
|
||||
{summary.denied > 0 ? (
|
||||
<Badge variant="destructive" size="sm">{summary.denied} отказ</Badge>
|
||||
) : null}
|
||||
{summary.timeout > 0 ? (
|
||||
<Badge variant="warning" size="sm">{summary.timeout} timeout</Badge>
|
||||
) : null}
|
||||
{summary.error > 0 ? (
|
||||
<Badge variant="outline" size="sm">{summary.error} err</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NestedList({
|
||||
rows,
|
||||
}: {
|
||||
rows: Array<{ key: string; primary: string; secondary?: string; status: string }>
|
||||
}) {
|
||||
return (
|
||||
<div className="bg-muted/30 flex flex-col gap-1 px-4 py-3">
|
||||
{rows.map((row) => (
|
||||
<div key={row.key} className="flex items-center justify-between gap-3 text-sm">
|
||||
<div className="flex min-w-0 flex-col">
|
||||
<span className="truncate font-medium">{row.primary}</span>
|
||||
{row.secondary ? (
|
||||
<span className="text-muted-foreground truncate text-xs">{row.secondary}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={row.status}
|
||||
label={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const vpsColumns: DataGridColumn<CensorcheckRunDto>[] = [
|
||||
{
|
||||
key: 'vps',
|
||||
header: 'VPS / IP',
|
||||
icon: ServerIcon,
|
||||
sortValue: (row) => row.vps?.dns || row.probePublicIp,
|
||||
cell: (row) => {
|
||||
const title = row.vps?.dns || row.probePublicIp
|
||||
const ip = row.probePublicIp
|
||||
const link = row.matchedVpsId ? (
|
||||
<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, ip)
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'dns',
|
||||
header: 'DNS',
|
||||
icon: GlobeIcon,
|
||||
sortValue: (row) => row.vps?.dns ?? '',
|
||||
cell: (row) => row.vps?.dns || '—',
|
||||
},
|
||||
{
|
||||
key: 'hoster',
|
||||
header: 'Хостер',
|
||||
sortValue: (row) => row.vps?.providerName ?? '',
|
||||
cell: (row) => row.vps?.providerName || '—',
|
||||
},
|
||||
{
|
||||
key: 'country',
|
||||
header: 'Страна',
|
||||
icon: MapPinIcon,
|
||||
sortValue: (row) => row.vps?.country ?? '',
|
||||
cell: (row) =>
|
||||
row.vps?.country
|
||||
? dataGridCellWithFlag(<CountryFlag country={row.vps.country} />, row.vps.country)
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'resources',
|
||||
header: 'Ресурсы',
|
||||
sortValue: (row) => row.vps?.vcpu ?? 0,
|
||||
cell: (row) =>
|
||||
row.vps
|
||||
? formatVpsResources(row.vps.vcpu, row.vps.ramGb, row.vps.diskGb)
|
||||
: '—',
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
header: 'Сводка',
|
||||
cell: (row) => <SummaryBadges run={row} />,
|
||||
},
|
||||
{
|
||||
key: 'checked',
|
||||
header: 'Проверено',
|
||||
sortValue: (row) => row.createdAt,
|
||||
cell: (row) => formatCheckedAt(row.createdAt),
|
||||
},
|
||||
]
|
||||
|
||||
const serviceColumns: DataGridColumn<BlockingServiceRow>[] = [
|
||||
{
|
||||
key: 'service',
|
||||
header: 'Сервис',
|
||||
icon: ShieldAlertIcon,
|
||||
sortValue: (row) => row.serviceKey,
|
||||
cell: (row) => dataGridCellStack(row.serviceLabel, row.category),
|
||||
},
|
||||
{
|
||||
key: 'probes',
|
||||
header: 'Пробы',
|
||||
sortValue: (row) => row.probes.length,
|
||||
sortingFn: 'basic',
|
||||
cell: (row) => row.probes.length,
|
||||
},
|
||||
]
|
||||
|
||||
export function BlockingVpsGrid({
|
||||
runs,
|
||||
onRowClick,
|
||||
emptyAction,
|
||||
}: {
|
||||
runs: CensorcheckRunDto[]
|
||||
onRowClick: (run: CensorcheckRunDto) => void
|
||||
emptyAction?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ExpandableResourceGrid
|
||||
columns={columnDefFromDataGrid(vpsColumns)}
|
||||
data={runs}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={runs.length > 10}
|
||||
emptyTitle="Нет проверок"
|
||||
emptyDescription="Запустите launcher на VPS, чтобы увидеть статусы блокировок."
|
||||
emptyAction={emptyAction}
|
||||
onRowClick={onRowClick}
|
||||
getRowCanExpand={(row) => (row.results?.length ?? 0) > 0}
|
||||
expandedContent={(row) => (
|
||||
<NestedList
|
||||
rows={(row.results ?? []).map((item) => ({
|
||||
key: item.id,
|
||||
primary: item.serviceLabel,
|
||||
secondary: item.category,
|
||||
status: item.status,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function BlockingServiceGrid({
|
||||
groups,
|
||||
emptyAction,
|
||||
}: {
|
||||
groups: BlockingServiceRow[]
|
||||
emptyAction?: ReactNode
|
||||
}) {
|
||||
return (
|
||||
<ExpandableResourceGrid
|
||||
columns={columnDefFromDataGrid(serviceColumns)}
|
||||
data={groups}
|
||||
rowId={(row) => row.id}
|
||||
dense
|
||||
pagination={groups.length > 10}
|
||||
emptyTitle="Нет сервисов"
|
||||
emptyAction={emptyAction}
|
||||
getRowCanExpand={(row) => row.probes.length > 0}
|
||||
expandedContent={(row) => (
|
||||
<NestedList
|
||||
rows={row.probes.map((probe) => ({
|
||||
key: `${probe.runId}-${probe.probePublicIp}`,
|
||||
primary: probe.dns || probe.probePublicIp,
|
||||
secondary: `${probe.probePublicIp} · ${formatCheckedAt(probe.createdAt)}`,
|
||||
status: probe.status,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import {
|
||||
BanIcon,
|
||||
CopyIcon,
|
||||
GlobeIcon,
|
||||
ServerIcon,
|
||||
ShieldAlertIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { PageShell } from '@/components/page-shell'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@cfdm/ui/components/toggle-group'
|
||||
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||
import { KpiStatGrid, ResourcePage, columnDefFromDataGrid } from '@/components/reui-kit'
|
||||
import { Filters, type Filter, type FilterFieldConfig } from '@/components/reui/filters'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { copyText } from '@/lib/clipboard'
|
||||
import { useSpaceId } from '@/lib/space'
|
||||
import {
|
||||
censorcheckCurrentQueryOptions,
|
||||
censorcheckHistoryQueryOptions,
|
||||
} from '@/queries/censorcheck'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import type { DataGridColumn } from '@/components/data-grid-types'
|
||||
import { BlockingServiceGrid, BlockingVpsGrid } from './blocking-grid'
|
||||
import { CheckRunSheet } from './check-run-sheet'
|
||||
import { filterCensorcheckRuns, groupRunsByService } from './blocking-filters'
|
||||
import {
|
||||
CENSORCHECK_STATUS_LABELS,
|
||||
LAUNCHER_CMD,
|
||||
formatCheckedAt,
|
||||
type CensorcheckRunDto,
|
||||
} from './types'
|
||||
|
||||
type GroupMode = 'vps' | 'service'
|
||||
type TabId = 'current' | 'history'
|
||||
|
||||
const FILTER_FIELDS: FilterFieldConfig[] = [
|
||||
{ key: 'q', label: 'Поиск', type: 'text', defaultOperator: 'contains', placeholder: 'IP, DNS, хостер, сервис' },
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Статус',
|
||||
type: 'multiselect',
|
||||
defaultOperator: 'is_any_of',
|
||||
options: [
|
||||
{ value: 'available', label: 'Доступен' },
|
||||
{ value: 'blocked', label: 'Заблокирован' },
|
||||
{ value: 'denied', label: 'Отказ' },
|
||||
{ value: 'timeout', label: 'Таймаут' },
|
||||
{ value: 'redirected', label: 'Редирект' },
|
||||
{ value: 'error', label: 'Ошибка' },
|
||||
],
|
||||
},
|
||||
{ key: 'service', label: 'Сервис', type: 'text', defaultOperator: 'is_any_of' },
|
||||
{ key: 'hoster', label: 'Хостер', type: 'text', defaultOperator: 'contains' },
|
||||
{ key: 'country', label: 'Страна', type: 'text', defaultOperator: 'contains' },
|
||||
{
|
||||
key: 'matched',
|
||||
label: 'Привязка',
|
||||
type: 'select',
|
||||
defaultOperator: 'is',
|
||||
options: [
|
||||
{ value: 'matched', label: 'Известный VPS' },
|
||||
{ value: 'unmatched', label: 'Unknown VPS' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const historyColumns: DataGridColumn<CensorcheckRunDto>[] = [
|
||||
{
|
||||
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={CENSORCHECK_STATUS_LABELS[row.status] ?? row.status} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'summary',
|
||||
header: 'Блок / всего',
|
||||
sortValue: (row) => row.summary.blocked,
|
||||
sortingFn: 'basic',
|
||||
cell: (row) => `${row.summary.blocked} / ${row.summary.total}`,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Проверено',
|
||||
sortValue: (row) => row.createdAt,
|
||||
cell: (row) => formatCheckedAt(row.createdAt),
|
||||
},
|
||||
]
|
||||
|
||||
export function BlockingPage() {
|
||||
const { spaceId } = useSpaceId()
|
||||
const [tab, setTab] = useState<TabId>('current')
|
||||
const [group, setGroup] = useState<GroupMode>('vps')
|
||||
const [filters, setFilters] = useState<Filter[]>([])
|
||||
const [selected, setSelected] = useState<CensorcheckRunDto | null>(null)
|
||||
|
||||
const currentQuery = useQuery(censorcheckCurrentQueryOptions(spaceId))
|
||||
const historyQuery = useQuery({
|
||||
...censorcheckHistoryQueryOptions({ limit: 50 }, spaceId),
|
||||
enabled: tab === 'history',
|
||||
})
|
||||
|
||||
const runs = currentQuery.data?.items ?? []
|
||||
const filtered = useMemo(() => filterCensorcheckRuns(runs, filters), [runs, filters])
|
||||
const serviceGroups = useMemo(() => groupRunsByService(filtered), [filtered])
|
||||
|
||||
const matched = filtered.filter((row) => row.matchedVpsId).length
|
||||
const blocked = filtered.reduce((sum, row) => sum + row.summary.blocked, 0)
|
||||
|
||||
const copyLauncher = (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => void copyText(LAUNCHER_CMD, 'Команда скопирована')}
|
||||
>
|
||||
<CopyIcon data-icon="inline-start" />
|
||||
Скопировать команду
|
||||
</Button>
|
||||
)
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<PageHeader
|
||||
title="Статус блокировок"
|
||||
description="Проверки DPI и геоблокировок с VPS через censorcheck."
|
||||
actions={copyLauncher}
|
||||
/>
|
||||
<KpiStatGrid
|
||||
items={[
|
||||
{
|
||||
id: 'probes',
|
||||
label: 'Пробы',
|
||||
value: filtered.length,
|
||||
icon: <GlobeIcon />,
|
||||
},
|
||||
{
|
||||
id: 'matched',
|
||||
label: 'Известные VPS',
|
||||
value: matched,
|
||||
icon: <ServerIcon />,
|
||||
},
|
||||
{
|
||||
id: 'unmatched',
|
||||
label: 'Unknown VPS',
|
||||
value: filtered.length - matched,
|
||||
icon: <ShieldAlertIcon />,
|
||||
},
|
||||
{
|
||||
id: 'blocked',
|
||||
label: 'Блокировки',
|
||||
value: blocked,
|
||||
icon: <BanIcon />,
|
||||
variant: blocked > 0 ? 'destructive' : 'default',
|
||||
},
|
||||
]}
|
||||
isLoading={currentQuery.isLoading}
|
||||
/>
|
||||
|
||||
<CountedLineTabs
|
||||
tabs={[
|
||||
{ id: 'current', label: 'Текущие', count: filtered.length },
|
||||
{ id: 'history', label: 'История', count: historyQuery.data?.items.length },
|
||||
]}
|
||||
value={tab}
|
||||
onValueChange={(value) => setTab(value as TabId)}
|
||||
/>
|
||||
|
||||
{tab === 'current' ? (
|
||||
<div className="flex flex-col gap-3">
|
||||
<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' ? (
|
||||
<BlockingVpsGrid
|
||||
runs={rows}
|
||||
onRowClick={setSelected}
|
||||
emptyAction={copyLauncher}
|
||||
/>
|
||||
) : (
|
||||
<BlockingServiceGrid groups={serviceGroups} emptyAction={copyLauncher} />
|
||||
)
|
||||
}
|
||||
</QueryState>
|
||||
</div>
|
||||
) : (
|
||||
<ResourcePage
|
||||
title="История проверок"
|
||||
description="Все сохранённые прогоны censorcheck."
|
||||
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,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<CheckRunSheet
|
||||
run={selected}
|
||||
open={Boolean(selected)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelected(null)
|
||||
}}
|
||||
/>
|
||||
</PageShell>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@cfdm/ui/components/sheet'
|
||||
import { DetailPanel } from '@/components/reui-kit/detail-panel'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { censorcheckRunQueryOptions } from '@/queries/censorcheck'
|
||||
import {
|
||||
CENSORCHECK_STATUS_LABELS,
|
||||
formatCheckedAt,
|
||||
formatVpsResources,
|
||||
type CensorcheckRunDto,
|
||||
} from './types'
|
||||
|
||||
interface CheckRunSheetProps {
|
||||
run: CensorcheckRunDto | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) {
|
||||
const needFetch = Boolean(run && !run.results)
|
||||
const { data: fetched } = useQuery({
|
||||
...censorcheckRunQueryOptions(needFetch ? run?.id ?? null : null),
|
||||
})
|
||||
const detail = run?.results ? run : fetched ?? run
|
||||
const title = detail?.vps?.dns || detail?.probePublicIp || 'Проверка'
|
||||
|
||||
return (
|
||||
<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: '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={CENSORCHECK_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.category}</span>
|
||||
</div>
|
||||
<StatusBadge
|
||||
status={item.status}
|
||||
label={CENSORCHECK_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,80 @@
|
||||
import type { CensorcheckSummary } from '@cfdm/shared/contracts/censorcheck'
|
||||
|
||||
export type CensorcheckResultDto = {
|
||||
id: string
|
||||
runId: string
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
category: string
|
||||
status: string
|
||||
httpStatus: number | null
|
||||
detail: string | null
|
||||
}
|
||||
|
||||
export type CensorcheckVpsInfo = {
|
||||
id: string
|
||||
ip: string
|
||||
dns: string
|
||||
providerId: string
|
||||
providerName: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
}
|
||||
|
||||
export type CensorcheckRunDto = {
|
||||
id: string
|
||||
spaceId: string
|
||||
runId: string
|
||||
probePublicIp: string
|
||||
claimedPublicIp: string | null
|
||||
matchedVpsId: string | null
|
||||
status: string
|
||||
schemaVersion: number
|
||||
launcherVersion: string | null
|
||||
censorcheckVersion: string | null
|
||||
summary: CensorcheckSummary
|
||||
createdAt: string
|
||||
completedAt: string
|
||||
observedSourceIp: string | null
|
||||
vps: CensorcheckVpsInfo | null
|
||||
results?: CensorcheckResultDto[]
|
||||
}
|
||||
|
||||
export const CENSORCHECK_STATUS_LABELS: Record<string, string> = {
|
||||
available: 'Доступен',
|
||||
redirected: 'Редирект',
|
||||
denied: 'Отказ',
|
||||
blocked: 'Заблокирован',
|
||||
timeout: 'Таймаут',
|
||||
error: 'Ошибка',
|
||||
complete: 'Полный',
|
||||
partial: 'Частичный',
|
||||
}
|
||||
|
||||
export const LAUNCHER_CMD = 'curl -fsSL https://vt.shnt.top/cc | bash'
|
||||
|
||||
export function formatVpsResources(vcpu: number, ramGb: number, diskGb: number): string {
|
||||
return `${vcpu} vCPU / ${ramGb} GB / ${diskGb} GB`
|
||||
}
|
||||
|
||||
export function formatCheckedAt(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
if (Number.isNaN(date.getTime())) return iso
|
||||
return date.toLocaleString('ru-RU')
|
||||
}
|
||||
|
||||
export function runSearchText(run: CensorcheckRunDto): string {
|
||||
const parts = [
|
||||
run.probePublicIp,
|
||||
run.claimedPublicIp ?? '',
|
||||
run.vps?.dns ?? '',
|
||||
run.vps?.providerName ?? '',
|
||||
run.vps?.country ?? '',
|
||||
...(run.results ?? []).map((row) => `${row.serviceKey} ${row.serviceLabel}`),
|
||||
]
|
||||
return parts.join(' ').toLowerCase()
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
FolderKanbanIcon,
|
||||
LayoutDashboardIcon,
|
||||
SearchIcon,
|
||||
ShieldAlertIcon,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -64,6 +65,10 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
|
||||
<ServerIcon />
|
||||
<span>Все VPS</span>
|
||||
</CommandItem>
|
||||
<CommandItem onSelect={() => go('/blocking')}>
|
||||
<ShieldAlertIcon />
|
||||
<span>Статус блокировок</span>
|
||||
</CommandItem>
|
||||
</CommandGroup>
|
||||
<CommandSeparator />
|
||||
<CommandGroup heading="VPS">
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
HistoryIcon,
|
||||
UsersIcon,
|
||||
Network,
|
||||
ShieldAlert,
|
||||
} from 'lucide-react'
|
||||
|
||||
import {
|
||||
@@ -82,6 +83,7 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: 'Инфраструктура',
|
||||
items: [
|
||||
{ to: '/vps', label: 'VPS', icon: Server },
|
||||
{ to: '/blocking', label: 'Статус блокировок', icon: ShieldAlert },
|
||||
{ to: '/topology', label: 'Схема', icon: Network },
|
||||
{ to: '/tariffs', label: 'Активные тарифы', icon: ServerCog },
|
||||
{ to: '/providers', label: 'Хостеры', icon: Building2 },
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import {
|
||||
FrameDataGrid,
|
||||
type FrameDataGridProps,
|
||||
} from './frame-data-grid'
|
||||
|
||||
/**
|
||||
* Frame + DataGrid with expandable rows.
|
||||
* Preview: https://reui.io/preview/base/components/c-data-grid-8
|
||||
* Docs: https://reui.io/docs/components/base/data-grid
|
||||
*/
|
||||
export function ExpandableResourceGrid<TData extends object>({
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
...props
|
||||
}: FrameDataGridProps<TData> & {
|
||||
expandedContent: (row: TData) => ReactNode
|
||||
getRowCanExpand?: (row: TData) => boolean
|
||||
}) {
|
||||
return (
|
||||
<FrameDataGrid
|
||||
{...props}
|
||||
expandedContent={expandedContent}
|
||||
getRowCanExpand={getRowCanExpand}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -4,14 +4,16 @@ import {
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
getPaginationRowModel,
|
||||
getExpandedRowModel,
|
||||
flexRender,
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
type RowSelectionState,
|
||||
type VisibilityState,
|
||||
type ExpandedState,
|
||||
type OnChangeFn,
|
||||
} from '@tanstack/react-table'
|
||||
import { Columns3Icon } from 'lucide-react'
|
||||
import { ChevronDownIcon, ChevronRightIcon, Columns3Icon } from 'lucide-react'
|
||||
|
||||
import { Checkbox } from '@cfdm/ui/components/checkbox'
|
||||
import { Button } from '@cfdm/ui/components/button'
|
||||
@@ -125,6 +127,9 @@ export interface FrameDataGridProps<TData extends object> {
|
||||
/** Начальная видимость колонок (перекрывает localStorage для отсутствующих ключей). */
|
||||
initialColumnVisibility?: VisibilityState
|
||||
className?: string
|
||||
/** Expandable rows — c-data-grid-8 / https://reui.io/preview/base/components/c-data-grid-8 */
|
||||
expandedContent?: (row: TData) => ReactNode
|
||||
getRowCanExpand?: (row: TData) => boolean
|
||||
}
|
||||
|
||||
function DataGridSectionHeader({
|
||||
@@ -251,9 +256,12 @@ export function FrameDataGrid<TData extends object>({
|
||||
columnVisibilityStorageKey,
|
||||
initialColumnVisibility,
|
||||
className,
|
||||
expandedContent,
|
||||
getRowCanExpand,
|
||||
}: FrameDataGridProps<TData>) {
|
||||
const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||
const [expanded, setExpanded] = useState<ExpandedState>({})
|
||||
const [internalColumnVisibility, setInternalColumnVisibility] = useState<VisibilityState>(() => {
|
||||
const stored = columnVisibilityStorageKey
|
||||
? loadStoredColumnVisibility(columnVisibilityStorageKey)
|
||||
@@ -295,7 +303,42 @@ export function FrameDataGrid<TData extends object>({
|
||||
meta: { cellClassName: 'w-10' },
|
||||
}
|
||||
|
||||
const tableColumns = enableRowSelection ? [selectColumn, ...columns] : columns
|
||||
const expandColumn: ColumnDef<TData, unknown> = {
|
||||
id: 'expand',
|
||||
header: () => null,
|
||||
cell: ({ row }) =>
|
||||
row.getCanExpand() ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="size-6 text-muted-foreground"
|
||||
aria-label={row.getIsExpanded() ? 'Свернуть' : 'Развернуть'}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
row.toggleExpanded()
|
||||
}}
|
||||
>
|
||||
{row.getIsExpanded() ? (
|
||||
<ChevronDownIcon className="size-4" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
) : null,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
meta: {
|
||||
cellClassName: 'w-10',
|
||||
expandedContent,
|
||||
},
|
||||
}
|
||||
|
||||
const tableColumns = [
|
||||
...(expandedContent ? [expandColumn] : []),
|
||||
...(enableRowSelection ? [selectColumn] : []),
|
||||
...columns,
|
||||
]
|
||||
|
||||
const lastColId = pinLastColumn ? tableColumns[tableColumns.length - 1]?.id ?? '' : ''
|
||||
|
||||
@@ -307,9 +350,11 @@ export function FrameDataGrid<TData extends object>({
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
expanded,
|
||||
...(enableRowSelection ? { rowSelection } : {}),
|
||||
},
|
||||
onSortingChange: setSorting,
|
||||
onExpandedChange: setExpanded,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onRowSelectionChange: enableRowSelection
|
||||
? (updater) => {
|
||||
@@ -325,6 +370,7 @@ export function FrameDataGrid<TData extends object>({
|
||||
: undefined,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getExpandedRowModel: expandedContent ? getExpandedRowModel() : undefined,
|
||||
getPaginationRowModel: showPagination ? getPaginationRowModel() : undefined,
|
||||
initialState: {
|
||||
...(showPagination ? { pagination: { pageIndex: 0, pageSize } } : {}),
|
||||
@@ -333,6 +379,9 @@ export function FrameDataGrid<TData extends object>({
|
||||
getRowId: rowId
|
||||
? (row, index) => rowId(row, index)
|
||||
: undefined,
|
||||
getRowCanExpand: expandedContent
|
||||
? (row) => (getRowCanExpand ? getRowCanExpand(row.original) : true)
|
||||
: undefined,
|
||||
enableColumnPinning: pinLastColumn,
|
||||
enableRowSelection,
|
||||
enableHiding: enableColumnVisibility,
|
||||
|
||||
@@ -20,6 +20,7 @@ export {
|
||||
type FrameDataGridProps,
|
||||
type DataGridColumnVisibilityOption,
|
||||
} from './frame-data-grid'
|
||||
export { ExpandableResourceGrid } from './expandable-resource-grid'
|
||||
export { OpsDashboard } from './ops-dashboard'
|
||||
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||
|
||||
@@ -8,12 +8,19 @@ const STATUS_VARIANT: Record<string, BadgeVariant> = {
|
||||
active: 'success',
|
||||
ok: 'success',
|
||||
paid: 'success',
|
||||
available: 'success',
|
||||
complete: 'success',
|
||||
paused: 'secondary',
|
||||
archived: 'outline',
|
||||
error: 'destructive',
|
||||
denied: 'destructive',
|
||||
blocked: 'destructive',
|
||||
running: 'info',
|
||||
overdue: 'warning',
|
||||
stale: 'warning',
|
||||
timeout: 'warning',
|
||||
redirected: 'warning',
|
||||
partial: 'warning',
|
||||
}
|
||||
|
||||
export function StatusBadge({ status, label }: { status: string; label?: string }) {
|
||||
|
||||
@@ -417,6 +417,37 @@ export const api = {
|
||||
actorUserId?: string | null
|
||||
createdAt: string
|
||||
}>>(`/api/audit?limit=${limit}`),
|
||||
|
||||
fetchCensorcheckCurrent: () =>
|
||||
fetchApi<{ items: import('@/components/censorcheck/types').CensorcheckRunDto[] }>(
|
||||
'/api/censorcheck/current',
|
||||
),
|
||||
|
||||
fetchCensorcheckRuns: (params: {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
q?: string
|
||||
status?: string
|
||||
matched?: boolean
|
||||
} = {}) => {
|
||||
const search = new URLSearchParams()
|
||||
if (params.cursor) search.set('cursor', params.cursor)
|
||||
if (params.limit) search.set('limit', String(params.limit))
|
||||
if (params.q) search.set('q', params.q)
|
||||
if (params.status) search.set('status', params.status)
|
||||
if (params.matched === true) search.set('matched', '1')
|
||||
if (params.matched === false) search.set('matched', '0')
|
||||
const qs = search.toString()
|
||||
return fetchApi<{
|
||||
items: import('@/components/censorcheck/types').CensorcheckRunDto[]
|
||||
nextCursor: string | null
|
||||
}>(`/api/censorcheck/runs${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
|
||||
fetchCensorcheckRun: (id: string) =>
|
||||
fetchApi<import('@/components/censorcheck/types').CensorcheckRunDto>(
|
||||
`/api/censorcheck/runs/${encodeURIComponent(id)}`,
|
||||
),
|
||||
}
|
||||
|
||||
export type {
|
||||
|
||||
@@ -234,6 +234,7 @@ export function permissionForPath(pathname: string): string | null {
|
||||
if (pathname.startsWith('/dashboard')) return 'vps:dashboard:read'
|
||||
if (
|
||||
pathname.startsWith('/vps') ||
|
||||
pathname.startsWith('/blocking') ||
|
||||
pathname.startsWith('/topology') ||
|
||||
pathname.startsWith('/tariffs') ||
|
||||
pathname.startsWith('/projects') ||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { queryClient } from '../lib/queryClient'
|
||||
import { api } from '../lib/api-client'
|
||||
import { getStoredSpaceId } from '../lib/space'
|
||||
|
||||
export const censorcheckKeys = {
|
||||
all: ['censorcheck'] as const,
|
||||
current: (spaceId: string | null) => ['censorcheck', 'current', spaceId ?? 'default'] as const,
|
||||
history: (spaceId: string | null, params: Record<string, unknown>) =>
|
||||
['censorcheck', 'history', spaceId ?? 'default', params] as const,
|
||||
detail: (id: string) => ['censorcheck', 'run', id] as const,
|
||||
}
|
||||
|
||||
export const censorcheckCurrentQueryOptions = (spaceId?: string | null) => {
|
||||
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
|
||||
return {
|
||||
queryKey: censorcheckKeys.current(id),
|
||||
queryFn: () => api.fetchCensorcheckCurrent(),
|
||||
staleTime: 15_000,
|
||||
}
|
||||
}
|
||||
|
||||
export const censorcheckHistoryQueryOptions = (
|
||||
params: { cursor?: string; limit?: number; q?: string; status?: string; matched?: boolean } = {},
|
||||
spaceId?: string | null,
|
||||
) => {
|
||||
const id = spaceId === undefined ? getStoredSpaceId() : spaceId
|
||||
return {
|
||||
queryKey: censorcheckKeys.history(id, params),
|
||||
queryFn: () => api.fetchCensorcheckRuns({ limit: 50, ...params }),
|
||||
staleTime: 15_000,
|
||||
}
|
||||
}
|
||||
|
||||
export const censorcheckRunQueryOptions = (id: string | null) => ({
|
||||
queryKey: censorcheckKeys.detail(id ?? ''),
|
||||
queryFn: () => api.fetchCensorcheckRun(id!),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
export { queryClient }
|
||||
@@ -24,6 +24,7 @@ import { Route as AuthProvidersRouteImport } from './routes/_auth/providers'
|
||||
import { Route as AuthProjectsRouteImport } from './routes/_auth/projects'
|
||||
import { Route as AuthPaymentsRouteImport } from './routes/_auth/payments'
|
||||
import { Route as AuthDashboardRouteImport } from './routes/_auth/dashboard'
|
||||
import { Route as AuthBlockingRouteImport } from './routes/_auth/blocking'
|
||||
import { Route as AuthBalanceRouteImport } from './routes/_auth/balance'
|
||||
import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
|
||||
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
|
||||
@@ -109,6 +110,11 @@ const AuthDashboardRoute = AuthDashboardRouteImport.update({
|
||||
path: '/dashboard',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthBlockingRoute = AuthBlockingRouteImport.update({
|
||||
id: '/blocking',
|
||||
path: '/blocking',
|
||||
getParentRoute: () => AuthRoute,
|
||||
} as any)
|
||||
const AuthBalanceRoute = AuthBalanceRouteImport.update({
|
||||
id: '/balance',
|
||||
path: '/balance',
|
||||
@@ -168,6 +174,7 @@ export interface FileRoutesByFullPath {
|
||||
'/accounts': typeof AuthAccountsRoute
|
||||
'/audit': typeof AuthAuditRoute
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/blocking': typeof AuthBlockingRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
@@ -193,6 +200,7 @@ export interface FileRoutesByTo {
|
||||
'/accounts': typeof AuthAccountsRoute
|
||||
'/audit': typeof AuthAuditRoute
|
||||
'/balance': typeof AuthBalanceRoute
|
||||
'/blocking': typeof AuthBlockingRoute
|
||||
'/dashboard': typeof AuthDashboardRoute
|
||||
'/payments': typeof AuthPaymentsRoute
|
||||
'/projects': typeof AuthProjectsRouteWithChildren
|
||||
@@ -221,6 +229,7 @@ export interface FileRoutesById {
|
||||
'/_auth/accounts': typeof AuthAccountsRoute
|
||||
'/_auth/audit': typeof AuthAuditRoute
|
||||
'/_auth/balance': typeof AuthBalanceRoute
|
||||
'/_auth/blocking': typeof AuthBlockingRoute
|
||||
'/_auth/dashboard': typeof AuthDashboardRoute
|
||||
'/_auth/payments': typeof AuthPaymentsRoute
|
||||
'/_auth/projects': typeof AuthProjectsRouteWithChildren
|
||||
@@ -249,6 +258,7 @@ export interface FileRouteTypes {
|
||||
| '/accounts'
|
||||
| '/audit'
|
||||
| '/balance'
|
||||
| '/blocking'
|
||||
| '/dashboard'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
@@ -274,6 +284,7 @@ export interface FileRouteTypes {
|
||||
| '/accounts'
|
||||
| '/audit'
|
||||
| '/balance'
|
||||
| '/blocking'
|
||||
| '/dashboard'
|
||||
| '/payments'
|
||||
| '/projects'
|
||||
@@ -301,6 +312,7 @@ export interface FileRouteTypes {
|
||||
| '/_auth/accounts'
|
||||
| '/_auth/audit'
|
||||
| '/_auth/balance'
|
||||
| '/_auth/blocking'
|
||||
| '/_auth/dashboard'
|
||||
| '/_auth/payments'
|
||||
| '/_auth/projects'
|
||||
@@ -435,6 +447,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof AuthDashboardRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/blocking': {
|
||||
id: '/_auth/blocking'
|
||||
path: '/blocking'
|
||||
fullPath: '/blocking'
|
||||
preLoaderRoute: typeof AuthBlockingRouteImport
|
||||
parentRoute: typeof AuthRoute
|
||||
}
|
||||
'/_auth/balance': {
|
||||
id: '/_auth/balance'
|
||||
path: '/balance'
|
||||
@@ -553,6 +572,7 @@ interface AuthRouteChildren {
|
||||
AuthAccountsRoute: typeof AuthAccountsRoute
|
||||
AuthAuditRoute: typeof AuthAuditRoute
|
||||
AuthBalanceRoute: typeof AuthBalanceRoute
|
||||
AuthBlockingRoute: typeof AuthBlockingRoute
|
||||
AuthDashboardRoute: typeof AuthDashboardRoute
|
||||
AuthPaymentsRoute: typeof AuthPaymentsRoute
|
||||
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren
|
||||
@@ -572,6 +592,7 @@ const AuthRouteChildren: AuthRouteChildren = {
|
||||
AuthAccountsRoute: AuthAccountsRoute,
|
||||
AuthAuditRoute: AuthAuditRoute,
|
||||
AuthBalanceRoute: AuthBalanceRoute,
|
||||
AuthBlockingRoute: AuthBlockingRoute,
|
||||
AuthDashboardRoute: AuthDashboardRoute,
|
||||
AuthPaymentsRoute: AuthPaymentsRoute,
|
||||
AuthProjectsRoute: AuthProjectsRouteWithChildren,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
import { BlockingPage } from '@/components/censorcheck/blocking-page'
|
||||
import { censorcheckCurrentQueryOptions } from '@/queries/censorcheck'
|
||||
|
||||
export const Route = createFileRoute('/_auth/blocking')({
|
||||
loader: ({ context: { queryClient } }) =>
|
||||
queryClient.ensureQueryData(censorcheckCurrentQueryOptions()),
|
||||
component: BlockingPage,
|
||||
})
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],
|
||||
|
||||
@@ -66,6 +66,11 @@ services:
|
||||
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
||||
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-https://auth.shnt.top}
|
||||
TRUST_PROXY: "1"
|
||||
CENSORCHECK_INGEST_SECRET: ${CENSORCHECK_INGEST_SECRET:-}
|
||||
CENSORCHECK_PUBLIC_URL: ${CENSORCHECK_PUBLIC_URL:-}
|
||||
VPS_DOMAIN: ${VPS_DOMAIN:-vps.shnt.top}
|
||||
VPS_LAUNCHER_DOMAIN: ${VPS_LAUNCHER_DOMAIN:-vt.shnt.top}
|
||||
# Import SQLite/JSON via UI (default in app: 100 MiB). Raise if DB is larger.
|
||||
BACKUP_BODY_LIMIT_BYTES: ${BACKUP_BODY_LIMIT_BYTES:-104857600}
|
||||
volumes:
|
||||
@@ -75,7 +80,7 @@ services:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.docker.network=vps-tracker
|
||||
- traefik.http.routers.vps-tracker.rule=Host(`${VPS_DOMAIN:-vps.shnt.top}`)
|
||||
- traefik.http.routers.vps-tracker.rule=Host(`${VPS_DOMAIN:-vps.shnt.top}`) || Host(`${VPS_LAUNCHER_DOMAIN:-vt.shnt.top}`)
|
||||
- traefik.http.routers.vps-tracker.entrypoints=websecure
|
||||
- traefik.http.routers.vps-tracker.tls=true
|
||||
- traefik.http.routers.vps-tracker.tls.certresolver=letsencrypt
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
CF_DNS_API_TOKEN=
|
||||
LETSENCRYPT_EMAIL=admin@example.com
|
||||
VPS_DOMAIN=vps.example.com
|
||||
# Короткий хост для launcher: curl -fsSL https://vt.example.com/cc | bash
|
||||
VPS_LAUNCHER_DOMAIN=vt.example.com
|
||||
# TRAEFIK_IMAGE_TAG=v3.7
|
||||
# TRAEFIK_HTTP_PORT=80
|
||||
# TRAEFIK_HTTPS_PORT=443
|
||||
@@ -23,5 +25,10 @@ AUTH_JWT_SECRET=
|
||||
AUTH_ISSUER=https://auth.shnt.top
|
||||
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||
|
||||
# --- Censorcheck launcher (GET /cc, ingest HMAC) ---
|
||||
# CENSORCHECK_INGEST_SECRET=
|
||||
# CENSORCHECK_PUBLIC_URL=https://vt.example.com
|
||||
# TRUST_PROXY is set to 1 in compose (Traefik X-Forwarded-For).
|
||||
|
||||
# --- Backup import (POST /api/backup/*). Fastify default is 1 MiB → 413 Content Too Large.
|
||||
# BACKUP_BODY_LIMIT_BYTES=104857600
|
||||
|
||||
@@ -48,6 +48,7 @@ Internet → :80/:443 (Traefik) → vps-tracker:3001
|
||||
| Type | Name | Content | Proxy |
|
||||
|------|------|---------|-------|
|
||||
| `A` / `AAAA` | `vps` (или нужный поддомен) | IP вашего VPS | **DNS only** (серое облако) |
|
||||
| `A` / `AAAA` | `vt` (launcher `curl \| bash`) | тот же IP | **DNS only** (серое облако) |
|
||||
|
||||
```bash
|
||||
dig +short vps.example.com A
|
||||
@@ -82,7 +83,8 @@ nano .env # заполнить секреты и домен
|
||||
|------------|------------|
|
||||
| `CF_DNS_API_TOKEN` | Cloudflare token для ACME DNS-01 (env контейнера **Traefik**) |
|
||||
| `LETSENCRYPT_EMAIL` | Email для Let's Encrypt |
|
||||
| `VPS_DOMAIN` | Хост в Traefik `Host(…)` (например `vps.example.com`) |
|
||||
| `VPS_DOMAIN` | Хост UI в Traefik `Host(…)` (например `vps.example.com`) |
|
||||
| `VPS_LAUNCHER_DOMAIN` | Короткий хост launcher (`vt.example.com` → тот же контейнер, `GET /cc`) |
|
||||
|
||||
Опционально:
|
||||
|
||||
@@ -91,6 +93,8 @@ nano .env # заполнить секреты и домен
|
||||
| `VPS_TRACKER_IMAGE` / `VPS_TRACKER_IMAGE_TAG` | Образ и тег |
|
||||
| `TRAEFIK_IMAGE_TAG`, `TRAEFIK_HTTP_PORT`, `TRAEFIK_HTTPS_PORT` | Версия Traefik и порты |
|
||||
| `AUTH_REQUIRED`, `AUTH_JWT_SECRET`, `AUTH_ISSUER`, `AUTH_PORTAL_URL` | SSO через auth-portal |
|
||||
| `CENSORCHECK_INGEST_SECRET` | HMAC-секрет одноразовых токенов `GET /cc` (fallback: `AUTH_JWT_SECRET`) |
|
||||
| `CENSORCHECK_PUBLIC_URL` | URL в скрипте launcher, обычно `https://vt.example.com` |
|
||||
|
||||
Для SSO с auth-portal:
|
||||
|
||||
@@ -136,6 +140,9 @@ docker compose logs -f --tail=100
|
||||
curl -fsS https://vps.example.com/health
|
||||
# {"ok":true}
|
||||
|
||||
curl -fsS https://vt.example.com/cc | head
|
||||
# bash launcher (Cache-Control: no-store)
|
||||
|
||||
echo | openssl s_client -connect vps.example.com:443 -servername vps.example.com 2>/dev/null \
|
||||
| openssl x509 -noout -issuer -dates -subject
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { and, desc, eq, isNotNull, isNull, like, or, sql } from 'drizzle-orm'
|
||||
import type {
|
||||
CensorcheckCategory,
|
||||
CensorcheckRunStatus,
|
||||
CensorcheckStatus,
|
||||
CensorcheckSummary,
|
||||
} from '@cfdm/shared/contracts/censorcheck'
|
||||
import { getDb, getSqlite, schema } from '../index.js'
|
||||
import { getCurrentSpaceId } from '../space-context.js'
|
||||
import { generateId } from './utils.js'
|
||||
import { vpsRepository } from './vps.js'
|
||||
|
||||
type RunRow = typeof schema.censorcheckRuns.$inferSelect
|
||||
type ResultRow = typeof schema.censorcheckResults.$inferSelect
|
||||
|
||||
export type CensorcheckResultDto = {
|
||||
id: string
|
||||
runId: string
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
category: CensorcheckCategory
|
||||
status: CensorcheckStatus
|
||||
httpStatus: number | null
|
||||
detail: string | null
|
||||
rawJson: string | null
|
||||
}
|
||||
|
||||
export type CensorcheckVpsInfo = {
|
||||
id: string
|
||||
ip: string
|
||||
dns: string
|
||||
providerId: string
|
||||
providerName: string
|
||||
country: string
|
||||
city: string
|
||||
datacenter: string
|
||||
vcpu: number
|
||||
ramGb: number
|
||||
diskGb: number
|
||||
}
|
||||
|
||||
export type CensorcheckRunDto = {
|
||||
id: string
|
||||
spaceId: string
|
||||
runId: string
|
||||
probePublicIp: string
|
||||
claimedPublicIp: string | null
|
||||
matchedVpsId: string | null
|
||||
status: CensorcheckRunStatus
|
||||
schemaVersion: number
|
||||
launcherVersion: string | null
|
||||
censorcheckVersion: string | null
|
||||
summary: CensorcheckSummary
|
||||
createdAt: string
|
||||
completedAt: string
|
||||
observedSourceIp: string | null
|
||||
vps: CensorcheckVpsInfo | null
|
||||
results?: CensorcheckResultDto[]
|
||||
}
|
||||
|
||||
export type CensorcheckInsertResult = {
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
category: CensorcheckCategory
|
||||
status: CensorcheckStatus
|
||||
httpStatus: number | null
|
||||
detail: string | null
|
||||
rawJson: string | null
|
||||
}
|
||||
|
||||
export type CensorcheckInsertRun = {
|
||||
spaceId: string
|
||||
runId: string
|
||||
probePublicIp: string
|
||||
claimedPublicIp: string | null
|
||||
matchedVpsId: string | null
|
||||
status: CensorcheckRunStatus
|
||||
schemaVersion: number
|
||||
launcherVersion: string | null
|
||||
censorcheckVersion: string | null
|
||||
summary: CensorcheckSummary
|
||||
observedSourceIp: string | null
|
||||
results: CensorcheckInsertResult[]
|
||||
}
|
||||
|
||||
export type CensorcheckHistoryQuery = {
|
||||
cursor?: string
|
||||
limit?: number
|
||||
q?: string
|
||||
status?: string
|
||||
matched?: boolean
|
||||
}
|
||||
|
||||
function parseSummary(raw: string | null | undefined): CensorcheckSummary {
|
||||
try {
|
||||
const parsed = raw ? (JSON.parse(raw) as Partial<CensorcheckSummary>) : {}
|
||||
return {
|
||||
total: Number(parsed.total) || 0,
|
||||
available: Number(parsed.available) || 0,
|
||||
redirected: Number(parsed.redirected) || 0,
|
||||
denied: Number(parsed.denied) || 0,
|
||||
blocked: Number(parsed.blocked) || 0,
|
||||
timeout: Number(parsed.timeout) || 0,
|
||||
error: Number(parsed.error) || 0,
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
total: 0,
|
||||
available: 0,
|
||||
redirected: 0,
|
||||
denied: 0,
|
||||
blocked: 0,
|
||||
timeout: 0,
|
||||
error: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toResultDto(row: ResultRow): CensorcheckResultDto {
|
||||
return {
|
||||
id: row.id,
|
||||
runId: row.runId,
|
||||
serviceKey: row.serviceKey,
|
||||
serviceLabel: row.serviceLabel,
|
||||
category: row.category as CensorcheckCategory,
|
||||
status: row.status as CensorcheckStatus,
|
||||
httpStatus: row.httpStatus ?? null,
|
||||
detail: row.detail ?? null,
|
||||
rawJson: row.rawJson ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function providerNameById(providerId: string): string {
|
||||
if (!providerId) return ''
|
||||
const row = getDb()
|
||||
.select({ name: schema.providers.name })
|
||||
.from(schema.providers)
|
||||
.where(eq(schema.providers.id, providerId))
|
||||
.get()
|
||||
return row?.name ?? ''
|
||||
}
|
||||
|
||||
function hydrateVps(matchedVpsId: string | null): CensorcheckVpsInfo | null {
|
||||
if (!matchedVpsId) return null
|
||||
const vps = vpsRepository.getAnySpace(matchedVpsId)
|
||||
if (!vps) return null
|
||||
return {
|
||||
id: vps.id,
|
||||
ip: vps.ip ?? '',
|
||||
dns: vps.dns ?? '',
|
||||
providerId: vps.providerId ?? '',
|
||||
providerName: providerNameById(vps.providerId ?? ''),
|
||||
country: vps.country ?? '',
|
||||
city: vps.city ?? '',
|
||||
datacenter: vps.datacenter ?? '',
|
||||
vcpu: Number(vps.vcpu) || 0,
|
||||
ramGb: Number(vps.ramGb) || 0,
|
||||
diskGb: Number(vps.diskGb) || 0,
|
||||
}
|
||||
}
|
||||
|
||||
function toRunDto(row: RunRow, includeResults = false): CensorcheckRunDto {
|
||||
const dto: CensorcheckRunDto = {
|
||||
id: row.id,
|
||||
spaceId: row.spaceId,
|
||||
runId: row.runId,
|
||||
probePublicIp: row.probePublicIp,
|
||||
claimedPublicIp: row.claimedPublicIp ?? null,
|
||||
matchedVpsId: row.matchedVpsId ?? null,
|
||||
status: row.status as CensorcheckRunStatus,
|
||||
schemaVersion: row.schemaVersion,
|
||||
launcherVersion: row.launcherVersion ?? null,
|
||||
censorcheckVersion: row.censorcheckVersion ?? null,
|
||||
summary: parseSummary(row.summaryJson),
|
||||
createdAt: row.createdAt,
|
||||
completedAt: row.completedAt,
|
||||
observedSourceIp: row.observedSourceIp ?? null,
|
||||
vps: hydrateVps(row.matchedVpsId ?? null),
|
||||
}
|
||||
if (includeResults) {
|
||||
dto.results = listResults(row.id)
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
function listResults(internalRunId: string): CensorcheckResultDto[] {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(schema.censorcheckResults)
|
||||
.where(eq(schema.censorcheckResults.runId, internalRunId))
|
||||
.all()
|
||||
.map(toResultDto)
|
||||
}
|
||||
|
||||
function encodeCursor(createdAt: string, id: string): string {
|
||||
return Buffer.from(`${createdAt}|${id}`, 'utf8').toString('base64url')
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string): { createdAt: string; id: string } | null {
|
||||
try {
|
||||
const raw = Buffer.from(cursor, 'base64url').toString('utf8')
|
||||
const idx = raw.indexOf('|')
|
||||
if (idx <= 0) return null
|
||||
return { createdAt: raw.slice(0, idx), id: raw.slice(idx + 1) }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const censorcheckRepository = {
|
||||
getByClientRunId(runId: string): CensorcheckRunDto | undefined {
|
||||
const row = getDb()
|
||||
.select()
|
||||
.from(schema.censorcheckRuns)
|
||||
.where(eq(schema.censorcheckRuns.runId, runId))
|
||||
.get()
|
||||
return row ? toRunDto(row, true) : undefined
|
||||
},
|
||||
|
||||
getById(id: string): CensorcheckRunDto | undefined {
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const row = getDb()
|
||||
.select()
|
||||
.from(schema.censorcheckRuns)
|
||||
.where(and(eq(schema.censorcheckRuns.id, id), eq(schema.censorcheckRuns.spaceId, spaceId)))
|
||||
.get()
|
||||
return row ? toRunDto(row, true) : undefined
|
||||
},
|
||||
|
||||
create(input: CensorcheckInsertRun): CensorcheckRunDto {
|
||||
const db = getDb()
|
||||
const now = new Date().toISOString()
|
||||
const id = generateId('ccrun')
|
||||
db.transaction(() => {
|
||||
db.insert(schema.censorcheckRuns)
|
||||
.values({
|
||||
id,
|
||||
spaceId: input.spaceId,
|
||||
runId: input.runId,
|
||||
probePublicIp: input.probePublicIp,
|
||||
claimedPublicIp: input.claimedPublicIp,
|
||||
matchedVpsId: input.matchedVpsId,
|
||||
status: input.status,
|
||||
schemaVersion: input.schemaVersion,
|
||||
launcherVersion: input.launcherVersion,
|
||||
censorcheckVersion: input.censorcheckVersion,
|
||||
summaryJson: JSON.stringify(input.summary),
|
||||
createdAt: now,
|
||||
completedAt: now,
|
||||
observedSourceIp: input.observedSourceIp,
|
||||
})
|
||||
.run()
|
||||
for (const result of input.results) {
|
||||
db.insert(schema.censorcheckResults)
|
||||
.values({
|
||||
id: generateId('ccres'),
|
||||
runId: id,
|
||||
serviceKey: result.serviceKey,
|
||||
serviceLabel: result.serviceLabel,
|
||||
category: result.category,
|
||||
status: result.status,
|
||||
httpStatus: result.httpStatus,
|
||||
detail: result.detail,
|
||||
rawJson: result.rawJson,
|
||||
})
|
||||
.run()
|
||||
}
|
||||
})
|
||||
return this.getByClientRunId(input.runId)!
|
||||
},
|
||||
|
||||
listCurrent(): CensorcheckRunDto[] {
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const sqlite = getSqlite()
|
||||
const rows = sqlite
|
||||
.prepare(
|
||||
`SELECT * FROM censorcheck_runs r
|
||||
WHERE r.spaceId = ?
|
||||
AND r.id = (
|
||||
SELECT r2.id FROM censorcheck_runs r2
|
||||
WHERE r2.spaceId = r.spaceId AND r2.probePublicIp = r.probePublicIp
|
||||
ORDER BY r2.createdAt DESC, r2.id DESC
|
||||
LIMIT 1
|
||||
)
|
||||
ORDER BY r.createdAt DESC`,
|
||||
)
|
||||
.all(spaceId) as RunRow[]
|
||||
return rows.map((row) => toRunDto(row, true))
|
||||
},
|
||||
|
||||
listHistory(query: CensorcheckHistoryQuery = {}): {
|
||||
items: CensorcheckRunDto[]
|
||||
nextCursor: string | null
|
||||
} {
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const limit = Math.min(Math.max(query.limit ?? 50, 1), 200)
|
||||
const q = query.q?.trim()
|
||||
const clauses = [eq(schema.censorcheckRuns.spaceId, spaceId)]
|
||||
|
||||
if (q) {
|
||||
const pattern = `%${q}%`
|
||||
clauses.push(
|
||||
or(
|
||||
like(schema.censorcheckRuns.probePublicIp, pattern),
|
||||
like(schema.censorcheckRuns.claimedPublicIp, pattern),
|
||||
like(schema.censorcheckRuns.runId, pattern),
|
||||
)!,
|
||||
)
|
||||
}
|
||||
if (query.status) {
|
||||
clauses.push(eq(schema.censorcheckRuns.status, query.status))
|
||||
}
|
||||
if (query.matched === true) {
|
||||
clauses.push(isNotNull(schema.censorcheckRuns.matchedVpsId))
|
||||
} else if (query.matched === false) {
|
||||
clauses.push(isNull(schema.censorcheckRuns.matchedVpsId))
|
||||
}
|
||||
|
||||
const cursor = query.cursor ? decodeCursor(query.cursor) : null
|
||||
if (cursor) {
|
||||
clauses.push(
|
||||
sql`(${schema.censorcheckRuns.createdAt} < ${cursor.createdAt} OR (${schema.censorcheckRuns.createdAt} = ${cursor.createdAt} AND ${schema.censorcheckRuns.id} < ${cursor.id}))`,
|
||||
)
|
||||
}
|
||||
|
||||
const rows = getDb()
|
||||
.select()
|
||||
.from(schema.censorcheckRuns)
|
||||
.where(and(...clauses))
|
||||
.orderBy(desc(schema.censorcheckRuns.createdAt), desc(schema.censorcheckRuns.id))
|
||||
.limit(limit + 1)
|
||||
.all()
|
||||
|
||||
const page = rows.slice(0, limit)
|
||||
const last = page[page.length - 1]
|
||||
return {
|
||||
items: page.map((row) => toRunDto(row, false)),
|
||||
nextCursor: rows.length > limit && last ? encodeCursor(last.createdAt, last.id) : null,
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
collectVpsIps,
|
||||
findVpsIdByIps,
|
||||
isPrivateOrLoopbackIp,
|
||||
normalizeIp,
|
||||
} from './ip-match.js'
|
||||
|
||||
describe('ip-match', () => {
|
||||
it('собирает ipv4, ipv6 и additionalIps', () => {
|
||||
expect(
|
||||
collectVpsIps({
|
||||
ip: '203.0.113.10',
|
||||
ipv6: '2001:db8::1',
|
||||
additionalIps: ['198.51.100.2'],
|
||||
}),
|
||||
).toEqual(['203.0.113.10', '2001:db8::1', '198.51.100.2'])
|
||||
})
|
||||
|
||||
it('матчит ровно один VPS по IPv6', () => {
|
||||
const all = [
|
||||
{ id: 'a', ip: '203.0.113.1', ipv6: '2001:db8::10', additionalIps: [] },
|
||||
{ id: 'b', ip: '203.0.113.2', ipv6: '2001:db8::20', additionalIps: [] },
|
||||
]
|
||||
expect(findVpsIdByIps(all, ['2001:DB8::10'])).toBe('a')
|
||||
})
|
||||
|
||||
it('не матчит при двух совпадениях', () => {
|
||||
const all = [
|
||||
{ id: 'a', ip: '203.0.113.10', additionalIps: [] },
|
||||
{ id: 'b', ip: '', additionalIps: ['203.0.113.10'] },
|
||||
]
|
||||
expect(findVpsIdByIps(all, ['203.0.113.10'])).toBeNull()
|
||||
})
|
||||
|
||||
it('считает loopback и RFC1918 приватными', () => {
|
||||
expect(isPrivateOrLoopbackIp('127.0.0.1')).toBe(true)
|
||||
expect(isPrivateOrLoopbackIp('10.1.2.3')).toBe(true)
|
||||
expect(isPrivateOrLoopbackIp('192.168.0.1')).toBe(true)
|
||||
expect(isPrivateOrLoopbackIp('::1')).toBe(true)
|
||||
expect(isPrivateOrLoopbackIp('203.0.113.10')).toBe(false)
|
||||
expect(normalizeIp(' 203.0.113.10 ')).toBe('203.0.113.10')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
export function normalizeIp(ip: string): string {
|
||||
return ip.trim().toLowerCase().split('%')[0] ?? ''
|
||||
}
|
||||
|
||||
export function isIpLiteral(value: string): boolean {
|
||||
const v = value.trim()
|
||||
if (!v) return false
|
||||
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(v)) {
|
||||
return v.split('.').every((p) => {
|
||||
const n = Number(p)
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
return v.includes(':') && !v.includes(' ')
|
||||
}
|
||||
|
||||
type VpsIpFields = {
|
||||
id: string
|
||||
ip?: string | null
|
||||
ipv6?: string | null
|
||||
additionalIps?: string[]
|
||||
}
|
||||
|
||||
export function collectVpsIps(vps: {
|
||||
ip?: string | null
|
||||
ipv6?: string | null
|
||||
additionalIps?: string[]
|
||||
}): string[] {
|
||||
const ips: string[] = []
|
||||
if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip))
|
||||
if (vps.ipv6?.trim()) ips.push(normalizeIp(vps.ipv6))
|
||||
for (const raw of vps.additionalIps ?? []) {
|
||||
if (raw?.trim()) ips.push(normalizeIp(raw))
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
export function findVpsIdByIps<T extends VpsIpFields>(allVps: T[], ips: string[]): string | null {
|
||||
const normalized = [...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip)))]
|
||||
if (normalized.length === 0) return null
|
||||
|
||||
const matches: string[] = []
|
||||
for (const v of allVps) {
|
||||
const vips = collectVpsIps(v)
|
||||
if (normalized.some((ip) => vips.includes(ip))) {
|
||||
matches.push(v.id)
|
||||
}
|
||||
}
|
||||
if (matches.length === 1) return matches[0]!
|
||||
return null
|
||||
}
|
||||
|
||||
function ipv4Octets(ip: string): number[] | null {
|
||||
const parts = ip.split('.')
|
||||
if (parts.length !== 4) return null
|
||||
const nums = parts.map((p) => Number(p))
|
||||
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return null
|
||||
return nums
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackIp(ip: string): boolean {
|
||||
const value = normalizeIp(ip)
|
||||
if (!value) return true
|
||||
const octets = ipv4Octets(value)
|
||||
if (octets) {
|
||||
const [a, b] = octets
|
||||
if (a === 0 || a === 10 || a === 127) return true
|
||||
if (a === 169 && b === 254) return true
|
||||
if (a === 172 && b !== undefined && b >= 16 && b <= 31) return true
|
||||
if (a === 192 && b === 168) return true
|
||||
return false
|
||||
}
|
||||
if (value.includes(':')) {
|
||||
if (value === '::' || value === '::1') return true
|
||||
if (value.startsWith('fe80:')) return true
|
||||
if (value.startsWith('fc') || value.startsWith('fd')) return true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -25,6 +25,8 @@ const ROLE_RANK: Record<SpaceRole, number> = {
|
||||
/** Tables with spaceId column — purge order (children first). */
|
||||
const SPACE_DATA_TABLES = [
|
||||
'vps_grants',
|
||||
'censorcheck_results',
|
||||
'censorcheck_runs',
|
||||
'notification_log',
|
||||
'notification_state',
|
||||
'vps_health_checks',
|
||||
|
||||
@@ -4,62 +4,17 @@ import type { CfdmBindingSyncItem } from '@cfdm/shared/contracts/integration-cfd
|
||||
import { getDb, schema } from '../index.js'
|
||||
import { getCurrentSpaceId } from '../space-context.js'
|
||||
import { generateId } from './utils.js'
|
||||
import { findVpsIdByIps, isIpLiteral } from './ip-match.js'
|
||||
import { vpsRepository } from './vps.js'
|
||||
|
||||
type Row = typeof schema.vpsDomains.$inferSelect
|
||||
|
||||
export type VpsDomainDto = Row
|
||||
|
||||
function normalizeIp(ip: string): string {
|
||||
return ip.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function normalizeHost(host: string): string {
|
||||
return host.trim().toLowerCase().replace(/\.+$/, '')
|
||||
}
|
||||
|
||||
function isIpLiteral(value: string): boolean {
|
||||
const v = value.trim()
|
||||
if (!v) return false
|
||||
if (/^(?:\d{1,3}\.){3}\d{1,3}$/.test(v)) {
|
||||
return v.split('.').every((p) => {
|
||||
const n = Number(p)
|
||||
return Number.isInteger(n) && n >= 0 && n <= 255
|
||||
})
|
||||
}
|
||||
// грубый IPv6 — отсекает hostname вроде ihome.rkns.top
|
||||
return v.includes(':') && !v.includes(' ')
|
||||
}
|
||||
|
||||
function collectVpsIps(vps: { ip?: string | null; additionalIps?: string[] }): string[] {
|
||||
const ips: string[] = []
|
||||
if (vps.ip?.trim()) ips.push(normalizeIp(vps.ip))
|
||||
for (const raw of vps.additionalIps ?? []) {
|
||||
if (raw?.trim()) ips.push(normalizeIp(raw))
|
||||
}
|
||||
return ips
|
||||
}
|
||||
|
||||
function findVpsIdByIps(
|
||||
allVps: ReturnType<typeof vpsRepository.list>,
|
||||
ips: string[],
|
||||
): string | null {
|
||||
const normalized = [
|
||||
...new Set(ips.map(normalizeIp).filter((ip) => ip && isIpLiteral(ip))),
|
||||
]
|
||||
if (normalized.length === 0) return null
|
||||
|
||||
const matches: string[] = []
|
||||
for (const v of allVps) {
|
||||
const vips = collectVpsIps(v)
|
||||
if (normalized.some((ip) => vips.includes(ip))) {
|
||||
matches.push(v.id)
|
||||
}
|
||||
}
|
||||
if (matches.length === 1) return matches[0]!
|
||||
return null
|
||||
}
|
||||
|
||||
/** Точное совпадение hostname с полем VPS.dns. */
|
||||
function findVpsIdByDns(
|
||||
allVps: ReturnType<typeof vpsRepository.list>,
|
||||
|
||||
@@ -157,6 +157,11 @@ export const vpsRepository = {
|
||||
return rows.map((r) => toDto(r)!) as VpsDto[]
|
||||
},
|
||||
|
||||
listAllSpaces(): VpsDto[] {
|
||||
const rows = getDb().select().from(schema.vps).orderBy(desc(schema.vps.createdAt)).all()
|
||||
return rows.map((r) => toDto(r)!) as VpsDto[]
|
||||
},
|
||||
|
||||
get(id: string): VpsDto | undefined {
|
||||
const row = getDb()
|
||||
.select()
|
||||
|
||||
@@ -283,6 +283,38 @@ const CORE_TABLE_MIGRATIONS: string[] = [
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS censorcheck_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
spaceId TEXT NOT NULL DEFAULT 'space-main' REFERENCES spaces(id),
|
||||
runId TEXT NOT NULL UNIQUE,
|
||||
probePublicIp TEXT NOT NULL,
|
||||
claimedPublicIp TEXT,
|
||||
matchedVpsId TEXT REFERENCES vps(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL,
|
||||
schemaVersion INTEGER NOT NULL DEFAULT 1,
|
||||
launcherVersion TEXT,
|
||||
censorcheckVersion TEXT,
|
||||
summaryJson TEXT NOT NULL DEFAULT '{}',
|
||||
createdAt TEXT NOT NULL,
|
||||
completedAt TEXT NOT NULL,
|
||||
observedSourceIp TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS censorcheck_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL REFERENCES censorcheck_runs(id) ON DELETE CASCADE,
|
||||
serviceKey TEXT NOT NULL,
|
||||
serviceLabel TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
httpStatus INTEGER,
|
||||
detail TEXT,
|
||||
rawJson TEXT
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS censorcheck_runs_probe_created ON censorcheck_runs(probePublicIp, createdAt)`,
|
||||
`CREATE INDEX IF NOT EXISTS censorcheck_runs_matched_created ON censorcheck_runs(matchedVpsId, createdAt)`,
|
||||
`CREATE INDEX IF NOT EXISTS censorcheck_runs_created ON censorcheck_runs(createdAt)`,
|
||||
`CREATE INDEX IF NOT EXISTS censorcheck_results_runId ON censorcheck_results(runId)`,
|
||||
`CREATE INDEX IF NOT EXISTS censorcheck_results_service_status ON censorcheck_results(serviceKey, status)`,
|
||||
]
|
||||
|
||||
/** Additive columns for DBs created before spaces / notifications / etc. */
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sqliteTable, text, integer, real, uniqueIndex } from 'drizzle-orm/sqlite-core'
|
||||
import { sqliteTable, text, integer, real, uniqueIndex, index } from 'drizzle-orm/sqlite-core'
|
||||
import { sql } from 'drizzle-orm'
|
||||
|
||||
export const spaces = sqliteTable('spaces', {
|
||||
@@ -364,4 +364,54 @@ export const topologyDiagrams = sqliteTable('topology_diagrams', {
|
||||
updatedAt: text('updatedAt').notNull(),
|
||||
})
|
||||
|
||||
export const censorcheckRuns = sqliteTable(
|
||||
'censorcheck_runs',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('spaceId')
|
||||
.notNull()
|
||||
.default('space-main')
|
||||
.references(() => spaces.id),
|
||||
runId: text('runId').notNull(),
|
||||
probePublicIp: text('probePublicIp').notNull(),
|
||||
claimedPublicIp: text('claimedPublicIp'),
|
||||
matchedVpsId: text('matchedVpsId').references(() => vps.id, { onDelete: 'set null' }),
|
||||
status: text('status').notNull(),
|
||||
schemaVersion: integer('schemaVersion').notNull().default(1),
|
||||
launcherVersion: text('launcherVersion'),
|
||||
censorcheckVersion: text('censorcheckVersion'),
|
||||
summaryJson: text('summaryJson').notNull().default('{}'),
|
||||
createdAt: text('createdAt').notNull(),
|
||||
completedAt: text('completedAt').notNull(),
|
||||
observedSourceIp: text('observedSourceIp'),
|
||||
},
|
||||
(t) => ({
|
||||
runIdUniq: uniqueIndex('censorcheck_runs_runId').on(t.runId),
|
||||
probeCreated: index('censorcheck_runs_probe_created').on(t.probePublicIp, t.createdAt),
|
||||
matchedCreated: index('censorcheck_runs_matched_created').on(t.matchedVpsId, t.createdAt),
|
||||
created: index('censorcheck_runs_created').on(t.createdAt),
|
||||
}),
|
||||
)
|
||||
|
||||
export const censorcheckResults = sqliteTable(
|
||||
'censorcheck_results',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
runId: text('runId')
|
||||
.notNull()
|
||||
.references(() => censorcheckRuns.id, { onDelete: 'cascade' }),
|
||||
serviceKey: text('serviceKey').notNull(),
|
||||
serviceLabel: text('serviceLabel').notNull(),
|
||||
category: text('category').notNull(),
|
||||
status: text('status').notNull(),
|
||||
httpStatus: integer('httpStatus'),
|
||||
detail: text('detail'),
|
||||
rawJson: text('rawJson'),
|
||||
},
|
||||
(t) => ({
|
||||
runIdx: index('censorcheck_results_runId').on(t.runId),
|
||||
serviceStatus: index('censorcheck_results_service_status').on(t.serviceKey, t.status),
|
||||
}),
|
||||
)
|
||||
|
||||
export const now = sql`(datetime('now'))`
|
||||
|
||||
@@ -284,6 +284,35 @@ CREATE TABLE IF NOT EXISTS vps_health_checks (
|
||||
latencyMs INTEGER,
|
||||
error TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS censorcheck_runs (
|
||||
id TEXT PRIMARY KEY,
|
||||
spaceId TEXT NOT NULL DEFAULT 'space-main',
|
||||
runId TEXT NOT NULL UNIQUE,
|
||||
probePublicIp TEXT NOT NULL,
|
||||
claimedPublicIp TEXT,
|
||||
matchedVpsId TEXT,
|
||||
status TEXT NOT NULL,
|
||||
schemaVersion INTEGER NOT NULL DEFAULT 1,
|
||||
launcherVersion TEXT,
|
||||
censorcheckVersion TEXT,
|
||||
summaryJson TEXT NOT NULL DEFAULT '{}',
|
||||
createdAt TEXT NOT NULL,
|
||||
completedAt TEXT NOT NULL,
|
||||
observedSourceIp TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS censorcheck_results (
|
||||
id TEXT PRIMARY KEY,
|
||||
runId TEXT NOT NULL,
|
||||
serviceKey TEXT NOT NULL,
|
||||
serviceLabel TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
httpStatus INTEGER,
|
||||
detail TEXT,
|
||||
rawJson TEXT
|
||||
);
|
||||
`
|
||||
|
||||
export function resetTestDb(): void {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const CENSORCHECK_STATUSES = [
|
||||
'available',
|
||||
'redirected',
|
||||
'denied',
|
||||
'blocked',
|
||||
'timeout',
|
||||
'error',
|
||||
] as const
|
||||
|
||||
export type CensorcheckStatus = (typeof CENSORCHECK_STATUSES)[number]
|
||||
|
||||
export const CENSORCHECK_CATEGORIES = ['dpi', 'geoblock', 'custom'] as const
|
||||
|
||||
export type CensorcheckCategory = (typeof CENSORCHECK_CATEGORIES)[number]
|
||||
|
||||
export const CENSORCHECK_RUN_STATUSES = ['complete', 'partial'] as const
|
||||
|
||||
export type CensorcheckRunStatus = (typeof CENSORCHECK_RUN_STATUSES)[number]
|
||||
|
||||
/** DPI-сервисы из vernette/censorcheck (pin 12c5839). */
|
||||
export const CENSORCHECK_DPI_HOSTS = [
|
||||
'youtube.com',
|
||||
'redirector.googlevideo.com',
|
||||
'discord.com',
|
||||
'instagram.com',
|
||||
'facebook.com',
|
||||
'x.com',
|
||||
'linkedin.com',
|
||||
'rutracker.org',
|
||||
'digitalocean.com',
|
||||
'amnezia.org',
|
||||
'getoutline.org',
|
||||
'mailfence.com',
|
||||
'flibusta.is',
|
||||
'rezka.ag',
|
||||
'api.telegram.org',
|
||||
'play.google.com',
|
||||
] as const
|
||||
|
||||
/** Геоблок-сервисы из vernette/censorcheck (pin 12c5839). */
|
||||
export const CENSORCHECK_GEOBLOCK_HOSTS = [
|
||||
'spotify.com',
|
||||
'netflix.com',
|
||||
'patreon.com',
|
||||
'swagger.io',
|
||||
'snyk.io',
|
||||
'mongodb.com',
|
||||
'autodesk.com',
|
||||
'graylog.org',
|
||||
'redis.io',
|
||||
'copilot.microsoft.com',
|
||||
] as const
|
||||
|
||||
const DPI_SET = new Set<string>(CENSORCHECK_DPI_HOSTS)
|
||||
const GEO_SET = new Set<string>(CENSORCHECK_GEOBLOCK_HOSTS)
|
||||
|
||||
export function inferCensorcheckCategory(serviceKey: string): CensorcheckCategory {
|
||||
const key = serviceKey.trim().toLowerCase()
|
||||
if (DPI_SET.has(key)) return 'dpi'
|
||||
if (GEO_SET.has(key)) return 'geoblock'
|
||||
return 'custom'
|
||||
}
|
||||
|
||||
export const censorcheckStatusSchema = z.enum(CENSORCHECK_STATUSES)
|
||||
export const censorcheckCategorySchema = z.enum(CENSORCHECK_CATEGORIES)
|
||||
|
||||
export const censorcheckIngestResultSchema = z.object({
|
||||
service: z.string().trim().min(1).max(253),
|
||||
category: censorcheckCategorySchema.optional(),
|
||||
raw: z.record(z.unknown()).default({}),
|
||||
})
|
||||
|
||||
export const censorcheckIngestBodySchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
runId: z.string().trim().min(8).max(80),
|
||||
probe: z.object({
|
||||
publicIp: z.string().trim().min(1).max(64),
|
||||
}),
|
||||
censorcheck: z
|
||||
.object({
|
||||
version: z.string().trim().max(32).optional(),
|
||||
mode: z.string().trim().max(32).optional(),
|
||||
})
|
||||
.optional(),
|
||||
launcherVersion: z.string().trim().max(32).optional(),
|
||||
results: z.array(censorcheckIngestResultSchema).min(1).max(200),
|
||||
})
|
||||
|
||||
export type CensorcheckIngestBody = z.infer<typeof censorcheckIngestBodySchema>
|
||||
export type CensorcheckIngestResult = z.infer<typeof censorcheckIngestResultSchema>
|
||||
|
||||
export type CensorcheckSummary = {
|
||||
total: number
|
||||
available: number
|
||||
redirected: number
|
||||
denied: number
|
||||
blocked: number
|
||||
timeout: number
|
||||
error: number
|
||||
}
|
||||
|
||||
export function emptyCensorcheckSummary(): CensorcheckSummary {
|
||||
return {
|
||||
total: 0,
|
||||
available: 0,
|
||||
redirected: 0,
|
||||
denied: 0,
|
||||
blocked: 0,
|
||||
timeout: 0,
|
||||
error: 0,
|
||||
}
|
||||
}
|
||||
Generated
+19
@@ -41,6 +41,9 @@ importers:
|
||||
'@fastify/jwt':
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.0
|
||||
'@fastify/rate-limit':
|
||||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
'@fastify/sensible':
|
||||
specifier: ^6.0.3
|
||||
version: 6.0.4
|
||||
@@ -945,6 +948,9 @@ packages:
|
||||
'@fastify/proxy-addr@5.1.0':
|
||||
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
|
||||
|
||||
'@fastify/rate-limit@11.2.0':
|
||||
resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==}
|
||||
|
||||
'@fastify/send@4.1.0':
|
||||
resolution: {integrity: sha512-TMYeQLCBSy2TOFmV95hQWkiTYgC/SEx7vMdV+wnZVX4tt8VBLKzmH8vV9OzJehV0+XBfg+WxPMt5wp+JBUKsVw==}
|
||||
|
||||
@@ -2554,6 +2560,10 @@ packages:
|
||||
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
ip-address@10.5.0:
|
||||
resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
|
||||
engines: {node: '>= 12'}
|
||||
|
||||
ipaddr.js@1.9.1:
|
||||
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
|
||||
engines: {node: '>= 0.10'}
|
||||
@@ -4069,6 +4079,13 @@ snapshots:
|
||||
'@fastify/forwarded': 3.0.1
|
||||
ipaddr.js: 2.4.0
|
||||
|
||||
'@fastify/rate-limit@11.2.0':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
fastify-plugin: 6.0.0
|
||||
ip-address: 10.5.0
|
||||
toad-cache: 3.7.1
|
||||
|
||||
'@fastify/send@4.1.0':
|
||||
dependencies:
|
||||
'@lukeed/ms': 2.0.2
|
||||
@@ -5678,6 +5695,8 @@ snapshots:
|
||||
|
||||
internmap@2.0.3: {}
|
||||
|
||||
ip-address@10.5.0: {}
|
||||
|
||||
ipaddr.js@1.9.1: {}
|
||||
|
||||
ipaddr.js@2.4.0: {}
|
||||
|
||||
Reference in New Issue
Block a user