Files
MikrotikManager/backend/src/services/wireguard-live.ts
T
DenozordecandCursor 25b82997b6
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
feat(config-revisions): implement configuration history for Firewall, GRE, and WireGuard
- 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]>
2026-09-11 14:03:00 +07:00

349 lines
11 KiB
TypeScript

import { eq } from "drizzle-orm"
import { db } from "../db/index.js"
import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js"
import type { WgIfaceDto, WgPeerDto } from "@mmapp/contracts/wireguard"
import {
canonicalWireguardSnapshot,
type WgLiveAddr,
type WgLiveIface,
type WgLivePeer,
type WgSnapshot,
} from "./entity-snapshots.js"
type ServerRow = typeof servers.$inferSelect
interface RosWireGuard {
".id"?: string
name?: string
"listen-port"?: string
mtu?: string
"public-key"?: string
"private-key"?: string
running?: string
disabled?: string
comment?: string
}
interface RosWireGuardPeer {
".id"?: string
interface?: string
name?: string
"public-key"?: string
"endpoint-address"?: string
"endpoint-port"?: string
"allowed-address"?: string
"last-handshake"?: string
rx?: string
tx?: string
disabled?: string
comment?: string
"persistent-keepalive"?: string
"client-address"?: string
"client-dns"?: string
"client-endpoint"?: string
"private-key"?: string
}
interface RosIpAddress {
".id"?: string
address?: string
interface?: string
disabled?: string
}
function parseBytes(v: string | undefined): number | undefined {
if (v == null || v === "") return undefined
const n = Number.parseInt(v, 10)
return Number.isFinite(n) ? n : undefined
}
function mapPeer(p: RosWireGuardPeer, idx: number): WgPeerDto {
const rosId = String(p[".id"] ?? `peer-${idx}`)
const allowed = (p["allowed-address"] ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean)
const epAddr = (p["endpoint-address"] ?? "").trim()
const epPort = (p["endpoint-port"] ?? "").trim()
const endpoint = epAddr ? (epPort ? `${epAddr}:${epPort}` : epAddr) : undefined
const ka = p["persistent-keepalive"]
? Number.parseInt(p["persistent-keepalive"], 10)
: undefined
return {
id: rosId,
rosId,
publicKey: p["public-key"] ?? "",
allowedIps: allowed,
endpoint,
latestHandshake: p["last-handshake"]?.trim() || undefined,
transferRx: parseBytes(p.rx),
transferTx: parseBytes(p.tx),
persistentKeepalive: Number.isFinite(ka) ? ka : undefined,
persistent: Number.isFinite(ka) && (ka as number) > 0,
comment: p.comment ?? undefined,
disabled: p.disabled === "true" || p.disabled === "yes",
name: p.name,
clientAddress: p["client-address"],
clientDns: p["client-dns"],
clientEndpoint: p["client-endpoint"],
}
}
function mapIface(
server: ServerRow,
w: RosWireGuard,
peers: WgPeerDto[],
address: string | undefined,
includePrivateKey: boolean,
): WgIfaceDto {
const rosId = String(w[".id"] ?? w.name ?? "wg")
const name = (w.name ?? "").trim() || rosId
const disabled = w.disabled === "true" || w.disabled === "yes"
const running = w.running === "true" || w.running === "yes"
return {
id: `${server.id}:${rosId}`,
rosId,
name,
serverId: String(server.id),
serverName: String(server.name ?? "").trim() || String(server.host ?? server.id),
serverCountry: server.country ?? undefined,
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
publicKey: w["public-key"] || undefined,
privateKey: includePrivateKey ? w["private-key"] || undefined : undefined,
address,
peers,
comment: w.comment ?? "",
enabled: !disabled,
status: disabled ? "down" : running ? "up" : "down",
}
}
async function fetchForServer(
server: ServerRow,
includePrivateKey: boolean,
): Promise<WgIfaceDto[]> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const peersByIface = new Map<string, WgPeerDto[]>()
peersRaw.forEach((p, idx) => {
const ifaceName = (p.interface ?? "").trim()
if (!ifaceName) return
const list = peersByIface.get(ifaceName) ?? []
list.push(mapPeer(p, idx))
peersByIface.set(ifaceName, list)
})
const addrByIface = new Map<string, string>()
for (const a of addrsRaw) {
if (a.disabled === "true" || a.disabled === "yes") continue
const iface = (a.interface ?? "").trim()
const addr = (a.address ?? "").trim()
if (iface && addr && !addrByIface.has(iface)) addrByIface.set(iface, addr)
}
return ifacesRaw.map((w) => {
const name = (w.name ?? "").trim()
return mapIface(
server,
w,
peersByIface.get(name) ?? [],
addrByIface.get(name),
includePrivateKey,
)
})
}
export async function fetchWireguardRestoreState(server: ServerRow): Promise<{
client: MikrotikClient
ifaces: WgLiveIface[]
peers: WgLivePeer[]
addrs: WgLiveAddr[]
snapshot: WgSnapshot
}> {
const client = MikrotikClient.fromServer(server)
const [ifacesRaw, peersRaw, addrsRaw] = await Promise.all([
client.get<RosWireGuard[]>("/interface/wireguard"),
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
])
const ifaces: WgLiveIface[] = (Array.isArray(ifacesRaw) ? ifacesRaw : []).map((w) => ({
name: (w.name ?? "").trim(),
rosId: String(w[".id"] ?? w.name ?? ""),
listenPort: Number.parseInt(w["listen-port"] ?? "13231", 10) || 13231,
mtu: Number.parseInt(w.mtu ?? "1420", 10) || 1420,
privateKey: w["private-key"] ?? "",
comment: w.comment ?? "",
disabled: w.disabled === "true" || w.disabled === "yes",
}))
const peers: WgLivePeer[] = (Array.isArray(peersRaw) ? peersRaw : []).map((p, idx) => {
const mapped = mapPeer(p, idx)
const ep = (p["endpoint-address"] ?? "").trim()
const port = (p["endpoint-port"] ?? "").trim()
const ka = p["persistent-keepalive"] ? Number.parseInt(p["persistent-keepalive"], 10) : NaN
return {
rosId: mapped.rosId,
interfaceName: (p.interface ?? "").trim(),
publicKey: mapped.publicKey,
allowedAddresses: mapped.allowedIps,
endpointAddress: ep,
endpointPort: port,
persistentKeepalive: Number.isFinite(ka) ? ka : null,
comment: mapped.comment ?? "",
name: mapped.name ?? "",
disabled: mapped.disabled === true,
privateKey: p["private-key"] ?? "",
clientAddress: mapped.clientAddress ?? "",
clientDns: mapped.clientDns ?? "",
clientEndpoint: mapped.clientEndpoint ?? "",
}
})
const addrs: WgLiveAddr[] = []
for (const a of Array.isArray(addrsRaw) ? addrsRaw : []) {
if (a.disabled === "true" || a.disabled === "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 })
}
const snapshot = canonicalWireguardSnapshot({
interfaces: ifaces.map((iface) => ({
name: iface.name,
listenPort: iface.listenPort,
mtu: iface.mtu,
privateKey: iface.privateKey,
address: addrs.find((a) => a.interfaceName === iface.name)?.address ?? "",
comment: iface.comment,
disabled: iface.disabled,
peers: peers.filter((p) => p.interfaceName === iface.name),
})),
})
return { client, ifaces, peers, addrs, snapshot }
}
export async function captureWireguardSnapshot(server: ServerRow): Promise<WgSnapshot> {
const state = await fetchWireguardRestoreState(server)
return state.snapshot
}
export type WgListResult = {
interfaces: WgIfaceDto[]
failures: Array<{ serverId: string; serverName?: string; error: string }>
}
export async function listWireGuardInterfaces(opts?: {
serverId?: string
includePrivateKey?: boolean
}): Promise<WgListResult> {
const includePrivateKey = opts?.includePrivateKey === true
let serverRows: ServerRow[]
if (opts?.serverId) {
const id = Number.parseInt(String(opts.serverId), 10)
if (!Number.isFinite(id)) {
return { interfaces: [], failures: [{ serverId: String(opts.serverId), error: "Некорректный serverId" }] }
}
const row = (await db.select().from(servers).where(eq(servers.id, id)).limit(1))[0]
serverRows = row ? [row] : []
} else {
serverRows = await db.select().from(servers).where(eq(servers.enabled, true))
}
const failures: WgListResult["failures"] = []
const results = await Promise.all(
serverRows.map(async (server) => {
try {
return await fetchForServer(server, includePrivateKey)
} catch (e) {
failures.push({
serverId: String(server.id),
serverName: server.name ?? undefined,
error: e instanceof Error ? e.message : String(e),
})
return [] as WgIfaceDto[]
}
}),
)
return { interfaces: results.flat(), failures }
}
export async function countWireGuardInterfaces(): Promise<number> {
try {
const result = await Promise.race([
await listWireGuardInterfaces({ includePrivateKey: false }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 8_000)),
])
if (!result) return 0
return result.interfaces.length
} catch {
return 0
}
}
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
}
export type CatalogWgPeer = {
interfaceName: string
publicKey: string
name: string
comment: string
allowedIps: string[]
latestHandshake?: string
disabled: boolean
}
const WG_CATALOG_TIMEOUT_MS = 5_000
export async function listWireGuardPeersForCatalog(serverId: number): Promise<{
peers: CatalogWgPeer[]
error?: string
}> {
const row = await getEnabledServerById(serverId)
if (!row) return { peers: [], error: "Сервер не найден" }
try {
const client = MikrotikClient.fromServer(row)
const peersRaw = await Promise.race([
client.get<RosWireGuardPeer[]>("/interface/wireguard/peers"),
new Promise<never>((_, reject) => {
setTimeout(() => reject(new Error("Таймаут RouterOS")), WG_CATALOG_TIMEOUT_MS)
}),
])
const peers: CatalogWgPeer[] = peersRaw.flatMap((p, idx) => {
const mapped = mapPeer(p, idx)
const interfaceName = (p.interface ?? "").trim()
const publicKey = mapped.publicKey.trim()
if (!interfaceName || !publicKey) return []
return [{
interfaceName,
publicKey,
name: mapped.name ?? "",
comment: mapped.comment ?? "",
allowedIps: mapped.allowedIps,
latestHandshake: mapped.latestHandshake,
disabled: mapped.disabled === true,
}]
})
return { peers }
} catch (e) {
return { peers: [], error: e instanceof Error ? e.message : String(e) }
}
}
export { type RosWireGuard, type RosWireGuardPeer }