"use client" import { useCallback, useEffect, useMemo, useState } from "react" import { PageHeader } from "@/components/page-header" import { greTunnels as mockGreTunnels, grePools as mockGrePools, servers as mockServers } from "@/lib/data" import type { GrePool, GreTunnel, GreStatus, IpsecEncAlg, IpsecAuthAlg, IpsecDhGroup, IkeVersion, Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" import { requestJson } from "@/shared/api/http-client" import { cn } from "@/lib/utils" import { toast } from "sonner" import { Card, CardContent } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose, } from "@/components/ui/sheet" import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuLabel, DropdownMenuGroup, } from "@/components/ui/dropdown-menu" import { Flag } from "@/components/flag" import { PlusIcon, SearchIcon, RefreshCwIcon, MoreHorizontalIcon, LockIcon, LockOpenIcon, ShieldCheckIcon, NetworkIcon, EyeIcon, EyeOffIcon, ChevronDownIcon, ChevronRightIcon, CodeXmlIcon, PencilIcon, PowerIcon, Trash2Icon, CopyIcon, CheckIcon, DatabaseIcon, } from "lucide-react" // ─── label maps ───────────────────────────────────────────────────────────── const ENC_LABELS: Record = { "aes-128": "AES-128", "aes-192": "AES-192", "aes-256": "AES-256" } const AUTH_LABELS: Record = { sha1: "SHA-1", sha256: "SHA-256", sha512: "SHA-512" } const DH_LABELS: Record = { modp1024: "DH-2 (1024)", modp2048: "DH-14 (2048)", modp4096: "DH-16 (4096)", ecp256: "ECP-256", ecp384: "ECP-384", ecp521: "ECP-521", } const IKE_LABELS: Record = { ikev1: "IKEv1", ikev2: "IKEv2" } const ENC_ROS: Record = { "aes-128": "aes-128-cbc", "aes-192": "aes-192-cbc", "aes-256": "aes-256-cbc" } const AUTH_ROS: Record = { sha1: "sha1", sha256: "sha256", sha512: "sha512" } const STATUS_MAP: Record = { up: { label: "Up", dot: "bg-emerald-500" }, degraded: { label: "Degraded", dot: "bg-amber-500" }, down: { label: "Down", dot: "bg-red-500" }, } // ─── RouterOS code generator ───────────────────────────────────────────────── function generateRosCommands(t: GreTunnel, serverById: Record): string { const lines: string[] = [] const srv = serverById[t.serverId] lines.push(`# Сервер: ${srv?.name ?? t.serverId} (${srv?.host ?? ""})`) lines.push(`# Сгенерировано MikrotikManager · ${new Date().toLocaleDateString("ru")}`) lines.push("") // GRE interface lines.push("# ── GRE-интерфейс ────────────────────────────────────────────") const greParts = [ `/interface gre add`, ` name=${t.name}`, ` remote-address=${t.remoteAddress}`, t.localAddress !== "0.0.0.0" ? ` local-address=${t.localAddress}` : null, t.ipsec ? ` ipsec-secret="${t.ipsec.secret}"` : null, ` mtu=${t.mtu}`, t.keepaliveInterval > 0 ? ` keepalive=${t.keepaliveInterval}s,${t.keepaliveRetries}` : ` keepalive=0`, ` dscp=${t.dscp}`, ` clamp-tcp-mss=${t.clampTcpMss ? "yes" : "no"}`, ` allow-fast-path=${t.allowFastPath ? "yes" : "no"}`, t.comment ? ` comment="${t.comment}"` : null, !t.enabled ? ` disabled=yes` : null, ].filter(Boolean) as string[] lines.push(greParts.join(" \\\n")) // Inner IP lines.push("") lines.push("# ── Внутренний IP ────────────────────────────────────────────") lines.push(`/ip address add \\`) lines.push(` address=${t.localInnerIp} \\`) lines.push(` interface=${t.name}`) // IPsec manual equivalent if (t.ipsec) { const ikeMode = t.ipsec.ikeVersion === "ikev2" ? "ike2" : "ike1" const pfsGroup = t.ipsec.pfs ? t.ipsec.dhGroup : "none" lines.push("") lines.push("# ── IPsec (авто через ipsec-secret; ручной эквивалент) ───────") lines.push("") lines.push(`/ip ipsec peer add \\`) lines.push(` name=${t.name} \\`) lines.push(` address=${t.remoteAddress} \\`) lines.push(` exchange-mode=${ikeMode} \\`) lines.push(` auth-method=pre-shared-key \\`) lines.push(` secret="${t.ipsec.secret}"`) lines.push("") lines.push(`/ip ipsec proposal add \\`) lines.push(` name=${t.name} \\`) lines.push(` enc-algorithms=${ENC_ROS[t.ipsec.encAlg]} \\`) lines.push(` auth-algorithms=${AUTH_ROS[t.ipsec.authAlg]} \\`) lines.push(` pfs-group=${pfsGroup} \\`) lines.push(` lifetime=${t.ipsec.lifetime}`) lines.push("") lines.push(`/ip ipsec policy add \\`) lines.push(` src-address=${t.localAddress !== "0.0.0.0" ? t.localAddress + "/32" : "0.0.0.0/0"} \\`) lines.push(` dst-address=${t.remoteAddress}/32 \\`) lines.push(` proposal=${t.name} \\`) lines.push(` tunnel=yes`) } return lines.join("\n") } // ─── small ui helpers ──────────────────────────────────────────────────────── function TunnelStatus({ status }: { status: GreStatus }) { const s = STATUS_MAP[status] return ( {s.label} ) } function IpsecBadge({ secured }: { secured: boolean }) { return secured ? ( IPsec ) : ( Открытый ) } function Field({ label, hint, required, children }: { label: string; hint?: string; required?: boolean; children: React.ReactNode }) { return (
{children} {hint &&

{hint}

}
) } function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) { return ( ) } function SectionTitle({ children }: { children: React.ReactNode }) { return (
{children}
) } function SegmentedControl({ value, onChange, options }: { value: T; onChange: (v: T) => void; options: { value: T; label: string }[] }) { return (
{options.map((o) => ( ))}
) } // ─── Live API (как на /filters) ───────────────────────────────────────────── interface BackendServer { id: number name: string host: string type: "jump-host" | "exit-node" | "home-router" site: string country: string asn: string enabled: boolean status: "online" | "offline" | null latency: number | null } interface GreTunnelsApiResponse { tunnels: GreTunnel[] } function makeApiFetch(backendUrl: string) { return async function apiFetch(path: string, init?: RequestInit): Promise { return requestJson(backendUrl, path, init) } } function mapBackendToServer(s: BackendServer): Server { return { id: String(s.id), name: s.name || s.host, host: s.host, model: "—", os: "—", site: s.site, country: s.country || "UN", asn: s.asn, type: s.type, enabled: s.enabled, status: (s.status ?? "offline") as Server["status"], latency: s.latency != null ? Math.round(s.latency) : null, sessions: 0, } } function derivePoolsFromTunnels(tunnels: GreTunnel[]): GrePool[] { const ids = [...new Set(tunnels.map((t) => t.poolId).filter(Boolean))] return ids.map((id) => { const list = tunnels.filter((t) => t.poolId === id) const allocated = list.length return { id, name: id === "live" ? "С устройств (опрос)" : id, cidr: "—", allocated, total: Math.max(allocated, 1), comment: "По poolId из данных GRE", } }) } // ─── default form states ───────────────────────────────────────────────────── const defaultTunnelForm = { name: "", serverId: "", localAddress: "", remoteAddress: "", poolId: "", localInnerIp: "", remoteInnerIp: "", comment: "", enabled: true, ipsecEnabled: false, ipsecSecret: "", ipsecShowSecret: false, ipsecIkeVersion: "ikev2" as IkeVersion, ipsecEncAlg: "aes-256" as IpsecEncAlg, ipsecAuthAlg: "sha256" as IpsecAuthAlg, ipsecDhGroup: "modp2048" as IpsecDhGroup, ipsecLifetime: "1d 00:00:00", ipsecPfs: true, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 10, dscp: "inherit", clampTcpMss: true, allowFastPath: true, showAdvanced: false, } const defaultPoolForm = { name: "", cidr: "", comment: "" } type TabFilter = "all" | "up" | "ipsec" | "plain" type PageTab = "tunnels" | "pools" // ════════════════════════════════════════════════════════════════════════════ export default function GrePage() { const { mode, backendUrl, backendStatus } = useDataSource() const isLive = mode === "live" && backendStatus === true const apiFetch = useMemo(() => makeApiFetch(backendUrl), [backendUrl]) const [liveServers, setLiveServers] = useState([]) const [liveTunnels, setLiveTunnels] = useState([]) const [dataLoading, setDataLoading] = useState(false) const [dataError, setDataError] = useState(null) const [syncJhBusy, setSyncJhBusy] = useState(false) const [pageTab, setPageTab] = useState("tunnels") const [tabFilter, setTabFilter] = useState("all") const [search, setSearch] = useState("") const [tunnelOpen, setTunnelOpen] = useState(false) const [poolOpen, setPoolOpen] = useState(false) const [codePreviewTunnel, setCodePreviewTunnel] = useState(null) const [copied, setCopied] = useState(false) const [tForm, setTForm] = useState(defaultTunnelForm) const [pForm, setPForm] = useState(defaultPoolForm) const setT = (k: K, v: (typeof defaultTunnelForm)[K]) => setTForm((f) => ({ ...f, [k]: v })) const loadLive = useCallback(async () => { if (!isLive) return setDataLoading(true) setDataError(null) try { const [backendServers, greRes] = await Promise.all([ apiFetch("/api/servers"), apiFetch("/api/filters/gre-tunnels"), ]) setLiveServers(backendServers.map(mapBackendToServer)) setLiveTunnels(greRes.tunnels) } catch (e) { setDataError(e instanceof Error ? e.message : "Ошибка загрузки") setLiveServers([]) setLiveTunnels([]) } finally { setDataLoading(false) } }, [isLive, apiFetch]) useEffect(() => { if (!isLive) { queueMicrotask(() => { setLiveServers([]) setLiveTunnels([]) setDataError(null) }) return } queueMicrotask(() => { void loadLive() }) }, [isLive, loadLive]) const displayTunnels = isLive ? liveTunnels : mockGreTunnels const displayServers = isLive ? liveServers : mockServers const displayPools = useMemo( () => (isLive ? derivePoolsFromTunnels(displayTunnels) : mockGrePools), [isLive, displayTunnels], ) const serverById = useMemo( () => Object.fromEntries(displayServers.map((s) => [s.id, s])), [displayServers], ) const poolById = useMemo( () => Object.fromEntries(displayPools.map((p) => [p.id, p])), [displayPools], ) const syncJhToDb = useCallback(async () => { if (!isLive || syncJhBusy) return const jh = displayServers.filter((s) => s.type === "jump-host" && s.enabled) if (jh.length === 0) { toast.info("Нет включённых Jump Host в списке серверов") return } setSyncJhBusy(true) const errors: string[] = [] try { for (const s of jh) { try { await apiFetch<{ ok: boolean }>("/api/filters/sync/from-router", { method: "POST", body: JSON.stringify({ serverId: s.id }), }) } catch (e) { errors.push(`${s.name}: ${e instanceof Error ? e.message : "ошибка"}`) } } const fresh = await apiFetch("/api/filters/gre-tunnels") setLiveTunnels(fresh.tunnels) if (errors.length) { toast.warning(`Синхронизировано JH: ${jh.length - errors.length}/${jh.length}. Ошибки: ${errors.join("; ")}`) } else { toast.success(`Правила с ${jh.length} JH записаны в БД, список GRE обновлён.`) } } catch (e) { toast.error(e instanceof Error ? e.message : "Ошибка после синхронизации") } finally { setSyncJhBusy(false) } }, [isLive, syncJhBusy, apiFetch, displayServers]) useEffect(() => { if (dataError) toast.error(dataError) }, [dataError]) const filtered = useMemo(() => { return displayTunnels.filter((t) => { if (tabFilter === "up" && t.status !== "up") return false if (tabFilter === "ipsec" && !t.ipsec) return false if (tabFilter === "plain" && t.ipsec) return false if (!search) return true const q = search.toLowerCase() return ( t.name.toLowerCase().includes(q) || t.remoteAddress.includes(q) || t.localInnerIp.includes(q) || serverById[t.serverId]?.name.toLowerCase().includes(q) ) }) }, [tabFilter, search, displayTunnels, serverById]) const upCount = displayTunnels.filter((t) => t.status === "up").length const ipsecCount = displayTunnels.filter((t) => t.ipsec).length const tunnelTabs: { value: TabFilter; label: string; count: number }[] = [ { value: "all", label: "Все", count: displayTunnels.length }, { value: "up", label: "Активные", count: upCount }, { value: "ipsec", label: "С IPsec", count: ipsecCount }, { value: "plain", label: "Без IPsec", count: displayTunnels.length - ipsecCount }, ] function handleCopy(code: string) { navigator.clipboard.writeText(code).then(() => { setCopied(true) toast.success("Команды скопированы") setTimeout(() => setCopied(false), 2000) }) } return (
} />
{/* Legacy banner */}

