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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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'))`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user