87 lines
4.9 KiB
TypeScript
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")
|
|
}
|
|
}
|