feat(censorcheck): добавить статус блокировок и launcher curl | bash
Docker / build (push) Failing after 25s
Docker / build (push) Failing after 25s
Прогон с VPS через HMAC-токен матчится к существующим серверам; UI /blocking показывает текущие проверки и историю. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
"@cfdm/shared": "workspace:*",
|
||||
"@fastify/cors": "^11.0.1",
|
||||
"@fastify/jwt": "^10.2.0",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@fastify/sensible": "^6.0.3",
|
||||
"@fastify/static": "^8.2.0",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
Vendor pin of https://github.com/vernette/censorcheck
|
||||
|
||||
- File: `censorcheck.sh`
|
||||
- Commit: `12c5839` (2026-08-11)
|
||||
- License: MIT (see upstream repository)
|
||||
|
||||
Do not fetch this script from GitHub at runtime — some probe networks block github.com.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# VPS Tracker launcher for vernette/censorcheck (vendor pin 12c5839).
|
||||
# Fetched via: curl -fsSL https://vt.shnt.top/cc | bash
|
||||
set -euo pipefail
|
||||
|
||||
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
|
||||
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
|
||||
LAUNCHER_VERSION="1"
|
||||
VENDOR_SHA="12c5839"
|
||||
|
||||
trap 'exit 130' INT
|
||||
|
||||
log() { printf '%s\n' "$*"; }
|
||||
die() { printf 'error: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
require_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1 || die "Нужна команда '$1' (установите пакет и повторите)."
|
||||
}
|
||||
|
||||
require_cmd curl
|
||||
require_cmd jq
|
||||
require_cmd bash
|
||||
|
||||
detect_public_ip() {
|
||||
local ip=""
|
||||
ip="$(curl -fsS --max-time 8 https://api.ipify.org 2>/dev/null || true)"
|
||||
if [ -z "$ip" ]; then
|
||||
ip="$(curl -fsS --max-time 8 https://icanhazip.com 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
fi
|
||||
if [ -z "$ip" ] && command -v dig >/dev/null 2>&1; then
|
||||
ip="$(dig +short myip.opendns.com @resolver1.opendns.com 2>/dev/null | tr -d '[:space:]' || true)"
|
||||
fi
|
||||
printf '%s' "$ip"
|
||||
}
|
||||
|
||||
write_vendor() {
|
||||
local dest="$1"
|
||||
if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then
|
||||
printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -d >"$dest" 2>/dev/null \
|
||||
|| printf '%s' "$CENSORCHECK_VENDOR_B64" | base64 -D >"$dest"
|
||||
return 0
|
||||
fi
|
||||
curl -fsSL --max-time 30 "${VT_API_URL}/cc/vendor" -o "$dest"
|
||||
}
|
||||
|
||||
uuid4() {
|
||||
if [ -r /proc/sys/kernel/random/uuid ]; then
|
||||
tr -d '[:space:]' </proc/sys/kernel/random/uuid
|
||||
return
|
||||
fi
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c 'import uuid; print(uuid.uuid4())'
|
||||
return
|
||||
fi
|
||||
openssl rand -hex 16
|
||||
}
|
||||
|
||||
VT_API_URL="${VT_API_URL%/}"
|
||||
[ -n "$VT_API_URL" ] || die "VT_API_URL пуст"
|
||||
[ -n "$VT_INGEST_TOKEN" ] || die "VT_INGEST_TOKEN пуст"
|
||||
|
||||
TMPDIR="$(mktemp -d /tmp/vt-censorcheck.XXXXXX)"
|
||||
cleanup() { rm -rf "$TMPDIR"; }
|
||||
trap 'cleanup; exit 130' INT
|
||||
trap 'cleanup' EXIT
|
||||
|
||||
VENDOR="$TMPDIR/censorcheck.sh"
|
||||
write_vendor "$VENDOR"
|
||||
chmod +x "$VENDOR"
|
||||
|
||||
PUBLIC_IP="$(detect_public_ip)"
|
||||
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
|
||||
|
||||
RUN_ID="$(uuid4)"
|
||||
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
|
||||
|
||||
log "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
|
||||
log "probe IP: ${PUBLIC_IP}"
|
||||
log "runId: ${RUN_ID}"
|
||||
|
||||
set +e
|
||||
RAW_JSON="$(bash "$VENDOR" --mode both --json --no-header --no-dns 2>/tmp/vt-censorcheck-err.$$)"
|
||||
CC_EXIT=$?
|
||||
set -e
|
||||
if [ "$CC_EXIT" -ne 0 ]; then
|
||||
log "censorcheck завершился с кодом ${CC_EXIT}" >&2
|
||||
if [ -s /tmp/vt-censorcheck-err.$$ ]; then
|
||||
cat /tmp/vt-censorcheck-err.$$ >&2 || true
|
||||
fi
|
||||
rm -f /tmp/vt-censorcheck-err.$$
|
||||
exit 1
|
||||
fi
|
||||
rm -f /tmp/vt-censorcheck-err.$$
|
||||
|
||||
PAYLOAD="$TMPDIR/payload.json"
|
||||
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" '
|
||||
{
|
||||
schemaVersion: 1,
|
||||
runId: $runId,
|
||||
probe: { publicIp: $ip },
|
||||
launcherVersion: $lv,
|
||||
censorcheck: {
|
||||
version: ((.version | tostring) // "1"),
|
||||
mode: "both"
|
||||
},
|
||||
results: ((.results // []) | map({
|
||||
service: .service,
|
||||
raw: .
|
||||
}))
|
||||
}
|
||||
' >"$PAYLOAD"
|
||||
|
||||
FALLBACK="/tmp/vt-censorcheck-${RUN_ID}.json"
|
||||
set +e
|
||||
RESP="$(curl -fsS --max-time 60 -X POST "${VT_API_URL}/api/integrations/censorcheck/runs" \
|
||||
-H "Authorization: Bearer ${VT_INGEST_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data-binary @"$PAYLOAD")"
|
||||
POST_EXIT=$?
|
||||
set -e
|
||||
|
||||
if [ "$POST_EXIT" -ne 0 ]; then
|
||||
cp "$PAYLOAD" "$FALLBACK"
|
||||
log "API недоступен (curl exit ${POST_EXIT}). JSON сохранён: ${FALLBACK}" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
CHECK_ID="$(printf '%s' "$RESP" | jq -r '.id // empty')"
|
||||
MATCHED="$(printf '%s' "$RESP" | jq -r '.matchedVpsId // "unmatched"')"
|
||||
if [ -z "$CHECK_ID" ]; then
|
||||
cp "$PAYLOAD" "$FALLBACK"
|
||||
log "Некорректный ответ API. JSON сохранён: ${FALLBACK}" >&2
|
||||
log "$RESP" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
log "Check ID: ${CHECK_ID}"
|
||||
log "VPS: ${MATCHED}"
|
||||
exit 0
|
||||
@@ -2,6 +2,7 @@ import Fastify from 'fastify'
|
||||
import cors from '@fastify/cors'
|
||||
import sensible from '@fastify/sensible'
|
||||
import staticPlugin from '@fastify/static'
|
||||
import rateLimit from '@fastify/rate-limit'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
@@ -25,6 +26,8 @@ import { dashboardRoutes } from './routes/dashboard.js'
|
||||
import { auditRoutes } from './routes/audit.js'
|
||||
import { notificationsRoutes } from './routes/notifications.js'
|
||||
import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
|
||||
import { censorcheckRoutes } from './routes/censorcheck.js'
|
||||
import { launcherRoutes } from './routes/launcher.js'
|
||||
import { appSwitcherRoutes } from './routes/app-switcher.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
import { authPlugin } from './plugins/auth.js'
|
||||
@@ -43,18 +46,28 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
if (opts.dbPath) process.env.DB_PATH = opts.dbPath
|
||||
getDb()
|
||||
|
||||
const trustProxy =
|
||||
process.env.TRUST_PROXY === '1' ||
|
||||
process.env.TRUST_PROXY === 'true' ||
|
||||
(process.env.NODE_ENV === 'production' &&
|
||||
process.env.TRUST_PROXY !== '0' &&
|
||||
process.env.TRUST_PROXY !== 'false')
|
||||
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
level: process.env.LOG_LEVEL ?? 'info',
|
||||
},
|
||||
trustProxy,
|
||||
})
|
||||
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(sensible)
|
||||
await app.register(rateLimit, { global: false })
|
||||
await app.register(authPlugin)
|
||||
await app.register(spacePlugin)
|
||||
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
await app.register(launcherRoutes)
|
||||
|
||||
await app.register(spacesRoutes)
|
||||
await app.register(portalUsersRoutes)
|
||||
@@ -75,6 +88,7 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(auditRoutes)
|
||||
await app.register(notificationsRoutes)
|
||||
await app.register(integrationsCfdmRoutes)
|
||||
await app.register(censorcheckRoutes)
|
||||
await app.register(appSwitcherRoutes)
|
||||
|
||||
const staticDir = opts.staticDir ?? join(__dirname, '..', '..', 'web', 'dist')
|
||||
|
||||
@@ -20,6 +20,7 @@ describe('hasPermission hierarchy', () => {
|
||||
describe('permissionForRequest', () => {
|
||||
it('maps vps CRUD', () => {
|
||||
expect(permissionForRequest('GET', '/api/vps')).toBe('vps:vps:read')
|
||||
expect(permissionForRequest('GET', '/api/censorcheck/current')).toBe('vps:vps:read')
|
||||
expect(permissionForRequest('POST', '/api/vps')).toBe('vps:vps:write')
|
||||
expect(permissionForRequest('DELETE', '/api/vps/abc')).toBe('vps:vps:write')
|
||||
})
|
||||
|
||||
@@ -51,7 +51,8 @@ const RULES: Rule[] = [
|
||||
p.startsWith('/api/vps/') ||
|
||||
p.startsWith('/api/projects') ||
|
||||
p.startsWith('/api/topology') ||
|
||||
p.startsWith('/api/data'),
|
||||
p.startsWith('/api/data') ||
|
||||
p.startsWith('/api/censorcheck'),
|
||||
permission: 'vps:vps:read',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -74,7 +74,9 @@ function isPublicPath(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url
|
||||
if (path === '/health' || path === '/ready') return true
|
||||
if (path === '/api/auth/config') return true
|
||||
if (path === '/cc' || path.startsWith('/cc/')) return true
|
||||
if (path.startsWith('/api/integrations/cfdm')) return true
|
||||
if (path.startsWith('/api/integrations/censorcheck')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
|
||||
import { buildApp } from '../index.js'
|
||||
import { mintIngestToken } from '../services/censorcheck/ingest-token.js'
|
||||
|
||||
const SECRET = 'test-censorcheck-ingest-secret'
|
||||
|
||||
function ingestPayload(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
runId: '11111111-1111-4111-8111-111111111111',
|
||||
probe: { publicIp: '203.0.113.10' },
|
||||
launcherVersion: '1',
|
||||
censorcheck: { version: '1', mode: 'both' },
|
||||
results: [
|
||||
{
|
||||
service: 'youtube.com',
|
||||
raw: { https: { ipv4: { status: 200 } } },
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('censorcheck ingest + reads', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.CENSORCHECK_INGEST_SECRET = SECRET
|
||||
process.env.CENSORCHECK_RATE_LIMIT = '0'
|
||||
process.env.TRUST_PROXY = '1'
|
||||
resetTestDb()
|
||||
seedTestProvider('p1')
|
||||
seedTestProviderAccount('a1', 'p1')
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
delete process.env.TRUST_PROXY
|
||||
})
|
||||
|
||||
async function post(body: unknown, token?: string, headers: Record<string, string> = {}) {
|
||||
return app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/integrations/censorcheck/runs',
|
||||
headers: {
|
||||
authorization: `Bearer ${token ?? mintIngestToken(SECRET)}`,
|
||||
'content-type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
payload: body as object,
|
||||
})
|
||||
}
|
||||
|
||||
it('отклоняет запрос без токена', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/integrations/censorcheck/runs',
|
||||
payload: ingestPayload(),
|
||||
})
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('отклоняет просроченный токен', async () => {
|
||||
const token = mintIngestToken(SECRET, 60, Date.now() - 120_000)
|
||||
const res = await post(ingestPayload(), token)
|
||||
expect(res.statusCode).toBe(401)
|
||||
})
|
||||
|
||||
it('отклоняет невалидный payload', async () => {
|
||||
const res = await post({ schemaVersion: 1, runId: 'short' })
|
||||
expect(res.statusCode).toBe(400)
|
||||
})
|
||||
|
||||
it('принимает прогон и оставляет unmatched', async () => {
|
||||
const res = await post(ingestPayload())
|
||||
expect(res.statusCode).toBe(200)
|
||||
const json = res.json() as { matchedVpsId: string | null; probePublicIp: string }
|
||||
expect(json.matchedVpsId).toBeNull()
|
||||
expect(json.probePublicIp).toBe('203.0.113.10')
|
||||
})
|
||||
|
||||
it('матчит VPS по IPv4 и IPv6', async () => {
|
||||
const vps = runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '203.0.113.10',
|
||||
ipv6: '2001:db8::55',
|
||||
dns: 'edge.example.com',
|
||||
providerId: 'p1',
|
||||
providerAccountId: 'a1',
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
vcpu: 2,
|
||||
ramGb: 4,
|
||||
diskGb: 40,
|
||||
}),
|
||||
)
|
||||
const v4 = await post(ingestPayload({ runId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' }))
|
||||
expect(v4.json().matchedVpsId).toBe(vps.id)
|
||||
|
||||
const v6 = await post(
|
||||
ingestPayload({
|
||||
runId: 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb',
|
||||
probe: { publicIp: '2001:db8::55' },
|
||||
}),
|
||||
)
|
||||
expect(v6.json().matchedVpsId).toBe(vps.id)
|
||||
})
|
||||
|
||||
it('повторяет duplicate runId без второй записи', async () => {
|
||||
const first = await post(ingestPayload())
|
||||
const second = await post(ingestPayload())
|
||||
expect(first.statusCode).toBe(200)
|
||||
expect(second.statusCode).toBe(200)
|
||||
expect(second.json().id).toBe(first.json().id)
|
||||
expect(second.json().replayed).toBe(true)
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
|
||||
expect(current.json().items).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('берёт observed XFF, а claimed сохраняет если расходится', async () => {
|
||||
runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '198.51.100.20',
|
||||
providerId: 'p1',
|
||||
providerAccountId: 'a1',
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
}),
|
||||
)
|
||||
const res = await post(ingestPayload({
|
||||
runId: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc',
|
||||
probe: { publicIp: '203.0.113.10' },
|
||||
}), undefined, { 'x-forwarded-for': '198.51.100.20' })
|
||||
const json = res.json() as { probePublicIp: string; matchedVpsId: string | null }
|
||||
expect(json.probePublicIp).toBe('198.51.100.20')
|
||||
expect(json.matchedVpsId).not.toBeNull()
|
||||
|
||||
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
|
||||
expect(current.json().items[0].claimedPublicIp).toBe('203.0.113.10')
|
||||
})
|
||||
|
||||
it('отдаёт историю и детали', async () => {
|
||||
await post(ingestPayload())
|
||||
const list = await app.inject({ method: 'GET', url: '/api/censorcheck/runs?limit=10' })
|
||||
expect(list.statusCode).toBe(200)
|
||||
const items = list.json().items as Array<{ id: string }>
|
||||
expect(items).toHaveLength(1)
|
||||
const detail = await app.inject({ method: 'GET', url: `/api/censorcheck/runs/${items[0]!.id}` })
|
||||
expect(detail.statusCode).toBe(200)
|
||||
expect(detail.json().results).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
|
||||
import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck'
|
||||
import { actorFromRequest } from '../lib/audit-actor.js'
|
||||
import {
|
||||
bearerToken,
|
||||
ingestSecret,
|
||||
verifyIngestToken,
|
||||
} from '../services/censorcheck/ingest-token.js'
|
||||
import { matchVpsByPublicIp, resolveProbeIp } from '../services/censorcheck/match-ip.js'
|
||||
import { normalizeIngestResult, summarizeResults } from '../services/censorcheck/normalize.js'
|
||||
|
||||
const BODY_LIMIT = 512 * 1024
|
||||
|
||||
function sendError(reply: FastifyReply, status: number, code: string, message: string) {
|
||||
return reply.code(status).send({ error: { code, message } })
|
||||
}
|
||||
|
||||
function requireIngestToken(request: FastifyRequest, reply: FastifyReply): boolean {
|
||||
const secret = ingestSecret()
|
||||
if (!secret) {
|
||||
void sendError(reply, 503, 'UNAVAILABLE', 'Censorcheck ingest не настроен')
|
||||
return false
|
||||
}
|
||||
const token = bearerToken(request.headers.authorization)
|
||||
if (!token || !verifyIngestToken(token, secret)) {
|
||||
void sendError(reply, 401, 'UNAUTHORIZED', 'Недействительный ingest-токен')
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
|
||||
const ingestOpts = {
|
||||
bodyLimit: BODY_LIMIT,
|
||||
...(process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
|
||||
? {}
|
||||
: { config: { rateLimit: { max: 6, timeWindow: '1 minute' } } }),
|
||||
}
|
||||
|
||||
app.post(
|
||||
'/api/integrations/censorcheck/runs',
|
||||
ingestOpts,
|
||||
async (request, reply) => {
|
||||
if (!requireIngestToken(request, reply)) return
|
||||
|
||||
const parsed = censorcheckIngestBodySchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return sendError(reply, 400, 'VALIDATION', parsed.error.message)
|
||||
}
|
||||
|
||||
const existing = censorcheckRepository.getByClientRunId(parsed.data.runId)
|
||||
if (existing) {
|
||||
return {
|
||||
id: existing.id,
|
||||
runId: existing.runId,
|
||||
matchedVpsId: existing.matchedVpsId,
|
||||
probePublicIp: existing.probePublicIp,
|
||||
summary: existing.summary,
|
||||
replayed: true,
|
||||
}
|
||||
}
|
||||
|
||||
const claimed = parsed.data.probe.publicIp
|
||||
const observed = actorFromRequest(request).ip ?? request.ip
|
||||
const probePublicIp = resolveProbeIp(observed, claimed)
|
||||
const claimedPublicIp =
|
||||
claimed && claimed !== probePublicIp ? claimed : null
|
||||
const match = matchVpsByPublicIp(probePublicIp)
|
||||
const results = parsed.data.results.map(normalizeIngestResult)
|
||||
const { summary, runStatus } = summarizeResults(results)
|
||||
|
||||
const created = censorcheckRepository.create({
|
||||
spaceId: match.spaceId,
|
||||
runId: parsed.data.runId,
|
||||
probePublicIp,
|
||||
claimedPublicIp,
|
||||
matchedVpsId: match.vpsId,
|
||||
status: runStatus,
|
||||
schemaVersion: parsed.data.schemaVersion,
|
||||
launcherVersion: parsed.data.launcherVersion ?? null,
|
||||
censorcheckVersion: parsed.data.censorcheck?.version ?? null,
|
||||
summary,
|
||||
observedSourceIp: observed ?? null,
|
||||
results,
|
||||
})
|
||||
|
||||
return {
|
||||
id: created.id,
|
||||
runId: created.runId,
|
||||
matchedVpsId: created.matchedVpsId,
|
||||
probePublicIp: created.probePublicIp,
|
||||
summary: created.summary,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get('/api/censorcheck/current', async () => ({
|
||||
items: censorcheckRepository.listCurrent(),
|
||||
}))
|
||||
|
||||
app.get('/api/censorcheck/runs', async (request) => {
|
||||
const q = request.query as Record<string, string | undefined>
|
||||
const matched =
|
||||
q.matched === '1' || q.matched === 'true'
|
||||
? true
|
||||
: q.matched === '0' || q.matched === 'false'
|
||||
? false
|
||||
: undefined
|
||||
return censorcheckRepository.listHistory({
|
||||
cursor: q.cursor,
|
||||
limit: q.limit ? Number(q.limit) : undefined,
|
||||
q: q.q,
|
||||
status: q.status,
|
||||
matched,
|
||||
})
|
||||
})
|
||||
|
||||
app.get('/api/censorcheck/runs/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string }
|
||||
const run = censorcheckRepository.getById(id)
|
||||
if (!run) {
|
||||
return sendError(reply, 404, 'NOT_FOUND', 'Прогон не найден')
|
||||
}
|
||||
return run
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb } from '@cfdm/db'
|
||||
import { resetTestDb } from '@cfdm/db/test-setup'
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
describe('GET /cc launcher', () => {
|
||||
let app: Awaited<ReturnType<typeof buildApp>>
|
||||
|
||||
beforeEach(async () => {
|
||||
process.env.CENSORCHECK_INGEST_SECRET = 'launcher-secret-key'
|
||||
process.env.CENSORCHECK_PUBLIC_URL = 'https://vt.shnt.top'
|
||||
process.env.CENSORCHECK_RATE_LIMIT = '0'
|
||||
resetTestDb()
|
||||
app = await buildApp()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close()
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('отдаёт bash-скрипт с токеном и no-store', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/cc' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.headers['content-type']).toMatch(/text\/plain/)
|
||||
expect(res.headers['cache-control']).toMatch(/no-store/)
|
||||
expect(res.body).toContain('https://vt.shnt.top')
|
||||
expect(res.body).toContain('VT_INGEST_TOKEN')
|
||||
expect(res.body).not.toContain('__VT_API_URL__')
|
||||
expect(res.body).not.toContain('__VT_INGEST_TOKEN__')
|
||||
})
|
||||
|
||||
it('отдаёт vendor-скрипт', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/cc/vendor' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
expect(res.body).toContain('#!/usr/bin/env bash')
|
||||
expect(res.body).toContain('SCRIPT_NAME')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { ingestSecret, mintIngestToken } from '../services/censorcheck/ingest-token.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const SCRIPT_DIR = join(__dirname, '..', '..', 'scripts', 'censorcheck')
|
||||
|
||||
export function censorcheckPublicUrl(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env.CENSORCHECK_PUBLIC_URL?.trim()) {
|
||||
return env.CENSORCHECK_PUBLIC_URL.replace(/\/$/, '')
|
||||
}
|
||||
const host = env.VPS_LAUNCHER_DOMAIN || env.VPS_DOMAIN || 'vt.shnt.top'
|
||||
return `https://${host}`
|
||||
}
|
||||
|
||||
function sendPlain(reply: FastifyReply, body: string, cache: 'no-store' | 'public'): void {
|
||||
void reply
|
||||
.header('Content-Type', 'text/plain; charset=utf-8')
|
||||
.header(
|
||||
'Cache-Control',
|
||||
cache === 'no-store' ? 'no-store, no-cache, must-revalidate' : 'public, max-age=3600',
|
||||
)
|
||||
.send(body)
|
||||
}
|
||||
|
||||
export const launcherRoutes: FastifyPluginAsync = async (app) => {
|
||||
const secret = ingestSecret()
|
||||
|
||||
const ccOpts =
|
||||
process.env.VITEST || process.env.CENSORCHECK_RATE_LIMIT === '0'
|
||||
? {}
|
||||
: { config: { rateLimit: { max: 30, timeWindow: '1 minute' } } }
|
||||
|
||||
app.get('/cc', ccOpts, async (_request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!secret) {
|
||||
return reply.code(503).send('censorcheck ingest is not configured\n')
|
||||
}
|
||||
const apiUrl = censorcheckPublicUrl()
|
||||
const token = mintIngestToken(secret)
|
||||
let template: string
|
||||
try {
|
||||
template = readFileSync(join(SCRIPT_DIR, 'launcher.sh'), 'utf8')
|
||||
} catch {
|
||||
return reply.code(500).send('launcher template missing\n')
|
||||
}
|
||||
const script = template
|
||||
.replaceAll('__VT_API_URL__', apiUrl)
|
||||
.replaceAll('__VT_INGEST_TOKEN__', token)
|
||||
sendPlain(reply, script, 'no-store')
|
||||
})
|
||||
|
||||
app.get('/cc/vendor', async (_request, reply) => {
|
||||
try {
|
||||
const body = readFileSync(join(SCRIPT_DIR, 'censorcheck.sh'), 'utf8')
|
||||
sendPlain(reply, body, 'public')
|
||||
} catch {
|
||||
return reply.code(500).send('vendor script missing\n')
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
|
||||
const TABLE_ORDER_DELETE = [
|
||||
'vps_grants',
|
||||
'censorcheck_results',
|
||||
'censorcheck_runs',
|
||||
'notification_log',
|
||||
'notification_state',
|
||||
'vps_health_checks',
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mintIngestToken, verifyIngestToken } from './ingest-token.js'
|
||||
|
||||
describe('censorcheck ingest token', () => {
|
||||
const secret = 'test-ingest-secret-key'
|
||||
|
||||
it('принимает свежий токен', () => {
|
||||
const token = mintIngestToken(secret)
|
||||
expect(verifyIngestToken(token, secret)).toBe(true)
|
||||
})
|
||||
|
||||
it('отклоняет просроченный токен', () => {
|
||||
const token = mintIngestToken(secret, 20 * 60, Date.now() - 21 * 60 * 1000)
|
||||
expect(verifyIngestToken(token, secret)).toBe(false)
|
||||
})
|
||||
|
||||
it('отклоняет подпись с другим секретом', () => {
|
||||
const token = mintIngestToken(secret)
|
||||
expect(verifyIngestToken(token, 'other-secret-key')).toBe(false)
|
||||
})
|
||||
|
||||
it('отклоняет мусор', () => {
|
||||
expect(verifyIngestToken('not-a-token', secret)).toBe(false)
|
||||
expect(verifyIngestToken('', secret)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
|
||||
const TTL_SEC = 20 * 60
|
||||
|
||||
export function ingestSecret(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return (
|
||||
env.CENSORCHECK_INGEST_SECRET ||
|
||||
env.AUTH_JWT_SECRET ||
|
||||
env.JWT_SECRET ||
|
||||
(env.NODE_ENV === 'production' ? '' : 'dev-secret-change-me')
|
||||
)
|
||||
}
|
||||
|
||||
export function mintIngestToken(
|
||||
secret: string,
|
||||
ttlSec = TTL_SEC,
|
||||
nowMs = Date.now(),
|
||||
): string {
|
||||
const payload = Buffer.from(
|
||||
JSON.stringify({
|
||||
jti: randomBytes(16).toString('hex'),
|
||||
exp: Math.floor(nowMs / 1000) + ttlSec,
|
||||
}),
|
||||
'utf8',
|
||||
).toString('base64url')
|
||||
const sig = createHmac('sha256', secret).update(payload).digest('base64url')
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyIngestToken(
|
||||
token: string,
|
||||
secret: string,
|
||||
nowMs = Date.now(),
|
||||
): boolean {
|
||||
if (!secret || !token) return false
|
||||
const [payload, sig] = token.split('.')
|
||||
if (!payload || !sig) return false
|
||||
const expected = createHmac('sha256', secret).update(payload).digest()
|
||||
let given: Buffer
|
||||
try {
|
||||
given = Buffer.from(sig, 'base64url')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return false
|
||||
try {
|
||||
const data = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as {
|
||||
exp?: unknown
|
||||
}
|
||||
if (typeof data.exp !== 'number') return false
|
||||
return data.exp * 1000 > nowMs
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function bearerToken(header: string | string[] | undefined): string | null {
|
||||
const raw = Array.isArray(header) ? header[0] : header
|
||||
if (!raw) return null
|
||||
const match = /^Bearer\s+(.+)$/i.exec(raw.trim())
|
||||
return match?.[1]?.trim() || null
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
|
||||
import { matchVpsByPublicIp, resolveProbeIp } from './match-ip.js'
|
||||
|
||||
describe('censorcheck match-ip', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
seedTestProvider('p1')
|
||||
seedTestProviderAccount('a1', 'p1')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('берёт claimed если observed приватный', () => {
|
||||
expect(resolveProbeIp('127.0.0.1', '203.0.113.10')).toBe('203.0.113.10')
|
||||
expect(resolveProbeIp('203.0.113.55', '203.0.113.10')).toBe('203.0.113.55')
|
||||
})
|
||||
|
||||
it('матчит VPS по IPv4 и отдаёт spaceId', () => {
|
||||
const vps = runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '203.0.113.10',
|
||||
ipv6: '2001:db8::aa',
|
||||
providerId: 'p1',
|
||||
providerAccountId: 'a1',
|
||||
status: 'active',
|
||||
tariffType: 'monthly',
|
||||
currency: 'RUB',
|
||||
vcpu: 1,
|
||||
ramGb: 1,
|
||||
diskGb: 10,
|
||||
}),
|
||||
)
|
||||
const created = Array.isArray(vps) ? vps[0]! : vps
|
||||
expect(matchVpsByPublicIp('203.0.113.10')).toEqual({
|
||||
vpsId: created.id,
|
||||
spaceId: MAIN_SPACE_ID,
|
||||
})
|
||||
expect(matchVpsByPublicIp('2001:db8::aa').vpsId).toBe(created.id)
|
||||
expect(matchVpsByPublicIp('198.51.100.1')).toEqual({
|
||||
vpsId: null,
|
||||
spaceId: MAIN_SPACE_ID,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MAIN_SPACE_ID } from '@cfdm/db'
|
||||
import { findVpsIdByIps, isPrivateOrLoopbackIp } from '@cfdm/db/repositories/ip-match'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
|
||||
export { isPrivateOrLoopbackIp }
|
||||
|
||||
export function resolveProbeIp(observed: string | null | undefined, claimed: string): string {
|
||||
const obs = observed?.trim() ?? ''
|
||||
const claim = claimed.trim()
|
||||
if (obs && !isPrivateOrLoopbackIp(obs)) return obs
|
||||
return claim
|
||||
}
|
||||
|
||||
export function matchVpsByPublicIp(ip: string): { vpsId: string | null; spaceId: string } {
|
||||
const all = vpsRepository.listAllSpaces()
|
||||
const vpsId = findVpsIdByIps(all, [ip])
|
||||
if (!vpsId) return { vpsId: null, spaceId: MAIN_SPACE_ID }
|
||||
const vps = all.find((row) => row.id === vpsId)
|
||||
return { vpsId, spaceId: vps?.spaceId ?? MAIN_SPACE_ID }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
normalizeIngestResult,
|
||||
statusFromErrorCode,
|
||||
statusFromHttpCode,
|
||||
summarizeResults,
|
||||
} from './normalize.js'
|
||||
|
||||
describe('censorcheck normalize', () => {
|
||||
it('мапит HTTP-коды HTTPS IPv4', () => {
|
||||
expect(statusFromHttpCode(200)).toBe('available')
|
||||
expect(statusFromHttpCode(403)).toBe('denied')
|
||||
expect(statusFromHttpCode(301)).toBe('redirected')
|
||||
expect(statusFromHttpCode(0)).toBe('timeout')
|
||||
expect(statusFromHttpCode(-1)).toBe('blocked')
|
||||
})
|
||||
|
||||
it('мапит error_code', () => {
|
||||
expect(statusFromErrorCode('blocked_by_ip')).toBe('blocked')
|
||||
expect(statusFromErrorCode('nxdomain')).toBe('error')
|
||||
expect(statusFromErrorCode('no_dns_record')).toBe('error')
|
||||
})
|
||||
|
||||
it('берёт HTTPS IPv4 как primary', () => {
|
||||
const row = normalizeIngestResult({
|
||||
service: 'YouTube.com',
|
||||
raw: {
|
||||
http: { ipv4: { status: 403 } },
|
||||
https: { ipv4: { status: 200 } },
|
||||
},
|
||||
})
|
||||
expect(row.serviceKey).toBe('youtube.com')
|
||||
expect(row.category).toBe('dpi')
|
||||
expect(row.status).toBe('available')
|
||||
expect(row.httpStatus).toBe(200)
|
||||
})
|
||||
|
||||
it('нормализует geoblock и timeout 000', () => {
|
||||
const row = normalizeIngestResult({
|
||||
service: 'netflix.com',
|
||||
raw: { https: { ipv4: { status: '000' } } },
|
||||
})
|
||||
expect(row.category).toBe('geoblock')
|
||||
expect(row.status).toBe('timeout')
|
||||
expect(row.httpStatus).toBe(0)
|
||||
})
|
||||
|
||||
it('считает summary и partial при timeout', () => {
|
||||
const { summary, runStatus } = summarizeResults([
|
||||
{ status: 'available' },
|
||||
{ status: 'timeout' },
|
||||
{ status: 'blocked' },
|
||||
])
|
||||
expect(summary.total).toBe(3)
|
||||
expect(summary.available).toBe(1)
|
||||
expect(summary.timeout).toBe(1)
|
||||
expect(summary.blocked).toBe(1)
|
||||
expect(runStatus).toBe('partial')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import {
|
||||
emptyCensorcheckSummary,
|
||||
inferCensorcheckCategory,
|
||||
type CensorcheckCategory,
|
||||
type CensorcheckIngestResult,
|
||||
type CensorcheckRunStatus,
|
||||
type CensorcheckStatus,
|
||||
type CensorcheckSummary,
|
||||
} from '@cfdm/shared/contracts/censorcheck'
|
||||
|
||||
function parseHttpStatus(raw: unknown): number | null {
|
||||
if (typeof raw === 'number' && Number.isFinite(raw)) return raw
|
||||
if (typeof raw === 'string' && raw.trim() !== '') {
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function protocolStatus(proto: unknown): { httpStatus: number | null; redirectUrl?: string } {
|
||||
if (!proto || typeof proto !== 'object') return { httpStatus: null }
|
||||
const rec = proto as { status?: unknown; redirect_url?: unknown }
|
||||
const redirectUrl = typeof rec.redirect_url === 'string' ? rec.redirect_url : undefined
|
||||
return { httpStatus: parseHttpStatus(rec.status), redirectUrl }
|
||||
}
|
||||
|
||||
function pickPrimaryProtocol(raw: Record<string, unknown>): {
|
||||
httpStatus: number | null
|
||||
redirectUrl?: string
|
||||
} {
|
||||
const https = raw.https
|
||||
if (https && typeof https === 'object') {
|
||||
const ipv4 = (https as { ipv4?: unknown }).ipv4
|
||||
if (ipv4) return protocolStatus(ipv4)
|
||||
}
|
||||
const http = raw.http
|
||||
if (http && typeof http === 'object') {
|
||||
const ipv4 = (http as { ipv4?: unknown }).ipv4
|
||||
if (ipv4) return protocolStatus(ipv4)
|
||||
}
|
||||
return { httpStatus: null }
|
||||
}
|
||||
|
||||
export function statusFromHttpCode(code: number | null): CensorcheckStatus {
|
||||
if (code == null) return 'error'
|
||||
if (code === 200) return 'available'
|
||||
if (code === 403) return 'denied'
|
||||
if (code >= 300 && code < 400) return 'redirected'
|
||||
if (code === 0) return 'timeout'
|
||||
if (code === -1) return 'blocked'
|
||||
if (code >= 400) return 'denied'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export function statusFromErrorCode(code: string | undefined): CensorcheckStatus {
|
||||
if (code === 'blocked_by_ip') return 'blocked'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
export type NormalizedServiceResult = {
|
||||
serviceKey: string
|
||||
serviceLabel: string
|
||||
category: CensorcheckCategory
|
||||
status: CensorcheckStatus
|
||||
httpStatus: number | null
|
||||
detail: string | null
|
||||
rawJson: string | null
|
||||
}
|
||||
|
||||
function compactRaw(raw: Record<string, unknown>): string | null {
|
||||
try {
|
||||
const compact: Record<string, unknown> = {}
|
||||
if (raw.service != null) compact.service = raw.service
|
||||
if (raw.error != null) compact.error = raw.error
|
||||
if (raw.error_code != null) compact.error_code = raw.error_code
|
||||
if (raw.http != null) compact.http = raw.http
|
||||
if (raw.https != null) compact.https = raw.https
|
||||
return JSON.stringify(compact)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeIngestResult(item: CensorcheckIngestResult): NormalizedServiceResult {
|
||||
const serviceKey = item.service.trim().toLowerCase()
|
||||
const raw = item.raw ?? {}
|
||||
const errorCode = typeof raw.error_code === 'string' ? raw.error_code : undefined
|
||||
const errorText = typeof raw.error === 'string' ? raw.error : undefined
|
||||
const primary = pickPrimaryProtocol(raw)
|
||||
|
||||
let status: CensorcheckStatus
|
||||
let httpStatus = primary.httpStatus
|
||||
let detail: string | null = primary.redirectUrl ?? errorText ?? null
|
||||
|
||||
if (errorCode || (raw.http == null && raw.https == null && errorText)) {
|
||||
status = statusFromErrorCode(errorCode)
|
||||
if (!detail) detail = errorCode ?? errorText ?? null
|
||||
} else {
|
||||
status = statusFromHttpCode(httpStatus)
|
||||
}
|
||||
|
||||
return {
|
||||
serviceKey,
|
||||
serviceLabel: item.service.trim(),
|
||||
category: item.category ?? inferCensorcheckCategory(serviceKey),
|
||||
status,
|
||||
httpStatus,
|
||||
detail,
|
||||
rawJson: compactRaw(raw),
|
||||
}
|
||||
}
|
||||
|
||||
export function summarizeResults(results: { status: CensorcheckStatus }[]): {
|
||||
summary: CensorcheckSummary
|
||||
runStatus: CensorcheckRunStatus
|
||||
} {
|
||||
const summary = emptyCensorcheckSummary()
|
||||
summary.total = results.length
|
||||
for (const row of results) {
|
||||
summary[row.status] += 1
|
||||
}
|
||||
const runStatus: CensorcheckRunStatus =
|
||||
summary.timeout > 0 || summary.error > 0 ? 'partial' : 'complete'
|
||||
return { summary, runStatus }
|
||||
}
|
||||
Reference in New Issue
Block a user