feat(censorcheck): добавить статус блокировок и launcher curl | bash
Docker / build (push) Failing after 25s

Прогон с VPS через HMAC-токен матчится к существующим серверам; UI /blocking показывает текущие проверки и историю.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-22 10:52:22 +07:00
co-authored by Cursor
parent 9e0311b53a
commit 5c43d88f1a
52 changed files with 3847 additions and 53 deletions
+10
View File
@@ -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
+1
View File
@@ -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",
+7
View File
@@ -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
+139
View File
@@ -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
+14
View File
@@ -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')
+1
View File
@@ -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')
})
+2 -1
View File
@@ -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',
},
{
+2
View File
@@ -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
}
+163
View File
@@ -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)
})
})
+127
View File
@@ -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
})
}
+39
View File
@@ -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')
})
})
+62
View File
@@ -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')
}
})
}
+2
View File
@@ -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'
+7
View File
@@ -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 }) {
+31
View File
@@ -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 {
+1
View File
@@ -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') ||
+40
View File
@@ -0,0 +1,40 @@
import { queryClient } from '../lib/queryClient'
import { api } from '../lib/api-client'
import { getStoredSpaceId } from '../lib/space'
export const 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 }
+21
View File
@@ -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,
+10
View File
@@ -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
View File
@@ -1,7 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@cfdm/ui/components/*": ["../../packages/ui/src/components/*"],
+6 -1
View File
@@ -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
+7
View File
@@ -5,6 +5,8 @@
CF_DNS_API_TOKEN=
[email protected]
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
+8 -1
View File
@@ -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
+341
View File
@@ -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')
})
})
+80
View File
@@ -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
}
+2
View File
@@ -25,6 +25,8 @@ const ROLE_RANK: Record<SpaceRole, number> = {
/** Tables with spaceId column — purge order (children first). */
const SPACE_DATA_TABLES = [
'vps_grants',
'censorcheck_results',
'censorcheck_runs',
'notification_log',
'notification_state',
'vps_health_checks',
+1 -46
View File
@@ -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>,
+5
View File
@@ -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()
+32
View File
@@ -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. */
+51 -1
View File
@@ -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'))`
+29
View File
@@ -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,
}
}
+19
View File
@@ -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/[email protected]':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@fastify/[email protected]':
resolution: {integrity: sha512-X7osJd4XSvMoejYrnJkSZYYjY1eNYoBqhjlzf1RakC2204qExFqZFTKj5+T7VuzA/iUI9Z3UoSqQRkB2HpG0oQ==}
'@fastify/[email protected]':
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'}
[email protected]:
resolution: {integrity: sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==}
engines: {node: '>= 12'}
[email protected]:
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/[email protected]':
dependencies:
'@lukeed/ms': 2.0.2
fastify-plugin: 6.0.0
ip-address: 10.5.0
toad-cache: 3.7.1
'@fastify/[email protected]':
dependencies:
'@lukeed/ms': 2.0.2
@@ -5678,6 +5695,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]: {}
[email protected]: {}