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:
@@ -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