Compare commits

...
2 Commits
Author SHA1 Message Date
DenozordecandCursor a8f2055c77 feat(netflow): расширить каталог ASN популярных сервисов
Docker images / prepare-release (push) Successful in 11s
Docker images / backend-test (push) Successful in 2m5s
Docker images / frontend-image (push) Successful in 3m14s
Docker images / updater-image (push) Successful in 46s
Docker images / backend-image (push) Successful in 4m2s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 11s
В аналитике потока видны сети со своим AS (Apple, Steam, VK, Epic и другие), а не только Cloudflare/AWS. Holder и порты Steam не перебивают Cloudflare CIDR.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-09 13:18:58 +07:00
DenozordecandCursor cdeb97d841 feat(certificates): добавить отключение автообновления на странице сертификатов
Docker images / prepare-release (push) Successful in 10s
Docker images / backend-test (push) Successful in 2m7s
Docker images / frontend-image (push) Successful in 3m19s
Docker images / updater-image (push) Successful in 49s
Docker images / backend-image (push) Successful in 2m45s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 15s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 16:21:50 +07:00
10 changed files with 518 additions and 63 deletions
+4 -1
View File
@@ -11,6 +11,7 @@ import { OpsPanel } from "@/components/ops-panel"
import { DataPageCard } from "@/components/data-page-card"
import { DataPageToolbar } from "@/components/data-page-toolbar"
import { CertificatesDataGrid } from "@/components/data-grids/certificates-data-grid"
import { CertificateRenewSettingsPanel } from "@/components/certificates/certificate-renew-settings"
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
import { ServerRailLayout, ServerRailMobileButton } from "@/components/server-rail-layout"
import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-rail"
@@ -243,7 +244,7 @@ function CertPartReference() {
return (
<OpsPanel
title="RouterOS 7 · /certificate — справка CLI"
description="RouterOS 7.22+ · публичные LE для Cloudflare через backend DNS-01, не через /certificate add-acme на устройстве."
description="RouterOS 7 умеет обновлять Let's Encrypt сам. Этот CLI — справка; автообновление MM включается панелью выше."
contentClassName="px-5 py-4"
>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-mono">
@@ -715,6 +716,8 @@ export default function CertificatesPage() {
<CertPartKpi displayCerts={scopedCerts} expiring={expiring} expired={expired} />
<CertificateRenewSettingsPanel backendUrl={backendUrl} liveReady={liveReady} />
{liveReady && (
<CertPartAcmeSettings
acmeDirectoryUrl={acmeDirectoryUrl}
+4
View File
@@ -1127,7 +1127,11 @@ export default function DataCollectionPage() {
onChange={(e) => setRenewBeforeDaysDraft(e.target.value)}
className="h-8 text-sm"
inputMode="numeric"
disabled={!draftCertRenewEnabled}
/>
<p className="text-[11px] text-muted-foreground mt-1">
Вкл/выкл автообновления MM также на странице «Сертификаты». Не включайте вместе со встроенным ACME RouterOS.
</p>
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
@@ -113,6 +113,15 @@ export async function collectCertificatesRenewOnce(): Promise<CertificatesRenewR
continue
}
const stillOn = await getCertificateRenewSettings()
if (!stillOn.enabled) {
item.action = "skipped"
item.message = "Автообновление выключено"
snapshot.skippedTargets += 1
snapshot.targets?.push(item)
continue
}
const jobId = randomUUID()
await createIssueJobRecord({
id: jobId,
@@ -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")
+208 -47
View File
@@ -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
@@ -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"
@@ -174,10 +174,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
}
@@ -0,0 +1,184 @@
"use client"
import { useCallback, useEffect, useState } from "react"
import Link from "next/link"
import { OpsPanel } from "@/components/ops-panel"
import { FormField, FormToggle } from "@/components/form-kit"
import { Alert, AlertDescription, AlertTitle } from "@/components/reui/alert"
import { Badge } from "@/components/reui/badge"
import { Button, buttonVariants } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { cn } from "@/lib/utils"
import {
getCertificateRenewSettings,
putCertificateRenewSettings,
} from "@/shared/api/certificates"
import { toast } from "sonner"
/**
* Автообновление сертификатов через MM (ACME DNS-01).
* Preview: https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-3
* Docs: https://reui.io/docs/components/base/frame · https://reui.io/docs/components/base/alert · https://reui.io/docs/components/base/badge
*/
export function CertificateRenewSettingsPanel({
backendUrl,
liveReady,
}: {
backendUrl: string
liveReady: boolean
}) {
const [enabled, setEnabled] = useState(true)
const [intervalDraft, setIntervalDraft] = useState("21600")
const [daysDraft, setDaysDraft] = useState("30")
const [lastCollectedAt, setLastCollectedAt] = useState<string | null>(null)
const [lastError, setLastError] = useState<string | null>(null)
const [loaded, setLoaded] = useState(false)
const [toggleBusy, setToggleBusy] = useState(false)
const [saveBusy, setSaveBusy] = useState(false)
const load = useCallback(async () => {
if (!liveReady) return
try {
const s = await getCertificateRenewSettings(backendUrl)
setEnabled(s.enabled)
setIntervalDraft(String(s.intervalSec))
setDaysDraft(String(s.renewBeforeDays))
setLastCollectedAt(s.lastCollectedAt ?? null)
setLastError(s.lastError ?? null)
setLoaded(true)
} catch (e) {
setLoaded(true)
toast.error(e instanceof Error ? e.message : "Не удалось загрузить настройки автообновления")
}
}, [backendUrl, liveReady])
useEffect(() => {
queueMicrotask(() => {
void load()
})
}, [load])
async function handleEnabledChange(next: boolean) {
if (!liveReady || toggleBusy) return
const prev = enabled
setEnabled(next)
setToggleBusy(true)
try {
const saved = await putCertificateRenewSettings(backendUrl, { enabled: next })
setEnabled(saved.enabled)
toast.success(next ? "Автообновление через MikrotikManager включено" : "Автообновление через MikrotikManager выключено")
} catch (e) {
setEnabled(prev)
toast.error(e instanceof Error ? e.message : "Не удалось сохранить")
} finally {
setToggleBusy(false)
}
}
async function handleSaveSchedule() {
if (!liveReady || saveBusy) return
const intervalSec = Math.max(300, Number.parseInt(intervalDraft, 10) || 21600)
const renewBeforeDays = Math.max(1, Math.min(90, Number.parseInt(daysDraft, 10) || 30))
setSaveBusy(true)
try {
const saved = await putCertificateRenewSettings(backendUrl, { intervalSec, renewBeforeDays })
setIntervalDraft(String(saved.intervalSec))
setDaysDraft(String(saved.renewBeforeDays))
toast.success("Расписание автообновления сохранено")
} catch (e) {
toast.error(e instanceof Error ? e.message : "Не удалось сохранить расписание")
} finally {
setSaveBusy(false)
}
}
const interactionsOff = !liveReady || toggleBusy || (liveReady && !loaded)
return (
<OpsPanel
title="Автообновление через MikrotikManager"
description="Фоновый выпуск Let's Encrypt (Cloudflare DNS-01) для сертификатов, выпущенных из этой панели. Ручной выпуск не зависит от переключателя."
headerRight={
<Badge
size="sm"
variant={!liveReady ? "warning-light" : enabled ? "success-light" : "secondary"}
>
{!liveReady ? "нет backend" : enabled ? "Включено" : "Выключено"}
</Badge>
}
contentClassName="px-5 py-4 flex flex-col gap-4"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-medium">Обновлять сертификаты из MM</p>
<p className="text-muted-foreground mt-0.5 text-xs">
Если ACME уже крутит RouterOS выключите, чтобы не было двойного перевыпуска.
</p>
</div>
<FormToggle checked={enabled} onChange={handleEnabledChange} disabled={interactionsOff} />
</div>
{!liveReady ? (
<Alert variant="warning">
<AlertTitle>Нет подключения к API</AlertTitle>
<AlertDescription>
Переключатель станет активен, когда backend доступен. Планировщик читает тот же флаг, что и страница «Сбор данных».
</AlertDescription>
</Alert>
) : enabled ? (
<Alert variant="warning">
<AlertTitle>Не смешивайте с ACME RouterOS</AlertTitle>
<AlertDescription>
MM обновляет только сертификаты, выпущенные через эту страницу. Встроенный Let&apos;s Encrypt на
устройстве для тех же имён лучше не включать одновременно.
</AlertDescription>
</Alert>
) : (
<Alert variant="info">
<AlertTitle>Обновление отдано RouterOS</AlertTitle>
<AlertDescription>
Планировщик MM больше не проверяет срок и не перевыпускает сертификаты. Ручной выпуск и импорт
остаются доступны.
</AlertDescription>
</Alert>
)}
<div className={cn("flex flex-col gap-4", !enabled && "pointer-events-none opacity-40")}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<FormField label="Интервал проверки" hint="Секунды, минимум 300">
<Input
inputMode="numeric"
value={intervalDraft}
onChange={(e) => setIntervalDraft(e.target.value)}
disabled={!liveReady || saveBusy}
/>
</FormField>
<FormField label="Обновлять за" hint="Дней до истечения, 1–90">
<Input
inputMode="numeric"
value={daysDraft}
onChange={(e) => setDaysDraft(e.target.value)}
disabled={!liveReady || saveBusy}
/>
</FormField>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" disabled={!liveReady || saveBusy || !enabled} onClick={() => void handleSaveSchedule()}>
Сохранить расписание
</Button>
<Link href="/data-collection" className={cn(buttonVariants({ variant: "ghost", size: "sm" }), "h-8 text-xs")}>
Журнал планировщика
</Link>
</div>
</div>
{lastCollectedAt || lastError ? (
<p className="text-muted-foreground text-xs">
Последний прогон:{" "}
{lastCollectedAt ? new Date(lastCollectedAt).toLocaleString("ru-RU") : "ещё не было"}
{lastError ? ` · ошибка: ${lastError}` : ""}
</p>
) : null}
</OpsPanel>
)
}
+1 -1
View File
@@ -40,7 +40,7 @@ export const SCHEDULER_JOB_DESCRIPTIONS: Record<string, string> = {
gre_bgp:
"Опрос GRE-туннелей и BGP-сессий на включённых серверах, запись сэмплов в PostgreSQL для движка оповещений.",
certificates_renew:
"Проверка сертификатов, выпущенных через UI, и автообновление через ACME DNS-01 (Cloudflare) до истечения срока.",
"Автообновление сертификатов, выпущенных через UI (ACME DNS-01 / Cloudflare). Отключается на странице «Сертификаты», если ACME ведёт RouterOS.",
backups:
"Плановые бэкапы RouterOS по расписанию со страницы «Бэкапы»; тик планировщика раз в минуту.",
alert_engine: