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() 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("/interface/gre"), client.get("/ip/address").catch(() => [] as RosIpAddress[]), ]) return { client, ...mapGreLive(server, Array.isArray(greRaw) ? greRaw : [], Array.isArray(addrsRaw) ? addrsRaw : []), } } export async function captureGreSnapshot(server: ServerRow): Promise { 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 } }