fix(wireguard): очищать подтверждённые объекты при отказе импорта
This commit is contained in:
1 parent
f9980d7d7e
commit
e01454b720
10 files changed
+331
-58
No files matched your search
@@ -0,0 +1,143 @@
|
||||
import assert from "node:assert/strict"
|
||||
import { test } from "node:test"
|
||||
import { MikrotikError } from "./mikrotik.js"
|
||||
import { applyWireguardImport, WireguardImportApplyError } from "./wireguard-import-apply.js"
|
||||
import type { WgParsedConfig } from "./wireguard-config.js"
|
||||
|
||||
const iface = "/interface/wireguard", addresses = "/ip/address", peers = "/interface/wireguard/peers"
|
||||
type Row = Record<string, string>
|
||||
const parsed: WgParsedConfig = { format: "conf", interface: { name: "own-wg", address: "198.18.255.241/30" }, peers: [{ publicKey: "peer-one", allowedAddresses: ["198.18.255.242/32"] }, { publicKey: "peer-two", allowedAddresses: ["198.18.255.243/32"] }] }
|
||||
const body = (p: WgParsedConfig["peers"][number], name: string) => ({ interface: name, "public-key": p.publicKey, "allowed-address": p.allowedAddresses.join(",") })
|
||||
|
||||
function fixture() {
|
||||
const rows: Record<string, Row[]> = {
|
||||
[iface]: [{ ".id": "*F", name: "foreign-wg" }],
|
||||
[addresses]: [{ ".id": "*A", interface: "foreign-wg", address: "192.0.2.1/24" }],
|
||||
[peers]: [{ ".id": "*P", interface: "foreign-wg", "public-key": "foreign-peer" }],
|
||||
}
|
||||
const baseline = structuredClone(rows), deletes: string[] = []
|
||||
let next = 0, putCount = 0
|
||||
const client = {
|
||||
async get<T>(path: string): Promise<T> { return structuredClone(rows[path]) as T },
|
||||
async put<T>(path: string, value: Record<string, string>): Promise<T> {
|
||||
putCount++
|
||||
const row = { ".id": "*OWN/" + (++next), ...value }
|
||||
rows[path].push(row)
|
||||
return structuredClone(row) as T
|
||||
},
|
||||
async delete(path: string): Promise<void> {
|
||||
deletes.push(path)
|
||||
const split = path.lastIndexOf("/"), base = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1))
|
||||
rows[base] = rows[base].filter(row => row[".id"] !== id)
|
||||
},
|
||||
}
|
||||
return { client, rows, baseline, deletes, puts: () => putCount }
|
||||
}
|
||||
const state = (expected: WireguardImportApplyError["state"]) => (error: unknown) => error instanceof WireguardImportApplyError && error.state === expected
|
||||
|
||||
test("baseline read failure prevents every mutation", async () => {
|
||||
const f = fixture()
|
||||
f.client.get = async () => { throw new Error("Read unavailable") }
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), /Read unavailable/)
|
||||
assert.equal(f.puts(), 0); assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
test("success cannot be reported when receipt identities do not match native state", async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => {
|
||||
const result = await put(path, value)
|
||||
if (path === iface) f.rows[iface].find(row => row.name === "own-wg")!.name = "different-native-name"
|
||||
return result
|
||||
}
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
test("success requires native receipt identities and repeat conflicts without mutations", async () => {
|
||||
const f = fixture()
|
||||
assert.deepEqual(await applyWireguardImport(f.client, parsed, body), { interfaceName: "own-wg", peersCreated: 2 })
|
||||
const before = structuredClone(f.rows), puts = f.puts()
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("conflict"))
|
||||
assert.deepEqual(f.rows, before); assert.equal(f.puts(), puts); assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
test("second peer rejection removes only receipt-owned objects, then retry succeeds", async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => {
|
||||
if (value["public-key"] === "peer-two") throw new MikrotikError(400, peers, "Rejected secret payload must not be exposed")
|
||||
return put(path, value)
|
||||
}
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), error => state("rolled-back")(error) && !(error as Error).message.includes("secret payload"))
|
||||
assert.deepEqual(f.rows, f.baseline)
|
||||
assert.deepEqual(f.deletes, [peers + "/*OWN%2F3", addresses + "/*OWN%2F2", iface + "/*OWN%2F1"])
|
||||
f.client.put = put
|
||||
assert.equal((await applyWireguardImport(f.client, parsed, body)).peersCreated, 2)
|
||||
})
|
||||
|
||||
test("address rejection removes the interface, preserves foreign objects", async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => { if (path === addresses) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("rolled-back"))
|
||||
assert.deepEqual(f.rows, f.baseline)
|
||||
})
|
||||
|
||||
for (const path of [iface, addresses, peers]) {
|
||||
test(`lost response after committed ${path} preserves unknown outcome for review`, async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (target, value) => { const receipt = await put(target, value); if (target === path) throw new Error("Connection reset after commit"); return receipt }
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
}
|
||||
|
||||
test("missing create receipt never permits cleanup by interface name", async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => { await put(path, value); return undefined as never }
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
for (const status of [408, 500]) {
|
||||
test(`HTTP ${status} after commit does not imply a rejected mutation`, async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => { await put(path, value); throw new MikrotikError(status, path, "Uncertain upstream response") }
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
}
|
||||
|
||||
test("receipt referring to baseline identity never permits foreign deletion", async () => {
|
||||
const f = fixture()
|
||||
f.client.put = async () => ({ ".id": "*F" }) as never
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.rows, f.baseline); assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
test("concurrent foreign child stops compensation before any deletion", async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => {
|
||||
if (path === peers) { f.rows[peers].push({ ".id": "*CONCURRENT", interface: "own-wg", "public-key": "foreign" }); throw new MikrotikError(400, path, "rejected") }
|
||||
return put(path, value)
|
||||
}
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
test("changed native identity stops compensation before deletion", async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => {
|
||||
if (path === peers) { f.rows[iface].find(row => row.name === "own-wg")!.name = "changed-elsewhere"; throw new MikrotikError(400, path, "rejected") }
|
||||
return put(path, value)
|
||||
}
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
assert.deepEqual(f.deletes, [])
|
||||
})
|
||||
|
||||
for (const retain of [false, true]) {
|
||||
test(`cleanup ${retain ? "unconfirmed" : "rejected"} is never reported as rolled back`, async () => {
|
||||
const f = fixture(), put = f.client.put
|
||||
f.client.put = async (path, value) => { if (path === peers) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
|
||||
f.client.delete = async () => { if (!retain) throw new Error("Delete rejected") }
|
||||
await assert.rejects(applyWireguardImport(f.client, parsed, body), state("needs-review"))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user