Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d39e3454aa | ||
|
|
a8f2055c77 |
@@ -1,11 +1,14 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {
|
||||
brandByAsn,
|
||||
brandByHolder,
|
||||
countryFromHolder,
|
||||
isSteamGamePort,
|
||||
lookupBrand,
|
||||
OTHER_SERVICE,
|
||||
isNamedInternetService,
|
||||
mapServiceNodeId,
|
||||
resolveFlowBrand,
|
||||
resolveRipeCountry,
|
||||
} from "./traffic-flow-brands.js"
|
||||
|
||||
@@ -39,4 +42,35 @@ assert.equal(isNamedInternetService("DNS", "DNS"), false)
|
||||
assert.equal(mapServiceNodeId("AWS"), "svc:aws")
|
||||
assert.equal(mapServiceNodeId("Cloudflare"), "svc:cloudflare")
|
||||
|
||||
assert.equal(brandByAsn(714)?.service, "Apple")
|
||||
assert.equal(brandByAsn(714)?.category, "CDN")
|
||||
assert.equal(brandByAsn(36459)?.service, "GitHub")
|
||||
assert.equal(brandByAsn(395701)?.service, "Epic")
|
||||
assert.equal(brandByAsn(6507)?.service, "Riot")
|
||||
assert.equal(brandByAsn(33353)?.service, "PlayStation")
|
||||
assert.equal(brandByAsn(14061)?.service, "DigitalOcean")
|
||||
assert.equal(brandByAsn(24940)?.service, "Hetzner")
|
||||
assert.equal(brandByAsn(8403)?.service, "Spotify")
|
||||
assert.equal(brandByAsn(13414)?.service, "X")
|
||||
assert.equal(brandByAsn(47541)?.service, "VK")
|
||||
assert.equal(brandByAsn(47764)?.service, "VK")
|
||||
assert.equal(brandByAsn(30103)?.service, "Zoom")
|
||||
assert.equal(brandByAsn(19281)?.service, "Quad9")
|
||||
assert.equal(brandByAsn(9059)?.service, "AWS")
|
||||
assert.equal(brandByAsn(396982)?.service, "Google")
|
||||
assert.equal(brandByAsn(400645)?.service, "ChatGPT")
|
||||
|
||||
assert.equal(brandByHolder("VALVE-CORPORATION")?.service, "Steam")
|
||||
assert.equal(brandByHolder("OpenAI, LLC")?.service, "ChatGPT")
|
||||
assert.equal(brandByHolder("YouTube LLC")?.service, "YouTube")
|
||||
assert.equal(brandByHolder("AMAZON-AES - Amazon.com, Inc."), null)
|
||||
|
||||
assert.equal(isSteamGamePort(17, 27015, 50000), true)
|
||||
assert.equal(isSteamGamePort(6, 443, 50000), false)
|
||||
|
||||
assert.equal(resolveFlowBrand("104.18.35.51", 32590, "VALVE-CORPORATION", 6, 443, 1)?.service, "Cloudflare")
|
||||
assert.equal(resolveFlowBrand("203.0.113.9", 32590, "", 17, 27015, 50000)?.service, "Steam")
|
||||
assert.equal(resolveRipeCountry("", 9059, ""), "IE")
|
||||
assert.equal(resolveRipeCountry("", 24940, ""), "DE")
|
||||
|
||||
console.log("traffic-flow-brands.test.ts: ok")
|
||||
|
||||
@@ -7,59 +7,162 @@ export interface BrandHit {
|
||||
category: string
|
||||
}
|
||||
|
||||
const CDN = { category: "CDN" } as const
|
||||
const WEB = { category: "Веб" } as const
|
||||
const VIDEO = { category: "Видео / стриминг" } as const
|
||||
const GAMES = { category: "Игры" } as const
|
||||
const VOICE = { category: "Голос" } as const
|
||||
const AI = { category: "ИИ" } as const
|
||||
const DNS = { category: "DNS" } as const
|
||||
|
||||
const CLOUDFLARE: BrandHit = { service: "Cloudflare", ...CDN }
|
||||
const FASTLY: BrandHit = { service: "Fastly", ...CDN }
|
||||
const AKAMAI: BrandHit = { service: "Akamai", ...CDN }
|
||||
const AWS: BrandHit = { service: "AWS", ...CDN }
|
||||
const MICROSOFT: BrandHit = { service: "Microsoft", ...CDN }
|
||||
const YANDEX: BrandHit = { service: "Yandex", ...CDN }
|
||||
const APPLE: BrandHit = { service: "Apple", ...CDN }
|
||||
const DIGITALOCEAN: BrandHit = { service: "DigitalOcean", ...CDN }
|
||||
const HETZNER: BrandHit = { service: "Hetzner", ...CDN }
|
||||
const OVH: BrandHit = { service: "OVH", ...CDN }
|
||||
const ORACLE: BrandHit = { service: "Oracle", ...CDN }
|
||||
const LINODE: BrandHit = { service: "Linode", ...CDN }
|
||||
const VULTR: BrandHit = { service: "Vultr", ...CDN }
|
||||
const SCALEWAY: BrandHit = { service: "Scaleway", ...CDN }
|
||||
const IBM_CLOUD: BrandHit = { service: "IBM Cloud", ...CDN }
|
||||
const ALIBABA: BrandHit = { service: "Alibaba", ...CDN }
|
||||
const TENCENT: BrandHit = { service: "Tencent", ...CDN }
|
||||
const GCORE: BrandHit = { service: "G-Core", ...CDN }
|
||||
const CDN77: BrandHit = { service: "CDN77", ...CDN }
|
||||
const SELECTEL: BrandHit = { service: "Selectel", ...CDN }
|
||||
const TIMEWEB: BrandHit = { service: "Timeweb", ...CDN }
|
||||
const BEGET: BrandHit = { service: "Beget", ...CDN }
|
||||
const DDOS_GUARD: BrandHit = { service: "DDoS-Guard", ...CDN }
|
||||
const META: BrandHit = { service: "Meta", ...CDN }
|
||||
|
||||
const GOOGLE: BrandHit = { service: "Google", ...WEB }
|
||||
const GITHUB: BrandHit = { service: "GitHub", ...WEB }
|
||||
const GITLAB: BrandHit = { service: "GitLab", ...WEB }
|
||||
const X: BrandHit = { service: "X", ...WEB }
|
||||
const LINKEDIN: BrandHit = { service: "LinkedIn", ...WEB }
|
||||
const VK: BrandHit = { service: "VK", ...WEB }
|
||||
const REDDIT: BrandHit = { service: "Reddit", ...WEB }
|
||||
const DROPBOX: BrandHit = { service: "Dropbox", ...WEB }
|
||||
const SNAP: BrandHit = { service: "Snap", ...WEB }
|
||||
const WIKIPEDIA: BrandHit = { service: "Wikipedia", ...WEB }
|
||||
const PAYPAL: BrandHit = { service: "PayPal", ...WEB }
|
||||
const SALESFORCE: BrandHit = { service: "Salesforce", ...WEB }
|
||||
|
||||
const YOUTUBE: BrandHit = { service: "YouTube", ...VIDEO }
|
||||
const NETFLIX: BrandHit = { service: "Netflix", ...VIDEO }
|
||||
const TWITCH: BrandHit = { service: "Twitch", ...VIDEO }
|
||||
const TIKTOK: BrandHit = { service: "TikTok", ...VIDEO }
|
||||
const SPOTIFY: BrandHit = { service: "Spotify", ...VIDEO }
|
||||
|
||||
const STEAM: BrandHit = { service: "Steam", ...GAMES }
|
||||
const BLIZZARD: BrandHit = { service: "Blizzard", ...GAMES }
|
||||
const EPIC: BrandHit = { service: "Epic", ...GAMES }
|
||||
const RIOT: BrandHit = { service: "Riot", ...GAMES }
|
||||
const PLAYSTATION: BrandHit = { service: "PlayStation", ...GAMES }
|
||||
const ROBLOX: BrandHit = { service: "Roblox", ...GAMES }
|
||||
const UBISOFT: BrandHit = { service: "Ubisoft", ...GAMES }
|
||||
|
||||
const DISCORD: BrandHit = { service: "Discord", ...VOICE }
|
||||
const TELEGRAM: BrandHit = { service: "Telegram", ...VOICE }
|
||||
const ZOOM: BrandHit = { service: "Zoom", ...VOICE }
|
||||
|
||||
const CHATGPT: BrandHit = { service: "ChatGPT", ...AI }
|
||||
|
||||
const QUAD9: BrandHit = { service: "Quad9", ...DNS }
|
||||
const OPENDNS: BrandHit = { service: "OpenDNS", ...DNS }
|
||||
|
||||
function brandEntries(hit: BrandHit, asns: number[]): Array<[number, BrandHit]> {
|
||||
return asns.map((asn) => [asn, hit])
|
||||
}
|
||||
|
||||
function hqEntries(cc: string, asns: number[]): Array<[number, string]> {
|
||||
return asns.map((asn) => [asn, cc])
|
||||
}
|
||||
|
||||
const ASN_BRANDS = new Map<number, BrandHit>([
|
||||
[13335, { service: "Cloudflare", category: "CDN" }],
|
||||
[209242, { service: "Cloudflare", category: "CDN" }],
|
||||
[54113, { service: "Fastly", category: "CDN" }],
|
||||
[20940, { service: "Akamai", category: "CDN" }],
|
||||
[16509, { service: "AWS", category: "CDN" }],
|
||||
[14618, { service: "AWS", category: "CDN" }],
|
||||
[8075, { service: "Microsoft", category: "CDN" }],
|
||||
[13238, { service: "Yandex", category: "CDN" }],
|
||||
[32590, { service: "Steam", category: "Игры" }],
|
||||
[57976, { service: "Blizzard", category: "Игры" }],
|
||||
[2906, { service: "Netflix", category: "Видео / стриминг" }],
|
||||
[40027, { service: "Netflix", category: "Видео / стриминг" }],
|
||||
[15169, { service: "Google", category: "Веб" }],
|
||||
[36040, { service: "YouTube", category: "Видео / стриминг" }],
|
||||
[46489, { service: "Twitch", category: "Видео / стриминг" }],
|
||||
[401115, { service: "ChatGPT", category: "ИИ" }],
|
||||
[49544, { service: "Discord", category: "Голос" }],
|
||||
[62041, { service: "Telegram", category: "Голос" }],
|
||||
[59930, { service: "Telegram", category: "Голос" }],
|
||||
[211157, { service: "Telegram", category: "Голос" }],
|
||||
[32934, { service: "Meta", category: "CDN" }],
|
||||
[396986, { service: "TikTok", category: "Видео / стриминг" }],
|
||||
...brandEntries(CLOUDFLARE, [13335, 209242]),
|
||||
...brandEntries(FASTLY, [54113]),
|
||||
...brandEntries(AKAMAI, [20940, 16625, 32787, 35994, 16702, 24319]),
|
||||
...brandEntries(AWS, [16509, 14618, 8987, 7224, 9059]),
|
||||
...brandEntries(MICROSOFT, [8075, 8068, 8069, 8070]),
|
||||
...brandEntries(YANDEX, [13238]),
|
||||
...brandEntries(APPLE, [714, 6185]),
|
||||
...brandEntries(DIGITALOCEAN, [14061]),
|
||||
...brandEntries(HETZNER, [24940, 213230]),
|
||||
...brandEntries(OVH, [16276]),
|
||||
...brandEntries(ORACLE, [31898]),
|
||||
...brandEntries(LINODE, [63949]),
|
||||
...brandEntries(VULTR, [20473]),
|
||||
...brandEntries(SCALEWAY, [12876]),
|
||||
...brandEntries(IBM_CLOUD, [36351]),
|
||||
...brandEntries(ALIBABA, [45102]),
|
||||
...brandEntries(TENCENT, [132203]),
|
||||
...brandEntries(GCORE, [199524]),
|
||||
...brandEntries(CDN77, [60068]),
|
||||
...brandEntries(SELECTEL, [50340, 49505]),
|
||||
...brandEntries(TIMEWEB, [9123]),
|
||||
...brandEntries(BEGET, [198610]),
|
||||
...brandEntries(DDOS_GUARD, [57724]),
|
||||
...brandEntries(META, [32934, 63293, 54115]),
|
||||
...brandEntries(GOOGLE, [15169, 396982]),
|
||||
...brandEntries(GITHUB, [36459]),
|
||||
...brandEntries(GITLAB, [54876]),
|
||||
...brandEntries(X, [13414]),
|
||||
...brandEntries(LINKEDIN, [14413, 40793]),
|
||||
...brandEntries(VK, [47541, 47764]),
|
||||
...brandEntries(REDDIT, [394706]),
|
||||
...brandEntries(DROPBOX, [19679]),
|
||||
...brandEntries(SNAP, [19750]),
|
||||
...brandEntries(WIKIPEDIA, [14907]),
|
||||
...brandEntries(PAYPAL, [17012, 26101]),
|
||||
...brandEntries(SALESFORCE, [14340]),
|
||||
...brandEntries(YOUTUBE, [36040, 43515]),
|
||||
...brandEntries(NETFLIX, [2906, 40027]),
|
||||
...brandEntries(TWITCH, [46489]),
|
||||
...brandEntries(TIKTOK, [396986, 138699]),
|
||||
...brandEntries(SPOTIFY, [8403, 34081]),
|
||||
...brandEntries(STEAM, [32590]),
|
||||
...brandEntries(BLIZZARD, [57976]),
|
||||
...brandEntries(EPIC, [395701]),
|
||||
...brandEntries(RIOT, [6507, 62830]),
|
||||
...brandEntries(PLAYSTATION, [33353]),
|
||||
...brandEntries(ROBLOX, [22697]),
|
||||
...brandEntries(UBISOFT, [197922]),
|
||||
...brandEntries(DISCORD, [49544, 394141]),
|
||||
...brandEntries(TELEGRAM, [62041, 59930, 211157]),
|
||||
...brandEntries(ZOOM, [30103]),
|
||||
...brandEntries(CHATGPT, [401115, 400645]),
|
||||
...brandEntries(QUAD9, [19281]),
|
||||
...brandEntries(OPENDNS, [36692]),
|
||||
])
|
||||
|
||||
const ASN_HQ_COUNTRY = new Map<number, string>([
|
||||
[13335, "US"],
|
||||
[209242, "US"],
|
||||
[54113, "US"],
|
||||
[20940, "US"],
|
||||
[16509, "US"],
|
||||
[14618, "US"],
|
||||
[8075, "US"],
|
||||
[15169, "US"],
|
||||
[32590, "US"],
|
||||
[57976, "US"],
|
||||
[2906, "US"],
|
||||
[40027, "US"],
|
||||
[36040, "US"],
|
||||
[46489, "US"],
|
||||
[401115, "US"],
|
||||
[49544, "US"],
|
||||
[32934, "US"],
|
||||
[13238, "RU"],
|
||||
[62041, "NL"],
|
||||
[59930, "NL"],
|
||||
[211157, "NL"],
|
||||
...hqEntries("US", [
|
||||
13335, 209242, 54113, 20940, 16625, 32787, 35994, 16702, 24319,
|
||||
16509, 14618, 8987, 7224, 8075, 8068, 8069, 8070, 15169, 396982,
|
||||
32590, 57976, 2906, 40027, 36040, 43515, 46489, 401115, 400645,
|
||||
49544, 394141, 32934, 63293, 54115, 714, 6185, 36459, 54876, 14061,
|
||||
31898, 63949, 20473, 36351, 13414, 14413, 40793, 394706, 19679, 19750,
|
||||
14907, 17012, 26101, 14340, 30103, 36692, 395701, 6507, 62830, 33353, 22697,
|
||||
]),
|
||||
...hqEntries("IE", [9059]),
|
||||
...hqEntries("SG", [138699]),
|
||||
...hqEntries("DE", [24940, 213230]),
|
||||
...hqEntries("FR", [16276, 12876, 197922]),
|
||||
...hqEntries("CN", [45102, 132203]),
|
||||
...hqEntries("LU", [199524]),
|
||||
...hqEntries("CZ", [60068]),
|
||||
...hqEntries("RU", [13238, 50340, 49505, 9123, 198610, 57724, 47541, 47764]),
|
||||
...hqEntries("SE", [8403, 34081]),
|
||||
...hqEntries("NL", [62041, 59930, 211157]),
|
||||
...hqEntries("CH", [19281]),
|
||||
])
|
||||
|
||||
const GOOGLE: BrandHit = { service: "Google", category: "Веб" }
|
||||
const CLOUDFLARE: BrandHit = { service: "Cloudflare", category: "CDN" }
|
||||
const YOUTUBE: BrandHit = { service: "YouTube", category: "Видео / стриминг" }
|
||||
|
||||
const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||
{ cidr: "104.16.0.0/13", prefixLen: 13, hit: CLOUDFLARE },
|
||||
{ cidr: "104.24.0.0/14", prefixLen: 14, hit: CLOUDFLARE },
|
||||
@@ -75,8 +178,25 @@ const CIDR_BRANDS: Array<{ cidr: string; prefixLen: number; hit: BrandHit }> = [
|
||||
{ cidr: "208.117.224.0/19", prefixLen: 19, hit: YOUTUBE },
|
||||
].sort((a, b) => b.prefixLen - a.prefixLen)
|
||||
|
||||
const HOLDER_BRANDS: Array<{ re: RegExp; hit: BrandHit }> = [
|
||||
{ re: /youtube/i, hit: YOUTUBE },
|
||||
{ re: /valve|\bsteam\b/i, hit: STEAM },
|
||||
{ re: /blizzard|battle.?net/i, hit: BLIZZARD },
|
||||
{ re: /openai/i, hit: CHATGPT },
|
||||
{ re: /riot games/i, hit: RIOT },
|
||||
{ re: /epic games/i, hit: EPIC },
|
||||
{ re: /\bapple\b/i, hit: APPLE },
|
||||
{ re: /github/i, hit: GITHUB },
|
||||
{ re: /spotify/i, hit: SPOTIFY },
|
||||
{ re: /twitter|\bx corp\b/i, hit: X },
|
||||
{ re: /dropbox/i, hit: DROPBOX },
|
||||
{ re: /akamai/i, hit: AKAMAI },
|
||||
]
|
||||
|
||||
const NON_ISO = new Set(["EU", "AP", "ZZ", "XX", "A1", "A2", "O1"])
|
||||
|
||||
const STEAM_ASN = 32590
|
||||
|
||||
export function isIsoCountry(code: string): boolean {
|
||||
const c = String(code ?? "").trim().toUpperCase()
|
||||
return /^[A-Z]{2}$/.test(c) && !NON_ISO.has(c)
|
||||
@@ -114,10 +234,51 @@ export function brandByCidr(ip: string): BrandHit | null {
|
||||
return null
|
||||
}
|
||||
|
||||
export function brandByHolder(holder: string): BrandHit | null {
|
||||
const h = String(holder ?? "").trim()
|
||||
if (!h) return null
|
||||
for (const row of HOLDER_BRANDS) {
|
||||
if (row.re.test(h)) return row.hit
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Игровые порты Steam — только вместе с AS32590, никогда :80/:443. */
|
||||
export function isSteamGamePort(proto: number, dstPort: number, srcPort: number): boolean {
|
||||
if (proto !== 6 && proto !== 17) return false
|
||||
const port = dstPort || srcPort
|
||||
if (!port || port === 80 || port === 443) return false
|
||||
if (port === 4380 || port === 3478) return true
|
||||
return port >= 27000 && port <= 27100
|
||||
}
|
||||
|
||||
export function lookupBrand(ip: string, asn: number): BrandHit | null {
|
||||
return brandByCidr(ip) || brandByAsn(asn)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare CIDR бьёт holder (витрина на CF не становится Steam).
|
||||
* Holder (YouTube и др.) бьёт остальные CIDR/ASN.
|
||||
* Порты Steam — только AS32590 и не выше Cloudflare CIDR.
|
||||
*/
|
||||
export function resolveFlowBrand(
|
||||
ip: string,
|
||||
asn: number,
|
||||
holder: string,
|
||||
proto = 0,
|
||||
dstPort = 0,
|
||||
srcPort = 0,
|
||||
): BrandHit | null {
|
||||
const cidrBrand = brandByCidr(ip)
|
||||
if (cidrBrand?.service === "Cloudflare") return cidrBrand
|
||||
const holderBrand = brandByHolder(holder)
|
||||
if (holderBrand) return holderBrand
|
||||
const fromLookup = cidrBrand || brandByAsn(asn)
|
||||
if (fromLookup) return fromLookup
|
||||
if (asn === STEAM_ASN && isSteamGamePort(proto, dstPort, srcPort)) return STEAM
|
||||
return null
|
||||
}
|
||||
|
||||
const SKIP_MAP_SERVICES = new Set([
|
||||
OTHER_SERVICE,
|
||||
"GRE",
|
||||
|
||||
@@ -53,6 +53,71 @@ const youtube = classifyFlowDst("173.194.160.163", 6, 443, 1, {
|
||||
assert.equal(youtube.service, "YouTube")
|
||||
assert.equal(youtube.category, "Видео / стриминг")
|
||||
|
||||
const valve = classifyFlowDst("203.0.113.40", 17, 27015, 50000, {
|
||||
prefix: "203.0.113.0/24",
|
||||
asn: 64501,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "VALVE-CORPORATION",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(valve.service, "Steam")
|
||||
assert.equal(valve.category, "Игры")
|
||||
|
||||
const openaiHolder = classifyFlowDst("203.0.113.41", 6, 443, 1, {
|
||||
prefix: "203.0.113.0/24",
|
||||
asn: 64502,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "OPENAI, US",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(openaiHolder.service, "ChatGPT")
|
||||
assert.equal(openaiHolder.category, "ИИ")
|
||||
|
||||
const cfNotSteam = classifyFlowDst("104.18.35.51", 6, 443, 1, {
|
||||
prefix: "104.18.0.0/16",
|
||||
asn: 32590,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "VALVE-CORPORATION",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(cfNotSteam.service, "Cloudflare")
|
||||
assert.notEqual(cfNotSteam.service, "Steam")
|
||||
|
||||
const awsIeu = classifyFlowDst("203.0.113.42", 6, 443, 1, {
|
||||
prefix: "203.0.113.0/24",
|
||||
asn: 9059,
|
||||
country: "IE",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "AMAZON-02",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(awsIeu.service, "AWS")
|
||||
assert.equal(awsIeu.category, "CDN")
|
||||
|
||||
const googleCloud = classifyFlowDst("203.0.113.43", 6, 443, 1, {
|
||||
prefix: "203.0.113.0/24",
|
||||
asn: 396982,
|
||||
country: "US",
|
||||
lat: null,
|
||||
lng: null,
|
||||
holder: "GOOGLE-CLOUD",
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
assert.equal(googleCloud.service, "Google")
|
||||
assert.equal(googleCloud.category, "Веб")
|
||||
|
||||
const gre = classifyFlowDst("198.51.100.1", 47, 0, 0, null)
|
||||
assert.equal(gre.service, "GRE")
|
||||
assert.equal(gre.category, "Туннель")
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { lookupBrand, OTHER_SERVICE } from "./traffic-flow-brands.js"
|
||||
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"
|
||||
@@ -47,12 +47,13 @@ export function seedFlowCatalogForTests(input: {
|
||||
|
||||
export function categoryFromPurpose(purpose: string, proto: number, dstPort: number, srcPort: number): string {
|
||||
const p = purpose.toLowerCase()
|
||||
if (/gaming|steam|epic|riot/.test(p)) return "Игры"
|
||||
if (/streaming|youtube|netflix|twitch|video/.test(p)) return "Видео / стриминг"
|
||||
if (/cdn|cloudflare|akamai|fastly/.test(p)) return "CDN"
|
||||
if (/gaming|steam|epic|riot|playstation|roblox|ubisoft/.test(p)) return "Игры"
|
||||
if (/streaming|youtube|netflix|twitch|video|spotify/.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 (/веб|web|google/.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 "Туннель"
|
||||
@@ -79,10 +80,7 @@ export function classifyFlowDst(
|
||||
if (app === "WireGuard") return { service: "WireGuard", category: "Туннель" }
|
||||
const hit = matchCidr(dst)
|
||||
const holder = ripe?.holder ?? ""
|
||||
const youtubeHolder = /youtube/i.test(holder)
|
||||
const brand = youtubeHolder
|
||||
? { service: "YouTube", category: "Видео / стриминг" }
|
||||
: lookupBrand(dst, ripe?.asn ?? 0)
|
||||
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
|
||||
|
||||
@@ -4,7 +4,13 @@ import {
|
||||
ingestParsedFlowsForServerForTests,
|
||||
resetFlowRingsForTests,
|
||||
} from "./traffic-flow-ingest.js"
|
||||
import { buildFlowMapHops, resetFlowMapHopsCacheForTests } from "./traffic-flow-map-hops.js"
|
||||
import {
|
||||
buildFlowMapHops,
|
||||
MAP_SERVICE_MIN_NODES,
|
||||
MAP_SERVICE_NODE_CAP,
|
||||
pickMapServices,
|
||||
resetFlowMapHopsCacheForTests,
|
||||
} from "./traffic-flow-map-hops.js"
|
||||
import { withPgOrSkip } from "../test/pg.js"
|
||||
import { seedFlowTopologyForTests, type FlowTopology } from "./traffic-flow-topology.js"
|
||||
import { disableCatalogFetchForTests, resetFlowCatalogForTests } from "./traffic-flow-classify.js"
|
||||
@@ -15,6 +21,55 @@ import {
|
||||
seedRipeCacheForTests,
|
||||
} from "./traffic-flow-ripe.js"
|
||||
|
||||
{
|
||||
const googleOnly = pickMapServices(
|
||||
[{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 1 }],
|
||||
5,
|
||||
)
|
||||
assert.equal(googleOnly.length, 1)
|
||||
assert.equal(googleOnly[0]?.share, 1)
|
||||
|
||||
const twoNamed = pickMapServices(
|
||||
[
|
||||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 400, bps: 0, share: 0.5 },
|
||||
{ id: "svc:cloudflare", label: "Cloudflare", category: "CDN", bytes: 400, bps: 0, share: 0.5 },
|
||||
],
|
||||
5,
|
||||
)
|
||||
assert.equal(twoNamed.length, 2)
|
||||
|
||||
const tinyTail = pickMapServices(
|
||||
[
|
||||
{ id: "svc:google", label: "Google", category: "Веб", bytes: 9000, bps: 0, share: 0.9 },
|
||||
...Array.from({ length: 9 }, (_, i) => ({
|
||||
id: `svc:t${i}`,
|
||||
label: `T${i}`,
|
||||
category: "Веб",
|
||||
bytes: 100,
|
||||
bps: 0,
|
||||
share: 0.01,
|
||||
})),
|
||||
],
|
||||
5,
|
||||
)
|
||||
assert.equal(tinyTail.length, MAP_SERVICE_MIN_NODES)
|
||||
assert.equal(tinyTail.at(-1)?.id, "svc:t6")
|
||||
|
||||
const allOff = pickMapServices(
|
||||
Array.from({ length: 25 }, (_, i) => ({
|
||||
id: `svc:n${i}`,
|
||||
label: `N${i}`,
|
||||
category: "Веб",
|
||||
bytes: 25 - i,
|
||||
bps: 0,
|
||||
share: 0.04,
|
||||
})),
|
||||
0,
|
||||
)
|
||||
assert.equal(allOff.length, MAP_SERVICE_NODE_CAP)
|
||||
console.log("traffic-flow-map-hops.test.ts: pickMapServices ok")
|
||||
}
|
||||
|
||||
if (!(await withPgOrSkip())) {
|
||||
console.log("traffic-flow-map-hops.test.ts: skip")
|
||||
process.exit(0)
|
||||
@@ -178,6 +233,19 @@ function googleRipe() {
|
||||
})
|
||||
}
|
||||
|
||||
function seedRipeAsn(ip: string, asn: number, holder: string) {
|
||||
seedRipeCacheForTests({
|
||||
prefix: `${ip}/32`,
|
||||
asn,
|
||||
country: "US",
|
||||
lat: 37.4,
|
||||
lng: -122.1,
|
||||
holder,
|
||||
ok: true,
|
||||
fetchedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
function payloadFlow(dst: string, bytes: number) {
|
||||
return {
|
||||
src: "10.100.1.17",
|
||||
@@ -241,10 +309,106 @@ try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const four = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||
assert.equal(four.totalBytes, 10_000)
|
||||
assert.ok(!(four.services ?? []).some((s) => s.id === "svc:google"), "Google < 5% hidden")
|
||||
const googleFour = four.services?.find((s) => s.id === "svc:google")
|
||||
assert.ok(googleFour, "единственный бренд виден при 4% от окна")
|
||||
assert.ok(googleFour.share >= 0.99, "доля среди брендов ≈ 1")
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const off = await buildFlowMapHops({ minutes: 5, minSharePct: 0 })
|
||||
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google 4%")
|
||||
assert.ok(off.services?.some((s) => s.id === "svc:google"), "порог 0 показывает Google")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
])
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
payloadFlow("8.8.8.8", 400),
|
||||
payloadFlow("104.18.35.51", 400),
|
||||
payloadFlow("203.0.113.50", 9200),
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const two = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||
const googleTwo = two.services?.find((s) => s.id === "svc:google")
|
||||
const cfTwo = two.services?.find((s) => s.id === "svc:cloudflare")
|
||||
assert.ok(googleTwo, "Google среди брендов")
|
||||
assert.ok(cfTwo, "Cloudflare среди брендов")
|
||||
assert.ok(googleTwo.share >= 0.05)
|
||||
assert.ok(cfTwo.share >= 0.05)
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
])
|
||||
seedRipeAsn("162.254.192.71", 32590, "VALVE-CORP")
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
payloadFlow("162.254.192.71", 2000),
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const steam = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||
assert.ok(steam.services?.some((s) => s.id === "svc:steam"), "Steam AS32590 на карте")
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
}
|
||||
|
||||
const smallBrands: Array<{ ip: string; asn: number; holder: string; bytes: number; id: string }> = [
|
||||
{ ip: "203.0.113.1", asn: 714, holder: "APPLE-ENGINEERING", bytes: 400, id: "svc:apple" },
|
||||
{ ip: "203.0.113.2", asn: 36459, holder: "GITHUB", bytes: 390, id: "svc:github" },
|
||||
{ ip: "203.0.113.3", asn: 54876, holder: "GITLAB", bytes: 380, id: "svc:gitlab" },
|
||||
{ ip: "203.0.113.4", asn: 8403, holder: "SPOTIFY", bytes: 370, id: "svc:spotify" },
|
||||
{ ip: "203.0.113.5", asn: 13414, holder: "TWITTER", bytes: 360, id: "svc:x" },
|
||||
{ ip: "203.0.113.6", asn: 47541, holder: "VKONTAKTE", bytes: 350, id: "svc:vk" },
|
||||
{ ip: "203.0.113.7", asn: 30103, holder: "ZOOM", bytes: 340, id: "svc:zoom" },
|
||||
{ ip: "203.0.113.8", asn: 395701, holder: "EPIC-GAMES", bytes: 330, id: "svc:epic" },
|
||||
{ ip: "203.0.113.9", asn: 6507, holder: "RIOT-GAMES", bytes: 320, id: "svc:riot" },
|
||||
]
|
||||
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
resetRipeCacheForTests()
|
||||
disableRipeEnqueueForTests()
|
||||
seedFlowTopologyForTests(topo)
|
||||
rememberServerIfaces(7, [
|
||||
{ ".id": "*2", name: "gre-client" },
|
||||
{ ".id": "*3", name: "gre-jh-en" },
|
||||
])
|
||||
googleRipe()
|
||||
for (const b of smallBrands) seedRipeAsn(b.ip, b.asn, b.holder)
|
||||
ingestParsedFlowsForServerForTests(7, [
|
||||
payloadFlow("8.8.8.8", 5000),
|
||||
...smallBrands.map((b) => payloadFlow(b.ip, b.bytes)),
|
||||
])
|
||||
try {
|
||||
resetFlowMapHopsCacheForTests()
|
||||
const top = await buildFlowMapHops({ minutes: 5, minSharePct: 5 })
|
||||
const ids = new Set((top.services ?? []).map((s) => s.id))
|
||||
assert.equal(top.services?.length, MAP_SERVICE_MIN_NODES, "топ-8 брендов на карте")
|
||||
assert.ok(ids.has("svc:google"))
|
||||
for (const b of smallBrands.slice(0, 7)) assert.ok(ids.has(b.id), b.id)
|
||||
assert.ok(!ids.has("svc:epic"), "хвост ниже ранга 8 скрыт")
|
||||
assert.ok(!ids.has("svc:riot"))
|
||||
} finally {
|
||||
resetFlowRingsForTests()
|
||||
resetIfaceCacheForTests()
|
||||
|
||||
@@ -5,8 +5,8 @@ import { userInterfaceBindings } from "../db/schema.js"
|
||||
import { applicationName, flowRowMatchesFilter } from "./traffic-flow-apps.js"
|
||||
import {
|
||||
isNamedInternetService,
|
||||
lookupBrand,
|
||||
mapServiceNodeId,
|
||||
resolveFlowBrand,
|
||||
} from "./traffic-flow-brands.js"
|
||||
import { dedupFlowRowsMaxBytes } from "./traffic-flow-dedup.js"
|
||||
import { getFlowListenerState, listFlowRowsForWindow } from "./traffic-flow-ingest.js"
|
||||
@@ -20,6 +20,8 @@ import { flowDataEpoch } from "./traffic-flow-engine.js"
|
||||
|
||||
export const DEFAULT_MAP_SERVICE_MIN_SHARE_PCT = 5
|
||||
export const MAP_SERVICE_NODE_CAP = 20
|
||||
/** Минимум узлов-брендов на карте, даже если доля ниже порога. */
|
||||
export const MAP_SERVICE_MIN_NODES = 8
|
||||
const HOPS_CACHE_TTL_MS = 2000
|
||||
|
||||
export interface FlowMapHopsQuery {
|
||||
@@ -99,6 +101,15 @@ export function clampMapServiceMinSharePct(n: unknown): number {
|
||||
return Math.min(100, Math.max(0, v))
|
||||
}
|
||||
|
||||
/** Доля среди именованных брендов; порог ИЛИ топ-N, затем cap. */
|
||||
export function pickMapServices(ranked: FlowMapService[], minSharePct: number): FlowMapService[] {
|
||||
if (minSharePct <= 0) return ranked.slice(0, MAP_SERVICE_NODE_CAP)
|
||||
const minShare = minSharePct / 100
|
||||
return ranked
|
||||
.filter((s, i) => s.share >= minShare || i < MAP_SERVICE_MIN_NODES)
|
||||
.slice(0, MAP_SERVICE_NODE_CAP)
|
||||
}
|
||||
|
||||
function hopsQueryKey(q: FlowMapHopsQuery, minSharePct: number): string {
|
||||
return JSON.stringify({
|
||||
epoch: flowDataEpoch(),
|
||||
@@ -174,10 +185,7 @@ function classifyMapDstLite(
|
||||
if (proto === 47 || proto === 50) return null
|
||||
const app = applicationName(proto, dstPort, srcPort)
|
||||
if (app === "WireGuard" || app === "DNS" || app === "SSH" || app === "BGP") return null
|
||||
if (/youtube/i.test(ripe?.holder ?? "")) {
|
||||
return { service: "YouTube", category: "Видео / стриминг" }
|
||||
}
|
||||
const brand = lookupBrand(dst, ripe?.asn ?? 0)
|
||||
const brand = resolveFlowBrand(dst, ripe?.asn ?? 0, ripe?.holder ?? "", proto, dstPort, srcPort)
|
||||
if (!brand || !isNamedInternetService(brand.service, brand.category)) return null
|
||||
return brand
|
||||
}
|
||||
@@ -439,21 +447,20 @@ async function buildFlowMapHopsUncached(q: FlowMapHopsQuery, minSharePct: number
|
||||
}
|
||||
}
|
||||
|
||||
const minShare = minSharePct / 100
|
||||
let services: FlowMapService[] = [...svcTotals.entries()]
|
||||
.map(([id, s]) => ({
|
||||
id,
|
||||
label: s.label,
|
||||
category: s.category,
|
||||
bytes: s.bytes,
|
||||
bps: (s.bytes * 8) / windowSec,
|
||||
share: totalBytes > 0 ? s.bytes / totalBytes : 0,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes)
|
||||
if (minSharePct > 0) {
|
||||
services = services.filter((s) => s.share >= minShare)
|
||||
}
|
||||
services = services.slice(0, MAP_SERVICE_NODE_CAP)
|
||||
const namedBytes = [...svcTotals.values()].reduce((n, s) => n + s.bytes, 0)
|
||||
const services = pickMapServices(
|
||||
[...svcTotals.entries()]
|
||||
.map(([id, s]) => ({
|
||||
id,
|
||||
label: s.label,
|
||||
category: s.category,
|
||||
bytes: s.bytes,
|
||||
bps: (s.bytes * 8) / windowSec,
|
||||
share: namedBytes > 0 ? s.bytes / namedBytes : 0,
|
||||
}))
|
||||
.sort((a, b) => b.bytes - a.bytes),
|
||||
minSharePct,
|
||||
)
|
||||
const keepSvc = new Set(services.map((s) => s.id))
|
||||
const serviceEdges: FlowMapServiceEdge[] = [...svcEdges.values()]
|
||||
.filter((e) => keepSvc.has(e.toId))
|
||||
|
||||
@@ -125,6 +125,117 @@ export function ServiceBrandIcon({ label, size = 22 }: { label: string; size?: n
|
||||
<path d="M13.4 4v9.1a3.3 3.3 0 1 1-2.8-3.3V7.2c1.6.9 3.2 1.4 5 1.5V5.4c-1.4-.1-2.7-.6-3.8-1.4H13.4Z" fill="#FE2C55" transform="translate(1.2 1)" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "apple":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M14.7 6.2c.8-.9 1.3-2.2 1.2-3.5-1.2.1-2.6.8-3.4 1.8-.8.9-1.5 2.2-1.3 3.5 1.3 0 2.6-.8 3.5-1.8Z" fill="#111" />
|
||||
<path d="M16.8 12.2c0-2.2 1.8-3.3 1.9-3.4-1.1-1.6-2.7-1.8-3.3-1.8-1.4-.1-2.7.8-3.4.8s-1.8-.8-3-.8c-1.5 0-3 .9-3.8 2.3-1.6 2.8-.4 7 1.2 9.3.8 1.1 1.7 2.3 2.9 2.3 1.2 0 1.6-.7 3-.7s1.8.7 3 .7 2-.1 2.9-2.2c1.1-1.5 1.5-3 1.5-3.1-.1 0-2.9-1.1-2.9-4.4Z" fill="#111" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "github":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#181717" />
|
||||
<path d="M12 6.4c-3.1 0-5.6 2.5-5.6 5.6 0 2.5 1.6 4.6 3.8 5.3.3.1.4-.1.4-.3v-1.1c-1.6.3-1.9-.7-1.9-.7-.3-.6-.6-.8-.6-.8-.5-.4 0-.4 0-.4.6 0 .9.6.9.6.5.9 1.4.6 1.7.5.1-.4.2-.6.4-.8-1.2-.1-2.5-.6-2.5-2.8 0-.6.2-1.1.6-1.5-.1-.1-.3-.7 0-1.4 0 0 .5-.2 1.6.6.5-.1 1-.2 1.5-.2s1 .1 1.5.2c1.1-.8 1.6-.6 1.6-.6.3.7.1 1.3 0 1.4.4.4.6.9.6 1.5 0 2.2-1.3 2.6-2.5 2.8.2.2.4.5.4 1.1v1.6c0 .2.1.4.4.3 2.2-.7 3.8-2.8 3.8-5.3 0-3.1-2.5-5.6-5.6-5.6Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "gitlab":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M12 19.2 8.4 8.4h7.2L12 19.2Z" fill="#E24329" />
|
||||
<path d="M12 19.2 8.4 8.4 5.2 16.2 12 19.2Z" fill="#FC6D26" />
|
||||
<path d="M12 19.2 15.6 8.4 18.8 16.2 12 19.2Z" fill="#FC6D26" />
|
||||
<path d="M5.2 16.2 3 8.4h5.4L5.2 16.2Z" fill="#FCA326" />
|
||||
<path d="M18.8 16.2 21 8.4h-5.4l3.2 7.8Z" fill="#FCA326" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "spotify":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#1DB954" />
|
||||
<path d="M7.2 10.4c3.2-1 6.8-.8 9.6.8M7.6 13c2.6-.8 5.6-.6 8 .6M8 15.4c2-.6 4.4-.4 6.2.4" fill="none" stroke="#fff" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "x":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect width="24" height="24" rx="5" fill="#111" />
|
||||
<path d="M6.2 5.6h3.2l3 4.2 3.6-4.2H18l-5.2 6.1 5.4 6.7h-3.2l-3.4-4.4-4 4.4H6.4l5.6-6.4Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "vk":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect width="24" height="24" rx="5" fill="#0077FF" />
|
||||
<path d="M4.8 7.8h2.6c.1 4.2 1.9 6.7 5.4 6.7V7.8h2.4v3.9c1.5-.2 2.9-1.7 3.4-3.9h2.4c-.6 3.3-2.6 5.4-4.4 6.2 1.8.6 4.1 2.4 5 5.2h-2.8c-.7-1.9-2.2-3.4-4-3.6v3.6h-2.4v-3.6c-3.5.1-6.1-2.4-6.6-6.8Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "zoom":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect width="24" height="24" rx="5" fill="#2D8CFF" />
|
||||
<path d="M5.2 9.2h7.2a2.2 2.2 0 0 1 2.2 2.2v5.2H7.4A2.2 2.2 0 0 1 5.2 14.4Z" fill="#fff" />
|
||||
<path d="M16.2 11.2 20 9.4v7.4l-3.8-1.8Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "epic":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#111" />
|
||||
<path d="M8.2 7.4h7.6v2H10.6v2h4.6v2h-4.6v3.2H8.2Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "riot":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<path d="M5 19.2 12 4.2 19 19.2h-3.2L12 10.6 8.2 19.2Z" fill="#D32936" />
|
||||
<path d="M9.4 19.2h5.2l-2.6-5.2Z" fill="#EB0029" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "playstation":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#003087" />
|
||||
<path d="M8.2 14.6c-1 .4-1.8.2-2-.4s.4-1.2 1.6-1.6l2-.7v1.6l-1.2.4c-.6.2-.8.4-.7.6.1.2.4.2.9 0l1-.4v1.5Zm3-6.4v8.2c-1.1.4-2.1.5-2.8.2-.9-.4-.9-1.3 0-1.7.5-.2 1.2-.3 2-.2V9.4c0-1.2.5-1.8 1.4-1.5.4.2.7.6.8 1.2Zm5.2 7.6c-1.1.4-2.2.4-3 0-.8-.4-.8-1.2 0-1.6.5-.2 1.2-.3 2-.2v-2.2l-2.4.8V11l4.2-1.5v6.3Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "roblox":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect width="24" height="24" rx="5" fill="#111" />
|
||||
<path d="M8.4 6.2 17.6 8.8 15.6 17.8 6.4 15.2Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "digitalocean":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#0080FF" />
|
||||
<path d="M12.4 6.2A5.8 5.8 0 0 0 7.8 16l1.6-1.5A3.6 3.6 0 1 1 16 12h-3.6Z" fill="#fff" />
|
||||
<path d="M12.4 16.2h-1.6v1.6h1.6zm-1.6-2h-1.4v1.4h1.4z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "hetzner":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect width="24" height="24" rx="4" fill="#D50C2D" />
|
||||
<path d="M7.2 6.4h2.6v4.4h4.4V6.4h2.6v11.2h-2.6v-4.4H9.8v4.4H7.2Z" fill="#fff" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "ovh":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<rect width="24" height="24" rx="4" fill="#123F6D" />
|
||||
<path d="M4.6 15.6 8.4 8.4h3.2L7.8 15.6Zm6.4 0 3.8-7.2h3.2l-3.8 7.2Zm2.2 0h3.4l1.8-3.4h-3.4Z" fill="#00A2E2" />
|
||||
</BrandSvg>
|
||||
)
|
||||
case "chatgpt":
|
||||
return (
|
||||
<BrandSvg size={size}>
|
||||
<circle cx="12" cy="12" r="10" fill="#10A37F" />
|
||||
<path d="M12.2 6.2c.9-.5 2-.5 2.9 0l2.2 1.3c.9.5 1.4 1.4 1.4 2.4v2.6c0 1-.5 1.9-1.4 2.4l-2.2 1.3c-.9.5-2 .5-2.9 0l-.4-.2c.6-.4 1-1 1.1-1.7l.5.3c.4.2.9.2 1.3 0l2.2-1.3c.4-.2.6-.6.6-1.1V10c0-.4-.2-.8-.6-1.1l-2.2-1.3c-.4-.2-.9-.2-1.3 0L10.2 9c-.4.2-.6.6-.6 1.1v.4h-2V10c0-1 .5-1.9 1.4-2.4Z" fill="#fff" />
|
||||
<path d="M8.8 9.6c.6-.4 1.3-.5 2-.3v2.1c0 .4.2.8.6 1.1l2.2 1.3c.4.2.9.2 1.3 0l.5-.3c.2.7.6 1.3 1.1 1.7l-.4.2c-.9.5-2 .5-2.9 0l-2.2-1.3c-.9-.5-1.4-1.4-1.4-2.4Z" fill="#fff" opacity="0.85" />
|
||||
</BrandSvg>
|
||||
)
|
||||
default:
|
||||
return <GenericCloud size={size} />
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user