fix(ui): защитить экспорт WireGuard от устаревших ответов
This commit is contained in:
1 parent
d3ae2598fb
commit
4aa4726c6b
6 files changed
+105
-8
No files matched your search
@@ -217,6 +217,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
peerConf?: string
|
||||
} | null>(null)
|
||||
const [exportBusy, setExportBusy] = useState(false)
|
||||
const exportEpoch = useRef(0)
|
||||
const exportPending = useRef<number | null>(null)
|
||||
useEffect(() => () => { exportEpoch.current += 1 }, [])
|
||||
|
||||
const loadLive = useCallback(async () => {
|
||||
if (!isLive) return
|
||||
@@ -593,7 +596,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
}
|
||||
|
||||
async function handleLiveExport(format: "rsc" | "conf" | "peer-conf") {
|
||||
if (!exportIface || !isLive) return
|
||||
if (!exportIface || !isLive || exportPending.current !== null) return
|
||||
const epoch = exportEpoch.current
|
||||
exportPending.current = epoch
|
||||
setExportBusy(true)
|
||||
try {
|
||||
const res = await exportWireGuard(backendUrl, {
|
||||
@@ -603,6 +608,7 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
includePrivateKey: format !== "peer-conf",
|
||||
peerId: format === "peer-conf" ? (exportPeerId ?? undefined) : undefined,
|
||||
})
|
||||
if (epoch !== exportEpoch.current) return
|
||||
setLiveExport((prev) => ({
|
||||
...prev,
|
||||
...(format === "rsc"
|
||||
@@ -613,13 +619,20 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
}))
|
||||
toast.success("Конфиг загружен с роутера")
|
||||
} catch (e) {
|
||||
if (epoch !== exportEpoch.current) return
|
||||
toast.error(e instanceof Error ? e.message : "Ошибка экспорта")
|
||||
} finally {
|
||||
setExportBusy(false)
|
||||
if (epoch === exportEpoch.current) {
|
||||
exportPending.current = null
|
||||
setExportBusy(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openExport(iface: WgIfaceWithServer, tab: "rsc" | "conf" | "peer" = "rsc", peerId?: string) {
|
||||
exportEpoch.current += 1
|
||||
exportPending.current = null
|
||||
setExportBusy(false)
|
||||
setLiveExport(null)
|
||||
setExportInitialTab(tab)
|
||||
setExportPeerId(peerId ?? null)
|
||||
@@ -927,6 +940,9 @@ function WireGuardPageContent({ selectedServerId, selectedInterfaceId, allInterf
|
||||
initialTab={exportInitialTab}
|
||||
peerId={exportPeerId}
|
||||
onClose={() => {
|
||||
exportEpoch.current += 1
|
||||
exportPending.current = null
|
||||
setExportBusy(false)
|
||||
setExportIface(null)
|
||||
setExportPeerId(null)
|
||||
setExportInitialTab("rsc")
|
||||
|
||||
+2
-2
@@ -80,7 +80,7 @@ flowchart LR
|
||||
|
||||
## Текущая точка продолжения
|
||||
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.5 — операции WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
По журналу этапы 0–8 завершены в границах локальной приёмки; внешние ограничения сохраняются. Этап 9 начат (блоки 9.1–9.6 — операции и защита экспорта WireGuard); этапы 10–12 не начаты. Этап 11 разделён: 11А — пользователи, контейнеры, сертификаты, резервирование; 11Б — оповещения, сбор данных, интеграции, релизы, настройки.
|
||||
|
||||
Подробные планы: [этап 6](ux-ui-stage-6.md), [этап 7](ux-ui-stage-7.md), [этап 8 и итоговая матрица](ux-ui-stage-8.md), [этап 9](ux-ui-stage-9.md).
|
||||
|
||||
@@ -88,7 +88,7 @@ flowchart LR
|
||||
|
||||
Ближайший оставшийся объём:
|
||||
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание интерфейса/пира и переключение WireGuard исправлены и проверены в 9.1–9.5. Далее — экспорт: ошибки, валидация ответа и контекст позднего ответа; затем IPsec/GRE/VXLAN/Firewall/Filters.
|
||||
1. Этап 9: карта разделов и схема сохранены; удаление, импорт, создание, переключение и контекст экспорта WireGuard исправлены в 9.1–9.6. Далее — точный выбор пира в экспорте и история/восстановление; затем IPsec/GRE/VXLAN/Firewall/Filters.
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
|
||||
@@ -25,12 +25,20 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.5 — операции WireGuard; далее экспорт |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.6 — операции и защита экспорта WireGuard |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.6 — контекст ответа экспорта WireGuard (2026-09-29)
|
||||
|
||||
- Каждое открытие/закрытие панели меняет поколение запроса. Поздние результат, ошибка и finally старого экспорта не меняют новую панель и не снимают её busy. Размонтирование также инвалидирует ответ. Повторный запрос в текущей панели защищён синхронной блокировкой; закрытие доступно.
|
||||
- Ответ проверяется схемой wgExportResponseSchema и совпадением формата с запросом. Повреждённый ответ/чужой формат не заменяет конфиг и не объявляется успешным экспортом. Локальное содержимое остаётся локальным; общая композиция CodeExportSheet не менялась.
|
||||
- Три новых E2E прошли: поздний успех, поздняя ошибка после открытия другого сервера, повреждённый ответ/другой формат и успешный повтор. Проверяется двойное нажатие. Итоговый набор из 22 WireGuard E2E, lint, TypeScript и diff-check прошли. Первоначально исправлен селектор теста под существующий aria-label кнопки.
|
||||
- Получен ReUI checklist. Production build не повторялся (последний — 9.4), реальные RouterOS/JWT/ключи не проверены. Запрос после закрытия не отменяется на сервере, только игнорируется в UI. Тесты используют фиктивное содержимое без секретов.
|
||||
- Далее: точность выбора пира в экспорте (ID/rosId, отсутствие подстановки первого пира при неизвестном ID), затем история/восстановление WireGuard и остальные сетевые разделы. На контрольной точке использовано 69% пятичасового и 58% недельного лимита. Локальный коммит без push.
|
||||
|
||||
## Блок 9.5 — подтверждение переключения WireGuard (2026-09-29)
|
||||
|
||||
- Переключение интерфейса защищено синхронным замком до ответа и обновления списка. Пункт меню недоступен во время операции; повторный вызов не отправляет второй PATCH. Никакого оптимистического изменения статуса нет.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Этап 9: VPN и сетевые операции
|
||||
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.5. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
Статус: начат 2026-09-28, выполнены блоки 9.1–9.6. Основа — существующие ReUI PRO/base-nova, Frame, ServerTileRail, DataGrid и формы. Правила применения сетевой конфигурации не меняем.
|
||||
|
||||
## Схема и референсы принятого плана
|
||||
|
||||
@@ -61,3 +61,7 @@
|
||||
## Блок 9.5 — переключение интерфейса (2026-09-29)
|
||||
|
||||
Повторы блокируются до PATCH и обновления списка; успех требует ok=true. Ожидание и ошибка с именами интерфейса/сервера объявляются через status/alert. Все 19 E2E, lint, TypeScript и diff-check прошли. Следующий блок — экспорт, включая поздние ответы и смену контекста. Реальное изменение RouterOS не проверено.
|
||||
|
||||
## Блок 9.6 — контекст экспорта (2026-09-29)
|
||||
|
||||
Результат, ошибка и завершение запроса привязаны к открытию панели; закрытие/новое открытие/размонтирование инвалидируют прежний ответ. Повтор защищён замком. Схема ответа и запрошенный формат проверяются. Три новых E2E, lint и TypeScript прошли; итог общего набора — в журнале. Далее: точный выбор пира, затем история/восстановление. Реальный экспорт ключей не проверен; сетевой запрос после закрытия не отменяется.
|
||||
@@ -1,4 +1,4 @@
|
||||
import { wgIfaceDtoSchema, wgImportResponseSchema } from "@mmapp/contracts/wireguard"
|
||||
import { wgIfaceDtoSchema, wgImportResponseSchema, wgExportResponseSchema } from "@mmapp/contracts/wireguard"
|
||||
import type {
|
||||
WgCreateInterface,
|
||||
WgCreatePeerRequest,
|
||||
@@ -116,8 +116,13 @@ export async function exportWireGuard(
|
||||
baseUrl: string,
|
||||
payload: WgExportRequest,
|
||||
): Promise<WgExportResponse> {
|
||||
return requestJson(baseUrl, "/api/wireguard/export", {
|
||||
const response = await requestJson<unknown>(baseUrl, "/api/wireguard/export", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const parsed = wgExportResponseSchema.safeParse(response)
|
||||
if (!parsed.success || parsed.data.format !== payload.format) {
|
||||
throw new Error("Некорректный ответ экспорта WireGuard")
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
@@ -351,3 +351,67 @@ test("WireGuard: переключение ждёт подтверждения и
|
||||
await expect(page.getByRole("alert").filter({ hasText: "Сервер не подтвердил" })).toHaveCount(0)
|
||||
expect(attempts).toBe(3)
|
||||
})
|
||||
|
||||
for (const oldFails of [false, true]) {
|
||||
test("WireGuard: поздний экспорт не меняет новую панель " + oldFails, async ({ page }) => {
|
||||
await setup(page, "/wireguard")
|
||||
let release: (() => void) | undefined
|
||||
const gate = new Promise<void>(resolve => { release = resolve })
|
||||
let requests = 0
|
||||
await page.route("**/api/wireguard/export", async route => {
|
||||
requests++
|
||||
const body = route.request().postDataJSON()
|
||||
expect(body.format).toBe("rsc")
|
||||
if (body.serverId === "1") {
|
||||
await gate
|
||||
return oldFails ? route.fulfill({ status: 500, json: { error: "Старая ошибка экспорта" } }) : route.fulfill({ json: { format: "rsc", filename: "old.rsc", content: "OLD_EXPORT" } })
|
||||
}
|
||||
expect(body.serverId).toBe("2")
|
||||
return route.fulfill({ json: { format: "rsc", filename: "new.rsc", content: "NEW_EXPORT" } })
|
||||
})
|
||||
const open = async (index: number) => {
|
||||
await page.getByRole("button", { name: "Действия: wg-same" }).nth(index).click()
|
||||
await page.getByRole("menuitem", { name: "Экспорт", exact: true }).click()
|
||||
}
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(4)
|
||||
await open(0)
|
||||
const dialog = page.getByRole("dialog", { name: "Экспорт WireGuard" })
|
||||
await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).evaluate(button => { (button as HTMLButtonElement).click(); (button as HTMLButtonElement).click() })
|
||||
await expect.poll(() => requests).toBe(1)
|
||||
await expect(dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true })).toBeDisabled()
|
||||
await page.keyboard.press("Escape")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await open(2)
|
||||
await expect(dialog).toContainText("Второй сервер")
|
||||
await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).click()
|
||||
await expect(dialog.locator("pre")).toContainText("NEW_EXPORT")
|
||||
const oldResponse = page.waitForResponse(response => response.url().endsWith("/api/wireguard/export") && response.request().postDataJSON().serverId === "1")
|
||||
release!()
|
||||
await (await oldResponse).finished()
|
||||
await page.waitForTimeout(150)
|
||||
await expect(dialog.locator("pre")).toContainText("NEW_EXPORT")
|
||||
await expect(page.getByText("Старая ошибка экспорта", { exact: true })).toHaveCount(0)
|
||||
expect(requests).toBe(2)
|
||||
})
|
||||
}
|
||||
|
||||
test("WireGuard: экспорт отклоняет повреждённый ответ и другой формат", async ({ page }) => {
|
||||
await setup(page, "/wireguard?server=1&interface=*%2F2")
|
||||
let attempts = 0
|
||||
await page.route("**/api/wireguard/export", route => {
|
||||
attempts++
|
||||
return route.fulfill({ json: attempts === 1 ? {} : attempts === 2 ? { format: "conf", filename: "wrong.conf", content: "WRONG_FORMAT" } : { format: "rsc", filename: "valid.rsc", content: "VALID_EXPORT" } })
|
||||
})
|
||||
await page.getByRole("button", { name: "Действия: wg-same" }).click()
|
||||
await page.getByRole("menuitem", { name: "Экспорт", exact: true }).click()
|
||||
const dialog = page.getByRole("dialog", { name: "Экспорт WireGuard" })
|
||||
for (let i = 0; i < 2; i++) {
|
||||
await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).click()
|
||||
await expect.poll(() => attempts).toBe(i + 1)
|
||||
await expect(page.getByText("Некорректный ответ экспорта WireGuard", { exact: true }).first()).toBeVisible()
|
||||
await expect(dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true })).toBeEnabled()
|
||||
await expect(dialog.locator("pre")).not.toContainText("WRONG_FORMAT")
|
||||
}
|
||||
await dialog.getByRole("button", { name: "Подтянуть конфиг с роутера с private-key", exact: true }).click()
|
||||
await expect(dialog.locator("pre")).toContainText("VALID_EXPORT")
|
||||
})
|
||||
Reference in new issue
Block a user