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