Docker images / prepare-release (push) Successful in 13s
Docker images / backend-test (push) Successful in 2m13s
Docker images / frontend-image (push) Successful in 4m31s
Docker images / updater-image (push) Successful in 48s
Docker images / backend-image (push) Successful in 3m1s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Lookup страны/ASN при ingest теперь идёт сначала по локальным mmdb MaxMind GeoLite2 (Country + ASN, зеркало P3TERX, без ключей), с мгновенным синхронным ответом для IPv4/IPv6; RIPEstat остаётся fallback до первой загрузки баз и при промахе. - backend/src/services/traffic-flow-geoip.ts: ридеры maxmind, фасад resolveFlowIp (geoip-first → RIPE-кэш), статус ридеров - geoip-update-collector.ts: conditional GET по ETag, валидация пробоем 8.8.8.8 (US / AS15169), атомарная подмена с .prev-откатом - джоба планировщика geoip_update (по умолчанию раз в 7 дней), настройки geoip_settings + миграция 0004, API /api/geoip (GET/PUT/update), контракты @mmapp/contracts/geoip - engine/analytics/map-hops переведены на resolveFlowIp - секция «GeoIP-базы» в настройках NetFlow, метки джобы на странице сбора данных, тесты test:geoip
199 lines
6.4 KiB
TypeScript
199 lines
6.4 KiB
TypeScript
import { rename, rm, mkdir, writeFile } from "node:fs/promises"
|
|
import path from "node:path"
|
|
import { open, type AsnResponse, type CountryResponse } from "maxmind"
|
|
import type { GeoipUpdateRunSnapshot } from "../types/scheduler-run-snapshot.js"
|
|
import { SCHEDULER_RUN_SNAPSHOT_VERSION } from "../types/scheduler-run-snapshot.js"
|
|
import { getGeoipEtags, getGeoipSettings, touchGeoipRunMeta } from "./geoip-settings.js"
|
|
import {
|
|
GEOIP_ASN_FILE,
|
|
GEOIP_COUNTRY_FILE,
|
|
geoipDir,
|
|
reloadGeoipReaders,
|
|
} from "./traffic-flow-geoip.js"
|
|
|
|
/** Зеркало GeoLite2 без регистрации и ключей (см. README: GeoIP). */
|
|
const MIRROR_BASE = "https://github.com/P3TERX/GeoLite.mmdb/raw/download"
|
|
const DOWNLOAD_TIMEOUT_MS = 120_000
|
|
/** Пробный IP для валидации скачанной базы: Google DNS. */
|
|
const PROBE_IP = "8.8.8.8"
|
|
|
|
type GeoipDbKind = "country" | "asn"
|
|
|
|
let updating = false
|
|
let fetchImpl: typeof fetch = globalThis.fetch.bind(globalThis)
|
|
|
|
export function getGeoipUpdateState(): { running: boolean } {
|
|
return { running: updating }
|
|
}
|
|
|
|
async function validateCountryFile(filePath: string): Promise<string> {
|
|
const reader = await open<CountryResponse>(filePath)
|
|
const rec = reader.get(PROBE_IP)
|
|
const iso = rec?.country?.iso_code ?? rec?.registered_country?.iso_code ?? ""
|
|
if (iso !== "US") {
|
|
throw new Error(`база Country не распознала ${PROBE_IP} как US (${iso || "нет записи"})`)
|
|
}
|
|
return reader.metadata.buildEpoch.toISOString()
|
|
}
|
|
|
|
async function validateAsnFile(filePath: string): Promise<string> {
|
|
const reader = await open<AsnResponse>(filePath)
|
|
const rec = reader.get(PROBE_IP)
|
|
const asn = rec?.autonomous_system_number ?? 0
|
|
if (asn !== 15169) {
|
|
throw new Error(`база ASN не распознала ${PROBE_IP} как AS15169 (${asn ? `AS${asn}` : "нет записи"})`)
|
|
}
|
|
return reader.metadata.buildEpoch.toISOString()
|
|
}
|
|
|
|
let validateCountry = validateCountryFile
|
|
let validateAsn = validateAsnFile
|
|
|
|
export function setGeoipFetchForTests(fn: typeof fetch): void {
|
|
fetchImpl = fn
|
|
}
|
|
|
|
export function setGeoipValidateForTests(opts: {
|
|
country?: (filePath: string) => Promise<string>
|
|
asn?: (filePath: string) => Promise<string>
|
|
}): void {
|
|
validateCountry = opts.country ?? validateCountryFile
|
|
validateAsn = opts.asn ?? validateAsnFile
|
|
}
|
|
|
|
export function resetGeoipUpdateForTests(): void {
|
|
updating = false
|
|
fetchImpl = globalThis.fetch.bind(globalThis)
|
|
validateCountry = validateCountryFile
|
|
validateAsn = validateAsnFile
|
|
}
|
|
|
|
/**
|
|
* Разовая проверка/доставка баз с зеркала P3TERX. Conditional GET по ETag
|
|
* (304 = не меняем файл), валидация пробоем 8.8.8.8, атомарная подмена через rename.
|
|
*/
|
|
export async function collectGeoipUpdateOnce(
|
|
opts: { force?: boolean } = {},
|
|
): Promise<GeoipUpdateRunSnapshot> {
|
|
const sampledAt = new Date().toISOString()
|
|
if (updating) {
|
|
if (opts.force) {
|
|
throw Object.assign(new Error("Обновление GeoIP уже выполняется"), { statusCode: 409 })
|
|
}
|
|
return emptySnapshot(sampledAt, true)
|
|
}
|
|
|
|
const settings = await getGeoipSettings()
|
|
if (!settings.enabled && !opts.force) {
|
|
return emptySnapshot(sampledAt, true)
|
|
}
|
|
|
|
updating = true
|
|
const snapshot: GeoipUpdateRunSnapshot = {
|
|
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
|
job: "geoip_update",
|
|
sampledAt,
|
|
checked: 0,
|
|
downloaded: 0,
|
|
skippedUnchanged: 0,
|
|
bytes: 0,
|
|
errors: [],
|
|
}
|
|
|
|
try {
|
|
const dir = geoipDir()
|
|
await mkdir(dir, { recursive: true })
|
|
const storedEtags = await getGeoipEtags()
|
|
const etags: Record<string, string> = {}
|
|
const buildAt: Partial<Record<GeoipDbKind, string>> = {}
|
|
|
|
for (const kind of ["country", "asn"] as const) {
|
|
snapshot.checked += 1
|
|
const file = kind === "country" ? GEOIP_COUNTRY_FILE : GEOIP_ASN_FILE
|
|
const target = path.join(dir, file)
|
|
const tmp = `${target}.tmp`
|
|
const prevEtag = storedEtags[file]
|
|
try {
|
|
const ac = new AbortController()
|
|
const timer = setTimeout(() => ac.abort(), DOWNLOAD_TIMEOUT_MS)
|
|
let res: Response
|
|
try {
|
|
res = await fetchImpl(`${MIRROR_BASE}/${file}`, {
|
|
headers: prevEtag ? { "If-None-Match": prevEtag } : {},
|
|
signal: ac.signal,
|
|
})
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
if (res.status === 304) {
|
|
snapshot.skippedUnchanged += 1
|
|
if (prevEtag) etags[file] = prevEtag
|
|
continue
|
|
}
|
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
const etag = res.headers.get("etag") ?? ""
|
|
const body = Buffer.from(await res.arrayBuffer())
|
|
snapshot.bytes += body.byteLength
|
|
await writeFile(tmp, body)
|
|
buildAt[kind] =
|
|
kind === "country" ? await validateCountry(tmp) : await validateAsn(tmp)
|
|
|
|
const prevFile = `${target}.prev`
|
|
await rm(prevFile, { force: true })
|
|
await rename(target, prevFile).catch(() => {
|
|
/* текущего файла могло ещё не быть */
|
|
})
|
|
await rename(tmp, target)
|
|
snapshot.downloaded += 1
|
|
if (etag) etags[file] = etag
|
|
} catch (e) {
|
|
await rm(tmp, { force: true }).catch(() => {
|
|
/* best-effort */
|
|
})
|
|
const message = e instanceof Error ? e.message : String(e)
|
|
snapshot.errors.push(`${file}: ${message}`)
|
|
}
|
|
}
|
|
|
|
if (snapshot.downloaded > 0) {
|
|
await reloadGeoipReaders()
|
|
}
|
|
|
|
await touchGeoipRunMeta({
|
|
lastCheckAt: sampledAt,
|
|
lastSuccessAt: snapshot.errors.length ? null : sampledAt,
|
|
lastError: snapshot.errors.length ? snapshot.errors.join("; ") : null,
|
|
countryBuildAt: buildAt.country,
|
|
asnBuildAt: buildAt.asn,
|
|
etags,
|
|
})
|
|
return snapshot
|
|
} catch (e) {
|
|
const message = e instanceof Error ? e.message : String(e)
|
|
snapshot.fatalError = message
|
|
await touchGeoipRunMeta({
|
|
lastCheckAt: sampledAt,
|
|
lastError: message,
|
|
}).catch(() => {
|
|
/* best-effort */
|
|
})
|
|
return snapshot
|
|
} finally {
|
|
updating = false
|
|
}
|
|
}
|
|
|
|
function emptySnapshot(sampledAt: string, skipped: boolean): GeoipUpdateRunSnapshot {
|
|
return {
|
|
v: SCHEDULER_RUN_SNAPSHOT_VERSION,
|
|
job: "geoip_update",
|
|
sampledAt,
|
|
skipped,
|
|
checked: 0,
|
|
downloaded: 0,
|
|
skippedUnchanged: 0,
|
|
bytes: 0,
|
|
errors: [],
|
|
}
|
|
}
|