diff --git a/app/(main)/gre/page.tsx b/app/(main)/gre/page.tsx index f314d078..96238fb0 100644 --- a/app/(main)/gre/page.tsx +++ b/app/(main)/gre/page.tsx @@ -9,10 +9,11 @@ import { DataPageCard } from "@/components/data-page-card" import { DataPageToolbar, DataPageToolbarFrame } from "@/components/data-page-toolbar" import { GreTunnelsDataGrid } from "@/components/data-grids/gre-tunnels-data-grid" import { GrePoolsDataGrid } from "@/components/data-grids/gre-pools-data-grid" -import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" +import { FormField, FormToggle, SectionTitle } from "@/components/form-kit" 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 type { GrePool, GreTunnel, GreStatus, Server } from "@/lib/data" import { useDataSource } from "@/lib/data-source" +import { generateGreRsc } from "@/lib/gre-export" import { greResponseSchema } from "@/lib/gre-response" import { vxlanServersSchema } from "@/lib/vxlan-response" import type { z } from "zod" @@ -53,17 +54,6 @@ import { ALL_SERVERS_ID, type ServerTileItem } from "@/components/server-tile-ra // ─── 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" }, @@ -74,76 +64,6 @@ function greStatusMeta(status: GreStatus | undefined) { return STATUS_MAP[status ?? "degraded"] ?? STATUS_MAP.degraded } -// ─── 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: live CHR имеет только ipsec-secret; proposal — у моков/формы - if (t.ipsec?.encAlg && t.ipsec.authAlg) { - const ikeMode = t.ipsec.ikeVersion === "ikev1" ? "ike1" : "ike2" - const pfsGroup = t.ipsec.pfs ? (t.ipsec.dhGroup ?? "none") : "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 ?? "1d"}`) - 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`) - } else if (t.ipsec) { - lines.push("") - lines.push("# IPsec: peer/policy создаёт RouterOS по ipsec-secret") - } - - return lines.join("\n") -} - // ─── small ui helpers ──────────────────────────────────────────────────────── // ─── Live API (как на /filters) ───────────────────────────────────────────── @@ -198,11 +118,6 @@ 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, @@ -435,7 +350,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on dscp: form.dscp, clampTcpMss: form.clampTcpMss, allowFastPath: form.allowFastPath, - ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : undefined, + ipsecSecret: form.ipsecEnabled ? form.ipsecSecret : "", } } @@ -635,7 +550,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on const formatReceipt = (at: string) => new Date(at).toLocaleString("ru-RU") const greExportCode = useMemo( - () => (codePreviewTunnel ? generateRosCommands(codePreviewTunnel, exportContext?.servers ?? {}) : ""), + () => (codePreviewTunnel ? generateGreRsc(codePreviewTunnel, exportContext?.servers ?? {}) : ""), [codePreviewTunnel, exportContext], ) @@ -917,7 +832,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on · - IPsec {codePreviewTunnel.ipsec.ikeVersion ? IKE_LABELS[codePreviewTunnel.ipsec.ikeVersion] : "PSK"} + IPsec · PSK ) : null} @@ -953,7 +868,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
Включён - setT("enabled", v)} /> + setT("enabled", v)} />
@@ -993,7 +908,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on

Включить IPsec

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

- setT("ipsecEnabled", v)} /> + setT("ipsecEnabled", v)} /> {tForm.ipsecEnabled && ( @@ -1001,46 +916,14 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
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)} /> -
-
+

