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]>
349 lines
11 KiB
TypeScript
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 }
|