fix(wireguard): поддержать IPv6 и исключить выключенные серверы

This commit is contained in:
Denozordec committed 2026-10-11 22:08:10 +07:00
1 parent f3cbc32720
commit 4d3cb659c1
30 files changed
+2001 -82

No files matched your search

+28 -30
View File
@@ -83,6 +83,7 @@ export interface WgSnapshotIface {
mtu: number
privateKey: string
address: string
innerAddresses?: GreSnapshotAddress[]
comment: string
disabled: boolean
peers: WgSnapshotPeer[]
@@ -119,11 +120,7 @@ export interface WgLivePeer {
clientEndpoint: string
}
export interface WgLiveAddr {
rosId: string
interfaceName: string
address: string
}
export interface WgLiveAddr extends GreLiveAddr {}
export interface GreSnapshotTunnel {
name: string
@@ -184,13 +181,14 @@ export interface GreSnapshotAddress {
eui64?: boolean
}
export class GreRestoreConflict extends Error {}
export class InterfaceAddressConflict extends Error {}
export const GreRestoreConflict = InterfaceAddressConflict
export function greAddressPath(address: string): "/ip/address" | "/ipv6/address" {
const parts = address.split("/")
const family = isIP(parts[0] ?? "")
if (!family || address.includes("%") || parts.length !== 2 || !/^\d+$/.test(parts[1] ?? "") || Number(parts[1]) > (family === 6 ? 128 : 32)) {
throw new GreRestoreConflict("Некорректный внутренний адрес GRE: требуется IPv4/IPv6 с префиксом")
throw new InterfaceAddressConflict("Некорректный адрес интерфейса: требуется IPv4/IPv6 с префиксом")
}
return family === 6 ? "/ipv6/address" : "/ip/address"
}
@@ -201,6 +199,9 @@ export function greAddressKey(address: string): string {
return `${path === "/ipv6/address" ? new URL(`http://[${ip}]`).hostname : ip}/${Number(prefix)}`
}
export const interfaceAddressPath = greAddressPath
export const interfaceAddressKey = greAddressKey
// ── IPsec / IKEv2 (managed-объекты, маркер MikrotikManager:ipsec) ────────────
export interface IpsecSnapshotPeer {
@@ -520,6 +521,7 @@ export function canonicalWireguardSnapshot(input: {
mtu: num(iface.mtu, 1420),
privateKey: str(iface.privateKey),
address: str(iface.address),
...(iface.innerAddresses === undefined ? {} : { innerAddresses: canonicalGreSnapshot({ tunnels: [{ name: iface.name, innerAddresses: iface.innerAddresses }] }).tunnels[0]?.innerAddresses ?? [] }),
comment: str(iface.comment),
disabled: Boolean(iface.disabled),
peers: (iface.peers ?? []).map(canonicalPeer).sort((a, b) => a.publicKey.localeCompare(b.publicKey)),
@@ -576,8 +578,8 @@ export function planWireguardRestore(
}
for (const addr of current.addrs) {
if (!wantedNames.has(addr.interfaceName)) {
ops.push({ op: "delete", path: `/ip/address/${addr.rosId}` })
if (!wantedNames.has(addr.interfaceName) && !addr.dynamic) {
ops.push({ op: "delete", path: `${interfaceAddressPath(addr.address)}/${addr.rosId}` })
}
}
@@ -607,17 +609,7 @@ export function planWireguardRestore(
})
}
const liveAddr = current.addrs.find((a) => a.interfaceName === want.name)
if (want.address) {
if (!liveAddr) {
ops.push({ op: "put", path: "/ip/address", body: { address: want.address, interface: want.name } })
} else if (liveAddr.address !== want.address) {
ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` })
ops.push({ op: "put", path: "/ip/address", body: { address: want.address, interface: want.name } })
}
} else if (liveAddr) {
ops.push({ op: "delete", path: `/ip/address/${liveAddr.rosId}` })
}
ops.push(...planStaticInterfaceAddresses(want.name, want.address, want.innerAddresses, current.addrs))
for (const peer of want.peers) {
if (!peer.publicKey) continue
@@ -1014,26 +1006,32 @@ export function planGreRestore(
ops.push({ op: "patch", path: `/interface/gre/${live.rosId}`, body })
}
const liveAddrs = current.addrs.filter(a => a.interfaceName === want.name && !a.dynamic)
if (liveAddrs.some(a => a.fromPool || a.eui64)) throw new GreRestoreConflict("GRE содержит адрес из IPv6-пула или EUI-64. Измените его отдельно в RouterOS")
if (want.innerAddresses === undefined && liveAddrs.length > 1) {
throw new GreRestoreConflict("Старая ревизия GRE не описывает все адреса интерфейса. Восстановление остановлено")
ops.push(...planStaticInterfaceAddresses(want.name, want.localInnerIp, want.innerAddresses, current.addrs))
}
return ops
}
export function planStaticInterfaceAddresses(name: string, primary: string, desired: GreSnapshotAddress[] | undefined, current: GreLiveAddr[]): RosWriteOp[] {
const ops: RosWriteOp[] = []
const liveAddrs = current.filter(a => a.interfaceName === name && !a.dynamic)
if (liveAddrs.some(a => a.fromPool || a.eui64)) throw new InterfaceAddressConflict("Интерфейс содержит адрес из IPv6-пула или EUI-64. Измените его отдельно в RouterOS")
if (desired === undefined && liveAddrs.length > 1) {
throw new InterfaceAddressConflict("Старая ревизия не описывает все адреса интерфейса. Восстановление остановлено")
}
const addresses = want.innerAddresses ?? (want.localInnerIp ? [{ address: want.localInnerIp, disabled: false }] : [])
const addresses = desired ?? (primary ? [{ address: primary, disabled: false }] : [])
const keys = new Set(addresses.map(a => greAddressKey(a.address)))
if (keys.size !== addresses.length) throw new GreRestoreConflict("В ревизии GRE повторяются внутренние адреса")
if (keys.size !== addresses.length) throw new InterfaceAddressConflict("В ревизии повторяются внутренние адреса")
for (const addr of liveAddrs) {
if (!keys.has(greAddressKey(addr.address))) ops.push({ op: "delete", path: `${greAddressPath(addr.address)}/${addr.rosId}` })
}
for (const addr of addresses) {
const existing = liveAddrs.find(a => greAddressKey(a.address) === greAddressKey(addr.address))
if (!existing) ops.push({ op: "put", path: greAddressPath(addr.address), body: greAddressBody(addr, want.name) })
if (!existing) ops.push({ op: "put", path: greAddressPath(addr.address), body: greAddressBody(addr, name) })
else if (Boolean(existing.disabled) !== addr.disabled || (addr.comment !== undefined && str(existing.comment) !== addr.comment) || (greAddressPath(addr.address) === "/ipv6/address" && (Boolean(existing.advertise) !== Boolean(addr.advertise) || (addr.noDad !== undefined && Boolean(existing.noDad) !== addr.noDad)))) {
ops.push({ op: "patch", path: `${greAddressPath(addr.address)}/${existing.rosId}`, body: greAddressBody(addr, want.name) })
ops.push({ op: "patch", path: `${greAddressPath(addr.address)}/${existing.rosId}`, body: greAddressBody(addr, name) })
}
}
}
return ops
}
@@ -0,0 +1,56 @@
import assert from "node:assert/strict"
import { getEnabledServerById } from "./wireguard-live.js"
import { MikrotikClient } from "./mikrotik.js"
import { pool, closePool } from "../db/index.js"
import { initDatabase } from "../db/bootstrap.js"
import { buildApp } from "../index.js"
try {
for (const id of ["srv1", "1abc", "", "0", "-1", "1.5", "9007199254740993", "2147483648"]) assert.equal(await getEnabledServerById(id), null)
const own = process.env.MMAPP_OWN_TEST_DB
if (own) {
assert(/^mm_accept_152_\d+$/.test(own))
assert.equal((await pool.query("SELECT current_database() AS name")).rows[0].name, own)
await initDatabase()
const id = Number((await pool.query("INSERT INTO servers (name,host,enabled) VALUES ('disabled-network-fixture','127.0.0.1',false) RETURNING id")).rows[0].id)
const app = await buildApp({ logger: false, startScheduler: false })
const original = MikrotikClient.fromServer
let contacts = 0, checks = 0
try {
MikrotikClient.fromServer = () => { contacts++; throw new Error("Disabled server was contacted") }
const key = Buffer.alloc(32, 7).toString("base64")
for (const type of ["home-router", "jump-host", "exit-node"]) {
await pool.query("UPDATE servers SET type=$1 WHERE id=$2", [type, id])
assert.equal(await getEnabledServerById(id), null)
for (const path of ["/wireguard", "/gre/tunnels"]) {
const r = await app.inject({ method: "GET", url: `/api${path}?serverId=${id}` })
assert.equal(r.statusCode, 200)
assert.deepEqual(r.json().failures, [])
assert.deepEqual(path === "/wireguard" ? r.json().interfaces : r.json().tunnels, [])
checks++
}
const cases = [
{ method: "POST", path: "/wireguard/interfaces", payload: { serverId: id, name: "own", address: "fd00::1/64" } },
{ method: "PATCH", path: `/wireguard/interfaces/${id}/*none`, payload: { address: "fd00::1/64" } },
{ method: "DELETE", path: `/wireguard/interfaces/${id}/*none`, payload: undefined },
{ method: "POST", path: "/wireguard/peers", payload: { serverId: id, interfaceName: "own", publicKey: key, allowedAddresses: ["fd00::2/128"] } },
{ method: "POST", path: "/wireguard/export", payload: { serverId: id, interfaceName: "own", format: "rsc" } },
{ method: "POST", path: "/wireguard/import", payload: { serverId: id, format: "conf", content: `[Interface]\nPrivateKey = ${key}\nAddress = fd00::1/64\n` } },
{ method: "POST", path: "/gre/tunnels", payload: { serverId: String(id), name: "own", remoteAddress: "192.0.2.1", localInnerIp: "fd00::1/64" } },
{ method: "PATCH", path: "/gre/tunnels", payload: { serverId: String(id), name: "own", localInnerIp: "fd00::1/64" } },
{ method: "DELETE", path: "/gre/tunnels", payload: { serverId: String(id), name: "own" } },
] as const
for (const item of cases) {
const r = await app.inject({ method: item.method, url: "/api" + item.path, payload: item.payload })
assert.equal(r.statusCode, 404, type + " " + item.path + " " + r.body)
checks++
}
}
assert.equal(checks, 33)
assert.equal(contacts, 0)
await pool.query("UPDATE servers SET enabled=true WHERE id=$1", [id])
assert.equal((await getEnabledServerById(id))?.id, id)
console.log("network-disabled-server.test.ts: 33 API/SQL checks for three types, zero RouterOS contacts, enabled positive control passed")
} finally { MikrotikClient.fromServer = original; await app.close() }
} else console.log("network-disabled-server.test.ts: invalid IDs passed; own SQL/disabled API checks skipped (MMAPP_OWN_TEST_DB required)")
} finally { await closePool() }
+6 -2
View File
@@ -225,8 +225,11 @@ export function parseMikrotikRsc(content: string): WgParsedConfig {
continue
}
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add")) {
if (lower.startsWith("/ip address add") || lower.startsWith("/ip/address add") || lower.startsWith("/ipv6 address add") || lower.startsWith("/ipv6/address add")) {
const kv = parseKvLine(line)
if (iface.address) throw new Error("Импорт нескольких адресов пока не поддерживается")
if (kv.interface && kv.interface !== iface.name) throw new Error("Адрес относится к другому интерфейсу")
if (kv["from-pool"] || kv["eui-64"] === "yes" || kv["no-dad"] === "yes" || kv.disabled === "yes" || kv.advertise === "yes") throw new Error("Импорт специальных параметров адреса не поддерживается")
if (kv.address) iface.address = kv.address
continue
}
@@ -327,8 +330,9 @@ export function generateMikrotikRsc(iface: WgExportIface): string {
lines.push(``)
if (iface.address) {
lines.push(`/ip address add \\`)
lines.push(`${iface.address.includes(":") ? "/ipv6" : "/ip"} address add \\`)
lines.push(` address=${iface.address} \\`)
if (iface.address.includes(":")) lines.push(` advertise=no \\`)
lines.push(` interface=${iface.name}`)
lines.push(``)
}
@@ -8,6 +8,7 @@ function fixture(opts: { failRead?: string; failDelete?: string; keep?: string;
'/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' }],
'/ipv6/address': [{ '.id': '*I', interface: 'wg-own' }, { '.id': '*J', interface: 'wg-foreign' }, { '.id': '*D', interface: 'wg-own', dynamic: 'true' } as Row],
}
if (opts.missing) rows['/interface/wireguard'] = rows['/interface/wireguard'].filter(r => r.name !== 'wg-own')
const deletes: string[] = []
@@ -28,12 +29,12 @@ function fixture(opts: { failRead?: string; failDelete?: string; keep?: string;
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.deletes, ['/interface/wireguard/peers/*P', '/interface/wireguard/peers/*Q', '/ip/address/*I', '/ipv6/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']) {
for (const path of ['/interface/wireguard', '/interface/wireguard/peers', '/ip/address', '/ipv6/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, [])
})
@@ -47,7 +48,7 @@ test('partial failure is surfaced and interface remains available for retry', as
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']) {
for (const path of ['/interface/wireguard', '/interface/wireguard/peers', '/ip/address', '/ipv6/address']) {
test(`unconfirmed removal ${path} cannot report success`, async () => {
const f = fixture({ keep: path }); await assert.rejects(deleteWireguardInterface(f.client, '*A/1'), /не подтвердил/)
})
+11 -4
View File
@@ -1,6 +1,6 @@
import type { MikrotikClient } from "./mikrotik.js"
type RosRow = { ".id"?: string; name?: string; interface?: string }
type RosRow = { ".id"?: string; name?: string; interface?: string; dynamic?: string }
type Client = Pick<MikrotikClient, "get" | "delete">
/** RouterOS retains peer/address records when their WireGuard interface is removed. */
@@ -10,13 +10,17 @@ export async function deleteWireguardInterface(client: Client, rosId: string): P
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([
const [peers, addresses, ipv6Addresses] = await Promise.all([
client.get<RosRow[]>("/interface/wireguard/peers"),
client.get<RosRow[]>("/ip/address"),
client.get<RosRow[]>("/ipv6/address"),
])
if (![peers, addresses, ipv6Addresses].every(Array.isArray)) throw new Error("Не удалось прочитать все связанные объекты WireGuard")
const staticAddress = (row: RosRow) => row.dynamic !== "true" && row.dynamic !== "yes"
const groups = [
{ path: "/interface/wireguard/peers", rows: peers.filter(belongs) },
{ path: "/ip/address", rows: addresses.filter(belongs) },
{ path: "/ipv6/address", rows: ipv6Addresses.filter(staticAddress).filter(belongs) },
]
// Finish all reads and validate identities before the first destructive request.
if (groups.some(group => group.rows.some(row => !row[".id"]))) {
@@ -26,15 +30,18 @@ export async function deleteWireguardInterface(client: Client, rosId: string): P
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([
const [remainingInterfaces, remainingPeers, remainingAddresses, remainingIpv6Addresses] = await Promise.all([
client.get<RosRow[]>("/interface/wireguard"),
client.get<RosRow[]>("/interface/wireguard/peers"),
client.get<RosRow[]>("/ip/address"),
client.get<RosRow[]>("/ipv6/address"),
])
if (![remainingInterfaces, remainingPeers, remainingAddresses, remainingIpv6Addresses].every(Array.isArray)) throw new Error("RouterOS не подтвердил полное удаление WireGuard")
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)) {
|| hasRemaining(remainingAddresses, groups[1].rows)
|| hasRemaining(remainingIpv6Addresses.filter(staticAddress), groups[2].rows)) {
throw new Error("RouterOS не подтвердил полное удаление интерфейса WireGuard и связанных объектов")
}
}
@@ -13,6 +13,7 @@ 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" }],
"/ipv6/address": [],
[peers]: [{ ".id": "*P", interface: "foreign-wg", "public-key": "foreign-peer" }],
}
const baseline = structuredClone(rows), deletes: string[] = []
@@ -35,6 +36,38 @@ function fixture() {
}
const state = (expected: WireguardImportApplyError["state"]) => (error: unknown) => error instanceof WireguardImportApplyError && error.state === expected
test("IPv6 import confirms normalized receipt, ignores automatic link-local, preserves IPv4 with identical ID", async () => {
const f = fixture(), put = f.client.put
f.rows[addresses].push({ ".id": "*OWN/2", interface: "foreign-wg", address: "192.0.2.8/24" })
f.client.put = async (path, value) => {
const result = await put(path, value)
if (path === "/ipv6/address") f.rows[path].at(-1)!.address = "fd00:152::1/64"
if (path === iface) f.rows["/ipv6/address"].push({ ".id": "*D", interface: "own-wg", address: "fe80::1/64", dynamic: "true" })
return result
}
await applyWireguardImport(f.client, { ...parsed, interface: { ...parsed.interface, address: "fd00:0152:0:0:0:0:0:1/64" } }, body)
assert(f.rows["/ipv6/address"].some(row => row.address === "fd00:152::1/64" && row.advertise === "no"))
assert(f.rows[addresses].some(row => row[".id"] === "*OWN/2" && row.interface === "foreign-wg"))
assert.deepEqual(f.deletes, [])
})
test("IPv6 read failure prevents every import mutation", async () => {
const f = fixture(), get = f.client.get
f.client.get = async path => { if (path === "/ipv6/address") throw new Error("IPv6 denied"); return get(path) }
await assert.rejects(applyWireguardImport(f.client, parsed, body), /IPv6 denied/)
assert.equal(f.puts(), 0)
})
test("rejected peer compensates only owned IPv6 address, not colliding IPv4 ID", async () => {
const f = fixture(), put = f.client.put
f.rows[addresses].push({ ".id": "*OWN/2", interface: "foreign-wg", address: "192.0.2.8/24" })
f.client.put = async (path, value) => { if (path === peers) throw new MikrotikError(400, path, "rejected"); return put(path, value) }
await assert.rejects(applyWireguardImport(f.client, { ...parsed, interface: { ...parsed.interface, address: "fd00:152::1/64" } }, body), state("rolled-back"))
assert(f.deletes.includes("/ipv6/address/*OWN%2F2"))
assert(!f.deletes.some(path => path.startsWith(addresses)))
assert(f.rows[addresses].some(row => row[".id"] === "*OWN/2"))
})
test("baseline read failure prevents every mutation", async () => {
const f = fixture()
f.client.get = async () => { throw new Error("Read unavailable") }
+12 -5
View File
@@ -1,10 +1,11 @@
import { MikrotikError, type MikrotikClient } from "./mikrotik.js"
import type { WgParsedConfig, WgParsedPeer } from "./wireguard-config.js"
import { asRosArray, toRosBody } from "./wireguard-ros.js"
import { interfaceAddressPath, interfaceAddressKey } from "./entity-snapshots.js"
type Client = Pick<MikrotikClient, "get" | "put" | "delete">
type Row = Record<string, string>
const paths = ["/interface/wireguard", "/ip/address", "/interface/wireguard/peers"] as const
const paths = ["/interface/wireguard", "/ip/address", "/interface/wireguard/peers", "/ipv6/address"] as const
type Created = { path: typeof paths[number]; id: string; identity: Record<string, string> }
export class WireguardImportApplyError extends Error {
@@ -24,12 +25,17 @@ export async function applyWireguardImport(
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 addressPath = parsed.interface.address ? interfaceAddressPath(parsed.interface.address) : undefined
const read = async () => Object.fromEntries(await Promise.all(paths.map(async path => {
const rows = await client.get(path)
if (!Array.isArray(rows)) throw new Error("Неполный каталог WireGuard для подтверждения импорта")
return [path, (rows as Row[]).filter(row => !path.endsWith("/address") || (row.dynamic !== "true" && row.dynamic !== "yes"))]
}))) 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 owns = (row: Row | undefined, item: Created) => !!row && Object.entries(item.identity).every(([key, value]) => key === "address" ? !!row.address && interfaceAddressKey(row.address) === interfaceAddressKey(value) : row[key] === value)
const put = async (path: typeof paths[number], body: Record<string, string>, identity: Record<string, string>) => {
let receipt: unknown
try {
@@ -49,8 +55,9 @@ export async function applyWireguardImport(
}
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 })
if (parsed.interface.address && addressPath) {
const path = addressPath
await put(path, { address: parsed.interface.address, interface: name, ...(path === "/ipv6/address" ? { advertise: "no" } : {}) }, { 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 })
@@ -0,0 +1,52 @@
import assert from "node:assert/strict"
import { MikrotikClient } from "./mikrotik.js"
import { fetchWireguardRestoreState } from "./wireguard-live.js"
import { putIpAddress } from "./wireguard-ros.js"
import { canonicalWireguardSnapshot, planWireguardRestore, InterfaceAddressConflict } from "./entity-snapshots.js"
import { parseWireguardImport } from "./wireguard-import.js"
import { generateMikrotikRsc } from "./wireguard-config.js"
import { wgCreateInterfaceSchema, wgPatchInterfaceSchema } from "@mmapp/contracts/wireguard"
const original = MikrotikClient.fromServer
const calls: string[] = []
const raw: Record<string, unknown[]> = {
"/interface/wireguard": [{ ".id": "*w", name: "wg-own", "private-key": "" }],
"/interface/wireguard/peers": [],
"/ip/address": [{ ".id": "*1", interface: "wg-own", address: "10.1.0.1/30" }],
"/ipv6/address": [{ ".id": "*1", interface: "wg-own", address: "fd00:152::1/64", advertise: "true", comment: "own", "no-dad": "true" }, { ".id": "*2", interface: "wg-own", address: "fd00:152::2/64", disabled: "true" }, { ".id": "*3", interface: "wg-own", address: "fe80::1/64", dynamic: "true" }],
}
try {
MikrotikClient.fromServer = () => ({ get: async (path: string) => { calls.push(path); return structuredClone(raw[path]) } }) as unknown as MikrotikClient
const state = await fetchWireguardRestoreState({ id: 1 } as Parameters<typeof fetchWireguardRestoreState>[0])
assert.equal(state.addrs.length, 3)
assert.equal(state.snapshot.interfaces[0]?.innerAddresses?.length, 3)
assert.equal(planWireguardRestore(state.snapshot, state).filter(op => op.path.includes("/address")).length, 0)
const legacy = canonicalWireguardSnapshot({ interfaces: [{ name: "wg-own", address: "fd00:152::1/64" }] })
assert.throws(() => planWireguardRestore(legacy, state), InterfaceAddressConflict)
const desired = structuredClone(state.snapshot)
desired.interfaces[0]!.innerAddresses = [{ address: "fd00:152::9/64", disabled: false }]
const ops = planWireguardRestore(desired, state)
assert(ops.some(op => op.op === "delete" && op.path === "/ip/address/*1"))
assert(ops.some(op => op.op === "delete" && op.path === "/ipv6/address/*1"))
assert(ops.some(op => op.op === "delete" && op.path === "/ipv6/address/*2"))
assert(!ops.some(op => op.path.endsWith("/*3")))
assert(ops.some(op => op.op === "put" && op.path === "/ipv6/address" && op.body.advertise === "no"))
await putIpAddress({ put: async (path: string, body: Record<string, string>) => { assert.equal(path, "/ipv6/address"); assert.equal(body.advertise, "no") } } as unknown as MikrotikClient, "fd00::1/64", "wg-own")
MikrotikClient.fromServer = () => ({ get: async (path: string) => { if (path === "/ipv6/address") throw new Error("Unauthorized"); return [] } }) as unknown as MikrotikClient
await assert.rejects(fetchWireguardRestoreState({ id: 1 } as Parameters<typeof fetchWireguardRestoreState>[0]), /Unauthorized/)
} finally { MikrotikClient.fromServer = original }
const key = Buffer.alloc(32, 7).toString("base64")
const rsc = generateMikrotikRsc({ name: "wg-own", mtu: 1400, address: "fd00:152::1/64", privateKey: key, peers: [] })
assert(rsc.includes("/ipv6 address add"))
assert(rsc.includes("advertise=no"))
assert.equal(parseWireguardImport(rsc, "rsc").interface.address, "fd00:152::1/64")
assert.throws(() => parseWireguardImport(rsc.replace("interface=wg-own", "interface=foreign"), "rsc"), /другому интерфейсу/)
assert.throws(() => parseWireguardImport(rsc + "\n/ipv6 address add address=fd00::2/64 interface=wg-own", "rsc"), /нескольких/)
assert.throws(() => parseWireguardImport(rsc.replace("advertise=no", "advertise=yes"), "rsc"), /специальных/)
for (const address of ["fd00::1", "fd00::1/129", "bad/64", "10.0.0.1/33"]) {
assert(!wgCreateInterfaceSchema.safeParse({ serverId: 1, name: "own", address }).success)
assert(!wgPatchInterfaceSchema.safeParse({ address }).success)
}
assert(wgCreateInterfaceSchema.safeParse({ serverId: 1, name: "own", address: "fd00::1/64" }).success)
assert(wgPatchInterfaceSchema.safeParse({ address: "" }).success)
console.log("WireGuard IPv6: dual-stack static snapshot, family restore, legacy conflict, failed read, native IPv6 PUT, RSC roundtrip and contracts passed")
+30 -14
View File
@@ -1,8 +1,9 @@
import { parseWireGuardBytes as parseBytes } from "./wireguard-observed-values.js"
import {boundedCompleteCount,completeCollectionCount} from "./complete-count.js"
import { eq } from "drizzle-orm"
import { and, eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { parseDbServerId } from "../utils/server-id.js"
import { MikrotikClient } from "./mikrotik.js"
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
import {
@@ -52,6 +53,12 @@ interface RosIpAddress {
address?: string
interface?: string
disabled?: string
dynamic?: string
advertise?: string
comment?: string
"no-dad"?: string
"from-pool"?: string
"eui-64"?: string
}
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
@@ -121,11 +128,14 @@ async function fetchForServer(
includePrivateKey: boolean,
): Promise<WgIfaceDto[]> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
const [ifacesRaw, peersRaw, v4Raw, v6Raw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
client.get<RosIpAddress[]>("/ip/address"),
client.get<RosIpAddress[]>("/ipv6/address"),
])
if (![ifacesRaw, peersRaw, v4Raw, v6Raw].every(Array.isArray)) throw new Error("RouterOS: неполный каталог WireGuard/адресов")
const addrsRaw = [...v4Raw, ...v6Raw].sort((a, b) => (a.address ?? "").localeCompare(b.address ?? ""))
const peersByIface = new Map<string, WgPeerDto[]>()
peersRaw.forEach((p, idx) => {
@@ -138,7 +148,7 @@ async function fetchForServer(
const addrByIface = new Map<string, string>()
for (const a of addrsRaw) {
if (a.disabled === "true" || a.disabled === "yes") continue
if (a.disabled === "true" || a.disabled === "yes" || a.dynamic === "true" || a.dynamic === "yes") continue
const iface = (a.interface ?? "").trim()
const addr = (a.address ?? "").trim()
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
@@ -164,11 +174,14 @@ export async function fetchWireguardRestoreState(server: ServerRow): Promise<{
snapshot: WgSnapshot
}> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
const [ifacesRaw, peersRaw, v4Raw, v6Raw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
client.get<RosIpAddress[]>("/ip/address"),
client.get<RosIpAddress[]>("/ipv6/address"),
])
if (![ifacesRaw, peersRaw, v4Raw, v6Raw].every(Array.isArray)) throw new Error("RouterOS: неполный каталог WireGuard/адресов")
const addrsRaw = [...v4Raw, ...v6Raw].sort((a, b) => Number(a.disabled === "true" || a.disabled === "yes") - Number(b.disabled === "true" || b.disabled === "yes") || (a.address ?? "").localeCompare(b.address ?? ""))
const ifaces: WgLiveIface[] = (Array.isArray(ifacesRaw) ? ifacesRaw : []).map((w) => ({
name: (w.name ?? "").trim(),
@@ -205,13 +218,15 @@ export async function fetchWireguardRestoreState(server: ServerRow): Promise<{
const addrs: WgLiveAddr[] = []
for (const a of Array.isArray(addrsRaw) ? addrsRaw : []) {
if (a.disabled === "true" || a.disabled === "yes") continue
if (a.dynamic === "true" || a.dynamic === "yes") continue
const iface = (a.interface ?? "").trim()
const address = (a.address ?? "").trim()
const rosId = String(a[".id"] ?? "")
if (!iface || !address || !rosId) continue
if (!ifaces.some((i) => i.name === iface)) continue
addrs.push({ rosId, interfaceName: iface, address })
addrs.push({ rosId, interfaceName: iface, address, disabled: a.disabled === "true" || a.disabled === "yes", comment: a.comment ?? "",
...(address.includes(":") ? { advertise: a.advertise === "true" || a.advertise === "yes", noDad: a["no-dad"] === "true" || a["no-dad"] === "yes", fromPool: a["from-pool"] ?? "", eui64: a["eui-64"] === "true" || a["eui-64"] === "yes" } : {}),
})
}
const snapshot = canonicalWireguardSnapshot({
@@ -221,6 +236,7 @@ export async function fetchWireguardRestoreState(server: ServerRow): Promise<{
mtu: iface.mtu,
privateKey: iface.privateKey,
address: addrs.find((a) => a.interfaceName === iface.name)?.address ?? "",
innerAddresses: addrs.filter(a => a.interfaceName === iface.name).map(a => ({ address: a.address, disabled: Boolean(a.disabled), comment: a.comment, ...(a.advertise === undefined ? {} : { advertise: a.advertise, noDad: a.noDad, fromPool: a.fromPool, eui64: a.eui64 }) })),
comment: iface.comment,
disabled: iface.disabled,
peers: peers.filter((p) => p.interfaceName === iface.name),
@@ -247,11 +263,11 @@ export async function listWireGuardInterfaces(opts?: {
const includePrivateKey = opts?.includePrivateKey === true
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
const id = parseDbServerId(opts.serverId)
if (id === null || !Number.isSafeInteger(id) || id > 2147483647) {
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
const row = (await db.select().from(servers).where(and(eq(servers.id, id), eq(servers.enabled, true))).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
@@ -278,9 +294,9 @@ export async function listWireGuardInterfaces(opts?: {
export async function countWireGuardInterfaces():Promise<number|undefined>{return boundedCompleteCount(()=>listWireGuardInterfaces({ includePrivateKey: false }),value=>completeCollectionCount(value,"interfaces"))}
export async function getEnabledServerById(serverId: string | number) {
const id = typeof serverId === "number" ? serverId : Number.parseInt(String(serverId), 10)
if (!Number.isFinite(id)) return null
return (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0] ?? null
const id = parseDbServerId(serverId)
if (id === null || !Number.isSafeInteger(id) || id > 2147483647) return null
return (await db.select().from(servers).where(and(eq(servers.id, id), eq(servers.enabled, true))).limit(1))[0] ?? null
}
export type CatalogWgPeer = {
+3 -1
View File
@@ -1,4 +1,5 @@
import type { MikrotikClient } from "./mikrotik.js"
import { interfaceAddressPath } from "./entity-snapshots.js"
/** Общие PUT iface / peer / address для `/wireguard` и traffic-flow overlay. */
export function toRosBody(obj: Record<string, string | undefined>): Record<string, string> {
@@ -31,7 +32,8 @@ export async function putIpAddress(
address: string,
iface: string,
): Promise<void> {
await client.put("/ip/address", { address, interface: iface })
const path = interfaceAddressPath(address)
await client.put(path, { address, interface: iface, ...(path === "/ipv6/address" ? { advertise: "no" } : {}) })
}
export async function putWireguardPeer(