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
+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 }
}