From 60a22b945026ecf0a0238659fc29555049929108 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Tue, 25 Aug 2026 13:19:02 +0700 Subject: [PATCH] =?UTF-8?q?feat(blocking):=20=D0=BE=D0=BF=D1=80=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BB=D1=8F=D1=82=D1=8C=20=D0=B8=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D1=8B=D0=B2=D0=B0=D1=82=D1=8C=20=D1=85=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=B5=D1=80=20=D0=B2=20=D0=BC=D0=B0=D1=82=D1=80?= =?UTF-8?q?=D0=B8=D1=86=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BE=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- apps/api/scripts/censorcheck/launcher.sh | 91 ++++++++++++++++++- apps/api/src/routes/censorcheck.test.ts | 40 ++++++++ apps/api/src/routes/censorcheck.ts | 3 +- apps/api/src/routes/launcher.test.ts | 2 + .../src/services/censorcheck/hoster.test.ts | 21 +++++ .../censorcheck/blocking-filters.test.ts | 33 ++++++- .../censorcheck/blocking-filters.ts | 2 +- .../components/censorcheck/blocking-grid.tsx | 10 +- .../censorcheck/check-run-sheet.tsx | 9 +- apps/web/src/components/censorcheck/types.ts | 8 ++ packages/db/src/repositories/censorcheck.ts | 5 + packages/db/src/runtime-migrate.ts | 4 +- packages/db/src/schema/index.ts | 1 + packages/db/src/test-setup.ts | 3 +- packages/shared/src/contracts/censorcheck.ts | 54 +++++++++++ 15 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 apps/api/src/services/censorcheck/hoster.test.ts diff --git a/apps/api/scripts/censorcheck/launcher.sh b/apps/api/scripts/censorcheck/launcher.sh index 40155fc..93bb224 100644 --- a/apps/api/scripts/censorcheck/launcher.sh +++ b/apps/api/scripts/censorcheck/launcher.sh @@ -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"), diff --git a/apps/api/src/routes/censorcheck.test.ts b/apps/api/src/routes/censorcheck.test.ts index 882e560..8cdadc8 100644 --- a/apps/api/src/routes/censorcheck.test.ts +++ b/apps/api/src/routes/censorcheck.test.ts @@ -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') + }) }) diff --git a/apps/api/src/routes/censorcheck.ts b/apps/api/src/routes/censorcheck.ts index 1442bc8..59a38d2 100644 --- a/apps/api/src/routes/censorcheck.ts +++ b/apps/api/src/routes/censorcheck.ts @@ -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, }) diff --git a/apps/api/src/routes/launcher.test.ts b/apps/api/src/routes/launcher.test.ts index 330c99e..2bf3c7f 100644 --- a/apps/api/src/routes/launcher.test.ts +++ b/apps/api/src/routes/launcher.test.ts @@ -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('Проверяю сайты') diff --git a/apps/api/src/services/censorcheck/hoster.test.ts b/apps/api/src/services/censorcheck/hoster.test.ts new file mode 100644 index 0000000..f66594e --- /dev/null +++ b/apps/api/src/services/censorcheck/hoster.test.ts @@ -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') + }) +}) diff --git a/apps/web/src/components/censorcheck/blocking-filters.test.ts b/apps/web/src/components/censorcheck/blocking-filters.test.ts index 336c4c4..5957d99 100644 --- a/apps/web/src/components/censorcheck/blocking-filters.test.ts +++ b/apps/web/src/components/censorcheck/blocking-filters.test.ts @@ -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 => ({ 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', () => { diff --git a/apps/web/src/components/censorcheck/blocking-filters.ts b/apps/web/src/components/censorcheck/blocking-filters.ts index 44abcfc..6b4f775 100644 --- a/apps/web/src/components/censorcheck/blocking-filters.ts +++ b/apps/web/src/components/censorcheck/blocking-filters.ts @@ -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 diff --git a/apps/web/src/components/censorcheck/blocking-grid.tsx b/apps/web/src/components/censorcheck/blocking-grid.tsx index acdb901..4979ec2 100644 --- a/apps/web/src/components/censorcheck/blocking-grid.tsx +++ b/apps/web/src/components/censorcheck/blocking-grid.tsx @@ -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 { 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 ? ( { ) : ( Unknown VPS ) - return dataGridCellStack(link, ip) + return dataGridCellStack(link, secondary) }, } } diff --git a/apps/web/src/components/censorcheck/check-run-sheet.tsx b/apps/web/src/components/censorcheck/check-run-sheet.tsx index 6bb877a..f6ef09c 100644 --- a/apps/web/src/components/censorcheck/check-run-sheet.tsx +++ b/apps/web/src/components/censorcheck/check-run-sheet.tsx @@ -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) { ) : undefined, }, + { + id: 'hoster', + icon: , + label: 'Хостер', + description: runHosterLabel(detail) || '—', + }, { id: 'geo', icon: , diff --git a/apps/web/src/components/censorcheck/types.ts b/apps/web/src/components/censorcheck/types.ts index 4b34d6b..a64e6fb 100644 --- a/apps/web/src/components/censorcheck/types.ts +++ b/apps/web/src/components/censorcheck/types.ts @@ -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}`), ] diff --git a/packages/db/src/repositories/censorcheck.ts b/packages/db/src/repositories/censorcheck.ts index 538e5ad..7f68ccb 100644 --- a/packages/db/src/repositories/censorcheck.ts +++ b/packages/db/src/repositories/censorcheck.ts @@ -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), )!, ) } diff --git a/packages/db/src/runtime-migrate.ts b/packages/db/src/runtime-migrate.ts index cdea61a..1bdba3b 100644 --- a/packages/db/src/runtime-migrate.ts +++ b/packages/db/src/runtime-migrate.ts @@ -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 = [ diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4500fae..c9842c8 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -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), diff --git a/packages/db/src/test-setup.ts b/packages/db/src/test-setup.ts index f3f7fdf..c86b401 100644 --- a/packages/db/src/test-setup.ts +++ b/packages/db/src/test-setup.ts @@ -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 ( diff --git a/packages/shared/src/contracts/censorcheck.ts b/packages/shared/src/contracts/censorcheck.ts index cc71480..3c44737 100644 --- a/packages/shared/src/contracts/censorcheck.ts +++ b/packages/shared/src/contracts/censorcheck.ts @@ -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) +}