Files
MikrotikManager/backend/src/modules/mcp/routing-profiles.ts
T

148 lines
16 KiB
TypeScript

import { assertNoActiveRoutingProfileOverlap } from "./routing-controller-overlap.js"
import { fingerprint } from "./security.js"
import { prepareRoutingControllerBaseline, assertRoutingControllerBaseline, routingControllerBaselinePrincipal, advanceRoutingControllerBaseline, type RoutingControllerBaseline } from "./routing-controller-baseline.js"
import { publicRoutingControllerState } from "./routing-community-public.js"
import { randomUUID } from "node:crypto"
import { z } from "zod"
import { routeAiControllerBlockReason, routeAiProfileSchema, usesScopedRouteAiContext, type RouteAiProfile } from "@mmapp/contracts/mcp-routing"
import { pool } from "../../db/index.js"
import { registerMcpService } from "./service-registry.js"
import { accessGeneration, McpError } from "./security.js"
import { audit, validatePrincipal, type McpPrincipal } from "./store.js"
import { decideRoutingSwitch, type ControllerState, type PathHealth, type rankRoutingCandidates } from "./routing-model.js"
import type { add } from "./actions.js"
import { COMMUNITY_PATH_MAX_AGE_MS } from "./path-proof-purpose.js"
import { controllerProfileCurrent, publishControllerState, saveControllerProfileCas, withControllerProfileLease } from "./routing-controller-fence.js"
import { assertIdentifiedRoutingCompletion, createRoutingReservationStore } from "./routing-reservation-store.js"
const targetIds = (profile: RouteAiProfile) => [...new Set([profile.serverId, ...profile.candidates.flatMap(candidate => [candidate.jumpHostId, candidate.exitNodeId])])]
function assertControllerCanBeEnabled(profile: RouteAiProfile) {
const reason = routeAiControllerBlockReason(profile)
if (reason) throw new McpError("UNSUPPORTED_CAPABILITY", reason, 409)
}
export function registerRoutingProfileActions(register: typeof add) {
register("routing.profiles.read", "mm_route_profiles_list", "Профили и состояние контроллера Route AI", "network", "mm:network:read", "read", "connection", z.object({}).strict(), {
read: async (_input, context) => ({ profiles: (await pool.query("SELECT id,version,config,state,enabled,grant_expires_at,updated_at FROM mcp_routing_profiles WHERE connection_id=$1 ORDER BY updated_at DESC LIMIT 200", [context.connection.id])).rows.filter(row => targetIds(routeAiProfileSchema.parse(row.config)).every(id => context.connection.policy.scope.serverIds.includes(id))).map(row=>({...row,state:publicRoutingControllerState(row.state)})) }),
})
registerMcpService("routing.profile.save", {
current: async input => (await pool.query("SELECT id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows[0] ?? null,
execute: async input => {
if (input.enabled) assertControllerCanBeEnabled(routeAiProfileSchema.parse(input.profile))
if (input.expectedVersion !== null && (!Number.isSafeInteger(input.expectedVersion) || Number(input.expectedVersion) < 1)) throw new McpError("PLAN_EXPIRED", "План не содержит ожидаемую версию профиля", 409)
if (input.enabled) {
const principal=await routingControllerBaselinePrincipal(input.baseline),connection=await validatePrincipal(principal)
await assertRoutingControllerBaseline(input.baseline,String(input.profileId),input.expectedVersion===null?1:Number(input.expectedVersion)+1,input.profile,String(input.expiresAt),{principal,connection})
await assertNoActiveRoutingProfileOverlap(String(input.profileId),input.profile)
}
const row = await saveControllerProfileCas(pool, input)
if (!row) throw new McpError("STATE_CONFLICT", "Профиль изменён после подготовки плана; нужен новый план", 409)
return row
},
verify: async (input, result) => !!result && (await pool.query("SELECT config,state,enabled,version,credential_id,generation,policy_version,grant_expires_at FROM mcp_routing_profiles WHERE id=$1 AND connection_id=$2", [input.profileId, input.connectionId])).rows.some(row => row.version === (result as { version: number }).version && row.version === (input.expectedVersion === null ? 1 : Number(input.expectedVersion) + 1) && JSON.stringify(routeAiProfileSchema.parse(row.config)) === JSON.stringify(routeAiProfileSchema.parse(input.profile)) && row.enabled === input.enabled && (!input.enabled || fingerprint(row.state.baseline)===fingerprint(input.baseline)) && row.credential_id === input.credentialId && row.generation === input.generation && row.policy_version === input.policyVersion && row.grant_expires_at.getTime() === Date.parse(String(input.expiresAt))),
})
register("routing.profiles.save", "mm_route_profile_save_plan", "Сохранить профиль и временный допуск контроллера", "network", "mm:network:write", "write", "servers", z.object({ profileId: z.string().uuid().optional(), profile: routeAiProfileSchema, enabled: z.boolean().default(false), expiresAt: z.string().datetime({ offset: true }) }).strict(), {
target: input => ({ scope: "servers", serverIds: targetIds(routeAiProfileSchema.parse(input.profile)) }),
prepare: async (input, context) => {
const profile = routeAiProfileSchema.parse(input.profile), ids = targetIds(profile), profileId = input.profileId ?? randomUUID()
if (input.enabled) assertControllerCanBeEnabled(profile)
const expires = Date.parse(String(input.expiresAt)), credentialExpires = Date.parse(context.principal.credentialExpiresAt)
if (expires <= Date.now() || expires > Math.min(Date.now() + 86400000, credentialExpires, Date.parse(context.connection.policy.expiresAt), context.connection.owner_valid_until.getTime())) throw new McpError("INVALID_EXPIRATION", "Допуск контроллера ограничен 24 часами и сроком подтверждения прав владельца", 400)
const existing = (await pool.query("SELECT id,connection_id,version,config,enabled,grant_expires_at FROM mcp_routing_profiles WHERE id=$1", [profileId])).rows[0]
if (existing && existing.connection_id !== context.connection.id) throw new McpError("NOT_FOUND", "Профиль не найден", 404)
if (input.enabled && !["routing.analyze", "routing.community.switch"].every(id => context.connection.policy.scope.operationIds.includes(id))) throw new McpError("OPERATION_DENIED", "Для контроллера нужны отдельные разрешения анализа и переключения")
if(input.enabled)await assertNoActiveRoutingProfileOverlap(String(profileId),profile)
const baseline=input.enabled?await prepareRoutingControllerBaseline(String(profileId),(existing?.version??0)+1,profile,String(input.expiresAt),context):undefined
const { connection_id: _connection, ...before } = existing ?? {}
return { target: { scope: "servers", serverIds: ids }, objects: ids.map(serverId => ({ kind: "server", id: String(serverId), serverId })), snapshots: [], before: existing ? before : null,
steps: [{ kind: "service", service: "routing.profile.save", input: { ...input, profileId, expectedVersion: existing?.version ?? null, connectionId: context.connection.id, baseline, credentialId: context.principal.credentialId, generation: await accessGeneration(), policyVersion: context.connection.policy.version } }], requiresHuman: true,
warnings: ["Контроллер использует исходный доступ, лимиты и подтверждения. Отзыв токена или изменение политики останавливает его.", "При неизвестном пути или результате записи контроллер приостанавливается."] }
},
version: 4,
})
}
interface PersistentController extends ControllerState { baseline?:RoutingControllerBaseline; planId?: string; candidateId?: string; expectedCandidateId?: string; reason?: string; checkedAt?: string; probedAt?: string }
/** Also fenced per profile so direct/concurrent ticks cannot publish stale state. */
export async function tickRoutingProfiles() {
const { invokeMcpTool, applyPlan } = await import("./runtime.js")
const { rows } = await pool.query("SELECT * FROM mcp_routing_profiles WHERE enabled ORDER BY updated_at LIMIT 100")
for (const listed of rows) {
await withControllerProfileLease(pool, listed.id, async lease => {
const { rows: [row] } = await lease.query("SELECT * FROM mcp_routing_profiles WHERE id=$1 AND enabled", [listed.id])
if (!row) return
const profile = routeAiProfileSchema.parse(row.config), state: PersistentController = row.state
const next: PersistentController = { ...state, checkedAt: new Date().toISOString() }
const stop = async (reason: string) => {
next.pausedReason = reason; next.reason = reason
if (await publishControllerState(lease, row, next, true)) await audit(lease, "route-controller", "controller.pause", row.connection_id, { profileId: row.id, reason })
}
const current = async () => { if (!await controllerProfileCurrent(lease, row)) throw new McpError("STATE_CONFLICT", "Допуск профиля изменился во время цикла", 409) }
try {
const controllerBlock = routeAiControllerBlockReason(profile)
if (controllerBlock) { await stop(controllerBlock); return }
if (row.grant_expires_at.getTime() <= Date.now() || row.generation !== await accessGeneration() || !row.credential_id) { await stop("Допуск контроллера истёк или отозван"); return }
const principal: McpPrincipal = await routingControllerBaselinePrincipal(state.baseline)
const connection = await validatePrincipal(principal)
if (connection.policy.version !== row.policy_version) { await stop("Политика изменилась; требуется новый допуск"); return }
if (state.pausedReason) { await stop(state.pausedReason); return }
if (state.planId) {
const { rows: [plan] } = await pool.query("SELECT p.state,p.expires_at,o.id AS operation_id,o.state AS operation_state FROM mcp_plans p LEFT JOIN mcp_operations o ON o.plan_id=p.id WHERE p.id=$1 AND p.connection_id=$2", [state.planId, connection.id])
if (!plan || ["rejected", "unknown", "partial"].includes(plan.operation_state) || ["cancelled", "invalidated", "expired"].includes(plan.state)) { await stop("Изменение отклонено или требует разбора результата"); return }
if (plan.operation_state === "applied") {
const at = new Date().toISOString()
await assertIdentifiedRoutingCompletion(lease, row.id, state.planId, plan.operation_id, principal)
next.baseline=await advanceRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection},state.planId,plan.operation_id)
next.lastSwitchAt = at; next.switches = [...(state.switches ?? [])]; next.consecutive = 0
next.expectedCandidateId = state.candidateId
delete next.planId; delete next.candidateId; delete next.pendingCandidateId; delete next.operationId
next.reason = "Изменение конфигурации проверено; ожидаются новые измерения сети"
} else if (plan.operation_id) next.reason = "Операция выполняется"
else if (plan.expires_at.getTime() <= Date.now()) { await stop("План истёк без подтверждения"); return }
else if (["approved", "draft"].includes(plan.state)) {
await current()
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
const operation = await applyPlan(principal, state.planId, `controller:${row.id}:${state.planId}`)
next.operationId = operation.id; next.reason = "Применение через общий исполнитель"
} else next.reason = "Ожидается подтверждение человеком"
} else {
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
if (state.probedAt && Date.now() - Date.parse(state.probedAt) < 55000) return
await current()
const analysis = await invokeMcpTool(principal, "mm_route_optimizer_analyze", { profile }) as { ranking: ReturnType<typeof rankRoutingCandidates>; currentCandidateId: string | null }
if (state.expectedCandidateId && analysis.currentCandidateId !== state.expectedCandidateId) { await stop("Наблюдаемый путь изменился вне ожидаемого переключения; требуется проверка вмешательства или дрейфа"); return }
if (analysis.currentCandidateId) next.expectedCandidateId = analysis.currentCandidateId
const health: PathHealth[] = []
const selected = [analysis.currentCandidateId, ...analysis.ranking.filter(value => value.eligible).slice(0, 2).map(value => value.candidate.id)]
if (profile.candidates.some(candidate => candidate.controlProbe) && !connection.policy.scope.operationIds.includes("routing.path.verify")) { await stop("Не выдано право контрольной проверки пути"); return }
for (const candidateId of [...new Set(selected)].filter((id): id is string => !!id)) {
await current()
if (profile.candidates.find(candidate => candidate.id === candidateId)?.controlProbe) health.push(await invokeMcpTool(principal, "mm_route_path_verify", { profile, candidateId }) as PathHealth)
}
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
const charges = await createRoutingReservationStore().readHourlyCharges(row.id, principal)
await current()
const decision = decideRoutingSwitch(profile, state, analysis.ranking, analysis.currentCandidateId, Date.now(), health, charges.totalCharges)
Object.assign(next, decision.state, { reason: decision.reason })
next.probedAt = new Date().toISOString()
if (decision.state.pausedReason) { await stop(decision.state.pausedReason); return }
const hasFreshSelectedProbe = health.some(probe => {
if (probe.candidateId !== decision.switchTo || probe.status !== "passed") return false
const age = Date.now() - Date.parse(probe.observedAt)
return age >= 0 && age < COMMUNITY_PATH_MAX_AGE_MS
})
if (decision.switchTo && usesScopedRouteAiContext(profile) && !hasFreshSelectedProbe) {
next.reason = "Ожидается успешная контрольная проба выбранного пути"
} else if (decision.switchTo) {
await current()
await assertRoutingControllerBaseline(state.baseline,row.id,row.version,profile,row.grant_expires_at.toISOString(),{principal,connection})
const prepared = await invokeMcpTool(principal, "mm_route_optimizer_plan", { profile, candidateId: decision.switchTo }, undefined, { persistentRoutingProfile: { id: row.id, version: row.version } }) as { planId: string }
next.planId = prepared.planId; next.candidateId = decision.switchTo; next.reason = "Подготовлен план переключения"
await audit(pool, "route-controller", "controller.plan", connection.id, { profileId: row.id, planId: prepared.planId, candidateId: decision.switchTo })
}
}
await publishControllerState(lease, row, next)
} catch (error) { await stop(error instanceof McpError ? error.code : "Проверка состояния недоступна") }
})
}
}