feat(blocking): определять и показывать хостер в матрице блокировок
Docker / build (push) Failing after 26s

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-08-25 13:19:02 +07:00
co-authored by Cursor
parent efebcaf16d
commit 60a22b9450
15 changed files with 273 additions and 13 deletions
+88 -3
View File
@@ -5,7 +5,7 @@ set -euo pipefail
VT_API_URL="${VT_API_URL:-__VT_API_URL__}"
VT_INGEST_TOKEN="${VT_INGEST_TOKEN:-__VT_INGEST_TOKEN__}"
LAUNCHER_VERSION="4"
LAUNCHER_VERSION="5"
VENDOR_SHA="12c5839"
OS_ID="unknown"
@@ -165,6 +165,88 @@ detect_public_ip() {
printf '%s' "$ip"
}
# Короткий таймаут: на обычном VPS link-local просто не ответит.
curl_meta() {
curl -fsS --connect-timeout 1 --max-time 1 "$@" 2>/dev/null || true
}
detect_cloud_hoster() {
local body=""
body="$(curl_meta http://169.254.169.254/hetzner/v1/metadata)"
if [ -n "$body" ]; then
printf 'Hetzner'
return 0
fi
body="$(curl_meta http://169.254.169.254/metadata/v1/id)"
if [ -n "$body" ]; then
printf 'DigitalOcean'
return 0
fi
body="$(curl_meta http://169.254.169.254/v1/instanceid)"
if [ -n "$body" ]; then
printf 'Vultr'
return 0
fi
body="$(curl_meta http://169.254.169.254/linode/v1/instance-id)"
if [ -n "$body" ]; then
printf 'Linode'
return 0
fi
body="$(curl_meta -H 'Metadata-Flavor: Google' http://metadata.google.internal/computeMetadata/v1/instance/id)"
if [ -n "$body" ]; then
printf 'Google Cloud'
return 0
fi
body="$(curl_meta -H 'Metadata: true' 'http://169.254.169.254/metadata/instance?api-version=2021-02-01')"
if [ -n "$body" ]; then
printf 'Azure'
return 0
fi
body="$(curl_meta http://169.254.169.254/latest/meta-data/instance-id)"
if [ -n "$body" ]; then
printf 'AWS'
return 0
fi
return 1
}
detect_asn_org() {
local ip="$1" json="" org=""
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipwho.is/${ip}" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.connection.org // .org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
json="$(curl -fsS --connect-timeout 4 --max-time 8 "https://ipinfo.io/${ip}/json" 2>/dev/null || true)"
if [ -n "$json" ]; then
org="$(printf '%s' "$json" | jq -r '.org // empty' 2>/dev/null || true)"
if [ -n "$org" ] && [ "$org" != "null" ]; then
printf '%s' "$org"
return 0
fi
fi
return 1
}
detect_ptr_hint() {
local ip="$1" ptr=""
command -v dig >/dev/null 2>&1 || return 1
ptr="$(dig +short -x "$ip" 2>/dev/null | awk 'NF{print; exit}' | tr -d '\r' | sed 's/\.$//')"
[ -n "$ptr" ] || return 1
printf '%s' "$ptr"
}
detect_hoster() {
local ip="$1" value=""
value="$(detect_cloud_hoster)" && { printf '%s' "$value"; return 0; }
value="$(detect_asn_org "$ip")" && { printf '%s' "$value"; return 0; }
value="$(detect_ptr_hint "$ip")" && { printf '%s' "$value"; return 0; }
return 1
}
write_vendor() {
local dest="$1"
if [ -n "${CENSORCHECK_VENDOR_B64:-}" ]; then
@@ -203,11 +285,14 @@ chmod +x "$VENDOR"
PUBLIC_IP="$(detect_public_ip)"
[ -n "$PUBLIC_IP" ] || die "Не удалось определить публичный IP"
HOSTER="$(detect_hoster "$PUBLIC_IP" || true)"
RUN_ID="$(uuid4)"
[ -n "$RUN_ID" ] || die "Не удалось сгенерировать runId"
log "censorcheck launcher ${LAUNCHER_VERSION} (vendor ${VENDOR_SHA})"
log "probe IP: ${PUBLIC_IP}"
log "хостер: ${HOSTER:-не определён}"
log "runId: ${RUN_ID}"
log "Проверяю сайты (последовательно, несколько минут)..."
@@ -222,11 +307,11 @@ if [ "$CC_EXIT" -ne 0 ]; then
fi
PAYLOAD="$TMPDIR/payload.json"
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" '
printf '%s' "$RAW_JSON" | jq --arg runId "$RUN_ID" --arg ip "$PUBLIC_IP" --arg lv "$LAUNCHER_VERSION" --arg hoster "$HOSTER" '
{
schemaVersion: 1,
runId: $runId,
probe: { publicIp: $ip },
probe: ({ publicIp: $ip } + if ($hoster | length) > 0 then { hoster: $hoster } else {} end),
launcherVersion: $lv,
censorcheck: {
version: ((.version | tostring) // "1"),
+40
View File
@@ -161,4 +161,44 @@ describe('censorcheck ingest + reads', () => {
expect(detail.statusCode).toBe(200)
expect(detail.json().results).toHaveLength(1)
})
it('сохраняет хостер из probe и канонизирует ASN', async () => {
const res = await post(
ingestPayload({
runId: 'dddddddd-dddd-4ddd-8ddd-dddddddddddd',
probe: { publicIp: '203.0.113.10', hoster: 'AS14061 DigitalOcean, LLC' },
}),
)
expect(res.statusCode).toBe(200)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
expect(current.json().items[0].detectedHoster).toBe('DigitalOcean')
expect(current.json().items[0].vps).toBeNull()
})
it('для matched VPS отдаёт имя хостера из инвентаря', async () => {
runWithSpace(MAIN_SPACE_ID, () =>
vpsRepository.create({
ip: '203.0.113.10',
dns: 'edge.example.com',
providerId: 'p1',
providerAccountId: 'a1',
status: 'active',
tariffType: 'monthly',
currency: 'RUB',
vcpu: 2,
ramGb: 4,
diskGb: 40,
}),
)
await post(
ingestPayload({
runId: 'eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee',
probe: { publicIp: '203.0.113.10', hoster: 'Hetzner Online GmbH' },
}),
)
const current = await app.inject({ method: 'GET', url: '/api/censorcheck/current' })
const item = current.json().items[0]
expect(item.vps.providerName).toBe('Test Host')
expect(item.detectedHoster).toBe('Hetzner')
})
})
+2 -1
View File
@@ -1,5 +1,5 @@
import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'
import { censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { canonicalizeHoster, censorcheckIngestBodySchema } from '@cfdm/shared/contracts/censorcheck'
import { censorcheckRepository } from '@cfdm/db/repositories/censorcheck'
import { actorFromRequest } from '../lib/audit-actor.js'
import {
@@ -82,6 +82,7 @@ export const censorcheckRoutes: FastifyPluginAsync = async (app) => {
censorcheckVersion: parsed.data.censorcheck?.version ?? null,
summary,
observedSourceIp: observed ?? null,
detectedHoster: canonicalizeHoster(parsed.data.probe.hoster),
results,
})
+2
View File
@@ -28,6 +28,8 @@ describe('GET /cc launcher', () => {
expect(res.body).toContain('VT_INGEST_TOKEN')
expect(res.body).toContain('ensure_cmds jq dig column')
expect(res.body).toContain('detect_os')
expect(res.body).toContain('detect_hoster')
expect(res.body).toContain('ipwho.is')
expect(res.body).toContain('/etc/os-release')
expect(res.body).toContain('apt-get install -y -qq')
expect(res.body).toContain('Проверяю сайты')
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'vitest'
import { canonicalizeHoster } from '@cfdm/shared/contracts/censorcheck'
describe('canonicalizeHoster', () => {
it('мапит ASN/org на короткое имя', () => {
expect(canonicalizeHoster('AS14061 DigitalOcean, LLC')).toBe('DigitalOcean')
expect(canonicalizeHoster('Hetzner Online GmbH')).toBe('Hetzner')
expect(canonicalizeHoster('hosted-by.vdsina.ru')).toBe('VDSina')
expect(canonicalizeHoster(' aeza.net. ')).toBe('Aeza')
})
it('пустую строку отбрасывает', () => {
expect(canonicalizeHoster('')).toBeNull()
expect(canonicalizeHoster(' ')).toBeNull()
expect(canonicalizeHoster(undefined)).toBeNull()
})
it('неизвестную org чистит без alias', () => {
expect(canonicalizeHoster('AS12345 Example Hosting LLC')).toBe('Example Hosting')
})
})
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import type { Filter } from '@/components/reui/filters'
import { filterCensorcheckRuns, groupRunsByService, collectServiceColumns, collectProbeColumns, shortHostLabel } from './blocking-filters'
import type { CensorcheckRunDto } from './types'
import { runHosterLabel, type CensorcheckRunDto } from './types'
const run = (overrides: Partial<CensorcheckRunDto> = {}): CensorcheckRunDto => ({
id: 'ccrun-1',
@@ -88,6 +88,37 @@ describe('filterCensorcheckRuns', () => {
]),
).toHaveLength(0)
})
it('фильтрует по хостеру из прогона, если инвентарь пуст', () => {
const unmatched = run({
matchedVpsId: null,
vps: null,
detectedHoster: 'DigitalOcean',
})
expect(
filterCensorcheckRuns([unmatched], [
{ id: '1', field: 'hoster', operator: 'contains', values: ['digital'] },
]),
).toHaveLength(1)
expect(
filterCensorcheckRuns([unmatched], [
{ id: '1', field: 'hoster', operator: 'contains', values: ['hetzner'] },
]),
).toHaveLength(0)
})
})
describe('runHosterLabel', () => {
it('предпочитает имя из инвентаря', () => {
expect(runHosterLabel(run())).toBe('Hoster')
expect(runHosterLabel(run({ detectedHoster: 'Hetzner' }))).toBe('Hoster')
})
it('берёт detectedHoster если VPS не сматчен', () => {
expect(runHosterLabel(run({ matchedVpsId: null, vps: null, detectedHoster: 'Hetzner' }))).toBe(
'Hetzner',
)
})
})
describe('groupRunsByService', () => {
@@ -39,7 +39,7 @@ export function filterCensorcheckRuns(
continue
}
if (filter.field === 'hoster') {
const name = (run.vps?.providerName ?? '').toLowerCase()
const name = `${run.vps?.providerName ?? ''} ${run.detectedHoster ?? ''}`.toLowerCase()
const hit = values.some((value) => name.includes(value.toLowerCase()) || name === value.toLowerCase())
if (!hit) return false
continue
@@ -13,7 +13,7 @@ import {
} from './blocking-filters'
import { resolveServiceIcon, ServiceGlyph } from './service-icons'
import { StatusMatrixCell } from './status-matrix-cell'
import type { CensorcheckRunDto } from './types'
import { runHosterLabel, type CensorcheckRunDto } from './types'
/** DNA data-grid-base-4: auto width + H-scroll + pin start. Preview: https://reui.io/preview/base/data-grid-base-4 */
export const BLOCKING_MATRIX_GRID = {
@@ -31,12 +31,14 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
icon: ServerIcon,
enableHiding: false,
enablePinning: true,
size: 220,
minSize: 180,
size: 240,
minSize: 200,
sortValue: (row) => row.vps?.dns || row.probePublicIp,
cell: (row) => {
const title = row.vps?.dns || row.probePublicIp
const ip = row.probePublicIp
const hoster = runHosterLabel(row)
const secondary = hoster ? `${ip} · ${hoster}` : ip
const link = row.matchedVpsId ? (
<Link
to="/vps/$vpsId"
@@ -49,7 +51,7 @@ function vpsIdentityColumn(): DataGridColumn<CensorcheckRunDto> {
) : (
<span className="font-medium">Unknown VPS</span>
)
return dataGridCellStack(link, ip)
return dataGridCellStack(link, secondary)
},
}
}
@@ -1,5 +1,5 @@
import { Link } from '@tanstack/react-router'
import { GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { Building2Icon, GlobeIcon, MapPinIcon, ServerIcon, ShieldAlertIcon } from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import {
@@ -16,6 +16,7 @@ import {
CENSORCHECK_STATUS_LABELS,
formatCheckedAt,
formatVpsResources,
runHosterLabel,
type CensorcheckRunDto,
} from './types'
@@ -67,6 +68,12 @@ export function CheckRunSheet({ run, open, onOpenChange }: CheckRunSheetProps) {
</Link>
) : undefined,
},
{
id: 'hoster',
icon: <Building2Icon />,
label: 'Хостер',
description: runHosterLabel(detail) || '—',
},
{
id: 'geo',
icon: <MapPinIcon />,
@@ -40,6 +40,7 @@ export type CensorcheckRunDto = {
createdAt: string
completedAt: string
observedSourceIp: string | null
detectedHoster?: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
@@ -67,12 +68,19 @@ export function formatCheckedAt(iso: string): string {
return date.toLocaleString('ru-RU')
}
export function runHosterLabel(run: CensorcheckRunDto): string {
const inventory = run.vps?.providerName?.trim() ?? ''
if (inventory) return inventory
return run.detectedHoster?.trim() ?? ''
}
export function runSearchText(run: CensorcheckRunDto): string {
const parts = [
run.probePublicIp,
run.claimedPublicIp ?? '',
run.vps?.dns ?? '',
run.vps?.providerName ?? '',
run.detectedHoster ?? '',
run.vps?.country ?? '',
...(run.results ?? []).map((row) => `${row.serviceKey} ${row.serviceLabel}`),
]
@@ -54,6 +54,7 @@ export type CensorcheckRunDto = {
createdAt: string
completedAt: string
observedSourceIp: string | null
detectedHoster: string | null
vps: CensorcheckVpsInfo | null
results?: CensorcheckResultDto[]
}
@@ -80,6 +81,7 @@ export type CensorcheckInsertRun = {
censorcheckVersion: string | null
summary: CensorcheckSummary
observedSourceIp: string | null
detectedHoster: string | null
results: CensorcheckInsertResult[]
}
@@ -175,6 +177,7 @@ function toRunDto(row: RunRow, includeResults = false): CensorcheckRunDto {
createdAt: row.createdAt,
completedAt: row.completedAt,
observedSourceIp: row.observedSourceIp ?? null,
detectedHoster: row.detectedHoster ?? null,
vps: hydrateVps(row.matchedVpsId ?? null),
}
if (includeResults) {
@@ -248,6 +251,7 @@ export const censorcheckRepository = {
createdAt: now,
completedAt: now,
observedSourceIp: input.observedSourceIp,
detectedHoster: input.detectedHoster,
})
.run()
for (const result of input.results) {
@@ -304,6 +308,7 @@ export const censorcheckRepository = {
like(schema.censorcheckRuns.probePublicIp, pattern),
like(schema.censorcheckRuns.claimedPublicIp, pattern),
like(schema.censorcheckRuns.runId, pattern),
like(schema.censorcheckRuns.detectedHoster, pattern),
)!,
)
}
+3 -1
View File
@@ -297,7 +297,8 @@ const CORE_TABLE_MIGRATIONS: string[] = [
summaryJson TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
completedAt TEXT NOT NULL,
observedSourceIp TEXT
observedSourceIp TEXT,
detectedHoster TEXT
)`,
`CREATE TABLE IF NOT EXISTS censorcheck_results (
id TEXT PRIMARY KEY,
@@ -366,6 +367,7 @@ const COLUMN_MIGRATIONS: string[] = [
`ALTER TABLE sync_log ADD COLUMN summary TEXT`,
`ALTER TABLE active_tariffs ADD COLUMN spaceId TEXT`,
`ALTER TABLE tariff_sync_options ADD COLUMN spaceId TEXT`,
`ALTER TABLE censorcheck_runs ADD COLUMN detectedHoster TEXT`,
]
const SPACE_BACKFILL_TABLES = [
+1
View File
@@ -384,6 +384,7 @@ export const censorcheckRuns = sqliteTable(
createdAt: text('createdAt').notNull(),
completedAt: text('completedAt').notNull(),
observedSourceIp: text('observedSourceIp'),
detectedHoster: text('detectedHoster'),
},
(t) => ({
runIdUniq: uniqueIndex('censorcheck_runs_runId').on(t.runId),
+2 -1
View File
@@ -299,7 +299,8 @@ CREATE TABLE IF NOT EXISTS censorcheck_runs (
summaryJson TEXT NOT NULL DEFAULT '{}',
createdAt TEXT NOT NULL,
completedAt TEXT NOT NULL,
observedSourceIp TEXT
observedSourceIp TEXT,
detectedHoster TEXT
);
CREATE TABLE IF NOT EXISTS censorcheck_results (
@@ -77,6 +77,7 @@ export const censorcheckIngestBodySchema = z.object({
runId: z.string().trim().min(8).max(80),
probe: z.object({
publicIp: z.string().trim().min(1).max(64),
hoster: z.string().trim().max(160).optional(),
}),
censorcheck: z
.object({
@@ -112,3 +113,56 @@ export function emptyCensorcheckSummary(): CensorcheckSummary {
error: 0,
}
}
const HOSTER_ALIASES: ReadonlyArray<{ test: RegExp; name: string }> = [
{ test: /digitalocean|digital ocean|\bas14061\b/i, name: 'DigitalOcean' },
{ test: /hetzner|\bas24940\b/i, name: 'Hetzner' },
{ test: /\bovh|\bas16276\b/i, name: 'OVH' },
{ test: /linode|akamai|\bas63949\b/i, name: 'Linode' },
{ test: /\bvultr|\bas20473\b/i, name: 'Vultr' },
{ test: /amazon|aws|\bec2\b|\bas16509\b|\bas14618\b/i, name: 'AWS' },
{ test: /google cloud|\bgcp\b|\bas15169\b/i, name: 'Google Cloud' },
{ test: /microsoft|azure|\bas8075\b/i, name: 'Azure' },
{ test: /oracle cloud|\bas31898\b/i, name: 'Oracle Cloud' },
{ test: /selectel|\bas49505\b/i, name: 'Selectel' },
{ test: /timeweb|\bas197695\b/i, name: 'Timeweb' },
{ test: /vdsina/i, name: 'VDSina' },
{ test: /4vps/i, name: '4VPS' },
{ test: /macloud/i, name: 'Macloud' },
{ test: /veesp/i, name: 'Veesp' },
{ test: /ruvds|ru-vds|ru vds/i, name: 'RUVDS' },
{ test: /\baeza\b/i, name: 'Aeza' },
{ test: /firstvds|firstdedic/i, name: 'FirstVDS' },
{ test: /yandex cloud|\bas200350\b/i, name: 'Yandex Cloud' },
{ test: /cloud\.ru|sbercloud|\bas208677\b/i, name: 'Cloud.ru' },
{ test: /\bbeget\b/i, name: 'Beget' },
{ test: /fornex/i, name: 'Fornex' },
{ test: /adminvps/i, name: 'AdminVPS' },
{ test: /\bihor\b/i, name: 'Ihor' },
{ test: /netcup/i, name: 'Netcup' },
{ test: /contabo/i, name: 'Contabo' },
{ test: /leaseweb/i, name: 'Leaseweb' },
]
function tidyOrgName(value: string): string {
const stripped = value
.replace(/^as\d+\s+/i, '')
.replace(/\s*[,;]?\s*\b(llc|ltd|inc|gmbh|corp|limited|jsc|ooo|ооо|ао)\b\.?$/i, '')
.replace(/\s+/g, ' ')
.trim()
if (!stripped) return value.slice(0, 80)
const words = stripped.split(' ')
if (stripped.length > 42 && words.length > 3) return words.slice(0, 3).join(' ')
return stripped.slice(0, 80)
}
/** Нормализует ASN/org/PTR/cloud-id в короткое имя хостера. */
export function canonicalizeHoster(raw: string | null | undefined): string | null {
const value = raw?.replace(/\s+/g, ' ').trim()
if (!value) return null
const sliced = value.slice(0, 160)
for (const alias of HOSTER_ALIASES) {
if (alias.test.test(sliced)) return alias.name
}
return tidyOrgName(sliced)
}