Docker images / prepare-release (push) Successful in 12s
Docker images / backend-test (push) Successful in 2m20s
Docker images / frontend-image (push) Successful in 2m51s
Docker images / updater-image (push) Successful in 44s
Docker images / backend-image (push) Successful in 2m49s
Docker images / notify-webhook (push) Skipped
Docker images / publish-release (push) Successful in 12s
- Added configuration history management for Firewall, GRE, and WireGuard pages, enabling users to view and restore previous configurations. - Introduced new components for displaying configuration history and integrated them into the respective pages. - Enhanced API routes to support fetching and restoring configuration revisions, ensuring data consistency across the application. - Updated state management to handle loading and restoring states effectively, improving user experience during data operations. - Enhanced tests to cover new functionalities and ensure reliability. Co-authored-by: Cursor <[email protected]>
521 lines
19 KiB
TypeScript
521 lines
19 KiB
TypeScript
import type { FastifyPluginAsyncZod } from "@fastify/type-provider-zod"
|
|
import {
|
|
wgCreateInterfaceSchema,
|
|
wgCreatePeerRequestSchema,
|
|
wgExportRequestSchema,
|
|
wgImportRequestSchema,
|
|
wgPatchInterfaceSchema,
|
|
wgPatchPeerSchema,
|
|
type WgCreatePeerRequest,
|
|
type WgIfaceDto,
|
|
} from "@mmapp/contracts/wireguard"
|
|
import { MikrotikClient, MikrotikError } from "../services/mikrotik.js"
|
|
import {
|
|
generateMikrotikRsc,
|
|
generateNativeConf,
|
|
generatePeerClientConf,
|
|
parseWgConfig,
|
|
type WgParsedConfig,
|
|
} from "../services/wireguard-config.js"
|
|
import {
|
|
captureWireguardSnapshot,
|
|
fetchWireguardRestoreState,
|
|
getEnabledServerById,
|
|
listWireGuardInterfaces,
|
|
} from "../services/wireguard-live.js"
|
|
import {
|
|
putIpAddress,
|
|
putWireguardInterface,
|
|
putWireguardPeer,
|
|
toRosBody,
|
|
} from "../services/wireguard-ros.js"
|
|
import {
|
|
captureAndAppendRevision,
|
|
listRevisions,
|
|
loadRevisionForRestore,
|
|
type ConfigRevisionSource,
|
|
} from "../services/config-revisions.js"
|
|
import { parseWireguardSnapshot, planWireguardRestore } from "../services/entity-snapshots.js"
|
|
import { executeRosOps } from "../services/ros-ops.js"
|
|
import { parseDbServerId } from "../utils/server-id.js"
|
|
import { z } from "zod"
|
|
|
|
function serverIdParam(v: string): string {
|
|
return decodeURIComponent(v)
|
|
}
|
|
|
|
function rosIdParam(v: string): string {
|
|
return decodeURIComponent(v)
|
|
}
|
|
|
|
function peerToRosBody(p: Omit<WgCreatePeerRequest, "serverId" | "interfaceName"> & { interfaceName: string }) {
|
|
return toRosBody({
|
|
interface: p.interfaceName,
|
|
"public-key": p.publicKey,
|
|
"allowed-address": p.allowedAddresses.join(","),
|
|
"endpoint-address": p.endpointAddress,
|
|
"endpoint-port": p.endpointPort != null ? String(p.endpointPort) : undefined,
|
|
"persistent-keepalive":
|
|
p.persistentKeepalive != null ? String(p.persistentKeepalive) : undefined,
|
|
comment: p.comment,
|
|
name: p.name,
|
|
"private-key": typeof p.privateKey === "string" ? p.privateKey : undefined,
|
|
"client-address": p.clientAddress,
|
|
"client-dns": p.clientDns,
|
|
"client-endpoint": p.clientEndpoint,
|
|
disabled: p.disabled === true ? "yes" : p.disabled === false ? "no" : undefined,
|
|
})
|
|
}
|
|
|
|
function previewFromParsed(parsed: WgParsedConfig) {
|
|
return {
|
|
format: parsed.format,
|
|
interface: {
|
|
name: parsed.interface.name,
|
|
listenPort: parsed.interface.listenPort,
|
|
mtu: parsed.interface.mtu,
|
|
privateKey: parsed.interface.privateKey,
|
|
comment: parsed.interface.comment,
|
|
address: parsed.interface.address,
|
|
disabled: parsed.interface.disabled,
|
|
},
|
|
peers: parsed.peers.map((p) => ({
|
|
publicKey: p.publicKey,
|
|
allowedAddresses: p.allowedAddresses,
|
|
endpointAddress: p.endpointAddress,
|
|
endpointPort: p.endpointPort,
|
|
persistentKeepalive: p.persistentKeepalive,
|
|
comment: p.comment,
|
|
name: p.name,
|
|
privateKey: p.privateKey,
|
|
clientAddress: p.clientAddress,
|
|
clientDns: p.clientDns,
|
|
clientEndpoint: p.clientEndpoint,
|
|
disabled: p.disabled,
|
|
})),
|
|
}
|
|
}
|
|
|
|
async function applyParsedConfig(
|
|
client: MikrotikClient,
|
|
parsed: WgParsedConfig,
|
|
): Promise<{ interfaceName: string; peersCreated: number }> {
|
|
const name = parsed.interface.name
|
|
const ifaceBody = 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,
|
|
})
|
|
await putWireguardInterface(client, ifaceBody)
|
|
|
|
if (parsed.interface.address) {
|
|
await putIpAddress(client, parsed.interface.address, name)
|
|
}
|
|
|
|
let peersCreated = 0
|
|
for (const p of parsed.peers) {
|
|
if (!p.publicKey) continue
|
|
await putWireguardPeer(
|
|
client,
|
|
peerToRosBody({
|
|
interfaceName: name,
|
|
publicKey: p.publicKey,
|
|
allowedAddresses: p.allowedAddresses.length ? p.allowedAddresses : ["0.0.0.0/0"],
|
|
endpointAddress: p.endpointAddress,
|
|
endpointPort: p.endpointPort,
|
|
persistentKeepalive: p.persistentKeepalive,
|
|
comment: p.comment,
|
|
name: p.name,
|
|
privateKey: p.privateKey,
|
|
clientAddress: p.clientAddress,
|
|
clientDns: p.clientDns,
|
|
clientEndpoint: p.clientEndpoint,
|
|
disabled: p.disabled,
|
|
}),
|
|
)
|
|
peersCreated += 1
|
|
}
|
|
return { interfaceName: name, peersCreated }
|
|
}
|
|
|
|
function findIface(
|
|
list: WgIfaceDto[],
|
|
serverId: string,
|
|
interfaceName: string,
|
|
): WgIfaceDto | undefined {
|
|
return list.find((i) => i.serverId === serverId && i.name === interfaceName)
|
|
}
|
|
|
|
async function recordWireguard(
|
|
server: NonNullable<Awaited<ReturnType<typeof getEnabledServerById>>>,
|
|
source: ConfigRevisionSource,
|
|
) {
|
|
await captureAndAppendRevision({
|
|
serverId: server.id,
|
|
section: "wireguard",
|
|
source,
|
|
capture: () => captureWireguardSnapshot(server),
|
|
})
|
|
}
|
|
|
|
const RevisionIdParamSchema = z.object({ id: z.string().min(1) })
|
|
|
|
const wireguardRoutes: FastifyPluginAsyncZod = async (app) => {
|
|
app.get("/wireguard", async (req, reply) => {
|
|
const q = req.query as { serverId?: string; includePrivateKey?: string }
|
|
const includePrivateKey = q.includePrivateKey === "1" || q.includePrivateKey === "true"
|
|
const result = await listWireGuardInterfaces({
|
|
serverId: q.serverId,
|
|
includePrivateKey,
|
|
})
|
|
const sid = parseDbServerId(q.serverId)
|
|
if (sid !== null) {
|
|
const server = await getEnabledServerById(sid)
|
|
if (server) await recordWireguard(server, "observed")
|
|
}
|
|
return reply.send(result)
|
|
})
|
|
|
|
app.post("/wireguard/interfaces", async (req, reply) => {
|
|
const parsed = wgCreateInterfaceSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const body = parsed.data
|
|
const server = await getEnabledServerById(body.serverId)
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
await putWireguardInterface(client, {
|
|
name: body.name,
|
|
"listen-port": String(body.listenPort),
|
|
mtu: String(body.mtu),
|
|
comment: body.comment,
|
|
"private-key": body.privateKey,
|
|
disabled: body.disabled ? "yes" : undefined,
|
|
})
|
|
|
|
if (body.address) {
|
|
await putIpAddress(client, body.address, body.name)
|
|
}
|
|
|
|
if (body.peer) {
|
|
await putWireguardPeer(client, peerToRosBody({ ...body.peer, interfaceName: body.name }))
|
|
}
|
|
|
|
const list = await listWireGuardInterfaces({
|
|
serverId: String(server.id),
|
|
includePrivateKey: true,
|
|
})
|
|
const created = list.interfaces.find((i) => i.name === body.name)
|
|
await recordWireguard(server, "apply")
|
|
return reply.status(201).send(created ?? { ok: true, name: body.name })
|
|
} catch (e) {
|
|
const msg = e instanceof MikrotikError ? e.message : e instanceof Error ? e.message : String(e)
|
|
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
|
}
|
|
})
|
|
|
|
app.patch("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const parsed = wgPatchInterfaceSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const server = await getEnabledServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
const d = parsed.data
|
|
try {
|
|
await client.patch(
|
|
`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`,
|
|
toRosBody({
|
|
name: d.name,
|
|
"listen-port": d.listenPort != null ? String(d.listenPort) : undefined,
|
|
mtu: d.mtu != null ? String(d.mtu) : undefined,
|
|
comment: d.comment,
|
|
"private-key": d.privateKey,
|
|
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
|
}),
|
|
)
|
|
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}` })
|
|
}
|
|
})
|
|
|
|
app.delete("/wireguard/interfaces/:serverId/:rosId", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const server = await getEnabledServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
await client.delete(`/interface/wireguard/${encodeURIComponent(rosIdParam(rosId))}`)
|
|
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}` })
|
|
}
|
|
})
|
|
|
|
app.post("/wireguard/peers", async (req, reply) => {
|
|
const parsed = wgCreatePeerRequestSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const body = parsed.data
|
|
const server = await getEnabledServerById(body.serverId)
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
await putWireguardPeer(client, peerToRosBody(body))
|
|
await recordWireguard(server, "apply")
|
|
return reply.status(201).send({ ok: true })
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
return reply.status(502).send({ error: `RouterOS: ${msg}` })
|
|
}
|
|
})
|
|
|
|
app.patch("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const parsed = wgPatchPeerSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const server = await getEnabledServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const d = parsed.data
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
await client.patch(
|
|
`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`,
|
|
toRosBody({
|
|
"public-key": d.publicKey,
|
|
"allowed-address": d.allowedAddresses?.join(","),
|
|
"endpoint-address": d.endpointAddress,
|
|
"endpoint-port": d.endpointPort != null ? String(d.endpointPort) : undefined,
|
|
"persistent-keepalive":
|
|
d.persistentKeepalive != null ? String(d.persistentKeepalive) : undefined,
|
|
comment: d.comment,
|
|
name: d.name,
|
|
"client-address": d.clientAddress,
|
|
"client-dns": d.clientDns,
|
|
"client-endpoint": d.clientEndpoint,
|
|
disabled: d.disabled === true ? "yes" : d.disabled === false ? "no" : undefined,
|
|
}),
|
|
)
|
|
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}` })
|
|
}
|
|
})
|
|
|
|
app.delete("/wireguard/peers/:serverId/:rosId", async (req, reply) => {
|
|
const { serverId, rosId } = req.params as { serverId: string; rosId: string }
|
|
const server = await getEnabledServerById(serverIdParam(serverId))
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
await client.delete(`/interface/wireguard/peers/${encodeURIComponent(rosIdParam(rosId))}`)
|
|
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}` })
|
|
}
|
|
})
|
|
|
|
app.post("/wireguard/import", async (req, reply) => {
|
|
const parsed = wgImportRequestSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const body = parsed.data
|
|
let config: WgParsedConfig
|
|
try {
|
|
config = parseWgConfig(body.content, body.format)
|
|
} catch (e) {
|
|
return reply.status(400).send({ error: e instanceof Error ? e.message : "Ошибка разбора конфига" })
|
|
}
|
|
const preview = previewFromParsed(config)
|
|
if (body.dryRun) {
|
|
return reply.send({ dryRun: true, preview })
|
|
}
|
|
|
|
const server = await getEnabledServerById(body.serverId)
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
const client = MikrotikClient.fromServer(server)
|
|
try {
|
|
const applied = await applyParsedConfig(client, config)
|
|
await recordWireguard(server, "copy")
|
|
return reply.send({ dryRun: false, preview, applied })
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e)
|
|
return reply.status(502).send({ error: `RouterOS: ${msg}`, preview })
|
|
}
|
|
})
|
|
|
|
app.post("/wireguard/export", async (req, reply) => {
|
|
const parsed = wgExportRequestSchema.safeParse(req.body ?? {})
|
|
if (!parsed.success) {
|
|
return reply.status(400).send({ error: "Некорректное тело запроса", details: parsed.error.flatten() })
|
|
}
|
|
const body = parsed.data
|
|
const server = await getEnabledServerById(body.serverId)
|
|
if (!server) return reply.status(404).send({ error: "Сервер не найден" })
|
|
|
|
const list = await listWireGuardInterfaces({
|
|
serverId: String(server.id),
|
|
includePrivateKey: body.includePrivateKey === true,
|
|
})
|
|
const iface = await findIface(list.interfaces, String(server.id), body.interfaceName)
|
|
if (!iface) return reply.status(404).send({ error: "Интерфейс не найден" })
|
|
|
|
if (body.format === "rsc") {
|
|
const content = generateMikrotikRsc({
|
|
name: iface.name,
|
|
listenPort: iface.listenPort,
|
|
mtu: iface.mtu,
|
|
comment: iface.comment,
|
|
enabled: iface.enabled,
|
|
privateKey: body.includePrivateKey ? iface.privateKey : undefined,
|
|
publicKey: iface.publicKey,
|
|
address: iface.address,
|
|
serverName: iface.serverName,
|
|
peers: iface.peers.map((p) => ({
|
|
publicKey: p.publicKey,
|
|
allowedIps: p.allowedIps,
|
|
endpoint: p.endpoint,
|
|
persistentKeepalive: p.persistentKeepalive,
|
|
persistent: p.persistent,
|
|
comment: p.comment,
|
|
name: p.name,
|
|
clientAddress: p.clientAddress,
|
|
clientDns: p.clientDns,
|
|
clientEndpoint: p.clientEndpoint,
|
|
})),
|
|
})
|
|
return reply.send({
|
|
format: "rsc",
|
|
filename: `${iface.name}.rsc`,
|
|
content,
|
|
})
|
|
}
|
|
|
|
if (body.format === "conf") {
|
|
const content = generateNativeConf(
|
|
{
|
|
name: iface.name,
|
|
listenPort: iface.listenPort,
|
|
mtu: iface.mtu,
|
|
comment: iface.comment,
|
|
enabled: iface.enabled,
|
|
privateKey: iface.privateKey,
|
|
publicKey: iface.publicKey,
|
|
address: iface.address,
|
|
serverName: iface.serverName,
|
|
peers: iface.peers.map((p) => ({
|
|
publicKey: p.publicKey,
|
|
allowedIps: p.allowedIps,
|
|
endpoint: p.endpoint,
|
|
persistentKeepalive: p.persistentKeepalive,
|
|
persistent: p.persistent,
|
|
comment: p.comment,
|
|
})),
|
|
},
|
|
{ includePrivateKey: body.includePrivateKey === true },
|
|
)
|
|
return reply.send({
|
|
format: "conf",
|
|
filename: `${iface.name}.conf`,
|
|
content,
|
|
})
|
|
}
|
|
|
|
// peer-conf
|
|
const peer = body.peerId
|
|
? iface.peers.find((p) => p.id === body.peerId || p.rosId === body.peerId)
|
|
: iface.peers[0]
|
|
if (!peer) return reply.status(404).send({ error: "Пир не найден" })
|
|
if (!iface.publicKey) {
|
|
return reply.status(400).send({ error: "У интерфейса нет public-key" })
|
|
}
|
|
const endpoint =
|
|
peer.clientEndpoint ||
|
|
(peer.endpoint
|
|
? peer.endpoint
|
|
: undefined)
|
|
const content = generatePeerClientConf({
|
|
peerAddress: peer.clientAddress,
|
|
peerDns: peer.clientDns,
|
|
serverPublicKey: iface.publicKey,
|
|
allowedIps: peer.allowedIps.length ? peer.allowedIps : ["0.0.0.0/0"],
|
|
endpoint:
|
|
endpoint ||
|
|
(peer.clientEndpoint
|
|
? peer.clientEndpoint.includes(":")
|
|
? peer.clientEndpoint
|
|
: `${peer.clientEndpoint}:${iface.listenPort}`
|
|
: undefined),
|
|
persistentKeepalive: peer.persistentKeepalive ?? 25,
|
|
})
|
|
return reply.send({
|
|
format: "peer-conf",
|
|
filename: `${iface.name}-peer.conf`,
|
|
content,
|
|
})
|
|
})
|
|
|
|
app.get("/wireguard/revisions", async (req, reply) => {
|
|
const q = req.query as { serverId?: string | number }
|
|
const serverId = parseDbServerId(q.serverId)
|
|
if (serverId === null) return reply.status(400).send({ error: "serverId is required" })
|
|
const revisions = await listRevisions(serverId, "wireguard")
|
|
return reply.send({ revisions })
|
|
})
|
|
|
|
app.post("/wireguard/revisions/:id/restore", {
|
|
schema: { params: RevisionIdParamSchema },
|
|
}, async (req, reply) => {
|
|
const { id } = req.params
|
|
const body = req.body as { serverId?: string | number } | undefined
|
|
const loaded = await loadRevisionForRestore({
|
|
id,
|
|
section: "wireguard",
|
|
requestedServerId: parseDbServerId(body?.serverId),
|
|
})
|
|
if (!loaded.ok) return reply.status(loaded.status).send({ error: loaded.error })
|
|
try {
|
|
const desired = parseWireguardSnapshot(loaded.row.payload)
|
|
const state = await fetchWireguardRestoreState(loaded.server)
|
|
const ops = planWireguardRestore(desired, {
|
|
ifaces: state.ifaces,
|
|
peers: state.peers,
|
|
addrs: state.addrs,
|
|
})
|
|
await executeRosOps(state.client, ops)
|
|
await recordWireguard(loaded.server, "rollback")
|
|
const list = await listWireGuardInterfaces({
|
|
serverId: String(loaded.server.id),
|
|
includePrivateKey: true,
|
|
})
|
|
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}` })
|
|
}
|
|
})
|
|
}
|
|
|
|
export default wireguardRoutes
|