138 lines
12 KiB
TypeScript
138 lines
12 KiB
TypeScript
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]}
|
||
}))
|
||
}
|