Files
MikrotikManager/backend/src/services/wireguard-import-apply.ts
T

87 lines
4.9 KiB
TypeScript

import { MikrotikError, type MikrotikClient } from "./mikrotik.js"
import type { WgParsedConfig, WgParsedPeer } from "./wireguard-config.js"
import { asRosArray, toRosBody } from "./wireguard-ros.js"
type Client = Pick<MikrotikClient, "get" | "put" | "delete">
type Row = Record<string, string>
const paths = ["/interface/wireguard", "/ip/address", "/interface/wireguard/peers"] as const
type Created = { path: typeof paths[number]; id: string; identity: Record<string, string> }
export class WireguardImportApplyError extends Error {
constructor(public readonly state: "conflict" | "rolled-back" | "needs-review") {
super(state === "conflict"
? "Интерфейс с таким именем уже существует. Укажите другое имя в конфигурации"
: state === "rolled-back"
? "RouterOS отклонил импорт. Созданные этим запросом объекты удалены; исправьте конфигурацию и повторите"
: "Не удалось подтвердить результат импорта или очистки. Проверьте интерфейс на роутере перед повтором")
}
}
/** Receipts establish ownership; a lost response never authorizes deletion by name alone. */
export async function applyWireguardImport(
client: Client,
parsed: WgParsedConfig,
peerBody: (peer: WgParsedPeer, interfaceName: string) => Record<string, string>,
): Promise<{ interfaceName: string; peersCreated: number }> {
const name = parsed.interface.name
const read = async () => Object.fromEntries(await Promise.all(paths.map(async path => [path, asRosArray<Row>(await client.get(path))]))) as Record<typeof paths[number], Row[]>
const baseline = await read()
if (baseline[paths[0]].some(row => row.name === name)) throw new WireguardImportApplyError("conflict")
const created: Created[] = []
let unconfirmed = false
const owns = (row: Row | undefined, item: Created) => !!row && Object.entries(item.identity).every(([key, value]) => row[key] === value)
const put = async (path: typeof paths[number], body: Record<string, string>, identity: Record<string, string>) => {
let receipt: unknown
try {
receipt = await client.put(path, body)
} catch (error) {
// Only an explicit RouterOS rejection permits immediate compensation.
unconfirmed = !(error instanceof MikrotikError && [400, 401, 403, 404, 405, 409, 422].includes(error.statusCode))
throw error
}
const rows = asRosArray<Row>(receipt)
const id = rows.length === 1 ? rows[0]?.[".id"] : undefined
if (typeof id !== "string" || !id || baseline[path].some(row => row[".id"] === id)) {
unconfirmed = true
throw new Error("Unconfirmed create receipt")
}
created.push({ path, id, identity })
}
try {
await put(paths[0], toRosBody({ name, "listen-port": String(parsed.interface.listenPort ?? 13231), mtu: String(parsed.interface.mtu ?? 1420), "private-key": parsed.interface.privateKey, comment: parsed.interface.comment, disabled: parsed.interface.disabled ? "yes" : undefined }), { name })
if (parsed.interface.address) {
await put(paths[1], { address: parsed.interface.address, interface: name }, { address: parsed.interface.address, interface: name })
}
for (const peer of parsed.peers) {
await put(paths[2], peerBody(peer, name), { interface: name, "public-key": peer.publicKey })
}
const current = await read()
if (created.some(item => !owns(current[item.path].find(row => row[".id"] === item.id), item))) {
unconfirmed = true
throw new Error("Native identities not confirmed")
}
return { interfaceName: name, peersCreated: parsed.peers.length }
} catch {
if (unconfirmed) throw new WireguardImportApplyError("needs-review")
try {
const current = await read()
const ifaceId = created.find(item => item.path === paths[0])?.id
// A concurrent foreign child must never be removed with the interface.
if (paths.slice(1).some(path => current[path].some(row => (row.interface === name || row.interface === ifaceId) && !created.some(item => item.path === path && item.id === row[".id"])))
|| created.some(item => !owns(current[item.path].find(row => row[".id"] === item.id), item))) {
throw new Error("Ownership not confirmed")
}
for (const item of [...created].reverse()) await client.delete(`${item.path}/${encodeURIComponent(item.id)}`)
const after = await read()
if (created.some(item => after[item.path].some(row => row[".id"] === item.id))
|| after[paths[0]].some(row => row.name === name)
|| paths.slice(1).some(path => after[path].some(row => row.interface === name || row.interface === ifaceId))) {
throw new Error("Cleanup not confirmed")
}
} catch {
throw new WireguardImportApplyError("needs-review")
}
throw new WireguardImportApplyError("rolled-back")
}
}