Files
MikrotikManager/backend/src/services/ipsec-pool-dependencies.ts
T

95 lines
6.7 KiB
TypeScript

import { isIP } from "node:net"
import { McpError } from "../modules/mcp/security.js"
type Row=Record<string,string>
export interface IpsecPoolConsumer {path:string;id:string}
export interface IpsecPoolDependency extends IpsecPoolConsumer {field:string;poolId:string}
/** IPv4 address-pool consumers. VPN servers inherit /ppp/profile; reading every
* profile and secret covers those direct and inherited pool selections without
* inventing a separate edge from a server's default-profile to an IPv4 pool.
* /ipv6/pool is a distinct namespace and is deliberately not resolved here. */
export const ipsecPoolDependencyFields={
"/ip/pool":["next-pool"],
"/ip/ipsec/mode-config":["address-pool"],
"/ip/dhcp-server":["address-pool"],
"/ppp/profile":["local-address","remote-address"],
"/ppp/secret":["local-address","remote-address"],
"/ip/hotspot":["address-pool"],
"/ip/hotspot/user/profile":["address-pool"],
} as const
export const ipsecPoolDependencyPaths=Object.keys(ipsecPoolDependencyFields) as Array<keyof typeof ipsecPoolDependencyFields>
export const ipsecExternalPoolDependencyPaths=ipsecPoolDependencyPaths.filter(path=>path!=="/ip/pool"&&path!=="/ip/ipsec/mode-config")
const id=(value:string)=>/^\*[a-z0-9]+$/i.test(value)&&value.length<=100
const idKey=(value:string)=>value.toLowerCase().replace(/^\*0+(?=[a-z0-9])/ ,"*")
const fail=(message:string,code="STATE_UNAVAILABLE"):never=>{throw new McpError(code,message,code==="STATE_UNAVAILABLE"?502:409)}
const objectKey=(path:string,value:string)=>path+":"+idKey(value)
/** Pure, fail-closed graph inventory. Callers must collect every required raw
* catalog under their live source/permission guard, bound and seal all rows,
* and repeat the same graph check immediately before every native dispatch.
* Missing/unsupported endpoints must remain errors rather than empty arrays. */
export function ipsecPoolDependencies(catalogs:Record<string,Row[]>):IpsecPoolDependency[]{
let count=0,bytes=0
for(const path of ipsecPoolDependencyPaths){
const rows=catalogs[path]
if(!Array.isArray(rows)||rows.length>2000||rows.some(row=>!row||typeof row!=="object"||Array.isArray(row)||typeof row[".id"]!=="string"||!id(row[".id"])||Object.values(row).some(value=>typeof value!=="string"))||new Set(rows.map(row=>idKey(row[".id"]))).size!==rows.length)fail("Неполный каталог потребителей пула IPsec")
count+=rows.length;bytes+=Buffer.byteLength(JSON.stringify(rows))
if(count>10000||bytes>1048576)fail("Превышен бюджет графа потребителей пула IPsec")
}
const pools=catalogs["/ip/pool"]
if(pools.some(row=>!row.name||row.name.trim()!==row.name)||new Set(pools.map(row=>row.name)).size!==pools.length)fail("Неоднозначные имена IPv4 pools")
const resolve=(value:string)=>{
const matches=pools.filter(pool=>pool.name===value||id(value)&&idKey(pool[".id"])===idKey(value))
if(matches.length>1)fail("Неоднозначная ссылка на IPv4 pool")
return matches[0]
}
// A name that aliases another native ID makes all reference interpretation ambiguous.
for(const pool of pools)if(id(pool.name)&&pools.some(other=>other!==pool&&idKey(other[".id"])===idKey(pool.name)))fail("Имя IPv4 pool совпадает с native ID другого объекта")
const edges:IpsecPoolDependency[]=[]
for(const path of ipsecPoolDependencyPaths)for(const row of catalogs[path])for(const field of ipsecPoolDependencyFields[path]){
const raw=row[field]?.trim()
if(!raw)continue
// An exact scalar name (including a comma) takes precedence over CSV parsing.
const exact=resolve(raw),tokens=exact?[raw]:raw.split(",").map(value=>value.trim())
if(tokens.some(token=>!token))fail("Повреждена ссылка потребителя IPv4 pool")
for(const token of tokens){
const pool=exact??resolve(token)
if(pool){edges.push({path,id:row[".id"],field,poolId:pool[".id"]});continue}
if(["none","static-only","default",""].includes(token))continue
// PPP per-secret/profile values can be literal addresses. Missing values
// inherit a profile, whose own row is included in the same graph.
if((path==="/ppp/profile"||path==="/ppp/secret")&&isIP(token)>0)continue
fail("Не подтверждена ссылка потребителя IPv4 pool")
}
}
// Pool fallback chains must terminate; cycles defeat a finite deletion closure.
const children=new Map(pools.map(pool=>[idKey(pool[".id"]),[]as string[]]))
for(const edge of edges)if(edge.path==="/ip/pool")children.get(idKey(edge.id))!.push(idKey(edge.poolId))
const done=new Set<string>(),active=new Set<string>()
for(const root of children.keys()){
const stack=[{key:root,exit:false}]
while(stack.length){const item=stack.pop()!;if(done.has(item.key))continue;if(item.exit){active.delete(item.key);done.add(item.key);continue}if(active.has(item.key))fail("Циклическая цепочка next-pool","STATE_CONFLICT");active.add(item.key);stack.push({key:item.key,exit:true});for(const key of children.get(item.key)!)stack.push({key,exit:false})}
}
return edges
}
/** An allow-list is the caller's explicit reviewed mutation/dependency closure,
* never an inference from a marker/comment. A pool range update affects every
* consumer just as deletion does, including disabled foreign consumers. */
export function assertPoolMutationDependencies(catalogs:Record<string,Row[]>,poolRows:Row[],allowedConsumers:IpsecPoolConsumer[]=[]):IpsecPoolDependency[]{
const edges=ipsecPoolDependencies(catalogs),targets=new Set<string>(),allowed=new Set(allowedConsumers.map(ref=>objectKey(ref.path,ref.id)))
for(const target of poolRows){
if(!target||!id(target[".id"]??""))fail("Не подтверждён native ID изменяемого IPv4 pool")
const actual=catalogs["/ip/pool"].filter(row=>idKey(row[".id"])===idKey(target[".id"]))
if(actual.length!==1||actual[0].name!==target.name)fail("Изменяемый IPv4 pool отсутствует или неоднозначен")
targets.add(idKey(target[".id"]))
}
// A changed fallback pool also affects allocations requested from every
// predecessor pool, so inspect those predecessors' external consumers too.
let changed=true
while(changed){changed=false;for(const edge of edges)if(edge.path==="/ip/pool"&&targets.has(idKey(edge.poolId))&&!targets.has(idKey(edge.id))){targets.add(idKey(edge.id));changed=true}}
const affected=edges.filter(edge=>targets.has(idKey(edge.poolId)))
if(affected.some(edge=>!allowed.has(objectKey(edge.path,edge.id))))fail("Изменяемый IPv4 pool используется объектом вне подтверждённого плана","DEPENDENCY_CONFLICT")
return affected
}