В RouterOS 7.x рекомендуется использовать WireGuard вместо GRE+IPsec

WireGuard проще в настройке, обеспечивает лучшую производительность и современную криптографию (ChaCha20-Poly1305). GRE-туннели остаются поддерживаемыми, но WireGuard — предпочтительный выбор для новых развёртываний.

{/* Stats */}
{[ { label: "Всего туннелей", value: displayTunnels.length, icon: }, { label: "Активно", value: upCount, icon: }, { label: "Защищены IPsec", value: ipsecCount, icon: }, { label: "IP-пулов", value: displayPools.length, icon: }, ].map((s) => (

{s.label}

{s.value}

{s.icon}
))}
{/* Page tabs */}
{(["tunnels", "pools"] as PageTab[]).map((tab) => ( ))}
{/* ── Tunnels ── */} {pageTab === "tunnels" && (
{tunnelTabs.map((t) => ( ))}
setSearch(e.target.value)} />
{filtered.length} туннелей
{filtered.map((t, index) => { const srv = serverById[t.serverId] const pool = poolById[t.poolId] return ( {/* actions */} ) })}
Интерфейс / Сервер Эндпоинты Внутренний IP Пул IPsec Шифрование MTU Keepalive Статус

{t.name}

{srv && } {srv?.name ?? t.serverId}

{t.localAddress === "0.0.0.0" ? авто : t.localAddress}

→ {t.remoteAddress}

{t.localInnerIp}

{t.remoteInnerIp}

{pool?.name ?? "—"} {t.ipsec ? (
{ENC_LABELS[t.ipsec.encAlg]} / {AUTH_LABELS[t.ipsec.authAlg]} {DH_LABELS[t.ipsec.dhGroup].split(" ")[0]} · {IKE_LABELS[t.ipsec.ikeVersion]}{t.ipsec.pfs && " · PFS"}
) : }
{t.mtu} {t.keepaliveInterval === 0 ? "откл." : `${t.keepaliveInterval}с / ${t.keepaliveRetries}`}
{/* Code preview button */} {/* Actions dropdown */} } /> {t.name} setCodePreviewTunnel(t)}> Просмотр кода Редактировать {t.enabled ? "Выключить" : "Включить"} Удалить туннель
)} {/* ── IP Pools ── */} {pageTab === "pools" && (
{displayPools.length} пула
{displayPools.map((pool) => { const pct = pool.total > 0 ? Math.round((pool.allocated / pool.total) * 100) : 0 return ( ) })}
Имя пула Диапазон CIDR Назначено /30 Доступно /30 Использование Назначение
{pool.name} {pool.cidr} {pool.allocated} {pool.total - pool.allocated}
80 ? "bg-amber-500" : "bg-emerald-500"}`} style={{ width: `${pct}%` }} />
{pct}%
{pool.comment} } /> Редактировать Удалить пул

Назначения по пулам

{displayPools.map((pool) => { const poolTunnels = displayTunnels.filter((t) => t.poolId === pool.id) return (

{pool.name}

{poolTunnels.map((t, tunnelIndex) => (
{t.name} {t.localInnerIp} ↔ {t.remoteInnerIp} {t.ipsec && }
))}
) })}
)} {/* RouterOS reference */}

RouterOS 7.20+ — параметры GRE-интерфейса

{[ ["/interface gre add", ""], [" name=", "имя интерфейса"], [" remote-address=", "IP удалённого конца (обязателен)"], [" local-address=", "0.0.0.0 = авто"], [" ipsec-secret=", "PSK → auto peer+policy+proposal"], [" mtu=", "по умолчанию 1476"], [" keepalive=", "10s,10 (интервал,попытки)"], [" dscp=", "inherit | 0-63"], [" clamp-tcp-mss=", "yes | no"], [" allow-fast-path=", "yes | no"], ["/ip address add", ""], [" address=x.x.x.x/30", "внутренний IP туннеля"], [" interface=", ""], ].map(([cmd, desc], i) => (
{cmd} {desc && # {desc}}
))}
{/* ══ Sheet: Code Preview ════════════════════════════════════════════════ */} { if (!open) setCodePreviewTunnel(null) }}> {codePreviewTunnel && (() => { const code = generateRosCommands(codePreviewTunnel, serverById) return ( <>
{codePreviewTunnel.name} Команды RouterOS 7.20+ для создания туннеля
{/* meta strip */}
{STATUS_MAP[codePreviewTunnel.status].label} · {serverById[codePreviewTunnel.serverId]?.name} · {codePreviewTunnel.localAddress === "0.0.0.0" ? "авто" : codePreviewTunnel.localAddress} → {codePreviewTunnel.remoteAddress} {codePreviewTunnel.ipsec && ( <> · IPsec {IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion]} )}
{/* code block */}
                    {code.split("\n").map((line, i) => {
                      const isComment  = line.startsWith("#")
                      const isSection  = isComment && line.includes("──")
                      const isKey      = /^\s+[a-z]/.test(line)
                      return (
                        
                          {line}
                          {"\n"}
                        
                      )
                    })}
                  
}>Закрыть ) })()}
{/* ══ Sheet: Add Tunnel ══════════════════════════════════════════════════ */} Новый GRE-туннель RouterOS 7.20+ · /interface gre add
Основные setT("name", e.target.value)} /> setT("comment", e.target.value)} />
Включён setT("enabled", v)} />
Эндпоинты setT("localAddress", e.target.value)} /> setT("remoteAddress", e.target.value)} />
Внутренний IP
setT("localInnerIp", e.target.value)} /> setT("remoteInnerIp", e.target.value)} />
IPsec

Включить IPsec

RouterOS автоматически создаст peer, policy и proposal

setT("ipsecEnabled", v)} />
{tForm.ipsecEnabled && (
setT("ipsecSecret", e.target.value)} />
setT("ipsecIkeVersion", v)} options={[{ value: "ikev1", label: "IKEv1" }, { value: "ikev2", label: "IKEv2 (рек.)" }]} />
setT("ipsecLifetime", e.target.value)} />
PFS setT("ipsecPfs", v)} />
)}
{tForm.showAdvanced && (
setT("mtu", Number(e.target.value))} /> setT("keepaliveInterval", Number(e.target.value))} /> setT("keepaliveRetries", Number(e.target.value))} />
{[ { key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" }, { key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" }, ].map(({ key, label, desc }) => (

{label}

{desc}

setT(key, v)} />
))}
)}
}>Отмена
{/* ══ Sheet: Add Pool ════════════════════════════════════════════════════ */} Новый IP-пул Пул адресов для назначения внутренних IP GRE-туннелям
Параметры пула setPForm((f) => ({ ...f, name: e.target.value }))} /> setPForm((f) => ({ ...f, cidr: e.target.value }))} /> setPForm((f) => ({ ...f, comment: e.target.value }))} /> {pForm.cidr && /\/\d+$/.test(pForm.cidr) && (() => { const prefix = parseInt(pForm.cidr.split("/")[1] ?? "0") const blocks = prefix <= 30 ? Math.pow(2, 30 - prefix) : 0 return blocks > 0 ? (

Доступно {blocks} блоков /30 {" "}= до {blocks} туннелей

) : null })()}
}>Отмена
) }