Files
MikrotikManager/backend/src/services/gre-mutation.ts
T

138 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { ServerRow } from "../modules/servers/repository/servers-repository.js"
import { getServerRowById } from "../modules/servers/repository/servers-repository.js"
import { fingerprint } from "../modules/mcp/security.js"
import { MikrotikClient } from "./mikrotik.js"
import { withApplicationMutationLock, withConfigMutationLock } from "./config-mutation-lock.js"
import { captureAndAppendRevision } from "./config-revisions.js"
import { mapGreLive, parseKeepalive } from "./gre-live.js"
import { readGreComment } from "./gre-comment.js"
import { greAddressPath, planGreCreate, planGreDelete, planGreRestore, type RosWriteOp } from "./entity-snapshots.js"
import { observedBoolean } from "./ospf-observed-values.js"
import { greMutationSchema, greTunnelFromBody, type GreMutation } from "./gre-mutation-input.js"
import { assertGreDeleteDependencies, greMutationProblem, greMutationProof, greNativeValue, greStableRows, greTarget, readGreMutationState, type GreMutationState } from "./gre-mutation-state.js"
const same = (a: unknown,b: unknown) => fingerprint(a) === fingerprint(b)
export async function recordGreMutationRevision(server:ServerRow){
try{
const revisionRecorded=(await captureAndAppendRevision({serverId:server.id,section:"gre",source:"apply",capture:async()=>{
const {captureGreSnapshot}=await import("./gre-live.js")
return captureGreSnapshot(server)
}}))!==null
return{revisionRecorded,revisionError:null as string|null}
}catch{return{revisionRecorded:false,revisionError:"Изменение GRE проверено, запись полной ревизии не подтверждена"}}
}
export function planGreMutation(server: ServerRow, input: GreMutation, before: GreMutationState): RosWriteOp[] {
const target = greTarget(input,before)
if (input.operation === "create") {
if (target || before.dependencies["/interface"].some(row=>row.name === input.fields.name)) throw greMutationProblem("Имя GRE уже используется интерфейсом")
const desired = greTunnelFromBody(input.fields), ops = planGreCreate(desired)
if (ops[0].op !== "put") throw greMutationProblem("Не подготовлено создание GRE")
ops[0].body.disabled = "yes"
if (!desired.disabled) ops.push({op:"patch",path:"/interface/gre/@created",body:{disabled:"no"}})
return ops
}
if (!target || observedBoolean(target.dynamic) === true) throw greMutationProblem("Требуется существующий статический GRE с native ID")
const selected = mapGreLive(server,[target],[...before.ipv4,...before.ipv6].filter(row=>[target.name,target[".id"]].includes(row.interface)))
const live = selected.gre[0]
if (input.operation === "delete") {
assertGreDeleteDependencies(before,target)
if ([...before.ipv4,...before.ipv6].some(row=>row.interface===target.name&&observedBoolean(row.dynamic)===true&&!/^fe80:/i.test(row.address))) throw greMutationProblem("GRE имеет динамический адрес; сначала отключите создающий его сервис")
const ops = planGreDelete(live.name,selected)
if (!live.disabled) ops.unshift({op:"patch",path:"/interface/gre/"+live.rosId,body:{disabled:"yes"}})
return ops
}
const fields = input.fields, original = selected.snapshot.tunnels[0], timer = parseKeepalive(live.keepalive)
if ((fields.keepaliveInterval !== undefined || fields.keepaliveRetries !== undefined) && (fields.keepaliveInterval === undefined || fields.keepaliveRetries === undefined)) {
const observedTimer = greNativeValue("keepalive",target.keepalive)
if (observedTimer!=="none"&&!Array.isArray(observedTimer)) throw greMutationProblem("Для изменения части keepalive требуется подтверждённый исходный таймер")
}
if (fields.allowFastPath === true && fields.ipsecSecret === undefined && (target["ipsec-secret"] === undefined || /^\*+$|^\(hidden\)$/.test(target["ipsec-secret"]))) throw greMutationProblem("Состояние PSK не подтверждено; включение FastPath требует явной проверенной настройки ключа")
const desired = greTunnelFromBody({ name: live.name, localAddress: fields.localAddress ?? live.localAddress, remoteAddress: fields.remoteAddress ?? live.remoteAddress,
localInnerIp: fields.localInnerIp ?? original.localInnerIp, remoteInnerIp: fields.remoteInnerIp ?? readGreComment(live.comment).remoteInnerIp, comment: fields.comment ?? readGreComment(live.comment).comment,
enabled: fields.enabled ?? !live.disabled, mtu: fields.mtu ?? live.mtu, keepaliveInterval: fields.keepaliveInterval ?? timer.interval, keepaliveRetries: fields.keepaliveRetries ?? timer.retries,
dscp: fields.dscp ?? live.dscp, clampTcpMss: fields.clampTcpMss ?? live.clampTcpMss, allowFastPath: fields.allowFastPath ?? (fields.ipsecSecret?.trim() ? false : live.allowFastPath), ipsecSecret: fields.ipsecSecret ?? live.ipsecSecret,
})
if (fields.keepaliveInterval === undefined && fields.keepaliveRetries === undefined) desired.keepalive = original.keepalive
desired.innerAddresses = original.innerAddresses?.map(row=>({...row}))
if (fields.localInnerIp !== undefined && fields.localInnerIp !== original.localInnerIp) {
desired.innerAddresses = (desired.innerAddresses ?? []).filter(row=>row.address !== original.localInnerIp)
if (fields.localInnerIp && !desired.innerAddresses.some(row=>row.address === fields.localInnerIp)) desired.innerAddresses.push({address:fields.localInnerIp,disabled:false,...(greAddressPath(fields.localInnerIp)==="/ipv6/address"?{advertise:false}:{})})
}
const ops = planGreRestore({tunnels:[desired]},selected)
if (ops[0]?.op !== "patch") throw greMutationProblem("Не подготовлено изменение GRE")
const body = ops[0].body
if (fields.ipsecSecret === undefined) delete body["ipsec-secret"]
else body["ipsec-secret"] = fields.ipsecSecret
if (fields.localAddress !== undefined) body["local-address"] = fields.localAddress || "0.0.0.0"
body.comment = fields.comment !== undefined || fields.remoteInnerIp !== undefined ? desired.comment : target.comment ?? ""
if (ops.length === 1 && Object.entries(body).every(([key,value])=>same(greNativeValue(key,target[key]),greNativeValue(key,value)))) throw greMutationProblem("GRE уже имеет запрошенную конфигурацию")
body.disabled = "yes"
if (!desired.disabled) ops.push({op:"patch",path:"/interface/gre/"+live.rosId,body:{disabled:"no"}})
return ops
}
export function verifyGreMutationOp(before: GreMutationState, after: GreMutationState, op: RosWriteOp) {
const paths = {"/interface/gre":"gre","/ip/address":"ipv4","/ipv6/address":"ipv6"} as const
const path = Object.keys(paths).find(path=>op.path===path||op.path.startsWith(path+"/")) as keyof typeof paths|undefined
if (!path || !["put","patch","delete"].includes(op.op)) return false
const key = paths[path], prior = greMutationProof(before), next = greMutationProof(after)
const id = op.path === path ? undefined : decodeURIComponent(op.path.slice(path.length+1))
if (op.op === "put") {
const added = next[key].filter(row=>!prior[key].some(previous=>previous[".id"]===row[".id"]))
if (added.length !== 1 || !same(next[key].filter(row=>row!==added[0]),prior[key]) || !Object.entries(op.body).every(([field,value])=>same(greNativeValue(field,after[key].find(row=>row[".id"]===added[0][".id"])?.[field]),greNativeValue(field,value)))) return false
if (key!=="gre"&&observedBoolean(after[key].find(row=>row[".id"]===added[0][".id"])?.dynamic)!==false) return false
// An address on our deliberately disabled GRE can legitimately be invalid.
// This proof confirms configuration, not link/DAD/forwarding readiness.
} else {
if (!prior[key].some(row=>row[".id"]===id)) return false
const expected = before[key].filter(row=>!(op.op==="delete"&&row[".id"]===id)).map(row=>row[".id"]===id&&op.op==="patch"?{...row,...op.body}:row)
// Automatic IPv6 link-local rows are not independently configurable.
const stable = greStableRows(expected.filter(row=>!(observedBoolean(row.dynamic)===true&&/^fe80:/i.test(row.address))),false,key==="gre"?"gre":"address")
if (!same(next[key],stable)) return false
}
return same({...prior,[key]:next[key]},next)
}
export async function executeGreMutationOps(input: GreMutation, before: GreMutationState, ops: RosWriteOp[], deps: { read:()=>Promise<GreMutationState>; command:(op:RosWriteOp)=>Promise<unknown>; validate?:(state:GreMutationState)=>void }) {
let current = before, completedCommands = 0, acknowledgedCommands = 0
for (const planned of ops) {
const fresh = await deps.read()
if (!same(greMutationProof(fresh),greMutationProof(current))) throw greMutationProblem("GRE или его зависимости изменились между командами")
deps.validate?.(fresh)
let op = planned
if (planned.path.endsWith("/@created")) {
const target = greTarget(input,current)
if (!target) throw greMutationProblem("Не подтверждён native ID нового GRE")
op = {...planned,path:"/interface/gre/"+target[".id"]}
}
await deps.command(op); acknowledgedCommands++
const next = await deps.read()
if (!verifyGreMutationOp(current,next,op)) return {verified:false,completedCommands,acknowledgedCommands,after:next}
current = next; completedCommands++
}
return {verified:true,completedCommands,acknowledgedCommands,after:current}
}
export async function mutateGreTunnel(server: ServerRow, raw: GreMutation, expectedBefore?: string) {
const input = greMutationSchema.parse(raw)
return withApplicationMutationLock(()=>withConfigMutationLock([server.id],async()=>{
const fresh = await getServerRowById(server.id)
if (!fresh?.enabled || !same(server,fresh)) throw greMutationProblem("Источник GRE изменился")
const before = await readGreMutationState(fresh,input)
if (expectedBefore && fingerprint(greMutationProof(before)) !== expectedBefore) throw greMutationProblem("GRE изменился после подготовки")
const ops = planGreMutation(fresh,input,before)
if (!ops.length || ops.length > 50) throw greMutationProblem("Операция GRE должна содержать от 1 до 50 команд")
const client = MikrotikClient.fromServer(fresh)
const receipt = await executeGreMutationOps(input,before,ops,{read:()=>readGreMutationState(fresh,input),...(input.operation==="delete"?{validate:(state:GreMutationState)=>{planGreMutation(fresh,input,state)}}:{}),command:op=>{
if (op.op === "put") return client.put(op.path,op.body)
const slash = op.path.lastIndexOf("/"), path = op.path.slice(0,slash+1)+encodeURIComponent(op.path.slice(slash+1))
if (op.op === "delete") return client.delete(path)
if (op.op === "patch") return client.patch(path,op.body)
throw greMutationProblem("Недопустимая команда GRE")
}})
const target = greTarget(input,receipt.after)
const final = target ? mapGreLive(fresh,[target],[...receipt.after.ipv4,...receipt.after.ipv6].filter(row=>row.interface===target.name)) : undefined
const revision=receipt.verified?await recordGreMutationRevision(fresh):{revisionRecorded:false,revisionError:null}
return {verified:receipt.verified,acknowledged:receipt.acknowledgedCommands>0,commandCount:ops.length,completedCommands:receipt.completedCommands,acknowledgedCommands:receipt.acknowledgedCommands,afterFingerprint:fingerprint(greMutationProof(receipt.after)),...revision,addressReadinessVerified:false,forwardingVerified:false,tunnel:final?.tunnels[0]}
}))
}