fix(gre): сохранять удалённый внутренний IP туннеля

This commit is contained in:
Denozordec committed 2026-10-08 15:50:11 +07:00
1 parent 56f29a60d1
commit bd3f996f55
12 files changed
+112 -14

No files matched your search

+1 -1
View File
@@ -322,7 +322,7 @@ function GrePageContent({ selectedServerId, selectedTunnelId, allTunnelsHref, on
localAddress: form.localAddress.trim() || undefined, localAddress: form.localAddress.trim() || undefined,
remoteAddress: form.remoteAddress.trim(), remoteAddress: form.remoteAddress.trim(),
localInnerIp: form.localInnerIp.trim() || undefined, localInnerIp: form.localInnerIp.trim() || undefined,
remoteInnerIp: form.remoteInnerIp.trim() || undefined, remoteInnerIp: form.remoteInnerIp.trim(),
comment: form.comment || undefined, comment: form.comment || undefined,
enabled: form.enabled, enabled: form.enabled,
mtu: form.mtu, mtu: form.mtu,
+4 -3
View File
@@ -24,6 +24,7 @@ import {
type ConfigRevisionSource, type ConfigRevisionSource,
} from "../services/config-revisions.js" } from "../services/config-revisions.js"
import { parseDbServerId } from "../utils/server-id.js" import { parseDbServerId } from "../utils/server-id.js"
import { readGreComment, writeGreComment } from "../services/gre-comment.js"
const TunnelWriteSchema = z.object({ const TunnelWriteSchema = z.object({
serverId: z.string().min(1), serverId: z.string().min(1),
@@ -86,8 +87,8 @@ function tunnelFromBody(body: z.infer<typeof TunnelWriteSchema> & { keepaliveInt
localAddress: body.localAddress ?? "", localAddress: body.localAddress ?? "",
remoteAddress: body.remoteAddress, remoteAddress: body.remoteAddress,
localInnerIp: body.localInnerIp ?? "", localInnerIp: body.localInnerIp ?? "",
remoteInnerIp: body.remoteInnerIp ?? "", remoteInnerIp: body.remoteInnerIp ?? readGreComment(body.comment).remoteInnerIp,
comment: body.comment ?? "", comment: body.remoteInnerIp === undefined ? body.comment ?? "" : writeGreComment(body.comment ?? "", body.remoteInnerIp),
disabled: body.enabled === false, disabled: body.enabled === false,
mtu: body.mtu ?? 1476, mtu: body.mtu ?? 1476,
keepalive: keepalive ?? "0", keepalive: keepalive ?? "0",
@@ -149,7 +150,7 @@ const greRoutes: FastifyPluginAsyncZod = async (app) => {
localAddress: body.localAddress ?? live.localAddress, localAddress: body.localAddress ?? live.localAddress,
remoteAddress: body.remoteAddress || live.remoteAddress, remoteAddress: body.remoteAddress || live.remoteAddress,
localInnerIp: body.localInnerIp ?? state.addrs.find((a) => a.interfaceName === live.name)?.address ?? "", localInnerIp: body.localInnerIp ?? state.addrs.find((a) => a.interfaceName === live.name)?.address ?? "",
remoteInnerIp: body.remoteInnerIp, remoteInnerIp: body.remoteInnerIp ?? state.tunnels.find(t => t.name === live.name)?.remoteInnerIp ?? "",
comment: body.comment ?? live.comment, comment: body.comment ?? live.comment,
enabled: body.enabled ?? !live.disabled, enabled: body.enabled ?? !live.disabled,
mtu: body.mtu ?? live.mtu, mtu: body.mtu ?? live.mtu,
+4 -1
View File
@@ -1,5 +1,6 @@
/** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */ /** Канонические снапшоты и планы restore для firewall / WireGuard / GRE / IPsec. */
import { isIpsecManagedComment } from "./ipsec-config.js" import { isIpsecManagedComment } from "./ipsec-config.js"
import { readGreComment, writeGreComment } from "./gre-comment.js"
export type FirewallFamily = "ip" | "ip6" export type FirewallFamily = "ip" | "ip6"
export type FirewallTable = "filter" | "nat" | "mangle" | "raw" export type FirewallTable = "filter" | "nat" | "mangle" | "raw"
@@ -854,6 +855,8 @@ export function parseGreSnapshot(payload: unknown): GreSnapshot {
} }
export function greInterfaceBody(t: GreSnapshotTunnel): Record<string, string> { export function greInterfaceBody(t: GreSnapshotTunnel): Record<string, string> {
const stored = readGreComment(t.comment)
const peer = t.remoteInnerIp || (stored.comment === t.comment ? stored.remoteInnerIp : "")
return compactBody({ return compactBody({
name: t.name, name: t.name,
"local-address": t.localAddress && t.localAddress !== "0.0.0.0" ? t.localAddress : undefined, "local-address": t.localAddress && t.localAddress !== "0.0.0.0" ? t.localAddress : undefined,
@@ -863,7 +866,7 @@ export function greInterfaceBody(t: GreSnapshotTunnel): Record<string, string> {
dscp: t.dscp, dscp: t.dscp,
"clamp-tcp-mss": t.clampTcpMss ? "yes" : "no", "clamp-tcp-mss": t.clampTcpMss ? "yes" : "no",
"allow-fast-path": t.allowFastPath ? "yes" : "no", "allow-fast-path": t.allowFastPath ? "yes" : "no",
comment: t.comment, comment: writeGreComment(t.comment, peer),
disabled: rosYesNo(t.disabled), disabled: rosYesNo(t.disabled),
"ipsec-secret": isHiddenSecret(t.ipsecSecret) ? undefined : t.ipsecSecret, "ipsec-secret": isHiddenSecret(t.ipsecSecret) ? undefined : t.ipsecSecret,
}) })
+26
View File
@@ -0,0 +1,26 @@
import assert from "node:assert/strict"
import {readGreComment,writeGreComment} from "./gre-comment.js"
import {canonicalGreSnapshot,planGreCreate,planGreRestore} from "./entity-snapshots.js"
import {mapGreLive} from "./gre-live.js"
const comment="Тестовый туннель\nВторая строка",peer="198.18.255.250/30"
const raw=writeGreComment(comment,peer)
assert.deepEqual(readGreComment(raw),{comment,remoteInnerIp:peer})
assert.equal(writeGreComment(raw,peer),raw)
assert.deepEqual(readGreComment("gateway=10.0.0.2/30"),{comment:"gateway=10.0.0.2/30",remoteInnerIp:"10.0.0.2/30"})
assert.equal(readGreComment("note\n[mmapp-gre-peer:v1:bad]").comment,"note\n[mmapp-gre-peer:v1:bad]")
const desired=canonicalGreSnapshot({tunnels:[{name:"gre-test",localInnerIp:"198.18.255.249/30",remoteInnerIp:peer,comment}]})
const create=planGreCreate(desired.tunnels[0]);assert.deepEqual(create.map(x=>x.path),["/interface/gre","/ip/address"])
const native={".id":"*3",name:"gre-test","local-address":"194.226.121.150","remote-address":"104.165.244.7",comment:(create[0] as {body:Record<string,string>}).body.comment,mtu:"1400",disabled:"false",running:"true"}
const server={id:1} as Parameters<typeof mapGreLive>[0]
const live=mapGreLive(server,[native],[{".id":"*4",address:"198.18.255.249/30",interface:"gre-test"}])
assert.equal(live.tunnels[0].remoteInnerIp,peer);assert.equal(live.tunnels[0].comment,comment);assert.equal(live.snapshot.tunnels[0].remoteInnerIp,peer)
const restored=planGreRestore(live.snapshot,{gre:live.gre,addrs:live.addrs});const iface=restored.find(x=>x.op==="patch"&&x.path==="/interface/gre/*3") as {body:Record<string,string>};assert(iface);assert.equal(readGreComment(iface.body.comment).remoteInnerIp,peer)
assert(restored.every(x=>!x.path.startsWith("/ip/route")))
const clearSnapshot=canonicalGreSnapshot({tunnels:[{...live.snapshot.tunnels[0],remoteInnerIp:""}]})
const clearedRestore=planGreRestore(clearSnapshot,{gre:live.gre,addrs:live.addrs}).find(x=>x.op==="patch"&&x.path==="/interface/gre/*3") as {body:Record<string,string>}
assert.equal(readGreComment(clearedRestore.body.comment).remoteInnerIp,"")
const legacy=canonicalGreSnapshot({tunnels:[{name:"legacy",comment:"gateway=10.0.0.2/30",remoteInnerIp:""}]});assert.equal(readGreComment((planGreCreate(legacy.tunnels[0])[0] as {body:Record<string,string>}).body.comment).remoteInnerIp,"10.0.0.2/30")
const cleared=writeGreComment("gateway=10.0.0.2/30","");assert.equal(readGreComment(cleared).remoteInnerIp,"")
const clearPlan=planGreCreate(canonicalGreSnapshot({tunnels:[{name:"clear",comment:cleared,remoteInnerIp:""}]}).tunnels[0]);assert.equal(readGreComment((clearPlan[0] as {body:Record<string,string>}).body.comment).remoteInnerIp,"")
assert.equal(readGreComment(writeGreComment(raw,"")).comment,comment)
console.log("GRE peer metadata: native/read/restore/legacy/clear, no route operations")
+25
View File
@@ -0,0 +1,25 @@
function legacyPeer(comment: string): string {
return comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
}
/** Keep app-only peer information on RouterOS without changing routes. */
export function readGreComment(raw = ""): { comment: string; remoteInnerIp: string } {
const match = raw.match(/(?:\n)?\[mmapp-gre-peer:v1:([A-Za-z0-9_-]+)\]$/)
if (match) {
try {
const value: unknown = JSON.parse(Buffer.from(match[1], "base64url").toString("utf8"))
if (value && typeof value === "object" && "remoteInnerIp" in value && typeof value.remoteInnerIp === "string") {
return { comment: raw.slice(0, match.index), remoteInnerIp: value.remoteInnerIp }
}
} catch { /* Preserve unrecognized user comments verbatim. */ }
}
return { comment: raw, remoteInnerIp: legacyPeer(raw) }
}
export function writeGreComment(raw: string, remoteInnerIp: string): string {
const { comment } = readGreComment(raw)
const peer = remoteInnerIp.trim()
if (!peer && !legacyPeer(comment)) return comment
const payload = Buffer.from(JSON.stringify({ remoteInnerIp: peer }), "utf8").toString("base64url")
return `${comment}${comment ? "\n" : ""}[mmapp-gre-peer:v1:${payload}]`
}
+4 -3
View File
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm"
import { db } from "../db/index.js" import { db } from "../db/index.js"
import { servers } from "../db/schema.js" import { servers } from "../db/schema.js"
import { MikrotikClient } from "./mikrotik.js" import { MikrotikClient } from "./mikrotik.js"
import { readGreComment } from "./gre-comment.js"
import { import {
canonicalGreSnapshot, canonicalGreSnapshot,
type GreLiveAddr, type GreLiveAddr,
@@ -83,7 +84,7 @@ function parseDscp(value: string | undefined): "inherit" | number {
function parseInnerFromComment(comment: string | undefined): { localInnerIp: string; remoteInnerIp: string } { function parseInnerFromComment(comment: string | undefined): { localInnerIp: string; remoteInnerIp: string } {
if (!comment) return { localInnerIp: "", remoteInnerIp: "" } if (!comment) return { localInnerIp: "", remoteInnerIp: "" }
const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? "" const local = comment.match(/address\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? ""
const remote = comment.match(/(?:network|gateway)\s*=\s*([0-9.]+\/\d+)/)?.[1] ?? "" const remote = readGreComment(comment).remoteInnerIp
return { localInnerIp: local, remoteInnerIp: remote } return { localInnerIp: local, remoteInnerIp: remote }
} }
@@ -164,7 +165,7 @@ export function mapGreLive(
dscp: parseDscp(g.dscp), dscp: parseDscp(g.dscp),
clampTcpMss: g["clamp-tcp-mss"] !== "false" && g["clamp-tcp-mss"] !== "no", clampTcpMss: g["clamp-tcp-mss"] !== "false" && g["clamp-tcp-mss"] !== "no",
allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no", allowFastPath: g["allow-fast-path"] !== "false" && g["allow-fast-path"] !== "no",
comment: g.comment ?? "", comment: readGreComment(g.comment).comment,
enabled: !disabled, enabled: !disabled,
status: disabled ? "down" : running ? "up" : "degraded", status: disabled ? "down" : running ? "up" : "degraded",
}) })
@@ -178,7 +179,7 @@ export function mapGreLive(
localAddress: g.localAddress, localAddress: g.localAddress,
remoteAddress: g.remoteAddress, remoteAddress: g.remoteAddress,
localInnerIp: addrs.find((a) => a.interfaceName === g.name)?.address ?? "", localInnerIp: addrs.find((a) => a.interfaceName === g.name)?.address ?? "",
remoteInnerIp: "", remoteInnerIp: tunnels.find(t => t.name === g.name)?.remoteInnerIp ?? "",
comment: g.comment, comment: g.comment,
disabled: g.disabled, disabled: g.disabled,
mtu: g.mtu, mtu: g.mtu,
+10
View File
@@ -355,3 +355,13 @@ Retry POST0, Telegram calls0, JS errors0, SQL fixture counts сохраняли
Обнаружен настоящий дефект: принятый remoteInnerIp возвращается пустым, затем теряется в форме редактирования. Связность обеспечивается настройкой обоих реальных /ip/address, но сохранение формы этим не считается исправленным. Следующий12.5.52 — исправить metadata persistence/restore peer IP и профильные тесты, затем развернуть и повторить настоящий GRE цикл. Ограничения ROS6 для остальных VPN/прав/частичных отказов остаются. Control main2servers/9speed/retention14 сохранён; application GRE revisions намеренно остаются журналом выполненных действий. Временный контейнер/туннель8018 удалены; RouterOS тестовые объекты очищены. Локальный коммит без push. Обнаружен настоящий дефект: принятый remoteInnerIp возвращается пустым, затем теряется в форме редактирования. Связность обеспечивается настройкой обоих реальных /ip/address, но сохранение формы этим не считается исправленным. Следующий12.5.52 — исправить metadata persistence/restore peer IP и профильные тесты, затем развернуть и повторить настоящий GRE цикл. Ограничения ROS6 для остальных VPN/прав/частичных отказов остаются. Control main2servers/9speed/retention14 сохранён; application GRE revisions намеренно остаются журналом выполненных действий. Временный контейнер/туннель8018 удалены; RouterOS тестовые объекты очищены. Локальный коммит без push.
Официальная схема пары и keepalive: [MikroTik GRE](https://manual.mikrotik.com/docs/virtual-private-networks/gre/). Документация текущей версии проверена, реальные команды/ответы — RouterOS7.24.5. Официальная схема пары и keepalive: [MikroTik GRE](https://manual.mikrotik.com/docs/virtual-private-networks/gre/). Документация текущей версии проверена, реальные команды/ответы — RouterOS7.24.5.
## 12.5.52 — сохранение удалённого внутреннего IP GRE (2026-10-08)
Исправлен дефект, обнаруженный на настоящей GRE паре в12.5.51. Новый backend services/gre-comment.ts хранит remoteInnerIp в версионированном base64url JSON suffix комментария RouterOS. Это метаданные приложения, не маршруты и не /ip/address на удалённом устройстве. greInterfaceBody сохраняет peer, mapGreLive возвращает его и чистый пользовательский комментарий; snapshot содержит peer и исходный raw comment для корректного restore. PATCH без remoteInnerIp сохраняет текущий peer; явная пустая строка очищает. Форма теперь передаёт пустую строку при очистке вместо пропуска поля.
Старые gateway/network комментарии читаются и сохраняются при restore; непонятный/повреждённый suffix остаётся обычным пользовательским текстом. Явная очистка legacy peer записывает пустое metadata значение, чтобы прежняя подсказка комментария не возвращала удалённое поле. Метаданные не создают сетевые маршруты и не содержат PSK/паролей; GRE/IPsec secret logic не менялась. Raw metadata видна в прямом RouterOS comment, в API/UI служебный suffix отделён от пользовательского текста.
gre-comment.test.ts проверил create plan→native comment→map→snapshot→restore, Unicode/несколько строк, idempotence, legacy, invalid suffix и explicit clear; планы ограничены GRE/IP, без /ip/route. Прежний entity-snapshots.test.ts passed. Playwright gre-form+gre-toggle5/5: создание/DTO/edit, сохранённый ввод при отказе и неверном receipt, explicit empty remoteInnerIp, подтверждение переключения. Browser ответы локальных проверок подменены; новое metadata на реальном RouterOS этим блоком не принято. Backend tsc --noEmit и scoped frontend typecheck passed. Eslint без ошибок, одно прежнее unused bool предупреждение в entity-snapshots.ts:258; дифф не меняет этот helper.
Код и тесты готовы к сборке; удалённый runtime пока backend8782054/frontendb55588f, реальные GRE объекты из51 удалены. Следующий12.5.53 — production Docker build/deploy backend и frontend, новая собственная GRE пара с независимой проверкой peer read/MTU-toggle/clear/restore, реальный UI и двусторонний Ping, очистка по имени и decoded ownership comment. Пользовательское требование реальных VPN объектов сохраняется. Локальный коммит без push.
+2 -2
View File
@@ -1,12 +1,12 @@
# План реализации UX/UI по пятичасовым окнам # План реализации UX/UI по пятичасовым окнам
Актуальное продолжение 2026-10-08: 12.5.51 создал реальную GRE пару через production API, проверил MTU/enable/disable, двусторонний Ping3/3 и реальные UI формы/URL/320px. Обе стороны удалены, выбранная конфигурация восстановлена. Подтверждён дефект: remoteInnerIp теряется в receipt и форме. Следующий12.5.52 — исправить сохранение peer IP; runtime backend8782054/frontendb55588f прежний. При VPN приёмке создавать собственные реальные тестовые объекты, особенно GRE, и удалять их после проверки. Актуальное продолжение 2026-10-08: 12.5.52 локально исправил потерю remoteInnerIp: versioned metadata комментария GRE, чистый пользовательский comment в DTO, сохранение peer в snapshot/restore и PATCH, явная очистка из формы. Backend pipeline tests/entity snapshots и browser5/5, backend/frontend typecheck passed; lint без ошибок (одно прежнее предупреждение). Исправление ещё не развёрнуто: runtime backend8782054/frontendb55588f. Следующий12.5.53 — собрать/развернуть обе части и повторить реальный GRE lifecycle/форму/очистку с metadata.
Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-08. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат. Общий план проекта MikrotikManager. Основан на согласованном плане из задачи; точка продолжения обновлена 2026-10-08. Источник фактического прогресса, проверок и ограничений — [журнал реализации](ux-ui-progress.md). Этот документ задаёт объём и критерии завершения, журнал фиксирует результат.
## Актуальная точка продолжения — 2026-10-08 ## Актуальная точка продолжения — 2026-10-08
Этапы0–11 завершены в описанных локальных границах. Этап12 открыт. На тестовом сервере161.104.46.222 работают backend8782054/frontendb55588f и два CHR. Внешние проверки подтвердили PG backup/restore/rollback, остановку фоновой работы для одного экземпляра, API JWT/RBAC и браузерные состояния восстановления. Native TCP/UDP speed, история и UI проверены в границах журнала; доступ ко второму CHR восстановлен пользователем. Очередь Telegram проверена на реальном PG: dedupe, конкурентный claim, retry/version, ошибки сохранения и неопределённый исход; ответы отправителя подменялись, реальной доставки это не доказывает. Блоки12.5.43–45 подтвердили browser retry/checkpoint и развернули понятное пояснение HTTP500. 12.5.46 подтвердил browser JWT/RBAC очереди на реальном HTTP/PG; 12.5.47 подтвердил изоляцию checkpoint при смене двух реальных HTTP/PG источников в конфигурации local; 12.5.48 подтвердил scoped axe/клавиатуру в обеих темах и320px; 12.5.49 собрал Q-матрицу и подтвердил отсутствие настройки Telegram; 12.5.50 подтвердил GET-only инвентаризацию: VPN/BGP/OSPF пусты, роли home-router/home-router; По уточнению пользователя12.5.51 создал реальные GRE туннели и подтвердил lifecycle/связность; обнаружена потеря peer IP, следующий12.5.52 — исправление сохранения metadata. Реальная Telegram доставка, Portal SSO, физический TCP disconnect, multi-instance и остальные профильные сетевые сценарии остаются открытыми. [Протокол](ux-ui-external-acceptance.md) и [журнал](ux-ui-stage-12.md) содержат доказательства и ограничения. Этапы0–11 завершены в описанных локальных границах. Этап12 открыт. На тестовом сервере161.104.46.222 работают backend8782054/frontendb55588f и два CHR. Внешние проверки подтвердили PG backup/restore/rollback, остановку фоновой работы для одного экземпляра, API JWT/RBAC и браузерные состояния восстановления. Native TCP/UDP speed, история и UI проверены в границах журнала; доступ ко второму CHR восстановлен пользователем. Очередь Telegram проверена на реальном PG: dedupe, конкурентный claim, retry/version, ошибки сохранения и неопределённый исход; ответы отправителя подменялись, реальной доставки это не доказывает. Блоки12.5.43–45 подтвердили browser retry/checkpoint и развернули понятное пояснение HTTP500. 12.5.46 подтвердил browser JWT/RBAC очереди на реальном HTTP/PG; 12.5.47 подтвердил изоляцию checkpoint при смене двух реальных HTTP/PG источников в конфигурации local; 12.5.48 подтвердил scoped axe/клавиатуру в обеих темах и320px; 12.5.49 собрал Q-матрицу и подтвердил отсутствие настройки Telegram; 12.5.50 подтвердил GET-only инвентаризацию: VPN/BGP/OSPF пусты, роли home-router/home-router; По уточнению пользователя12.5.51 создал реальные GRE туннели и подтвердил lifecycle/связность; обнаружена потеря peer IP, 12.5.52 локально исправил сохранение peer metadata, профильные проверки passed; следующий12.5.53 — build/deploy и повтор настоящей GRE приёмки. Реальная Telegram доставка, Portal SSO, физический TCP disconnect, multi-instance и остальные профильные сетевые сценарии остаются открытыми. [Протокол](ux-ui-external-acceptance.md) и [журнал](ux-ui-stage-12.md) содержат доказательства и ограничения.
Выполненные внешние сценарии не заменяют приёмку оставшихся интеграций и условий эксплуатации. Датированные продолжения ниже — история; точный следующий шаг указан в начале [журнала](ux-ui-progress.md). Выполненные внешние сценарии не заменяют приёмку оставшихся интеграций и условий эксплуатации. Датированные продолжения ниже — история; точный следующий шаг указан в начале [журнала](ux-ui-progress.md).
+12 -2
View File
@@ -1,6 +1,6 @@
# UX/UI: журнал реализации # UX/UI: журнал реализации
Актуальное продолжение 2026-10-08: 12.5.51 создал реальную GRE пару через production API, проверил MTU/enable/disable, двусторонний Ping3/3 и реальные UI формы/URL/320px. Обе стороны удалены, выбранная конфигурация восстановлена. Подтверждён дефект: remoteInnerIp теряется в receipt и форме. Следующий12.5.52 — исправить сохранение peer IP; runtime backend8782054/frontendb55588f прежний. При VPN приёмке создавать собственные реальные тестовые объекты, особенно GRE, и удалять их после проверки. Актуальное продолжение 2026-10-08: 12.5.52 локально исправил потерю remoteInnerIp: versioned metadata комментария GRE, чистый пользовательский comment в DTO, сохранение peer в snapshot/restore и PATCH, явная очистка из формы. Backend pipeline tests/entity snapshots и browser5/5, backend/frontend typecheck passed; lint без ошибок (одно прежнее предупреждение). Исправление ещё не развёрнуто: runtime backend8782054/frontendb55588f. Следующий12.5.53 — собрать/развернуть обе части и повторить реальный GRE lifecycle/форму/очистку с metadata.
Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md). Общий план: [UX/UI по этапам и пятичасовым окнам](ux-ui-plan.md). План задаёт объём, этот журнал фиксирует выполненные блоки и ограничения. Записи расположены от новых к старым; подробная история этапа12 также сохранена в [ux-ui-stage-12.md](ux-ui-stage-12.md).
@@ -34,7 +34,17 @@
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 | | 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 | | 11А | Пользователи, контейнеры, сертификаты и резервные копии | Завершён в границах локальной приёмки 2026-10-01; 11А.1–11А.33, внешние ограничения в 11А.33 |
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 | | 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Завершён в границах локальной приёмки 2026-10-03; ограничения и evidence в11Б.7 |
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–51 и ограничения ниже. Runtime backend8782054/frontendb55588f. | | 12 | Итоговая доступность, адаптивность, производительность и регрессия | Локальная приёмка завершена в границах12.3.83; внешняя12.5 в работе, выполненные блоки1–52 и ограничения ниже. Runtime backend8782054/frontendb55588f. |
## 12.5.52 — сохранение удалённого внутреннего IP GRE (2026-10-08)
Исправлен дефект, обнаруженный на настоящей GRE паре в12.5.51. Новый backend services/gre-comment.ts хранит remoteInnerIp в версионированном base64url JSON suffix комментария RouterOS. Это метаданные приложения, не маршруты и не /ip/address на удалённом устройстве. greInterfaceBody сохраняет peer, mapGreLive возвращает его и чистый пользовательский комментарий; snapshot содержит peer и исходный raw comment для корректного restore. PATCH без remoteInnerIp сохраняет текущий peer; явная пустая строка очищает. Форма теперь передаёт пустую строку при очистке вместо пропуска поля.
Старые gateway/network комментарии читаются и сохраняются при restore; непонятный/повреждённый suffix остаётся обычным пользовательским текстом. Явная очистка legacy peer записывает пустое metadata значение, чтобы прежняя подсказка комментария не возвращала удалённое поле. Метаданные не создают сетевые маршруты и не содержат PSK/паролей; GRE/IPsec secret logic не менялась. Raw metadata видна в прямом RouterOS comment, в API/UI служебный suffix отделён от пользовательского текста.
gre-comment.test.ts проверил create plan→native comment→map→snapshot→restore, Unicode/несколько строк, idempotence, legacy, invalid suffix и explicit clear; планы ограничены GRE/IP, без /ip/route. Прежний entity-snapshots.test.ts passed. Playwright gre-form+gre-toggle5/5: создание/DTO/edit, сохранённый ввод при отказе и неверном receipt, explicit empty remoteInnerIp, подтверждение переключения. Browser ответы локальных проверок подменены; новое metadata на реальном RouterOS этим блоком не принято. Backend tsc --noEmit и scoped frontend typecheck passed. Eslint без ошибок, одно прежнее unused bool предупреждение в entity-snapshots.ts:258; дифф не меняет этот helper.
Код и тесты готовы к сборке; удалённый runtime пока backend8782054/frontendb55588f, реальные GRE объекты из51 удалены. Следующий12.5.53 — production Docker build/deploy backend и frontend, новая собственная GRE пара с независимой проверкой peer read/MTU-toggle/clear/restore, реальный UI и двусторонний Ping, очистка по имени и decoded ownership comment. Пользовательское требование реальных VPN объектов сохраняется. Локальный коммит без push.
## 12.5.51 — реальная GRE пара, связность и lifecycle (2026-10-08) ## 12.5.51 — реальная GRE пара, связность и lifecycle (2026-10-08)
+11 -1
View File
@@ -1,6 +1,6 @@
# Этап 12: итоговая приёмка UX/UI # Этап 12: итоговая приёмка UX/UI
Актуальное продолжение 2026-10-08: 12.5.51 создал реальную GRE пару через production API, проверил MTU/enable/disable, двусторонний Ping3/3 и реальные UI формы/URL/320px. Обе стороны удалены, выбранная конфигурация восстановлена. Подтверждён дефект: remoteInnerIp теряется в receipt и форме. Следующий12.5.52 — исправить сохранение peer IP; runtime backend8782054/frontendb55588f прежний. При VPN приёмке создавать собственные реальные тестовые объекты, особенно GRE, и удалять их после проверки. Актуальное продолжение 2026-10-08: 12.5.52 локально исправил потерю remoteInnerIp: versioned metadata комментария GRE, чистый пользовательский comment в DTO, сохранение peer в snapshot/restore и PATCH, явная очистка из формы. Backend pipeline tests/entity snapshots и browser5/5, backend/frontend typecheck passed; lint без ошибок (одно прежнее предупреждение). Исправление ещё не развёрнуто: runtime backend8782054/frontendb55588f. Следующий12.5.53 — собрать/развернуть обе части и повторить реальный GRE lifecycle/форму/очистку с metadata.
План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push. План: [ux-ui-plan.md](ux-ui-plan.md). Фактические результаты: [ux-ui-progress.md](ux-ui-progress.md). Подготовлен после сводных проверок 11Б; завершение каждого блока требует локального коммита без push.
@@ -1520,3 +1520,13 @@ Retry POST0, Telegram calls0, JS errors0, SQL fixture counts сохраняли
Обнаружен настоящий дефект: принятый remoteInnerIp возвращается пустым, затем теряется в форме редактирования. Связность обеспечивается настройкой обоих реальных /ip/address, но сохранение формы этим не считается исправленным. Следующий12.5.52 — исправить metadata persistence/restore peer IP и профильные тесты, затем развернуть и повторить настоящий GRE цикл. Ограничения ROS6 для остальных VPN/прав/частичных отказов остаются. Control main2servers/9speed/retention14 сохранён; application GRE revisions намеренно остаются журналом выполненных действий. Временный контейнер/туннель8018 удалены; RouterOS тестовые объекты очищены. Локальный коммит без push. Обнаружен настоящий дефект: принятый remoteInnerIp возвращается пустым, затем теряется в форме редактирования. Связность обеспечивается настройкой обоих реальных /ip/address, но сохранение формы этим не считается исправленным. Следующий12.5.52 — исправить metadata persistence/restore peer IP и профильные тесты, затем развернуть и повторить настоящий GRE цикл. Ограничения ROS6 для остальных VPN/прав/частичных отказов остаются. Control main2servers/9speed/retention14 сохранён; application GRE revisions намеренно остаются журналом выполненных действий. Временный контейнер/туннель8018 удалены; RouterOS тестовые объекты очищены. Локальный коммит без push.
Официальная схема пары и keepalive: [MikroTik GRE](https://manual.mikrotik.com/docs/virtual-private-networks/gre/). Документация текущей версии проверена, реальные команды/ответы — RouterOS7.24.5. Официальная схема пары и keepalive: [MikroTik GRE](https://manual.mikrotik.com/docs/virtual-private-networks/gre/). Документация текущей версии проверена, реальные команды/ответы — RouterOS7.24.5.
## 12.5.52 — сохранение удалённого внутреннего IP GRE (2026-10-08)
Исправлен дефект, обнаруженный на настоящей GRE паре в12.5.51. Новый backend services/gre-comment.ts хранит remoteInnerIp в версионированном base64url JSON suffix комментария RouterOS. Это метаданные приложения, не маршруты и не /ip/address на удалённом устройстве. greInterfaceBody сохраняет peer, mapGreLive возвращает его и чистый пользовательский комментарий; snapshot содержит peer и исходный raw comment для корректного restore. PATCH без remoteInnerIp сохраняет текущий peer; явная пустая строка очищает. Форма теперь передаёт пустую строку при очистке вместо пропуска поля.
Старые gateway/network комментарии читаются и сохраняются при restore; непонятный/повреждённый suffix остаётся обычным пользовательским текстом. Явная очистка legacy peer записывает пустое metadata значение, чтобы прежняя подсказка комментария не возвращала удалённое поле. Метаданные не создают сетевые маршруты и не содержат PSK/паролей; GRE/IPsec secret logic не менялась. Raw metadata видна в прямом RouterOS comment, в API/UI служебный suffix отделён от пользовательского текста.
gre-comment.test.ts проверил create plan→native comment→map→snapshot→restore, Unicode/несколько строк, idempotence, legacy, invalid suffix и explicit clear; планы ограничены GRE/IP, без /ip/route. Прежний entity-snapshots.test.ts passed. Playwright gre-form+gre-toggle5/5: создание/DTO/edit, сохранённый ввод при отказе и неверном receipt, explicit empty remoteInnerIp, подтверждение переключения. Browser ответы локальных проверок подменены; новое metadata на реальном RouterOS этим блоком не принято. Backend tsc --noEmit и scoped frontend typecheck passed. Eslint без ошибок, одно прежнее unused bool предупреждение в entity-snapshots.ts:258; дифф не меняет этот helper.
Код и тесты готовы к сборке; удалённый runtime пока backend8782054/frontendb55588f, реальные GRE объекты из51 удалены. Следующий12.5.53 — production Docker build/deploy backend и frontend, новая собственная GRE пара с независимой проверкой peer read/MTU-toggle/clear/restore, реальный UI и двусторонний Ping, очистка по имени и decoded ownership comment. Пользовательское требование реальных VPN объектов сохраняется. Локальный коммит без push.
+10
View File
@@ -363,3 +363,13 @@ Retry POST0, Telegram calls0, JS errors0, SQL fixture counts сохраняли
Обнаружен настоящий дефект: принятый remoteInnerIp возвращается пустым, затем теряется в форме редактирования. Связность обеспечивается настройкой обоих реальных /ip/address, но сохранение формы этим не считается исправленным. Следующий12.5.52 — исправить metadata persistence/restore peer IP и профильные тесты, затем развернуть и повторить настоящий GRE цикл. Ограничения ROS6 для остальных VPN/прав/частичных отказов остаются. Control main2servers/9speed/retention14 сохранён; application GRE revisions намеренно остаются журналом выполненных действий. Временный контейнер/туннель8018 удалены; RouterOS тестовые объекты очищены. Локальный коммит без push. Обнаружен настоящий дефект: принятый remoteInnerIp возвращается пустым, затем теряется в форме редактирования. Связность обеспечивается настройкой обоих реальных /ip/address, но сохранение формы этим не считается исправленным. Следующий12.5.52 — исправить metadata persistence/restore peer IP и профильные тесты, затем развернуть и повторить настоящий GRE цикл. Ограничения ROS6 для остальных VPN/прав/частичных отказов остаются. Control main2servers/9speed/retention14 сохранён; application GRE revisions намеренно остаются журналом выполненных действий. Временный контейнер/туннель8018 удалены; RouterOS тестовые объекты очищены. Локальный коммит без push.
Официальная схема пары и keepalive: [MikroTik GRE](https://manual.mikrotik.com/docs/virtual-private-networks/gre/). Документация текущей версии проверена, реальные команды/ответы — RouterOS7.24.5. Официальная схема пары и keepalive: [MikroTik GRE](https://manual.mikrotik.com/docs/virtual-private-networks/gre/). Документация текущей версии проверена, реальные команды/ответы — RouterOS7.24.5.
## 12.5.52 — сохранение удалённого внутреннего IP GRE (2026-10-08)
Исправлен дефект, обнаруженный на настоящей GRE паре в12.5.51. Новый backend services/gre-comment.ts хранит remoteInnerIp в версионированном base64url JSON suffix комментария RouterOS. Это метаданные приложения, не маршруты и не /ip/address на удалённом устройстве. greInterfaceBody сохраняет peer, mapGreLive возвращает его и чистый пользовательский комментарий; snapshot содержит peer и исходный raw comment для корректного restore. PATCH без remoteInnerIp сохраняет текущий peer; явная пустая строка очищает. Форма теперь передаёт пустую строку при очистке вместо пропуска поля.
Старые gateway/network комментарии читаются и сохраняются при restore; непонятный/повреждённый suffix остаётся обычным пользовательским текстом. Явная очистка legacy peer записывает пустое metadata значение, чтобы прежняя подсказка комментария не возвращала удалённое поле. Метаданные не создают сетевые маршруты и не содержат PSK/паролей; GRE/IPsec secret logic не менялась. Raw metadata видна в прямом RouterOS comment, в API/UI служебный suffix отделён от пользовательского текста.
gre-comment.test.ts проверил create plan→native comment→map→snapshot→restore, Unicode/несколько строк, idempotence, legacy, invalid suffix и explicit clear; планы ограничены GRE/IP, без /ip/route. Прежний entity-snapshots.test.ts passed. Playwright gre-form+gre-toggle5/5: создание/DTO/edit, сохранённый ввод при отказе и неверном receipt, explicit empty remoteInnerIp, подтверждение переключения. Browser ответы локальных проверок подменены; новое metadata на реальном RouterOS этим блоком не принято. Backend tsc --noEmit и scoped frontend typecheck passed. Eslint без ошибок, одно прежнее unused bool предупреждение в entity-snapshots.ts:258; дифф не меняет этот helper.
Код и тесты готовы к сборке; удалённый runtime пока backend8782054/frontendb55588f, реальные GRE объекты из51 удалены. Следующий12.5.53 — production Docker build/deploy backend и frontend, новая собственная GRE пара с независимой проверкой peer read/MTU-toggle/clear/restore, реальный UI и двусторонний Ping, очистка по имени и decoded ownership comment. Пользовательское требование реальных VPN объектов сохраняется. Локальный коммит без push.
+3 -1
View File
@@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"
for (const mode of ["create", "dto", "edit"]) { for (const mode of ["create", "dto", "edit"]) {
test(`GRE: сохранение ${mode} подтверждается API`, async ({ page }) => { test(`GRE: сохранение ${mode} подтверждается API`, async ({ page }) => {
let attempts = 0 let attempts = 0
const remoteInnerIp = mode === "edit" ? "" : "10.0.0.2/30"
let release: (() => void) | undefined let release: (() => void) | undefined
const gate = new Promise<void>(resolve => { release = resolve }) const gate = new Promise<void>(resolve => { release = resolve })
const tunnel = { id: "*T/1", name: "gre-test", serverId: "1", localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "pool", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" } const tunnel = { id: "*T/1", name: "gre-test", serverId: "1", localAddress: "192.0.2.1", remoteAddress: "192.0.2.2", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "pool", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" }
@@ -12,7 +13,7 @@ for (const mode of ["create", "dto", "edit"]) {
if (route.request().method() === (mode === "edit" ? "PATCH" : "POST")) { if (route.request().method() === (mode === "edit" ? "PATCH" : "POST")) {
attempts++ attempts++
expect(path).toBe("/api/gre/tunnels") expect(path).toBe("/api/gre/tunnels")
expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", name: "gre-test", remoteAddress: "192.0.2.9", comment: "сохранённый ввод" }) expect(route.request().postDataJSON()).toMatchObject({ serverId: "1", name: "gre-test", remoteAddress: "192.0.2.9", remoteInnerIp, comment: "сохранённый ввод" })
if (mode === "edit") expect(route.request().postDataJSON().rosId).toBe("*T/1") if (mode === "edit") expect(route.request().postDataJSON().rosId).toBe("*T/1")
if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Запись запрещена тест" } }) } if (attempts === 1) { await gate; return route.fulfill({ status: 403, json: { error: "Запись запрещена тест" } }) }
if (attempts === 2) return route.fulfill({ json: {} }) if (attempts === 2) return route.fulfill({ json: {} })
@@ -38,6 +39,7 @@ for (const mode of ["create", "dto", "edit"]) {
} }
await sheet.getByLabel("Удалённый адрес").fill("192.0.2.9") await sheet.getByLabel("Удалённый адрес").fill("192.0.2.9")
await sheet.getByLabel("Комментарий").fill("сохранённый ввод") await sheet.getByLabel("Комментарий").fill("сохранённый ввод")
await sheet.getByLabel("Удалённый IP", { exact: true }).fill(remoteInnerIp)
const submit = sheet.getByRole("button", { name: mode === "edit" ? "Сохранить" : "Создать туннель", exact: true }) const submit = sheet.getByRole("button", { name: mode === "edit" ? "Сохранить" : "Создать туннель", exact: true })
await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() }) await submit.evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
await expect.poll(() => attempts).toBe(1) await expect.poll(() => attempts).toBe(1)