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