fix(wireguard): проверять конфигурацию до импорта

This commit is contained in:
Denozordec committed 2026-10-09 08:39:36 +07:00
1 parent 7ce43adc07
commit 479f2adc78
14 files changed
+247 -30

No files matched your search

@@ -0,0 +1,27 @@
import assert from "node:assert/strict"
import Fastify from "fastify"
import { validatorCompiler, serializerCompiler } from "@fastify/type-provider-zod"
import wireguardRoutes from "./wireguard.js"
import { MikrotikClient } from "../services/mikrotik.js"
const app = Fastify()
app.setValidatorCompiler(validatorCompiler)
app.setSerializerCompiler(serializerCompiler)
await app.register(wireguardRoutes, { prefix: "/api" })
let mutations = 0
const originalPut = MikrotikClient.prototype.put
MikrotikClient.prototype.put = async () => { mutations++; throw new Error("Unexpected RouterOS mutation") }
try {
for (const dryRun of [false, true]) {
for (const content of ["[Interface]\n# PrivateKey = placeholder", "[Peer]\nPublicKey = placeholder"]) {
const response = await app.inject({ method: "POST", url: "/api/wireguard/import", payload: { serverId: "1", content, format: "conf", dryRun } })
assert.equal(response.statusCode, 400)
assert.match(response.json().error, /PrivateKey|Interface/)
}
}
assert.equal(mutations, 0)
} finally {
MikrotikClient.prototype.put = originalPut
await app.close()
}
console.log("WireGuard import HTTP: invalid template/section rejected for apply and dry-run, zero mutations")
+7 -3
View File
@@ -12,11 +12,11 @@ import {
} from "@mmapp/contracts/wireguard"
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
import { deleteWireguardInterface } from "../services/wireguard-delete.js"
import { parseWireguardImport } from "../services/wireguard-import.js"
import {
generateMikrotikRsc,
generateNativeConf,
generatePeerClientConf,
parseWgConfig,
type WgParsedConfig,
} from "../services/wireguard-config.js"
import {
@@ -125,7 +125,7 @@ async function applyParsedConfig(
peerToRosBody({
interfaceName: name,
publicKey: p.publicKey,
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
allowedAddresses: p.allowedAddresses,
endpointAddress: p.endpointAddress,
endpointPort: p.endpointPort,
persistentKeepalive: p.persistentKeepalive,
@@ -345,7 +345,7 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
const body = parsed.data
let config: WgParsedConfig
try {
config = parseWgConfig(body.content, body.format)
config = parseWireguardImport(body.content, body.format)
} catch (e) {
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
}
@@ -358,6 +358,10 @@ const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
const client = MikrotikClient.fromServer(server)
try {
const existing = await client.get<Array<{ name?: string }>>("/interface/wireguard")
if (existing.some(iface => iface.name === config.interface.name)) {
return reply.status(409).send({ error: "Интерфейс с таким именем уже существует. Укажите другое имя в конфигурации", preview })
}
const applied = await applyParsedConfig(client, config)
await recordWireguard(server, "copy")
return reply.send({ dryRun: false, preview, applied })
+11 -11
View File
@@ -136,8 +136,8 @@ export function parseNativeConf(content: string): WgParsedConfig {
if (section === "interface") {
if (key === "privatekey") iface.privateKey = value
else if (key === "address") iface.address = value.split(",")[0]?.trim()
else if (key === "listenport") iface.listenPort = Number.parseInt(value, 10) || undefined
else if (key === "mtu") iface.mtu = Number.parseInt(value, 10) || undefined
else if (key === "listenport") iface.listenPort = Number(value)
else if (key === "mtu") iface.mtu = Number(value)
else if (key === "name") iface.name = value || iface.name
} else if (section === "peer" && currentPeer) {
if (key === "publickey") currentPeer.publicKey = value
@@ -150,16 +150,16 @@ export function parseNativeConf(content: string): WgParsedConfig {
const lastColon = value.lastIndexOf(":")
if (lastColon > 0 && !value.includes("]:")) {
currentPeer.endpointAddress = value.slice(0, lastColon)
currentPeer.endpointPort = Number.parseInt(value.slice(lastColon + 1), 10) || undefined
currentPeer.endpointPort = Number(value.slice(lastColon + 1))
} else if (value.startsWith("[") && value.includes("]:")) {
const idx = value.indexOf("]:")
currentPeer.endpointAddress = value.slice(1, idx)
currentPeer.endpointPort = Number.parseInt(value.slice(idx + 2), 10) || undefined
currentPeer.endpointPort = Number(value.slice(idx + 2))
} else {
currentPeer.endpointAddress = value
}
} else if (key === "persistentkeepalive") {
currentPeer.persistentKeepalive = Number.parseInt(value, 10) || undefined
currentPeer.persistentKeepalive = Number(value)
} else if (key === "presharedkey") {
// ignore PSK for ROS import for now
}
@@ -187,8 +187,8 @@ export function parseMikrotikRsc(content: string): WgParsedConfig {
) {
const kv = parseKvLine(line)
if (kv.name) iface.name = kv.name
if (kv["listen-port"]) iface.listenPort = Number.parseInt(kv["listen-port"], 10) || undefined
if (kv.mtu) iface.mtu = Number.parseInt(kv.mtu, 10) || undefined
if (kv["listen-port"]) iface.listenPort = Number(kv["listen-port"])
if (kv.mtu) iface.mtu = Number(kv.mtu)
if (kv["private-key"]) iface.privateKey = kv["private-key"]
if (kv.comment) iface.comment = kv.comment
if (kv.disabled === "yes") iface.disabled = true
@@ -207,13 +207,13 @@ export function parseMikrotikRsc(content: string): WgParsedConfig {
.filter(Boolean)
peers.push({
publicKey: kv["public-key"] ?? "",
allowedAddresses: allowed.length ? allowed : ["0.0.0.0/0"],
allowedAddresses: allowed,
endpointAddress: kv["endpoint-address"],
endpointPort: kv["endpoint-port"]
? Number.parseInt(kv["endpoint-port"], 10) || undefined
? Number(kv["endpoint-port"])
: undefined,
persistentKeepalive: kv["persistent-keepalive"]
? Number.parseInt(kv["persistent-keepalive"], 10) || undefined
? Number(kv["persistent-keepalive"])
: undefined,
comment: kv.comment,
name: kv.name,
@@ -232,7 +232,7 @@ export function parseMikrotikRsc(content: string): WgParsedConfig {
}
}
if (!foundIface && peers.length === 0) {
if (!foundIface) {
throw new Error("Не удалось распознать RouterOS WireGuard .rsc")
}
return { format: "rsc", interface: iface, peers }
@@ -0,0 +1,51 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { parseWireguardImport } from "./wireguard-import.js"
import { generatePeerClientConf } from "./wireguard-config.js"
const key = Buffer.alloc(32, 7).toString("base64")
const conf = `[Interface]\nPrivateKey = ${key}\nAddress = 198.18.255.241/30\nListenPort = 51879\n[Peer]\nPublicKey = ${key}\nAllowedIPs = 198.18.255.242/32\nEndpoint = 192.0.2.1:51879\n`
test("complete conf accepts the documented wg0 name and explicit routes", () => {
const parsed = parseWireguardImport(conf, "auto")
assert.equal(parsed.interface.name, "wg0")
assert.deepEqual(parsed.peers[0]?.allowedAddresses, ["198.18.255.242/32"])
assert.equal(parseWireguardImport(conf.replace("198.18.255.242/32", "0.0.0.0/0"), "conf").peers[0]?.allowedAddresses[0], "0.0.0.0/0")
})
test("generated client template cannot be imported before it is completed", () => {
const template = generatePeerClientConf({ serverPublicKey: key, endpoint: "192.0.2.1:51879" })
assert.throws(() => parseWireguardImport(template, "conf"), /PrivateKey/)
})
for (const [label, content, message] of [
["absent interface", "[Peer]\nPublicKey = " + key, /Interface/],
["duplicate interface", conf + "[Interface]\n", /Interface/],
["missing peer key", conf.replace(`PublicKey = ${key}`, "# PublicKey absent"), /PublicKey/],
["invalid private key", conf.replace(`PrivateKey = ${key}`, "PrivateKey = placeholder"), /PrivateKey/],
["invalid peer key", conf.replace(`PublicKey = ${key}`, "PublicKey = placeholder"), /PublicKey/],
["absent routes", conf.replace("AllowedIPs = 198.18.255.242/32", "# AllowedIPs absent"), /AllowedIPs/],
["invalid prefix", conf.replace("198.18.255.242/32", "198.18.255.242/33"), /AllowedIPs/],
["extra prefix separator", conf.replace("198.18.255.242/32", "198.18.255.242/32/"), /AllowedIPs/],
["invalid address", conf.replace("198.18.255.241/30", "not-an-address"), /Address/],
["unsupported multiple addresses", conf.replace("198.18.255.241/30", "198.18.255.241/30, 198.18.255.242/30"), /нескольких/],
["unsupported PSK", conf + `PresharedKey = ${key}\n`, /PresharedKey/],
["invalid listen port", conf.replace("ListenPort = 51879", "ListenPort = 65536"), /ListenPort/],
["zero listen port", conf.replace("ListenPort = 51879", "ListenPort = 0"), /ListenPort/],
["non-numeric listen port", conf.replace("ListenPort = 51879", "ListenPort = invalid"), /ListenPort/],
["invalid MTU", conf.replace("[Interface]", "[Interface]\nMTU = invalid"), /MTU/],
["zero MTU", conf.replace("[Interface]", "[Interface]\nMTU = 0"), /MTU/],
["zero endpoint port", conf.replace("192.0.2.1:51879", "192.0.2.1:0"), /Endpoint/],
["invalid endpoint port", conf.replace("192.0.2.1:51879", "192.0.2.1:invalid"), /Endpoint/],
["negative keepalive", conf + "PersistentKeepalive = -1", /PersistentKeepalive/],
] as const) {
test(label, () => assert.throws(() => parseWireguardImport(content, "conf"), message))
}
test("RSC peers without routes or interface are rejected without default routes", () => {
const peer = `/interface wireguard peers add public-key="${key}"`
assert.throws(() => parseWireguardImport(peer, "rsc"), /распознать/)
assert.throws(() => parseWireguardImport(`/interface wireguard add name=own-wg\n${peer}`, "rsc"), /allowed-address/)
assert.throws(() => parseWireguardImport("/interface wireguard add name=own-wg mtu=0", "rsc"), /MTU/)
assert.deepEqual(parseWireguardImport(`/interface wireguard add name=own-wg\n${peer} allowed-address=192.0.2.0/24`, "rsc").peers[0]?.allowedAddresses, ["192.0.2.0/24"])
})
+64
View File
@@ -0,0 +1,64 @@
import { isIP } from "node:net"
import { parseWgConfig, type WgParsedConfig } from "./wireguard-config.js"
function validKey(value: string): boolean {
return /^[A-Za-z0-9+/]{43}=$/.test(value) && Buffer.from(value, "base64").length === 32
}
function validNetwork(value: string): boolean {
const parts = value.split("/")
const [address, prefix] = parts
const version = isIP(address ?? "")
return parts.length === 2 && version !== 0 && /^\d+$/.test(prefix ?? "") && Number(prefix) <= (version === 4 ? 32 : 128)
}
/** Validate the complete import before the first RouterOS mutation. Never echo keys. */
export function parseWireguardImport(content: string, format: "auto" | "rsc" | "conf"): WgParsedConfig {
const parsed = parseWgConfig(content, format)
if (parsed.format === "conf") {
if ((content.match(/^\s*\[Interface\]\s*$/gim) ?? []).length !== 1) {
throw new Error("Конфигурация .conf должна содержать одну секцию [Interface]")
}
if (!parsed.interface.privateKey) {
throw new Error("Заполните PrivateKey: клиентский шаблон нельзя применить без приватного ключа")
}
if ((content.match(/^\s*\[Peer\]\s*$/gim) ?? []).length !== parsed.peers.length) {
throw new Error("У каждого пира должен быть PublicKey")
}
if (/^\s*Address\s*=.*,/im.test(content)) {
throw new Error("Импорт нескольких адресов Address пока не поддерживается; укажите один адрес")
}
}
if (/^\s*PresharedKey\s*=\s*\S/im.test(content) || /\bpreshared-key\s*=/i.test(content)) {
throw new Error("Импорт PresharedKey пока не поддерживается; конфигурация не применена")
}
if (!parsed.interface.name.trim()) throw new Error("Укажите имя интерфейса")
if (parsed.interface.privateKey && !validKey(parsed.interface.privateKey)) {
throw new Error("Некорректный PrivateKey: нужен ключ WireGuard длиной 32 байта в base64")
}
if (parsed.interface.address && !validNetwork(parsed.interface.address)) {
throw new Error("Некорректный Address: укажите IPv4 или IPv6 адрес с префиксом")
}
if (parsed.interface.listenPort !== undefined && (!Number.isInteger(parsed.interface.listenPort) || parsed.interface.listenPort < 1 || parsed.interface.listenPort > 65535)) {
throw new Error("ListenPort должен быть от 1 до 65535")
}
if (parsed.interface.mtu !== undefined && (!Number.isInteger(parsed.interface.mtu) || parsed.interface.mtu <= 0)) {
throw new Error("MTU должен быть положительным целым числом")
}
for (const peer of parsed.peers) {
if (!validKey(peer.publicKey)) throw new Error("Некорректный PublicKey пира")
if (!peer.allowedAddresses.length || peer.allowedAddresses.some(value => !validNetwork(value))) {
throw new Error("Заполните AllowedIPs / allowed-address каждого пира корректными сетями с префиксом")
}
if (peer.endpointPort !== undefined && (!Number.isInteger(peer.endpointPort) || peer.endpointPort < 1 || peer.endpointPort > 65535)) {
throw new Error("Порт Endpoint должен быть от 1 до 65535")
}
if (parsed.format === "conf" && peer.endpointAddress && peer.endpointPort === undefined) {
throw new Error("Укажите порт Endpoint после адреса")
}
if (peer.persistentKeepalive !== undefined && (!Number.isInteger(peer.persistentKeepalive) || peer.persistentKeepalive < 0)) {
throw new Error("PersistentKeepalive должен быть неотрицательным целым числом")
}
}
return parsed
}