fix(evolution): проверить серверный каталог и завершить локальную приёмку

This commit is contained in:
Denozordec committed 2026-10-03 14:12:06 +07:00
1 parent d272ef57f0
commit 2b6d3ab2e0
8 files changed
+169 -129

No files matched your search

@@ -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")
+17
View File
@@ -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")
+126
View File
@@ -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 })
}
})
}
}
+6 -123
View File
@@ -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