Применяется только PSK через ipsec-secret. Алгоритмы IKE, proposal и PFS задаются настройками RouterOS; эта форма их не изменяет.

)} @@ -1074,14 +957,14 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on {[ { key: "clampTcpMss" as const, label: "Clamp TCP MSS", desc: "Ограничить MSS до MTU туннеля" }, - { key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Аппаратное ускорение трафика" }, + { key: "allowFastPath" as const, label: "Allow Fast Path", desc: "Ускоренный путь обработки пакетов; отключите для IPsec" }, ].map(({ key, label, desc }) => (

{label}

{desc}

- setT(key, v)} /> + setT(key, v)} />
))} diff --git a/components/form-kit/form-toggle.tsx b/components/form-kit/form-toggle.tsx index 32e9b767..1be606e1 100644 --- a/components/form-kit/form-toggle.tsx +++ b/components/form-kit/form-toggle.tsx @@ -8,11 +8,13 @@ interface FormToggleProps { onChange: (value: boolean) => void disabled?: boolean className?: string + ariaLabel?: string } -function FormToggle({ checked, onChange, disabled, className }: FormToggleProps) { +function FormToggle({ checked, onChange, disabled, className, ariaLabel }: FormToggleProps) { return ( line.startsWith("/")) +assert.equal(commands.length, 2) +assert.ok(commands[0].includes("name=" + quoteRosString(tunnel.name))) +assert.ok(commands[0].includes("ipsec-secret=" + quoteRosString(tunnel.ipsec!.secret))) +assert.ok(commands[0].includes("comment=" + quoteRosString(tunnel.comment))) +assert.ok(commands[0].includes("keepalive=10s,3 dscp=inherit clamp-tcp-mss=yes allow-fast-path=no disabled=yes")) +assert.ok(commands[1].includes('address="10.0.0.1/30" interface=' + quoteRosString(tunnel.name))) +for (const unsupported of ["/ip ipsec peer add", "/ip/ipsec/peer/add", "proposal add", "policy add", "exchange-mode=", "local-address=", "\n/system reboot", "10.0.0.2/30"]) assert.ok(!code.includes(unsupported), unsupported) +const noInner = generateGreRsc({ ...tunnel, localInnerIp: "", ipsec: null, localAddress: "192.0.2.10", keepaliveInterval: 0, dscp: 12, enabled: true }, {}) +assert.equal(noInner.split("\n").filter(line => line.startsWith("/")).length, 1) +assert.ok(noInner.includes('local-address="192.0.2.10"')) +assert.ok(noInner.includes("keepalive=0 dscp=12")) +assert.ok(!noInner.includes("ipsec-secret=")) +console.log("gre-export: escaped strings, observed GRE/PSK only, optional IP and no duplicate IPsec passed") diff --git a/lib/gre-export.ts b/lib/gre-export.ts new file mode 100644 index 00000000..69add3d2 --- /dev/null +++ b/lib/gre-export.ts @@ -0,0 +1,26 @@ +import type { GreTunnel, Server } from "./data" +import { quoteRosString } from "./vxlan-export" + +const commentLine = (value: string) => value.replace(/[\r\n\x00-\x1f\x7f]/g, " ") + +/** GRE/PSK fragment: https://help.mikrotik.com/docs/spaces/ROS/pages/24805531/GRE */ +export function generateGreRsc(t: GreTunnel, servers: Record): string { + const server = servers[t.serverId] + const lines = ["# GRE — " + commentLine(t.name)] + if (server) lines.push("# Сервер: " + commentLine(server.name) + " (" + commentLine(server.host) + ")") + lines.push("# Фрагмент интерфейса и локального IP; удалённая сторона и маршруты не включены", "") + const args = [ + "name=" + quoteRosString(t.name), "remote-address=" + quoteRosString(t.remoteAddress), + "mtu=" + t.mtu, "keepalive=" + (t.keepaliveInterval > 0 ? t.keepaliveInterval + "s," + t.keepaliveRetries : "0"), + "dscp=" + t.dscp, "clamp-tcp-mss=" + (t.clampTcpMss ? "yes" : "no"), + "allow-fast-path=" + (t.allowFastPath ? "yes" : "no"), "disabled=" + (t.enabled ? "no" : "yes"), + "comment=" + quoteRosString(t.comment), + ] + if (t.localAddress && t.localAddress !== "0.0.0.0") args.push("local-address=" + quoteRosString(t.localAddress)) + if (t.ipsec) args.push("ipsec-secret=" + quoteRosString(t.ipsec.secret)) + lines.push("/interface/gre/add " + args.join(" ")) + if (t.localInnerIp) lines.push("/ip/address/add address=" + quoteRosString(t.localInnerIp) + " interface=" + quoteRosString(t.name)) + if (t.ipsec) lines.push("# Peer/policy создаёт RouterOS по ipsec-secret; ручные объекты IPsec не экспортируются") + if (t.ipsec && t.allowFastPath) lines.push("# В снимке включён FastPath: для GRE с IPsec документация RouterOS требует allow-fast-path=no") + return lines.join("\n") + "\n" +} diff --git a/tests/e2e/gre-psk.spec.ts b/tests/e2e/gre-psk.spec.ts new file mode 100644 index 00000000..8b5c0bbd --- /dev/null +++ b/tests/e2e/gre-psk.spec.ts @@ -0,0 +1,34 @@ +import { test, expect } from "@playwright/test" +for (const disable of [false, true]) { + test(`GRE: PSK ${disable ? "очищается" : "изменяется"} через поддерживаемый API`, async ({ page }) => { + let writes = 0 + const tunnel = { id: "*1", serverId: "1", name: "gre-psk", localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "live", ipsec: { secret: "original-secret" }, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: false, comment: "", enabled: true, status: "up" } + await page.route("**/health", r => r.fulfill({ json: { ok: true } })) + await page.route("**/api/**", r => { + const path = new URL(r.request().url()).pathname + if (r.request().method() === "PATCH") { + writes++ + expect(r.request().postDataJSON()).toMatchObject({ serverId: "1", rosId: "*1", name: "gre-psk", ipsecSecret: disable ? "" : "updated-secret" }) + for (const key of ["ipsecIkeVersion", "ipsecEncAlg", "ipsecAuthAlg", "ipsecDhGroup", "ipsecLifetime", "ipsecPfs"]) expect(r.request().postDataJSON()).not.toHaveProperty(key) + return r.fulfill({ json: { ok: true } }) + } + return r.fulfill({ json: path === "/api/gre/tunnels" ? { tunnels: [tunnel] } : path === "/api/servers" ? [{ id: 1, name: "Роутер", host: "192.0.2.1", enabled: true, country: "RU" }] : path === "/api/auth/config" ? { required: false } : {} }) + }) + await page.goto("/gre") + await page.getByRole("button", { name: "Действия: gre-psk" }).click() + await page.getByRole("menuitem", { name: "Редактировать", exact: true }).click() + const dialog = page.getByRole("dialog") + await expect(dialog.getByLabel("Пароль (PSK)")).toHaveValue("original-secret") + await expect(dialog).toContainText("эта форма их не изменяет") + for (const label of ["IKE-версия", "Шифрование", "Хеш-алгоритм", "DH-группа", "Срок жизни SA"]) await expect(dialog.getByText(label, { exact: true })).toHaveCount(0) + if (disable) await dialog.getByRole("switch", { name: "Включить IPsec", exact: true }).click() + else { + await dialog.getByRole("button", { name: "Показать PSK" }).click() + await expect(dialog.getByLabel("Пароль (PSK)")).toHaveAttribute("type", "text") + await dialog.getByLabel("Пароль (PSK)").fill("updated-secret") + } + await dialog.getByRole("button", { name: "Сохранить", exact: true }).click() + await expect(dialog).toHaveCount(0) + expect(writes).toBe(1) + }) +}