Docker images / prepare-release (push) Successful in 9s
Docker images / backend-test (push) Successful in 2m34s
Docker images / frontend-image (push) Successful in 3m20s
Docker images / updater-image (push) Successful in 43s
Docker images / backend-image (push) Successful in 2m25s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 13s
- Added support for Instagram in traffic flow resolution and classification, including new test cases to validate its behavior. - Introduced `INSTAGRAM` brand handling in the traffic flow logic, ensuring accurate service identification for Instagram-related traffic. - Updated existing tests to cover various scenarios involving Instagram, including IP resolution and service categorization. - Enhanced the service brand icon component to include an Instagram icon for better visual representation. Co-authored-by: Cursor <[email protected]>
154 lines
5.6 KiB
TypeScript
154 lines
5.6 KiB
TypeScript
import { OTHER_SERVICE, resolveFlowBrand } from "./traffic-flow-brands.js"
|
|
import { db } from "../db/index.js"
|
|
import { evobgpSettings } from "../db/schema.js"
|
|
import { ipInCidrV4, parseCidrV4 } from "./traffic-flow-ip.js"
|
|
import type { FlowIpMeta } from "./traffic-flow-ripe.js"
|
|
import { applicationName } from "./traffic-flow-apps.js"
|
|
|
|
export interface FlowClassification {
|
|
service: string
|
|
category: string
|
|
}
|
|
|
|
interface CatalogCidr {
|
|
cidr: string
|
|
purpose: string
|
|
prefixLen: number
|
|
}
|
|
|
|
const CATALOG_TTL_MS = 10 * 60_000
|
|
let cidrs: CatalogCidr[] = []
|
|
let asnPurpose = new Map<number, string>()
|
|
let fetchedAt = 0
|
|
let catalogFetchEnabled = true
|
|
let inflight: Promise<void> | null = null
|
|
|
|
export function disableCatalogFetchForTests(): void {
|
|
catalogFetchEnabled = false
|
|
}
|
|
|
|
export function resetFlowCatalogForTests(): void {
|
|
cidrs = []
|
|
asnPurpose = new Map()
|
|
fetchedAt = 0
|
|
inflight = null
|
|
}
|
|
|
|
export function seedFlowCatalogForTests(input: {
|
|
cidrs?: Array<{ cidr: string; purpose: string }>
|
|
asns?: Array<{ asn: number; purpose: string }>
|
|
}): void {
|
|
cidrs = (input.cidrs ?? [])
|
|
.map((c) => ({ cidr: c.cidr, purpose: c.purpose, prefixLen: parseCidrV4(c.cidr)?.prefixLen ?? 0 }))
|
|
.sort((a, b) => b.prefixLen - a.prefixLen)
|
|
asnPurpose = new Map((input.asns ?? []).map((a) => [a.asn, a.purpose]))
|
|
fetchedAt = Date.now()
|
|
}
|
|
|
|
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
|
const p = purpose.toLowerCase()
|
|
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
|
|
if (/streaming|youtube|netflix|twitch|video|spotify|instagram/.test(p)) return "Видео / стриминг"
|
|
if (/cdn|cloudflare|akamai|fastly|hetzner|ovh|apple/.test(p)) return "CDN"
|
|
if (/voip|discord|zoom/.test(p)) return "Голос"
|
|
if (/openai|chatgpt|\bai\b/.test(p)) return "ИИ"
|
|
if (/quad9|opendns/.test(p)) return "DNS"
|
|
if (/веб|web|google|github|paypal|vk|linkedin/.test(p)) return "Веб"
|
|
const app = applicationName(proto, dstPort, srcPort)
|
|
if (app === "DNS" || app === "SSH" || app === "BGP") return app
|
|
if (app === "GRE" || app === "ESP" || app === "WireGuard") return "Туннель"
|
|
return OTHER_SERVICE
|
|
}
|
|
|
|
function matchCidr(ip: string): CatalogCidr | null {
|
|
for (const row of cidrs) {
|
|
if (ipInCidrV4(ip, row.cidr)) return row
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function classifyFlowDst(
|
|
dst: string,
|
|
proto: number,
|
|
dstPort: number,
|
|
srcPort: number,
|
|
ripe: FlowIpMeta | null,
|
|
opts?: { ignoreTunnelProto?: boolean },
|
|
): FlowClassification {
|
|
if (!opts?.ignoreTunnelProto) {
|
|
if (proto === 47) return { service: "GRE", category: "Туннель" }
|
|
if (proto === 50) return { service: "ESP", category: "Туннель" }
|
|
}
|
|
const app = applicationName(proto, dstPort, srcPort)
|
|
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
|
const hit = matchCidr(dst)
|
|
const holder = ripe?.holder ?? ""
|
|
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, holder, proto, dstPort, srcPort)
|
|
const asnName = ripe?.asn ? asnPurpose.get(ripe.asn) : undefined
|
|
const service = (hit?.purpose || brand?.service || asnName || OTHER_SERVICE).trim() || OTHER_SERVICE
|
|
const category = hit
|
|
? categoryFromPurpose(hit.purpose, proto, dstPort, srcPort)
|
|
: (brand?.category || categoryFromPurpose(asnName || "", proto, dstPort, srcPort))
|
|
return { service, category }
|
|
}
|
|
|
|
async function fetchCatalog(): Promise<void> {
|
|
if (!catalogFetchEnabled) return
|
|
if (Date.now() - fetchedAt < CATALOG_TTL_MS) return
|
|
if (inflight) return inflight
|
|
inflight = (async () => {
|
|
try {
|
|
const row = (await db.select().from(evobgpSettings).limit(1))[0]
|
|
if (!row?.enabled) return
|
|
const root = String(row.baseUrl ?? "").replace(/\/+$/, "")
|
|
const token = String(row.apiKey ?? "").replace(/^Bearer\s+/i, "").trim()
|
|
if (!root || !token) return
|
|
const ac = new AbortController()
|
|
const t = setTimeout(() => ac.abort(), 20_000)
|
|
try {
|
|
const res = await fetch(`${root}/v1/router-lists/catalog`, {
|
|
headers: { Authorization: `Bearer ${token}`, Accept: "application/json" },
|
|
signal: ac.signal,
|
|
})
|
|
if (!res.ok) return
|
|
const catalog = await res.json() as {
|
|
modules?: { items?: Array<{ id: string; name: string }> }
|
|
ip_ranges?: { items?: Array<{ module_id: string; entry: { prefix: string } }> }
|
|
asns?: { items?: Array<{ module_id: string; entry: { asn: number } }> }
|
|
}
|
|
const mods = new Map((catalog.modules?.items ?? []).map((m) => [m.id, m.name]))
|
|
const next: CatalogCidr[] = []
|
|
for (const item of catalog.ip_ranges?.items ?? []) {
|
|
const prefix = String(item.entry?.prefix ?? "").trim()
|
|
const purpose = mods.get(item.module_id) ?? ""
|
|
const parsed = parseCidrV4(prefix)
|
|
if (!prefix || !parsed) continue
|
|
next.push({ cidr: prefix, purpose, prefixLen: parsed.prefixLen })
|
|
}
|
|
next.sort((a, b) => b.prefixLen - a.prefixLen)
|
|
const nextAsn = new Map<number, string>()
|
|
for (const item of catalog.asns?.items ?? []) {
|
|
const purpose = mods.get(item.module_id)
|
|
const asn = Number(item.entry?.asn)
|
|
if (purpose && Number.isFinite(asn) && asn > 0) nextAsn.set(asn, purpose)
|
|
}
|
|
cidrs = next
|
|
asnPurpose = nextAsn
|
|
fetchedAt = Date.now()
|
|
} finally {
|
|
clearTimeout(t)
|
|
}
|
|
} catch {
|
|
/* catalog optional */
|
|
} finally {
|
|
inflight = null
|
|
}
|
|
})()
|
|
return inflight
|
|
}
|
|
|
|
/** Background refresh — analytics never awaits the HTTP. */
|
|
export function refreshFlowCatalogInBackground(): void {
|
|
void fetchCatalog()
|
|
}
|