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 <cursoragent@cursor.com>
254 lines
7.8 KiB
TypeScript
254 lines
7.8 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 {
|
|
canonicalGreSnapshot,
|
|
type GreLiveAddr,
|
|
type GreLiveIface,
|
|
type GreSnapshot,
|
|
} from "./entity-snapshots.js"
|
|
import { captureAndAppendRevision } from "./config-revisions.js"
|
|
|
|
type ServerRow = typeof servers.$inferSelect
|
|
|
|
export interface RosGre {
|
|
".id"?: string
|
|
name?: string
|
|
"local-address"?: string
|
|
"remote-address"?: string
|
|
"allow-fast-path"?: string
|
|
"clamp-tcp-mss"?: string
|
|
mtu?: string
|
|
keepalive?: string
|
|
dscp?: string
|
|
running?: string
|
|
disabled?: string
|
|
comment?: string
|
|
"ipsec-secret"?: string
|
|
}
|
|
|
|
interface RosIpAddress {
|
|
".id"?: string
|
|
address?: string
|
|
interface?: string
|
|
disabled?: string
|
|
network?: string
|
|
}
|
|
|
|
export interface LiveGreTunnel {
|
|
id: string
|
|
rosId: string
|
|
name: string
|
|
serverId: string
|
|
localAddress: string
|
|
remoteAddress: string
|
|
localInnerIp: string
|
|
remoteInnerIp: string
|
|
poolId: string
|
|
ipsec: { secret: string } | null
|
|
mtu: number
|
|
keepaliveInterval: number
|
|
keepaliveRetries: number
|
|
dscp: "inherit" | number
|
|
clampTcpMss: boolean
|
|
allowFastPath: boolean
|
|
comment: string
|
|
enabled: boolean
|
|
status: "up" | "down" | "degraded"
|
|
}
|
|
|
|
export function parseKeepalive(value: string | undefined): { interval: number; retries: number } {
|
|
if (!value || value.toLowerCase() === "none") return { interval: 0, retries: 0 }
|
|
const [intervalRaw, retriesRaw] = value.split(",")
|
|
const interval = Number.parseInt((intervalRaw ?? "").trim(), 10)
|
|
const retries = Number.parseInt((retriesRaw ?? "").trim(), 10)
|
|
return {
|
|
interval: Number.isFinite(interval) ? interval : 0,
|
|
retries: Number.isFinite(retries) ? retries : 0,
|
|
}
|
|
}
|
|
|
|
export function formatKeepalive(interval: number, retries: number): string {
|
|
if (!interval || interval <= 0) return "0"
|
|
return `${interval}s,${retries > 0 ? retries : 10}`
|
|
}
|
|
|
|
function parseDscp(value: string | undefined): "inherit" | number {
|
|
if (!value || value === "inherit") return "inherit"
|
|
const n = Number.parseInt(value, 10)
|
|
return Number.isFinite(n) ? n : "inherit"
|
|
}
|
|
|
|
function parseInnerFromComment(comment: string | undefined): { localInnerIp: string; remoteInnerIp: string } {
|
|
if (!comment) return { localInnerIp: "", remoteInnerIp: "" }
|
|
const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
|
|
const remote = comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
|
|
return { localInnerIp: local, remoteInnerIp: remote }
|
|
}
|
|
|
|
function rosDisabled(v: string | undefined): boolean {
|
|
return v === "true" || v === "yes"
|
|
}
|
|
|
|
export function mapGreLive(
|
|
server: ServerRow,
|
|
greRaw: RosGre[],
|
|
addrsRaw: RosIpAddress[],
|
|
): {
|
|
tunnels: LiveGreTunnel[]
|
|
snapshot: GreSnapshot
|
|
gre: GreLiveIface[]
|
|
addrs: GreLiveAddr[]
|
|
} {
|
|
const addrsByIface = new Map<string, { address: string; rosId: string }[]>()
|
|
for (const a of addrsRaw) {
|
|
if (rosDisabled(a.disabled)) continue
|
|
const iface = (a.interface ?? "").trim()
|
|
const address = (a.address ?? "").trim()
|
|
const rosId = String(a[".id"] ?? "")
|
|
if (!iface || !address || !rosId) continue
|
|
const list = addrsByIface.get(iface) ?? []
|
|
list.push({ address, rosId })
|
|
addrsByIface.set(iface, list)
|
|
}
|
|
|
|
const gre: GreLiveIface[] = []
|
|
const addrs: GreLiveAddr[] = []
|
|
const tunnels: LiveGreTunnel[] = []
|
|
|
|
greRaw.forEach((g, idx) => {
|
|
const rosId = String(g[".id"] ?? g.name ?? `gre-${idx}`)
|
|
const name = (g.name ?? "").trim() || `gre-${idx + 1}`
|
|
const keepalive = parseKeepalive(g.keepalive)
|
|
const fromComment = parseInnerFromComment(g.comment)
|
|
const ifaceAddrs = addrsByIface.get(name) ?? []
|
|
const localInnerIp = ifaceAddrs[0]?.address || fromComment.localInnerIp
|
|
const secret = (g["ipsec-secret"] ?? "").trim()
|
|
const disabled = rosDisabled(g.disabled)
|
|
const running = g.running === "true" || g.running === "yes"
|
|
|
|
gre.push({
|
|
name,
|
|
rosId,
|
|
localAddress: g["local-address"] ?? "",
|
|
remoteAddress: g["remote-address"] ?? "",
|
|
comment: g.comment ?? "",
|
|
disabled,
|
|
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
|
|
keepalive: g.keepalive ?? "0",
|
|
dscp: g.dscp ?? "inherit",
|
|
clampTcpMss: g["clamp-tcp-mss"] !== "false" && g["clamp-tcp-mss"] !== "no",
|
|
allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no",
|
|
ipsecSecret: secret,
|
|
})
|
|
|
|
for (const a of ifaceAddrs) {
|
|
addrs.push({ rosId: a.rosId, interfaceName: name, address: a.address })
|
|
}
|
|
|
|
tunnels.push({
|
|
id: rosId || `${server.id}:${name}`,
|
|
rosId,
|
|
name,
|
|
serverId: String(server.id),
|
|
localAddress: g["local-address"] ?? "",
|
|
remoteAddress: g["remote-address"] ?? "",
|
|
localInnerIp,
|
|
remoteInnerIp: fromComment.remoteInnerIp,
|
|
poolId: "live",
|
|
ipsec: secret ? { secret } : null,
|
|
mtu: Number.parseInt(g.mtu ?? "1476", 10) || 1476,
|
|
keepaliveInterval: keepalive.interval,
|
|
keepaliveRetries: keepalive.retries,
|
|
dscp: parseDscp(g.dscp),
|
|
clampTcpMss: g["clamp-tcp-mss"] !== "false" && g["clamp-tcp-mss"] !== "no",
|
|
allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no",
|
|
comment: g.comment ?? "",
|
|
enabled: !disabled,
|
|
status: disabled ? "down" : running ? "up" : "degraded",
|
|
})
|
|
})
|
|
|
|
return {
|
|
tunnels,
|
|
snapshot: canonicalGreSnapshot({
|
|
tunnels: gre.map((g) => ({
|
|
name: g.name,
|
|
localAddress: g.localAddress,
|
|
remoteAddress: g.remoteAddress,
|
|
localInnerIp: addrs.find((a) => a.interfaceName === g.name)?.address ?? "",
|
|
remoteInnerIp: "",
|
|
comment: g.comment,
|
|
disabled: g.disabled,
|
|
mtu: g.mtu,
|
|
keepalive: g.keepalive,
|
|
dscp: g.dscp,
|
|
clampTcpMss: g.clampTcpMss,
|
|
allowFastPath: g.allowFastPath,
|
|
ipsecSecret: g.ipsecSecret,
|
|
})),
|
|
}),
|
|
gre,
|
|
addrs,
|
|
}
|
|
}
|
|
|
|
export async function fetchGreState(server: ServerRow) {
|
|
const client = MikrotikClient.fromServer(server)
|
|
const [greRaw, addrsRaw] = await Promise.all([
|
|
client.get<RosGre[]>("/interface/gre"),
|
|
client.get<RosIpAddress[]>("/ip/address").catch(() => [] as RosIpAddress[]),
|
|
])
|
|
return {
|
|
client,
|
|
...mapGreLive(server, Array.isArray(greRaw) ? greRaw : [], Array.isArray(addrsRaw) ? addrsRaw : []),
|
|
}
|
|
}
|
|
|
|
export async function captureGreSnapshot(server: ServerRow): Promise<GreSnapshot> {
|
|
const state = await fetchGreState(server)
|
|
return state.snapshot
|
|
}
|
|
|
|
export async function listGreTunnels(opts?: { serverId?: string }): Promise<{
|
|
tunnels: LiveGreTunnel[]
|
|
failures: Array<{ serverId: string; serverName?: string; error: string }>
|
|
}> {
|
|
let serverRows: ServerRow[]
|
|
if (opts?.serverId) {
|
|
const id = Number.parseInt(String(opts.serverId), 10)
|
|
if (!Number.isFinite(id)) {
|
|
return { tunnels: [], 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: Array<{ serverId: string; serverName?: string; error: string }> = []
|
|
const chunks = await Promise.all(
|
|
serverRows.map(async (server) => {
|
|
try {
|
|
const state = await fetchGreState(server)
|
|
await captureAndAppendRevision({
|
|
serverId: server.id,
|
|
section: "gre",
|
|
source: "observed",
|
|
capture: async () => state.snapshot,
|
|
})
|
|
return state.tunnels
|
|
} catch (e) {
|
|
failures.push({
|
|
serverId: String(server.id),
|
|
serverName: server.name ?? undefined,
|
|
error: e instanceof Error ? e.message : String(e),
|
|
})
|
|
return [] as LiveGreTunnel[]
|
|
}
|
|
}),
|
|
)
|
|
return { tunnels: chunks.flat(), failures }
|
|
}
|