fix(wireguard): удалять связанные пиры и IP вместе с интерфейсом
This commit is contained in:
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'), /не подтвердил/)
|
||||
})
|
||||
}
|
||||
@@ -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 и связанных объектов")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user