fix(wireguard): поддержать IPv6 и исключить выключенные серверы
This commit is contained in:
1 parent
f3cbc32720
commit
4d3cb659c1
30 files changed
+2001
-82
No files matched your search
@@ -39,7 +39,7 @@ import {
|
||||
loadRevisionForRestore,
|
||||
type ConfigRevisionSource,
|
||||
} from "../services/config-revisions.js"
|
||||
import { parseWireguardSnapshot, planWireguardRestore } from "../services/entity-snapshots.js"
|
||||
import { parseWireguardSnapshot, planWireguardRestore, planStaticInterfaceAddresses, interfaceAddressPath, InterfaceAddressConflict } from "../services/entity-snapshots.js"
|
||||
import { executeRosOps } from "../services/ros-ops.js"
|
||||
import { parseDbServerId } from "../utils/server-id.js"
|
||||
import { z } from "zod"
|
||||
@@ -190,6 +190,17 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const client = MikrotikClient.fromServer(server)
|
||||
const d = parsed.data
|
||||
try {
|
||||
let addressOps: Parameters<typeof executeRosOps>[1] = []
|
||||
if (d.address !== undefined) {
|
||||
const state = await fetchWireguardRestoreState(server)
|
||||
const live = state.ifaces.find(i => i.rosId === rosIdParam(rosId))
|
||||
if (!live) return reply.status(404).send({ error: "Интерфейс не найден" })
|
||||
if (d.name && d.name !== live.name) return reply.status(409).send({ error: "Изменяйте имя и адрес WireGuard отдельными операциями" })
|
||||
const original = state.snapshot.interfaces.find(i => i.name === live.name)!
|
||||
const desired = original.innerAddresses!.filter(a => a.address !== original.address).map(a => ({ ...a }))
|
||||
if (d.address && !desired.some(a => a.address === d.address)) desired.push({ address: d.address, disabled: false, ...(interfaceAddressPath(d.address) === "/ipv6/address" ? { advertise: false } : {}) })
|
||||
addressOps = planStaticInterfaceAddresses(live.name, d.address, desired, state.addrs)
|
||||
}
|
||||
await client.patch(
|
||||
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
|
||||
toRosBody({
|
||||
@@ -201,11 +212,12 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
||||
}),
|
||||
)
|
||||
await executeRosOps(client, addressOps)
|
||||
await recordWireguard(server, "apply")
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
return reply.status(e instanceof InterfaceAddressConflict ? 409 : 502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -220,7 +232,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ ok: true })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
return reply.status(e instanceof InterfaceAddressConflict ? 409 : 502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -344,6 +356,16 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const iface = await findIface(list.interfaces, String(server.id), body.interfaceName)
|
||||
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
|
||||
|
||||
if (body.format === "rsc" || body.format === "conf") {
|
||||
const state = await fetchWireguardRestoreState(server)
|
||||
const addresses = state.snapshot.interfaces.find(i => i.name === iface.name)?.innerAddresses
|
||||
if (!addresses) return reply.status(409).send({ error: "Интерфейс изменился. Обновите список перед экспортом" })
|
||||
if (addresses.length > 1 || addresses.some(a => a.disabled || a.advertise || a.noDad || a.fromPool || a.eui64)) {
|
||||
return reply.status(409).send({ error: "Экспорт не воспроизводит несколько адресов или специальные параметры. Используйте экспорт RouterOS" })
|
||||
}
|
||||
if ((addresses[0]?.address ?? "") !== (iface.address ?? "")) return reply.status(409).send({ error: "Адрес изменился. Обновите список перед экспортом" })
|
||||
}
|
||||
|
||||
if (body.format === "rsc") {
|
||||
const content = generateMikrotikRsc({
|
||||
name: iface.name,
|
||||
@@ -465,7 +487,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
return reply.send({ ok: true, interfaces: list.interfaces })
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
||||
return reply.status(e instanceof InterfaceAddressConflict ? 409 : 502).send({ error: `RouterOS: ${msg}` })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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() }
|
||||
@@ -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'), /не подтвердил/)
|
||||
})
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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")
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user