fix(wireguard): удалять связанные пиры и IP вместе с интерфейсом

This commit is contained in:
Denozordec committed 2026-10-08 21:59:53 +07:00
1 parent fe0ca3fa46
commit 3722fad329
11 files changed
+420 -8

No files matched your search

@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { deleteWireguardInterface } from './wireguard-delete.js'
type Row = { '.id': string; name?: string; interface?: string }
function fixture(opts: { failRead?: string; failDelete?: string; keep?: string; missing?: boolean } = {}) {
const rows: Record<string, Row[]> = {
'/interface/wireguard': [{ '.id': '*A/1', name: 'wg-own' }, { '.id': '*B', name: 'wg-foreign' }],
'/interface/wireguard/peers': [{ '.id': '*P', interface: 'wg-own' }, { '.id': '*Q', interface: '*A/1' }, { '.id': '*F', interface: 'wg-foreign' }],
'/ip/address': [{ '.id': '*I', interface: 'wg-own' }, { '.id': '*J', interface: 'wg-foreign' }],
}
if (opts.missing) rows['/interface/wireguard'] = rows['/interface/wireguard'].filter(r => r.name !== 'wg-own')
const deletes: string[] = []
const client = {
async get<T>(path: string): Promise<T> {
if (path === opts.failRead) throw new Error('Read forbidden')
return structuredClone(rows[path]) as T
},
async delete(path: string): Promise<void> {
deletes.push(path)
if (path === opts.failDelete) throw new Error('Delete forbidden')
const split = path.lastIndexOf('/'), base = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1))
if (base !== opts.keep) rows[base] = rows[base].filter(row => row['.id'] !== id)
},
}
return { client, rows, deletes }
}
test('removes named/id-associated peers and addresses before interface; preserves foreign objects', async () => {
const f = fixture(); await deleteWireguardInterface(f.client, '*A/1')
assert.deepEqual(f.deletes, ['/interface/wireguard/peers/*P', '/interface/wireguard/peers/*Q', '/ip/address/*I', '/interface/wireguard/*A%2F1'])
assert.deepEqual(f.rows['/interface/wireguard'], [{ '.id': '*B', name: 'wg-foreign' }])
assert.deepEqual(f.rows['/interface/wireguard/peers'], [{ '.id': '*F', interface: 'wg-foreign' }])
assert.deepEqual(f.rows['/ip/address'], [{ '.id': '*J', interface: 'wg-foreign' }])
})
for (const path of ['/interface/wireguard', '/interface/wireguard/peers', '/ip/address']) {
test(`read failure ${path} prevents every write`, async () => {
const f = fixture({ failRead: path }); await assert.rejects(deleteWireguardInterface(f.client, '*A/1'), /Read forbidden/); assert.deepEqual(f.deletes, [])
})
}
test('unknown interface never touches foreign resources', async () => {
const f = fixture({ missing: true }); await assert.rejects(deleteWireguardInterface(f.client, '*A/1'), /не найден/); assert.deepEqual(f.deletes, [])
})
test('partial failure is surfaced and interface remains available for retry', async () => {
const f = fixture({ failDelete: '/ip/address/*I' }); await assert.rejects(deleteWireguardInterface(f.client, '*A/1'), /Delete forbidden/)
assert(f.rows['/interface/wireguard'].some(r => r.name === 'wg-own'))
await deleteWireguardInterface({ ...f.client, async delete(path) { const split = path.lastIndexOf('/'), base = path.slice(0, split), id = decodeURIComponent(path.slice(split + 1)); f.rows[base] = f.rows[base].filter(r => r['.id'] !== id) } }, '*A/1')
assert.deepEqual(f.rows['/ip/address'], [{ '.id': '*J', interface: 'wg-foreign' }])
})
for (const path of ['/interface/wireguard', '/interface/wireguard/peers', '/ip/address']) {
test(`unconfirmed removal ${path} cannot report success`, async () => {
const f = fixture({ keep: path }); await assert.rejects(deleteWireguardInterface(f.client, '*A/1'), /не подтвердил/)
})
}
+40
View File
@@ -0,0 +1,40 @@
import type { MikrotikClient } from "./mikrotik.js"
type RosRow = { ".id"?: string; name?: string; interface?: string }
type Client = Pick<MikrotikClient, "get" | "delete">
/** RouterOS retains peer/address records when their WireGuard interface is removed. */
export async function deleteWireguardInterface(client: Client, rosId: string): Promise<void> {
const interfaces = await client.get<RosRow[]>("/interface/wireguard")
const target = interfaces.find(row => row[".id"] === rosId)
if (!target?.name) throw new Error("Интерфейс WireGuard не найден; удаление не выполнено")
const name = target.name
const belongs = (row: RosRow) => row.interface === name || row.interface === rosId
const [peers, addresses] = await Promise.all([
client.get<RosRow[]>("/interface/wireguard/peers"),
client.get<RosRow[]>("/ip/address"),
])
const groups = [
{ path: "/interface/wireguard/peers", rows: peers.filter(belongs) },
{ path: "/ip/address", rows: addresses.filter(belongs) },
]
// Finish all reads and validate identities before the first destructive request.
if (groups.some(group => group.rows.some(row => !row[".id"]))) {
throw new Error("Не удалось определить связанные объекты WireGuard; удаление не выполнено")
}
for (const group of groups) {
for (const row of group.rows) await client.delete(`${group.path}/${encodeURIComponent(row[".id"]!)}`)
}
await client.delete(`/interface/wireguard/${encodeURIComponent(rosId)}`)
const [remainingInterfaces, remainingPeers, remainingAddresses] = await Promise.all([
client.get<RosRow[]>("/interface/wireguard"),
client.get<RosRow[]>("/interface/wireguard/peers"),
client.get<RosRow[]>("/ip/address"),
])
const hasRemaining = (rows: RosRow[], original: RosRow[]) => rows.some(row => belongs(row) || original.some(before => before[".id"] === row[".id"]))
if (remainingInterfaces.some(row => row[".id"] === rosId || row.name === name)
|| hasRemaining(remainingPeers, groups[0].rows)
|| hasRemaining(remainingAddresses, groups[1].rows)) {
throw new Error("RouterOS не подтвердил полное удаление интерфейса WireGuard и связанных объектов")
}
}