fix(evolution): проверить серверный каталог и завершить локальную приёмку
This commit is contained in:
1 parent
d272ef57f0
commit
2b6d3ab2e0
8 files changed
+169
-129
No files matched your search
@@ -28,7 +28,7 @@
|
||||
"test:backups": "tsx src/services/backup-scheduler-run.test.ts && tsx src/services/backup-restore.test.ts && tsx src/services/backup-deletion.test.ts && tsx src/services/backup-dto.test.ts && tsx src/services/backup-storage-update.test.ts && tsx src/services/backup-job-execution.test.ts && tsx src/routes/backups-operations.test.ts && tsx src/services/s3-backup-client.test.ts",
|
||||
"test:live-maps": "tsx src/services/ospf-route-parse.test.ts && tsx src/services/vxlan-live.test.ts && tsx src/services/containers-live.test.ts && tsx src/services/firewall-poll.test.ts && tsx src/services/gre-catalog-poll.test.ts && tsx src/routes/filters-gre-catalog.test.ts && tsx src/routes/bgp-catalog.test.ts && tsx src/services/bgp-catalog-parse.test.ts && tsx src/routes/ospf-catalog.test.ts && tsx src/services/ospf-observed-values.test.ts",
|
||||
"test": "npm run test:probes && npm run test:exec && npm run test:alert-engine && npm run test:auth && npm run test:wireguard && npm run test:traffic-rate && npm run test:traffic-flow && npm run test:users && npm run test:pg && npm run test:backups && npm run test:scheduler && npm run test:live-maps && npm run test:ospf-optimizer && npm run test:recursive-routes && npm run test:config-sync && npm run test:ros-file && npm run test:containers && npm run test:certificates && npm run test:netflow-settings && npm run test:netflow-purge && npm run test:evolution",
|
||||
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:evolution": "tsx src/services/evobgp-catalog.test.ts && tsx src/routes/evobgp-catalog.test.ts && tsx src/routes/evobgp-settings.test.ts && tsx src/plugins/auth.evolution-settings.test.ts",
|
||||
"test:netflow-purge": "tsx src/db/operation-lock.test.ts && tsx src/services/traffic-flow-purge-execution.test.ts && tsx src/routes/traffic-flow-purge.test.ts",
|
||||
"test:netflow-settings": "tsx src/services/traffic-flow-host-file-builders.test.ts && tsx src/services/traffic-flow-key-execution.test.ts && tsx src/routes/traffic-flow-settings.test.ts && tsx src/plugins/auth.netflow-settings.test.ts",
|
||||
"test:geoip": "tsx src/services/traffic-flow-geoip.test.ts && tsx src/plugins/auth.geoip.test.ts",
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import {SignJWT} from "jose"
|
||||
import {createEvoBgpCatalogRoutes} from "../routes/evobgp-catalog.js"
|
||||
import {createEvoBgpSettingsRoutes} from "../routes/evobgp-settings.js"
|
||||
process.env.AUTH_REQUIRED="true";process.env.AUTH_JWT_SECRET="evolution-fixture-secret";process.env.AUTH_ISSUER="https://auth.test.local";process.env.NODE_ENV="test"
|
||||
const {default:authPlugin,requireAuth}=await import("./auth.js")
|
||||
const app=Fastify();await app.register(authPlugin);app.addHook("preHandler",requireAuth)
|
||||
let writes=0,probes=0
|
||||
let writes=0,probes=0,catalogs=0
|
||||
await app.register(createEvoBgpSettingsRoutes({read:async()=>({baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-only"}),write:async()=>{writes++;return {baseUrl:"https://evo.example.test",enabled:false,apiKey:"fixture-only"}},probe:async()=>{probes++;return {items:[]}}}),{prefix:"/api"})
|
||||
await app.register(createEvoBgpCatalogRoutes({read:async()=>({baseUrl:"https://evo.example.test",enabled:true,apiKey:"fixture-only"}),fetchCatalog:async()=>{catalogs++;return {modules:{items:[]},domains:{items:[]},ip_ranges:{items:[]},asns:{items:[]},communities:{items:[]}}},serversByCommunity:async()=>new Map()}),{prefix:"/api"})
|
||||
for(const permission of ["mm:settings:admin","mm:settings:write","mm:traffic:admin","mm:dashboard:read"]){
|
||||
const token=await new SignJWT({sub:"fixture",apps:["mm"],permissions:[permission]}).setProtectedHeader({alg:"HS256"}).setIssuer("https://auth.test.local").setExpirationTime("1h").sign(new TextEncoder().encode("evolution-fixture-secret"))
|
||||
for(const [method,url] of [["GET","/api/evobgp/settings"],["PUT","/api/evobgp/settings"],["POST","/api/evobgp/test"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:{enabled:false}}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403);assert.equal(response.body.includes("fixture-only"),false)}
|
||||
for(const [method,url] of [["GET","/api/evobgp/settings"],["PUT","/api/evobgp/settings"],["POST","/api/evobgp/test"],["POST","/api/evobgp/catalog"]] as const){const response=await app.inject({method,url,headers:{authorization:"Bearer "+token},...(method==="PUT"?{payload:{enabled:false}}:{})});assert.equal(response.statusCode,permission==="mm:settings:admin"?200:403);assert.equal(response.body.includes("fixture-only"),false)}
|
||||
}
|
||||
assert.equal(writes,1);assert.equal(probes,1);assert.equal((await app.inject({method:"GET",url:"/api/evobgp/settings"})).statusCode,401)
|
||||
await app.close();console.log("Evolution JWT gate: actual settings/test handlers require settings admin, no key echo")
|
||||
assert.equal(writes,1);assert.equal(probes,1);assert.equal(catalogs,1);assert.equal((await app.inject({method:"POST",url:"/api/evobgp/catalog"})).statusCode,401);assert.equal((await app.inject({method:"GET",url:"/api/evobgp/settings"})).statusCode,401)
|
||||
await app.close();console.log("Evolution JWT gate: actual settings/test/catalog handlers require settings admin, no key echo")
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict"
|
||||
import Fastify from "fastify"
|
||||
import {createEvoBgpCatalogRoutes} from "./evobgp-catalog.js"
|
||||
const empty={modules:{items:[]},domains:{items:[]},ip_ranges:{items:[]},asns:{items:[]},communities:{items:[]}}
|
||||
let settings={enabled:true,baseUrl:"EVO.EXAMPLE.test/",apiKey:"Bearer fixture-secret"},reads=0,fetches=0,serverReads=0,failFetch=false,failLocal=false
|
||||
let catalog:unknown=empty
|
||||
const app=Fastify();await app.register(createEvoBgpCatalogRoutes({read:async()=>{reads++;return {...settings}},fetchCatalog:async(root,key)=>{fetches++;assert.equal(root,"https://evo.example.test");assert.equal(key,"fixture-secret");if(failFetch)throw Error("upstream unavailable");return catalog},serversByCommunity:async()=>{serverReads++;if(failLocal)throw Error("local associations unavailable");return new Map([["64512:1",3]])},now:()=>new Date("2026-10-03T00:00:00Z")}),{prefix:"/api"})
|
||||
const load=()=>app.inject({method:"POST",url:"/api/evobgp/catalog",payload:{baseUrl:"https://untrusted.test",apiKey:"untrusted"}})
|
||||
let response=await load();assert.equal(response.statusCode,200);assert.deepEqual(response.json(),{fetchedAt:"2026-10-03T00:00:00.000Z",modules:[],domains:[],ipRanges:[],asns:[],communities:[]});assert.equal(reads,1);assert.equal(fetches,1)
|
||||
catalog={...empty,modules:{items:[{id:"m",name:"Example module",type:"custom"}]},domains:{items:[{module_id:"m",entry:{id:"d",fqdn:"example.test",community_id:"c"}}]},asns:{items:[{module_id:"m",entry:{id:"a",asn:64512,community_id:"c"}},{module_id:"m",entry:{id:"z",asn:64513,community_id:"z",prefix_count:0}}]},communities:{items:[{id:"c",community:"64512:1",title:"Example community"},{id:"z",community:"64512:2"},{id:"empty",community:"64512:3"}]}}
|
||||
response=await load();assert.equal(response.statusCode,200);const mapped=response.json();assert.equal(mapped.asns[0].prefixes,null);assert.equal(mapped.asns[1].prefixes,0);assert.equal(mapped.communities[0].prefixCount,null);assert.equal(mapped.communities[0].serverCount,3);assert.equal(mapped.communities[1].prefixCount,0);assert.equal(mapped.communities[2].prefixCount,0);assert.equal(mapped.domains[0].purpose,"Example module");assert.equal(response.body.includes("fixture-secret"),false)
|
||||
const serverBefore=serverReads;for(const malformed of [{},null,{...empty,domains:undefined},{...empty,asns:{items:[{module_id:"m",entry:{id:"a",asn:1,prefix_count:1.2}}]}}]){catalog=malformed;response=await load();assert.equal(response.statusCode,502);assert.match(response.json().error,/не подтверждён/);assert.equal(response.json().domains,undefined)}assert.equal(serverReads,serverBefore)
|
||||
settings={...settings,enabled:false};const before=fetches;response=await load();assert.equal(response.statusCode,400);assert.equal(fetches,before)
|
||||
settings={...settings,enabled:true,apiKey:""};response=await load();assert.equal(response.statusCode,400);assert.equal(fetches,before)
|
||||
settings={...settings,apiKey:"fixture-secret"};catalog=empty;failFetch=true;response=await load();assert.equal(response.statusCode,502);assert.equal(fetches,before+1);assert.equal(response.json().domains,undefined)
|
||||
failFetch=false;failLocal=true;response=await load();assert.equal(response.statusCode,502);assert.equal(response.json().domains,undefined)
|
||||
await app.close();console.log("Evolution catalog HTTP: one settings snapshot, DB credentials only, known empty/nullable counts, malformed/upstream/local errors without fabricated success")
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp"
|
||||
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "../services/evobgp-catalog.js"
|
||||
interface Dependencies {
|
||||
read: () => Promise<{enabled:boolean;baseUrl:string;apiKey:string}>
|
||||
fetchCatalog: (root:string,key:string) => Promise<unknown>
|
||||
serversByCommunity: () => Promise<Map<string,number>>
|
||||
now?: () => Date
|
||||
}
|
||||
export function createEvoBgpCatalogRoutes(deps:Dependencies):FastifyPluginAsyncZod {
|
||||
return async app => {
|
||||
app.post("/evobgp/catalog", async (_req, reply) => {
|
||||
const row = await deps.read()
|
||||
if (!row.enabled) {
|
||||
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
|
||||
}
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = normalizeApiKey(row.apiKey)
|
||||
const cred = root && apiKey ? {root,apiKey} : null
|
||||
if (!cred) {
|
||||
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
||||
}
|
||||
try {
|
||||
const catalog = parseEvoCatalogRaw(await deps.fetchCatalog(cred.root, cred.apiKey))
|
||||
const modules = catalog.modules.items
|
||||
const modMap = new Map(modules.map((m) => [m.id, m]))
|
||||
const commItems = catalog.communities.items
|
||||
const commLabel = new Map<string, string>()
|
||||
for (const c of commItems) {
|
||||
const label = (c.community && c.title)
|
||||
? `${c.title} (${c.community})`
|
||||
: (c.title ?? c.community ?? c.id)
|
||||
commLabel.set(c.id, label)
|
||||
}
|
||||
|
||||
const now = deps.now?.() ?? new Date()
|
||||
const fmtTime = now.toLocaleString("ru-RU")
|
||||
const fetchedAt = now.toISOString()
|
||||
|
||||
const domains = catalog.domains.items.map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
domain: rowItem.entry.fqdn,
|
||||
resolvedIp: "—",
|
||||
asn: "—",
|
||||
purpose: mod?.name ?? "—",
|
||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const ipRanges = catalog.ip_ranges.items.map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
cidr: rowItem.entry.prefix,
|
||||
asn: "—",
|
||||
country: "—",
|
||||
purpose: mod?.name ?? "—",
|
||||
filter: commLabel.get(cid) ?? cid,
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const asns = catalog.asns.items.map((rowItem) => {
|
||||
const cid = rowItem.entry.community_id
|
||||
const e = rowItem.entry
|
||||
const name = (e.asn_name ?? "").trim()
|
||||
const prefixes = e.prefix_count ?? null
|
||||
let updated = fmtTime
|
||||
const rawResolved = (e.asn_resolved_at ?? "").trim()
|
||||
if (rawResolved) {
|
||||
const d = new Date(rawResolved)
|
||||
if (!Number.isNaN(d.getTime())) updated = d.toLocaleString("ru-RU")
|
||||
}
|
||||
return {
|
||||
id: `${rowItem.module_id}:${e.id}`,
|
||||
asn: `AS${e.asn}`,
|
||||
org: name || "—",
|
||||
country: "—",
|
||||
prefixes,
|
||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||
updated,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
|
||||
const serversByCommunityStr = await deps.serversByCommunity()
|
||||
|
||||
const communities = commItems.map((c) => {
|
||||
const valueStr = (c.community ?? "").trim()
|
||||
return {
|
||||
id: c.id,
|
||||
value: c.community ?? c.id,
|
||||
name: c.title ?? c.community ?? c.id,
|
||||
description: "",
|
||||
type: "custom" as const,
|
||||
filterIds: [] as string[],
|
||||
serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0,
|
||||
prefixCount: routeCountsByCommUuid.has(c.id) ? routeCountsByCommUuid.get(c.id)! : 0,
|
||||
action: "permit" as const,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
fetchedAt,
|
||||
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
|
||||
domains,
|
||||
ipRanges,
|
||||
asns,
|
||||
communities,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
|
||||
return reply.status(502).send({ error: msg })
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { eq, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
||||
import { normalizeEvoBgpBaseUrl as normalizeBaseUrl, normalizeEvoBgpApiKey as normalizeApiKey } from "@mmapp/contracts/evobgp"
|
||||
import { parseEvoCatalogRaw, aggregateCommunityRouteCounts } from "../services/evobgp-catalog.js"
|
||||
import { createEvoBgpCatalogRoutes } from "./evobgp-catalog.js"
|
||||
import { createEvoBgpSettingsRoutes } from "./evobgp-settings.js"
|
||||
import { db } from "../db/index.js"
|
||||
import { evobgpSettings, filterRules } from "../db/schema.js"
|
||||
@@ -71,14 +70,6 @@ async function fetchEvoJson<T>(root: string, path: string, token: string): Promi
|
||||
}
|
||||
}
|
||||
|
||||
async function credentialsFromDb(): Promise<{ root: string; apiKey: string } | null> {
|
||||
const row = await ensureEvobgpRow()
|
||||
const root = normalizeBaseUrl(row.baseUrl)
|
||||
const apiKey = normalizeApiKey(row.apiKey)
|
||||
if (!root || !apiKey) return null
|
||||
return { root, apiKey }
|
||||
}
|
||||
|
||||
const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
await app.register(createEvoBgpSettingsRoutes({
|
||||
read: ensureEvobgpRow,
|
||||
@@ -95,119 +86,11 @@ const evobgpRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
},
|
||||
}))
|
||||
|
||||
/**
|
||||
* Прокси к GET /v1/router-lists/catalog — учётные данные только из БД.
|
||||
*/
|
||||
app.post("/evobgp/catalog", async (_req, reply) => {
|
||||
const row = await ensureEvobgpRow()
|
||||
if (!row.enabled) {
|
||||
return reply.status(400).send({ error: "Интеграция EvoBGP выключена в настройках" })
|
||||
}
|
||||
const cred = await credentialsFromDb()
|
||||
if (!cred) {
|
||||
return reply.status(400).send({ error: "Не заданы базовый URL или API-ключ в БД" })
|
||||
}
|
||||
try {
|
||||
const catalog = parseEvoCatalogRaw(await fetchEvoJson<unknown>(cred.root, "/v1/router-lists/catalog", cred.apiKey))
|
||||
const modules = catalog.modules.items
|
||||
const modMap = new Map(modules.map((m) => [m.id, m]))
|
||||
const commItems = catalog.communities.items
|
||||
const commLabel = new Map<string, string>()
|
||||
for (const c of commItems) {
|
||||
const label = (c.community && c.title)
|
||||
? `${c.title} (${c.community})`
|
||||
: (c.title ?? c.community ?? c.id)
|
||||
commLabel.set(c.id, label)
|
||||
}
|
||||
|
||||
const fmtTime = new Date().toLocaleString("ru-RU")
|
||||
const fetchedAt = new Date().toISOString()
|
||||
|
||||
const domains = catalog.domains.items.map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
domain: rowItem.entry.fqdn,
|
||||
resolvedIp: "—",
|
||||
asn: "—",
|
||||
purpose: mod?.name ?? "—",
|
||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const ipRanges = catalog.ip_ranges.items.map((rowItem) => {
|
||||
const mod = modMap.get(rowItem.module_id)
|
||||
const cid = rowItem.entry.community_id
|
||||
return {
|
||||
id: `${rowItem.module_id}:${rowItem.entry.id}`,
|
||||
cidr: rowItem.entry.prefix,
|
||||
asn: "—",
|
||||
country: "—",
|
||||
purpose: mod?.name ?? "—",
|
||||
filter: commLabel.get(cid) ?? cid,
|
||||
updated: fmtTime,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const asns = catalog.asns.items.map((rowItem) => {
|
||||
const cid = rowItem.entry.community_id
|
||||
const e = rowItem.entry
|
||||
const name = (e.asn_name ?? "").trim()
|
||||
const prefixes = e.prefix_count ?? null
|
||||
let updated = fmtTime
|
||||
const rawResolved = (e.asn_resolved_at ?? "").trim()
|
||||
if (rawResolved) {
|
||||
const d = new Date(rawResolved)
|
||||
if (!Number.isNaN(d.getTime())) updated = d.toLocaleString("ru-RU")
|
||||
}
|
||||
return {
|
||||
id: `${rowItem.module_id}:${e.id}`,
|
||||
asn: `AS${e.asn}`,
|
||||
org: name || "—",
|
||||
country: "—",
|
||||
prefixes,
|
||||
filter: cid ? (commLabel.get(cid) ?? cid) : "—",
|
||||
updated,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
const routeCountsByCommUuid = aggregateCommunityRouteCounts(catalog)
|
||||
const serversByCommunityStr = await distinctServersByFilterCommunityValue()
|
||||
|
||||
const communities = commItems.map((c) => {
|
||||
const valueStr = (c.community ?? "").trim()
|
||||
return {
|
||||
id: c.id,
|
||||
value: c.community ?? c.id,
|
||||
name: c.title ?? c.community ?? c.id,
|
||||
description: "",
|
||||
type: "custom" as const,
|
||||
filterIds: [] as string[],
|
||||
serverCount: valueStr ? (serversByCommunityStr.get(valueStr) ?? 0) : 0,
|
||||
prefixCount: routeCountsByCommUuid.has(c.id) ? routeCountsByCommUuid.get(c.id)! : 0,
|
||||
action: "permit" as const,
|
||||
enabled: true,
|
||||
}
|
||||
})
|
||||
|
||||
return reply.send({
|
||||
fetchedAt,
|
||||
modules: modules.map((m) => ({ id: m.id, name: m.name, type: m.type })),
|
||||
domains,
|
||||
ipRanges,
|
||||
asns,
|
||||
communities,
|
||||
})
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : "Ошибка EvoBGP"
|
||||
return reply.status(502).send({ error: msg })
|
||||
}
|
||||
})
|
||||
await app.register(createEvoBgpCatalogRoutes({
|
||||
read: ensureEvobgpRow,
|
||||
fetchCatalog: (root,key) => fetchEvoJson<unknown>(root,"/v1/router-lists/catalog",key),
|
||||
serversByCommunity: distinctServersByFilterCommunityValue,
|
||||
}))
|
||||
}
|
||||
|
||||
export default evobgpRoutes
|
||||
Reference in new issue
Block a user