From ed369a3072b04a3e7432a466aac003fe45d858e1 Mon Sep 17 00:00:00 2001 From: BROadmin <307129396+BROadmin@users.noreply.github.com> Date: Wed, 29 Jul 2026 11:22:09 +0300 Subject: [PATCH 1/3] feat: prepare BROray 2.2.0 release candidate --- CHANGELOG.md | 13 + README.md | 34 +- docs/RELEASE-2.2.0.md | 28 + docs/assets/cloudtips-qr.svg | 2 + docs/support.md | 14 + packaging/opkg/postinst | 10 +- packaging/opkg/preinst | 124 ++-- root/opt/broray/bin/broray | 2 +- root/opt/broray/bin/broray-routes | 54 ++ root/opt/broray/bin/broray-routes-dot | 13 + root/opt/broray/config/version | 2 +- root/opt/broray/lib/broray-cleanup.sh | 600 +++++++++++++++ root/opt/broray/lib/broray-page.sh | 610 +++++++++++++++- root/opt/broray/lib/package-setup.sh | 2 +- root/opt/broray/lib/routes-api-operation.sh | 128 ++++ root/opt/broray/lib/routes-bundle-registry.sh | 2 + root/opt/broray/lib/routes-dot.sh | 382 ++++++++++ root/opt/broray/lib/routes-download.sh | 2 + .../broray/lib/routes-operation-preflight.sh | 493 +++++++++++++ .../broray/lib/routes-operation-progress.sh | 546 ++++++++++++++ root/opt/broray/lib/routes-router-delete.sh | 383 +++++++++- root/opt/broray/lib/routes-router-sync.sh | 433 ++++++++++- root/opt/broray/lib/routes-runtime-repair.sh | 2 + root/opt/broray/lib/routes-source-check.sh | 9 + root/opt/broray/lib/routes-summary.sh | 64 +- root/opt/broray/lib/routes-user-import.sh | 2 +- root/opt/broray/lib/routes-web-action.sh | 118 ++- .../broray/share/routes/user-import-version | 2 +- .../broray/tests/broray-cleanup-selftest.sh | 162 ++++ .../tests/broray-cleanup-ui-selftest.js | 64 ++ .../broray/tests/broray-reinstall-selftest.sh | 328 +++++++++ .../tests/broray-reinstall-ui-selftest.js | 50 ++ .../tests/broray-support-ui-selftest.js | 47 ++ .../tests/routes-api-operation-selftest.sh | 74 ++ .../broray/tests/routes-dot-api-selftest.sh | 54 ++ root/opt/broray/tests/routes-dot-selftest.sh | 122 ++++ .../broray/tests/routes-dot-ui-selftest.js | 40 + .../tests/routes-global-operation-selftest.sh | 76 ++ ...routes-operation-preflight-api-selftest.sh | 130 ++++ .../routes-operation-preflight-selftest.sh | 253 +++++++ .../routes-operation-progress-selftest.sh | 120 +++ .../tests/routes-r9-export-chain-selftest.sh | 49 +- .../broray/tests/routes-resume-selftest.sh | 82 +++ .../tests/routes-router-delete-selftest.sh | 174 +++-- .../broray/tests/routes-summary-selftest.sh | 89 ++- .../broray/tests/routes-ui-action-selftest.js | 280 +++++++ .../broray/tests/routes-verify-selftest.sh | 262 +++++++ .../web-new/api/broray/cleanup-plan.cgi | 34 + .../opt/broray/web-new/api/broray/cleanup.cgi | 32 + .../broray/web-new/api/broray/reinstall.cgi | 16 + root/opt/broray/web-new/api/routes/check.cgi | 21 +- .../web-new/api/routes/custom-common.sh | 21 + .../broray/web-new/api/routes/dot-apply.cgi | 11 + .../broray/web-new/api/routes/dot-common.sh | 76 ++ .../broray/web-new/api/routes/dot-delete.cgi | 8 + .../broray/web-new/api/routes/dot-status.cgi | 8 + .../broray/web-new/api/routes/dot-test.cgi | 11 + .../broray/web-new/api/routes/download.cgi | 19 +- root/opt/broray/web-new/api/routes/plan.cgi | 19 +- .../broray/web-new/api/routes/preflight.cgi | 126 ++++ .../broray/web-new/api/routes/progress.cgi | 96 +++ root/opt/broray/web-new/api/routes/resume.cgi | 5 + root/opt/broray/web-new/api/routes/status.cgi | 63 +- root/opt/broray/web-new/api/routes/stop.cgi | 50 ++ root/opt/broray/web-new/api/routes/verify.cgi | 404 ++++++++++ .../opt/broray/web-new/assets/css/allpage.css | 635 ++++++++++++++++ .../assets/images/support/cloudtips-qr.svg | 2 + .../opt/broray/web-new/assets/js/app-shell.js | 38 +- root/opt/broray/web-new/assets/js/broray.js | 198 ++++- .../broray/web-new/assets/js/routes-dot.js | 329 +++++++++ root/opt/broray/web-new/assets/js/routes.js | 691 +++++++++++++++--- root/opt/broray/web-new/broray.html | 131 +++- root/opt/broray/web-new/build.json | 6 +- root/opt/broray/web-new/home.html | 46 +- root/opt/broray/web-new/index.html | 18 +- root/opt/broray/web-new/keenetic.html | 46 +- root/opt/broray/web-new/modules.html | 4 +- root/opt/broray/web-new/routes.html | 103 ++- root/opt/broray/web-new/servers.html | 54 +- root/opt/broray/web-new/subscriptions.html | 46 +- root/opt/broray/web-new/xray.html | 48 +- root/opt/broray/web-src/BUILD | 2 +- .../web-src/api/broray/cleanup-plan.cgi | 34 + .../opt/broray/web-src/api/broray/cleanup.cgi | 32 + .../broray/web-src/api/broray/reinstall.cgi | 16 + root/opt/broray/web-src/api/routes/check.cgi | 21 +- .../web-src/api/routes/custom-common.sh | 21 + .../broray/web-src/api/routes/dot-apply.cgi | 11 + .../broray/web-src/api/routes/dot-common.sh | 76 ++ .../broray/web-src/api/routes/dot-delete.cgi | 8 + .../broray/web-src/api/routes/dot-status.cgi | 8 + .../broray/web-src/api/routes/dot-test.cgi | 11 + .../broray/web-src/api/routes/download.cgi | 19 +- root/opt/broray/web-src/api/routes/plan.cgi | 19 +- .../broray/web-src/api/routes/preflight.cgi | 126 ++++ .../broray/web-src/api/routes/progress.cgi | 96 +++ root/opt/broray/web-src/api/routes/resume.cgi | 5 + root/opt/broray/web-src/api/routes/status.cgi | 63 +- root/opt/broray/web-src/api/routes/stop.cgi | 50 ++ root/opt/broray/web-src/api/routes/verify.cgi | 404 ++++++++++ .../opt/broray/web-src/assets/css/allpage.css | 635 ++++++++++++++++ .../assets/images/support/cloudtips-qr.svg | 2 + .../opt/broray/web-src/assets/js/app-shell.js | 38 +- root/opt/broray/web-src/assets/js/broray.js | 198 ++++- .../broray/web-src/assets/js/routes-dot.js | 329 +++++++++ root/opt/broray/web-src/assets/js/routes.js | 691 +++++++++++++++--- root/opt/broray/web-src/pages.json | 3 +- root/opt/broray/web-src/pages/broray.html | 85 ++- root/opt/broray/web-src/pages/routes.html | 52 ++ root/opt/broray/web-src/shell.html | 24 + root/opt/broray/web-src/validate-web.sh | 18 +- scripts/.build-opkg-release-2.1.1-2.9646 | 388 ++++++++++ scripts/.build-opkg-release-2.1.1-2.9652 | 388 ++++++++++ scripts/.build-opkg-release-2.2.0.9761 | 388 ++++++++++ scripts/.build-opkg-release-2.2.0.9767 | 388 ++++++++++ scripts/build-opkg-release-2.2.0.sh | 30 + scripts/install-opkg-release-2.2.0-1.sh | 57 ++ scripts/safe-opkg-upgrade-2.2.0-1.sh | 207 ++++++ scripts/test-opkg-preinst-2.2.0.sh | 71 ++ scripts/test-opkg-upgrade.sh | 8 +- scripts/verify-release-2.2.0.sh | 179 +++++ site/docs.brovibe.cloud/SHA256SUMS | 5 +- .../assets/cloudtips-qr.svg | 2 + site/docs.brovibe.cloud/broray/index.html | 38 +- site/docs.brovibe.cloud/deploy.sh | 8 +- site/docs.brovibe.cloud/styles.css | 48 +- 126 files changed, 14692 insertions(+), 555 deletions(-) create mode 100644 docs/RELEASE-2.2.0.md create mode 100644 docs/assets/cloudtips-qr.svg create mode 100644 docs/support.md create mode 100755 root/opt/broray/bin/broray-routes-dot create mode 100755 root/opt/broray/lib/broray-cleanup.sh create mode 100755 root/opt/broray/lib/routes-api-operation.sh create mode 100755 root/opt/broray/lib/routes-dot.sh create mode 100755 root/opt/broray/lib/routes-operation-preflight.sh create mode 100755 root/opt/broray/lib/routes-operation-progress.sh create mode 100755 root/opt/broray/tests/broray-cleanup-selftest.sh create mode 100755 root/opt/broray/tests/broray-cleanup-ui-selftest.js create mode 100755 root/opt/broray/tests/broray-reinstall-selftest.sh create mode 100644 root/opt/broray/tests/broray-reinstall-ui-selftest.js create mode 100755 root/opt/broray/tests/broray-support-ui-selftest.js create mode 100755 root/opt/broray/tests/routes-api-operation-selftest.sh create mode 100755 root/opt/broray/tests/routes-dot-api-selftest.sh create mode 100755 root/opt/broray/tests/routes-dot-selftest.sh create mode 100755 root/opt/broray/tests/routes-dot-ui-selftest.js create mode 100755 root/opt/broray/tests/routes-global-operation-selftest.sh create mode 100755 root/opt/broray/tests/routes-operation-preflight-api-selftest.sh create mode 100755 root/opt/broray/tests/routes-operation-preflight-selftest.sh create mode 100755 root/opt/broray/tests/routes-operation-progress-selftest.sh create mode 100755 root/opt/broray/tests/routes-resume-selftest.sh create mode 100644 root/opt/broray/tests/routes-ui-action-selftest.js create mode 100644 root/opt/broray/tests/routes-verify-selftest.sh create mode 100755 root/opt/broray/web-new/api/broray/cleanup-plan.cgi create mode 100755 root/opt/broray/web-new/api/broray/cleanup.cgi create mode 100755 root/opt/broray/web-new/api/broray/reinstall.cgi create mode 100755 root/opt/broray/web-new/api/routes/dot-apply.cgi create mode 100755 root/opt/broray/web-new/api/routes/dot-common.sh create mode 100755 root/opt/broray/web-new/api/routes/dot-delete.cgi create mode 100755 root/opt/broray/web-new/api/routes/dot-status.cgi create mode 100755 root/opt/broray/web-new/api/routes/dot-test.cgi create mode 100755 root/opt/broray/web-new/api/routes/preflight.cgi create mode 100755 root/opt/broray/web-new/api/routes/progress.cgi create mode 100755 root/opt/broray/web-new/api/routes/resume.cgi create mode 100755 root/opt/broray/web-new/api/routes/stop.cgi create mode 100644 root/opt/broray/web-new/api/routes/verify.cgi create mode 100644 root/opt/broray/web-new/assets/images/support/cloudtips-qr.svg create mode 100644 root/opt/broray/web-new/assets/js/routes-dot.js create mode 100755 root/opt/broray/web-src/api/broray/cleanup-plan.cgi create mode 100755 root/opt/broray/web-src/api/broray/cleanup.cgi create mode 100755 root/opt/broray/web-src/api/broray/reinstall.cgi create mode 100755 root/opt/broray/web-src/api/routes/dot-apply.cgi create mode 100755 root/opt/broray/web-src/api/routes/dot-common.sh create mode 100755 root/opt/broray/web-src/api/routes/dot-delete.cgi create mode 100755 root/opt/broray/web-src/api/routes/dot-status.cgi create mode 100755 root/opt/broray/web-src/api/routes/dot-test.cgi create mode 100755 root/opt/broray/web-src/api/routes/preflight.cgi create mode 100755 root/opt/broray/web-src/api/routes/progress.cgi create mode 100755 root/opt/broray/web-src/api/routes/resume.cgi create mode 100755 root/opt/broray/web-src/api/routes/stop.cgi create mode 100644 root/opt/broray/web-src/api/routes/verify.cgi create mode 100644 root/opt/broray/web-src/assets/images/support/cloudtips-qr.svg create mode 100644 root/opt/broray/web-src/assets/js/routes-dot.js create mode 100755 scripts/.build-opkg-release-2.1.1-2.9646 create mode 100755 scripts/.build-opkg-release-2.1.1-2.9652 create mode 100755 scripts/.build-opkg-release-2.2.0.9761 create mode 100755 scripts/.build-opkg-release-2.2.0.9767 create mode 100755 scripts/build-opkg-release-2.2.0.sh create mode 100755 scripts/install-opkg-release-2.2.0-1.sh create mode 100755 scripts/safe-opkg-upgrade-2.2.0-1.sh create mode 100755 scripts/test-opkg-preinst-2.2.0.sh create mode 100755 scripts/verify-release-2.2.0.sh create mode 100644 site/docs.brovibe.cloud/assets/cloudtips-qr.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index f71e5b4..bcada95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,18 @@ # История изменений +## 2.2.0 — 2026-07-29 + +- долгие операции с маршрутами показывают фактический прогресс `обработано из общего`; +- добавлены безопасная остановка после текущего маршрута и продолжение после остановки или устранимой ошибки; +- перед установкой, обновлением, восстановлением и удалением выполняется предварительная проверка Keenetic, локального набора, конфликтов, свободного места и точки продолжения; +- единая блокировка запрещает параллельные конфликтующие операции маршрутов и обслуживания BROray; +- «Проверить обновление» проверяет внешний источник, а «Проверить набор» — локальные файлы и фактическое состояние Keenetic; +- Active-цвет получает только одно рекомендуемое следующее действие в карточке маршрутов; +- добавлены проверка, установка, обновление и безопасное удаление DNS-over-TLS с сохранением сторонних DoT/DoH-записей и учётом лимита Keenetic; +- текущую версию BROray можно восстановительно переустановить с отдельным сохранением пользовательских данных и автоматическим откатом; +- добавлена безопасная очистка управляемых резервных копий, временных файлов, журналов и копий отката; +- QR-код и ссылка поддержки проекта добавлены в WebUI, README, GitHub-документацию и BROvibe Docs. + ## 2.1.1-2 — 2026-07-27 - утверждены понятные пользовательские сценарии маршрутов: поиск, проверка обновлений, скачивание, установка, обновление и восстановление; diff --git a/README.md b/README.md index 0c494d3..d97e79f 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,15 @@ BROray — менеджер Xray для Keenetic с WebUI, подписками, - обнаружение добавленных, изменённых и удалённых файлов маршрутов; - безопасное добавление и удаление маршрутов с сохранением общих маршрутов других наборов; - локальный WebUI на порту `8080` и публикация через KeenDNS HTTP Proxy; -- универсальное безопасное обновление с любой установленной версии BROray. +- универсальное безопасное обновление с любой установленной версии BROray; +- фактический прогресс, безопасная остановка и продолжение долгих операций с маршрутами; +- раздельные проверки внешнего источника и локального набора с фактической сверкой Keenetic; +- DNS-over-TLS: TLS/SNI-проверка, установка, обновление и безопасное удаление; +- восстановительная переустановка текущей версии и безопасная очистка диска; ## Текущая версия -BROray 2.1.1, пакет OPKG `2.1.1-2`. +BROray 2.2.0, пакет OPKG `2.2.0-1` (кандидат до проверки на реальном Keenetic). Проверено: @@ -39,10 +43,10 @@ BROray 2.1.1, пакет OPKG `2.1.1-2`. ```sh opkg update && opkg install ca-bundle ca-certificates wget-ssl && -TARGET="/tmp/broray-install-2.1.1-2.sh" && +TARGET="/tmp/broray-install-2.2.0-1.sh" && /opt/bin/wget -qO "$TARGET.part" \ - "https://api.brovibe.cloud/releases/broray-install-2.1.1-2.sh?v=$(date +%s)" && -echo "f14a86fc481d1a5c5c294635fe79031f71aa999d432b48be00c3a137ab7b8396 $TARGET.part" | sha256sum -c - && + "https://api.brovibe.cloud/releases/broray-install-2.2.0-1.sh?v=$(date +%s)" && +echo "af5db97667fe93b344651d928ba36a0010b93f27507aa6c5030c26cb5269f57a $TARGET.part" | sha256sum -c - && mv "$TARGET.part" "$TARGET" && /opt/bin/ash "$TARGET" ``` @@ -51,15 +55,15 @@ mv "$TARGET.part" "$TARGET" && ## Обновление -Обновление не зависит от исходной версии BROray. Обновитель до создания резервной копии очищает старые резервные копии, временные файлы, журналы и OPKG-кэш. Проверенная временная копия создаётся в `/tmp`, а при ошибке выполняется автоматический откат. +Обновление не зависит от исходной версии BROray. Обновитель очищает только безопасные временные данные и OPKG-кэш. Пользовательские данные и сохранённые резервные копии не удаляются. Проверенная временная копия создаётся в `/tmp`, а при ошибке выполняется автоматический откат. ```sh opkg update && opkg install ca-bundle ca-certificates wget-ssl && -TARGET="/tmp/broray-safe-upgrade-2.1.1-2.sh" && +TARGET="/tmp/broray-safe-upgrade-2.2.0-1.sh" && /opt/bin/wget -qO "$TARGET.part" \ - "https://api.brovibe.cloud/releases/broray-safe-upgrade-2.1.1-2.sh?v=$(date +%s)" && -echo "1cfcc536215825a29f087582483297baaecabb68fe641dd4ddc960f7146e9504 $TARGET.part" | sha256sum -c - && + "https://api.brovibe.cloud/releases/broray-safe-upgrade-2.2.0-1.sh?v=$(date +%s)" && +echo "a3af58edbb46e8d3f39d09f514a18ebe4905cfa66a95bda684d4e4a5547c7ba3 $TARGET.part" | sha256sum -c - && mv "$TARGET.part" "$TARGET" && /opt/bin/ash "$TARGET" ``` @@ -102,8 +106,20 @@ https://api.brovibe.cloud/releases/opkg/aarch64-3.10 ## Поддержать проект +BROray развивается как открытый проект. Поддержка помогает оплачивать инфраструктуру, тестирование и выпуск обновлений. + +

+ + QR-код CloudTips для поддержки BROray + +

+ [Поддержать BROray через CloudTips](https://pay.cloudtips.ru/p/09b23d0a) +Прямая ссылка: `https://pay.cloudtips.ru/p/09b23d0a` + +[Подробно о поддержке проекта](docs/support.md) + ## Лицензия Исходный код BROray распространяется на условиях [GNU General Public License v3.0](LICENSE). Xray-core не является частью исходного кода BROray и распространяется на собственных условиях, сохранённых в `third_party/xray/`. diff --git a/docs/RELEASE-2.2.0.md b/docs/RELEASE-2.2.0.md new file mode 100644 index 0000000..49b6b4e --- /dev/null +++ b/docs/RELEASE-2.2.0.md @@ -0,0 +1,28 @@ +# BROray 2.2.0 / OPKG 2.2.0-1 + +Статус: релиз-кандидат до проверки на реальном Keenetic. + +## Основные изменения + +- фактический прогресс долгих операций с маршрутами; +- безопасная остановка и продолжение после остановки или ошибки; +- предварительная проверка и единая блокировка конфликтующих операций; +- раздельные действия «Проверить обновление» и «Проверить набор»; +- Active-цвет только для рекомендуемого следующего действия; +- управление DNS-over-TLS с TLS/SNI-тестом и транзакционным откатом; +- восстановительная переустановка текущей версии с сохранением пользовательских данных; +- безопасная очистка диска; +- поддержка проекта в WebUI, README и BROvibe Docs. + +## Проверка до публикации + +1. Полный набор автоматических тестов BusyBox `ash`, WebUI, маршрутов, DNS-over-TLS, подписок, переустановки и очистки. +2. Сборка и разбор OPKG-пакета `2.2.0-1`. +3. Проверка чистой установки и обновления на тестовом Keenetic. +4. Проверка остановки/продолжения большого набора маршрутов и реального DNS-over-TLS. +5. Только после полевой проверки — публикация пакета, обновителя, установщика и документации. +## Контрольные суммы кандидата + +- OPKG `broray_2.2.0-1_aarch64-3.10.ipk`: `d6df5cf179b66e7f1867071db6f649d584dda3eac17df630e70ec23e38a09f07` +- Безопасный обновитель: `a3af58edbb46e8d3f39d09f514a18ebe4905cfa66a95bda684d4e4a5547c7ba3` +- Чистый установщик: `af5db97667fe93b344651d928ba36a0010b93f27507aa6c5030c26cb5269f57a` diff --git a/docs/assets/cloudtips-qr.svg b/docs/assets/cloudtips-qr.svg new file mode 100644 index 0000000..65ef336 --- /dev/null +++ b/docs/assets/cloudtips-qr.svg @@ -0,0 +1,2 @@ + +QR-код поддержки BROrayОткрывает страницу CloudTips для поддержки проекта BROray. \ No newline at end of file diff --git a/docs/support.md b/docs/support.md new file mode 100644 index 0000000..17d3270 --- /dev/null +++ b/docs/support.md @@ -0,0 +1,14 @@ +# Поддержать BROray + +BROray развивается как открытый проект. Поддержка помогает оплачивать инфраструктуру, тестирование и выпуск обновлений. + +

+ + QR-код CloudTips для поддержки BROray + +

+ +- CloudTips: +- Telegram: + +QR-код содержит ту же прямую ссылку CloudTips и не использует промежуточные сервисы. diff --git a/packaging/opkg/postinst b/packaging/opkg/postinst index 816d26f..b980f4f 100755 --- a/packaging/opkg/postinst +++ b/packaging/opkg/postinst @@ -2,9 +2,13 @@ [ -z "${IPKG_INSTROOT:-}" ] || exit 0 -/opt/bin/ash /opt/broray/lib/package-setup.sh || exit $? +OPT_ROOT="${BRORAY_OPT_ROOT:-/opt}" +TMP_ROOT="${BRORAY_TMP_ROOT:-/tmp}" + +"$OPT_ROOT/bin/ash" "$OPT_ROOT/broray/lib/package-setup.sh" || exit $? rm -f \ - /tmp/broray-opkg-services-before-upgrade \ - /tmp/broray-opkg-existing-backup + "$TMP_ROOT/broray-opkg-services-before-upgrade" \ + "$TMP_ROOT/broray-opkg-existing-backup" \ + "$TMP_ROOT/broray-opkg-prepared-update" exit 0 diff --git a/packaging/opkg/preinst b/packaging/opkg/preinst index 5a9ecaa..21c69a1 100755 --- a/packaging/opkg/preinst +++ b/packaging/opkg/preinst @@ -4,23 +4,24 @@ set -u [ -z "${IPKG_INSTROOT:-}" ] || exit 0 -SERVICE_STATE="/tmp/broray-opkg-services-before-upgrade" -BACKUP_MARKER="/tmp/broray-opkg-existing-backup" -EXCLUDES="/tmp/broray-opkg-backup-excludes.$$" +OPT_ROOT="${BRORAY_OPT_ROOT:-/opt}" +TMP_ROOT="${BRORAY_TMP_ROOT:-/tmp}" +SERVICE_STATE="$TMP_ROOT/broray-opkg-services-before-upgrade" +BACKUP_MARKER="$TMP_ROOT/broray-opkg-prepared-update" BACKUP_PART="" +TAR_BIN="" services=" - /opt/etc/init.d/S28broray-subscriptions \ - /opt/etc/init.d/S27broray-auto-switch \ - /opt/etc/init.d/S25broray-web \ - /opt/etc/init.d/S24broray \ - /opt/etc/init.d/S23broray-monitor + $OPT_ROOT/etc/init.d/S28broray-subscriptions \ + $OPT_ROOT/etc/init.d/S27broray-auto-switch \ + $OPT_ROOT/etc/init.d/S25broray-web \ + $OPT_ROOT/etc/init.d/S24broray \ + $OPT_ROOT/etc/init.d/S23broray-monitor " restart_previously_running() { [ -r "$SERVICE_STATE" ] || return 0 - while IFS= read -r service; do [ -x "$service" ] || continue "$service" start >/dev/null 2>&1 || true @@ -29,7 +30,7 @@ restart_previously_running() cleanup() { - rm -f "$EXCLUDES" "$BACKUP_MARKER" + rm -f "$BACKUP_MARKER" [ -n "$BACKUP_PART" ] && rm -f "$BACKUP_PART" } @@ -42,35 +43,48 @@ fail() exit 1 } -: >"$SERVICE_STATE" || - fail "не удалось сохранить состояние служб" +archive_safe() +{ + archive="$1" + members="$TMP_ROOT/broray-opkg-members-$$" + [ -f "$archive" ] && [ ! -L "$archive" ] || return 1 + "$TAR_BIN" -tzf "$archive" >"$members" 2>/dev/null || { rm -f "$members"; return 1; } + awk '/^\// {bad=1} /(^|\/)\.\.($|\/)/ {bad=1} END {exit bad ? 1 : 0}' "$members" + result=$? + rm -f "$members" + return "$result" +} +if [ -x "$OPT_ROOT/bin/tar" ]; then + TAR_BIN="$OPT_ROOT/bin/tar" +elif command -v tar >/dev/null 2>&1; then + TAR_BIN="$(command -v tar)" +else + fail "не найдена команда tar" +fi + +"$TAR_BIN" --help 2>&1 | grep -q -- '--exclude' || + fail "требуется Entware tar с поддержкой --exclude" + +: >"$SERVICE_STATE" || fail "не удалось сохранить состояние служб" for service in $services; do [ -x "$service" ] || continue - if "$service" status >/dev/null 2>&1; then - printf '%s\n' "$service" >>"$SERVICE_STATE" || - fail "не удалось сохранить состояние $service" + printf '%s\n' "$service" >>"$SERVICE_STATE" || fail "не удалось сохранить состояние $service" fi - "$service" stop >/dev/null 2>&1 || true done prepared_backup="${BRORAY_OPKG_BACKUP:-}" - if [ -z "$prepared_backup" ] && [ -r "$BACKUP_MARKER" ]; then marker_backup="$(sed -n '1p' "$BACKUP_MARKER")" marker_epoch="$(sed -n '2p' "$BACKUP_MARKER")" current_epoch="$(date '+%s')" - case "$marker_epoch:$current_epoch" in - *[!0-9:]*|'') - ;; + *[!0-9:]*|'') ;; *) marker_age=$((current_epoch - marker_epoch)) - if [ "$marker_age" -ge 0 ] && - [ "$marker_age" -le 600 ] - then + if [ "$marker_age" -ge 0 ] && [ "$marker_age" -le 600 ]; then prepared_backup="$marker_backup" fi ;; @@ -79,63 +93,41 @@ fi if [ -n "$prepared_backup" ]; then case "$prepared_backup" in - /opt/broray/backup/*.tar.gz) + "$OPT_ROOT"/broray/backup/*.tar.gz|"$OPT_ROOT"/broray/backups/*.tar.gz|"$TMP_ROOT"/broray-safe-upgrade-2.2.0-1-*/backup.tar.gz) ;; *) fail "передан небезопасный путь готовой резервной копии" ;; esac - - [ -f "$prepared_backup" ] || - fail "готовая резервная копия не найдена" - tar -tzf "$prepared_backup" >/dev/null 2>&1 || - fail "готовая резервная копия не прошла проверку" - printf 'Резервная копия: %s\n' "$prepared_backup" + archive_safe "$prepared_backup" || fail "готовая резервная копия не прошла проверку" + printf 'Резервная копия подготовлена обновителем: %s\n' "$prepared_backup" cleanup exit 0 fi -if [ -d /opt/broray ]; then - backup_dir="/opt/broray/backups" +if [ -d "$OPT_ROOT/broray" ]; then + backup_dir="$OPT_ROOT/broray/backups" backup_stamp="$(date '+%Y%m%d-%H%M%S')" - backup_file="$backup_dir/opkg-before-2.1.0-2-$backup_stamp.tar.gz" + backup_file="$backup_dir/opkg-before-2.2.0-1-$backup_stamp.tar.gz" BACKUP_PART="$backup_file.part" - mkdir -p "$backup_dir" || - fail "не удалось создать каталог резервных копий" - - printf '%s\n' \ - 'broray/backup' \ - 'broray/backup/*' \ - 'broray/backups' \ - 'broray/backups/*' \ - 'broray/cache' \ - 'broray/cache/*' \ - 'broray/logs' \ - 'broray/logs/*' \ - 'broray/run' \ - 'broray/run/*' \ - 'broray/tmp' \ - 'broray/tmp/*' \ - 'broray/update' \ - 'broray/update/*' \ - 'broray/routes/tmp' \ - 'broray/routes/tmp/*' \ - >"$EXCLUDES" || - fail "не удалось подготовить список исключений" - - tar \ - -X "$EXCLUDES" \ + mkdir -p "$backup_dir" || fail "не удалось создать каталог резервных копий" + + "$TAR_BIN" \ + --exclude='broray/backup' \ + --exclude='broray/backups' \ + --exclude='broray/cache' \ + --exclude='broray/logs' \ + --exclude='broray/run' \ + --exclude='broray/tmp' \ + --exclude='broray/update' \ + --exclude='broray/routes/tmp' \ -czf "$BACKUP_PART" \ - -C /opt \ - broray || - fail "не удалось создать резервную копию" - - tar -tzf "$BACKUP_PART" >/dev/null 2>&1 || - fail "резервная копия не прошла проверку" + -C "$OPT_ROOT" \ + broray || fail "не удалось создать резервную копию" - mv "$BACKUP_PART" "$backup_file" || - fail "не удалось сохранить резервную копию" + archive_safe "$BACKUP_PART" || fail "резервная копия не прошла проверку" + mv "$BACKUP_PART" "$backup_file" || fail "не удалось сохранить резервную копию" BACKUP_PART="" chmod 600 "$backup_file" printf 'Резервная копия: %s\n' "$backup_file" diff --git a/root/opt/broray/bin/broray b/root/opt/broray/bin/broray index cfbde22..6e117a2 100755 --- a/root/opt/broray/bin/broray +++ b/root/opt/broray/bin/broray @@ -1,7 +1,7 @@ #!/bin/sh BRORAY_BASE="/opt/broray" -BRORAY_VERSION="2.1.1" +BRORAY_VERSION="2.2.0" . "$BRORAY_BASE/lib/util.sh" . "$BRORAY_BASE/lib/parser-vless.sh" diff --git a/root/opt/broray/bin/broray-routes b/root/opt/broray/bin/broray-routes index b3b9b71..210f5ab 100755 --- a/root/opt/broray/bin/broray-routes +++ b/root/opt/broray/bin/broray-routes @@ -12,6 +12,7 @@ PREFLIGHT_LIBRARY="$ROOT/lib/routes-router-preflight.sh" ROUTER_EXPORT_LIBRARY="$ROOT/lib/routes-router-export.sh" SYNC_LIBRARY="$ROOT/lib/routes-router-sync.sh" ROUTER_DELETE_LIBRARY="$ROOT/lib/routes-router-delete.sh" +PROGRESS_LIBRARY="$ROOT/lib/routes-operation-progress.sh" broray_routes_cli_run_self() { @@ -38,6 +39,8 @@ usage() echo " broray-routes plan " >&2 echo " broray-routes export " >&2 echo " broray-routes delete " >&2 + echo " broray-routes stop " >&2 + echo " broray-routes resume " >&2 exit 2 } @@ -138,6 +141,57 @@ case "${1:-}" in broray_routes_delete_cleanup exit "$rc" ;; + stop) + [ "$#" -eq 2 ] || usage + [ -r "$PROGRESS_LIBRARY" ] || { + echo "ОШИБКА: модуль прогресса маршрутов недоступен." >&2 + exit 1 + } + . "$PROGRESS_LIBRARY" + if broray_routes_progress_request_stop "$2"; then + echo "Запрошена остановка после текущего маршрута." + else + rc=$? + case "$rc" in + 2) echo "ОШИБКА: состояние операции не найдено." >&2 ;; + 3) echo "ОШИБКА: операция с этим набором сейчас не выполняется." >&2 ;; + *) echo "ОШИБКА: не удалось запросить остановку операции." >&2 ;; + esac + exit 1 + fi + ;; + resume) + [ "$#" -eq 2 ] || usage + [ -r "$PROGRESS_LIBRARY" ] || { + echo "ОШИБКА: модуль прогресса маршрутов недоступен." >&2 + exit 1 + } + . "$PROGRESS_LIBRARY" + progress_json="$(broray_routes_progress_read "$2")" || { + echo "ОШИБКА: не удалось прочитать состояние операции." >&2 + exit 1 + } + printf '%s +' "$progress_json" | jq -e '.resumable == true and .running == false' >/dev/null 2>&1 || { + echo "ОШИБКА: для этого набора нет операции, которую можно продолжить." >&2 + exit 1 + } + operation="$(printf '%s +' "$progress_json" | jq -r '.operation // empty')" + case "$0" in + */*) CLI_SELF="$0" ;; + *) CLI_SELF="$(command -v "$0" 2>/dev/null || true)" ;; + esac + [ -n "$CLI_SELF" ] && [ -x "$CLI_SELF" ] || { + echo "ОШИБКА: не удалось определить путь broray-routes." >&2 + exit 1 + } + case "$operation" in + delete) broray_routes_cli_run_self delete "$2" ;; + install|update|restore) broray_routes_cli_run_self export "$2" ;; + *) echo "ОШИБКА: тип сохранённой операции не поддерживается." >&2; exit 1 ;; + esac + ;; *) usage ;; diff --git a/root/opt/broray/bin/broray-routes-dot b/root/opt/broray/bin/broray-routes-dot new file mode 100755 index 0000000..c4a2e0a --- /dev/null +++ b/root/opt/broray/bin/broray-routes-dot @@ -0,0 +1,13 @@ +#!/opt/bin/ash +set -u +ROOT="${BRORAY_ROOT:-/opt/broray}" +LIB="$ROOT/lib/routes-dot.sh" +[ -r "$LIB" ] || { echo 'BRORAY_ERROR:MODULE_UNAVAILABLE:Модуль DNS-over-TLS недоступен.' >&2; exit 1; } +. "$LIB" +case "${1:-}" in + status) broray_dot_status ;; + test) [ -n "${2:-}" ] || broray_dot_error REQUEST_INVALID "Не указан файл запроса."; broray_dot_test "$2" ;; + apply) [ -n "${2:-}" ] || broray_dot_error REQUEST_INVALID "Не указан файл запроса."; broray_dot_apply "$2" ;; + delete) broray_dot_delete ;; + *) echo 'Использование: broray-routes-dot {status|test REQUEST.json|apply REQUEST.json|delete}' >&2; exit 2 ;; +esac diff --git a/root/opt/broray/config/version b/root/opt/broray/config/version index 3e3c2f1..ccbccc3 100644 --- a/root/opt/broray/config/version +++ b/root/opt/broray/config/version @@ -1 +1 @@ -2.1.1 +2.2.0 diff --git a/root/opt/broray/lib/broray-cleanup.sh b/root/opt/broray/lib/broray-cleanup.sh new file mode 100755 index 0000000..1f99585 --- /dev/null +++ b/root/opt/broray/lib/broray-cleanup.sh @@ -0,0 +1,600 @@ +#!/opt/bin/ash + +# Safe, BusyBox-compatible cleanup planner for BROray. +# Only explicitly managed files below /opt/broray may be selected. + +BRORAY_BASE="${BRORAY_BASE:-/opt/broray}" +BRORAY_CLEANUP_RUN="${BRORAY_CLEANUP_RUN:-$BRORAY_BASE/run/cleanup}" +BRORAY_CLEANUP_PLAN_DIR="${BRORAY_CLEANUP_PLAN_DIR:-$BRORAY_CLEANUP_RUN/plans}" +BRORAY_CLEANUP_GLOBAL_LOCK="${BRORAY_CLEANUP_GLOBAL_LOCK:-$BRORAY_BASE/run/global-operation.lock}" +BRORAY_CLEANUP_TTL="${BRORAY_CLEANUP_TTL:-120}" +BRORAY_CLEANUP_LOCK_HELD=false +BRORAY_CLEANUP_ERROR_CODE="" +BRORAY_CLEANUP_ERROR_MESSAGE="" + +broray_cleanup_error() +{ + local code message + code="$1" + message="$2" + BRORAY_CLEANUP_ERROR_CODE="$code" + BRORAY_CLEANUP_ERROR_MESSAGE="$message" + return 1 +} + +broray_cleanup_now_epoch() +{ + date +%s +} + +broray_cleanup_now_iso() +{ + date -u '+%Y-%m-%dT%H:%M:%SZ' +} + +broray_cleanup_is_uint() +{ + case "${1:-}" in + ''|*[!0-9]*) return 1 ;; + esac + return 0 +} + +broray_cleanup_require_runtime() +{ + command -v jq >/dev/null 2>&1 || + broray_cleanup_error CLEANUP_JQ_MISSING 'Для очистки требуется jq.' || return 1 + command -v sha256sum >/dev/null 2>&1 || + broray_cleanup_error CLEANUP_SHA256_MISSING 'Для очистки требуется sha256sum.' || return 1 + mkdir -p "$BRORAY_CLEANUP_PLAN_DIR" || + broray_cleanup_error CLEANUP_RUNTIME_FAILED 'Не удалось создать служебный каталог очистки.' || return 1 + chmod 700 "$BRORAY_CLEANUP_RUN" "$BRORAY_CLEANUP_PLAN_DIR" 2>/dev/null || true + return 0 +} + +broray_cleanup_token_valid() +{ + local token + case "${1:-}" in + ????????????????????????????????) ;; + *) return 1 ;; + esac + case "$1" in + *[!0-9a-f]*) return 1 ;; + esac + return 0 +} + +broray_cleanup_token_create() +{ + printf '%s:%s:%s:%s\n' "$(broray_cleanup_now_epoch)" "$$" "${PPID:-0}" "$(cat /proc/uptime 2>/dev/null || true)" | + sha256sum | awk '{print substr($1, 1, 32)}' +} + +broray_cleanup_path_relative() +{ + local path + case "$1" in + "$BRORAY_BASE"/*) printf '%s\n' "${1#"$BRORAY_BASE"/}" ;; + *) return 1 ;; + esac +} + +broray_cleanup_name_safe() +{ + local name + case "$1" in + ''|.|..|*'/'*|*'\t'*|*'\n'*|*'\r'*) return 1 ;; + esac + return 0 +} + +broray_cleanup_is_old() +{ + local path days + path="$1" + days="$2" + [ -n "$(find "$path" -maxdepth 0 -mtime "+$days" -print 2>/dev/null)" ] +} + +broray_cleanup_size_bytes() +{ + local path size + path="$1" + if [ -L "$path" ]; then + return 1 + fi + if [ -f "$path" ]; then + size="$(stat -c '%s' "$path" 2>/dev/null || true)" + if ! broray_cleanup_is_uint "$size"; then + size="$(wc -c <"$path" 2>/dev/null | tr -d ' ')" + fi + elif [ -d "$path" ]; then + size="$(du -sk "$path" 2>/dev/null | awk 'NR == 1 {print $1}')" + broray_cleanup_is_uint "$size" || return 1 + size=$((size * 1024)) + else + return 1 + fi + broray_cleanup_is_uint "$size" || return 1 + printf '%s\n' "$size" +} + +broray_cleanup_candidate_add() +{ + local output category path relative kind size + output="$1" + category="$2" + path="$3" + [ -e "$path" ] || return 0 + [ ! -L "$path" ] || return 0 + relative="$(broray_cleanup_path_relative "$path")" || return 0 + case "$relative" in + *'..'*|*'\t'*|*'\n'*|*'\r'*) return 0 ;; + esac + if [ -f "$path" ]; then + kind=file + elif [ -d "$path" ]; then + kind=directory + else + return 0 + fi + size="$(broray_cleanup_size_bytes "$path")" || return 0 + printf '%s\t%s\t%s\t%s\n' "$category" "$kind" "$size" "$relative" >>"$output" +} + +broray_cleanup_add_aged_glob() +{ + local output category days path + output="$1" + category="$2" + days="$3" + shift 3 + for path in "$@"; do + [ -e "$path" ] || continue + [ ! -L "$path" ] || continue + broray_cleanup_is_old "$path" "$days" || continue + broray_cleanup_candidate_add "$output" "$category" "$path" + done +} + +broray_cleanup_add_preserved_group() +{ + local output category keep days list path base index + output="$1" + category="$2" + keep="$3" + days="$4" + shift 4 + list="/tmp/broray-cleanup-group-$$.$category" + : >"$list" || return 1 + for path in "$@"; do + [ -e "$path" ] || continue + [ ! -L "$path" ] || continue + base="${path##*/}" + broray_cleanup_name_safe "$base" || continue + printf '%s\n' "$path" >>"$list" + done + sort -r "$list" -o "$list" 2>/dev/null || { + rm -f "$list" + return 1 + } + index=0 + while IFS= read -r path; do + [ -n "$path" ] || continue + index=$((index + 1)) + [ "$index" -gt "$keep" ] || continue + broray_cleanup_is_old "$path" "$days" || continue + broray_cleanup_candidate_add "$output" "$category" "$path" + done <"$list" + rm -f "$list" + return 0 +} + +broray_cleanup_collect_temp() +{ + local output + output="$1" + broray_cleanup_add_aged_glob "$output" temp 0 \ + "$BRORAY_BASE"/tmp/broray-* \ + "$BRORAY_BASE"/tmp/routes-* \ + "$BRORAY_BASE"/tmp/.broray-* \ + "$BRORAY_BASE"/tmp/*.part \ + "$BRORAY_BASE"/tmp/*.download \ + "$BRORAY_BASE"/tmp/*.tmp.* \ + "$BRORAY_BASE"/routes/tmp/* \ + "$BRORAY_BASE"/routes/transactions/* \ + "$BRORAY_BASE"/run/broray/*.tmp.* +} + +broray_cleanup_collect_backups() +{ + local output backup + output="$1" + backup="$BRORAY_BASE/backup" + broray_cleanup_add_preserved_group "$output" backups 3 0 \ + "$backup"/system-before-*.tar.gz \ + "$backup"/system-before-reinstall-*.tar.gz + broray_cleanup_add_preserved_group "$output" backups 3 0 \ + "$backup"/user-before-*.tar.gz \ + "$backup"/user-before-reinstall-*.tar.gz + broray_cleanup_add_preserved_group "$output" backups 3 0 \ + "$backup"/web-new-before-*.tar.gz \ + "$backup"/web-before-*.tar.gz + broray_cleanup_add_preserved_group "$output" backups 3 0 \ + "$backup"/xray-before-*.tar.gz \ + "$backup"/xray-binaries/* + broray_cleanup_add_preserved_group "$output" backups 3 0 \ + "$backup"/lighttpd.conf.before-* \ + "$backup"/package-before-*.tar.gz \ + "$backup"/before-update-*.tar.gz \ + "$backup"/before-restore-*.tar.gz \ + "$backup"/before-uninstall-*.tar.gz +} + +broray_cleanup_collect_route_backups() +{ + local output backup + output="$1" + backup="$BRORAY_BASE/routes/backup" + broray_cleanup_add_preserved_group "$output" routeBackups 3 0 \ + "$backup"/catalog-* \ + "$backup"/user-removed-* \ + "$backup"/rollback-* \ + "$backup"/transaction-* +} + +broray_cleanup_collect_logs() +{ + local output + output="$1" + broray_cleanup_add_aged_glob "$output" logs 6 \ + "$BRORAY_BASE"/logs/*.gz \ + "$BRORAY_BASE"/logs/*.old \ + "$BRORAY_BASE"/logs/*.log.[0-9]* \ + "$BRORAY_BASE"/logs/*.log-* \ + "$BRORAY_BASE"/run/broray/operation.log.* \ + "$BRORAY_BASE"/run/broray/*.old +} + +broray_cleanup_build_list() +{ + local output include_temp include_backups include_route_backups include_logs raw + output="$1" + include_temp="$2" + include_backups="$3" + include_route_backups="$4" + include_logs="$5" + raw="$output.raw.$$" + : >"$raw" || return 1 + [ "$include_temp" = true ] && broray_cleanup_collect_temp "$raw" + [ "$include_backups" = true ] && broray_cleanup_collect_backups "$raw" + [ "$include_route_backups" = true ] && broray_cleanup_collect_route_backups "$raw" + [ "$include_logs" = true ] && broray_cleanup_collect_logs "$raw" + sort -u "$raw" >"$output" || { + rm -f "$raw" + return 1 + } + rm -f "$raw" + return 0 +} + +broray_cleanup_list_digest() +{ + local list + sha256sum "$1" | awk '{print $1}' +} + +broray_cleanup_list_count() +{ + local list + awk 'NF > 0 {count++} END {print count + 0}' "$1" +} + +broray_cleanup_list_bytes() +{ + local list + awk -F '\t' 'NF == 4 {sum += $3} END {printf "%.0f\n", sum + 0}' "$1" +} + +broray_cleanup_list_json() +{ + local list ndjson tab category kind size relative rc + list="$1" + ndjson="$list.ndjson.$$" + : >"$ndjson" || return 1 + tab="$(printf '\t')" + while IFS="$tab" read -r category kind size relative; do + [ -n "$relative" ] || continue + jq -nc \ + --arg category "$category" \ + --arg kind "$kind" \ + --arg path "$relative" \ + --argjson sizeBytes "$size" \ + '{category:$category,kind:$kind,path:$path,sizeBytes:$sizeBytes}' >>"$ndjson" || { + rm -f "$ndjson" + return 1 + } + done <"$list" + jq -s '.' "$ndjson" + rc=$? + rm -f "$ndjson" + return "$rc" +} + +broray_cleanup_summary_json() +{ + local list + list="$1" + jq -Rn \ + '[inputs | split("\t") | select(length == 4) | {category:.[0], bytes:(.[2] | tonumber)}] + | group_by(.category) + | map({key:.[0].category,value:{count:length,estimatedBytes:(map(.bytes)|add)}}) + | from_entries' <"$list" +} + +broray_cleanup_expired_plans_remove() +{ + local now plan expires token + now="$(broray_cleanup_now_epoch)" + for plan in "$BRORAY_CLEANUP_PLAN_DIR"/*.json; do + [ -f "$plan" ] || continue + expires="$(jq -r '.expiresEpoch // 0' "$plan" 2>/dev/null || printf '0')" + broray_cleanup_is_uint "$expires" || expires=0 + [ "$expires" -ge "$now" ] || { + token="${plan##*/}" + token="${token%.json}" + rm -f "$plan" "$BRORAY_CLEANUP_PLAN_DIR/$token.list" + } + done +} + +broray_cleanup_global_active() +{ + local owner + [ -d "$BRORAY_CLEANUP_GLOBAL_LOCK" ] || return 1 + owner="$(sed -n '1p' "$BRORAY_CLEANUP_GLOBAL_LOCK/pid" 2>/dev/null || true)" + broray_cleanup_is_uint "$owner" || return 1 + kill -0 "$owner" 2>/dev/null +} + +broray_cleanup_global_lock_acquire() +{ + local owner + mkdir -p "$(dirname "$BRORAY_CLEANUP_GLOBAL_LOCK")" || return 1 + if mkdir "$BRORAY_CLEANUP_GLOBAL_LOCK" 2>/dev/null; then + : + else + if broray_cleanup_global_active; then + return 2 + fi + rm -rf "$BRORAY_CLEANUP_GLOBAL_LOCK" 2>/dev/null || return 1 + mkdir "$BRORAY_CLEANUP_GLOBAL_LOCK" 2>/dev/null || return 1 + fi + printf '%s\n' "$$" >"$BRORAY_CLEANUP_GLOBAL_LOCK/pid" || return 1 + printf '%s\n' system >"$BRORAY_CLEANUP_GLOBAL_LOCK/scope" || return 1 + printf '%s\n' cleanup >"$BRORAY_CLEANUP_GLOBAL_LOCK/action" || return 1 + : >"$BRORAY_CLEANUP_GLOBAL_LOCK/bundle" || return 1 + printf '%s\n' "$(broray_cleanup_now_iso)" >"$BRORAY_CLEANUP_GLOBAL_LOCK/startedAt" || return 1 + BRORAY_CLEANUP_LOCK_HELD=true + return 0 +} + +broray_cleanup_global_lock_release() +{ + local owner action + [ "$BRORAY_CLEANUP_LOCK_HELD" = true ] || return 0 + owner="$(sed -n '1p' "$BRORAY_CLEANUP_GLOBAL_LOCK/pid" 2>/dev/null || true)" + action="$(sed -n '1p' "$BRORAY_CLEANUP_GLOBAL_LOCK/action" 2>/dev/null || true)" + if [ "$owner" = "$$" ] && [ "$action" = cleanup ]; then + rm -rf "$BRORAY_CLEANUP_GLOBAL_LOCK" 2>/dev/null || true + fi + BRORAY_CLEANUP_LOCK_HELD=false +} + +broray_cleanup_plan_create() +{ + local include_temp include_backups include_route_backups include_logs value token list plan digest count bytes created expires summary candidates + include_temp="$1" + include_backups="$2" + include_route_backups="$3" + include_logs="$4" + for value in "$include_temp" "$include_backups" "$include_route_backups" "$include_logs"; do + case "$value" in true|false) ;; *) + broray_cleanup_error CLEANUP_OPTIONS_INVALID 'Параметры очистки некорректны.' + return 1 + esac + done + [ "$include_temp$include_backups$include_route_backups$include_logs" != falsefalsefalsefalse ] || { + broray_cleanup_error CLEANUP_NOTHING_SELECTED 'Выберите хотя бы одну категорию очистки.' + return 1 + } + broray_cleanup_require_runtime || return 1 + broray_cleanup_expired_plans_remove + if broray_cleanup_global_active; then + broray_cleanup_error CLEANUP_OPERATION_BUSY 'Другая конфликтующая операция уже выполняется.' + return 1 + fi + token="$(broray_cleanup_token_create)" + broray_cleanup_token_valid "$token" || { + broray_cleanup_error CLEANUP_TOKEN_FAILED 'Не удалось создать подтверждение очистки.' + return 1 + } + list="$BRORAY_CLEANUP_PLAN_DIR/$token.list" + plan="$BRORAY_CLEANUP_PLAN_DIR/$token.json" + broray_cleanup_build_list "$list" "$include_temp" "$include_backups" "$include_route_backups" "$include_logs" || { + rm -f "$list" "$plan" + broray_cleanup_error CLEANUP_PLAN_FAILED 'Не удалось составить план очистки.' + return 1 + } + digest="$(broray_cleanup_list_digest "$list")" + count="$(broray_cleanup_list_count "$list")" + bytes="$(broray_cleanup_list_bytes "$list")" + created="$(broray_cleanup_now_epoch)" + expires=$((created + BRORAY_CLEANUP_TTL)) + summary="$(broray_cleanup_summary_json "$list")" || summary='{}' + candidates="$(broray_cleanup_list_json "$list")" || candidates='[]' + jq -nc \ + --arg token "$token" \ + --arg createdAt "$(broray_cleanup_now_iso)" \ + --arg digest "$digest" \ + --argjson createdEpoch "$created" \ + --argjson expiresEpoch "$expires" \ + --argjson ttlSeconds "$BRORAY_CLEANUP_TTL" \ + --argjson includeTemp "$include_temp" \ + --argjson includeBackups "$include_backups" \ + --argjson includeRouteBackups "$include_route_backups" \ + --argjson includeLogs "$include_logs" \ + --argjson candidateCount "$count" \ + --argjson estimatedBytes "$bytes" \ + --argjson categories "$summary" \ + --argjson candidates "$candidates" \ + '{schemaVersion:1,token:$token,createdAt:$createdAt,createdEpoch:$createdEpoch, + expiresEpoch:$expiresEpoch,ttlSeconds:$ttlSeconds,digest:$digest, + options:{temp:$includeTemp,backups:$includeBackups,routeBackups:$includeRouteBackups,logs:$includeLogs}, + candidateCount:$candidateCount,estimatedBytes:$estimatedBytes,categories:$categories,candidates:$candidates}' | + tee "$plan" || { + rm -f "$list" "$plan" + broray_cleanup_error CLEANUP_PLAN_FAILED 'Не удалось сохранить план очистки.' + return 1 + } + chmod 600 "$list" "$plan" 2>/dev/null || true + return 0 +} + +broray_cleanup_path_allowed() +{ + local category kind relative + category="$1" + kind="$2" + relative="$3" + case "$relative" in + ''|/*|*'..'*|*'\t'*|*'\n'*|*'\r'*) return 1 ;; + esac + case "$category:$kind:$relative" in + temp:file:tmp/broray-*|temp:file:tmp/routes-*|temp:file:tmp/.broray-*|temp:file:tmp/*.part|temp:file:tmp/*.download|temp:file:tmp/*.tmp.*|\ + temp:file:routes/tmp/*|temp:directory:routes/tmp/*|temp:file:routes/transactions/*|temp:directory:routes/transactions/*|temp:file:run/broray/*.tmp.*) ;; + backups:file:backup/system-before-*.tar.gz|backups:file:backup/system-before-reinstall-*.tar.gz|\ + backups:file:backup/user-before-*.tar.gz|backups:file:backup/user-before-reinstall-*.tar.gz|\ + backups:file:backup/web-new-before-*.tar.gz|backups:file:backup/web-before-*.tar.gz|\ + backups:file:backup/xray-before-*.tar.gz|backups:file:backup/xray-binaries/*|\ + backups:file:backup/lighttpd.conf.before-*|backups:file:backup/package-before-*.tar.gz|\ + backups:file:backup/before-update-*.tar.gz|backups:file:backup/before-restore-*.tar.gz|backups:file:backup/before-uninstall-*.tar.gz) ;; + routeBackups:directory:routes/backup/catalog-*|routeBackups:directory:routes/backup/user-removed-*|\ + routeBackups:directory:routes/backup/rollback-*|routeBackups:directory:routes/backup/transaction-*|\ + routeBackups:file:routes/backup/catalog-*|routeBackups:file:routes/backup/user-removed-*|\ + routeBackups:file:routes/backup/rollback-*|routeBackups:file:routes/backup/transaction-*) ;; + logs:file:logs/*.gz|logs:file:logs/*.old|logs:file:logs/*.log.[0-9]*|logs:file:logs/*.log-*|\ + logs:file:run/broray/operation.log.*|logs:file:run/broray/*.old) ;; + *) return 1 ;; + esac + return 0 +} + +broray_cleanup_execute() +{ + local token plan list now expires include_temp include_backups include_route_backups include_logs expected_digest current current_digest lock_rc deleted bytes failed tab category kind size relative path + token="$1" + broray_cleanup_require_runtime || return 1 + broray_cleanup_token_valid "$token" || { + broray_cleanup_error CLEANUP_TOKEN_INVALID 'Подтверждение очистки некорректно.' + return 1 + } + plan="$BRORAY_CLEANUP_PLAN_DIR/$token.json" + list="$BRORAY_CLEANUP_PLAN_DIR/$token.list" + [ -s "$plan" ] && [ -f "$list" ] || { + broray_cleanup_error CLEANUP_PLAN_NOT_FOUND 'План очистки не найден. Выполните проверку заново.' + return 1 + } + now="$(broray_cleanup_now_epoch)" + expires="$(jq -r '.expiresEpoch // 0' "$plan" 2>/dev/null || printf '0')" + broray_cleanup_is_uint "$expires" || expires=0 + [ "$expires" -ge "$now" ] || { + rm -f "$plan" "$list" + broray_cleanup_error CLEANUP_PLAN_EXPIRED 'Срок подтверждения истёк. Выполните проверку заново.' + return 1 + } + include_temp="$(jq -r '.options.temp // false' "$plan")" + include_backups="$(jq -r '.options.backups // false' "$plan")" + include_route_backups="$(jq -r '.options.routeBackups // false' "$plan")" + include_logs="$(jq -r '.options.logs // false' "$plan")" + expected_digest="$(jq -r '.digest // empty' "$plan")" + current="$BRORAY_CLEANUP_PLAN_DIR/$token.current.$$" + broray_cleanup_build_list "$current" "$include_temp" "$include_backups" "$include_route_backups" "$include_logs" || { + rm -f "$current" + broray_cleanup_error CLEANUP_RECHECK_FAILED 'Не удалось повторно проверить план очистки.' + return 1 + } + current_digest="$(broray_cleanup_list_digest "$current")" + if [ "$current_digest" != "$expected_digest" ] || ! cmp -s "$list" "$current"; then + rm -f "$current" "$plan" "$list" + broray_cleanup_error CLEANUP_PLAN_CHANGED 'Состав файлов изменился. Выполните проверку заново.' + return 1 + fi + rm -f "$current" + + lock_rc=0 + broray_cleanup_global_lock_acquire || lock_rc=$? + case "$lock_rc" in + 0) ;; + 2) + broray_cleanup_error CLEANUP_OPERATION_BUSY 'Другая конфликтующая операция уже выполняется.' + return 1 + ;; + *) + broray_cleanup_error CLEANUP_LOCK_FAILED 'Не удалось установить блокировку очистки.' + return 1 + ;; + esac + + deleted=0 + bytes=0 + failed="" + tab="$(printf '\t')" + while IFS="$tab" read -r category kind size relative; do + [ -n "$relative" ] || continue + broray_cleanup_path_allowed "$category" "$kind" "$relative" || { + failed="$relative" + break + } + path="$BRORAY_BASE/$relative" + [ ! -L "$path" ] || { + failed="$relative" + break + } + if [ "$kind" = file ]; then + [ -f "$path" ] || { + failed="$relative" + break + } + rm -f "$path" || { + failed="$relative" + break + } + else + [ -d "$path" ] || { + failed="$relative" + break + } + rm -rf "$path" || { + failed="$relative" + break + } + fi + deleted=$((deleted + 1)) + bytes=$((bytes + size)) + done <"$list" + + broray_cleanup_global_lock_release + if [ -n "$failed" ]; then + rm -f "$plan" "$list" + broray_cleanup_error CLEANUP_DELETE_FAILED "Очистка остановлена на объекте: $failed" + return 1 + fi + rm -f "$plan" "$list" + jq -nc \ + --arg completedAt "$(broray_cleanup_now_iso)" \ + --argjson deletedCount "$deleted" \ + --argjson freedBytes "$bytes" \ + '{ok:true,deletedCount:$deletedCount,freedBytes:$freedBytes,completedAt:$completedAt}' +} diff --git a/root/opt/broray/lib/broray-page.sh b/root/opt/broray/lib/broray-page.sh index 9fedbfc..41fd6f5 100644 --- a/root/opt/broray/lib/broray-page.sh +++ b/root/opt/broray/lib/broray-page.sh @@ -11,11 +11,14 @@ BRORAY_BACKUP="$BRORAY_BASE/backup" BRORAY_STATUS="$BRORAY_RUN/operation.json" BRORAY_UPDATE_CACHE="$BRORAY_RUN/update.json" BRORAY_LOCK="$BRORAY_RUN/operation.lock" +BRORAY_GLOBAL_LOCK="${BRORAY_GLOBAL_LOCK:-$BRORAY_BASE/run/global-operation.lock}" +BRORAY_GLOBAL_LOCK_HELD=false BRORAY_LOG="$BRORAY_RUN/operation.log" BRORAY_LAST_BACKUP="$BRORAY_RUN/last-backup" BRORAY_PACKAGE="broray" BRORAY_INIT_ROOT="${BRORAY_INIT_ROOT:-/opt/etc/init.d}" BRORAY_FEED_FILE="${BRORAY_FEED_FILE:-/opt/etc/opkg/broray.conf}" +BRORAY_OPKG_LISTS_DIR="${BRORAY_OPKG_LISTS_DIR:-/opt/var/opkg-lists}" BRORAY_PROJECT_URL="https://docs.brovibe.cloud/broray/" BRORAY_GITHUB_URL="https://github.com/BROadmin/BROray" BRORAY_DONATE_URL="https://pay.cloudtips.ru/p/09b23d0a" @@ -170,6 +173,84 @@ broray_system_parser_available() { esac } +broray_system_is_pid() { + case "${1:-}" in + ''|*[!0-9]*) return 1 ;; + esac + return 0 +} + +broray_system_global_operation_running() { + [ -d "$BRORAY_GLOBAL_LOCK" ] || return 1 + [ -r "$BRORAY_GLOBAL_LOCK/pid" ] || return 1 + global_pid="$(sed -n '1p' "$BRORAY_GLOBAL_LOCK/pid" 2>/dev/null)" + broray_system_is_pid "$global_pid" || return 1 + kill -0 "$global_pid" 2>/dev/null +} + +broray_system_global_lock_recover_stale() { + [ -d "$BRORAY_GLOBAL_LOCK" ] || return 0 + broray_system_global_operation_running && return 1 + rm -rf "$BRORAY_GLOBAL_LOCK" +} + +broray_system_global_lock_acquire() { + global_action="${1:-system}" + mkdir -p "$(dirname "$BRORAY_GLOBAL_LOCK")" || return 1 + broray_system_global_lock_recover_stale || return 2 + mkdir "$BRORAY_GLOBAL_LOCK" 2>/dev/null || return 2 + printf '%s\n' "$$" >"$BRORAY_GLOBAL_LOCK/pid" || { + rm -rf "$BRORAY_GLOBAL_LOCK" + return 1 + } + printf '%s\n' system >"$BRORAY_GLOBAL_LOCK/scope" || { + rm -rf "$BRORAY_GLOBAL_LOCK" + return 1 + } + printf '%s\n' "$global_action" >"$BRORAY_GLOBAL_LOCK/action" || { + rm -rf "$BRORAY_GLOBAL_LOCK" + return 1 + } + : >"$BRORAY_GLOBAL_LOCK/bundle" || { + rm -rf "$BRORAY_GLOBAL_LOCK" + return 1 + } + printf '%s\n' "$(broray_system_now)" >"$BRORAY_GLOBAL_LOCK/startedAt" || { + rm -rf "$BRORAY_GLOBAL_LOCK" + return 1 + } + BRORAY_GLOBAL_LOCK_HELD=true + return 0 +} + +broray_system_global_lock_transfer() { + global_pid="${1:-}" + global_operation_id="${2:-}" + broray_system_is_pid "$global_pid" || return 1 + [ -d "$BRORAY_GLOBAL_LOCK" ] || return 1 + [ "$(sed -n '1p' "$BRORAY_GLOBAL_LOCK/scope" 2>/dev/null || true)" = system ] || return 1 + [ "$(sed -n '1p' "$BRORAY_GLOBAL_LOCK/pid" 2>/dev/null || true)" = "$$" ] || return 1 + printf '%s\n' "$global_pid" >"$BRORAY_GLOBAL_LOCK/pid" || return 1 + printf '%s\n' "$global_operation_id" >"$BRORAY_GLOBAL_LOCK/operation-id" || return 1 + BRORAY_GLOBAL_LOCK_HELD=false + return 0 +} + +broray_system_global_lock_release() { + [ -d "$BRORAY_GLOBAL_LOCK" ] || { + BRORAY_GLOBAL_LOCK_HELD=false + return 0 + } + global_pid="$(sed -n '1p' "$BRORAY_GLOBAL_LOCK/pid" 2>/dev/null || true)" + global_scope="$(sed -n '1p' "$BRORAY_GLOBAL_LOCK/scope" 2>/dev/null || true)" + if [ "$global_scope" = system ] && { + [ "$BRORAY_GLOBAL_LOCK_HELD" = true ] || [ "$global_pid" = "$$" ]; + }; then + rm -rf "$BRORAY_GLOBAL_LOCK" 2>/dev/null || true + fi + BRORAY_GLOBAL_LOCK_HELD=false +} + broray_system_operation_running() { [ -d "$BRORAY_LOCK" ] || return 1 [ -r "$BRORAY_LOCK/pid" ] || return 0 @@ -268,6 +349,8 @@ broray_system_status_json() { 'Операция была прервана.' \ 'Фоновый процесс операции больше не выполняется.' rm -rf "$BRORAY_LOCK" + broray_system_global_lock_release + broray_system_global_lock_recover_stale >/dev/null 2>&1 || true fi fi @@ -289,6 +372,16 @@ broray_system_info_json() { } current_version="$(broray_system_version)" + installed_package_version="$(broray_system_installed_package_version 2>/dev/null || true)" + reinstall_supported=false + if [ -n "$installed_package_version" ] && + command -v opkg >/dev/null 2>&1 && + command -v curl >/dev/null 2>&1 && + command -v sha256sum >/dev/null 2>&1 && + command -v tar >/dev/null 2>&1 + then + reinstall_supported=true + fi build="$(broray_system_build)" architecture="$(broray_system_architecture)" available_version="$(broray_system_available_version)" @@ -334,6 +427,7 @@ broray_system_info_json() { jq -nc \ --arg version "$current_version" \ + --arg installedPackageVersion "$installed_package_version" \ --arg build "$build" \ --arg architecture "$architecture" \ --arg channel 'stable' \ @@ -345,12 +439,15 @@ broray_system_info_json() { --arg donateUrl "$BRORAY_DONATE_URL" \ --arg updatedAt "$(broray_system_now)" \ --argjson updateAvailable "$update_available" \ + --argjson reinstallSupported "$reinstall_supported" \ --argjson installationHealthy "$healthy" \ --argjson components "$components" \ --argjson protocols "$protocols" \ '{ ok:true, version:$version, + installedPackageVersion:(if $installedPackageVersion == "" then null else $installedPackageVersion end), + reinstallSupported:$reinstallSupported, build:$build, architecture:$architecture, updateChannel:$channel, @@ -368,7 +465,9 @@ broray_system_info_json() { "Интеграция с управляемым ProxyN", "Маршрутизация сервисов", "Проверка и восстановление компонентов", - "Обновление через WebUI" + "Обновление через WebUI", + "Восстановительная переустановка текущей версии", + "Безопасная очистка резервных копий и временных файлов" ], links:{github:$githubUrl,project:$projectUrl,donate:$donateUrl}, updatedAt:$updatedAt @@ -379,7 +478,7 @@ broray_system_feed_value() { wanted_version="$1" wanted_key="$2" - for feed in /opt/var/opkg-lists/*; do + for feed in "$BRORAY_OPKG_LISTS_DIR"/*; do [ -f "$feed" ] || continue awk \ -v wanted_package="$BRORAY_PACKAGE" \ @@ -761,7 +860,7 @@ broray_system_backup_create() { set -- for item in \ VERSION BUILD .build \ - bin lib web-new web config subscriptions servers routes; do + bin lib web-new web config data deleted-subscriptions subscriptions servers routes; do [ -e "$BRORAY_BASE/$item" ] && set -- "$@" "$item" done @@ -793,9 +892,57 @@ broray_system_backup_create() { printf '%s\n' "$archive" } +broray_system_archive_safe() { + local archive list result + archive="$1" + list="/tmp/broray-archive-list-$$" + [ -s "$archive" ] || return 1 + rm -f "$list" + tar -tzf "$archive" >"$list" 2>/dev/null || { + rm -f "$list" + return 1 + } + awk ' + /^\// {bad = 1} + /(^|\/)\.\.($|\/)/ {bad = 1} + END {exit bad ? 1 : 0} + ' "$list" + result=$? + rm -f "$list" + return "$result" +} + broray_system_backup_restore() { + local archive members roots root archive="$1" - [ -f "$archive" ] || return 1 + members="/tmp/broray-restore-members-$$" + roots="/tmp/broray-restore-roots-$$" + broray_system_archive_safe "$archive" || return 1 + tar -tzf "$archive" >"$members" 2>/dev/null || { + rm -f "$members" "$roots" + return 1 + } + sed 's#^\./##; s#/.*##' "$members" | + awk 'NF > 0 && $0 != "." {print}' | + sort -u >"$roots" || { + rm -f "$members" "$roots" + return 1 + } + while IFS= read -r root; do + case "$root" in + VERSION|BUILD|.build|bin|lib|web-new|web|config|data|deleted-subscriptions|subscriptions|servers|routes) + rm -rf "$BRORAY_BASE/$root" || { + rm -f "$members" "$roots" + return 1 + } + ;; + *) + rm -f "$members" "$roots" + return 1 + ;; + esac + done <"$roots" + rm -f "$members" "$roots" tar -xzf "$archive" -C "$BRORAY_BASE" >>"$BRORAY_LOG" 2>&1 } @@ -805,10 +952,14 @@ $BRORAY_BASE/bin/broray $BRORAY_BASE/bin/xray $BRORAY_BASE/lib/xray.sh $BRORAY_BASE/lib/server-service.sh +$BRORAY_BASE/bin/broray-routes-dot +$BRORAY_BASE/lib/routes-dot.sh $BRORAY_BASE/web-new/index.html $BRORAY_BASE/web-new/home.html $BRORAY_BASE/web-new/broray.html $BRORAY_BASE/web-new/api/auth-common.sh +$BRORAY_BASE/web-new/api/broray/reinstall.cgi +$BRORAY_BASE/web-new/api/routes/dot-status.cgi $BRORAY_INIT_ROOT/S23broray-monitor $BRORAY_INIT_ROOT/S24broray $BRORAY_INIT_ROOT/S25broray-web @@ -871,6 +1022,422 @@ broray_system_restart_services() { broray_system_services_health_check } +broray_system_reinstall_user_items() { + for item in config data deleted-subscriptions subscriptions servers routes; do + [ -e "$BRORAY_BASE/$item" ] && printf '%s\n' "$item" + done +} + +broray_system_reinstall_user_backup_create() { + local purpose stamp archive temporary staging items item item_size_kb + local source_size_kb backup_size_kb backup_free_kb required_kb + purpose="$1" + stamp="$(date -u '+%Y%m%d-%H%M%S')" + archive="$BRORAY_BACKUP/${purpose}-${stamp}.tar.gz" + temporary="$BRORAY_BACKUP/.${purpose}-${stamp}.tar.gz.$$" + staging="$BRORAY_BACKUP/.${purpose}-stage-$$" + items="/tmp/broray-${purpose}-items-$$" + + rm -rf "$staging" + rm -f "$temporary" "$items" + broray_system_reinstall_user_items >"$items" || return 1 + [ -s "$items" ] || { + rm -f "$items" + return 1 + } + + source_size_kb=0 + while IFS= read -r item; do + [ -n "$item" ] || continue + item_size_kb="$(du -sk "$BRORAY_BASE/$item" 2>/dev/null | awk 'NR == 1 {print $1}')" + case "$item_size_kb" in + ''|*[!0-9]*) + rm -f "$items" + return 1 + ;; + esac + source_size_kb=$((source_size_kb + item_size_kb)) + done <"$items" + + backup_free_kb="$(df -Pk "$BRORAY_BACKUP" 2>/dev/null | awk 'NR == 2 {print $4}')" + case "$backup_free_kb" in + ''|*[!0-9]*) + rm -f "$items" + return 1 + ;; + esac + required_kb=$((source_size_kb * 2 + 2048)) + [ "$backup_free_kb" -gt "$required_kb" ] || { + rm -f "$items" + return 1 + } + + mkdir -p "$staging" || { + rm -f "$items" + return 1 + } + while IFS= read -r item; do + [ -n "$item" ] || continue + cp -a "$BRORAY_BASE/$item" "$staging/$item" >>"$BRORAY_LOG" 2>&1 || { + rm -rf "$staging" + rm -f "$temporary" "$items" + return 1 + } + done <"$items" + + rm -rf \ + "$staging/routes/tmp" \ + "$staging/routes/locks" \ + "$staging/routes/transactions" || { + rm -rf "$staging" + rm -f "$temporary" "$items" + return 1 + } + + tar -czf "$temporary" -C "$staging" . >>"$BRORAY_LOG" 2>&1 || { + rm -rf "$staging" + rm -f "$temporary" "$items" + return 1 + } + rm -rf "$staging" + rm -f "$items" + + broray_system_archive_safe "$temporary" || { + rm -f "$temporary" + return 1 + } + backup_size_kb="$(du -k "$temporary" 2>/dev/null | awk 'NR == 1 {print $1}')" + backup_free_kb="$(df -Pk "$BRORAY_BACKUP" 2>/dev/null | awk 'NR == 2 {print $4}')" + case "$backup_size_kb:$backup_free_kb" in + *[!0-9:]*|'') + rm -f "$temporary" + return 1 + ;; + esac + [ "$backup_free_kb" -gt $((backup_size_kb + 2048)) ] || { + rm -f "$temporary" + return 1 + } + mv -f "$temporary" "$archive" || { + rm -f "$temporary" + return 1 + } + chmod 600 "$archive" 2>/dev/null || true + printf '%s\n' "$archive" +} + +broray_system_reinstall_user_manifest() { + local output temporary paths item path relative target hash + output="$1" + temporary="$output.new.$$" + paths="$output.paths.$$" + + : >"$paths" || return 1 + broray_system_reinstall_user_items | + while IFS= read -r item; do + [ -n "$item" ] || continue + find "$BRORAY_BASE/$item" \( -type f -o -type l \) 2>/dev/null + done | sort >"$paths" || { + rm -f "$paths" + return 1 + } + + : >"$temporary" || { + rm -f "$paths" + return 1 + } + while IFS= read -r path; do + [ -n "$path" ] || continue + relative="${path#"$BRORAY_BASE"/}" + case "$relative" in + routes/tmp/*|routes/locks/*|routes/transactions/*) continue ;; + esac + if [ -L "$path" ]; then + target="$(readlink "$path" 2>/dev/null)" || { + rm -f "$paths" "$temporary" + return 1 + } + printf 'L %s %s\n' "$relative" "$target" >>"$temporary" || return 1 + else + hash="$(sha256sum "$path" 2>/dev/null | awk '{print $1}')" + [ -n "$hash" ] || { + rm -f "$paths" "$temporary" + return 1 + } + printf 'F %s %s\n' "$relative" "$hash" >>"$temporary" || return 1 + fi + done <"$paths" + rm -f "$paths" + mv -f "$temporary" "$output" +} + +broray_system_reinstall_user_backup_restore() { + local archive item + archive="$1" + broray_system_archive_safe "$archive" || return 1 + + broray_system_reinstall_user_items | + while IFS= read -r item; do + [ -n "$item" ] || continue + rm -rf "$BRORAY_BASE/$item" || exit 1 + done || return 1 + + tar -xzf "$archive" -C "$BRORAY_BASE" >>"$BRORAY_LOG" 2>&1 || return 1 + mkdir -p \ + "$BRORAY_BASE/routes/locks" \ + "$BRORAY_BASE/routes/tmp" \ + "$BRORAY_BASE/routes/transactions" || return 1 + return 0 +} + +broray_system_download_exact_package() { + local staging wanted_version installed_arch feed_base filename expected_sha feed_arch + local package part actual_sha package_arch + staging="$1" + wanted_version="$2" + installed_arch="$(broray_system_installed_package_architecture)" + feed_base="$(broray_system_feed_base_url)" + filename="$(broray_system_feed_value "$wanted_version" Filename)" + expected_sha="$(broray_system_feed_value "$wanted_version" SHA256sum)" + feed_arch="$(broray_system_feed_value "$wanted_version" Architecture)" + + case "$wanted_version" in + ''|*[!0-9A-Za-z._+-]*) return 1 ;; + esac + case "$installed_arch" in + ''|*[!0-9A-Za-z._+-]*) return 1 ;; + esac + [ -n "$feed_base" ] && [ -n "$filename" ] && [ -n "$expected_sha" ] || return 1 + case "$filename" in + /*|*'..'*|*[!0-9A-Za-z._+/-]*) return 1 ;; + esac + case "$expected_sha" in + ''|*[!0-9a-fA-F]*) return 1 ;; + esac + [ "${#expected_sha}" -eq 64 ] || return 1 + [ "$feed_arch" = "$installed_arch" ] || [ "$feed_arch" = all ] || return 1 + + mkdir -p "$staging" || return 1 + package="$staging/${filename##*/}" + part="$package.part" + rm -f "$part" "$package" + curl -fL --connect-timeout 15 --max-time 180 \ + -o "$part" "${feed_base%/}/$filename" >>"$BRORAY_LOG" 2>&1 || { + rm -f "$part" + return 1 + } + mv -f "$part" "$package" || return 1 + + actual_sha="$(sha256sum "$package" | awk '{print $1}')" + [ "$(printf '%s' "$actual_sha" | tr 'A-F' 'a-f')" = "$(printf '%s' "$expected_sha" | tr 'A-F' 'a-f')" ] || return 1 + [ "$(broray_system_ipk_control_value "$package" Package)" = "$BRORAY_PACKAGE" ] || return 1 + [ "$(broray_system_ipk_control_value "$package" Version)" = "$wanted_version" ] || return 1 + package_arch="$(broray_system_ipk_control_value "$package" Architecture)" + [ "$package_arch" = "$installed_arch" ] || [ "$package_arch" = all ] || return 1 + printf '%s\n' "$package" +} + +broray_system_reinstall_rollback() { + local rollback_package expected_version full_backup + rollback_package="$1" + expected_version="$2" + full_backup="$3" + + [ -s "$rollback_package" ] && [ -s "$full_backup" ] || return 1 + broray_system_log "Возвращается пакет BROray $expected_version после неудачной переустановки." + opkg install --force-reinstall "$rollback_package" >>"$BRORAY_LOG" 2>&1 || return 1 + [ "$(broray_system_installed_package_version)" = "$expected_version" ] || return 1 + broray_system_backup_restore "$full_backup" || return 1 + broray_system_restart_services || return 1 + broray_system_health_check || return 1 + rm -f /tmp/broray-opkg-services-before-upgrade /tmp/broray-opkg-existing-backup + return 0 +} + +broray_system_worker_reinstall() { + local operation_id installed_version staging package_file rollback_package + local full_backup user_backup manifest_before manifest_after free_kb reinstall_ok + operation_id="$1" + installed_version="" + staging="" + package_file="" + rollback_package="" + full_backup="" + user_backup="" + manifest_before="" + manifest_after="" + + broray_system_status_write "$operation_id" reinstall running check 5 \ + 'Проверяется возможность переустановки текущей версии.' '' + + installed_version="$(broray_system_installed_package_version)" + [ -n "$installed_version" ] || { + broray_system_status_write "$operation_id" reinstall error check 100 \ + 'Не удалось определить установленный пакет BROray.' \ + 'Переустановка остановлена до изменения файлов.' + return 1 + } + + broray_system_log 'Обновляются списки пакетов OPKG для поиска точной текущей версии.' + opkg update >>"$BRORAY_LOG" 2>&1 || { + broray_system_status_write "$operation_id" reinstall error check 100 \ + 'Не удалось обновить индекс пакетов.' \ + 'Переустановка остановлена до изменения файлов.' + return 1 + } + + staging="/tmp/broray-reinstall-$operation_id" + rm -rf "$staging" + mkdir -p "$staging" || return 1 + + broray_system_status_write "$operation_id" reinstall running download 18 \ + "Загружается текущая версия BROray $installed_version." '' + package_file="$(broray_system_download_exact_package "$staging" "$installed_version")" || { + broray_system_status_write "$operation_id" reinstall error download 100 \ + 'Точный пакет текущей версии недоступен или не прошёл проверку.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + } + + free_kb="$(df -Pk /opt 2>/dev/null | awk 'NR == 2 {print $4}')" + case "$free_kb" in + ''|*[!0-9]*) + broray_system_status_write "$operation_id" reinstall error verify 100 \ + 'Не удалось определить свободное место.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + ;; + esac + [ "$free_kb" -gt 15360 ] || { + broray_system_status_write "$operation_id" reinstall error verify 100 \ + 'Недостаточно свободного места для безопасной переустановки.' \ + 'Требуется более 15360 КБ.' + rm -rf "$staging" + return 1 + } + + broray_system_status_write "$operation_id" reinstall running rollback 30 \ + 'Подготавливается пакет автоматического возврата.' '' + rollback_package="$staging/rollback-${package_file##*/}" + broray_system_make_rollback_ipk "$package_file" "$rollback_package" || { + broray_system_status_write "$operation_id" reinstall error rollback 100 \ + 'Не удалось подготовить автоматический возврат.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + } + + broray_system_status_write "$operation_id" reinstall running backup 42 \ + 'Создаётся полный снимок для аварийного возврата.' '' + full_backup="$(broray_system_backup_create system-before-reinstall)" || { + broray_system_status_write "$operation_id" reinstall error backup 100 \ + 'Не удалось создать полный резервный снимок.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + } + + broray_system_status_write "$operation_id" reinstall running backup 50 \ + 'Отдельно сохраняются пользовательские данные.' '' + user_backup="$(broray_system_reinstall_user_backup_create user-before-reinstall)" || { + broray_system_status_write "$operation_id" reinstall error backup 100 \ + 'Не удалось сохранить пользовательские данные.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + } + manifest_before="$staging/user-before.manifest" + manifest_after="$staging/user-after.manifest" + broray_system_reinstall_user_manifest "$manifest_before" || { + broray_system_status_write "$operation_id" reinstall error backup 100 \ + 'Не удалось зафиксировать контрольные суммы пользовательских данных.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + } + + { + printf '%s\n' "$full_backup" + date '+%s' + } >/tmp/broray-opkg-existing-backup || { + broray_system_status_write "$operation_id" reinstall error backup 100 \ + 'Не удалось передать резервный снимок установщику.' \ + 'Файлы BROray не изменялись.' + rm -rf "$staging" + return 1 + } + + broray_system_status_write "$operation_id" reinstall running install 65 \ + "Переустанавливается BROray $installed_version." '' + BRORAY_OPKG_BACKUP="$full_backup" \ + opkg install --force-reinstall "$package_file" >>"$BRORAY_LOG" 2>&1 || { + broray_system_status_write "$operation_id" reinstall restoring restore 80 \ + 'Переустановка завершилась ошибкой. Возвращается исходное состояние.' '' + if broray_system_reinstall_rollback "$rollback_package" "$installed_version" "$full_backup"; then + broray_system_status_write "$operation_id" reinstall error restored 100 \ + 'Исходная версия и состояние восстановлены.' \ + 'OPKG не смог переустановить пакет.' + else + broray_system_status_write "$operation_id" reinstall error restore-failed 100 \ + 'Автоматический возврат не завершён.' \ + "Полный снимок: $full_backup" + fi + rm -rf "$staging" + return 1 + } + + broray_system_status_write "$operation_id" reinstall running user-data 76 \ + 'Возвращаются пользовательские настройки и данные.' '' + broray_system_reinstall_user_backup_restore "$user_backup" || { + broray_system_status_write "$operation_id" reinstall restoring restore 86 \ + 'Пользовательские данные восстановить не удалось. Выполняется возврат.' '' + if broray_system_reinstall_rollback "$rollback_package" "$installed_version" "$full_backup"; then + broray_system_status_write "$operation_id" reinstall error restored 100 \ + 'Исходная версия и состояние восстановлены.' \ + 'Восстановление пользовательских данных завершилось ошибкой.' + else + broray_system_status_write "$operation_id" reinstall error restore-failed 100 \ + 'Автоматический возврат не завершён.' \ + "Полный снимок: $full_backup" + fi + rm -rf "$staging" + return 1 + } + + broray_system_status_write "$operation_id" reinstall running health 88 \ + 'Проверяются версия, службы и сохранность пользовательских данных.' '' + reinstall_ok=true + [ "$(broray_system_installed_package_version)" = "$installed_version" ] || reinstall_ok=false + broray_system_restart_services || reinstall_ok=false + broray_system_health_check || reinstall_ok=false + broray_system_reinstall_user_manifest "$manifest_after" || reinstall_ok=false + cmp -s "$manifest_before" "$manifest_after" || reinstall_ok=false + + if [ "$reinstall_ok" != true ]; then + broray_system_status_write "$operation_id" reinstall restoring restore 94 \ + 'Проверка переустановки не пройдена. Выполняется автоматический возврат.' '' + if broray_system_reinstall_rollback "$rollback_package" "$installed_version" "$full_backup"; then + broray_system_status_write "$operation_id" reinstall error restored 100 \ + 'Исходная версия и состояние восстановлены.' \ + 'Переустановленная система не прошла контрольную проверку.' + else + broray_system_status_write "$operation_id" reinstall error restore-failed 100 \ + 'Автоматический возврат не завершён.' \ + "Полный снимок: $full_backup" + fi + rm -rf "$staging" + return 1 + fi + + rm -f /tmp/broray-opkg-services-before-upgrade /tmp/broray-opkg-existing-backup + rm -rf "$staging" + broray_system_status_write "$operation_id" reinstall success complete 100 \ + "BROray $installed_version переустановлен. Пользовательские данные сохранены." '' + return 0 +} + broray_system_worker_update() { operation_id="$1" backup="" @@ -1137,12 +1704,28 @@ broray_system_start_worker() { return 1 } + global_lock_rc=0 + broray_system_global_lock_acquire "$operation" || global_lock_rc=$? + case "$global_lock_rc" in + 0) ;; + 2) + broray_system_error_json OPERATION_BUSY 'Сейчас выполняется другая конфликтующая операция.' + return 1 + ;; + *) + broray_system_error_json GLOBAL_LOCK_FAILED 'Не удалось установить общую блокировку операции.' + return 1 + ;; + esac + broray_system_recover_stale_lock || { + broray_system_global_lock_release broray_system_error_json OPERATION_BUSY 'Сейчас выполняется другая операция.' return 1 } mkdir "$BRORAY_LOCK" 2>/dev/null || { + broray_system_global_lock_release broray_system_error_json OPERATION_BUSY 'Сейчас выполняется другая операция.' return 1 } @@ -1150,6 +1733,7 @@ broray_system_start_worker() { operation_id="${operation}-$(date -u '+%Y%m%d%H%M%S')-$$" workers="$(broray_system_copy_worker "$operation_id")" || { rm -rf "$BRORAY_LOCK" + broray_system_global_lock_release broray_system_error_json WORKER_COPY_FAILED 'Не удалось подготовить фоновую операцию.' return 1 } @@ -1166,6 +1750,13 @@ broray_system_start_worker() { worker_pid=$! printf '%s\n' "$worker_pid" >"$BRORAY_LOCK/pid" printf '%s\n' "$operation_id" >"$BRORAY_LOCK/operation-id" + broray_system_global_lock_transfer "$worker_pid" "$operation_id" || { + kill "$worker_pid" 2>/dev/null || true + rm -rf "$BRORAY_LOCK" + broray_system_global_lock_release + broray_system_error_json GLOBAL_LOCK_TRANSFER_FAILED 'Не удалось передать общую блокировку фоновой операции.' + return 1 + } jq -nc \ --arg operationId "$operation_id" \ @@ -1177,6 +1768,7 @@ broray_system_worker_finish() { worker_bin="$1" worker_lib="$2" rm -rf "$BRORAY_LOCK" + broray_system_global_lock_release rm -f "$worker_bin" "$worker_lib" } @@ -1306,13 +1898,16 @@ broray_system_main() { update-start) broray_system_start_worker update ;; + reinstall-start) + broray_system_start_worker reinstall + ;; restore-start) broray_system_start_worker restore ;; uninstall-start) broray_system_uninstall_start "${1:-}" "${2:-}" ;; - worker-update|worker-restore|worker-uninstall) + worker-update|worker-reinstall|worker-restore|worker-uninstall) operation_id="${1:-}" mode="${2:-}" worker_bin="$0" @@ -1322,6 +1917,9 @@ broray_system_main() { worker-update) broray_system_worker_update "$operation_id" || result=$? ;; + worker-reinstall) + broray_system_worker_reinstall "$operation_id" || result=$? + ;; worker-restore) broray_system_worker_restore "$operation_id" || result=$? ;; @@ -1342,7 +1940,7 @@ broray_system_main() { return "$result" ;; *) - printf '%s\n' 'Использование: broray-system {info|status|update-check|update-start|restore-start|uninstall-start}' >&2 + printf '%s\n' 'Использование: broray-system {info|status|update-check|update-start|reinstall-start|restore-start|uninstall-start}' >&2 return 2 ;; esac diff --git a/root/opt/broray/lib/package-setup.sh b/root/opt/broray/lib/package-setup.sh index 682018f..4549868 100755 --- a/root/opt/broray/lib/package-setup.sh +++ b/root/opt/broray/lib/package-setup.sh @@ -6,7 +6,7 @@ PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH PRODUCT="BROray" -VERSION="2.1.1" +VERSION="2.2.0" TARGET="/opt/broray" LIGHTTPD="" LAN_IP="" diff --git a/root/opt/broray/lib/routes-api-operation.sh b/root/opt/broray/lib/routes-api-operation.sh new file mode 100755 index 0000000..35ef9b2 --- /dev/null +++ b/root/opt/broray/lib/routes-api-operation.sh @@ -0,0 +1,128 @@ +#!/opt/bin/ash + +# Serialises WebUI route actions across all route cards. This lock is separate +# from the low-level operation.lock used by CLI modules, so one Web request may +# safely run several CLI stages while every competing Web request is rejected. + +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +BRORAY_ROUTES_ROOT="${BRORAY_ROUTES_ROOT:-$BRORAY_ROOT/routes}" +BRORAY_ROUTES_API_LOCK="${BRORAY_ROUTES_API_LOCK:-$BRORAY_ROOT/run/global-operation.lock}" +BRORAY_ROUTES_API_LOCK_HELD=false + +broray_routes_api_now() +{ + date '+%Y-%m-%dT%H:%M:%S%z' +} + +broray_routes_api_is_pid() +{ + case "${1:-}" in + ''|*[!0-9]*) return 1 ;; + esac + return 0 +} + +broray_routes_api_lock_write() +{ + local action bundle + + action="${1:-unknown}" + bundle="${2:-}" + + printf '%s\n' "$$" >"$BRORAY_ROUTES_API_LOCK/pid" || return 1 + printf '%s\n' "routes" >"$BRORAY_ROUTES_API_LOCK/scope" || return 1 + printf '%s\n' "$action" >"$BRORAY_ROUTES_API_LOCK/action" || return 1 + printf '%s\n' "$bundle" >"$BRORAY_ROUTES_API_LOCK/bundle" || return 1 + printf '%s\n' "$(broray_routes_api_now)" >"$BRORAY_ROUTES_API_LOCK/startedAt" || return 1 + return 0 +} + +broray_routes_api_lock_acquire() +{ + local action bundle owner + + action="${1:-unknown}" + bundle="${2:-}" + mkdir -p "$(dirname "$BRORAY_ROUTES_API_LOCK")" || return 1 + + if mkdir "$BRORAY_ROUTES_API_LOCK" 2>/dev/null; then + broray_routes_api_lock_write "$action" "$bundle" || { + rm -rf "$BRORAY_ROUTES_API_LOCK" 2>/dev/null || true + return 1 + } + BRORAY_ROUTES_API_LOCK_HELD=true + return 0 + fi + + owner="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/pid" 2>/dev/null || true)" + if broray_routes_api_is_pid "$owner" && kill -0 "$owner" 2>/dev/null; then + return 2 + fi + + rm -rf "$BRORAY_ROUTES_API_LOCK" 2>/dev/null || return 1 + if mkdir "$BRORAY_ROUTES_API_LOCK" 2>/dev/null; then + broray_routes_api_lock_write "$action" "$bundle" || { + rm -rf "$BRORAY_ROUTES_API_LOCK" 2>/dev/null || true + return 1 + } + BRORAY_ROUTES_API_LOCK_HELD=true + return 0 + fi + + return 1 +} + +broray_routes_api_lock_release() +{ + local owner scope + + [ "$BRORAY_ROUTES_API_LOCK_HELD" = true ] || return 0 + owner="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/pid" 2>/dev/null || true)" + scope="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/scope" 2>/dev/null || true)" + if [ "$owner" = "$$" ] && [ "$scope" = routes ]; then + rm -rf "$BRORAY_ROUTES_API_LOCK" 2>/dev/null || true + fi + BRORAY_ROUTES_API_LOCK_HELD=false +} + +broray_routes_api_lock_read_json() +{ + local owner scope action bundle started active stale + + if [ ! -d "$BRORAY_ROUTES_API_LOCK" ]; then + jq -n '{active:false,pid:null,scope:null,action:null,bundleId:null,startedAt:null,stale:false}' + return 0 + fi + + owner="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/pid" 2>/dev/null || true)" + scope="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/scope" 2>/dev/null || true)" + action="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/action" 2>/dev/null || true)" + bundle="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/bundle" 2>/dev/null || true)" + started="$(sed -n '1p' "$BRORAY_ROUTES_API_LOCK/startedAt" 2>/dev/null || true)" + active=false + stale=true + if broray_routes_api_is_pid "$owner" && kill -0 "$owner" 2>/dev/null; then + active=true + stale=false + fi + + case "$owner" in ''|*[!0-9]*) owner=0 ;; esac + jq -n \ + --argjson active "$active" \ + --argjson stale "$stale" \ + --argjson pid "$owner" \ + --arg scope "$scope" \ + --arg action "$action" \ + --arg bundleId "$bundle" \ + --arg startedAt "$started" ' + { + active: $active, + pid: (if $pid == 0 then null else $pid end), + scope: (if $scope == "" then null else $scope end), + action: (if $action == "" then null else $action end), + bundleId: (if $bundleId == "" then null else $bundleId end), + startedAt: (if $startedAt == "" then null else $startedAt end), + stale: $stale + } + ' +} diff --git a/root/opt/broray/lib/routes-bundle-registry.sh b/root/opt/broray/lib/routes-bundle-registry.sh index bed87e4..0660ccf 100755 --- a/root/opt/broray/lib/routes-bundle-registry.sh +++ b/root/opt/broray/lib/routes-bundle-registry.sh @@ -163,11 +163,13 @@ broray_routes_registry_migrate() installedVersion: null, routeCount: null, lastCheckedAt: null, + lastVerifiedAt: null, lastDownloadedAt: null, lastExportedAt: null, lastDeletedAt: null, lastError: null, checkResult: null, + verifyResult: null, downloadResult: null, exportBuild: null, preflight: null, diff --git a/root/opt/broray/lib/routes-dot.sh b/root/opt/broray/lib/routes-dot.sh new file mode 100755 index 0000000..723b070 --- /dev/null +++ b/root/opt/broray/lib/routes-dot.sh @@ -0,0 +1,382 @@ +#!/opt/bin/ash + +# BROray DNS-over-TLS manager for KeeneticOS. +# Stores ownership locally and never removes secure DNS entries that were not +# created by BROray. + +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +BRORAY_DOT_ROOT="${BRORAY_DOT_ROOT:-$BRORAY_ROOT/routes/dot}" +BRORAY_DOT_CONFIG="${BRORAY_DOT_CONFIG:-$BRORAY_DOT_ROOT/config.json}" +BRORAY_DOT_STATE="${BRORAY_DOT_STATE:-$BRORAY_DOT_ROOT/state.json}" +BRORAY_DOT_NDMC="${BRORAY_DOT_NDMC:-ndmc}" +BRORAY_DOT_OPENSSL="${BRORAY_DOT_OPENSSL:-openssl}" +BRORAY_DOT_TIMEOUT="${BRORAY_DOT_TIMEOUT:-timeout}" +BRORAY_DOT_TEST_TTL="${BRORAY_DOT_TEST_TTL:-600}" +BRORAY_DOT_MAX_SERVERS=8 + +broray_dot_now() { date '+%Y-%m-%dT%H:%M:%S%z'; } +broray_dot_epoch() { date '+%s'; } + +broray_dot_error() +{ + code="$1" + message="$2" + details="${3:-}" + printf 'BRORAY_ERROR:%s:%s\n' "$code" "$message" >&2 + [ -z "$details" ] || printf '%s\n' "$details" >&2 + return 1 +} + +broray_dot_presets() +{ + cat <<'JSON' +[ + {"id":"google-primary","provider":"Google","name":"Google 8.8.8.8","address":"8.8.8.8","sni":"dns.google"}, + {"id":"google-secondary","provider":"Google","name":"Google 8.8.4.4","address":"8.8.4.4","sni":"dns.google"}, + {"id":"cloudflare-primary","provider":"Cloudflare","name":"Cloudflare 1.1.1.1","address":"1.1.1.1","sni":"cloudflare-dns.com"}, + {"id":"cloudflare-secondary","provider":"Cloudflare","name":"Cloudflare 1.0.0.1","address":"1.0.0.1","sni":"cloudflare-dns.com"}, + {"id":"quad9","provider":"Quad9","name":"Quad9 9.9.9.9","address":"9.9.9.9","sni":"dns.quad9.net"}, + {"id":"adguard-primary","provider":"AdGuard DNS","name":"AdGuard 94.140.14.14","address":"94.140.14.14","sni":"dns.adguard-dns.com"}, + {"id":"adguard-secondary","provider":"AdGuard DNS","name":"AdGuard 94.140.15.15","address":"94.140.15.15","sni":"dns.adguard-dns.com"} +] +JSON +} + +broray_dot_require_runtime() +{ + command -v jq >/dev/null 2>&1 || broray_dot_error DEPENDENCY_MISSING "Для DNS-over-TLS требуется jq." + mkdir -p "$BRORAY_DOT_ROOT" "$BRORAY_ROOT/tmp" "$BRORAY_ROOT/run" || + broray_dot_error STORAGE_UNAVAILABLE "Не удалось подготовить хранилище DNS-over-TLS." +} + +broray_dot_atomic_json() +{ + target="$1" + temp="$target.new.$$" + cat >"$temp" || { rm -f "$temp"; return 1; } + jq -e . "$temp" >/dev/null 2>&1 || { rm -f "$temp"; return 1; } + chmod 600 "$temp" 2>/dev/null || true + mv -f "$temp" "$target" +} + +broray_dot_ensure_files() +{ + broray_dot_require_runtime || return 1 + if [ ! -s "$BRORAY_DOT_CONFIG" ]; then + jq -n '{schemaVersion:1,selectedIds:["google-primary","cloudflare-primary","quad9"],managed:[],updatedAt:null}' | + broray_dot_atomic_json "$BRORAY_DOT_CONFIG" || return 1 + fi + if [ ! -s "$BRORAY_DOT_STATE" ]; then + jq -n '{schemaVersion:1,tests:[],lastTestedAt:null,lastAppliedAt:null,lastDeletedAt:null,lastOperation:null,lastError:null,updatedAt:null}' | + broray_dot_atomic_json "$BRORAY_DOT_STATE" || return 1 + fi + jq -e '(.schemaVersion==1) and ((.selectedIds|type)=="array") and ((.managed|type)=="array")' "$BRORAY_DOT_CONFIG" >/dev/null 2>&1 || + broray_dot_error CONFIG_INVALID "Конфигурация DNS-over-TLS повреждена." + jq -e '(.schemaVersion==1) and ((.tests|type)=="array")' "$BRORAY_DOT_STATE" >/dev/null 2>&1 || + broray_dot_error STATE_INVALID "Состояние DNS-over-TLS повреждено." +} + +broray_dot_ndmc_path() +{ + case "$BRORAY_DOT_NDMC" in + */*) [ -x "$BRORAY_DOT_NDMC" ] && printf '%s\n' "$BRORAY_DOT_NDMC" ;; + *) command -v "$BRORAY_DOT_NDMC" 2>/dev/null || true ;; + esac +} + +broray_dot_openssl_path() +{ + case "$BRORAY_DOT_OPENSSL" in + */*) [ -x "$BRORAY_DOT_OPENSSL" ] && printf '%s\n' "$BRORAY_DOT_OPENSSL" ;; + *) command -v "$BRORAY_DOT_OPENSSL" 2>/dev/null || true ;; + esac +} + +broray_dot_timeout_path() +{ + case "$BRORAY_DOT_TIMEOUT" in + */*) [ -x "$BRORAY_DOT_TIMEOUT" ] && printf '%s\n' "$BRORAY_DOT_TIMEOUT" ;; + *) command -v "$BRORAY_DOT_TIMEOUT" 2>/dev/null || true ;; + esac +} + +broray_dot_fetch_running() +{ + output="$1" + raw="$output.raw" + tsv="$output.tsv" + ndmc_bin="$(broray_dot_ndmc_path)" + [ -n "$ndmc_bin" ] || broray_dot_error NDMC_UNAVAILABLE "Команда ndmc недоступна." + "$ndmc_bin" -c 'show running-config' >"$raw" 2>"$output.err" || { + details="$(tail -n 20 "$output.err" 2>/dev/null)" + rm -f "$raw" "$tsv" "$output.err" + broray_dot_error KEENETIC_UNAVAILABLE "Не удалось прочитать конфигурацию Keenetic." "$details" + } + awk ' + { + line=$0; sub(/^[[:space:]]+/,"",line); sub(/[[:space:]]+$/,"",line) + n=split(line,f,/[[:space:]]+/) + if (n>=4 && f[1]=="dns-proxy" && f[2]=="tls" && f[3]=="upstream") { + address=f[4]; sni=""; port="" + i=5 + if (i<=n && f[i] ~ /^[0-9]+$/) { port=f[i]; i++ } + while (i<=n) { if (f[i]=="sni" && i=3 && f[1]=="dns-proxy" && f[2]=="https" && f[3]=="upstream") { + print "doh\t" f[4] "\t\t" + } + } + ' "$raw" >"$tsv" || { rm -f "$raw" "$tsv" "$output.err"; return 1; } + jq -Rn ' + [inputs | split("\t") | {kind:.[0],address:.[1],sni:(if .[2]=="" then null else .[2] end),port:(if .[3]=="" then null else (.[3]|tonumber) end)}] as $all | + {schemaVersion:1,source:"running-config",dot:[$all[]|select(.kind=="dot")|del(.kind)],dohCount:([$all[]|select(.kind=="doh")]|length),totalSecure:($all|length)} + ' <"$tsv" >"$output" || { rm -f "$raw" "$tsv" "$output.err"; return 1; } + rm -f "$raw" "$tsv" "$output.err" +} + +broray_dot_validate_request() +{ + request="$1" + [ -r "$request" ] || broray_dot_error REQUEST_INVALID "Запрос DNS-over-TLS отсутствует." + presets="$BRORAY_ROOT/tmp/dot-presets.$$.json" + broray_dot_presets >"$presets" + jq -e --slurpfile presets "$presets" ' + (.serverIds|type)=="array" and (.serverIds|length)>=1 and (.serverIds|length)<=8 and + ((.serverIds|unique|length)==(.serverIds|length)) and + all(.serverIds[]; . as $id | any($presets[0][]; .id==$id)) and + ((.allowUntested // false)|type)=="boolean" + ' "$request" >/dev/null 2>&1 || { rm -f "$presets"; broray_dot_error REQUEST_INVALID "Выбран некорректный список DNS-over-TLS серверов."; } + rm -f "$presets" +} + +broray_dot_entries_for_request() +{ + request="$1" + output="$2" + presets="$BRORAY_ROOT/tmp/dot-presets.$$.json" + broray_dot_presets >"$presets" + jq -n --slurpfile request "$request" --slurpfile presets "$presets" ' + $request[0].serverIds as $ids | + [$ids[] as $id | $presets[0][] | select(.id==$id)] + ' >"$output" + rc=$? + rm -f "$presets" + return "$rc" +} + +broray_dot_tests_fresh_and_ok() +{ + request="$1" + now="$(broray_dot_epoch)" + jq -e --argjson now "$now" --argjson ttl "$BRORAY_DOT_TEST_TTL" --slurpfile req "$request" ' + . as $state | $req[0].serverIds as $ids | + all($ids[]; . as $id | any($state.tests[]?; .id==$id and .ok==true and (($now-(.testedEpoch//0)) <= $ttl))) + ' "$BRORAY_DOT_STATE" >/dev/null 2>&1 +} + +broray_dot_test() +{ + request="$1" + broray_dot_ensure_files || return 1 + broray_dot_validate_request "$request" || return 1 + entries="$BRORAY_ROOT/tmp/dot-test-entries.$$.json" + results="$BRORAY_ROOT/tmp/dot-test-results.$$.jsonl" + broray_dot_entries_for_request "$request" "$entries" || return 1 + : >"$results" + openssl_bin="$(broray_dot_openssl_path)" + timeout_bin="$(broray_dot_timeout_path)" + tested_at="$(broray_dot_now)" + tested_epoch="$(broray_dot_epoch)" + jq -c '.[]' "$entries" | while IFS= read -r entry; do + id="$(printf '%s' "$entry" | jq -r '.id')" + address="$(printf '%s' "$entry" | jq -r '.address')" + sni="$(printf '%s' "$entry" | jq -r '.sni')" + fixture="${BRORAY_DOT_TEST_FIXTURE_DIR:-}/$id.json" + if [ -n "${BRORAY_DOT_TEST_FIXTURE_DIR:-}" ] && [ -r "$fixture" ]; then + jq -c --arg id "$id" --arg address "$address" --arg sni "$sni" --arg testedAt "$tested_at" --argjson testedEpoch "$tested_epoch" ' + {id:$id,address:$address,sni:$sni,ok:(.ok==true),status:(.status//(if .ok then "ok" else "failed" end)),latencyMs:(.latencyMs//null),message:(.message//null),testedAt:$testedAt,testedEpoch:$testedEpoch} + ' "$fixture" >>"$results" + continue + fi + if [ -z "$openssl_bin" ]; then + jq -nc --arg id "$id" --arg address "$address" --arg sni "$sni" --arg testedAt "$tested_at" --argjson testedEpoch "$tested_epoch" '{id:$id,address:$address,sni:$sni,ok:false,status:"unavailable",latencyMs:null,message:"OpenSSL недоступен.",testedAt:$testedAt,testedEpoch:$testedEpoch}' >>"$results" + continue + fi + out="$BRORAY_ROOT/tmp/dot-openssl-$id.$$.out" + start="$(broray_dot_epoch)" + if [ -n "$timeout_bin" ]; then + "$timeout_bin" 12 "$openssl_bin" s_client -connect "$address:853" -servername "$sni" -verify_hostname "$sni" -verify_return_error -brief "$out" 2>&1 + else + "$openssl_bin" s_client -connect "$address:853" -servername "$sni" -verify_hostname "$sni" -verify_return_error -brief "$out" 2>&1 + fi + rc=$? + finish="$(broray_dot_epoch)" + latency=$(((finish-start)*1000)) + if [ "$rc" -eq 0 ]; then ok=true; status=ok; message="TLS-соединение и имя сертификата проверены."; else ok=false; status=failed; message="TLS-проверка завершилась ошибкой."; fi + jq -nc --arg id "$id" --arg address "$address" --arg sni "$sni" --arg status "$status" --arg message "$message" --arg testedAt "$tested_at" --argjson testedEpoch "$tested_epoch" --argjson latencyMs "$latency" --argjson ok "$ok" '{id:$id,address:$address,sni:$sni,ok:$ok,status:$status,latencyMs:$latencyMs,message:$message,testedAt:$testedAt,testedEpoch:$testedEpoch}' >>"$results" + rm -f "$out" + done + tests_json="$(jq -s '.' "$results")" || return 1 + jq --argjson tests "$tests_json" --arg testedAt "$tested_at" --argjson testedEpoch "$tested_epoch" --arg updatedAt "$(broray_dot_now)" '.tests=$tests | .lastTestedAt=$testedAt | .lastTestedEpoch=$testedEpoch | .lastError=null | .updatedAt=$updatedAt' "$BRORAY_DOT_STATE" | + broray_dot_atomic_json "$BRORAY_DOT_STATE" || return 1 + rm -f "$entries" "$results" + broray_dot_status +} + +broray_dot_entry_key() { printf '%s|%s\n' "$1" "$2"; } + +broray_dot_command() +{ + command_text="$1" + ndmc_bin="$(broray_dot_ndmc_path)" + [ -n "$ndmc_bin" ] || broray_dot_error NDMC_UNAVAILABLE "Команда ndmc недоступна." + "$ndmc_bin" -c "$command_text" +} + +broray_dot_status() +{ + broray_dot_ensure_files || return 1 + running="$BRORAY_ROOT/tmp/dot-running.$$.json" + presets="$BRORAY_ROOT/tmp/dot-presets.$$.json" + running_ok=true + broray_dot_fetch_running "$running" >/dev/null 2>&1 || { running_ok=false; jq -n '{schemaVersion:1,dot:[],dohCount:0,totalSecure:0}' >"$running"; } + broray_dot_presets >"$presets" + jq -n --slurpfile presets "$presets" --slurpfile config "$BRORAY_DOT_CONFIG" --slurpfile state "$BRORAY_DOT_STATE" --slurpfile running "$running" --argjson runningOk "$running_ok" --argjson maxServers "$BRORAY_DOT_MAX_SERVERS" ' + $config[0] as $c | $state[0] as $s | $running[0] as $r | + def key($e): ($e.address+"|"+$e.sni); + ($c.managed // []) as $managed | + ($r.dot // []) as $actual | + ($presets[0] | map(. as $p | $p + { + selected: (($c.selectedIds//[]) | index($p.id) != null), + present: (any($actual[]?; .address==$p.address and .sni==$p.sni)), + managed: (any($managed[]?; .address==$p.address and .sni==$p.sni)), + test: ([$s.tests[]? | select(.id==$p.id)] | last // null) + })) as $servers | + ($managed | map(. as $m | any($actual[]?; .address==$m.address and .sni==$m.sni)) | all) as $allManagedPresent | + { + schemaVersion:1, + maxServers:$maxServers, + runningConfigAvailable:$runningOk, + selectedIds:($c.selectedIds//[]), + managed:($managed), + servers:$servers, + actual:{dot:($actual),dohCount:($r.dohCount//0),totalSecure:($r.totalSecure//0)}, + installed:(($managed|length)>0 and $allManagedPresent), + drift:(($managed|length)>0 and ($allManagedPresent|not)), + tests:($s.tests//[]), + lastTestedAt:$s.lastTestedAt, + lastAppliedAt:$s.lastAppliedAt, + lastDeletedAt:$s.lastDeletedAt, + lastOperation:$s.lastOperation, + lastError:$s.lastError, + updatedAt:$s.updatedAt + } + ' || return 1 + rm -f "$running" "$presets" +} + +broray_dot_apply() +{ + request="$1" + broray_dot_ensure_files || return 1 + broray_dot_validate_request "$request" || return 1 + allow="$(jq -r '.allowUntested // false' "$request")" + if ! broray_dot_tests_fresh_and_ok "$request" && [ "$allow" != true ]; then + broray_dot_error DOT_TEST_CONFIRMATION_REQUIRED "Не все выбранные DNS-over-TLS серверы успешно проверены. Требуется явное подтверждение." + return 1 + fi + desired="$BRORAY_ROOT/tmp/dot-desired.$$.json" + current="$BRORAY_ROOT/tmp/dot-current.$$.json" + plan="$BRORAY_ROOT/tmp/dot-plan.$$.json" + old_config="$BRORAY_ROOT/tmp/dot-old-config.$$.json" + cp "$BRORAY_DOT_CONFIG" "$old_config" || return 1 + broray_dot_entries_for_request "$request" "$desired" || return 1 + broray_dot_fetch_running "$current" || return 1 + jq -n --slurpfile desired "$desired" --slurpfile current "$current" --slurpfile config "$BRORAY_DOT_CONFIG" ' + $desired[0] as $d | $current[0] as $r | $config[0] as $c | + def same($a;$b): $a.address==$b.address and $a.sni==$b.sni; + ($c.managed//[]) as $oldManaged | + ([$r.dot[]? | select(. as $a | any($oldManaged[]?; same($a;.)) | not)]) as $external | + ([$oldManaged[]? | select(. as $m | any($d[]; same($m;.)) | not)]) as $remove | + ([$d[] | select(. as $want | any($r.dot[]?; same($want;.)) | not)]) as $add | + ([$d[] | select(. as $want | any($external[]?; same($want;.)))]) as $reusedExternal | + (($r.dohCount//0) + ($external|length) + ([$d[] | select(. as $want | any($external[]?; same($want;.)) | not)]|length)) as $afterTotal | + {remove:$remove,add:$add,reusedExternal:$reusedExternal,afterTotal:$afterTotal,dohCount:($r.dohCount//0),externalDotCount:($external|length),oldManaged:$oldManaged} + ' >"$plan" || return 1 + after_total="$(jq -r '.afterTotal' "$plan")" + [ "$after_total" -le "$BRORAY_DOT_MAX_SERVERS" ] || { rm -f "$desired" "$current" "$plan" "$old_config"; broray_dot_error DOT_LIMIT_EXCEEDED "После экспорта будет больше восьми DoT/DoH серверов."; return 1; } + added="$BRORAY_ROOT/tmp/dot-added.$$.jsonl"; removed="$BRORAY_ROOT/tmp/dot-removed.$$.jsonl"; : >"$added"; : >"$removed" + failed=false; failure="" + jq -c '.remove[]' "$plan" | while IFS= read -r e; do + a="$(printf '%s' "$e"|jq -r '.address')"; s="$(printf '%s' "$e"|jq -r '.sni')" + if broray_dot_command "no dns-proxy tls upstream $a sni $s" >/dev/null 2>&1; then printf '%s\n' "$e" >>"$removed"; else printf '%s\n' "remove:$a:$s" >"$BRORAY_ROOT/tmp/dot-failure.$$"; break; fi + done + [ ! -s "$BRORAY_ROOT/tmp/dot-failure.$$" ] || failed=true + if [ "$failed" = false ]; then + jq -c '.add[]' "$plan" | while IFS= read -r e; do + a="$(printf '%s' "$e"|jq -r '.address')"; s="$(printf '%s' "$e"|jq -r '.sni')" + if broray_dot_command "dns-proxy tls upstream $a sni $s" >/dev/null 2>&1; then printf '%s\n' "$e" >>"$added"; else printf '%s\n' "add:$a:$s" >"$BRORAY_ROOT/tmp/dot-failure.$$"; break; fi + done + [ ! -s "$BRORAY_ROOT/tmp/dot-failure.$$" ] || failed=true + fi + if [ "$failed" = false ]; then broray_dot_command 'system configuration save' >/dev/null 2>&1 || { echo save >"$BRORAY_ROOT/tmp/dot-failure.$$"; failed=true; }; fi + verify="$BRORAY_ROOT/tmp/dot-verify.$$.json" + if [ "$failed" = false ]; then + broray_dot_fetch_running "$verify" >/dev/null 2>&1 || failed=true + if [ "$failed" = false ]; then + jq -e --slurpfile desired "$desired" '. as $actual | all($desired[0][]; . as $w | any($actual.dot[]?; .address==$w.address and .sni==$w.sni))' "$verify" >/dev/null 2>&1 || failed=true + fi + fi + if [ "$failed" = true ]; then + [ -s "$added" ] && jq -c '.' "$added" | while IFS= read -r e; do a="$(printf '%s' "$e"|jq -r '.address')"; s="$(printf '%s' "$e"|jq -r '.sni')"; broray_dot_command "no dns-proxy tls upstream $a sni $s" >/dev/null 2>&1 || true; done + [ -s "$removed" ] && jq -c '.' "$removed" | while IFS= read -r e; do a="$(printf '%s' "$e"|jq -r '.address')"; s="$(printf '%s' "$e"|jq -r '.sni')"; broray_dot_command "dns-proxy tls upstream $a sni $s" >/dev/null 2>&1 || true; done + broray_dot_command 'system configuration save' >/dev/null 2>&1 || true + failure="$(cat "$BRORAY_ROOT/tmp/dot-failure.$$" 2>/dev/null)" + jq --arg at "$(broray_dot_now)" --arg error "$failure" '.lastError={code:"DOT_APPLY_FAILED",message:"Экспорт DNS-over-TLS не завершён. Изменения отменены.",details:$error} | .lastOperation={type:"apply",success:false,rolledBack:true,completedAt:$at} | .updatedAt=$at' "$BRORAY_DOT_STATE" | broray_dot_atomic_json "$BRORAY_DOT_STATE" || true + rm -f "$desired" "$current" "$plan" "$old_config" "$added" "$removed" "$verify" "$BRORAY_ROOT/tmp/dot-failure.$$" + broray_dot_error DOT_APPLY_FAILED "Экспорт DNS-over-TLS не завершён. Выполнен откат." "$failure" + return 1 + fi + added_json="$(jq -s '.' "$added")"; old_managed="$(jq '.managed//[]' "$old_config")"; desired_ids="$(jq '.serverIds' "$request")" + new_managed="$(jq -n --argjson old "$old_managed" --argjson added "$added_json" --slurpfile desired "$desired" ' + def same($a;$b): $a.address==$b.address and $a.sni==$b.sni; + [$desired[0][] as $d | select(any($old[]?; same($d;.)) or any($added[]?; same($d;.))) | {id:$d.id,address:$d.address,sni:$d.sni,provider:$d.provider}] + ')" + now="$(broray_dot_now)" + jq --argjson selected "$desired_ids" --argjson managed "$new_managed" --arg at "$now" '.selectedIds=$selected | .managed=$managed | .updatedAt=$at' "$BRORAY_DOT_CONFIG" | broray_dot_atomic_json "$BRORAY_DOT_CONFIG" || return 1 + jq --arg at "$now" --argjson selected "$desired_ids" '.lastAppliedAt=$at | .lastError=null | .lastOperation={type:"apply",success:true,rolledBack:false,selectedIds:$selected,completedAt:$at} | .updatedAt=$at' "$BRORAY_DOT_STATE" | broray_dot_atomic_json "$BRORAY_DOT_STATE" || return 1 + rm -f "$desired" "$current" "$plan" "$old_config" "$added" "$removed" "$verify" "$BRORAY_ROOT/tmp/dot-failure.$$" + broray_dot_status +} + +broray_dot_delete() +{ + broray_dot_ensure_files || return 1 + managed="$(jq -c '.managed//[]' "$BRORAY_DOT_CONFIG")" + [ "$(printf '%s' "$managed" | jq 'length')" -gt 0 ] || { broray_dot_status; return 0; } + removed="$BRORAY_ROOT/tmp/dot-delete-removed.$$.jsonl"; : >"$removed"; failed=false + printf '%s' "$managed" | jq -c '.[]' | while IFS= read -r e; do + a="$(printf '%s' "$e"|jq -r '.address')"; s="$(printf '%s' "$e"|jq -r '.sni')" + if broray_dot_command "no dns-proxy tls upstream $a sni $s" >/dev/null 2>&1; then printf '%s\n' "$e" >>"$removed"; else echo "$a|$s" >"$BRORAY_ROOT/tmp/dot-delete-failure.$$"; break; fi + done + [ ! -s "$BRORAY_ROOT/tmp/dot-delete-failure.$$" ] || failed=true + if [ "$failed" = false ]; then broray_dot_command 'system configuration save' >/dev/null 2>&1 || failed=true; fi + verify="$BRORAY_ROOT/tmp/dot-delete-verify.$$.json" + if [ "$failed" = false ]; then + broray_dot_fetch_running "$verify" >/dev/null 2>&1 || failed=true + if [ "$failed" = false ]; then printf '%s' "$managed" | jq -e --slurpfile actual "$verify" '$actual[0] as $a | all(.[]; . as $m | any($a.dot[]?; .address==$m.address and .sni==$m.sni) | not)' >/dev/null 2>&1 || failed=true; fi + fi + if [ "$failed" = true ]; then + [ -s "$removed" ] && jq -c '.' "$removed" | while IFS= read -r e; do a="$(printf '%s' "$e"|jq -r '.address')"; s="$(printf '%s' "$e"|jq -r '.sni')"; broray_dot_command "dns-proxy tls upstream $a sni $s" >/dev/null 2>&1 || true; done + broray_dot_command 'system configuration save' >/dev/null 2>&1 || true + now="$(broray_dot_now)"; jq --arg at "$now" '.lastError={code:"DOT_DELETE_FAILED",message:"Удаление DNS-over-TLS не завершено. Выполнен откат."} | .lastOperation={type:"delete",success:false,rolledBack:true,completedAt:$at} | .updatedAt=$at' "$BRORAY_DOT_STATE" | broray_dot_atomic_json "$BRORAY_DOT_STATE" || true + rm -f "$removed" "$verify" "$BRORAY_ROOT/tmp/dot-delete-failure.$$" + broray_dot_error DOT_DELETE_FAILED "Удаление DNS-over-TLS не завершено. Выполнен откат." + return 1 + fi + now="$(broray_dot_now)" + jq --arg at "$now" '.managed=[] | .updatedAt=$at' "$BRORAY_DOT_CONFIG" | broray_dot_atomic_json "$BRORAY_DOT_CONFIG" || return 1 + jq --arg at "$now" '.lastDeletedAt=$at | .lastError=null | .lastOperation={type:"delete",success:true,rolledBack:false,completedAt:$at} | .updatedAt=$at' "$BRORAY_DOT_STATE" | broray_dot_atomic_json "$BRORAY_DOT_STATE" || return 1 + rm -f "$removed" "$verify" "$BRORAY_ROOT/tmp/dot-delete-failure.$$" + broray_dot_status +} diff --git a/root/opt/broray/lib/routes-download.sh b/root/opt/broray/lib/routes-download.sh index 7a92b28..d5fba71 100755 --- a/root/opt/broray/lib/routes-download.sh +++ b/root/opt/broray/lib/routes-download.sh @@ -642,6 +642,8 @@ broray_routes_download_run() } | .routeCount = $route_count | .lastDownloadedAt = $now | + .lastVerifiedAt = null | + .verifyResult = null | .lastError = null | .downloadResult = { result: $result, diff --git a/root/opt/broray/lib/routes-operation-preflight.sh b/root/opt/broray/lib/routes-operation-preflight.sh new file mode 100755 index 0000000..83c3e21 --- /dev/null +++ b/root/opt/broray/lib/routes-operation-preflight.sh @@ -0,0 +1,493 @@ +#!/opt/bin/ash + +# Builds and validates short-lived confirmations for destructive or long-running +# route operations. The actual operation always rebuilds its plan under the +# low-level route lock; this token only proves that the user confirmed a fresh, +# successful preflight for the same bundle and operation. + +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +BRORAY_ROUTES_ROOT="${BRORAY_ROUTES_ROOT:-$BRORAY_ROOT/routes}" +BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR="${BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR:-$BRORAY_ROUTES_ROOT/preflight}" +BRORAY_ROUTES_OPERATION_PREFLIGHT_TTL="${BRORAY_ROUTES_OPERATION_PREFLIGHT_TTL:-120}" +BRORAY_ROUTES_OPERATION_PREFLIGHT_CONFIG_LIBRARY="${BRORAY_ROUTES_OPERATION_PREFLIGHT_CONFIG_LIBRARY:-$BRORAY_ROOT/lib/routes-router-config.sh}" +BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC="${BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC:-ndmc}" + +broray_routes_operation_preflight_now() +{ + date '+%Y-%m-%dT%H:%M:%S%z' +} + +broray_routes_operation_preflight_epoch() +{ + date '+%s' +} + +broray_routes_operation_preflight_bundle_valid() +{ + case "${1:-}" in + ''|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) + return 1 + ;; + esac + return 0 +} + +broray_routes_operation_preflight_token_valid() +{ + local token + token="${1:-}" + [ "${#token}" -eq 64 ] || return 1 + case "$token" in *[!0-9a-f]*) return 1 ;; esac + return 0 +} + +broray_routes_operation_preflight_uint() +{ + case "${1:-}" in ''|*[!0-9]*) return 1 ;; esac + return 0 +} + +broray_routes_operation_preflight_ndmc_path() +{ + case "$BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC" in + */*) printf '%s\n' "$BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC" ;; + *) command -v "$BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC" 2>/dev/null || true ;; + esac +} + +broray_routes_operation_preflight_resolve_action() +{ + local bundle requested progress operation + + bundle="${1:-}" + requested="${2:-}" + broray_routes_operation_preflight_bundle_valid "$bundle" || return 1 + + case "$requested" in + export) printf '%s\t\n' export ;; + delete) printf '%s\t%s\n' delete delete ;; + resume) + progress="$BRORAY_ROUTES_ROOT/operations/$bundle.json" + [ -r "$progress" ] || return 2 + jq -e '.resumable == true and .running == false' "$progress" >/dev/null 2>&1 || return 2 + operation="$(jq -r '.operation // empty' "$progress" 2>/dev/null)" + case "$operation" in + install|update|restore) printf '%s\t%s\n' export "$operation" ;; + delete) printf '%s\t%s\n' delete delete ;; + *) return 2 ;; + esac + ;; + *) return 1 ;; + esac +} + +broray_routes_operation_preflight_delete_plan() +{ + local bundle output config registry bundle_registry actual temp interface metric rc + + bundle="${1:-}" + output="${2:-}" + broray_routes_operation_preflight_bundle_valid "$bundle" || return 1 + [ -n "$output" ] || return 1 + + config="$BRORAY_ROUTES_ROOT/config.json" + registry="$BRORAY_ROUTES_ROOT/installed/routes.json" + bundle_registry="$BRORAY_ROUTES_ROOT/installed/bundles/$bundle.json" + [ -r "$config" ] && [ -r "$registry" ] && [ -r "$bundle_registry" ] || return 1 + + interface="$(jq -r '.managedInterface // empty' "$config" 2>/dev/null)" + metric="$(jq -r '.managedMetric // empty' "$config" 2>/dev/null)" + case "$interface" in Proxy[0-9]*) ;; *) return 1 ;; esac + case "${interface#Proxy}" in ''|*[!0-9]*) return 1 ;; esac + [ "$metric" = 1200 ] || return 1 + + jq -e --arg id "$bundle" --arg interface "$interface" ' + (.schemaVersion == 1) and (.bundleId == $id) and + (.installedVersion != null) and (.targetInterface == $interface) and + (.managedMetric == 1200) and + ((.routeKeys | type) == "array") and + ((.managedRouteKeys | type) == "array") and + ((.externalRouteKeys | type) == "array") + ' "$bundle_registry" >/dev/null 2>&1 || return 1 + + jq -e --arg interface "$interface" ' + (.schemaVersion == 1) and (.managedInterface == $interface) and + (.managedMetric == 1200) and ((.routes | type) == "array") + ' "$registry" >/dev/null 2>&1 || return 1 + + [ -r "$BRORAY_ROUTES_OPERATION_PREFLIGHT_CONFIG_LIBRARY" ] || return 1 + . "$BRORAY_ROUTES_OPERATION_PREFLIGHT_CONFIG_LIBRARY" + temp="$output.actual.$$" + actual="$temp.json" + BRORAY_ROUTES_CONFIG_NDMC="$BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC" + export BRORAY_ROUTES_CONFIG_NDMC + rm -f "${BRORAY_ROUTES_CONFIG_CACHE:-}" 2>/dev/null || true + broray_routes_config_fetch "$actual" || { + rm -f "$actual" "$actual.raw" "$actual.tsv" "$actual.err" + return 1 + } + + jq -n \ + --slurpfile global "$registry" \ + --slurpfile bundle "$bundle_registry" \ + --slurpfile actual "$actual" \ + --arg bundleId "$bundle" \ + --arg interface "$interface" ' + $global[0] as $g | + $bundle[0] as $b | + $actual[0] as $a | + def exact_matches($route): + [ + $a.routes[]? | + select( + .network == $route.network and + .prefix == $route.prefix and + .interface == $interface and + ((.gateway // "0.0.0.0") == "0.0.0.0") and + ((.metric // 1000) == 1200) and + (((.proto // "static") | ascii_downcase) == "static") + ) + ]; + [ + $b.managedRouteKeys[] as $key | + ([$g.routes[]? | select(.key == $key)]) as $registered | + if ($registered | length) != 1 then + {key:$key,status:"conflict",reason:"registry_mismatch"} + else + $registered[0] as $route | + (exact_matches($route)) as $exact | + { + key: $key, + route: $route, + exactMatchCount: ($exact | length), + status: ( + if ($route.createdByBROray != true) or ($route.managed != true) or + ($route.interface != $interface) or (($route.metric // 0) != 1200) or + ((($route.owners // []) | index($bundleId)) == null) + then "conflict" + elif (($route.owners // []) | length) > 1 then "shared_keep" + elif ($exact | length) == 1 then "delete" + elif ($exact | length) == 0 then "already_absent" + else "conflict" + end + ) + } + end + ] as $items | + ([$items[] | select(.status == "conflict")]) as $blocking | + { + schemaVersion: 1, + bundleId: $bundleId, + mode: "delete", + targetInterface: $interface, + managedMetric: 1200, + contentSha256: ($b.installedVersion.contentSha256 // null), + canApply: (($blocking | length) == 0), + summary: { + total: (($b.routeKeys // []) | length), + toCreate: 0, + managedExisting: 0, + toDelete: ([$items[] | select(.status == "delete")] | length), + sharedKept: ([$items[] | select(.status == "shared_keep")] | length), + alreadyAbsent: ([$items[] | select(.status == "already_absent")] | length), + conflicts: ($blocking | length), + externalExisting: (($b.externalRouteKeys // []) | length), + unchangedRoutes: 0, + addedRoutes: 0, + removedRoutes: (($b.routeKeys // []) | length) + }, + items: $items, + blocking: $blocking, + message: ( + if ($blocking | length) > 0 then + "Удаление заблокировано: реестр владения или конфигурация Keenetic содержит конфликт." + else + "Предварительная проверка удаления завершена." + end + ) + } + ' >"$output" + rc=$? + rm -f "$actual" "$actual.raw" "$actual.tsv" "$actual.err" + [ "$rc" -eq 0 ] || return 1 + + jq -e --arg id "$bundle" ' + (.schemaVersion == 1) and (.bundleId == $id) and + (.mode == "delete") and ((.canApply | type) == "boolean") and + ((.summary | type) == "object") + ' "$output" >/dev/null 2>&1 +} + +broray_routes_operation_preflight_storage_values() +{ + local bundle routes catalog_kb free_kb required_kb + + bundle="${1:-}" + routes="${2:-0}" + broray_routes_operation_preflight_uint "$routes" || routes=0 + catalog_kb="$(du -sk "$BRORAY_ROUTES_ROOT/catalog/$bundle" 2>/dev/null | awk 'NR == 1 {print $1}')" + free_kb="$(df -Pk "$BRORAY_ROOT" 2>/dev/null | awk 'NR == 2 {print $4}')" + broray_routes_operation_preflight_uint "$catalog_kb" || catalog_kb=0 + broray_routes_operation_preflight_uint "$free_kb" || free_kb=0 + required_kb=$((2048 + catalog_kb * 2 + (routes + 3) / 4)) + [ "$required_kb" -ge 4096 ] || required_kb=4096 + printf '%s\t%s\n' "$free_kb" "$required_kb" +} + +broray_routes_operation_preflight_finalize() +{ + local bundle requested resolved saved_operation plan output state bundle_registry progress + local checked_at checked_epoch interface metric content_sha mode route_count plan_ready + local ndmc_path interface_out interface_err keenetic_available connected state_up interface_ok + local storage_values free_kb required_kb storage_ok local_ok ready token canonical + local resume_current resume_total resume_operation preflight_file temp message + + bundle="${1:-}" + requested="${2:-}" + resolved="${3:-}" + saved_operation="${4:-}" + plan="${5:-}" + output="${6:-}" + broray_routes_operation_preflight_bundle_valid "$bundle" || return 1 + case "$requested" in export|delete|resume) ;; *) return 1 ;; esac + case "$resolved" in export|delete) ;; *) return 1 ;; esac + [ -r "$plan" ] && [ -n "$output" ] || return 1 + + state="$BRORAY_ROUTES_ROOT/state/$bundle.json" + bundle_registry="$BRORAY_ROUTES_ROOT/installed/bundles/$bundle.json" + progress="$BRORAY_ROUTES_ROOT/operations/$bundle.json" + [ -r "$state" ] && [ -r "$bundle_registry" ] || return 1 + + jq -e --arg id "$bundle" ' + (.schemaVersion == 1) and (.bundleId == $id) and + ((.canApply | type) == "boolean") and ((.summary | type) == "object") + ' "$plan" >/dev/null 2>&1 || return 1 + + checked_at="$(broray_routes_operation_preflight_now)" + checked_epoch="$(broray_routes_operation_preflight_epoch)" + interface="$(jq -r '.targetInterface // empty' "$plan")" + metric="$(jq -r '.managedMetric // 0' "$plan")" + mode="$(jq -r '.mode // empty' "$plan")" + route_count="$(jq -r '.summary.total // 0' "$plan")" + plan_ready="$(jq -r '.canApply' "$plan")" + broray_routes_operation_preflight_uint "$route_count" || return 1 + [ "$metric" = 1200 ] || return 1 + + if [ "$resolved" = delete ]; then + content_sha="$(jq -r '.installedVersion.contentSha256 // empty' "$bundle_registry")" + mode=delete + else + content_sha="$(jq -r '.contentSha256 // empty' "$plan")" + [ -n "$content_sha" ] || content_sha="$(jq -r '.downloadedVersion.contentSha256 // empty' "$state")" + fi + + ndmc_path="$(broray_routes_operation_preflight_ndmc_path)" + interface_out="$output.interface.out" + interface_err="$output.interface.err" + keenetic_available=false + connected=false + state_up=false + interface_ok=false + if [ -n "$ndmc_path" ] && [ -x "$ndmc_path" ]; then + if "$ndmc_path" -c "show interface $interface" >"$interface_out" 2>"$interface_err"; then + keenetic_available=true + grep -Eq "^[[:space:]]*id:[[:space:]]*$interface[[:space:]]*$" "$interface_out" && + grep -Eq '^[[:space:]]*connected:[[:space:]]*yes[[:space:]]*$' "$interface_out" && connected=true + grep -Eq '^[[:space:]]*state:[[:space:]]*up[[:space:]]*$' "$interface_out" && state_up=true + [ "$connected" = true ] && [ "$state_up" = true ] && interface_ok=true + fi + fi + + storage_values="$(broray_routes_operation_preflight_storage_values "$bundle" "$route_count")" + free_kb="$(printf '%s' "$storage_values" | cut -f1)" + required_kb="$(printf '%s' "$storage_values" | cut -f2)" + storage_ok=false + [ "$free_kb" -ge "$required_kb" ] 2>/dev/null && storage_ok=true + local_ok=false + [ -n "$content_sha" ] && [ "$route_count" -gt 0 ] && local_ok=true + + resume_current=0 + resume_total=0 + resume_operation="" + if [ "$requested" = resume ]; then + [ -r "$progress" ] || return 1 + resume_current="$(jq -r '.current // 0' "$progress")" + resume_total="$(jq -r '.total // 0' "$progress")" + resume_operation="$(jq -r '.operation // empty' "$progress")" + broray_routes_operation_preflight_uint "$resume_current" || return 1 + broray_routes_operation_preflight_uint "$resume_total" || return 1 + [ "$resume_current" -le "$resume_total" ] || return 1 + fi + + ready=false + if [ "$plan_ready" = true ] && [ "$interface_ok" = true ] && + [ "$storage_ok" = true ] && [ "$local_ok" = true ] + then + ready=true + fi + + canonical="$bundle|$requested|$resolved|$saved_operation|$mode|$checked_epoch|$content_sha|$route_count|$resume_current|$resume_total|$$" + token="$(printf '%s' "$canonical" | sha256sum | awk '{print $1}')" + broray_routes_operation_preflight_token_valid "$token" || return 1 + + if [ "$ready" = true ]; then + message="Предварительная проверка завершена. Операция готова к запуску." + elif [ "$plan_ready" != true ]; then + message="Операция заблокирована: обнаружены конфликты маршрутов или владения." + elif [ "$interface_ok" != true ]; then + message="Операция заблокирована: Keenetic или управляемый интерфейс недоступен." + elif [ "$storage_ok" != true ]; then + message="Операция заблокирована: недостаточно свободного места в /opt." + else + message="Операция заблокирована: локальный набор не готов." + fi + + jq -n \ + --slurpfile plan "$plan" \ + --arg token "$token" \ + --arg bundleId "$bundle" \ + --arg requestedAction "$requested" \ + --arg resolvedAction "$resolved" \ + --arg operation "$saved_operation" \ + --arg mode "$mode" \ + --arg checkedAt "$checked_at" \ + --arg contentSha256 "$content_sha" \ + --arg targetInterface "$interface" \ + --arg ndmcPath "$ndmc_path" \ + --arg message "$message" \ + --arg resumeOperation "$resume_operation" \ + --argjson checkedEpoch "$checked_epoch" \ + --argjson expiresAfterSeconds "$BRORAY_ROUTES_OPERATION_PREFLIGHT_TTL" \ + --argjson ready "$ready" \ + --argjson keeneticAvailable "$keenetic_available" \ + --argjson connected "$connected" \ + --argjson stateUp "$state_up" \ + --argjson interfaceOk "$interface_ok" \ + --argjson storageOk "$storage_ok" \ + --argjson freeKb "$free_kb" \ + --argjson requiredKb "$required_kb" \ + --argjson localOk "$local_ok" \ + --argjson routeCount "$route_count" \ + --argjson resumeCurrent "$resume_current" \ + --argjson resumeTotal "$resume_total" ' + $plan[0] as $p | + { + schemaVersion: 1, + token: $token, + bundleId: $bundleId, + requestedAction: $requestedAction, + resolvedAction: $resolvedAction, + operation: (if $operation == "" then $mode else $operation end), + mode: $mode, + ready: $ready, + checkedAt: $checkedAt, + checkedEpoch: $checkedEpoch, + expiresAfterSeconds: $expiresAfterSeconds, + contentSha256: (if $contentSha256 == "" then null else $contentSha256 end), + targetInterface: $targetInterface, + managedMetric: 1200, + checks: { + operationLock: {ok:true, message:"Конфликтующих операций не обнаружено."}, + ndmc: {ok:($ndmcPath != ""), path:(if $ndmcPath == "" then null else $ndmcPath end)}, + keenetic: { + ok:$interfaceOk, + available:$keeneticAvailable, + interface:$targetInterface, + connected:$connected, + stateUp:$stateUp + }, + storage: {ok:$storageOk, freeKb:$freeKb, requiredKb:$requiredKb}, + localSet: {ok:$localOk, routeCount:$routeCount, duplicateRouteCount:0, invalidRouteCount:0}, + ownership: {ok:$p.canApply, conflictCount:($p.summary.conflicts // 0)} + }, + summary: { + total: ($p.summary.total // 0), + alreadyPresent: ($p.summary.managedExisting // 0), + toCreate: ($p.summary.toCreate // 0), + toDelete: ($p.summary.toDelete // 0), + sharedKept: ($p.summary.sharedKept // 0), + alreadyAbsent: ($p.summary.alreadyAbsent // 0), + conflicts: ($p.summary.conflicts // 0), + externalKept: ($p.summary.externalExisting // 0), + unchanged: ($p.summary.unchangedRoutes // 0), + addedToBundle: ($p.summary.addedRoutes // 0), + removedFromBundle: ($p.summary.removedRoutes // 0) + }, + resume: { + operation: (if $resumeOperation == "" then null else $resumeOperation end), + current: $resumeCurrent, + total: $resumeTotal + }, + message: $message + } + ' >"$output" || return 1 + rm -f "$interface_out" "$interface_err" + + mkdir -p "$BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR" || return 1 + preflight_file="$BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR/$bundle.json" + temp="$preflight_file.new.$$" + cp -p "$output" "$temp" || { rm -f "$temp"; return 1; } + chmod 600 "$temp" 2>/dev/null || true + mv -f "$temp" "$preflight_file" || return 1 + return 0 +} + +broray_routes_operation_preflight_validate() +{ + local bundle requested token file now checked ttl age content_sha current_sha progress + local saved_current saved_total current total saved_operation operation + + bundle="${1:-}" + requested="${2:-}" + token="${3:-}" + broray_routes_operation_preflight_bundle_valid "$bundle" || return 1 + case "$requested" in export|delete|resume) ;; *) return 1 ;; esac + broray_routes_operation_preflight_token_valid "$token" || return 2 + file="$BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR/$bundle.json" + [ -r "$file" ] || return 2 + + jq -e \ + --arg bundle "$bundle" \ + --arg requested "$requested" \ + --arg token "$token" ' + (.schemaVersion == 1) and (.bundleId == $bundle) and + (.requestedAction == $requested) and (.token == $token) and + (.ready == true) and ((.checkedEpoch | type) == "number") and + ((.expiresAfterSeconds | type) == "number") + ' "$file" >/dev/null 2>&1 || return 2 + + now="$(broray_routes_operation_preflight_epoch)" + checked="$(jq -r '.checkedEpoch' "$file")" + ttl="$(jq -r '.expiresAfterSeconds' "$file")" + broray_routes_operation_preflight_uint "$checked" || return 3 + broray_routes_operation_preflight_uint "$ttl" || return 3 + age=$((now - checked)) + [ "$age" -ge 0 ] && [ "$age" -le "$ttl" ] || return 3 + + content_sha="$(jq -r '.contentSha256 // empty' "$file")" + case "$requested" in + export) + current_sha="$(jq -r '.downloadedVersion.contentSha256 // empty' "$BRORAY_ROUTES_ROOT/state/$bundle.json" 2>/dev/null)" + [ -n "$current_sha" ] && [ "$current_sha" = "$content_sha" ] || return 4 + ;; + delete) + current_sha="$(jq -r '.installedVersion.contentSha256 // empty' "$BRORAY_ROUTES_ROOT/installed/bundles/$bundle.json" 2>/dev/null)" + [ -n "$current_sha" ] && [ "$current_sha" = "$content_sha" ] || return 4 + ;; + resume) + progress="$BRORAY_ROUTES_ROOT/operations/$bundle.json" + [ -r "$progress" ] || return 4 + saved_current="$(jq -r '.resume.current // 0' "$file")" + saved_total="$(jq -r '.resume.total // 0' "$file")" + saved_operation="$(jq -r '.resume.operation // empty' "$file")" + current="$(jq -r '.current // 0' "$progress")" + total="$(jq -r '.total // 0' "$progress")" + operation="$(jq -r '.operation // empty' "$progress")" + [ "$saved_current" = "$current" ] && [ "$saved_total" = "$total" ] && + [ "$saved_operation" = "$operation" ] || return 4 + ;; + esac + + rm -f "$file" 2>/dev/null || return 1 + return 0 +} diff --git a/root/opt/broray/lib/routes-operation-progress.sh b/root/opt/broray/lib/routes-operation-progress.sh new file mode 100755 index 0000000..237bc1a --- /dev/null +++ b/root/opt/broray/lib/routes-operation-progress.sh @@ -0,0 +1,546 @@ +#!/opt/bin/ash + +# Persistent progress state for long-running route operations. +# The file is written atomically and can be polled by WebUI while ndmc applies +# thousands of routes one by one. + +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +BRORAY_ROUTES_PROGRESS_DIR="${BRORAY_ROUTES_PROGRESS_DIR:-${BRORAY_ROUTES_ROOT:-$BRORAY_ROOT/routes}/operations}" +BRORAY_ROUTES_PROGRESS_FILE="" +BRORAY_ROUTES_PROGRESS_COUNTER_FILE="" +BRORAY_ROUTES_PROGRESS_BUNDLE="" +BRORAY_ROUTES_PROGRESS_OPERATION="" +BRORAY_ROUTES_PROGRESS_PHASE="idle" +BRORAY_ROUTES_PROGRESS_CURRENT=0 +BRORAY_ROUTES_PROGRESS_TOTAL=0 +BRORAY_ROUTES_PROGRESS_MESSAGE="" +BRORAY_ROUTES_PROGRESS_ROUTE="" +BRORAY_ROUTES_PROGRESS_STARTED_AT="" +BRORAY_ROUTES_PROGRESS_COMPLETED_AT="" +BRORAY_ROUTES_PROGRESS_RUNNING=false +BRORAY_ROUTES_PROGRESS_SUCCESS=null +BRORAY_ROUTES_PROGRESS_ROLLED_BACK=false +BRORAY_ROUTES_PROGRESS_ACTIVE=false +BRORAY_ROUTES_PROGRESS_STOP_FILE="" +BRORAY_ROUTES_PROGRESS_RESUMABLE=false +BRORAY_ROUTES_PROGRESS_STOP_REQUESTED=false +BRORAY_ROUTES_PROGRESS_STOPPED_BY_USER=false +BRORAY_ROUTES_PROGRESS_ERROR_ROUTE="" +BRORAY_ROUTES_PROGRESS_RESUMED=false + +broray_routes_progress_now() +{ + date '+%Y-%m-%dT%H:%M:%S%z' +} + +broray_routes_progress_bundle_valid() +{ + case "${1:-}" in + ''|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) + return 1 + ;; + esac + + return 0 +} + +broray_routes_progress_uint() +{ + case "${1:-}" in + ''|*[!0-9]*) return 1 ;; + esac + return 0 +} + +broray_routes_progress_percent() +{ + local current total percent + + current="${1:-0}" + total="${2:-0}" + broray_routes_progress_uint "$current" || current=0 + broray_routes_progress_uint "$total" || total=0 + + if [ "$total" -le 0 ]; then + if [ "$BRORAY_ROUTES_PROGRESS_RUNNING" = false ] && + [ "$BRORAY_ROUTES_PROGRESS_SUCCESS" = true ] + then + printf '%s\n' 100 + else + printf '%s\n' 0 + fi + return 0 + fi + + [ "$current" -le "$total" ] || current="$total" + percent=$((current * 100 / total)) + [ "$percent" -le 100 ] || percent=100 + printf '%s\n' "$percent" +} + + +broray_routes_progress_counter_write() +{ + local temp route + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + [ -n "$BRORAY_ROUTES_PROGRESS_COUNTER_FILE" ] || return 1 + route="$(printf '%s' "$BRORAY_ROUTES_PROGRESS_ROUTE" | tr '\t\r\n' ' ')" + temp="$BRORAY_ROUTES_PROGRESS_COUNTER_FILE.new.$$" + printf '%s\t%s\n' \ + "$BRORAY_ROUTES_PROGRESS_CURRENT" "$route" >"$temp" || { + rm -f "$temp" + return 1 + } + chmod 644 "$temp" 2>/dev/null || true + mv -f "$temp" "$BRORAY_ROUTES_PROGRESS_COUNTER_FILE" +} + +broray_routes_progress_tick() +{ + local current current_route + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + current="${1:-$BRORAY_ROUTES_PROGRESS_CURRENT}" + current_route="${2:-}" + broray_routes_progress_uint "$current" || return 1 + [ "$current" -le "$BRORAY_ROUTES_PROGRESS_TOTAL" ] || + current="$BRORAY_ROUTES_PROGRESS_TOTAL" + + BRORAY_ROUTES_PROGRESS_CURRENT="$current" + BRORAY_ROUTES_PROGRESS_ROUTE="$current_route" + broray_routes_progress_counter_write +} + +broray_routes_progress_write() +{ + local updated_at percent temp + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + [ -n "$BRORAY_ROUTES_PROGRESS_FILE" ] || return 1 + + mkdir -p "$BRORAY_ROUTES_PROGRESS_DIR" || return 1 + updated_at="$(broray_routes_progress_now)" + percent="$(broray_routes_progress_percent \ + "$BRORAY_ROUTES_PROGRESS_CURRENT" \ + "$BRORAY_ROUTES_PROGRESS_TOTAL")" + temp="$BRORAY_ROUTES_PROGRESS_FILE.new.$$" + + jq -n \ + --arg bundleId "$BRORAY_ROUTES_PROGRESS_BUNDLE" \ + --arg operation "$BRORAY_ROUTES_PROGRESS_OPERATION" \ + --arg phase "$BRORAY_ROUTES_PROGRESS_PHASE" \ + --arg message "$BRORAY_ROUTES_PROGRESS_MESSAGE" \ + --arg currentRoute "$BRORAY_ROUTES_PROGRESS_ROUTE" \ + --arg startedAt "$BRORAY_ROUTES_PROGRESS_STARTED_AT" \ + --arg updatedAt "$updated_at" \ + --arg completedAt "$BRORAY_ROUTES_PROGRESS_COMPLETED_AT" \ + --arg errorRoute "$BRORAY_ROUTES_PROGRESS_ERROR_ROUTE" \ + --argjson current "$BRORAY_ROUTES_PROGRESS_CURRENT" \ + --argjson total "$BRORAY_ROUTES_PROGRESS_TOTAL" \ + --argjson percent "$percent" \ + --argjson running "$BRORAY_ROUTES_PROGRESS_RUNNING" \ + --argjson success "$BRORAY_ROUTES_PROGRESS_SUCCESS" \ + --argjson rolledBack "$BRORAY_ROUTES_PROGRESS_ROLLED_BACK" \ + --argjson resumable "$BRORAY_ROUTES_PROGRESS_RESUMABLE" \ + --argjson stopRequested "$BRORAY_ROUTES_PROGRESS_STOP_REQUESTED" \ + --argjson stoppedByUser "$BRORAY_ROUTES_PROGRESS_STOPPED_BY_USER" \ + --argjson resumed "$BRORAY_ROUTES_PROGRESS_RESUMED" \ + --argjson pid "$$" ' + { + schemaVersion: 2, + kind: "routes", + bundleId: $bundleId, + operation: $operation, + phase: $phase, + current: $current, + total: $total, + percent: $percent, + currentRoute: ( + if $currentRoute == "" then null else $currentRoute end + ), + message: $message, + running: $running, + success: $success, + rolledBack: $rolledBack, + resumable: $resumable, + stopRequested: $stopRequested, + stoppedByUser: $stoppedByUser, + resumed: $resumed, + errorRoute: ( + if $errorRoute == "" then null else $errorRoute end + ), + pid: $pid, + startedAt: $startedAt, + updatedAt: $updatedAt, + completedAt: ( + if $completedAt == "" then null else $completedAt end + ) + } + ' >"$temp" || { + rm -f "$temp" + return 1 + } + + chmod 644 "$temp" 2>/dev/null || true + mv -f "$temp" "$BRORAY_ROUTES_PROGRESS_FILE" || return 1 + broray_routes_progress_counter_write +} + +broray_routes_progress_begin() +{ + local bundle operation total message initial_current resumed + + bundle="${1:-}" + operation="${2:-}" + total="${3:-0}" + message="${4:-Подготовка операции с маршрутами.}" + initial_current="${5:-0}" + resumed="${6:-false}" + + broray_routes_progress_bundle_valid "$bundle" || return 1 + case "$operation" in + install|update|restore|delete) ;; + *) return 1 ;; + esac + broray_routes_progress_uint "$total" || return 1 + broray_routes_progress_uint "$initial_current" || return 1 + [ "$initial_current" -le "$total" ] || return 1 + case "$resumed" in true|false) ;; *) resumed=false ;; esac + + BRORAY_ROUTES_PROGRESS_BUNDLE="$bundle" + BRORAY_ROUTES_PROGRESS_OPERATION="$operation" + BRORAY_ROUTES_PROGRESS_PHASE="preparing" + BRORAY_ROUTES_PROGRESS_CURRENT="$initial_current" + BRORAY_ROUTES_PROGRESS_TOTAL="$total" + BRORAY_ROUTES_PROGRESS_MESSAGE="$message" + BRORAY_ROUTES_PROGRESS_ROUTE="" + BRORAY_ROUTES_PROGRESS_STARTED_AT="$(broray_routes_progress_now)" + BRORAY_ROUTES_PROGRESS_COMPLETED_AT="" + BRORAY_ROUTES_PROGRESS_RUNNING=true + BRORAY_ROUTES_PROGRESS_SUCCESS=null + BRORAY_ROUTES_PROGRESS_ROLLED_BACK=false + BRORAY_ROUTES_PROGRESS_RESUMABLE=false + BRORAY_ROUTES_PROGRESS_STOP_REQUESTED=false + BRORAY_ROUTES_PROGRESS_STOPPED_BY_USER=false + BRORAY_ROUTES_PROGRESS_ERROR_ROUTE="" + BRORAY_ROUTES_PROGRESS_RESUMED="$resumed" + BRORAY_ROUTES_PROGRESS_FILE="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.json" + BRORAY_ROUTES_PROGRESS_COUNTER_FILE="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.counter" + BRORAY_ROUTES_PROGRESS_STOP_FILE="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.stop" + BRORAY_ROUTES_PROGRESS_ACTIVE=true + + rm -f "$BRORAY_ROUTES_PROGRESS_STOP_FILE" 2>/dev/null || true + broray_routes_progress_write +} + +broray_routes_progress_update() +{ + local phase current total message current_route + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + phase="${1:-applying}" + current="${2:-$BRORAY_ROUTES_PROGRESS_CURRENT}" + total="${3:-$BRORAY_ROUTES_PROGRESS_TOTAL}" + message="${4:-$BRORAY_ROUTES_PROGRESS_MESSAGE}" + current_route="${5:-}" + + broray_routes_progress_uint "$current" || return 1 + broray_routes_progress_uint "$total" || return 1 + [ "$current" -le "$total" ] || current="$total" + + BRORAY_ROUTES_PROGRESS_PHASE="$phase" + BRORAY_ROUTES_PROGRESS_CURRENT="$current" + BRORAY_ROUTES_PROGRESS_TOTAL="$total" + BRORAY_ROUTES_PROGRESS_MESSAGE="$message" + BRORAY_ROUTES_PROGRESS_ROUTE="$current_route" + BRORAY_ROUTES_PROGRESS_RUNNING=true + BRORAY_ROUTES_PROGRESS_SUCCESS=null + BRORAY_ROUTES_PROGRESS_COMPLETED_AT="" + + broray_routes_progress_write +} + +broray_routes_progress_complete() +{ + local message + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + message="${1:-Операция с маршрутами завершена.}" + + BRORAY_ROUTES_PROGRESS_PHASE="completed" + BRORAY_ROUTES_PROGRESS_CURRENT="$BRORAY_ROUTES_PROGRESS_TOTAL" + BRORAY_ROUTES_PROGRESS_MESSAGE="$message" + BRORAY_ROUTES_PROGRESS_ROUTE="" + BRORAY_ROUTES_PROGRESS_RUNNING=false + BRORAY_ROUTES_PROGRESS_SUCCESS=true + BRORAY_ROUTES_PROGRESS_ROLLED_BACK=false + BRORAY_ROUTES_PROGRESS_RESUMABLE=false + BRORAY_ROUTES_PROGRESS_STOP_REQUESTED=false + BRORAY_ROUTES_PROGRESS_STOPPED_BY_USER=false + BRORAY_ROUTES_PROGRESS_ERROR_ROUTE="" + BRORAY_ROUTES_PROGRESS_COMPLETED_AT="$(broray_routes_progress_now)" + + rm -f "$BRORAY_ROUTES_PROGRESS_STOP_FILE" 2>/dev/null || true + broray_routes_progress_write +} + +broray_routes_progress_fail() +{ + local message rolled_back + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + message="${1:-Операция с маршрутами завершилась ошибкой.}" + rolled_back="${2:-false}" + case "$rolled_back" in true|false) ;; *) rolled_back=false ;; esac + + if [ "$rolled_back" = true ]; then + BRORAY_ROUTES_PROGRESS_PHASE="rolled_back" + else + BRORAY_ROUTES_PROGRESS_PHASE="failed" + fi + BRORAY_ROUTES_PROGRESS_MESSAGE="$message" + BRORAY_ROUTES_PROGRESS_ROUTE="" + BRORAY_ROUTES_PROGRESS_RUNNING=false + BRORAY_ROUTES_PROGRESS_SUCCESS=false + BRORAY_ROUTES_PROGRESS_ROLLED_BACK="$rolled_back" + BRORAY_ROUTES_PROGRESS_RESUMABLE=false + BRORAY_ROUTES_PROGRESS_STOP_REQUESTED=false + BRORAY_ROUTES_PROGRESS_STOPPED_BY_USER=false + BRORAY_ROUTES_PROGRESS_ERROR_ROUTE="" + BRORAY_ROUTES_PROGRESS_COMPLETED_AT="$(broray_routes_progress_now)" + + rm -f "$BRORAY_ROUTES_PROGRESS_STOP_FILE" 2>/dev/null || true + broray_routes_progress_write +} + +broray_routes_progress_pause() +{ + local message stopped_by_user error_route + + [ "$BRORAY_ROUTES_PROGRESS_ACTIVE" = true ] || return 0 + message="${1:-Операция приостановлена.}" + stopped_by_user="${2:-true}" + error_route="${3:-}" + case "$stopped_by_user" in true|false) ;; *) stopped_by_user=true ;; esac + + if [ "$stopped_by_user" = true ]; then + BRORAY_ROUTES_PROGRESS_PHASE="paused" + BRORAY_ROUTES_PROGRESS_SUCCESS=null + else + BRORAY_ROUTES_PROGRESS_PHASE="failed_resumable" + BRORAY_ROUTES_PROGRESS_SUCCESS=false + fi + BRORAY_ROUTES_PROGRESS_MESSAGE="$message" + BRORAY_ROUTES_PROGRESS_ROUTE="" + BRORAY_ROUTES_PROGRESS_RUNNING=false + BRORAY_ROUTES_PROGRESS_ROLLED_BACK=false + BRORAY_ROUTES_PROGRESS_RESUMABLE=true + BRORAY_ROUTES_PROGRESS_STOP_REQUESTED=false + BRORAY_ROUTES_PROGRESS_STOPPED_BY_USER="$stopped_by_user" + BRORAY_ROUTES_PROGRESS_ERROR_ROUTE="$error_route" + BRORAY_ROUTES_PROGRESS_COMPLETED_AT="$(broray_routes_progress_now)" + + rm -f "$BRORAY_ROUTES_PROGRESS_STOP_FILE" 2>/dev/null || true + broray_routes_progress_write +} + +broray_routes_progress_stop_requested() +{ + local bundle stop_file + + bundle="${1:-$BRORAY_ROUTES_PROGRESS_BUNDLE}" + broray_routes_progress_bundle_valid "$bundle" || return 1 + stop_file="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.stop" + [ -f "$stop_file" ] +} + +broray_routes_progress_request_stop() +{ + local bundle file stop_file temp now + + bundle="${1:-}" + broray_routes_progress_bundle_valid "$bundle" || return 1 + file="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.json" + stop_file="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.stop" + [ -r "$file" ] || return 2 + jq -e '.running == true and (.operation != null)' "$file" >/dev/null 2>&1 || return 3 + + mkdir -p "$BRORAY_ROUTES_PROGRESS_DIR" || return 1 + now="$(broray_routes_progress_now)" + temp="$stop_file.new.$$" + jq -n --arg bundleId "$bundle" --arg requestedAt "$now" --argjson requestedByPid "$$" ' + { + schemaVersion: 1, + bundleId: $bundleId, + requestedAt: $requestedAt, + requestedByPid: $requestedByPid + } + ' >"$temp" || { rm -f "$temp"; return 1; } + chmod 644 "$temp" 2>/dev/null || true + mv -f "$temp" "$stop_file" || return 1 + + temp="$file.new.$$" + jq --arg now "$now" ' + .stopRequested = true | + .phase = "stopping" | + .message = "Остановка запрошена. Текущий маршрут будет завершён." | + .updatedAt = $now + ' "$file" >"$temp" || { rm -f "$temp"; return 1; } + chmod 644 "$temp" 2>/dev/null || true + mv -f "$temp" "$file" || return 1 + return 0 +} + +broray_routes_progress_resume_values() +{ + local bundle operation remaining file current total resumable saved_operation + + bundle="${1:-}" + operation="${2:-}" + remaining="${3:-0}" + broray_routes_progress_bundle_valid "$bundle" || return 1 + broray_routes_progress_uint "$remaining" || return 1 + file="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.json" + + if [ -r "$file" ]; then + resumable="$(jq -r '.resumable // false' "$file" 2>/dev/null || true)" + saved_operation="$(jq -r '.operation // empty' "$file" 2>/dev/null || true)" + current="$(jq -r '.current // 0' "$file" 2>/dev/null || true)" + total="$(jq -r '.total // 0' "$file" 2>/dev/null || true)" + if [ "$resumable" = true ] && [ "$saved_operation" = "$operation" ] && + broray_routes_progress_uint "$current" && + broray_routes_progress_uint "$total" && + [ "$current" -le "$total" ] && + [ "$((total - current))" -eq "$remaining" ] + then + printf '%s\t%s\t%s\n' "$current" "$total" true + return 0 + fi + fi + + printf '0\t%s\t%s\n' "$remaining" false +} + +broray_routes_progress_idle_json() +{ + local bundle + bundle="${1:-}" + + jq -n --arg bundleId "$bundle" ' + { + schemaVersion: 2, + kind: "routes", + bundleId: $bundleId, + operation: null, + phase: "idle", + current: 0, + total: 0, + percent: 0, + currentRoute: null, + message: "Операция не выполняется.", + running: false, + success: null, + rolledBack: false, + resumable: false, + stopRequested: false, + stoppedByUser: false, + resumed: false, + errorRoute: null, + pid: null, + startedAt: null, + updatedAt: null, + completedAt: null + } + ' +} + +broray_routes_progress_read() +{ + local bundle file counter lock_pid pid current current_route total percent interrupted + + bundle="${1:-}" + broray_routes_progress_bundle_valid "$bundle" || return 1 + file="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.json" + counter="$BRORAY_ROUTES_PROGRESS_DIR/$bundle.counter" + + if [ ! -r "$file" ]; then + broray_routes_progress_idle_json "$bundle" + return 0 + fi + + jq -e --arg bundleId "$bundle" ' + ((.schemaVersion == 1) or (.schemaVersion == 2)) and + (.kind == "routes") and + (.bundleId == $bundleId) and + ((.operation == null) or (.operation == "install") or + (.operation == "update") or (.operation == "restore") or + (.operation == "delete")) and + ((.phase | type) == "string") and + ((.current | type) == "number") and (.current >= 0) and + ((.total | type) == "number") and (.total >= 0) and + ((.percent | type) == "number") and (.percent >= 0) and (.percent <= 100) and + ((.running | type) == "boolean") and + ((.success == null) or ((.success | type) == "boolean")) and + ((.rolledBack | type) == "boolean") and + (((.resumable // false) | type) == "boolean") and + (((.stopRequested // false) | type) == "boolean") and + (((.stoppedByUser // false) | type) == "boolean") and + (((.resumed // false) | type) == "boolean") and + ((.errorRoute == null) or ((.errorRoute | type) == "string")) + ' "$file" >/dev/null 2>&1 || return 1 + + current="$(jq -r '.current' "$file")" + total="$(jq -r '.total' "$file")" + current_route="" + + if [ -r "$counter" ]; then + current="$(cut -f1 "$counter" 2>/dev/null | sed -n '1p')" + current_route="$(cut -f2- "$counter" 2>/dev/null | sed -n '1p')" + fi + broray_routes_progress_uint "$current" || current=0 + broray_routes_progress_uint "$total" || total=0 + [ "$current" -le "$total" ] || current="$total" + if [ "$total" -gt 0 ]; then + percent=$((current * 100 / total)) + else + percent="$(jq -r 'if .success == true then 100 else 0 end' "$file")" + fi + + pid="$(jq -r 'if .running == true then (.pid // empty) else empty end' "$file" 2>/dev/null)" + case "$pid" in ''|*[!0-9]*) pid="" ;; esac + interrupted=false + lock_pid="$(sed -n '1p' "${BRORAY_ROUTES_ROOT:-$BRORAY_ROOT/routes}/locks/operation.lock/pid" 2>/dev/null || true)" + case "$lock_pid" in ''|*[!0-9]*) lock_pid="" ;; esac + if [ -n "$pid" ]; then + if ! kill -0 "$pid" 2>/dev/null || [ "$lock_pid" != "$pid" ]; then + interrupted=true + fi + fi + + jq \ + --argjson current "$current" \ + --argjson percent "$percent" \ + --arg currentRoute "$current_route" \ + --argjson interrupted "$interrupted" ' + .current = $current | + .percent = $percent | + .currentRoute = ( + if $currentRoute == "" then null else $currentRoute end + ) | + if $interrupted then + .phase = "interrupted" | + .running = false | + .success = false | + .rolledBack = false | + .resumable = false | + .stopRequested = false | + .stoppedByUser = false | + .currentRoute = null | + .message = "Операция неожиданно завершилась. Выполните проверку набора перед продолжением." | + .completedAt = (.completedAt // .updatedAt) + else + . + end + ' "$file" +} diff --git a/root/opt/broray/lib/routes-router-delete.sh b/root/opt/broray/lib/routes-router-delete.sh index 45f95eb..ecf007d 100755 --- a/root/opt/broray/lib/routes-router-delete.sh +++ b/root/opt/broray/lib/routes-router-delete.sh @@ -15,14 +15,71 @@ BRORAY_ROUTES_DELETE_ORIGINAL="" BRORAY_ROUTES_DELETE_BUNDLE_ID="" BRORAY_ROUTES_DELETE_INTERFACE="${BRORAY_ROUTES_DELETE_INTERFACE:-Proxy0}" BRORAY_ROUTES_DELETE_NDMC="" +BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE=false +BRORAY_ROUTES_DELETE_PROGRESS_CURRENT=0 +BRORAY_ROUTES_DELETE_PROGRESS_TOTAL=0 +BRORAY_ROUTES_DELETE_LAST_ERROR="" +BRORAY_ROUTES_DELETE_COMPLETED_FILE="" +BRORAY_ROUTES_DELETE_TRANSACTION="" +BRORAY_ROUTES_DELETE_PAUSED=false +BRORAY_ROUTES_DELETE_SAVE_NEEDED=false +BRORAY_ROUTES_DELETE_RESUME_BASE=0 +BRORAY_ROUTES_DELETE_RESUMED=false +BRORAY_ROUTES_DELETE_PREVIOUS_DELETED=0 +BRORAY_ROUTES_DELETE_PREVIOUS_ABSENT=0 BRORAY_ROOT="${BRORAY_ROOT:-$BRORAY_ROUTES_DELETE_ROOT}" BRORAY_INTERFACE_OWNER_LIBRARY="${BRORAY_INTERFACE_OWNER_LIBRARY:-$BRORAY_ROOT/lib/interface-owner.sh}" +BRORAY_ROUTES_DELETE_PROGRESS_LIBRARY="${BRORAY_ROUTES_DELETE_PROGRESS_LIBRARY:-$BRORAY_ROOT/lib/routes-operation-progress.sh}" if [ -r "$BRORAY_INTERFACE_OWNER_LIBRARY" ]; then BRORAY_BASE="$BRORAY_ROOT" export BRORAY_BASE . "$BRORAY_INTERFACE_OWNER_LIBRARY" fi +[ -r "$BRORAY_ROUTES_DELETE_PROGRESS_LIBRARY" ] && + . "$BRORAY_ROUTES_DELETE_PROGRESS_LIBRARY" + +broray_routes_delete_progress_begin() +{ + command -v broray_routes_progress_begin >/dev/null 2>&1 || return 0 + broray_routes_progress_begin "$@" || return 1 + BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE=true +} + +broray_routes_delete_progress_update() +{ + [ "$BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_update >/dev/null 2>&1 || return 0 + broray_routes_progress_update "$@" +} + +broray_routes_delete_progress_tick() +{ + [ "$BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_tick >/dev/null 2>&1 || return 0 + broray_routes_progress_tick "$@" +} + +broray_routes_delete_progress_complete() +{ + [ "$BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_complete >/dev/null 2>&1 || return 0 + broray_routes_progress_complete "$@" +} + +broray_routes_delete_progress_fail() +{ + [ "$BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_fail >/dev/null 2>&1 || return 0 + broray_routes_progress_fail "$@" +} + +broray_routes_delete_progress_pause() +{ + [ "$BRORAY_ROUTES_DELETE_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_pause >/dev/null 2>&1 || return 0 + broray_routes_progress_pause "$@" +} broray_routes_delete_now() { @@ -36,6 +93,7 @@ broray_routes_delete_stamp() broray_routes_delete_error() { + BRORAY_ROUTES_DELETE_LAST_ERROR="$*" echo "ОШИБКА: $*" >&2 } @@ -314,6 +372,8 @@ broray_routes_delete_prepare() ) | .installedVersion = null | .lastDeletedAt = $now | + .lastVerifiedAt = null | + .verifyResult = null | .deleteResult = { result: "removed", message: $message, @@ -514,30 +574,232 @@ broray_routes_delete_rollback_routes() done } +broray_routes_delete_partial_commit() +{ + local message error_route completed_json output now registry bundle_registry state + local completed_count deleted_now absent_now total_deleted total_absent + + message="$1" + error_route="${2:-}" + completed_json="$BRORAY_ROUTES_DELETE_WORK/completed.json" + output="$BRORAY_ROUTES_DELETE_WORK/partial" + now="$(broray_routes_delete_now)" + registry="$BRORAY_ROUTES_DELETE_ROUTES/installed/routes.json" + bundle_registry="$BRORAY_ROUTES_DELETE_ROUTES/installed/bundles/$BRORAY_ROUTES_DELETE_BUNDLE_ID.json" + state="$BRORAY_ROUTES_DELETE_ROUTES/state/$BRORAY_ROUTES_DELETE_BUNDLE_ID.json" + + mkdir -p "$output" || return 1 + jq -Rn ' + [ + inputs | split("\t") | select(length == 7) | + { + result: .[0], key: .[1], network: .[2], + prefix: (.[3] | tonumber), mask: .[4], + interface: .[5], metric: (.[6] | tonumber) + } + ] + ' <"$BRORAY_ROUTES_DELETE_COMPLETED_FILE" >"$completed_json" || return 1 + + completed_count="$(jq -r 'length' "$completed_json")" + deleted_now="$(jq -r '[.[] | select(.result == "deleted")] | length' "$completed_json")" + absent_now="$(jq -r '[.[] | select(.result == "absent")] | length' "$completed_json")" + total_deleted="$((BRORAY_ROUTES_DELETE_PREVIOUS_DELETED + deleted_now))" + total_absent="$((BRORAY_ROUTES_DELETE_PREVIOUS_ABSENT + absent_now))" + + [ "$completed_count" -eq "$((BRORAY_ROUTES_DELETE_PROGRESS_CURRENT - BRORAY_ROUTES_DELETE_RESUME_BASE))" ] || return 1 + + jq -n \ + --slurpfile old "$registry" \ + --slurpfile completed "$completed_json" \ + --arg bundleId "$BRORAY_ROUTES_DELETE_BUNDLE_ID" \ + --arg now "$now" ' + $old[0] as $o | + ($completed[0] | map(.key) | unique) as $keys | + [ + ($o.routes // [])[] | + if (.key as $key | $keys | index($key)) != null then + .owners = (((.owners // []) - [$bundleId]) | unique) | + .updatedAt = $now + else . end | + select(((.owners // []) | length) > 0) + ] as $routes | + $o + {routes: $routes, updatedAt: $now} + ' >"$output/routes.json" || return 1 + + jq -n \ + --slurpfile old "$bundle_registry" \ + --slurpfile completed "$completed_json" \ + --arg now "$now" ' + $old[0] as $o | + ($completed[0] | map(.key) | unique) as $keys | + $o + { + routeKeys: (($o.routeKeys // []) - $keys), + managedRouteKeys: (($o.managedRouteKeys // []) - $keys), + externalRouteKeys: (($o.externalRouteKeys // []) - $keys), + updatedAt: $now + } + ' >"$output/bundle.json" || return 1 + + jq \ + --arg now "$now" \ + --arg message "$message" \ + --arg errorRoute "$error_route" \ + --argjson current "$BRORAY_ROUTES_DELETE_PROGRESS_CURRENT" \ + --argjson total "$BRORAY_ROUTES_DELETE_PROGRESS_TOTAL" \ + --argjson deleted "$total_deleted" \ + --argjson alreadyAbsent "$total_absent" ' + .status = "installed" | + .lastVerifiedAt = null | + .verifyResult = null | + .preflight = null | + .resumeOperation = { + operation: "delete", + resumable: true, + current: $current, + total: $total, + deleted: $deleted, + alreadyAbsent: $alreadyAbsent, + message: $message, + errorRoute: (if $errorRoute == "" then null else $errorRoute end), + updatedAt: $now + } | + .lastError = ( + if $errorRoute == "" then null + else {code: "ROUTES_DELETE_PAUSED_AFTER_ERROR", message: $message, route: $errorRoute, resumable: true} + end + ) | + .updatedAt = $now + ' "$state" >"$output/state.json" || return 1 + + jq -e --arg interface "$BRORAY_ROUTES_DELETE_INTERFACE" ' + (.schemaVersion == 1) and + (.managedInterface == $interface) and + (.managedMetric == 1200) and + (([.routes[].key] | length) == ([.routes[].key] | unique | length)) and + (all(.routes[]; + .interface == $interface and + .metric == 1200 and + .createdByBROray == true and + .managed == true and + (((.owners // []) | length) > 0) + )) + ' "$output/routes.json" >/dev/null 2>&1 || return 1 + jq -e --arg id "$BRORAY_ROUTES_DELETE_BUNDLE_ID" ' + (.bundleId == $id) and + ((.routeKeys | type) == "array") and + ((.managedRouteKeys | type) == "array") and + (.routeKeys == .managedRouteKeys) + ' "$output/bundle.json" >/dev/null 2>&1 || return 1 + jq -e '.resumeOperation.operation == "delete" and .resumeOperation.resumable == true' \ + "$output/state.json" >/dev/null 2>&1 || return 1 + + cp -p "$output/routes.json" "$registry.new.$$" && mv "$registry.new.$$" "$registry" || return 1 + cp -p "$output/bundle.json" "$bundle_registry.new.$$" && mv "$bundle_registry.new.$$" "$bundle_registry" || return 1 + cp -p "$output/state.json" "$state.new.$$" && mv "$state.new.$$" "$state" || return 1 + chmod 644 "$registry" "$bundle_registry" "$state" 2>/dev/null || true + return 0 +} + +broray_routes_delete_pause() +{ + local message stopped_by_user error_route exit_code out err details + + message="$1" + stopped_by_user="${2:-true}" + error_route="${3:-}" + case "$stopped_by_user" in true|false) ;; *) stopped_by_user=true ;; esac + trap - EXIT HUP INT TERM + + if [ "$BRORAY_ROUTES_DELETE_SAVE_NEEDED" = true ]; then + out="$BRORAY_ROUTES_DELETE_WORK/pause-save.out" + err="$BRORAY_ROUTES_DELETE_WORK/pause-save.err" + if ! broray_routes_delete_ndmc "system configuration save" "$out" "$err" 12; then + details="$(tail -n 20 "$out" 2>/dev/null; tail -n 20 "$err" 2>/dev/null)" + broray_routes_delete_error "Не удалось сохранить частично выполненное удаление. $details" + broray_routes_delete_cleanup + exit 1 + fi + fi + + broray_routes_delete_partial_commit "$message" "$error_route" || { + broray_routes_delete_error "Не удалось сохранить состояние удаления для продолжения." + broray_routes_delete_cleanup + exit 1 + } + + BRORAY_ROUTES_DELETE_PAUSED=true + BRORAY_ROUTES_DELETE_ROLLBACK_NEEDED=false + broray_routes_delete_transaction_write \ + "$BRORAY_ROUTES_DELETE_TRANSACTION" "paused" \ + "$BRORAY_ROUTES_DELETE_PROGRESS_CURRENT" \ + "$BRORAY_ROUTES_DELETE_SAVE_NEEDED" false "$message" || true + broray_routes_delete_progress_pause "$message" "$stopped_by_user" "$error_route" >/dev/null 2>&1 || true + broray_routes_delete_lock_release + + printf '%s\n' "$message" + printf 'Выполнено: %s из %s\n' \ + "$BRORAY_ROUTES_DELETE_PROGRESS_CURRENT" \ + "$BRORAY_ROUTES_DELETE_PROGRESS_TOTAL" + printf '%s\n' 'Операцию можно продолжить позднее.' + + rm -rf "$BRORAY_ROUTES_DELETE_WORK" 2>/dev/null || true + BRORAY_ROUTES_DELETE_WORK="" + if [ "$stopped_by_user" = true ]; then exit 74; else exit 76; fi +} + +broray_routes_delete_stop_if_requested() +{ + [ "$BRORAY_ROUTES_DELETE_PROGRESS_CURRENT" -lt "$BRORAY_ROUTES_DELETE_PROGRESS_TOTAL" ] || return 0 + command -v broray_routes_progress_stop_requested >/dev/null 2>&1 || return 0 + if broray_routes_progress_stop_requested "$BRORAY_ROUTES_DELETE_BUNDLE_ID"; then + broray_routes_delete_pause \ + "Удаление остановлено пользователем. Выполнено $BRORAY_ROUTES_DELETE_PROGRESS_CURRENT из $BRORAY_ROUTES_DELETE_PROGRESS_TOTAL." \ + true "" + fi +} + +broray_routes_delete_route_failure() +{ + local message route + message="$1" + route="$2" + broray_routes_delete_pause \ + "$message Выполнено $BRORAY_ROUTES_DELETE_PROGRESS_CURRENT из $BRORAY_ROUTES_DELETE_PROGRESS_TOTAL. Можно продолжить после устранения причины." \ + false "$route" +} + broray_routes_delete_cleanup() { + local rolled_back progress_error + trap - EXIT HUP INT TERM + rolled_back=false broray_routes_delete_kill_active - if [ "$BRORAY_ROUTES_DELETE_ROLLBACK_NEEDED" = true ] && + if [ "$BRORAY_ROUTES_DELETE_PAUSED" != true ] && + [ "$BRORAY_ROUTES_DELETE_ROLLBACK_NEEDED" = true ] && [ "$BRORAY_ROUTES_DELETE_COMMITTED" != true ] then broray_routes_delete_rollback_routes + rolled_back=true if [ -n "$BRORAY_ROUTES_DELETE_ORIGINAL" ] && [ -d "$BRORAY_ROUTES_DELETE_ORIGINAL" ] then - broray_routes_delete_restore_local \ - "$BRORAY_ROUTES_DELETE_ORIGINAL" \ - "$BRORAY_ROUTES_DELETE_ROUTES/installed/routes.json" \ - "$BRORAY_ROUTES_DELETE_ROUTES/installed/bundles/$BRORAY_ROUTES_DELETE_BUNDLE_ID.json" \ - "$BRORAY_ROUTES_DELETE_ROUTES/state/$BRORAY_ROUTES_DELETE_BUNDLE_ID.json" \ - "$BRORAY_ROUTES_DELETE_ROUTES/catalog/$BRORAY_ROUTES_DELETE_BUNDLE_ID/export-plan.json" \ - "$BRORAY_ROUTES_DELETE_ROUTES/catalog/$BRORAY_ROUTES_DELETE_BUNDLE_ID/router-delete-result.json" + broray_routes_delete_restore_local "$BRORAY_ROUTES_DELETE_ORIGINAL" "$BRORAY_ROUTES_DELETE_ROUTES/installed/routes.json" "$BRORAY_ROUTES_DELETE_ROUTES/installed/bundles/$BRORAY_ROUTES_DELETE_BUNDLE_ID.json" "$BRORAY_ROUTES_DELETE_ROUTES/state/$BRORAY_ROUTES_DELETE_BUNDLE_ID.json" "$BRORAY_ROUTES_DELETE_ROUTES/catalog/$BRORAY_ROUTES_DELETE_BUNDLE_ID/export-plan.json" "$BRORAY_ROUTES_DELETE_ROUTES/catalog/$BRORAY_ROUTES_DELETE_BUNDLE_ID/router-delete-result.json" fi fi + if [ "$BRORAY_ROUTES_DELETE_PAUSED" != true ] && + [ "$BRORAY_ROUTES_DELETE_COMMITTED" != true ]; then + progress_error="${BRORAY_ROUTES_DELETE_LAST_ERROR:-Операция удаления прервана.}" + if [ "$rolled_back" = true ]; then + progress_error="$progress_error Изменения операции отменены." + fi + broray_routes_delete_progress_fail "$progress_error" "$rolled_back" >/dev/null 2>&1 || true + fi + broray_routes_delete_lock_release if [ -n "$BRORAY_ROUTES_DELETE_WORK" ]; then @@ -551,7 +813,8 @@ broray_routes_router_delete_run() local stamp now prepared original transaction delete_tsv deleted_tsv local total managed_count external_count physical shared already_absent tab key network prefix mask interface metric local actual_config filtered_tsv runtime_absent - local out err command_text delete_rc deleted_count save_needed message + local out err command_text delete_rc deleted_count processed_count save_needed message current_route + local completed_tsv resume_values resume_base resume_total resumed previous_deleted previous_absent bundle_id="$1" @@ -693,7 +956,9 @@ broray_routes_router_delete_run() prepared="$BRORAY_ROUTES_DELETE_WORK/prepared" original="$BRORAY_ROUTES_DELETE_WORK/original" transaction="$transactions/delete-$bundle_id-$stamp.json" + BRORAY_ROUTES_DELETE_TRANSACTION="$transaction" deleted_tsv="$BRORAY_ROUTES_DELETE_WORK/deleted.tsv" + completed_tsv="$BRORAY_ROUTES_DELETE_WORK/completed.tsv" mkdir -p "$BRORAY_ROUTES_DELETE_WORK" "$transactions" || { broray_routes_delete_error "Не удалось создать рабочий каталог." @@ -713,7 +978,9 @@ broray_routes_router_delete_run() } : >"$deleted_tsv" + : >"$completed_tsv" BRORAY_ROUTES_DELETE_DELETED_FILE="$deleted_tsv" + BRORAY_ROUTES_DELETE_COMPLETED_FILE="$completed_tsv" BRORAY_ROUTES_DELETE_ORIGINAL="$original" broray_routes_delete_prepare \ @@ -780,8 +1047,43 @@ broray_routes_router_delete_run() return 1 } + previous_deleted="$(jq -r '.resumeOperation.deleted // 0' "$state" 2>/dev/null || printf 0)" + previous_absent="$(jq -r '.resumeOperation.alreadyAbsent // 0' "$state" 2>/dev/null || printf 0)" + case "$previous_deleted" in ''|*[!0-9]*) previous_deleted=0 ;; esac + case "$previous_absent" in ''|*[!0-9]*) previous_absent=0 ;; esac + resume_values="$(broray_routes_progress_resume_values "$bundle_id" delete "$physical" 2>/dev/null || printf '0\t%s\tfalse\n' "$physical")" + resume_base="$(printf '%s' "$resume_values" | cut -f1)" + resume_total="$(printf '%s' "$resume_values" | cut -f2)" + resumed="$(printf '%s' "$resume_values" | cut -f3)" + case "$resume_base" in ''|*[!0-9]*) resume_base=0 ;; esac + case "$resume_total" in ''|*[!0-9]*) resume_total="$physical" ;; esac + case "$resumed" in true|false) ;; *) resumed=false ;; esac + if [ "$resumed" != true ]; then + previous_deleted=0 + previous_absent=0 + fi + deleted_count=0 + processed_count="$resume_base" save_needed=false + BRORAY_ROUTES_DELETE_RESUME_BASE="$resume_base" + BRORAY_ROUTES_DELETE_RESUMED="$resumed" + BRORAY_ROUTES_DELETE_PREVIOUS_DELETED="$previous_deleted" + BRORAY_ROUTES_DELETE_PREVIOUS_ABSENT="$previous_absent" + BRORAY_ROUTES_DELETE_PROGRESS_CURRENT="$resume_base" + BRORAY_ROUTES_DELETE_PROGRESS_TOTAL="$resume_total" + broray_routes_delete_progress_begin \ + "$bundle_id" delete "$resume_total" \ + "Подготовка удаления: $resume_base из $resume_total." \ + "$resume_base" "$resumed" || { + broray_routes_delete_error "Не удалось создать состояние прогресса удаления." + return 1 + } + broray_routes_delete_progress_update \ + applying "$resume_base" "$resume_total" "Удаление маршрутов из Keenetic." "" || { + broray_routes_delete_error "Не удалось обновить состояние прогресса удаления." + return 1 + } broray_routes_delete_transaction_write \ "$transaction" "checked" 0 false false \ @@ -843,20 +1145,45 @@ broray_routes_router_delete_run() # The route was removed manually after our running-config snapshot. # This is an idempotent success, not an operation failure. already_absent=$((already_absent + 1)) + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + absent "$key" "$network" "$prefix" "$mask" "$interface" "$metric" \ + >>"$completed_tsv" + processed_count=$((processed_count + 1)) + BRORAY_ROUTES_DELETE_PROGRESS_CURRENT="$processed_count" + current_route="$network/$prefix" + broray_routes_delete_progress_tick \ + "$processed_count" "$current_route" || { + broray_routes_delete_error "Не удалось записать прогресс удаления маршрутов." + return 1 + } + broray_routes_delete_stop_if_requested continue else details="$(tail -n 20 "$out" 2>/dev/null; tail -n 20 "$err" 2>/dev/null)" - broray_routes_delete_error \ - "Не удалось удалить $network/$prefix. $details" - return 1 + broray_routes_delete_route_failure \ + "Не удалось удалить $network/$prefix. $details" \ + "$network/$prefix" fi printf '%s\t%s\t%s\t%s\t%s\t%s\n' \ "$key" "$network" "$prefix" "$mask" "$interface" "$metric" \ >>"$deleted_tsv" + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + deleted "$key" "$network" "$prefix" "$mask" "$interface" "$metric" \ + >>"$completed_tsv" deleted_count=$((deleted_count + 1)) + processed_count=$((processed_count + 1)) + BRORAY_ROUTES_DELETE_PROGRESS_CURRENT="$processed_count" save_needed=true + BRORAY_ROUTES_DELETE_SAVE_NEEDED=true + current_route="$network/$prefix" + broray_routes_delete_progress_tick \ + "$processed_count" "$current_route" || { + broray_routes_delete_error "Не удалось записать прогресс удаления маршрутов." + return 1 + } + broray_routes_delete_stop_if_requested broray_routes_delete_transaction_write \ "$transaction" "deleting" "$deleted_count" false false \ @@ -879,6 +1206,13 @@ broray_routes_router_delete_run() } + broray_routes_delete_progress_update \ + verifying "$processed_count" "$resume_total" \ + "Проверка удаления маршрутов в Keenetic." "" || { + broray_routes_delete_error "Не удалось записать этап проверки удаления." + return 1 + } + # Confirm the real configured state before clearing local ownership. # ndmc messages alone are insufficient for hidden routes that coexist on # another interface. @@ -898,12 +1232,31 @@ broray_routes_router_delete_run() return 1 } + # Final result spans all resumed attempts, not only this process. + broray_routes_delete_adjust_prepared \ + "$prepared" \ + "$((previous_deleted + deleted_count))" \ + "$shared" \ + "$((previous_absent + already_absent))" || { + broray_routes_delete_error "Не удалось объединить результат продолженной операции удаления." + return 1 + } + jq 'del(.resumeOperation)' "$prepared/state.json" >"$prepared/state.json.new.$$" && + mv "$prepared/state.json.new.$$" "$prepared/state.json" || return 1 + broray_routes_delete_install_local \ "$prepared" "$registry" "$bundle_registry" "$state" "$plan" "$result" || { broray_routes_delete_error "Не удалось установить локальное состояние удаления." return 1 } + broray_routes_delete_progress_update \ + saving "$processed_count" "$resume_total" \ + "Сохранение конфигурации Keenetic." "" || { + broray_routes_delete_error "Не удалось записать этап сохранения конфигурации." + return 1 + } + if [ "$save_needed" = true ]; then out="$BRORAY_ROUTES_DELETE_WORK/save.out" err="$BRORAY_ROUTES_DELETE_WORK/save.err" @@ -925,6 +1278,8 @@ broray_routes_router_delete_run() broray_routes_delete_transaction_write \ "$transaction" "committed" "$deleted_count" \ "$save_needed" true "$message" || true + broray_routes_delete_progress_complete \ + "Удаление маршрутов из Keenetic завершено: $resume_total из $resume_total." || true broray_routes_delete_lock_release rm -rf "$BRORAY_ROUTES_DELETE_WORK" 2>/dev/null || true @@ -932,9 +1287,9 @@ broray_routes_router_delete_run() echo "$message" echo "Набор: $bundle_id" - echo "Удалено физических маршрутов BROray: $deleted_count" + echo "Удалено физических маршрутов BROray: $((previous_deleted + deleted_count))" echo "Сохранено общих маршрутов: $shared" - echo "Уже отсутствовало в Keenetic: $already_absent" + echo "Уже отсутствовало в Keenetic: $((previous_absent + already_absent))" echo "Не затронуто внешних маршрутов: $external_count" echo "Интерфейс удаления: $managed_interface" echo "Метрика владения BROray: 1200" diff --git a/root/opt/broray/lib/routes-router-sync.sh b/root/opt/broray/lib/routes-router-sync.sh index 9594f2e..62e7476 100755 --- a/root/opt/broray/lib/routes-router-sync.sh +++ b/root/opt/broray/lib/routes-router-sync.sh @@ -10,6 +10,7 @@ BRORAY_SYNC_ROUTES="${BRORAY_ROUTES_ROOT:-$BRORAY_ROOT/routes}" BRORAY_SYNC_LOCK="$BRORAY_SYNC_ROUTES/locks/operation.lock" BRORAY_SYNC_CONFIG_LIBRARY="${BRORAY_SYNC_CONFIG_LIBRARY:-$BRORAY_ROOT/lib/routes-router-config.sh}" BRORAY_SYNC_OWNER_LIBRARY="${BRORAY_SYNC_OWNER_LIBRARY:-$BRORAY_ROOT/lib/interface-owner.sh}" +BRORAY_SYNC_PROGRESS_LIBRARY="${BRORAY_SYNC_PROGRESS_LIBRARY:-$BRORAY_ROOT/lib/routes-operation-progress.sh}" BRORAY_SYNC_NDMC="${BRORAY_SYNC_NDMC:-ndmc}" BRORAY_SYNC_ACTIVE_PID="" BRORAY_SYNC_WORK="" @@ -21,9 +22,57 @@ BRORAY_SYNC_ROUTER_CHANGED=false BRORAY_SYNC_ROLLING_BACK=false BRORAY_SYNC_LOCK_HELD=false BRORAY_SYNC_TRANSACTION="" +BRORAY_SYNC_PROGRESS_ACTIVE=false +BRORAY_SYNC_PROGRESS_CURRENT=0 +BRORAY_SYNC_PROGRESS_TOTAL=0 +BRORAY_SYNC_RESUME_BASE=0 +BRORAY_SYNC_RESUMED=false [ -r "$BRORAY_SYNC_CONFIG_LIBRARY" ] && . "$BRORAY_SYNC_CONFIG_LIBRARY" [ -r "$BRORAY_SYNC_OWNER_LIBRARY" ] && . "$BRORAY_SYNC_OWNER_LIBRARY" +[ -r "$BRORAY_SYNC_PROGRESS_LIBRARY" ] && . "$BRORAY_SYNC_PROGRESS_LIBRARY" + +broray_routes_sync_progress_begin() +{ + command -v broray_routes_progress_begin >/dev/null 2>&1 || return 0 + broray_routes_progress_begin "$@" || return 1 + BRORAY_SYNC_PROGRESS_ACTIVE=true +} + +broray_routes_sync_progress_update() +{ + [ "$BRORAY_SYNC_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_update >/dev/null 2>&1 || return 0 + broray_routes_progress_update "$@" +} + +broray_routes_sync_progress_tick() +{ + [ "$BRORAY_SYNC_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_tick >/dev/null 2>&1 || return 0 + broray_routes_progress_tick "$@" +} + +broray_routes_sync_progress_complete() +{ + [ "$BRORAY_SYNC_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_complete >/dev/null 2>&1 || return 0 + broray_routes_progress_complete "$@" +} + +broray_routes_sync_progress_fail() +{ + [ "$BRORAY_SYNC_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_fail >/dev/null 2>&1 || return 0 + broray_routes_progress_fail "$@" +} + +broray_routes_sync_progress_pause() +{ + [ "$BRORAY_SYNC_PROGRESS_ACTIVE" = true ] || return 0 + command -v broray_routes_progress_pause >/dev/null 2>&1 || return 0 + broray_routes_progress_pause "$@" +} broray_routes_sync_now() { @@ -205,14 +254,14 @@ broray_routes_sync_restore_local() broray_routes_sync_rollback_router() { - local tab key network prefix mask interface metric out err rc + local tab kind key network prefix mask interface metric out err rc [ "$BRORAY_SYNC_ROUTER_CHANGED" = true ] || return 0 tab="$(printf '\t')" if [ -f "$BRORAY_SYNC_DELETED_FILE" ]; then sed '1!G;h;$!d' "$BRORAY_SYNC_DELETED_FILE" | - while IFS="$tab" read -r key network prefix mask interface metric; do + while IFS="$tab" read -r kind key network prefix mask interface metric; do [ -n "$network" ] || continue out="$BRORAY_SYNC_WORK/rollback-add.out" err="$BRORAY_SYNC_WORK/rollback-add.err" @@ -222,7 +271,7 @@ broray_routes_sync_rollback_router() if [ -f "$BRORAY_SYNC_ADDED_FILE" ]; then sed '1!G;h;$!d' "$BRORAY_SYNC_ADDED_FILE" | - while IFS="$tab" read -r key network prefix mask interface metric; do + while IFS="$tab" read -r kind key network prefix mask interface metric; do [ -n "$network" ] || continue out="$BRORAY_SYNC_WORK/rollback-delete.out" err="$BRORAY_SYNC_WORK/rollback-delete.err" @@ -243,6 +292,268 @@ broray_routes_sync_cleanup() BRORAY_SYNC_WORK="" } +broray_routes_sync_partial_commit() +{ + local sync_plan message error_route output now registry bundle_registry state + local added_json deleted_json added_count deleted_count previous_created previous_deleted + + sync_plan="$1" + message="$2" + error_route="$3" + output="$BRORAY_SYNC_WORK/partial" + now="$(broray_routes_sync_now)" + registry="$BRORAY_SYNC_ROUTES/installed/routes.json" + bundle_registry="$BRORAY_SYNC_ROUTES/installed/bundles/$BRORAY_SYNC_BUNDLE.json" + state="$BRORAY_SYNC_ROUTES/state/$BRORAY_SYNC_BUNDLE.json" + added_json="$BRORAY_SYNC_WORK/partial-added.json" + deleted_json="$BRORAY_SYNC_WORK/partial-deleted.json" + + mkdir -p "$output" || return 1 + + jq -Rn ' + [ + inputs | split("\t") | select(length == 7) | + { + kind: .[0], key: .[1], network: .[2], + prefix: (.[3] | tonumber), mask: .[4], + interface: .[5], metric: (.[6] | tonumber) + } + ] + ' <"$BRORAY_SYNC_ADDED_FILE" >"$added_json" || return 1 + + jq -Rn ' + [ + inputs | split("\t") | select(length == 7) | + { + kind: .[0], key: .[1], network: .[2], + prefix: (.[3] | tonumber), mask: .[4], + interface: .[5], metric: (.[6] | tonumber) + } + ] + ' <"$BRORAY_SYNC_DELETED_FILE" >"$deleted_json" || return 1 + + added_count="$(jq -r '[.[] | select(.kind == "create")] | length' "$added_json")" + deleted_count="$(jq -r 'length' "$deleted_json")" + previous_created="$(jq -r '.resumeOperation.created // 0' "$state" 2>/dev/null || printf 0)" + previous_deleted="$(jq -r '.resumeOperation.deleted // 0' "$state" 2>/dev/null || printf 0)" + + jq -n \ + --slurpfile old "$registry" \ + --slurpfile sync "$sync_plan" \ + --slurpfile added "$added_json" \ + --slurpfile deleted "$deleted_json" \ + --arg bundleId "$BRORAY_SYNC_BUNDLE" \ + --arg now "$now" ' + $old[0] as $o | + $sync[0] as $s | + def desired_route($key): first($s.desired[]? | select(.route.key == $key) | .route); + (reduce $added[0][] as $item (($o.routes // []); + if $item.kind == "create" then + (desired_route($item.key)) as $r | + (map(.key) | index($item.key)) as $idx | + if $idx == null then + . + [{ + key: $r.key, + family: ($r.family // "ipv4"), + network: $r.network, + prefix: $r.prefix, + mask: $r.mask, + interface: $r.targetInterface, + gatewayToken: ($r.gatewayToken // "0.0.0.0"), + metric: ($r.metric // 1200), + automatic: ($r.automatic // null), + exclusive: ($r.exclusive // null), + comment: ($r.comment // "BROray"), + createdByBROray: true, + managed: true, + routerCommentPersisted: false, + actualStatus: "present", + owners: [$bundleId], + createdAt: $now, + updatedAt: $now + }] + else + .[$idx].owners = (((.[$idx].owners // []) + [$bundleId]) | unique) | + .[$idx].actualStatus = "present" | + .[$idx].updatedAt = $now + end + elif $item.kind == "shared_restore" then + (map(.key) | index($item.key)) as $idx | + if $idx == null then . + else + .[$idx].owners = (((.[$idx].owners // []) - [$bundleId]) | unique) | + .[$idx].actualStatus = "present" | + .[$idx].updatedAt = $now + end + else . end + )) as $after_added | + (reduce $deleted[0][] as $item ($after_added; + (map(.key) | index($item.key)) as $idx | + if $idx == null then . + else + .[$idx].owners = (((.[$idx].owners // []) - [$bundleId]) | unique) | + .[$idx].updatedAt = $now + end + )) as $routes | + $o + { + schemaVersion: 1, + managedInterface: $s.targetInterface, + managedMetric: 1200, + routes: ($routes | map(select(((.owners // []) | length) > 0))), + updatedAt: $now + } + ' >"$output/routes.json" || return 1 + + jq -n \ + --slurpfile old "$bundle_registry" \ + --slurpfile added "$added_json" \ + --slurpfile deleted "$deleted_json" \ + --arg now "$now" ' + $old[0] as $o | + (reduce $added[0][] as $item (($o.routeKeys // []); + if $item.kind == "create" then (. + [$item.key] | unique) + else (. - [$item.key]) end + )) as $after_added | + (reduce $deleted[0][] as $item ($after_added; . - [$item.key])) as $keys | + $o + { + routeKeys: $keys, + managedRouteKeys: $keys, + externalRouteKeys: [], + updatedAt: $now + } + ' >"$output/bundle.json" || return 1 + + jq \ + --arg now "$now" \ + --arg message "$message" \ + --arg errorRoute "$error_route" \ + --arg operation "$(jq -r '.mode // "install"' "$sync_plan")" \ + --argjson current "$BRORAY_SYNC_PROGRESS_CURRENT" \ + --argjson total "$BRORAY_SYNC_PROGRESS_TOTAL" \ + --argjson created "$((previous_created + added_count))" \ + --argjson deleted "$((previous_deleted + deleted_count))" ' + .lastVerifiedAt = null | + .verifyResult = null | + .preflight = null | + .resumeOperation = { + operation: $operation, + resumable: true, + current: $current, + total: $total, + created: $created, + deleted: $deleted, + message: $message, + errorRoute: (if $errorRoute == "" then null else $errorRoute end), + updatedAt: $now + } | + .lastError = ( + if $errorRoute == "" then null + else {code: "ROUTES_OPERATION_PAUSED_AFTER_ERROR", message: $message, route: $errorRoute, resumable: true} + end + ) | + .updatedAt = $now + ' "$state" >"$output/state.json" || return 1 + + jq -e --arg interface "$(jq -r '.targetInterface' "$sync_plan")" ' + (.schemaVersion == 1) and + (.managedInterface == $interface) and + (.managedMetric == 1200) and + (([.routes[].key] | length) == ([.routes[].key] | unique | length)) and + (all(.routes[]; + .interface == $interface and + .metric == 1200 and + .createdByBROray == true and + .managed == true and + (((.owners // []) | length) > 0) + )) + ' "$output/routes.json" >/dev/null 2>&1 || return 1 + + jq -e --arg id "$BRORAY_SYNC_BUNDLE" ' + (.bundleId == $id) and + ((.routeKeys | type) == "array") and + ((.managedRouteKeys | type) == "array") and + (.routeKeys == .managedRouteKeys) + ' "$output/bundle.json" >/dev/null 2>&1 || return 1 + + jq -e '.resumeOperation.resumable == true' "$output/state.json" >/dev/null 2>&1 || return 1 + + cp -p "$output/routes.json" "$registry.new.$$" && mv "$registry.new.$$" "$registry" || return 1 + cp -p "$output/bundle.json" "$bundle_registry.new.$$" && mv "$bundle_registry.new.$$" "$bundle_registry" || return 1 + cp -p "$output/state.json" "$state.new.$$" && mv "$state.new.$$" "$state" || return 1 + chmod 644 "$registry" "$bundle_registry" "$state" 2>/dev/null || true + return 0 +} + +broray_routes_sync_pause() +{ + local sync_plan message stopped_by_user error_route exit_code phase_message + + sync_plan="$1" + message="$2" + stopped_by_user="${3:-true}" + error_route="${4:-}" + case "$stopped_by_user" in true|false) ;; *) stopped_by_user=true ;; esac + + trap - EXIT HUP INT TERM + if [ "$stopped_by_user" = true ]; then + phase_message="Остановка после текущего маршрута." + exit_code=74 + else + phase_message="Операция приостановлена после ошибки." + exit_code=76 + fi + + broray_routes_sync_progress_update \ + "stopping" "$BRORAY_SYNC_PROGRESS_CURRENT" "$BRORAY_SYNC_PROGRESS_TOTAL" \ + "$phase_message" "$error_route" >/dev/null 2>&1 || true + + if [ "$BRORAY_SYNC_ROUTER_CHANGED" = true ]; then + broray_routes_sync_save_config || + broray_routes_sync_abort "Не удалось сохранить частично выполненную операцию; выполнен откат." + fi + + broray_routes_sync_partial_commit "$sync_plan" "$message" "$error_route" || + broray_routes_sync_abort "Не удалось сохранить состояние для продолжения; выполнен откат." + + broray_routes_sync_transaction_write "paused" "$message" >/dev/null 2>&1 || true + BRORAY_SYNC_ROUTER_CHANGED=false + BRORAY_SYNC_LOCAL_COMMITTED=false + broray_routes_sync_progress_pause "$message" "$stopped_by_user" "$error_route" >/dev/null 2>&1 || true + broray_routes_sync_lock_release + + printf '%s\n' "$message" + printf 'Выполнено: %s из %s\n' "$BRORAY_SYNC_PROGRESS_CURRENT" "$BRORAY_SYNC_PROGRESS_TOTAL" + printf '%s\n' 'Операцию можно продолжить позднее.' + + broray_routes_sync_cleanup + exit "$exit_code" +} + +broray_routes_sync_stop_if_requested() +{ + local sync_plan + sync_plan="$1" + command -v broray_routes_progress_stop_requested >/dev/null 2>&1 || return 0 + if broray_routes_progress_stop_requested "$BRORAY_SYNC_BUNDLE"; then + broray_routes_sync_pause \ + "$sync_plan" \ + "Операция остановлена пользователем. Выполнено $BRORAY_SYNC_PROGRESS_CURRENT из $BRORAY_SYNC_PROGRESS_TOTAL." \ + true "" + fi +} + +broray_routes_sync_route_failure() +{ + local sync_plan message route + sync_plan="$1" + message="$2" + route="$3" + broray_routes_sync_pause \ + "$sync_plan" \ + "$message Выполнено $BRORAY_SYNC_PROGRESS_CURRENT из $BRORAY_SYNC_PROGRESS_TOTAL. Можно продолжить после устранения причины." \ + false "$route" +} + broray_routes_sync_abort() { local message @@ -254,6 +565,7 @@ broray_routes_sync_abort() broray_routes_sync_rollback_router broray_routes_sync_transaction_write "rolled_back" "$message" >/dev/null 2>&1 || true fi + broray_routes_sync_progress_fail "$message Изменения операции отменены." true >/dev/null 2>&1 || true broray_routes_sync_cleanup printf 'ОШИБКА: %s\n' "$message" >&2 exit 1 @@ -646,7 +958,7 @@ broray_routes_sync_plan() broray_routes_sync_prepare_local() { local sync_plan registry bundle_registry state export_plan output now mode message - local total created deleted shared kept restored absent unchanged + local total created deleted shared kept restored absent unchanged previous_created previous_deleted sync_plan="$1" output="$2" @@ -657,8 +969,10 @@ broray_routes_sync_prepare_local() now="$(broray_routes_sync_now)" mode="$(jq -r '.mode' "$sync_plan")" total="$(jq -r '.summary.total' "$sync_plan")" - created="$(wc -l <"$BRORAY_SYNC_ADDED_FILE" | tr -d ' ')" - deleted="$(wc -l <"$BRORAY_SYNC_DELETED_FILE" | tr -d ' ')" + previous_created="$(jq -r '.resumeOperation.created // 0' "$state" 2>/dev/null || printf 0)" + previous_deleted="$(jq -r '.resumeOperation.deleted // 0' "$state" 2>/dev/null || printf 0)" + created="$((previous_created + $(wc -l <"$BRORAY_SYNC_ADDED_FILE" | tr -d ' ')))" + deleted="$((previous_deleted + $(wc -l <"$BRORAY_SYNC_DELETED_FILE" | tr -d ' ')))" shared="$(jq -r '.summary.sharedKept' "$sync_plan")" restored="$(jq -r '.summary.sharedToRestore' "$sync_plan")" absent="$(jq -r '.summary.alreadyAbsent' "$sync_plan")" @@ -747,6 +1061,37 @@ broray_routes_sync_prepare_local() .status = "installed" | .installedVersion = .downloadedVersion | .lastExportedAt = $now | + .lastVerifiedAt = $now | + .verifyResult = { + result: "complete", + success: true, + message: "Набор исправен. В Keenetic найдено " + ($total | tostring) + " из " + ($total | tostring) + " маршрутов.", + checkedAt: $now, + contentSha256: (.downloadedVersion.contentSha256 // null), + local: { + valid: true, + routeCount: $total, + sourceFileCount: (.downloadedVersion.sourceFileCount // 0), + duplicateRouteCount: 0, + invalidRouteCount: 0 + }, + keenetic: { + checked: true, + available: true, + status: "complete", + expectedRouteCount: $total, + presentRouteCount: $total, + missingRouteCount: 0, + conflictCount: 0, + externalRouteCount: 0, + complete: true, + updatePending: false, + mode: $mode, + canApply: true, + targetInterface: $sync[0].targetInterface, + managedMetric: 1200 + } + } | .preflight = $sync[0] | .exportResult = { result: "installed", @@ -765,6 +1110,7 @@ broray_routes_sync_prepare_local() completedAt: $now } | .lastError = null | + .resumeOperation = null | .updatedAt = $now ' "$state" >"$output/state.json" || return 1 @@ -815,6 +1161,8 @@ broray_routes_sync_apply() { local bundle_id lock_rc sync_plan interface out err tab key network prefix mask metric status rc local to_create to_delete added_count deleted_count actual_after prepared original registry bundle_registry state export_plan result transaction + local mode processed_count progress_total progress_message current_route remaining_total resume_values + local kind bundle_id="${1:-}" broray_routes_sync_id_valid "$bundle_id" || broray_routes_sync_abort "Некорректный идентификатор набора." @@ -878,18 +1226,51 @@ broray_routes_sync_apply() broray_routes_sync_abort "Не удалось записать журнал транзакции." jq -r ' - (.desired[] | select(.status == "create") | .route), - (.obsolete[] | select(.status == "shared_restore") | .route) | - [.key, .network, (.prefix | tostring), .mask, .targetInterface, (.metric | tostring)] | @tsv + (.desired[] | select(.status == "create") | ["create", .route.key, .route.network, (.route.prefix | tostring), .route.mask, .route.targetInterface, (.route.metric | tostring)]), + (.obsolete[] | select(.status == "shared_restore") | ["shared_restore", .route.key, .route.network, (.route.prefix | tostring), .route.mask, .route.targetInterface, (.route.metric | tostring)]) | + @tsv ' "$sync_plan" >"$BRORAY_SYNC_WORK/to-create.tsv" || broray_routes_sync_abort "Не удалось подготовить список добавления." - jq -r '.obsolete[] | select(.status == "delete") | .route | [.key, .network, (.prefix | tostring), .mask, .targetInterface, (.metric | tostring)] | @tsv' \ + jq -r '.obsolete[] | select(.status == "delete") | ["delete", .route.key, .route.network, (.route.prefix | tostring), .route.mask, .route.targetInterface, (.route.metric | tostring)] | @tsv' \ "$sync_plan" >"$BRORAY_SYNC_WORK/to-delete.tsv" || broray_routes_sync_abort "Не удалось подготовить список удаления." + to_create="$(wc -l <"$BRORAY_SYNC_WORK/to-create.tsv" | tr -d ' ')" + to_delete="$(wc -l <"$BRORAY_SYNC_WORK/to-delete.tsv" | tr -d ' ')" + remaining_total="$((to_create + to_delete))" + mode="$(jq -r '.mode // "install"' "$sync_plan")" + resume_values="0 $remaining_total false" + if command -v broray_routes_progress_resume_values >/dev/null 2>&1; then + resume_values="$(broray_routes_progress_resume_values "$bundle_id" "$mode" "$remaining_total")" || + resume_values="0 $remaining_total false" + fi + BRORAY_SYNC_RESUME_BASE="$(printf '%s' "$resume_values" | cut -f1)" + progress_total="$(printf '%s' "$resume_values" | cut -f2)" + BRORAY_SYNC_RESUMED="$(printf '%s' "$resume_values" | cut -f3)" + case "$BRORAY_SYNC_RESUME_BASE" in ''|*[!0-9]*) BRORAY_SYNC_RESUME_BASE=0 ;; esac + case "$progress_total" in ''|*[!0-9]*) progress_total="$remaining_total" ;; esac + case "$BRORAY_SYNC_RESUMED" in true|false) ;; *) BRORAY_SYNC_RESUMED=false ;; esac + processed_count="$BRORAY_SYNC_RESUME_BASE" + case "$mode" in + install) progress_message="Установка маршрутов в Keenetic." ;; + update) progress_message="Обновление маршрутов в Keenetic." ;; + restore) progress_message="Восстановление маршрутов в Keenetic." ;; + *) mode="install"; progress_message="Установка маршрутов в Keenetic." ;; + esac + BRORAY_SYNC_PROGRESS_CURRENT=0 + BRORAY_SYNC_PROGRESS_TOTAL="$progress_total" + broray_routes_sync_progress_begin \ + "$bundle_id" "$mode" "$progress_total" \ + "Подготовка операции: $processed_count из $progress_total." \ + "$processed_count" "$BRORAY_SYNC_RESUMED" || + broray_routes_sync_abort "Не удалось создать состояние прогресса операции." + broray_routes_sync_progress_update \ + "applying" "$processed_count" "$progress_total" "$progress_message" "" || + broray_routes_sync_abort "Не удалось обновить состояние прогресса операции." + BRORAY_SYNC_ROUTER_CHANGED=true tab="$(printf '\t')" - while IFS="$tab" read -r key network prefix mask interface metric; do + while IFS="$tab" read -r kind key network prefix mask interface metric; do [ -n "$network" ] || continue out="$BRORAY_SYNC_WORK/add.out" err="$BRORAY_SYNC_WORK/add.err" @@ -899,9 +1280,16 @@ broray_routes_sync_apply() grep -Eiq 'Io::Netlink error|system failed|invalid command|unknown command' "$out" "$err" 2>/dev/null && broray_routes_sync_abort "Keenetic вернул ошибку при добавлении $network/$prefix." grep -Fq "Added static route: $network/$prefix via $interface." "$out" || broray_routes_sync_abort "Keenetic не подтвердил создание $network/$prefix." printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$key" "$network" "$prefix" "$mask" "$interface" "$metric" >>"$BRORAY_SYNC_ADDED_FILE" + processed_count=$((processed_count + 1)) + BRORAY_SYNC_PROGRESS_CURRENT="$processed_count" + current_route="$network/$prefix" + broray_routes_sync_progress_tick \ + "$processed_count" "$current_route" || + broray_routes_sync_abort "Не удалось записать прогресс установки маршрутов." + broray_routes_sync_stop_if_requested "$sync_plan" done <"$BRORAY_SYNC_WORK/to-create.tsv" - while IFS="$tab" read -r key network prefix mask interface metric; do + while IFS="$tab" read -r kind key network prefix mask interface metric; do [ -n "$network" ] || continue out="$BRORAY_SYNC_WORK/delete.out" err="$BRORAY_SYNC_WORK/delete.err" @@ -914,10 +1302,22 @@ broray_routes_sync_apply() elif cat "$out" "$err" 2>/dev/null | grep -Fq 'No such route:'; then : else - broray_routes_sync_abort "Не удалось удалить устаревший маршрут $network/$prefix." + broray_routes_sync_route_failure "$sync_plan" "Не удалось удалить устаревший маршрут $network/$prefix." "$network/$prefix" fi + processed_count=$((processed_count + 1)) + BRORAY_SYNC_PROGRESS_CURRENT="$processed_count" + current_route="$network/$prefix" + broray_routes_sync_progress_tick \ + "$processed_count" "$current_route" || + broray_routes_sync_abort "Не удалось записать прогресс обновления маршрутов." + broray_routes_sync_stop_if_requested "$sync_plan" done <"$BRORAY_SYNC_WORK/to-delete.tsv" + broray_routes_sync_progress_update \ + "verifying" "$processed_count" "$progress_total" \ + "Проверка маршрутов в Keenetic." "" || + broray_routes_sync_abort "Не удалось записать этап проверки маршрутов." + actual_after="$BRORAY_SYNC_WORK/running-config-after.json" BRORAY_ROUTES_CONFIG_NDMC="$BRORAY_SYNC_NDMC" export BRORAY_ROUTES_CONFIG_NDMC @@ -979,10 +1379,17 @@ broray_routes_sync_apply() broray_routes_sync_transaction_write "local_committed" "Локальные реестры обновлены." || broray_routes_sync_abort "Не удалось обновить журнал транзакции." + broray_routes_sync_progress_update \ + "saving" "$processed_count" "$progress_total" \ + "Сохранение конфигурации Keenetic." "" || + broray_routes_sync_abort "Не удалось записать этап сохранения конфигурации." broray_routes_sync_save_config || broray_routes_sync_abort "Не удалось сохранить конфигурацию Keenetic." broray_routes_sync_transaction_write "committed" "Маршруты применены, проверены и сохранены." || broray_routes_sync_abort "Не удалось завершить журнал транзакции." + broray_routes_sync_progress_complete \ + "$progress_message Выполнено $progress_total из $progress_total." || true + added_count="$(wc -l <"$BRORAY_SYNC_ADDED_FILE" | tr -d ' ')" deleted_count="$(wc -l <"$BRORAY_SYNC_DELETED_FILE" | tr -d ' ')" diff --git a/root/opt/broray/lib/routes-runtime-repair.sh b/root/opt/broray/lib/routes-runtime-repair.sh index a18e8f6..3c8f272 100755 --- a/root/opt/broray/lib/routes-runtime-repair.sh +++ b/root/opt/broray/lib/routes-runtime-repair.sh @@ -161,11 +161,13 @@ broray_routes_runtime_prepare() installedVersion: null, routeCount: null, lastCheckedAt: null, + lastVerifiedAt: null, lastDownloadedAt: null, lastExportedAt: null, lastDeletedAt: null, lastError: null, checkResult: null, + verifyResult: null, downloadResult: null, exportBuild: null, preflight: null, diff --git a/root/opt/broray/lib/routes-source-check.sh b/root/opt/broray/lib/routes-source-check.sh index ab798b5..8b7ca29 100755 --- a/root/opt/broray/lib/routes-source-check.sh +++ b/root/opt/broray/lib/routes-source-check.sh @@ -494,9 +494,18 @@ broray_routes_check_create_state() "installedVersion": null, "routeCount": null, "lastCheckedAt": null, + "lastVerifiedAt": null, "lastDownloadedAt": null, "lastExportedAt": null, + "lastDeletedAt": null, "lastError": null, + "checkResult": null, + "verifyResult": null, + "downloadResult": null, + "exportBuild": null, + "preflight": null, + "exportResult": null, + "deleteResult": null, "updatedAt": "$now" } STATE_JSON diff --git a/root/opt/broray/lib/routes-summary.sh b/root/opt/broray/lib/routes-summary.sh index e84a8e5..9fae0fb 100755 --- a/root/opt/broray/lib/routes-summary.sh +++ b/root/opt/broray/lib/routes-summary.sh @@ -112,7 +112,11 @@ broray_routes_summary() ((.installedVersion | type) == "object")) and ((.lastError == null) or ((.lastError | type) == "string") or - ((.lastError | type) == "object")) + ((.lastError | type) == "object")) and + ((.lastVerifiedAt == null) or + ((.lastVerifiedAt | type) == "string")) and + ((.verifyResult == null) or + ((.verifyResult | type) == "object")) ' "$summary_state_file" >/dev/null 2>&1 || { broray_routes_summary_error \ "ROUTES_STATE_INVALID" \ @@ -237,6 +241,38 @@ broray_routes_summary() ( $sourceUpdateAvailable or $keeneticUpdateAvailable ) as $updateAvailable | + ($s.verifyResult // null) as $verify | + ( + $downloaded and + ($verify != null) and + (($verify.contentSha256 // "") != "") and + (($verify.contentSha256 // "") == ($s.downloadedVersion.contentSha256 // "")) + ) as $verifyMatchesDownloaded | + ( + $verifyMatchesDownloaded and + ($verify.local.valid == false) + ) as $localInvalid | + ( + $verifyMatchesDownloaded and + ($verify.local.valid == true) + ) as $verificationCurrent | + ( + $downloaded and + ($localInvalid | not) and + ($verificationCurrent | not) + ) as $verificationRequired | + ( + $verificationCurrent and + (($verify.keenetic.status // "") == "conflict") + ) as $verificationConflict | + ( + $verificationCurrent and + ( + (($verify.keenetic.available // true) == false) or + (($verify.keenetic.status // "") == "unavailable") + ) + ) as $verificationRetryRequired | + ($downloadRequired or $localInvalid) as $downloadActionRequired | (($b.routeKeys // []) | length) as $installedRouteCount | (($b.managedRouteKeys // []) | length) as $managedRouteCount | @@ -283,6 +319,11 @@ broray_routes_summary() ($s.lastDownloadedAt // null); ($s.downloadResult.message // null) ), + operation_item( + "verify"; + ($s.lastVerifiedAt // null); + ($s.verifyResult.message // null) + ), operation_item( "install"; ($s.lastExportedAt // null); @@ -313,7 +354,7 @@ broray_routes_summary() ( if $operationRunning then "busy" - elif $error != null then "error" + elif $localInvalid or $verificationConflict or ($error != null) then "error" elif $installed and $updateAvailable then "update_available" elif $installed then "installed" elif $downloaded then "downloaded" @@ -324,11 +365,15 @@ broray_routes_summary() ( if $operationRunning then "wait" + elif $localInvalid then "download" + elif $verificationRetryRequired then "verify" elif $error != null then "review" elif $downloadRequired then "download" + elif $verificationRequired then "verify" + elif $verificationConflict then "review" elif $keeneticUpdateRequired and ($s.installedVersion != null) then "update" elif $keeneticUpdateRequired then "install" - elif $installed then "none" + elif $installed then "check" else "check" end ) as $recommendedAction | @@ -346,6 +391,17 @@ broray_routes_summary() sourceUpdateAvailable: $sourceUpdateAvailable, keeneticUpdateAvailable: $keeneticUpdateAvailable, downloadRequired: $downloadRequired, + downloadActionRequired: $downloadActionRequired, + verificationRequired: $verificationRequired, + verificationCurrent: $verificationCurrent, + localSetValid: ( + if $localInvalid then false + elif $verificationCurrent then true + else null + end + ), + verificationConflict: $verificationConflict, + verificationRetryRequired: $verificationRetryRequired, keeneticUpdateRequired: $keeneticUpdateRequired, exportRequired: $keeneticUpdateRequired, operationRunning: $operationRunning, @@ -360,7 +416,9 @@ broray_routes_summary() availableVersion: $s.availableVersion, downloadedVersion: $s.downloadedVersion, installedVersion: $s.installedVersion, + verifyResult: ($s.verifyResult // null), lastCheckedAt: ($s.lastCheckedAt // null), + lastVerifiedAt: ($s.lastVerifiedAt // null), lastDownloadedAt: ($s.lastDownloadedAt // null), lastExportedAt: ($s.lastExportedAt // null), lastDeletedAt: ($s.lastDeletedAt // null), diff --git a/root/opt/broray/lib/routes-user-import.sh b/root/opt/broray/lib/routes-user-import.sh index 48cb6e0..81af6f4 100755 --- a/root/opt/broray/lib/routes-user-import.sh +++ b/root/opt/broray/lib/routes-user-import.sh @@ -1,6 +1,6 @@ #!/opt/bin/ash -# BROray 2.1.1 — safe local BAT route importer. +# BROray 2.2.0 — safe local BAT route importer. # Uploaded BAT files are treated strictly as text and are never executed. BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" diff --git a/root/opt/broray/lib/routes-web-action.sh b/root/opt/broray/lib/routes-web-action.sh index 93bf4ab..d2ae8d7 100755 --- a/root/opt/broray/lib/routes-web-action.sh +++ b/root/opt/broray/lib/routes-web-action.sh @@ -6,12 +6,15 @@ PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" +API_LOCK_LIBRARY="/opt/broray/lib/routes-api-operation.sh" +PREFLIGHT_LIBRARY="/opt/broray/lib/routes-operation-preflight.sh" CLI="/opt/broray/bin/broray-routes" BUNDLES="/opt/broray/routes/bundles.json" CONFIG="/opt/broray/routes/config.json" MANIFEST_DIR="/opt/broray/routes/manifests" STATE_DIR="/opt/broray/routes/state" BUNDLE_REGISTRY_DIR="/opt/broray/routes/installed/bundles" +PROGRESS_DIR="/opt/broray/routes/operations" ACTION="${ROUTES_ACTION:-}" OUTPUT_FILE="/tmp/broray-routes-${ACTION:-action}-$$.out" @@ -20,6 +23,8 @@ ERROR_FILE="/tmp/broray-routes-${ACTION:-action}-$$.err" cleanup() { rm -f "$OUTPUT_FILE" "$ERROR_FILE" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && + broray_routes_api_lock_release } trap cleanup EXIT HUP INT TERM @@ -40,7 +45,7 @@ broray_api_require_method POST broray_api_require_session case "$ACTION" in - export|delete) + export|delete|resume) ;; *) broray_api_error \ @@ -67,18 +72,22 @@ then fi query="${QUERY_STRING:-}" +bundle_id="" +preflight_token="" +old_ifs="$IFS" +IFS='&' +set -- $query +IFS="$old_ifs" +for query_item in "$@"; do + case "$query_item" in + bundleId=*) bundle_id="${query_item#bundleId=}" ;; + preflightToken=*) preflight_token="${query_item#preflightToken=}" ;; + esac +done -case "$query" in - bundleId=*) - bundle_id="${query#bundleId=}" - ;; - *) - broray_api_error \ - "400 Bad Request" \ - "ROUTES_BUNDLE_REQUIRED" \ - "Не указан идентификатор набора маршрутов." - ;; -esac +[ -n "$bundle_id" ] || broray_api_error \ + "400 Bad Request" "ROUTES_BUNDLE_REQUIRED" \ + "Не указан идентификатор набора маршрутов." case "$bundle_id" in ""|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) @@ -100,6 +109,39 @@ then "Набор маршрутов не найден." fi +[ -r "$API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." +[ -r "$PREFLIGHT_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_PREFLIGHT_UNAVAILABLE" \ + "Модуль предварительной проверки недоступен." +. "$API_LOCK_LIBRARY" +. "$PREFLIGHT_LIBRARY" +lock_rc=0 +broray_routes_api_lock_acquire "$ACTION" "$bundle_id" || lock_rc=$? +case "$lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; +esac + +preflight_rc=0 +broray_routes_operation_preflight_validate \ + "$bundle_id" "$ACTION" "$preflight_token" || preflight_rc=$? +case "$preflight_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_PREFLIGHT_REQUIRED" \ + "Перед запуском выполните предварительную проверку и подтвердите актуальный план." ;; + 3) broray_api_error "409 Conflict" "ROUTES_PREFLIGHT_EXPIRED" \ + "Предварительная проверка устарела. Выполните её повторно." ;; + 4) broray_api_error "409 Conflict" "ROUTES_PREFLIGHT_CHANGED" \ + "Состояние набора изменилось после проверки. Выполните предварительную проверку повторно." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_PREFLIGHT_VALIDATE_FAILED" \ + "Не удалось подтвердить предварительную проверку." ;; +esac + managed_interface="$( jq -r '.managedInterface // empty' \ "$CONFIG" 2>/dev/null @@ -130,6 +172,9 @@ fi manifest="$MANIFEST_DIR/$bundle_id.json" state_file="$STATE_DIR/$bundle_id.json" bundle_registry="$BUNDLE_REGISTRY_DIR/$bundle_id.json" +progress_file="$PROGRESS_DIR/$bundle_id.json" +result_action="$ACTION" +paused_result=false if [ ! -r "$manifest" ] || [ ! -r "$state_file" ] || @@ -183,6 +228,27 @@ case "$ACTION" in "Управляемые маршруты этого набора не установлены." fi ;; + resume) + if [ ! -r "$progress_file" ] || + ! jq -e '.resumable == true and .running == false' "$progress_file" >/dev/null 2>&1 + then + broray_api_error \ + "409 Conflict" \ + "ROUTES_RESUME_NOT_READY" \ + "Для этого набора нет операции, которую можно продолжить." + fi + result_action="$(jq -r '.operation // empty' "$progress_file")" + case "$result_action" in + install|update|restore) result_action="export" ;; + delete) ;; + *) + broray_api_error \ + "409 Conflict" \ + "ROUTES_RESUME_TYPE_INVALID" \ + "Тип сохранённой операции не поддерживается." + ;; + esac + ;; esac if "$CLI" "$ACTION" "$bundle_id" \ @@ -204,6 +270,11 @@ details="$( tail -n 60 )" +if [ "$command_ok" != true ] && { [ "$command_rc" = 74 ] || [ "$command_rc" = 76 ]; }; then + command_ok=true + paused_result=true +fi + if [ "$command_ok" != true ]; then if printf '%s\n' "$details" | grep -Fq "Другая операция с маршрутами уже выполняется." @@ -211,7 +282,7 @@ if [ "$command_ok" != true ]; then broray_api_error \ "409 Conflict" \ "ROUTES_OPERATION_BUSY" \ - "Другая операция с маршрутами уже выполняется." \ + "Другая конфликтующая операция уже выполняется." \ "$details" fi @@ -220,7 +291,7 @@ if [ "$command_ok" != true ]; then broray_api_error \ "409 Conflict" \ "ROUTES_OPERATION_BUSY" \ - "Другая операция с маршрутами уже выполняется." \ + "Другая конфликтующая операция уже выполняется." \ "$details" ;; esac @@ -234,6 +305,10 @@ if [ "$command_ok" != true ]; then error_code="ROUTES_DELETE_FAILED" error_message="Не удалось удалить маршруты из Keenetic." ;; + resume) + error_code="ROUTES_RESUME_FAILED" + error_message="Не удалось продолжить операцию с маршрутами." + ;; esac broray_api_error \ @@ -257,7 +332,8 @@ then "$details" fi -case "$ACTION" in +if [ "$paused_result" != true ]; then +case "$result_action" in export) if ! jq -e ' (.status == "installed") and @@ -287,15 +363,21 @@ case "$ACTION" in fi ;; esac +fi command_output="$(tail -n 60 "$OUTPUT_FILE" 2>/dev/null)" completed_at="$(date '+%Y-%m-%dT%H:%M:%S%z')" +operation_progress="null" +if [ -r "$progress_file" ]; then + operation_progress="$(jq -c '.' "$progress_file" 2>/dev/null || printf null)" +fi data_json="$( jq -c \ --arg action "$ACTION" \ --arg commandOutput "$command_output" \ - --arg completedAt "$completed_at" ' + --arg completedAt "$completed_at" \ + --argjson operationProgress "$operation_progress" ' { schemaVersion, bundleId, @@ -305,16 +387,20 @@ data_json="$( installedVersion, routeCount, lastCheckedAt, + lastVerifiedAt, lastDownloadedAt, lastExportedAt, lastDeletedAt, lastError, checkResult, + verifyResult, downloadResult, exportBuild, preflight, exportResult, deleteResult, + resumeOperation, + operationProgress: $operationProgress, updatedAt, operation: { type: $action, diff --git a/root/opt/broray/share/routes/user-import-version b/root/opt/broray/share/routes/user-import-version index 3e3c2f1..ccbccc3 100644 --- a/root/opt/broray/share/routes/user-import-version +++ b/root/opt/broray/share/routes/user-import-version @@ -1 +1 @@ -2.1.1 +2.2.0 diff --git a/root/opt/broray/tests/broray-cleanup-selftest.sh b/root/opt/broray/tests/broray-cleanup-selftest.sh new file mode 100755 index 0000000..faea050 --- /dev/null +++ b/root/opt/broray/tests/broray-cleanup-selftest.sh @@ -0,0 +1,162 @@ +#!/opt/bin/ash +set -eu + +SOURCE_ROOT="${BRORAY_TEST_SOURCE_ROOT:-/opt/broray}" +LIB="$SOURCE_ROOT/lib/broray-cleanup.sh" +[ -r "$LIB" ] || { printf '%s\n' 'FAIL: модуль очистки не найден' >&2; exit 1; } + +TEST_ROOT="/tmp/broray-cleanup-selftest-$$" +BASE="$TEST_ROOT/broray" +OUTSIDE="$TEST_ROOT/outside.txt" + +fail() +{ + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +cleanup() +{ + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT HUP INT TERM + +reset_tree() +{ + rm -rf "$BASE" + mkdir -p \ + "$BASE/tmp" "$BASE/backup/xray-binaries" \ + "$BASE/routes/tmp" "$BASE/routes/transactions" "$BASE/routes/backup" \ + "$BASE/logs" "$BASE/run/broray" +} + +make_old() +{ + case "${2:-10 days ago}" in + '3 days ago') stamp=202601020000 ;; + '30 days ago') stamp=202501010000 ;; + *) stamp=202601010000 ;; + esac + /usr/bin/busybox touch -t "$stamp" "$1" +} + +load_module() +{ + BRORAY_BASE="$BASE" + BRORAY_CLEANUP_RUN="$BASE/run/cleanup" + BRORAY_CLEANUP_PLAN_DIR="$BRORAY_CLEANUP_RUN/plans" + BRORAY_CLEANUP_GLOBAL_LOCK="$BASE/run/global-operation.lock" + BRORAY_CLEANUP_TTL=120 + export BRORAY_BASE BRORAY_CLEANUP_RUN BRORAY_CLEANUP_PLAN_DIR + export BRORAY_CLEANUP_GLOBAL_LOCK BRORAY_CLEANUP_TTL + . "$LIB" +} + +create_fixture() +{ + printf managed >"$BASE/tmp/broray-old.part" + printf unknown >"$BASE/tmp/customer-file.txt" + printf outside >"$OUTSIDE" + ln -s "$OUTSIDE" "$BASE/tmp/broray-external-link" + make_old "$BASE/tmp/broray-old.part" + + for stamp in 20260101-000001 20260101-000002 20260101-000003 20260101-000004 20260101-000005; do + printf '%s' "$stamp" >"$BASE/backup/system-before-$stamp.tar.gz" + make_old "$BASE/backup/system-before-$stamp.tar.gz" '3 days ago' + done + printf unknown >"$BASE/backup/manual-copy.tar.gz" + make_old "$BASE/backup/manual-copy.tar.gz" '30 days ago' + + for stamp in 20260101-000001 20260101-000002 20260101-000003 20260101-000004; do + mkdir -p "$BASE/routes/backup/catalog-$stamp" + printf route >"$BASE/routes/backup/catalog-$stamp/item" + make_old "$BASE/routes/backup/catalog-$stamp" '3 days ago' + done + + printf old >"$BASE/logs/broray.log.1" + printf active >"$BASE/logs/broray.log" + make_old "$BASE/logs/broray.log.1" +} + +reset_tree +create_fixture +load_module + +plan="$(broray_cleanup_plan_create true true true true)" || fail "$BRORAY_CLEANUP_ERROR_MESSAGE" +printf '%s' "$plan" | jq -e '.schemaVersion == 1 and .candidateCount == 5 and .estimatedBytes > 0' >/dev/null || + fail 'неверный план очистки' +printf '%s' "$plan" | jq -e '[.candidates[].path] | index("tmp/customer-file.txt") == null' >/dev/null || + fail 'неизвестный файл попал в план' +printf '%s' "$plan" | jq -e '[.candidates[].path] | index("tmp/broray-external-link") == null' >/dev/null || + fail 'симлинк попал в план' +printf '%s' "$plan" | jq -e '[.candidates[].path] | index("backup/manual-copy.tar.gz") == null' >/dev/null || + fail 'ручная резервная копия попала в план' + +token="$(printf '%s' "$plan" | jq -r '.token')" +result="$(broray_cleanup_execute "$token")" || fail "$BRORAY_CLEANUP_ERROR_MESSAGE" +printf '%s' "$result" | jq -e '.ok == true and .deletedCount == 5 and .freedBytes > 0' >/dev/null || + fail 'неверный результат очистки' +[ ! -e "$BASE/tmp/broray-old.part" ] || fail 'временный файл не удалён' +[ -e "$BASE/tmp/customer-file.txt" ] || fail 'неизвестный файл удалён' +[ -L "$BASE/tmp/broray-external-link" ] || fail 'симлинк удалён' +[ -e "$OUTSIDE" ] || fail 'внешняя цель симлинка удалена' +[ -e "$BASE/backup/manual-copy.tar.gz" ] || fail 'ручная копия удалена' +[ "$(find "$BASE/backup" -maxdepth 1 -type f -name 'system-before-*' | wc -l | tr -d ' ')" = 3 ] || + fail 'не сохранены три последние системные копии' +[ "$(find "$BASE/routes/backup" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')" = 3 ] || + fail 'не сохранены три последние копии маршрутов' +[ -e "$BASE/logs/broray.log" ] || fail 'активный журнал удалён' +[ ! -e "$BASE/logs/broray.log.1" ] || fail 'старый журнал не удалён' +[ ! -d "$BASE/run/global-operation.lock" ] || fail 'глобальная блокировка не освобождена' + +# A changed candidate invalidates the short-lived confirmation. +reset_tree +printf old >"$BASE/tmp/broray-change.part" +make_old "$BASE/tmp/broray-change.part" +load_module +plan="$(broray_cleanup_plan_create true false false false)" || fail 'не создан план изменения' +token="$(printf '%s' "$plan" | jq -r '.token')" +printf changed >>"$BASE/tmp/broray-change.part" +if broray_cleanup_execute "$token" >/dev/null 2>&1; then + fail 'изменённый план был принят' +fi +[ "$BRORAY_CLEANUP_ERROR_CODE" = CLEANUP_PLAN_CHANGED ] || fail 'неверная ошибка изменённого плана' +[ -e "$BASE/tmp/broray-change.part" ] || fail 'файл удалён после изменения плана' + +# An expired confirmation is rejected without deleting anything. +reset_tree +printf old >"$BASE/tmp/broray-expired.part" +make_old "$BASE/tmp/broray-expired.part" +load_module +plan="$(broray_cleanup_plan_create true false false false)" || fail 'не создан просроченный план' +token="$(printf '%s' "$plan" | jq -r '.token')" +plan_file="$BRORAY_CLEANUP_PLAN_DIR/$token.json" +jq '.expiresEpoch = 0' "$plan_file" >"$plan_file.new" && mv "$plan_file.new" "$plan_file" +if broray_cleanup_execute "$token" >/dev/null 2>&1; then + fail 'просроченный план был принят' +fi +[ "$BRORAY_CLEANUP_ERROR_CODE" = CLEANUP_PLAN_EXPIRED ] || fail 'неверная ошибка просроченного плана' +[ -e "$BASE/tmp/broray-expired.part" ] || fail 'файл удалён по просроченному плану' + +# An active operation blocks both planning and deletion. +reset_tree +printf old >"$BASE/tmp/broray-busy.part" +make_old "$BASE/tmp/broray-busy.part" +load_module +mkdir -p "$BRORAY_CLEANUP_GLOBAL_LOCK" +printf '%s\n' "$$" >"$BRORAY_CLEANUP_GLOBAL_LOCK/pid" +printf '%s\n' routes >"$BRORAY_CLEANUP_GLOBAL_LOCK/scope" +printf '%s\n' export >"$BRORAY_CLEANUP_GLOBAL_LOCK/action" +if broray_cleanup_plan_create true false false false >/dev/null 2>&1; then + fail 'план создан при активной операции' +fi +[ "$BRORAY_CLEANUP_ERROR_CODE" = CLEANUP_OPERATION_BUSY ] || fail 'неверная ошибка блокировки' +rm -rf "$BRORAY_CLEANUP_GLOBAL_LOCK" + +# Empty selection is rejected. +if broray_cleanup_plan_create false false false false >/dev/null 2>&1; then + fail 'пустой набор категорий принят' +fi +[ "$BRORAY_CLEANUP_ERROR_CODE" = CLEANUP_NOTHING_SELECTED ] || fail 'неверная ошибка пустого выбора' + +printf '%s\n' 'PASS: safe cleanup planning, preservation, expiry, recheck and global lock' diff --git a/root/opt/broray/tests/broray-cleanup-ui-selftest.js b/root/opt/broray/tests/broray-cleanup-ui-selftest.js new file mode 100755 index 0000000..057a5ad --- /dev/null +++ b/root/opt/broray/tests/broray-cleanup-ui-selftest.js @@ -0,0 +1,64 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const root = process.argv[2]; +if (!root) { + throw new Error("Usage: node broray-cleanup-ui-selftest.js "); +} + +function read(relative) { + return fs.readFileSync(path.join(root, relative), "utf8"); +} + +function requireText(text, needle, message) { + if (!text.includes(needle)) { + throw new Error(message + ": " + needle); + } +} + +const sourceHtml = read("web-src/pages/broray.html"); +const builtHtml = read("web-new/broray.html"); +const sourceJs = read("web-src/assets/js/broray.js"); +const builtJs = read("web-new/assets/js/broray.js"); +const sourceCss = read("web-src/assets/css/allpage.css"); +const builtCss = read("web-new/assets/css/allpage.css"); +const planApi = read("web-src/api/broray/cleanup-plan.cgi"); +const cleanupApi = read("web-src/api/broray/cleanup.cgi"); +const backend = read("lib/broray-cleanup.sh"); +const systemBackend = read("lib/broray-page.sh"); + +requireText(sourceHtml, 'id="broray-cleanup-title"', "Нет карточки безопасной очистки"); +requireText(sourceHtml, 'id="cleanup-plan"', "Нет кнопки проверки очистки"); +requireText(sourceHtml, 'id="cleanup-run"', "Нет кнопки запуска очистки"); +requireText(sourceHtml, 'id="cleanup-candidates"', "Нет списка кандидатов очистки"); +requireText(sourceJs, '"/api/broray/cleanup-plan.cgi"', "Интерфейс не вызывает предварительную проверку"); +requireText(sourceJs, '"/api/broray/cleanup.cgi"', "Интерфейс не вызывает очистку"); +requireText(sourceJs, "function invalidateCleanupPlan()", "Изменение параметров не отменяет старый план"); +requireText(sourceJs, "plan.token", "Токен плана не передаётся на выполнение"); +requireText(sourceCss, ".broray-cleanup-options", "Нет стилей карточки очистки"); +requireText(planApi, "broray_cleanup_plan_create", "API не формирует план"); +requireText(cleanupApi, "broray_cleanup_execute", "API не выполняет проверенный план"); +requireText(backend, "BRORAY_CLEANUP_TTL", "Нет срока действия подтверждения"); +requireText(backend, "broray_cleanup_path_allowed", "Нет повторной проверки разрешённых путей"); +requireText(backend, "broray_cleanup_global_lock_acquire", "Нет общей блокировки очистки"); +requireText(systemBackend, '$BRORAY_BASE/run/global-operation.lock', "Системные операции используют другую глобальную блокировку"); + +if (sourceJs !== builtJs) { + throw new Error("web-src и web-new содержат разные broray.js"); +} +if (sourceCss !== builtCss) { + throw new Error("web-src и web-new содержат разные allpage.css"); +} +if (!builtHtml.includes('id="cleanup-run"')) { + throw new Error("Собранная страница не содержит очистку"); +} +for (const name of ["cleanup-plan.cgi", "cleanup.cgi"]) { + if (read("web-src/api/broray/" + name) !== read("web-new/api/broray/" + name)) { + throw new Error("web-src и web-new содержат разные " + name); + } +} + +console.log("PASS: safe cleanup UI, API and global operation contract"); diff --git a/root/opt/broray/tests/broray-reinstall-selftest.sh b/root/opt/broray/tests/broray-reinstall-selftest.sh new file mode 100755 index 0000000..62fbc2e --- /dev/null +++ b/root/opt/broray/tests/broray-reinstall-selftest.sh @@ -0,0 +1,328 @@ +#!/opt/bin/ash + +set -u + +TEST_ROOT="/tmp/broray-reinstall-selftest-$$" +SOURCE_ROOT="${BRORAY_TEST_SOURCE_ROOT:-/opt/broray}" +BASE="$TEST_ROOT/broray" +MOCK_BIN="$TEST_ROOT/bin" +MOCK_INIT="$TEST_ROOT/init.d" +MOCK_LISTS="$TEST_ROOT/opkg-lists" +MOCK_FEED="$TEST_ROOT/opkg/broray.conf" +MOCK_PACKAGE="$TEST_ROOT/broray_2.1.1-2_all.ipk" +MOCK_STATE="$TEST_ROOT/state" + +cleanup() +{ + rm -rf "$TEST_ROOT" + rm -f /tmp/broray-opkg-existing-backup /tmp/broray-opkg-services-before-upgrade +} +trap cleanup EXIT HUP INT TERM + +fail() +{ + printf 'FAIL: %s\n' "$*" >&2 + exit 1 +} + +assert_eq() +{ + [ "$1" = "$2" ] || fail "ожидалось '$1', получено '$2'" +} + +make_package() +{ + work="$TEST_ROOT/ipk-work" + rm -rf "$work" + mkdir -p "$work/control" "$work/data" + cat >"$work/control/control" <<'CONTROL' +Package: broray +Version: 2.1.1-2 +Architecture: all +Description: BROray selftest package +CONTROL + cat >"$work/control/preinst" <<'SCRIPT' +#!/bin/sh +exit 0 +SCRIPT + cat >"$work/control/postinst" <<'SCRIPT' +#!/bin/sh +exit 0 +SCRIPT + chmod 755 "$work/control/preinst" "$work/control/postinst" + printf '2.0\n' >"$work/debian-binary" + (cd "$work/control" && tar -czf "$work/control.tar.gz" .) || return 1 + (cd "$work/data" && tar -czf "$work/data.tar.gz" .) || return 1 + (cd "$work" && tar -czf "$MOCK_PACKAGE" ./debian-binary ./data.tar.gz ./control.tar.gz) || return 1 +} + +make_mock_commands() +{ + mkdir -p "$MOCK_BIN" "$MOCK_STATE" + ln -sf "$(command -v busybox)" "$MOCK_BIN/ash" + ln -sf "$(command -v busybox)" "$MOCK_BIN/tar" + + cat >"$MOCK_BIN/curl" <<'SCRIPT' +#!/bin/sh +out="" +while [ "$#" -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + *) shift ;; + esac +done +[ -n "$out" ] || exit 2 +cp "$MOCK_PACKAGE" "$out" +SCRIPT + + cat >"$MOCK_BIN/df" <<'SCRIPT' +#!/bin/sh +printf 'Filesystem 1024-blocks Used Available Capacity Mounted on\n' +printf '/dev/mock 100000 1000 99000 1%% /opt\n' +SCRIPT + + cat >"$MOCK_BIN/opkg" <<'SCRIPT' +#!/bin/sh +command_name="${1:-}" +case "$command_name" in + list-installed) + [ "${2:-}" = broray ] && printf 'broray - 2.1.1-2\n' + ;; + status) + [ "${2:-}" = broray ] && { + printf 'Package: broray\nVersion: 2.1.1-2\nArchitecture: all\nStatus: install user installed\n' + } + ;; + update) + exit 0 + ;; + install) + count="$(cat "$MOCK_STATE/install-count" 2>/dev/null || printf 0)" + count=$((count + 1)) + printf '%s\n' "$count" >"$MOCK_STATE/install-count" + printf 'package-write-%s\n' "$count" >"$BRORAY_BASE/lib/package-write-marker" + printf '{"user":"changed-by-package"}\n' >"$BRORAY_BASE/config/user.json" + if [ "${MOCK_INSTALL_MODE:-success}" = fail-first ] && [ "$count" -eq 1 ]; then + printf 'corrupted\n' >"$BRORAY_BASE/lib/core-marker" + exit 1 + fi + printf 'repaired\n' >"$BRORAY_BASE/lib/core-marker" + exit 0 + ;; + print-architecture) + printf 'arch all 1\n' + ;; + *) + exit 0 + ;; +esac +SCRIPT + chmod 755 "$MOCK_BIN/curl" "$MOCK_BIN/df" "$MOCK_BIN/opkg" +} + +make_service() +{ + file="$1" + cat >"$file" <<'SCRIPT' +#!/bin/sh +if [ "${1:-}" = restart ] && [ "${MOCK_SERVICE_MUTATE_ONCE:-0}" = 1 ] && + [ ! -e "$MOCK_STATE/service-mutated" ] +then + printf '{"user":"changed-after-restart"}\n' >"$BRORAY_BASE/config/user.json" + : >"$MOCK_STATE/service-mutated" +fi +case "${1:-}" in + status|start|stop|restart) exit 0 ;; + *) exit 0 ;; +esac +SCRIPT + chmod 755 "$file" +} + +prepare_base() +{ + rm -rf "$BASE" "$MOCK_INIT" + mkdir -p \ + "$BASE/bin" "$BASE/lib" "$BASE/web-new/api/broray" \ + "$BASE/web-new/api/routes" "$BASE/config" "$BASE/data" \ + "$BASE/deleted-subscriptions" "$BASE/subscriptions" "$BASE/servers" \ + "$BASE/routes/dot" "$BASE/routes/tmp" "$BASE/routes/locks" \ + "$BASE/routes/transactions" "$BASE/run/broray" "$BASE/backup" \ + "$MOCK_INIT" + + cp "$SOURCE_ROOT/lib/broray-page.sh" "$BASE/lib/broray-page.sh" + cat >"$BASE/bin/broray-system" <<'SCRIPT' +#!/bin/sh +exit 0 +SCRIPT + cat >"$BASE/bin/broray" <<'SCRIPT' +#!/bin/sh +exit 0 +SCRIPT + cat >"$BASE/bin/xray" <<'SCRIPT' +#!/bin/sh +[ "${1:-}" = version ] && printf 'Xray 26.7.28 linux/amd64\n' +exit 0 +SCRIPT + cat >"$BASE/bin/broray-routes-dot" <<'SCRIPT' +#!/bin/sh +exit 0 +SCRIPT + cat >"$BASE/lib/xray.sh" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/lib/server-service.sh" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/lib/routes-dot.sh" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/lib/component-lifecycle.sh" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/lib/web-publish.sh" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + printf 'original\n' >"$BASE/lib/core-marker" + printf '2.1.1\n' >"$BASE/config/version" + printf '{"log":{},"inbounds":[],"outbounds":[]}\n' >"$BASE/config/config.json" + printf '{"user":"original"}\n' >"$BASE/config/user.json" + printf 'subscription-secret\n' >"$BASE/subscriptions/demo.txt" + printf 'server-secret\n' >"$BASE/servers/demo.json" + printf '{"schemaVersion":1,"servers":[{"address":"1.1.1.1","tlsName":"cloudflare-dns.com"}]}\n' >"$BASE/routes/dot/config.json" + printf 'temporary\n' >"$BASE/routes/tmp/ignored.tmp" + ln -s ../config/user.json "$BASE/data/user-link" + printf '\n' >"$BASE/web-new/index.html" + cp "$BASE/web-new/index.html" "$BASE/web-new/home.html" + cp "$BASE/web-new/index.html" "$BASE/web-new/broray.html" + cat >"$BASE/web-new/api/auth-common.sh" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/web-new/api/broray/info.cgi" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/web-new/api/broray/reinstall.cgi" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + cat >"$BASE/web-new/api/routes/dot-status.cgi" <<'SCRIPT' +#!/bin/sh +: +SCRIPT + + chmod 755 "$BASE/bin/"* "$BASE/web-new/api/broray/"*.cgi "$BASE/web-new/api/routes/"*.cgi + for service in S23broray-monitor S24broray S25broray-web S27broray-auto-switch S28broray-subscriptions; do + make_service "$MOCK_INIT/$service" + done + rm -f "$MOCK_STATE/install-count" "$MOCK_STATE/service-mutated" +} + +prepare_feed() +{ + mkdir -p "$(dirname "$MOCK_FEED")" "$MOCK_LISTS" + printf 'src/gz broray https://example.invalid/feed\n' >"$MOCK_FEED" + sha="$(sha256sum "$MOCK_PACKAGE" | awk '{print $1}')" + cat >"$MOCK_LISTS/broray" <"$MOCK_LISTS/broray" +export MOCK_INSTALL_MODE=success +export MOCK_SERVICE_MUTATE_ONCE=0 +if broray_system_worker_reinstall reinstall-no-package; then + fail "переустановка без точного пакета не должна запускаться" +fi +assert_eq error "$(jq -r '.state' "$BRORAY_STATUS")" +assert_eq download "$(jq -r '.stage' "$BRORAY_STATUS")" +[ ! -e "$MOCK_STATE/install-count" ] || fail "OPKG install запущен без проверенного пакета" +assert_eq original "$(cat "$BASE/lib/core-marker")" + +printf 'PASS: восстановительная переустановка текущей версии\n' diff --git a/root/opt/broray/tests/broray-reinstall-ui-selftest.js b/root/opt/broray/tests/broray-reinstall-ui-selftest.js new file mode 100644 index 0000000..408e761 --- /dev/null +++ b/root/opt/broray/tests/broray-reinstall-ui-selftest.js @@ -0,0 +1,50 @@ +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const root = process.argv[2]; +if (!root) { + throw new Error("Usage: node broray-reinstall-ui-selftest.js "); +} + +function read(relative) { + return fs.readFileSync(path.join(root, relative), "utf8"); +} + +function requireText(text, needle, message) { + if (!text.includes(needle)) { + throw new Error(message + ": " + needle); + } +} + +const sourceHtml = read("web-src/pages/broray.html"); +const builtHtml = read("web-new/broray.html"); +const sourceJs = read("web-src/assets/js/broray.js"); +const builtJs = read("web-new/assets/js/broray.js"); +const sourceApi = read("web-src/api/broray/reinstall.cgi"); +const builtApi = read("web-new/api/broray/reinstall.cgi"); + +requireText(sourceHtml, 'id="reinstall-current"', "Нет кнопки восстановительной переустановки"); +requireText(sourceHtml, 'id="installed-package-version"', "Не отображается версия пакета OPKG"); +requireText(sourceHtml, "сохранив настройки, подписки, серверы, маршруты и DNS-over-TLS", "Нет пояснения о сохранении данных"); +requireText(sourceJs, 'byId("reinstall-current").disabled = state.busy || !info || !info.reinstallSupported', "Кнопка не связана с backend-возможностью"); +requireText(sourceJs, 'reinstall: "Переустановка"', "Нет подписи операции переустановки"); +requireText(sourceJs, '"/api/broray/reinstall.cgi"', "Не используется API переустановки"); +requireText(sourceJs, 'byId("reinstall-current").addEventListener("click", reinstallCurrent)', "Кнопка не привязана к обработчику"); +requireText(sourceApi, "/opt/broray/bin/broray-system reinstall-start", "API не запускает reinstall-start"); + +if (sourceJs !== builtJs) { + throw new Error("web-src и web-new содержат разные broray.js"); +} +if (sourceApi !== builtApi) { + throw new Error("web-src и web-new содержат разные reinstall.cgi"); +} +if (!builtHtml.includes('id="reinstall-current"')) { + throw new Error("Собранная страница не содержит кнопку переустановки"); +} +if ((sourceJs.match(/addEventListener\("click", reinstallCurrent\)/g) || []).length !== 1) { + throw new Error("Обработчик переустановки должен быть зарегистрирован ровно один раз"); +} + +console.log("PASS: интерфейс восстановительной переустановки"); diff --git a/root/opt/broray/tests/broray-support-ui-selftest.js b/root/opt/broray/tests/broray-support-ui-selftest.js new file mode 100755 index 0000000..1c6799b --- /dev/null +++ b/root/opt/broray/tests/broray-support-ui-selftest.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node +"use strict"; + +const fs = require("fs"); +const path = require("path"); + +const root = process.argv[2]; +if (!root) { + console.error("Usage: broray-support-ui-selftest.js "); + process.exit(2); +} + +function read(relativePath) { + return fs.readFileSync(path.join(root, relativePath), "utf8"); +} + +function assert(condition, message) { + if (!condition) { + console.error("FAIL: " + message); + process.exit(1); + } +} + +const shell = read("root/opt/broray/web-src/shell.html"); +const appShell = read("root/opt/broray/web-src/assets/js/app-shell.js"); +const page = read("root/opt/broray/web-src/pages/broray.html"); +const readme = read("README.md"); +const supportDoc = read("docs/support.md"); +const site = read("site/docs.brovibe.cloud/broray/index.html"); +const deploy = read("site/docs.brovibe.cloud/deploy.sh"); +const url = "https://pay.cloudtips.ru/p/09b23d0a"; + +assert(shell.includes('id="support-button"'), "в меню нет кнопки поддержки"); +assert(shell.includes('id="support-root"'), "нет общего окна поддержки"); +assert(shell.includes("/assets/images/support/cloudtips-qr.svg"), "общее окно не использует локальный QR"); +assert(appShell.includes("function openSupport()"), "окно поддержки не открывается"); +assert(appShell.includes("function closeSupport()"), "окно поддержки не закрывается"); +assert(page.includes('id="donate-link"'), "на странице BROray нет раскрываемого блока поддержки"); +assert(page.includes("/assets/images/support/cloudtips-qr.svg"), "на странице BROray нет QR"); +assert(readme.includes("docs/assets/cloudtips-qr.svg"), "README не содержит QR"); +assert(supportDoc.includes("assets/cloudtips-qr.svg"), "документ поддержки не содержит QR"); +assert(site.includes("/assets/cloudtips-qr.svg"), "сайт не содержит QR"); +assert(deploy.includes("assets/cloudtips-qr.svg"), "deploy.sh не публикует QR"); +for (const content of [shell, page, readme, supportDoc, site]) { + assert(content.includes(url), "один из материалов не содержит прямую ссылку CloudTips"); +} +console.log("PASS: support project UI and documentation"); diff --git a/root/opt/broray/tests/routes-api-operation-selftest.sh b/root/opt/broray/tests/routes-api-operation-selftest.sh new file mode 100755 index 0000000..0abde8d --- /dev/null +++ b/root/opt/broray/tests/routes-api-operation-selftest.sh @@ -0,0 +1,74 @@ +#!/bin/sh + +set -eu + +ROOT="${BRORAY_ROOT:-/opt/broray}" +LIBRARY="$ROOT/lib/routes-api-operation.sh" +WORK="${TMPDIR:-/tmp}/broray-routes-api-lock-selftest-$$" +LOCK="$WORK/routes/locks/api-operation.lock" + +cleanup() +{ + rm -rf "$WORK" +} + +fail() +{ + echo "BROray routes API operation lock self-test: FAIL — $*" >&2 + exit 1 +} + +trap cleanup EXIT HUP INT TERM + +[ -r "$LIBRARY" ] || fail "модуль блокировки API недоступен" +mkdir -p "$WORK/routes/locks" +BRORAY_ROUTES_API_LOCK="$LOCK" +export BRORAY_ROUTES_API_LOCK +. "$LIBRARY" + +broray_routes_api_lock_acquire "preflight:export" telegram || + fail "не удалось получить свободную блокировку" + +broray_routes_api_lock_read_json | + jq -e ' + .active == true and .stale == false and .scope == "routes" and + .action == "preflight:export" and .bundleId == "telegram" and + (.pid | type) == "number" and .startedAt != null + ' >/dev/null || fail "активная блокировка описана неверно" + +if BRORAY_ROUTES_API_LOCK="$LOCK" /opt/bin/ash -c ' + . "$1" + rc=0 + broray_routes_api_lock_acquire "download" whatsapp || rc=$? + [ "$rc" -eq 2 ] +' ash "$LIBRARY" +then + : +else + fail "параллельная операция не была отклонена" +fi + +broray_routes_api_lock_release +[ ! -d "$LOCK" ] || fail "блокировка не освобождена" + +mkdir -p "$LOCK" +printf '%s\n' 999999 >"$LOCK/pid" +printf '%s\n' verify >"$LOCK/action" +printf '%s\n' youtube >"$LOCK/bundle" +printf '%s\n' 2026-01-01T00:00:00+0000 >"$LOCK/startedAt" + +broray_routes_api_lock_read_json | + jq -e '.active == false and .stale == true and .bundleId == "youtube"' >/dev/null || + fail "устаревшая блокировка не распознана" + +broray_routes_api_lock_acquire "delete" youtube || + fail "устаревшая блокировка не восстановлена" +[ "$(sed -n '1p' "$LOCK/pid")" = "$$" ] || + fail "после восстановления записан неверный владелец" +broray_routes_api_lock_release + +broray_routes_api_lock_read_json | + jq -e '.active == false and .pid == null and .action == null' >/dev/null || + fail "состояние свободной блокировки неверно" + +echo "BROray routes API operation lock self-test: PASS" diff --git a/root/opt/broray/tests/routes-dot-api-selftest.sh b/root/opt/broray/tests/routes-dot-api-selftest.sh new file mode 100755 index 0000000..c409bcc --- /dev/null +++ b/root/opt/broray/tests/routes-dot-api-selftest.sh @@ -0,0 +1,54 @@ +#!/bin/sh +set -eu +SOURCE_ROOT="${BRORAY_ROOT:-/opt/broray}" +WORK="${TMPDIR:-/tmp}/broray-routes-dot-api-selftest-$$" +ROOT="$WORK/root" +cleanup(){ rm -rf "$WORK"; } +fail(){ echo "BROray DNS-over-TLS API self-test: FAIL — $*" >&2; exit 1; } +trap cleanup EXIT HUP INT TERM +mkdir -p "$ROOT/bin" "$ROOT/lib" "$ROOT/run" "$ROOT/tmp" "$ROOT/web-new/api/routes" +cp "$SOURCE_ROOT/lib/routes-api-operation.sh" "$ROOT/lib/" +cp "$SOURCE_ROOT/web-new/api/routes/dot-common.sh" "$ROOT/web-new/api/routes/" +for name in dot-status.cgi dot-test.cgi dot-apply.cgi dot-delete.cgi; do cp "$SOURCE_ROOT/web-new/api/routes/$name" "$ROOT/web-new/api/routes/$name"; done +cat >"$ROOT/web-new/api/auth-common.sh" <<'AUTH' +broray_api_require_method(){ [ "${REQUEST_METHOD:-GET}" = "$1" ] || exit 91; } +broray_api_require_session(){ return 0; } +broray_api_error(){ status="$1"; code="$2"; message="$3"; details="${4:-}"; jq -cn --arg status "$status" --arg code "$code" --arg message "$message" --arg details "$details" '{success:false,status:$status,data:null,error:{code:$code,message:$message,details:(if $details=="" then null else $details end)}}'; exit 0; } +broray_api_success(){ printf '%s\n' "$1" | jq '{success:true,data:.,error:null}'; exit 0; } +AUTH +cat >"$ROOT/bin/broray-routes-dot" <<'CLI' +#!/bin/sh +case "${1:-}" in + status) echo '{"schemaVersion":1,"installed":false,"servers":[],"actual":{"dot":[],"dohCount":0,"totalSecure":0}}' ;; + test|apply) + [ -r "${2:-}" ] || exit 2 + if [ "${DOT_FAKE_ERROR:-}" = confirm ]; then echo 'BRORAY_ERROR:DOT_TEST_CONFIRMATION_REQUIRED:Требуется подтверждение.' >&2; exit 1; fi + jq -n --arg action "$1" --slurpfile request "$2" '{schemaVersion:1,action:$action,serverIds:$request[0].serverIds}' ;; + delete) echo '{"schemaVersion":1,"action":"delete","managed":[]}' ;; + *) exit 2 ;; +esac +CLI +chmod 755 "$ROOT/bin/broray-routes-dot" "$ROOT/web-new/api/routes"/*.cgi + +output="$(BRORAY_ROOT="$ROOT" REQUEST_METHOD=GET /opt/bin/ash "$ROOT/web-new/api/routes/dot-status.cgi")" +printf '%s\n' "$output" | jq -e '.success==true and .data.schemaVersion==1' >/dev/null || fail "status CGI" + +body='{"serverIds":["google-primary","cloudflare-primary"],"allowUntested":false}' +output="$(printf '%s' "$body" | BRORAY_ROOT="$ROOT" REQUEST_METHOD=POST CONTENT_LENGTH="${#body}" /opt/bin/ash "$ROOT/web-new/api/routes/dot-test.cgi")" +printf '%s\n' "$output" | jq -e '.success==true and .data.action=="test" and (.data.serverIds|length)==2' >/dev/null || fail "test CGI" +[ ! -d "$ROOT/run/global-operation.lock" ] || fail "блокировка test не освобождена" + +mkdir -p "$ROOT/run/global-operation.lock" +printf '%s\n' "$$" >"$ROOT/run/global-operation.lock/pid" +printf '%s\n' routes >"$ROOT/run/global-operation.lock/scope" +printf '%s\n' export >"$ROOT/run/global-operation.lock/action" +printf '%s\n' telegram >"$ROOT/run/global-operation.lock/bundle" +printf '%s\n' now >"$ROOT/run/global-operation.lock/startedAt" +output="$(printf '%s' "$body" | BRORAY_ROOT="$ROOT" REQUEST_METHOD=POST CONTENT_LENGTH="${#body}" /opt/bin/ash "$ROOT/web-new/api/routes/dot-apply.cgi")" +printf '%s\n' "$output" | jq -e '.success==false and .error.code=="ROUTES_OPERATION_BUSY"' >/dev/null || fail "конфликтующая операция не отклонена" +rm -rf "$ROOT/run/global-operation.lock" + +output="$(printf '%s' "$body" | DOT_FAKE_ERROR=confirm BRORAY_ROOT="$ROOT" REQUEST_METHOD=POST CONTENT_LENGTH="${#body}" /opt/bin/ash "$ROOT/web-new/api/routes/dot-apply.cgi")" +printf '%s\n' "$output" | jq -e '.success==false and .error.code=="DOT_TEST_CONFIRMATION_REQUIRED" and .status=="409 Conflict"' >/dev/null || fail "ошибка подтверждения отображена неверно" + +echo 'BROray DNS-over-TLS API self-test: PASS' diff --git a/root/opt/broray/tests/routes-dot-selftest.sh b/root/opt/broray/tests/routes-dot-selftest.sh new file mode 100755 index 0000000..8d5c810 --- /dev/null +++ b/root/opt/broray/tests/routes-dot-selftest.sh @@ -0,0 +1,122 @@ +#!/bin/sh +set -eu + +ROOT="${BRORAY_ROOT:-/opt/broray}" +WORK="$ROOT/routes/tmp/routes-dot-selftest-$$" +DOT_ROOT="$WORK/dot" +MOCK="$WORK/mock-bin" +CONFIG="$WORK/running-config.txt" +FIXTURES="$WORK/fixtures" +CLI="$ROOT/bin/broray-routes-dot" + +cleanup() { rm -rf "$WORK"; } +fail() { echo "ОШИБКА routes-dot self-test: $*" >&2; exit 1; } +trap cleanup EXIT HUP INT TERM +mkdir -p "$MOCK" "$FIXTURES" "$DOT_ROOT" + +cat >"$MOCK/ndmc" <<'MOCK' +#!/bin/sh +set -eu +[ "${1:-}" = "-c" ] || exit 2 +cmd="${2:-}" +case "$cmd" in + 'show running-config') cat "$MOCK_NDMC_CONFIG" ;; + 'system configuration save') exit 0 ;; + dns-proxy\ tls\ upstream\ *) + [ "${MOCK_NDMC_FAIL_MATCH:-}" = "" ] || case "$cmd" in *"$MOCK_NDMC_FAIL_MATCH"*) exit 9 ;; esac + grep -Fx "$cmd" "$MOCK_NDMC_CONFIG" >/dev/null 2>&1 || printf '%s\n' "$cmd" >>"$MOCK_NDMC_CONFIG" + ;; + no\ dns-proxy\ tls\ upstream\ *) + [ "${MOCK_NDMC_FAIL_MATCH:-}" = "" ] || case "$cmd" in *"$MOCK_NDMC_FAIL_MATCH"*) exit 9 ;; esac + target="${cmd#no }" + grep -Fvx "$target" "$MOCK_NDMC_CONFIG" >"$MOCK_NDMC_CONFIG.new" || true + mv "$MOCK_NDMC_CONFIG.new" "$MOCK_NDMC_CONFIG" + ;; + *) exit 3 ;; +esac +MOCK +chmod 755 "$MOCK/ndmc" + +cat >"$FIXTURES/google-primary.json" <<'JSON' +{"ok":true,"status":"ok","latencyMs":31,"message":"OK"} +JSON +cat >"$FIXTURES/cloudflare-primary.json" <<'JSON' +{"ok":true,"status":"ok","latencyMs":24,"message":"OK"} +JSON +cat >"$FIXTURES/quad9.json" <<'JSON' +{"ok":false,"status":"failed","latencyMs":1000,"message":"timeout"} +JSON + +export BRORAY_ROOT="$ROOT" +export BRORAY_DOT_ROOT="$DOT_ROOT" +export BRORAY_DOT_CONFIG="$DOT_ROOT/config.json" +export BRORAY_DOT_STATE="$DOT_ROOT/state.json" +export BRORAY_DOT_NDMC="$MOCK/ndmc" +export BRORAY_DOT_TEST_FIXTURE_DIR="$FIXTURES" +export MOCK_NDMC_CONFIG="$CONFIG" + +cat >"$CONFIG" <<'CFG' +dns-proxy tls upstream 149.112.112.112 sni dns.quad9.net +dns-proxy https upstream https://example.test/dns-query dnsm +CFG + +cat >"$WORK/request.json" <<'JSON' +{"serverIds":["google-primary","cloudflare-primary"],"allowUntested":false} +JSON + +"$CLI" status >"$WORK/status.json" || fail "status" +jq -e '.actual.totalSecure==2 and .actual.dohCount==1 and (.managed|length)==0' "$WORK/status.json" >/dev/null || fail "неверный начальный статус" + +"$CLI" test "$WORK/request.json" >"$WORK/test.json" || fail "test" +jq -e '[.tests[]|select(.ok==true)]|length==2' "$WORK/test.json" >/dev/null || fail "TLS-тесты не сохранены" + +"$CLI" apply "$WORK/request.json" >"$WORK/apply.json" || fail "apply" +grep -Fx 'dns-proxy tls upstream 149.112.112.112 sni dns.quad9.net' "$CONFIG" >/dev/null || fail "чужая DoT-запись удалена" +grep -Fx 'dns-proxy https upstream https://example.test/dns-query dnsm' "$CONFIG" >/dev/null || fail "DoH-запись удалена" +grep -Fx 'dns-proxy tls upstream 8.8.8.8 sni dns.google' "$CONFIG" >/dev/null || fail "Google не добавлен" +grep -Fx 'dns-proxy tls upstream 1.1.1.1 sni cloudflare-dns.com' "$CONFIG" >/dev/null || fail "Cloudflare не добавлен" +jq -e '.installed==true and (.managed|length)==2 and .actual.totalSecure==4' "$WORK/apply.json" >/dev/null || fail "неверный статус после экспорта" + +cat >"$WORK/update.json" <<'JSON' +{"serverIds":["cloudflare-primary","quad9"],"allowUntested":true} +JSON +"$CLI" apply "$WORK/update.json" >"$WORK/update-result.json" || fail "update" +! grep -Fx 'dns-proxy tls upstream 8.8.8.8 sni dns.google' "$CONFIG" >/dev/null || fail "старая управляемая запись не удалена" +grep -Fx 'dns-proxy tls upstream 1.1.1.1 sni cloudflare-dns.com' "$CONFIG" >/dev/null || fail "сохраняемая запись пропала" +grep -Fx 'dns-proxy tls upstream 9.9.9.9 sni dns.quad9.net' "$CONFIG" >/dev/null || fail "Quad9 не добавлен" +grep -Fx 'dns-proxy tls upstream 149.112.112.112 sni dns.quad9.net' "$CONFIG" >/dev/null || fail "чужая запись повреждена при обновлении" + +"$CLI" delete >"$WORK/delete.json" || fail "delete" +! grep -Fx 'dns-proxy tls upstream 1.1.1.1 sni cloudflare-dns.com' "$CONFIG" >/dev/null || fail "управляемая Cloudflare не удалена" +! grep -Fx 'dns-proxy tls upstream 9.9.9.9 sni dns.quad9.net' "$CONFIG" >/dev/null || fail "управляемая Quad9 не удалена" +grep -Fx 'dns-proxy tls upstream 149.112.112.112 sni dns.quad9.net' "$CONFIG" >/dev/null || fail "чужая запись удалена вместе с BROray" +jq -e '.installed==false and (.managed|length)==0' "$WORK/delete.json" >/dev/null || fail "неверный статус после удаления" + +# Лимит восьми защищённых DNS-серверов. +: >"$CONFIG" +i=1 +while [ "$i" -le 7 ]; do printf 'dns-proxy tls upstream 192.0.2.%s sni external%s.example\n' "$i" "$i" >>"$CONFIG"; i=$((i+1)); done +printf '%s\n' 'dns-proxy https upstream https://external.example/dns-query dnsm' >>"$CONFIG" +if "$CLI" apply "$WORK/request.json" >"$WORK/limit.out" 2>"$WORK/limit.err"; then fail "экспорт сверх лимита разрешён"; fi +grep -F 'DOT_LIMIT_EXCEEDED' "$WORK/limit.err" >/dev/null || fail "неверная ошибка лимита" +[ "$(wc -l <"$CONFIG" | tr -d ' ')" = 8 ] || fail "конфигурация изменилась при ошибке лимита" + +# Откат частично выполненного обновления. +cat >"$CONFIG" <<'CFG' +dns-proxy tls upstream 149.112.112.112 sni dns.quad9.net +CFG +rm -f "$BRORAY_DOT_CONFIG" "$BRORAY_DOT_STATE" +"$CLI" test "$WORK/request.json" >/dev/null || fail "повторный test" +"$CLI" apply "$WORK/request.json" >/dev/null || fail "подготовительный apply" +cat >"$WORK/failing.json" <<'JSON' +{"serverIds":["quad9"],"allowUntested":true} +JSON +export MOCK_NDMC_FAIL_MATCH='9.9.9.9' +if "$CLI" apply "$WORK/failing.json" >"$WORK/fail.out" 2>"$WORK/fail.err"; then fail "ошибочный apply завершился успешно"; fi +unset MOCK_NDMC_FAIL_MATCH +grep -Fx 'dns-proxy tls upstream 8.8.8.8 sni dns.google' "$CONFIG" >/dev/null || fail "Google не восстановлен откатом" +grep -Fx 'dns-proxy tls upstream 1.1.1.1 sni cloudflare-dns.com' "$CONFIG" >/dev/null || fail "Cloudflare не восстановлен откатом" +grep -Fx 'dns-proxy tls upstream 149.112.112.112 sni dns.quad9.net' "$CONFIG" >/dev/null || fail "чужая запись пропала при откате" +jq -e '.lastOperation.success==false and .lastOperation.rolledBack==true' "$BRORAY_DOT_STATE" >/dev/null || fail "откат не зафиксирован" + +echo 'BROray DNS-over-TLS self-test: PASS' diff --git a/root/opt/broray/tests/routes-dot-ui-selftest.js b/root/opt/broray/tests/routes-dot-ui-selftest.js new file mode 100755 index 0000000..19e6630 --- /dev/null +++ b/root/opt/broray/tests/routes-dot-ui-selftest.js @@ -0,0 +1,40 @@ +#!/usr/bin/env node +"use strict"; +const fs = require("fs"); +const vm = require("vm"); +const path = process.argv[2] || "/opt/broray/web-new/assets/js/routes-dot.js"; +function fail(message) { console.error("BROray DNS-over-TLS UI self-test: FAIL — " + message); process.exit(1); } +const sandbox = { + window: {BROrayDotTestMode: true}, + document: {addEventListener: function () {}, getElementById: function () { return null; }}, + console, Date, Math, Number, String, Boolean, Array, Object, Promise, setTimeout, clearTimeout +}; +sandbox.window.window = sandbox.window; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(path, "utf8"), sandbox, {filename:path}); +const hooks = sandbox.window.BROrayDotTestHooks; +if (!hooks) fail("тестовые функции не экспортированы"); +const now = 2000000; +const fresh = {ok:true,testedEpoch:(now-1000)/1000,latencyMs:25}; +const stale = {ok:true,testedEpoch:(now-700000)/1000,latencyMs:25}; +if (!hooks.serverTestFresh(fresh, now)) fail("свежая проверка не распознана"); +if (hooks.serverTestFresh(stale, now)) fail("устаревшая проверка признана свежей"); +const base = { + runningConfigAvailable:true, + drift:false, + managed:[], + servers:[ + {id:"google",address:"8.8.8.8",sni:"dns.google",present:false,test:fresh}, + {id:"cloudflare",address:"1.1.1.1",sni:"cloudflare-dns.com",present:false,test:fresh} + ] +}; +if (hooks.recommendedAction(base,["google","cloudflare"],now)!=="apply") fail("проверенный набор не предлагает экспорт"); +const installed = JSON.parse(JSON.stringify(base)); +installed.servers.forEach(s=>s.present=true); +installed.managed=installed.servers.map(s=>({address:s.address,sni:s.sni})); +if (hooks.recommendedAction(installed,["google","cloudflare"],now)!=="test") fail("исправная конфигурация не предлагает следующую проверку"); +const drift = JSON.parse(JSON.stringify(installed)); drift.drift=true; drift.servers[0].test=stale; +if (hooks.recommendedAction(drift,["google","cloudflare"],now)!=="test") fail("дрейф с устаревшей проверкой не предлагает тест"); +if (hooks.recommendedAction({runningConfigAvailable:false,servers:[]},["google"],now)!=="refresh") fail("недоступный Keenetic не предлагает обновить состояние"); +if (hooks.testPresentation({test:{ok:false,status:"failed",testedEpoch:Date.now()/1000}}).className!=="is-error") fail("ошибка TLS оформлена неверно"); +console.log("BROray DNS-over-TLS UI self-test: PASS"); diff --git a/root/opt/broray/tests/routes-global-operation-selftest.sh b/root/opt/broray/tests/routes-global-operation-selftest.sh new file mode 100755 index 0000000..3cebcd2 --- /dev/null +++ b/root/opt/broray/tests/routes-global-operation-selftest.sh @@ -0,0 +1,76 @@ +#!/bin/sh + +set -eu + +SOURCE_ROOT="${BRORAY_ROOT:-/opt/broray}" +ROUTES_LIBRARY="$SOURCE_ROOT/lib/routes-api-operation.sh" +SYSTEM_LIBRARY="$SOURCE_ROOT/lib/broray-page.sh" +WORK="${TMPDIR:-/tmp}/broray-global-operation-selftest-$$" +TEST_ROOT="$WORK/root" +GLOBAL_LOCK="$TEST_ROOT/run/global-operation.lock" + +cleanup() +{ + rm -rf "$WORK" +} + +fail() +{ + echo "BROray global operation lock self-test: FAIL — $*" >&2 + exit 1 +} + +trap cleanup EXIT HUP INT TERM + +[ -r "$ROUTES_LIBRARY" ] || fail "модуль блокировки маршрутов недоступен" +[ -r "$SYSTEM_LIBRARY" ] || fail "backend страницы BROray недоступен" +mkdir -p "$TEST_ROOT/run" + +BRORAY_BASE="$TEST_ROOT" +BRORAY_GLOBAL_LOCK="$GLOBAL_LOCK" +export BRORAY_BASE BRORAY_GLOBAL_LOCK +. "$SYSTEM_LIBRARY" + +BRORAY_ROOT="$TEST_ROOT" +BRORAY_ROUTES_API_LOCK="$GLOBAL_LOCK" +export BRORAY_ROOT BRORAY_ROUTES_API_LOCK +. "$ROUTES_LIBRARY" + +broray_system_global_lock_acquire update || + fail "системная операция не получила общую блокировку" + +broray_routes_api_lock_read_json | + jq -e '.active == true and .scope == "system" and .action == "update" and .bundleId == null' >/dev/null || + fail "системная блокировка не видна странице маршрутов" + +rc=0 +broray_routes_api_lock_acquire export telegram || rc=$? +[ "$rc" -eq 2 ] || fail "маршруты не заблокированы системной операцией" +broray_system_global_lock_release +[ ! -d "$GLOBAL_LOCK" ] || fail "системная блокировка не освобождена" + +broray_routes_api_lock_acquire export telegram || + fail "операция маршрутов не получила общую блокировку" +broray_routes_api_lock_read_json | + jq -e '.active == true and .scope == "routes" and .action == "export" and .bundleId == "telegram"' >/dev/null || + fail "блокировка маршрутов описана неверно" + +rc=0 +broray_system_global_lock_acquire restore || rc=$? +[ "$rc" -eq 2 ] || fail "системная операция не заблокирована маршрутами" +broray_routes_api_lock_release +[ ! -d "$GLOBAL_LOCK" ] || fail "блокировка маршрутов не освобождена" + +mkdir -p "$GLOBAL_LOCK" +printf '%s\n' 999999 >"$GLOBAL_LOCK/pid" +printf '%s\n' system >"$GLOBAL_LOCK/scope" +printf '%s\n' update >"$GLOBAL_LOCK/action" +: >"$GLOBAL_LOCK/bundle" +printf '%s\n' 2026-01-01T00:00:00Z >"$GLOBAL_LOCK/startedAt" +broray_system_global_lock_acquire uninstall || + fail "устаревшая общая блокировка не восстановлена" +[ "$(sed -n '1p' "$GLOBAL_LOCK/action")" = uninstall ] || + fail "после восстановления записано неверное действие" +broray_system_global_lock_release + +echo "BROray global operation lock self-test: PASS" diff --git a/root/opt/broray/tests/routes-operation-preflight-api-selftest.sh b/root/opt/broray/tests/routes-operation-preflight-api-selftest.sh new file mode 100755 index 0000000..bda570d --- /dev/null +++ b/root/opt/broray/tests/routes-operation-preflight-api-selftest.sh @@ -0,0 +1,130 @@ +#!/bin/sh + +set -eu + +SOURCE_ROOT="${BRORAY_ROOT:-/opt/broray}" +PREFLIGHT_CGI="${1:-$SOURCE_ROOT/web-new/api/routes/preflight.cgi}" +WORK="${TMPDIR:-/tmp}/broray-routes-preflight-api-selftest-$$" +ROOT="$WORK/root" + +cleanup() +{ + rm -rf "$WORK" +} + +fail() +{ + echo "BROray routes preflight API self-test: FAIL — $*" >&2 + exit 1 +} + +trap cleanup EXIT HUP INT TERM + +[ -r "$PREFLIGHT_CGI" ] || fail "CGI предварительной проверки недоступен" +mkdir -p \ + "$ROOT/bin" "$ROOT/lib" "$ROOT/run" \ + "$ROOT/routes/state" "$ROOT/routes/installed/bundles" \ + "$ROOT/routes/catalog/demo" "$ROOT/routes/preflight" \ + "$ROOT/web-new/api" + +cp "$SOURCE_ROOT/lib/routes-api-operation.sh" "$ROOT/lib/routes-api-operation.sh" +cp "$SOURCE_ROOT/lib/routes-operation-preflight.sh" "$ROOT/lib/routes-operation-preflight.sh" +chmod 755 "$ROOT/lib/routes-api-operation.sh" "$ROOT/lib/routes-operation-preflight.sh" + +cat >"$ROOT/web-new/api/auth-common.sh" <<'AUTH' +broray_api_require_method() +{ + [ "${REQUEST_METHOD:-}" = "$1" ] || exit 1 +} +broray_api_require_session() +{ + return 0 +} +broray_api_error() +{ + status="$1" + code="$2" + message="$3" + details="${4:-}" + jq -cn --arg status "$status" --arg code "$code" --arg message "$message" --arg details "$details" \ + '{success:false,status:$status,data:null,error:{code:$code,message:$message,details:$details}}' + exit 0 +} +broray_api_success() +{ + printf '%s\n' "$1" + exit 0 +} +AUTH + +cat >"$ROOT/routes/bundles.json" <<'JSON' +{"schemaVersion":1,"bundles":["demo"]} +JSON +cat >"$ROOT/routes/state/demo.json" <<'JSON' +{"schemaVersion":1,"bundleId":"demo","downloadedVersion":{"contentSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}} +JSON +cat >"$ROOT/routes/installed/bundles/demo.json" <<'JSON' +{"schemaVersion":1,"bundleId":"demo","installedVersion":null,"routeKeys":[],"managedRouteKeys":[],"externalRouteKeys":[],"targetInterface":"Proxy0","managedMetric":1200} +JSON + +cat >"$ROOT/bin/broray-routes" <<'CLI' +#!/bin/sh +[ "${1:-}" = plan ] && [ "${2:-}" = demo ] || exit 1 +cat <<'JSON' +{ + "schemaVersion":1, + "bundleId":"demo", + "mode":"install", + "targetInterface":"Proxy0", + "managedMetric":1200, + "contentSha256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "canApply":true, + "summary":{"total":2,"managedExisting":0,"toCreate":2,"toDelete":0,"sharedKept":0,"alreadyAbsent":0,"conflicts":0,"externalExisting":0,"unchangedRoutes":0,"addedRoutes":2,"removedRoutes":0} +} +JSON +CLI +chmod 755 "$ROOT/bin/broray-routes" + +cat >"$ROOT/bin/ndmc" <<'NDMC' +#!/bin/sh +[ "${1:-}" = -c ] && [ "${2:-}" = "show interface Proxy0" ] || exit 1 +printf '%s\n' 'id: Proxy0' 'connected: yes' 'state: up' +NDMC +chmod 755 "$ROOT/bin/ndmc" + +output="$( + BRORAY_ROOT="$ROOT" \ + BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC="$ROOT/bin/ndmc" \ + REQUEST_METHOD=POST \ + QUERY_STRING='bundleId=demo&action=export' \ + /opt/bin/ash "$PREFLIGHT_CGI" +)" +printf '%s\n' "$output" | + jq -e ' + .schemaVersion == 1 and .bundleId == "demo" and + .requestedAction == "export" and .operation == "install" and + .ready == true and .summary.total == 2 and .summary.toCreate == 2 and + .checks.operationLock.ok == true and .checks.keenetic.ok == true and + (.token | length) == 64 + ' >/dev/null || fail "CGI вернул неверную предварительную проверку" +[ ! -d "$ROOT/run/global-operation.lock" ] || fail "CGI не освободил общую блокировку" + +mkdir -p "$ROOT/run/global-operation.lock" +printf '%s\n' "$$" >"$ROOT/run/global-operation.lock/pid" +printf '%s\n' routes >"$ROOT/run/global-operation.lock/scope" +printf '%s\n' export >"$ROOT/run/global-operation.lock/action" +printf '%s\n' telegram >"$ROOT/run/global-operation.lock/bundle" +printf '%s\n' 2026-01-01T00:00:00+0000 >"$ROOT/run/global-operation.lock/startedAt" + +output="$( + BRORAY_ROOT="$ROOT" \ + BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC="$ROOT/bin/ndmc" \ + REQUEST_METHOD=POST \ + QUERY_STRING='bundleId=demo&action=export' \ + /opt/bin/ash "$PREFLIGHT_CGI" +)" +printf '%s\n' "$output" | + jq -e '.success == false and .error.code == "ROUTES_OPERATION_BUSY"' >/dev/null || + fail "CGI не отклонил конфликтующую операцию" + +echo "BROray routes preflight API self-test: PASS" diff --git a/root/opt/broray/tests/routes-operation-preflight-selftest.sh b/root/opt/broray/tests/routes-operation-preflight-selftest.sh new file mode 100755 index 0000000..6005a1e --- /dev/null +++ b/root/opt/broray/tests/routes-operation-preflight-selftest.sh @@ -0,0 +1,253 @@ +#!/bin/sh + +set -eu + +SOURCE_ROOT="${BRORAY_ROOT:-/opt/broray}" +LIBRARY="$SOURCE_ROOT/lib/routes-operation-preflight.sh" +WORK="${TMPDIR:-/tmp}/broray-routes-operation-preflight-selftest-$$" +ROOT="$WORK/root" +ROUTES="$ROOT/routes" +PLAN="$WORK/plan.json" +RESULT="$WORK/result.json" +MOCK_NDMC="$WORK/ndmc" +MOCK_CONFIG_LIBRARY="$WORK/routes-router-config.sh" + +cleanup() +{ + rm -rf "$WORK" +} + +fail() +{ + echo "BROray routes operation preflight self-test: FAIL — $*" >&2 + exit 1 +} + +expect_rc() +{ + expected="$1" + shift + rc=0 + "$@" || rc=$? + [ "$rc" -eq "$expected" ] || + fail "ожидался код $expected, получен $rc: $*" +} + +trap cleanup EXIT HUP INT TERM + +[ -r "$LIBRARY" ] || fail "модуль предварительной проверки недоступен" +mkdir -p \ + "$ROUTES/catalog/demo" \ + "$ROUTES/state" \ + "$ROUTES/installed/bundles" \ + "$ROUTES/operations" \ + "$ROUTES/preflight" + +cat >"$MOCK_NDMC" <<'NDMC' +#!/bin/sh +if [ "${1:-}" = "-c" ] && [ "${2:-}" = "show interface Proxy0" ]; then + cat <<'OUT' +id: Proxy0 +connected: yes +state: up +OUT + exit 0 +fi +exit 1 +NDMC +chmod 755 "$MOCK_NDMC" + +cat >"$MOCK_CONFIG_LIBRARY" <<'LIB' +broray_routes_config_fetch() +{ + cp "$PREFLIGHT_ACTUAL_FIXTURE" "$1" +} +LIB + +cat >"$ROUTES/state/demo.json" <<'JSON' +{ + "schemaVersion": 1, + "bundleId": "demo", + "downloadedVersion": {"contentSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} +} +JSON +cat >"$ROUTES/installed/bundles/demo.json" <<'JSON' +{ + "schemaVersion": 1, + "bundleId": "demo", + "installedVersion": {"contentSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}, + "routeKeys": ["r1", "r2", "r3"], + "managedRouteKeys": ["r1", "r2", "r3"], + "externalRouteKeys": [], + "targetInterface": "Proxy0", + "managedMetric": 1200 +} +JSON +cat >"$ROUTES/config.json" <<'JSON' +{ + "schemaVersion": 1, + "managedInterface": "Proxy0", + "managedMetric": 1200 +} +JSON +cat >"$ROUTES/installed/routes.json" <<'JSON' +{ + "schemaVersion": 1, + "managedInterface": "Proxy0", + "managedMetric": 1200, + "routes": [ + {"key":"r1","network":"198.51.100.0","prefix":24,"mask":"255.255.255.0","interface":"Proxy0","metric":1200,"createdByBROray":true,"managed":true,"owners":["demo"]}, + {"key":"r2","network":"203.0.113.0","prefix":24,"mask":"255.255.255.0","interface":"Proxy0","metric":1200,"createdByBROray":true,"managed":true,"owners":["demo","shared"]}, + {"key":"r3","network":"192.0.2.0","prefix":24,"mask":"255.255.255.0","interface":"Proxy0","metric":1200,"createdByBROray":true,"managed":true,"owners":["demo"]} + ] +} +JSON +cat >"$WORK/actual.json" <<'JSON' +{ + "schemaVersion": 1, + "routes": [ + {"network":"198.51.100.0","prefix":24,"mask":"255.255.255.0","interface":"Proxy0","gateway":"0.0.0.0","metric":1200,"proto":"static"}, + {"network":"203.0.113.0","prefix":24,"mask":"255.255.255.0","interface":"Proxy0","gateway":"0.0.0.0","metric":1200,"proto":"static"} + ] +} +JSON +cat >"$PLAN" <<'JSON' +{ + "schemaVersion": 1, + "bundleId": "demo", + "mode": "install", + "targetInterface": "Proxy0", + "managedMetric": 1200, + "contentSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "canApply": true, + "summary": { + "total": 3, + "managedExisting": 1, + "toCreate": 2, + "toDelete": 0, + "sharedKept": 0, + "alreadyAbsent": 0, + "conflicts": 0, + "externalExisting": 0, + "unchangedRoutes": 1, + "addedRoutes": 2, + "removedRoutes": 0 + } +} +JSON + +BRORAY_ROOT="$ROOT" +BRORAY_ROUTES_ROOT="$ROUTES" +BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR="$ROUTES/preflight" +BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC="$MOCK_NDMC" +BRORAY_ROUTES_OPERATION_PREFLIGHT_CONFIG_LIBRARY="$MOCK_CONFIG_LIBRARY" +PREFLIGHT_ACTUAL_FIXTURE="$WORK/actual.json" +export BRORAY_ROOT BRORAY_ROUTES_ROOT BRORAY_ROUTES_OPERATION_PREFLIGHT_DIR +export BRORAY_ROUTES_OPERATION_PREFLIGHT_NDMC BRORAY_ROUTES_OPERATION_PREFLIGHT_CONFIG_LIBRARY +export PREFLIGHT_ACTUAL_FIXTURE +. "$LIBRARY" + +export_values="$(broray_routes_operation_preflight_resolve_action demo export)" +[ "$(printf '%s' "$export_values" | cut -f1)" = export ] && +[ -z "$(printf '%s' "$export_values" | cut -f2)" ] || + fail "обычная установка получила неверный сохранённый тип операции" + +broray_routes_operation_preflight_finalize demo export export install "$PLAN" "$RESULT" || + fail "готовая предварительная проверка не сформирована" + +jq -e ' + .schemaVersion == 1 and .ready == true and + .requestedAction == "export" and .resolvedAction == "export" and + .operation == "install" and .summary.total == 3 and + .summary.toCreate == 2 and .checks.operationLock.ok == true and + .checks.ndmc.ok == true and .checks.keenetic.ok == true and + .checks.storage.ok == true and .checks.localSet.ok == true and + (.token | length) == 64 +' "$RESULT" >/dev/null || fail "результат готовой проверки неверен" + +token="$(jq -r '.token' "$RESULT")" +broray_routes_operation_preflight_validate demo export "$token" || + fail "актуальный токен не принят" +[ ! -e "$ROUTES/preflight/demo.json" ] || fail "использованный токен не удалён" + +broray_routes_operation_preflight_finalize demo export export install "$PLAN" "$RESULT" || + fail "повторная проверка не сформирована" +token="$(jq -r '.token' "$RESULT")" +file="$ROUTES/preflight/demo.json" +jq '.checkedEpoch = 0' "$file" >"$file.new" && mv "$file.new" "$file" +expect_rc 3 broray_routes_operation_preflight_validate demo export "$token" + +broray_routes_operation_preflight_finalize demo export export install "$PLAN" "$RESULT" || + fail "проверка перед изменением набора не сформирована" +token="$(jq -r '.token' "$RESULT")" +jq '.downloadedVersion.contentSha256 = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"' \ + "$ROUTES/state/demo.json" >"$ROUTES/state/demo.json.new" && + mv "$ROUTES/state/demo.json.new" "$ROUTES/state/demo.json" +expect_rc 4 broray_routes_operation_preflight_validate demo export "$token" +jq '.downloadedVersion.contentSha256 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"' \ + "$ROUTES/state/demo.json" >"$ROUTES/state/demo.json.new" && + mv "$ROUTES/state/demo.json.new" "$ROUTES/state/demo.json" + +cat >"$ROUTES/operations/demo.json" <<'JSON' +{ + "schemaVersion": 2, + "bundleId": "demo", + "operation": "install", + "current": 1, + "total": 3, + "running": false, + "resumable": true +} +JSON +resume_values="$(broray_routes_operation_preflight_resolve_action demo resume)" +[ "$(printf '%s' "$resume_values" | cut -f1)" = export ] && +[ "$(printf '%s' "$resume_values" | cut -f2)" = install ] || + fail "продолжение установки разрешено неверно" +broray_routes_operation_preflight_finalize demo resume export install "$PLAN" "$RESULT" || + fail "проверка продолжения не сформирована" +jq -e '.ready == true and .resume.operation == "install" and .resume.current == 1 and .resume.total == 3' \ + "$RESULT" >/dev/null || fail "параметры продолжения неверны" +token="$(jq -r '.token' "$RESULT")" +jq '.current = 2' "$ROUTES/operations/demo.json" >"$ROUTES/operations/demo.json.new" && + mv "$ROUTES/operations/demo.json.new" "$ROUTES/operations/demo.json" +expect_rc 4 broray_routes_operation_preflight_validate demo resume "$token" + +PREFLIGHT_ACTUAL_FIXTURE="$WORK/actual.json" +export PREFLIGHT_ACTUAL_FIXTURE +broray_routes_operation_preflight_delete_plan demo "$WORK/delete-plan.json" || + fail "план удаления не сформирован" +jq -e ' + .canApply == true and .mode == "delete" and + .summary.total == 3 and .summary.toDelete == 1 and + .summary.sharedKept == 1 and .summary.alreadyAbsent == 1 and + .summary.conflicts == 0 +' "$WORK/delete-plan.json" >/dev/null || fail "сводка предварительного удаления неверна" + +jq '(.routes[] | select(.key == "r1") | .owners) = []' \ + "$ROUTES/installed/routes.json" >"$ROUTES/installed/routes.json.new" && + mv "$ROUTES/installed/routes.json.new" "$ROUTES/installed/routes.json" +broray_routes_operation_preflight_delete_plan demo "$WORK/delete-conflict.json" || + fail "конфликтный план удаления не сформирован" +jq -e '.canApply == false and .summary.conflicts == 1' "$WORK/delete-conflict.json" >/dev/null || + fail "конфликт владения не заблокировал удаление" + +cat >"$WORK/blocked-plan.json" <<'JSON' +{ + "schemaVersion": 1, + "bundleId": "demo", + "mode": "install", + "targetInterface": "Proxy0", + "managedMetric": 1200, + "contentSha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "canApply": false, + "summary": {"total":3,"managedExisting":0,"toCreate":0,"toDelete":0,"sharedKept":0,"alreadyAbsent":0,"conflicts":1,"externalExisting":0,"unchangedRoutes":0,"addedRoutes":0,"removedRoutes":0} +} +JSON +broray_routes_operation_preflight_finalize demo export export install "$WORK/blocked-plan.json" "$RESULT" || + fail "заблокированная проверка не сформирована" +jq -e '.ready == false and .checks.ownership.ok == false and .summary.conflicts == 1' "$RESULT" >/dev/null || + fail "конфликтный план ошибочно разрешён" + +expect_rc 2 broray_routes_operation_preflight_validate demo export "$(jq -r '.token' "$RESULT")" + +echo "BROray routes operation preflight self-test: PASS" diff --git a/root/opt/broray/tests/routes-operation-progress-selftest.sh b/root/opt/broray/tests/routes-operation-progress-selftest.sh new file mode 100755 index 0000000..0991d3e --- /dev/null +++ b/root/opt/broray/tests/routes-operation-progress-selftest.sh @@ -0,0 +1,120 @@ +#!/bin/sh + +set -eu + +ROOT="${BRORAY_ROOT:-/opt/broray}" +LIBRARY="$ROOT/lib/routes-operation-progress.sh" +WORK="${TMPDIR:-/tmp}/broray-routes-progress-selftest-$$" + +cleanup() +{ + rm -rf "$WORK" +} + +fail() +{ + echo "ОШИБКА self-test: $*" >&2 + exit 1 +} + +trap cleanup EXIT HUP INT TERM + +[ -r "$LIBRARY" ] || fail "Модуль прогресса недоступен" +mkdir -p "$WORK" +BRORAY_ROUTES_PROGRESS_DIR="$WORK/operations" +export BRORAY_ROUTES_PROGRESS_DIR +. "$LIBRARY" + +broray_routes_progress_begin telegram install 3 \ + "Подготовка установки." || fail "Не удалось начать операцию" + +FILE="$BRORAY_ROUTES_PROGRESS_DIR/telegram.json" +[ -r "$FILE" ] || fail "Файл прогресса не создан" +jq -e ' + .bundleId == "telegram" and + .operation == "install" and + .phase == "preparing" and + .current == 0 and .total == 3 and .percent == 0 and + .running == true and .success == null +' "$FILE" >/dev/null || fail "Некорректное начальное состояние" + +broray_routes_progress_update applying 1 3 \ + "Установка маршрутов в Keenetic." "198.51.100.0/24" || + fail "Не удалось обновить прогресс" + +jq -e ' + .phase == "applying" and + .current == 1 and .total == 3 and .percent == 33 and + .currentRoute == "198.51.100.0/24" and .running == true +' "$FILE" >/dev/null || fail "Некорректное промежуточное состояние" + +broray_routes_progress_complete "Установлено 3 из 3." || + fail "Не удалось завершить прогресс" + +jq -e ' + .phase == "completed" and + .current == 3 and .total == 3 and .percent == 100 and + .running == false and .success == true and .completedAt != null +' "$FILE" >/dev/null || fail "Некорректное итоговое состояние" + +broray_routes_progress_begin telegram delete 4 \ + "Подготовка удаления." || fail "Не удалось начать удаление" +broray_routes_progress_update applying 2 4 \ + "Удаление маршрутов из Keenetic." "203.0.113.0/24" || + fail "Не удалось обновить удаление" +broray_routes_progress_fail "Ошибка; изменения отменены." true || + fail "Не удалось записать откат" + +jq -e ' + .operation == "delete" and + .phase == "rolled_back" and + .current == 2 and .total == 4 and .percent == 50 and + .running == false and .success == false and .rolledBack == true +' "$FILE" >/dev/null || fail "Некорректное состояние отката" + +broray_routes_progress_read telegram | + jq -e '.bundleId == "telegram" and .running == false' >/dev/null || + fail "Чтение прогресса не прошло проверку" + +broray_routes_progress_read whatsapp | + jq -e '.bundleId == "whatsapp" and .phase == "idle"' >/dev/null || + fail "Не возвращено состояние ожидания" + +broray_routes_progress_begin telegram install 5 \ + "Подготовка безопасной остановки." || fail "Не удалось начать тест остановки" +broray_routes_progress_tick 2 "192.0.2.0/24" || fail "Не удалось записать выполненную работу" +broray_routes_progress_request_stop telegram || fail "Не удалось запросить остановку" +broray_routes_progress_stop_requested telegram || fail "Запрос остановки не обнаружен" +broray_routes_progress_pause "Остановлено: 2 из 5." true "" || fail "Не удалось приостановить операцию" + +jq -e ' + .schemaVersion == 2 and .phase == "paused" and + .current == 2 and .total == 5 and .percent == 40 and + .running == false and .success == null and + .resumable == true and .stoppedByUser == true and + .stopRequested == false +' "$FILE" >/dev/null || fail "Некорректное состояние безопасной остановки" + +resume_values="$(broray_routes_progress_resume_values telegram install 3)" +[ "$(printf '%s' "$resume_values" | cut -f1)" = 2 ] || fail "Не восстановлен счётчик продолжения" +[ "$(printf '%s' "$resume_values" | cut -f2)" = 5 ] || fail "Не восстановлена исходная цель" +[ "$(printf '%s' "$resume_values" | cut -f3)" = true ] || fail "Продолжение не распознано" + +broray_routes_progress_begin telegram install 5 "Продолжение: 2 из 5." 2 true || + fail "Не удалось продолжить операцию" +broray_routes_progress_tick 5 "198.51.100.0/24" || fail "Не удалось завершить продолженную работу" +broray_routes_progress_complete "Установлено 5 из 5." || fail "Не удалось завершить продолженную операцию" +jq -e '.phase == "completed" and .current == 5 and .total == 5 and .resumed == true and .resumable == false' \ + "$FILE" >/dev/null || fail "Некорректный результат продолжения" + +broray_routes_progress_begin telegram delete 4 "Тест ошибки." || fail "Не удалось начать тест ошибки" +broray_routes_progress_tick 1 "203.0.113.0/24" || fail "Не удалось записать работу до ошибки" +broray_routes_progress_pause "Ошибка на следующем маршруте." false "203.0.114.0/24" || + fail "Не удалось сохранить возобновляемую ошибку" +jq -e ' + .phase == "failed_resumable" and .current == 1 and .total == 4 and + .success == false and .resumable == true and .stoppedByUser == false and + .errorRoute == "203.0.114.0/24" +' "$FILE" >/dev/null || fail "Некорректное состояние ошибки с продолжением" + +echo "OK: progress routes self-test" diff --git a/root/opt/broray/tests/routes-r9-export-chain-selftest.sh b/root/opt/broray/tests/routes-r9-export-chain-selftest.sh index 32e9843..03e9711 100755 --- a/root/opt/broray/tests/routes-r9-export-chain-selftest.sh +++ b/root/opt/broray/tests/routes-r9-export-chain-selftest.sh @@ -29,17 +29,10 @@ broray_routes_export_build_run() } EOF -cat >"$WORK/lib/routes-router-preflight.sh" <<'EOF' -broray_routes_preflight_run() +cat >"$WORK/lib/routes-router-sync.sh" <<'EOF' +broray_routes_sync_apply() { - echo preflight >>"$BRORAY_ROOT/order.log" -} -EOF - -cat >"$WORK/lib/routes-router-export.sh" <<'EOF' -broray_routes_router_export_run() -{ - echo export >>"$BRORAY_ROOT/order.log" + echo sync-apply >>"$BRORAY_ROOT/order.log" } EOF @@ -49,10 +42,22 @@ broray_routes_download_run() { :; } EOF cat >"$WORK/lib/routes-router-delete.sh" <<'EOF' -broray_routes_router_delete_run() { :; } +broray_routes_router_delete_run() { echo delete-run >>"$BRORAY_ROOT/order.log"; } broray_routes_delete_cleanup() { :; } EOF +cat >"$WORK/lib/routes-operation-progress.sh" <<'EOF' +broray_routes_progress_request_stop() { echo stop-request >>"$BRORAY_ROOT/order.log"; } +broray_routes_progress_read() +{ + if [ -f "$BRORAY_ROOT/resume-delete" ]; then + echo '{"operation":"delete","running":false,"resumable":true}' + else + echo '{"operation":"install","running":false,"resumable":true}' + fi +} +EOF + : >"$WORK/order.log" if [ -n "${BRORAY_ASH_BIN:-}" ] && [ -x "$BRORAY_ASH_BIN" ]; then @@ -69,10 +74,28 @@ else "$WORK/bin/broray-routes" export tiktok fi +if [ -n "${BRORAY_ASH_BIN:-}" ] && [ -x "$BRORAY_ASH_BIN" ]; then + RUN_ASH="$BRORAY_ASH_BIN" +elif [ -x /opt/bin/ash ]; then + RUN_ASH=/opt/bin/ash +elif command -v ash >/dev/null 2>&1; then + RUN_ASH="$(command -v ash)" +else + RUN_ASH="$(command -v busybox) ash" +fi + +BRORAY_ROOT="$WORK" $RUN_ASH "$WORK/bin/broray-routes" stop tiktok >/dev/null +BRORAY_ROOT="$WORK" $RUN_ASH "$WORK/bin/broray-routes" resume tiktok >/dev/null +: >"$WORK/resume-delete" +BRORAY_ROOT="$WORK" $RUN_ASH "$WORK/bin/broray-routes" resume tiktok >/dev/null + cat >"$WORK/expected.log" <<'EOF' build-export -preflight -export +sync-apply +stop-request +build-export +sync-apply +delete-run EOF diff -u "$WORK/expected.log" "$WORK/order.log" diff --git a/root/opt/broray/tests/routes-resume-selftest.sh b/root/opt/broray/tests/routes-resume-selftest.sh new file mode 100755 index 0000000..5515d6d --- /dev/null +++ b/root/opt/broray/tests/routes-resume-selftest.sh @@ -0,0 +1,82 @@ +#!/bin/sh + +set -eu + +ROOT="${BRORAY_ROOT:-/opt/broray}" +SYNC_LIBRARY="$ROOT/lib/routes-router-sync.sh" +PROGRESS_LIBRARY="$ROOT/lib/routes-operation-progress.sh" +WORK="${TMPDIR:-/tmp}/broray-routes-resume-selftest-$$" + +cleanup() { rm -rf "$WORK"; } +fail() { echo "ОШИБКА self-test: $*" >&2; exit 1; } +trap cleanup EXIT HUP INT TERM + +mkdir -p "$WORK/routes/installed/bundles" "$WORK/routes/state" "$WORK/routes/tmp" "$WORK/routes/operations" +cat >"$WORK/routes/installed/routes.json" <<'JSON' +{"schemaVersion":1,"managedInterface":"Proxy0","managedMetric":1200,"routes":[],"updatedAt":null} +JSON +cat >"$WORK/routes/installed/bundles/telegram.json" <<'JSON' +{"schemaVersion":1,"bundleId":"telegram","installedVersion":null,"routeKeys":[],"managedRouteKeys":[],"externalRouteKeys":[],"targetInterface":"Proxy0","managedMetric":1200} +JSON +cat >"$WORK/routes/state/telegram.json" <<'JSON' +{"schemaVersion":1,"bundleId":"telegram","status":"downloaded","downloadedVersion":{"contentSha256":"abc"},"installedVersion":null,"lastError":null} +JSON +cat >"$WORK/plan.json" <<'JSON' +{ + "schemaVersion":1, + "bundleId":"telegram", + "mode":"install", + "targetInterface":"Proxy0", + "managedMetric":1200, + "desired":[ + {"status":"create","route":{"key":"r1","family":"ipv4","network":"198.51.100.0","prefix":24,"mask":"255.255.255.0","targetInterface":"Proxy0","gatewayToken":"0.0.0.0","metric":1200,"automatic":null,"exclusive":null,"comment":"BROray"}}, + {"status":"create","route":{"key":"r2","family":"ipv4","network":"203.0.113.0","prefix":24,"mask":"255.255.255.0","targetInterface":"Proxy0","gatewayToken":"0.0.0.0","metric":1200,"automatic":null,"exclusive":null,"comment":"BROray"}} + ], + "obsolete":[], + "summary":{"total":2} +} +JSON + +BRORAY_ROOT="$WORK" +BRORAY_ROUTES_ROOT="$WORK/routes" +export BRORAY_ROOT BRORAY_ROUTES_ROOT +. "$SYNC_LIBRARY" +BRORAY_SYNC_ROUTES="$WORK/routes" +BRORAY_SYNC_WORK="$WORK/routes/tmp/sync" +BRORAY_SYNC_BUNDLE="telegram" +BRORAY_SYNC_ADDED_FILE="$BRORAY_SYNC_WORK/added.tsv" +BRORAY_SYNC_DELETED_FILE="$BRORAY_SYNC_WORK/deleted.tsv" +BRORAY_SYNC_PROGRESS_CURRENT=1 +BRORAY_SYNC_PROGRESS_TOTAL=2 +mkdir -p "$BRORAY_SYNC_WORK" +printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \ + create r1 198.51.100.0 24 255.255.255.0 Proxy0 1200 >"$BRORAY_SYNC_ADDED_FILE" +: >"$BRORAY_SYNC_DELETED_FILE" +broray_routes_sync_partial_commit "$WORK/plan.json" "Остановлено: 1 из 2." "" || + fail "Не удалось сохранить частично выполненную установку" + +jq -e ' + (.routes | length) == 1 and .routes[0].key == "r1" and + .routes[0].owners == ["telegram"] and .routes[0].actualStatus == "present" +' "$WORK/routes/installed/routes.json" >/dev/null || fail "Неверный частичный общий реестр" +jq -e ' + .routeKeys == ["r1"] and .managedRouteKeys == ["r1"] +' "$WORK/routes/installed/bundles/telegram.json" >/dev/null || fail "Неверный частичный реестр набора" +jq -e ' + .resumeOperation.operation == "install" and .resumeOperation.resumable == true and + .resumeOperation.current == 1 and .resumeOperation.total == 2 and + .resumeOperation.created == 1 +' "$WORK/routes/state/telegram.json" >/dev/null || fail "Не сохранено состояние продолжения установки" + +BRORAY_ROUTES_PROGRESS_DIR="$WORK/routes/operations" +export BRORAY_ROUTES_PROGRESS_DIR +. "$PROGRESS_LIBRARY" +broray_routes_progress_begin telegram install 2 "Начало." || fail "Не создан прогресс" +broray_routes_progress_tick 1 "198.51.100.0/24" || fail "Не записан прогресс" +broray_routes_progress_pause "Остановлено." true "" || fail "Не записана пауза" +values="$(broray_routes_progress_resume_values telegram install 1)" +[ "$(printf '%s' "$values" | cut -f1)" = 1 ] || fail "Не восстановлен текущий счётчик" +[ "$(printf '%s' "$values" | cut -f2)" = 2 ] || fail "Не восстановлена общая цель" +[ "$(printf '%s' "$values" | cut -f3)" = true ] || fail "Операция не распознана как продолжаемая" + +echo "BROray routes resume self-test: PASS" diff --git a/root/opt/broray/tests/routes-router-delete-selftest.sh b/root/opt/broray/tests/routes-router-delete-selftest.sh index 71d3a9e..a75a73e 100755 --- a/root/opt/broray/tests/routes-router-delete-selftest.sh +++ b/root/opt/broray/tests/routes-router-delete-selftest.sh @@ -4,6 +4,7 @@ set -eu ROOT="${BRORAY_ROOT:-/opt/broray}" LIBRARY="$ROOT/lib/routes-router-delete.sh" +PROGRESS_LIBRARY="$ROOT/lib/routes-operation-progress.sh" WORK="$ROOT/routes/tmp/router-delete-selftest-$$" MOCK_BIN="$WORK/bin" TEST_ROOT="$WORK/root" @@ -23,6 +24,7 @@ fail() trap cleanup EXIT HUP INT TERM [ -r "$LIBRARY" ] || fail "Модуль удаления недоступен" +[ -r "$PROGRESS_LIBRARY" ] || fail "Модуль прогресса недоступен" mkdir -p \ "$MOCK_BIN" \ @@ -36,6 +38,7 @@ mkdir -p \ "$TEST_ROOT/routes/transactions" cp -p "$LIBRARY" "$TEST_ROOT/lib/routes-router-delete.sh" +cp -p "$PROGRESS_LIBRARY" "$TEST_ROOT/lib/routes-operation-progress.sh" cat >"$TEST_ROOT/lib/interface-owner.sh" <<'OWNER_LIBRARY' #!/bin/sh @@ -61,7 +64,7 @@ cat >"$TEST_ROOT/lib/routes-router-config.sh" <<'ROUTES_CONFIG_LIBRARY' broray_routes_config_fetch() { - local output last_198 present_198 + local output last_198 present_198 last_203 present_203 output="$1" last_198="$( @@ -71,6 +74,9 @@ broray_routes_config_fetch() tail -n 1 )" present_198=true + last_203="" + present_203=true + [ -f "${BRORAY_TEST_203_DELETED:-/nonexistent}" ] && present_203=false case "$last_198" in "no ip route "*) present_198=false ;; @@ -78,7 +84,8 @@ broray_routes_config_fetch() esac jq -n \ - --argjson present_198 "$present_198" ' + --argjson present_198 "$present_198" \ + --argjson present_203 "$present_203" ' { schemaVersion: 1, source: "running-config", @@ -97,15 +104,21 @@ broray_routes_config_fetch() else [] end - ) + [{ - network: "203.0.113.0", - mask: "255.255.255.0", - destination: "203.0.113.0/24", - interface: "Proxy0", - gateway: "0.0.0.0", - metric: 1200, - proto: "static" - }] + ) + ( + if $present_203 then + [{ + network: "203.0.113.0", + mask: "255.255.255.0", + destination: "203.0.113.0/24", + interface: "Proxy0", + gateway: "0.0.0.0", + metric: 1200, + proto: "static" + }] + else + [] + end + ) ) } ' >"$output" @@ -283,6 +296,14 @@ jq -e ' [ "$(grep -F -x -c 'system configuration save' "$LOG")" = "1" ] || fail "Конфигурация не сохранена ровно один раз" +jq -e ' + .operation == "delete" and + .phase == "completed" and + .current == 1 and .total == 1 and .percent == 100 and + .running == false and .success == true +' "$TEST_ROOT/routes/operations/telegram.json" >/dev/null || + fail "Прогресс успешного удаления не завершён корректно" + # Проверка отката при частичной ошибке удаления. FAIL_ROOT="$WORK/failure-root" FAIL_LOG="$WORK/failure-ndmc.log" @@ -299,6 +320,7 @@ mkdir -p \ "$FAIL_ROOT/routes/transactions" cp -p "$LIBRARY" "$FAIL_ROOT/lib/routes-router-delete.sh" +cp -p "$PROGRESS_LIBRARY" "$FAIL_ROOT/lib/routes-operation-progress.sh" cp -p "$TEST_ROOT/lib/interface-owner.sh" \ "$FAIL_ROOT/lib/interface-owner.sh" cp -p "$TEST_ROOT/lib/routes-router-config.sh" \ @@ -381,16 +403,22 @@ case "$2" in exit 0 ;; "no ip route 203.0.113.0 255.255.255.0 Proxy0") - echo "simulated delete failure" >&2 - exit 9 + if [ ! -f "$BRORAY_TEST_FAIL_ONCE" ]; then + : >"$BRORAY_TEST_FAIL_ONCE" + echo "simulated delete failure" >&2 + exit 9 + fi + : >"$BRORAY_TEST_203_DELETED" + echo "Network::RoutingTable: Deleted static route: 203.0.113.0/24 via Proxy0." + exit 0 ;; "ip route 198.51.100.0 255.255.255.0 Proxy0 1200") echo "Network::RoutingTable: Added static route: 198.51.100.0/24 via Proxy0." exit 0 ;; "system configuration save") - echo "save must not run" >&2 - exit 10 + echo "Core::ConfigurationSaver: Saving configuration." + exit 0 ;; *) echo "unexpected command: $2" >&2 @@ -403,39 +431,95 @@ chmod 755 "$MOCK_BIN/ndmc" BRORAY_ROOT="$FAIL_ROOT" BRORAY_TEST_FAIL_LOG="$FAIL_LOG" BRORAY_TEST_CONFIG_LOG="$FAIL_LOG" -export BRORAY_ROOT BRORAY_TEST_FAIL_LOG BRORAY_TEST_CONFIG_LOG - -. "$FAIL_ROOT/lib/routes-router-delete.sh" -trap broray_routes_delete_cleanup EXIT HUP INT TERM - -if broray_routes_router_delete_run telegram >/dev/null 2>&1; then - fail "Моделируемая ошибка удаления не была обнаружена" -fi +BRORAY_TEST_FAIL_ONCE="$WORK/delete-fail-once" +BRORAY_TEST_203_DELETED="$WORK/route-203-deleted" +export BRORAY_ROOT BRORAY_TEST_FAIL_LOG BRORAY_TEST_CONFIG_LOG BRORAY_TEST_FAIL_ONCE BRORAY_TEST_203_DELETED + +unset BRORAY_ROUTES_DELETE_PROGRESS_LIBRARY +unset BRORAY_ROUTES_PROGRESS_DIR +unset BRORAY_ROUTES_PROGRESS_FILE +unset BRORAY_ROUTES_PROGRESS_COUNTER_FILE + +set +e +( + . "$FAIL_ROOT/lib/routes-router-delete.sh" + trap broray_routes_delete_cleanup EXIT HUP INT TERM + broray_routes_router_delete_run telegram >/dev/null 2>&1 +) +first_rc=$? +set -e +[ "$first_rc" = 76 ] || fail "Ошибка удаления не сохранила возобновляемое состояние: rc=$first_rc" -trap - EXIT HUP INT TERM -broray_routes_delete_cleanup -trap cleanup EXIT HUP INT TERM - -sha256sum \ - "$FAIL_ROOT/routes/installed/routes.json" \ - "$FAIL_ROOT/routes/installed/bundles/telegram.json" \ - "$FAIL_ROOT/routes/state/telegram.json" \ - "$FAIL_ROOT/routes/catalog/telegram/export-plan.json" \ - >"$AFTER_SUMS" - -cmp -s "$BEFORE_SUMS" "$AFTER_SUMS" || - fail "Локальное состояние не восстановлено после ошибки" - -[ "$(grep -F -x -c 'no ip route 198.51.100.0 255.255.255.0 Proxy0' "$FAIL_LOG")" = "1" ] || - fail "Первый маршрут удалялся неверное число раз" +jq -e ' + (.routes | length) == 1 and + .routes[0].network == "203.0.113.0" and + .routes[0].owners == ["telegram"] +' "$FAIL_ROOT/routes/installed/routes.json" >/dev/null || + fail "Успешно удалённый маршрут не исключён из частичного реестра" -[ "$(grep -F -x -c 'no ip route 203.0.113.0 255.255.255.0 Proxy0' "$FAIL_LOG")" = "1" ] || - fail "Ошибка второго удаления не была смоделирована" +jq -e ' + (.routeKeys | length) == 1 and + (.managedRouteKeys | length) == 1 and + (.routeKeys[0] | contains("203.0.113.0/24")) +' "$FAIL_ROOT/routes/installed/bundles/telegram.json" >/dev/null || + fail "Реестр набора не сохранил только необработанный маршрут" -[ "$(grep -F -x -c 'ip route 198.51.100.0 255.255.255.0 Proxy0 1200' "$FAIL_LOG")" = "1" ] || - fail "Первый маршрут не восстановлен после ошибки" +jq -e ' + .status == "installed" and + .resumeOperation.operation == "delete" and + .resumeOperation.resumable == true and + .resumeOperation.current == 1 and .resumeOperation.total == 2 and + .resumeOperation.deleted == 1 and + .lastError.resumable == true +' "$FAIL_ROOT/routes/state/telegram.json" >/dev/null || + fail "Состояние удаления не допускает продолжение после ошибки" -[ "$(grep -F -x -c 'system configuration save' "$FAIL_LOG" 2>/dev/null || true)" = "0" ] || - fail "Конфигурация была сохранена при неудачном удалении" +jq -e ' + .operation == "delete" and + .phase == "failed_resumable" and + .current == 1 and .total == 2 and .percent == 50 and + .running == false and .success == false and .resumable == true and + .rolledBack == false and .errorRoute == "203.0.113.0/24" +' "$FAIL_ROOT/routes/operations/telegram.json" >/dev/null || + fail "Прогресс неудачного удаления не зафиксировал возможность продолжения" + +[ "$(grep -F -x -c 'ip route 198.51.100.0 255.255.255.0 Proxy0 1200' "$FAIL_LOG" 2>/dev/null || true)" = 0 ] || + fail "Успешно удалённый маршрут был ошибочно восстановлен" +[ "$(grep -F -x -c 'system configuration save' "$FAIL_LOG" 2>/dev/null || true)" = 1 ] || + fail "Частичное удаление не было сохранено ровно один раз" + +# Повторный запуск продолжает только с оставшегося маршрута. +unset BRORAY_ROUTES_DELETE_PROGRESS_LIBRARY +unset BRORAY_ROUTES_PROGRESS_DIR +unset BRORAY_ROUTES_PROGRESS_FILE +unset BRORAY_ROUTES_PROGRESS_COUNTER_FILE +( + . "$FAIL_ROOT/lib/routes-router-delete.sh" + trap broray_routes_delete_cleanup EXIT HUP INT TERM + broray_routes_router_delete_run telegram >/dev/null + trap - EXIT HUP INT TERM + broray_routes_delete_cleanup +) + +jq -e '(.routes | length) == 0' "$FAIL_ROOT/routes/installed/routes.json" >/dev/null || + fail "Продолженное удаление не очистило общий реестр" +jq -e '.installedVersion == null and (.routeKeys | length) == 0' \ + "$FAIL_ROOT/routes/installed/bundles/telegram.json" >/dev/null || + fail "Продолженное удаление не завершило реестр набора" +jq -e ' + .status == "downloaded" and .installedVersion == null and + .deleteResult.deleted == 2 and (.resumeOperation == null) +' "$FAIL_ROOT/routes/state/telegram.json" >/dev/null || + fail "Итог продолженного удаления не объединён" +jq -e ' + .phase == "completed" and .current == 2 and .total == 2 and + .percent == 100 and .resumed == true and .resumable == false +' "$FAIL_ROOT/routes/operations/telegram.json" >/dev/null || + fail "Прогресс продолженного удаления завершён неверно" + +[ "$(grep -F -x -c 'no ip route 198.51.100.0 255.255.255.0 Proxy0' "$FAIL_LOG")" = 1 ] || + fail "Первый маршрут был обработан повторно" +[ "$(grep -F -x -c 'no ip route 203.0.113.0 255.255.255.0 Proxy0' "$FAIL_LOG")" = 2 ] || + fail "Ошибочный маршрут не был повторён при продолжении" echo "BROray routes router delete self-test: PASS" diff --git a/root/opt/broray/tests/routes-summary-selftest.sh b/root/opt/broray/tests/routes-summary-selftest.sh index 2063a37..5667553 100755 --- a/root/opt/broray/tests/routes-summary-selftest.sh +++ b/root/opt/broray/tests/routes-summary-selftest.sh @@ -34,12 +34,29 @@ mkdir -p \ "$TEST_ROOT/routes/locks" || fail "не удалось создать тестовую структуру" +cat >"$TEST_ROOT/routes/bundles.json" <<'EOF_BUNDLES' +{ + "schemaVersion": 1, + "bundles": ["telegram"] +} +EOF_BUNDLES + +cat >"$TEST_ROOT/routes/config.json" <<'EOF_CONFIG' +{ + "schemaVersion": 1, + "managedInterface": "Proxy0", + "managedMetric": 1200, + "routeComment": "BROray" +} +EOF_CONFIG + write_state() { state_status="$1" available_json="$2" downloaded_json="$3" installed_json="$4" + verify_json="${5:-null}" cat >"$TEST_ROOT/routes/state/telegram.json" </dev/null 2>&1 || fail "downloaded summary неверен" +write_state "downloaded" "$VERSION_NEW" "$VERSION_NEW" "null" "$VERIFY_NEW_NOT_INSTALLED" + +broray_routes_summary telegram >"$TEST_ROOT/verified.json" || + fail "verified summary не сформирован" + +jq -e ' + (.state == "downloaded") and + (.verificationCurrent == true) and + (.localSetValid == true) and + (.recommendedAction == "install") +' "$TEST_ROOT/verified.json" >/dev/null 2>&1 || + fail "verified summary неверен" + cat >"$TEST_ROOT/routes/installed/bundles/telegram.json" <"$TEST_ROOT/routes/installed/routes.json" <<'EOF_GLOBAL_INSTALLED' } EOF_GLOBAL_INSTALLED -write_state "installed" "$VERSION_OLD" "$VERSION_OLD" "$VERSION_OLD" +write_state "installed" "$VERSION_OLD" "$VERSION_OLD" "$VERSION_OLD" "$VERIFY_OLD_COMPLETE" broray_routes_summary telegram >"$TEST_ROOT/installed.json" || fail "installed summary не сформирован" @@ -149,7 +186,7 @@ broray_routes_summary telegram >"$TEST_ROOT/installed.json" || jq -e ' (.state == "installed") and (.installed == true) and - (.recommendedAction == "none") and + (.recommendedAction == "check") and (.installedRouteCount == 2) and (.managedRouteCount == 2) and (.globalOwnedRouteCount == 2) and @@ -157,7 +194,7 @@ jq -e ' ' "$TEST_ROOT/installed.json" >/dev/null 2>&1 || fail "installed summary неверен" -write_state "installed" "$VERSION_NEW" "$VERSION_NEW" "$VERSION_OLD" +write_state "installed" "$VERSION_NEW" "$VERSION_NEW" "$VERSION_OLD" "$VERIFY_NEW_UPDATE_PENDING" broray_routes_summary telegram >"$TEST_ROOT/update.json" || fail "update summary не сформирован" @@ -172,6 +209,48 @@ jq -e ' ' "$TEST_ROOT/update.json" >/dev/null 2>&1 || fail "update summary неверен" +write_state "downloaded" "$VERSION_NEW" "$VERSION_NEW" "null" "$VERIFY_NEW_INVALID" +write_bundle_empty +write_global_empty + +broray_routes_summary telegram >"$TEST_ROOT/invalid-local.json" || + fail "invalid local summary не сформирован" + +jq -e ' + (.state == "error") and + (.localSetValid == false) and + (.downloadActionRequired == true) and + (.recommendedAction == "download") +' "$TEST_ROOT/invalid-local.json" >/dev/null 2>&1 || + fail "invalid local summary неверен" + +cat >"$TEST_ROOT/routes/installed/bundles/telegram.json" <"$TEST_ROOT/routes/installed/routes.json" <<'EOF_GLOBAL_UPDATE' +{ + "schemaVersion": 1, + "managedInterface": "Proxy0", + "managedMetric": 1200, + "routes": [ + {"key":"route-1","owners":["telegram"]}, + {"key":"route-2","owners":["telegram"]} + ] +} +EOF_GLOBAL_UPDATE + +write_state "installed" "$VERSION_NEW" "$VERSION_NEW" "$VERSION_OLD" "$VERIFY_NEW_UPDATE_PENDING" + mkdir -p "$TEST_ROOT/routes/locks/operation.lock" broray_routes_summary telegram >"$TEST_ROOT/busy.json" || diff --git a/root/opt/broray/tests/routes-ui-action-selftest.js b/root/opt/broray/tests/routes-ui-action-selftest.js new file mode 100644 index 0000000..11489f7 --- /dev/null +++ b/root/opt/broray/tests/routes-ui-action-selftest.js @@ -0,0 +1,280 @@ +#!/usr/bin/env node + +"use strict"; + +const fs = require("fs"); +const vm = require("vm"); +const path = process.argv[2] || "/opt/broray/web-new/assets/js/routes.js"; + +function fail(message) { + console.error("BROray routes UI action self-test: FAIL — " + message); + process.exit(1); +} + +function version(sha, sourceSha) { + return { + contentSha256: sha, + sourceSetSha256: sourceSha || sha, + sourceCommit: sha + }; +} + +function verified(sha, status, localValid) { + return { + contentSha256: sha, + success: status !== "conflict" && localValid !== false, + local: {valid: localValid !== false}, + keenetic: {status: status} + }; +} + +const sandbox = { + window: {BROrayTestMode: true}, + document: { + readyState: "loading", + addEventListener: function () {} + }, + console, + setTimeout, + clearTimeout, + Promise, + Date, + Math, + Number, + String, + Boolean, + Array, + Object, + isFinite, + isNaN +}; +sandbox.window.window = sandbox.window; +vm.createContext(sandbox); +vm.runInContext(fs.readFileSync(path, "utf8"), sandbox, {filename: path}); + +const hooks = sandbox.window.BROrayRoutesTestHooks; +if (!hooks) fail("тестовые функции не экспортированы"); + +const V1 = version("v1", "s1"); +const V2 = version("v2", "s2"); + +const cases = [ + { + name: "источник ещё не проверен", + state: {}, + expected: "check" + }, + { + name: "источник найден, файлы не скачаны", + state: {availableVersion: V1, downloadedVersion: null}, + expected: "download" + }, + { + name: "файлы скачаны, набор ещё не проверен", + state: {availableVersion: V1, downloadedVersion: V1, installedVersion: null}, + expected: "verify" + }, + { + name: "набор проверен и готов к установке", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: null, + verifyResult: verified("v1", "not_installed") + }, + expected: "export" + }, + { + name: "набор установлен и соответствует Keenetic", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: V1, + verifyResult: verified("v1", "complete"), + routerPresence: { + available: true, + registered: true, + actualInstalled: true, + drift: false + } + }, + expected: "check" + }, + { + name: "доступно обновление источника", + state: { + availableVersion: V2, + downloadedVersion: V1, + installedVersion: V1, + verifyResult: verified("v1", "complete"), + checkResult: {downloadRequired: true} + }, + expected: "download" + }, + { + name: "локальный набор повреждён", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: null, + verifyResult: verified("v1", "not_checked", false) + }, + expected: "download" + }, + { + name: "в Keenetic отсутствуют маршруты", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: V1, + verifyResult: verified("v1", "restore_required"), + routerPresence: { + available: true, + registered: true, + actualInstalled: null, + drift: true, + expectedRouteCount: 10, + presentRouteCount: 8, + missingRouteCount: 2 + } + }, + expected: "export" + }, + { + name: "обнаружен конфликт Keenetic", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: V1, + verifyResult: verified("v1", "conflict") + }, + expected: null + }, + { + name: "долгая операция приостановлена", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: null, + operationProgress: { + operation: "install", + running: false, + resumable: true, + current: 200, + total: 226, + phase: "paused" + } + }, + expected: "resume" + }, + { + name: "Keenetic недоступен во время проверки набора", + state: { + availableVersion: V1, + downloadedVersion: V1, + installedVersion: V1, + verifyResult: { + contentSha256: "v1", + success: false, + local: {valid: true}, + keenetic: {available: false, status: "unavailable"} + }, + lastError: {code: "ROUTES_VERIFY_ROUTER_FAILED", message: "Keenetic недоступен"} + }, + expected: "verify" + } +]; + +cases.forEach(function (testCase) { + const actual = hooks.nextAction(testCase.state); + if (actual !== testCase.expected) { + fail(testCase.name + ": ожидалось " + String(testCase.expected) + ", получено " + String(actual)); + } +}); + +if (hooks.checkLabel({}) !== "Проверить обновление") { + fail("кнопка источника имеет неверную подпись"); +} + +const invalidState = cases[6].state; +if (!hooks.localSetInvalid(invalidState) || !hooks.downloadActionRequired(invalidState)) { + fail("повреждённый локальный набор не предлагает повторное скачивание"); +} + +const unverifiedState = cases[2].state; +if (!hooks.verificationRequired(unverifiedState)) { + fail("скачанный набор не требует отдельной проверки"); +} + +const completeState = cases[4].state; +if (!hooks.verificationCurrent(completeState)) { + fail("актуальная проверка набора не распознана"); +} + + +const preflight = { + ready: true, + requestedAction: "export", + operation: "install", + message: "Предварительная проверка завершена. Операция готова к запуску.", + checks: { + operationLock: {ok: true}, + ndmc: {ok: true}, + keenetic: {ok: true, interface: "Proxy0"}, + storage: {ok: true, freeKb: 8192, requiredKb: 4096}, + localSet: {ok: true, routeCount: 226, invalidRouteCount: 0, duplicateRouteCount: 0} + }, + summary: { + total: 226, + alreadyPresent: 26, + toCreate: 200, + toDelete: 0, + sharedKept: 4, + alreadyAbsent: 0, + externalKept: 3, + conflicts: 0 + } +}; + +const preflightText = hooks.preflightMessage(preflight); +[ + "Конфликтующих операций нет", + "Keenetic: Proxy0 подключён", + "Свободное место: 8.0 МБ", + "Всего в наборе: 226", + "Будет добавлено: 200", + "Общие маршруты будут сохранены: 4", + "Внешние маршруты не затрагиваются: 3", + "Конфликты: 0" +].forEach(function (fragment) { + if (preflightText.indexOf(fragment) === -1) { + fail("в сводке предварительной проверки отсутствует: " + fragment); + } +}); + +if (hooks.preflightTitle({name: "Telegram"}, preflight) !== "Установить «Telegram» в Keenetic") { + fail("неверный заголовок предварительной установки"); +} +if (hooks.preflightTitle({name: "Telegram"}, {operation: "delete"}) !== "Удалить «Telegram» из Keenetic") { + fail("неверный заголовок предварительного удаления"); +} +if (hooks.preflightTitle({name: "Telegram"}, {operation: "update"}) !== "Обновить «Telegram» в Keenetic") { + fail("неверный заголовок предварительного обновления"); +} +const resumeText = hooks.preflightMessage(Object.assign({}, preflight, { + requestedAction: "resume", + resume: {current: 100, total: 5454} +})); +if (resumeText.indexOf("Продолжение с позиции: 100 из 5454") === -1) { + fail("предварительная проверка продолжения не показывает сохранённую позицию"); +} + + +if (hooks.globalOperationMessage({active: true, scope: "system", action: "update"}).indexOf("обновление BROray") === -1) { + fail("системная блокировка не объясняется пользователю"); +} +if (hooks.globalOperationMessage({active: true, scope: "routes", action: "delete", bundleId: "telegram"}).indexOf("Удаляются маршруты «Telegram»") === -1) { + fail("блокировка другой карточкой не показывает выполняемое действие"); +} + +console.log("BROray routes UI action self-test: PASS"); diff --git a/root/opt/broray/tests/routes-verify-selftest.sh b/root/opt/broray/tests/routes-verify-selftest.sh new file mode 100644 index 0000000..64802bc --- /dev/null +++ b/root/opt/broray/tests/routes-verify-selftest.sh @@ -0,0 +1,262 @@ +#!/bin/sh + +set -eu + +VERIFY_CGI="${1:-/opt/broray/web-new/api/routes/verify.cgi}" +API_LOCK_LIBRARY="${2:-/opt/broray/lib/routes-api-operation.sh}" +WORK="/tmp/broray-routes-verify-selftest.$$" +ROOT="$WORK/root" +STATE="$ROOT/routes/state/demo.json" + +cleanup() +{ + rm -rf "$WORK" +} + +fail() +{ + echo "BROray routes verify self-test: FAIL — $*" >&2 + exit 1 +} + +trap cleanup EXIT HUP INT TERM + +mkdir -p \ + "$ROOT/bin" \ + "$ROOT/lib" \ + "$ROOT/routes/state" \ + "$ROOT/web-new/api" + +[ -r "$API_LOCK_LIBRARY" ] || fail "модуль блокировки API не найден" +cp "$API_LOCK_LIBRARY" "$ROOT/lib/routes-api-operation.sh" || fail "не удалось скопировать модуль блокировки API" +chmod 755 "$ROOT/lib/routes-api-operation.sh" + +cat >"$ROOT/routes/bundles.json" <<'JSON' +{ + "schemaVersion": 1, + "bundles": ["demo"] +} +JSON + +cat >"$ROOT/web-new/api/auth-common.sh" <<'SH' +broray_api_require_method() +{ + [ "${REQUEST_METHOD:-}" = "$1" ] || exit 1 +} + +broray_api_require_session() +{ + return 0 +} + +broray_api_error() +{ + status="$1" + code="$2" + message="$3" + details="${4:-}" + jq -cn \ + --arg status "$status" \ + --arg code "$code" \ + --arg message "$message" \ + --arg details "$details" ' + {success:false,status:$status,data:null,error:{code:$code,message:$message,details:$details}} + ' + exit 0 +} + +broray_api_success() +{ + printf '%s\n' "$1" + exit 0 +} +SH + +cat >"$ROOT/bin/broray-routes" <<'SH' +#!/bin/sh +set -u + +command_name="${1:-}" +scenario="${VERIFY_SCENARIO:-complete}" + +case "$command_name" in + build-export) + if [ "$scenario" = "invalid_local" ]; then + echo "Каталог маршрутов повреждён." >&2 + exit 1 + fi + echo "Файл Keenetic подготовлен" + ;; + plan) + case "$scenario" in + complete) + mode="none" + can_apply=true + managed=2 + create=0 + conflicts=0 + external=0 + ;; + not_installed) + mode="install" + can_apply=true + managed=0 + create=2 + conflicts=0 + external=0 + ;; + conflict) + mode="none" + can_apply=false + managed=1 + create=0 + conflicts=1 + external=1 + ;; + router_unavailable) + echo "Не удалось прочитать running-config." >&2 + exit 1 + ;; + *) exit 2 ;; + esac + + jq -n \ + --arg mode "$mode" \ + --argjson canApply "$can_apply" \ + --argjson managed "$managed" \ + --argjson create "$create" \ + --argjson conflicts "$conflicts" \ + --argjson external "$external" ' + { + schemaVersion: 1, + bundleId: "demo", + mode: $mode, + targetInterface: "Proxy0", + managedMetric: 1200, + contentSha256: "demo-sha", + canApply: $canApply, + summary: { + total: 2, + addedRoutes: 0, + removedRoutes: 0, + unchangedRoutes: 2, + toCreate: $create, + managedExisting: $managed, + externalExisting: $external, + conflicts: $conflicts, + toDelete: 0, + sharedKept: 0, + sharedToRestore: 0, + alreadyAbsent: 0 + }, + desired: [], + obsolete: [], + blocking: [] + } + ' + ;; + *) exit 2 ;; +esac +SH +chmod +x "$ROOT/bin/broray-routes" + +write_state() +{ + installed_json="$1" + cat >"$STATE" <"$output" +} + +INSTALLED='{"contentSha256":"demo-sha","sourceSetSha256":"source-sha"}' + +write_state "$INSTALLED" +run_verify complete +jq -e ' + (.lastError == null) and + (.verifyResult.success == true) and + (.verifyResult.local.valid == true) and + (.verifyResult.local.duplicateRouteCount == 0) and + (.verifyResult.keenetic.status == "complete") and + (.verifyResult.keenetic.presentRouteCount == 2) and + (.verifyResult.keenetic.missingRouteCount == 0) +' "$STATE" >/dev/null || fail "полное соответствие определено неверно" + +write_state null +run_verify not_installed +jq -e ' + (.verifyResult.success == true) and + (.verifyResult.keenetic.status == "not_installed") and + (.verifyResult.keenetic.expectedRouteCount == 2) and + (.verifyResult.keenetic.presentRouteCount == 0) +' "$STATE" >/dev/null || fail "неустановленный набор определён неверно" + +write_state "$INSTALLED" +run_verify conflict +jq -e ' + (.lastError == null) and + (.verifyResult.success == false) and + (.verifyResult.local.valid == true) and + (.verifyResult.keenetic.status == "conflict") and + (.verifyResult.keenetic.conflictCount == 1) +' "$STATE" >/dev/null || fail "конфликт Keenetic определён неверно" + +write_state null +run_verify invalid_local +jq -e ' + (.lastError.code == "ROUTES_SET_INVALID") and + (.verifyResult.success == false) and + (.verifyResult.local.valid == false) and + (.verifyResult.keenetic.status == "not_checked") +' "$STATE" >/dev/null || fail "повреждение локального набора не сохранено" + +write_state "$INSTALLED" +run_verify router_unavailable +jq -e ' + (.lastError.code == "ROUTES_VERIFY_ROUTER_FAILED") and + (.verifyResult.local.valid == true) and + (.verifyResult.keenetic.available == false) and + (.verifyResult.keenetic.status == "unavailable") +' "$STATE" >/dev/null || fail "недоступность Keenetic не сохранена" + +echo "BROray routes verify self-test: PASS" diff --git a/root/opt/broray/web-new/api/broray/cleanup-plan.cgi b/root/opt/broray/web-new/api/broray/cleanup-plan.cgi new file mode 100755 index 0000000..fc71690 --- /dev/null +++ b/root/opt/broray/web-new/api/broray/cleanup-plan.cgi @@ -0,0 +1,34 @@ +#!/opt/bin/ash + +. /opt/broray/web-new/api/auth-common.sh + +broray_api_require_method POST +broray_api_require_session +[ "${HTTP_X_BRORAY_REQUEST:-}" = 1 ] || { + broray_api_error "403 Forbidden" "INVALID_REQUEST" "Запрос отклонён." +} + +length="${CONTENT_LENGTH:-0}" +case "$length" in ''|*[!0-9]*) length=0 ;; esac +[ "$length" -le 4096 ] || broray_api_error \ + "413 Payload Too Large" "REQUEST_TOO_LARGE" "Тело запроса слишком большое." +body='{}' +[ "$length" -gt 0 ] && body="$(dd bs=1 count="$length" 2>/dev/null)" +printf '%s' "$body" | jq -e 'type == "object"' >/dev/null 2>&1 || broray_api_error \ + "400 Bad Request" "INVALID_JSON" "Тело запроса содержит некорректный JSON." + +temp="$(printf '%s' "$body" | jq -r 'if has("temp") then .temp else true end')" +backups="$(printf '%s' "$body" | jq -r 'if has("backups") then .backups else true end')" +route_backups="$(printf '%s' "$body" | jq -r 'if has("routeBackups") then .routeBackups else true end')" +logs="$(printf '%s' "$body" | jq -r 'if has("logs") then .logs else true end')" + +. /opt/broray/lib/broray-cleanup.sh +result="$(broray_cleanup_plan_create "$temp" "$backups" "$route_backups" "$logs")" || { + case "$BRORAY_CLEANUP_ERROR_CODE" in + CLEANUP_OPERATION_BUSY) status='409 Conflict' ;; + CLEANUP_OPTIONS_INVALID|CLEANUP_NOTHING_SELECTED) status='400 Bad Request' ;; + *) status='500 Internal Server Error' ;; + esac + broray_api_error "$status" "$BRORAY_CLEANUP_ERROR_CODE" "$BRORAY_CLEANUP_ERROR_MESSAGE" +} +broray_api_success "$result" diff --git a/root/opt/broray/web-new/api/broray/cleanup.cgi b/root/opt/broray/web-new/api/broray/cleanup.cgi new file mode 100755 index 0000000..66175b9 --- /dev/null +++ b/root/opt/broray/web-new/api/broray/cleanup.cgi @@ -0,0 +1,32 @@ +#!/opt/bin/ash + +. /opt/broray/web-new/api/auth-common.sh + +broray_api_require_method POST +broray_api_require_session +[ "${HTTP_X_BRORAY_REQUEST:-}" = 1 ] || { + broray_api_error "403 Forbidden" "INVALID_REQUEST" "Запрос отклонён." +} + +length="${CONTENT_LENGTH:-0}" +case "$length" in ''|*[!0-9]*) length=0 ;; esac +[ "$length" -le 4096 ] || broray_api_error \ + "413 Payload Too Large" "REQUEST_TOO_LARGE" "Тело запроса слишком большое." +body='{}' +[ "$length" -gt 0 ] && body="$(dd bs=1 count="$length" 2>/dev/null)" +token="$(printf '%s' "$body" | jq -r '.token // empty' 2>/dev/null)" +[ -n "$token" ] || broray_api_error \ + "400 Bad Request" "CLEANUP_TOKEN_REQUIRED" "Не передано подтверждение очистки." + +. /opt/broray/lib/broray-cleanup.sh +result="$(broray_cleanup_execute "$token")" || { + case "$BRORAY_CLEANUP_ERROR_CODE" in + CLEANUP_TOKEN_INVALID|CLEANUP_PLAN_NOT_FOUND|CLEANUP_PLAN_EXPIRED|CLEANUP_PLAN_CHANGED) + status='409 Conflict' + ;; + CLEANUP_OPERATION_BUSY) status='409 Conflict' ;; + *) status='500 Internal Server Error' ;; + esac + broray_api_error "$status" "$BRORAY_CLEANUP_ERROR_CODE" "$BRORAY_CLEANUP_ERROR_MESSAGE" +} +broray_api_success "$result" diff --git a/root/opt/broray/web-new/api/broray/reinstall.cgi b/root/opt/broray/web-new/api/broray/reinstall.cgi new file mode 100755 index 0000000..43c13d2 --- /dev/null +++ b/root/opt/broray/web-new/api/broray/reinstall.cgi @@ -0,0 +1,16 @@ +#!/opt/bin/ash + +. /opt/broray/web-new/api/auth-common.sh + +broray_api_require_method POST +broray_api_require_session +[ "${HTTP_X_BRORAY_REQUEST:-}" = 1 ] || { + broray_api_error \ + "403 Forbidden" \ + "INVALID_REQUEST" \ + "Запрос отклонён." +} + +broray_api_print_json_headers +printf '\r\n' +/opt/broray/bin/broray-system reinstall-start diff --git a/root/opt/broray/web-new/api/routes/check.cgi b/root/opt/broray/web-new/api/routes/check.cgi index e3fb5e5..9e1cc0e 100755 --- a/root/opt/broray/web-new/api/routes/check.cgi +++ b/root/opt/broray/web-new/api/routes/check.cgi @@ -6,6 +6,7 @@ PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" +API_LOCK_LIBRARY="/opt/broray/lib/routes-api-operation.sh" CLI="/opt/broray/bin/broray-routes" BUNDLES="/opt/broray/routes/bundles.json" CONFIG="/opt/broray/routes/config.json" @@ -18,6 +19,8 @@ ERROR_FILE="/tmp/broray-routes-check-$$.err" cleanup() { rm -f "$OUTPUT_FILE" "$ERROR_FILE" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && + broray_routes_api_lock_release } trap cleanup EXIT HUP INT TERM @@ -90,6 +93,20 @@ then "Набор маршрутов не найден." fi +[ -r "$API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." +. "$API_LOCK_LIBRARY" +lock_rc=0 +broray_routes_api_lock_acquire "check" "$bundle_id" || lock_rc=$? +case "$lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; +esac + managed_interface="$( jq -r '.managedInterface // empty' \ "$CONFIG" 2>/dev/null @@ -183,14 +200,14 @@ if [ "$command_ok" != true ]; then broray_api_error \ "409 Conflict" \ "ROUTES_OPERATION_BUSY" \ - "Другая операция с маршрутами уже выполняется." \ + "Другая конфликтующая операция уже выполняется." \ "$details" ;; *) broray_api_error \ "502 Bad Gateway" \ "ROUTES_CHECK_FAILED" \ - "Не удалось выполнить поиск обновлений маршрутов." \ + "Не удалось проверить обновление маршрутов." \ "$details" ;; esac diff --git a/root/opt/broray/web-new/api/routes/custom-common.sh b/root/opt/broray/web-new/api/routes/custom-common.sh index 48e7c3e..82e6a21 100755 --- a/root/opt/broray/web-new/api/routes/custom-common.sh +++ b/root/opt/broray/web-new/api/routes/custom-common.sh @@ -2,6 +2,8 @@ . /opt/broray/web-new/api/auth-common.sh +BRORAY_CUSTOM_ROUTES_API_LOCK_LIBRARY="/opt/broray/lib/routes-api-operation.sh" + BRORAY_CUSTOM_ROUTES_CLI="/opt/broray/bin/broray-routes-user" BRORAY_CUSTOM_ROUTES_BODY_LIMIT=4194304 BRORAY_CUSTOM_BUNDLE_ID="" @@ -98,6 +100,22 @@ broray_custom_routes_run() { output_file="/opt/broray/tmp/custom-routes-api-output.$$.json" error_file="/opt/broray/tmp/custom-routes-api-error.$$" + custom_action="${2:-custom}" + custom_bundle="${3:-}" + + [ -r "$BRORAY_CUSTOM_ROUTES_API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." + . "$BRORAY_CUSTOM_ROUTES_API_LOCK_LIBRARY" + custom_lock_rc=0 + broray_routes_api_lock_acquire "custom:$custom_action" "$custom_bundle" || custom_lock_rc=$? + case "$custom_lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; + esac mkdir -p /opt/broray/tmp @@ -105,6 +123,7 @@ broray_custom_routes_run() if ! jq -e 'type == "object"' "$output_file" >/dev/null 2>&1; then details="$(cat "$output_file" 2>/dev/null)" rm -f "$output_file" "$error_file" + broray_routes_api_lock_release broray_api_error \ "500 Internal Server Error" \ "CUSTOM_ROUTES_RESPONSE_INVALID" \ @@ -114,6 +133,7 @@ broray_custom_routes_run() data_json="$(jq -c . "$output_file")" rm -f "$output_file" "$error_file" + broray_routes_api_lock_release broray_api_success "$data_json" exit 0 fi @@ -147,6 +167,7 @@ $details}" esac rm -f "$output_file" "$error_file" + broray_routes_api_lock_release broray_api_error \ "$http_status" \ "$error_code" \ diff --git a/root/opt/broray/web-new/api/routes/dot-apply.cgi b/root/opt/broray/web-new/api/routes/dot-apply.cgi new file mode 100755 index 0000000..65d8f01 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/dot-apply.cgi @@ -0,0 +1,11 @@ +#!/opt/bin/ash +set -u +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +. "$BRORAY_ROOT/web-new/api/routes/dot-common.sh" +broray_api_require_method POST +broray_api_require_session +request="$BRORAY_ROOT/tmp/dot-apply-request.$$.json" +trap 'rm -f "$request"; command -v broray_routes_api_lock_release >/dev/null 2>&1 && broray_routes_api_lock_release' EXIT HUP INT TERM +broray_dot_api_read_body "$request" +broray_dot_api_lock apply +broray_dot_api_run apply "$request" diff --git a/root/opt/broray/web-new/api/routes/dot-common.sh b/root/opt/broray/web-new/api/routes/dot-common.sh new file mode 100755 index 0000000..0c8fe83 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/dot-common.sh @@ -0,0 +1,76 @@ +#!/opt/bin/ash + +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +. "$BRORAY_ROOT/web-new/api/auth-common.sh" + +BRORAY_DOT_CLI="${BRORAY_DOT_CLI:-$BRORAY_ROOT/bin/broray-routes-dot}" +BRORAY_DOT_API_LOCK_LIBRARY="${BRORAY_DOT_API_LOCK_LIBRARY:-$BRORAY_ROOT/lib/routes-api-operation.sh}" +BRORAY_DOT_BODY_LIMIT=65536 + +broray_dot_api_read_body() +{ + target="$1" + length="${CONTENT_LENGTH:-0}" + case "$length" in ''|*[!0-9]*) broray_api_error "400 Bad Request" CONTENT_LENGTH_INVALID "Некорректный размер запроса." ;; esac + [ "$length" -gt 0 ] || broray_api_error "400 Bad Request" REQUEST_BODY_REQUIRED "Тело запроса отсутствует." + [ "$length" -le "$BRORAY_DOT_BODY_LIMIT" ] || broray_api_error "413 Payload Too Large" REQUEST_TOO_LARGE "Запрос DNS-over-TLS слишком велик." + dd bs=1 count="$length" >"$target" 2>/dev/null || broray_api_error "400 Bad Request" REQUEST_BODY_READ_FAILED "Не удалось прочитать тело запроса." + [ "$(wc -c <"$target" | tr -d ' ')" = "$length" ] || broray_api_error "400 Bad Request" REQUEST_BODY_INCOMPLETE "Тело запроса получено не полностью." + jq -e 'type=="object"' "$target" >/dev/null 2>&1 || broray_api_error "400 Bad Request" REQUEST_JSON_INVALID "Запрос должен содержать корректный JSON-объект." +} + +broray_dot_api_lock() +{ + action="$1" + [ -r "$BRORAY_DOT_API_LOCK_LIBRARY" ] || broray_api_error "500 Internal Server Error" ROUTES_API_LOCK_UNAVAILABLE "Модуль блокировки операций недоступен." + . "$BRORAY_DOT_API_LOCK_LIBRARY" + rc=0 + broray_routes_api_lock_acquire "dot:$action" "dns-over-tls" || rc=$? + case "$rc" in + 0) ;; + 2) broray_api_error "409 Conflict" ROUTES_OPERATION_BUSY "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" ROUTES_API_LOCK_FAILED "Не удалось установить блокировку операции." ;; + esac +} + +broray_dot_api_run() +{ + action="$1" + shift + output="$BRORAY_ROOT/tmp/dot-api-output.$$.json" + error="$BRORAY_ROOT/tmp/dot-api-error.$$" + mkdir -p "$BRORAY_ROOT/tmp" + if "$BRORAY_DOT_CLI" "$action" "$@" >"$output" 2>"$error"; then + jq -e 'type=="object"' "$output" >/dev/null 2>&1 || { + details="$(tail -n 30 "$output" 2>/dev/null)" + rm -f "$output" "$error" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && broray_routes_api_lock_release + broray_api_error "500 Internal Server Error" DOT_RESPONSE_INVALID "Модуль DNS-over-TLS вернул некорректный ответ." "$details" + } + data="$(jq -c . "$output")" + rm -f "$output" "$error" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && broray_routes_api_lock_release + broray_api_success "$data" + exit 0 + fi + first="$(sed -n '1p' "$error" 2>/dev/null)" + details="$(sed -n '2,40p' "$error" 2>/dev/null)" + code=DOT_OPERATION_FAILED + message="Операция DNS-over-TLS завершилась ошибкой." + case "$first" in + BRORAY_ERROR:*:*) rest="${first#BRORAY_ERROR:}"; code="${rest%%:*}"; message="${rest#*:}" ;; + '') ;; + *) details="$first${details:+ +$details}" ;; + esac + status="400 Bad Request" + case "$code" in + ROUTES_OPERATION_BUSY) status="409 Conflict" ;; + DOT_TEST_CONFIRMATION_REQUIRED|DOT_LIMIT_EXCEEDED) status="409 Conflict" ;; + NDMC_UNAVAILABLE|DEPENDENCY_MISSING|MODULE_UNAVAILABLE|STORAGE_UNAVAILABLE|CONFIG_INVALID|STATE_INVALID) status="500 Internal Server Error" ;; + KEENETIC_UNAVAILABLE|DOT_APPLY_FAILED|DOT_DELETE_FAILED) status="502 Bad Gateway" ;; + esac + rm -f "$output" "$error" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && broray_routes_api_lock_release + broray_api_error "$status" "$code" "$message" "$details" +} diff --git a/root/opt/broray/web-new/api/routes/dot-delete.cgi b/root/opt/broray/web-new/api/routes/dot-delete.cgi new file mode 100755 index 0000000..1c150a9 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/dot-delete.cgi @@ -0,0 +1,8 @@ +#!/opt/bin/ash +set -u +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +. "$BRORAY_ROOT/web-new/api/routes/dot-common.sh" +broray_api_require_method POST +broray_api_require_session +broray_dot_api_lock delete +broray_dot_api_run delete diff --git a/root/opt/broray/web-new/api/routes/dot-status.cgi b/root/opt/broray/web-new/api/routes/dot-status.cgi new file mode 100755 index 0000000..d63686f --- /dev/null +++ b/root/opt/broray/web-new/api/routes/dot-status.cgi @@ -0,0 +1,8 @@ +#!/opt/bin/ash +set -u +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +. "$BRORAY_ROOT/web-new/api/routes/dot-common.sh" +broray_api_require_method GET +broray_api_require_session +[ -x "$BRORAY_DOT_CLI" ] || broray_api_error "500 Internal Server Error" DOT_CLI_UNAVAILABLE "Команда DNS-over-TLS недоступна." +broray_dot_api_run status diff --git a/root/opt/broray/web-new/api/routes/dot-test.cgi b/root/opt/broray/web-new/api/routes/dot-test.cgi new file mode 100755 index 0000000..81a58e1 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/dot-test.cgi @@ -0,0 +1,11 @@ +#!/opt/bin/ash +set -u +BRORAY_ROOT="${BRORAY_ROOT:-/opt/broray}" +. "$BRORAY_ROOT/web-new/api/routes/dot-common.sh" +broray_api_require_method POST +broray_api_require_session +request="$BRORAY_ROOT/tmp/dot-test-request.$$.json" +trap 'rm -f "$request"; command -v broray_routes_api_lock_release >/dev/null 2>&1 && broray_routes_api_lock_release' EXIT HUP INT TERM +broray_dot_api_read_body "$request" +broray_dot_api_lock test +broray_dot_api_run test "$request" diff --git a/root/opt/broray/web-new/api/routes/download.cgi b/root/opt/broray/web-new/api/routes/download.cgi index 5a5423d..f00578d 100755 --- a/root/opt/broray/web-new/api/routes/download.cgi +++ b/root/opt/broray/web-new/api/routes/download.cgi @@ -6,6 +6,7 @@ PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" +API_LOCK_LIBRARY="/opt/broray/lib/routes-api-operation.sh" CLI="/opt/broray/bin/broray-routes" BUNDLES="/opt/broray/routes/bundles.json" CONFIG="/opt/broray/routes/config.json" @@ -18,6 +19,8 @@ ERROR_FILE="/tmp/broray-routes-download-$$.err" cleanup() { rm -f "$OUTPUT_FILE" "$ERROR_FILE" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && + broray_routes_api_lock_release } trap cleanup EXIT HUP INT TERM @@ -87,6 +90,20 @@ then "Набор маршрутов не найден." fi +[ -r "$API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." +. "$API_LOCK_LIBRARY" +lock_rc=0 +broray_routes_api_lock_acquire "download" "$bundle_id" || lock_rc=$? +case "$lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; +esac + managed_interface="$( jq -r '.managedInterface // empty' \ "$CONFIG" 2>/dev/null @@ -179,7 +196,7 @@ if [ "$command_ok" != true ]; then broray_api_error \ "409 Conflict" \ "ROUTES_OPERATION_BUSY" \ - "Другая операция с маршрутами уже выполняется." \ + "Другая конфликтующая операция уже выполняется." \ "$details" fi diff --git a/root/opt/broray/web-new/api/routes/plan.cgi b/root/opt/broray/web-new/api/routes/plan.cgi index da7bc30..894093c 100755 --- a/root/opt/broray/web-new/api/routes/plan.cgi +++ b/root/opt/broray/web-new/api/routes/plan.cgi @@ -6,6 +6,7 @@ PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" export PATH AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" +API_LOCK_LIBRARY="/opt/broray/lib/routes-api-operation.sh" CLI="/opt/broray/bin/broray-routes" BUNDLES="/opt/broray/routes/bundles.json" OUTPUT_FILE="/tmp/broray-routes-plan-$$.json" @@ -14,6 +15,8 @@ ERROR_FILE="/tmp/broray-routes-plan-$$.err" cleanup() { rm -f "$OUTPUT_FILE" "$ERROR_FILE" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && + broray_routes_api_lock_release } trap cleanup EXIT HUP INT TERM @@ -74,6 +77,20 @@ jq -e --arg id "$bundle_id" ' "ROUTES_BUNDLE_NOT_FOUND" \ "Набор маршрутов не найден." +[ -r "$API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." +. "$API_LOCK_LIBRARY" +lock_rc=0 +broray_routes_api_lock_acquire "plan" "$bundle_id" || lock_rc=$? +case "$lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; +esac + if "$CLI" plan "$bundle_id" >"$OUTPUT_FILE" 2>"$ERROR_FILE"; then command_ok=true command_rc=0 @@ -89,7 +106,7 @@ if [ "$command_ok" != true ]; then broray_api_error \ "409 Conflict" \ "ROUTES_OPERATION_BUSY" \ - "Другая операция с маршрутами уже выполняется." \ + "Другая конфликтующая операция уже выполняется." \ "$details" ;; *) diff --git a/root/opt/broray/web-new/api/routes/preflight.cgi b/root/opt/broray/web-new/api/routes/preflight.cgi new file mode 100755 index 0000000..693f79d --- /dev/null +++ b/root/opt/broray/web-new/api/routes/preflight.cgi @@ -0,0 +1,126 @@ +#!/opt/bin/ash + +set -u + +PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" +export PATH + +ROOT="${BRORAY_ROOT:-/opt/broray}" +AUTH_COMMON="$ROOT/web-new/api/auth-common.sh" +API_LOCK_LIBRARY="$ROOT/lib/routes-api-operation.sh" +PREFLIGHT_LIBRARY="$ROOT/lib/routes-operation-preflight.sh" +CLI="$ROOT/bin/broray-routes" +BUNDLES="$ROOT/routes/bundles.json" +PLAN_FILE="/tmp/broray-routes-operation-preflight-plan-$$.json" +RESULT_FILE="/tmp/broray-routes-operation-preflight-result-$$.json" +ERROR_FILE="/tmp/broray-routes-operation-preflight-$$.err" + +cleanup() +{ + rm -f "$PLAN_FILE" "$RESULT_FILE" "$ERROR_FILE" \ + "$PLAN_FILE.actual.$$" "$PLAN_FILE.actual.$$.json" \ + "$PLAN_FILE.actual.$$.json.raw" "$PLAN_FILE.actual.$$.json.tsv" \ + "$PLAN_FILE.actual.$$.json.err" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && + broray_routes_api_lock_release +} +trap cleanup EXIT HUP INT TERM + +if [ ! -r "$AUTH_COMMON" ]; then + printf '%s\r\n' 'Status: 500 Internal Server Error' + printf '%s\r\n' 'Content-Type: application/json; charset=utf-8' + printf '%s\r\n' 'Cache-Control: no-store' + printf '\r\n' + printf '%s\n' '{"success":false,"data":null,"error":{"code":"AUTH_MODULE_UNAVAILABLE","message":"Модуль авторизации недоступен."}}' + exit 0 +fi +. "$AUTH_COMMON" +broray_api_require_method POST +broray_api_require_session + +[ -x "$CLI" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_CLI_UNAVAILABLE" \ + "Команда управления маршрутами недоступна." +[ -r "$API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." +[ -r "$PREFLIGHT_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_PREFLIGHT_UNAVAILABLE" \ + "Модуль предварительной проверки недоступен." +. "$API_LOCK_LIBRARY" +. "$PREFLIGHT_LIBRARY" + +bundle_id="" +action="" +old_ifs="$IFS" +IFS='&' +set -- ${QUERY_STRING:-} +IFS="$old_ifs" +for item in "$@"; do + case "$item" in + bundleId=*) bundle_id="${item#bundleId=}" ;; + action=*) action="${item#action=}" ;; + esac +done + +case "$bundle_id" in + ''|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) + broray_api_error "400 Bad Request" "ROUTES_BUNDLE_INVALID" \ + "Некорректный идентификатор набора маршрутов." + ;; +esac +case "$action" in + export|delete|resume) ;; + *) broray_api_error "400 Bad Request" "ROUTES_PREFLIGHT_ACTION_INVALID" \ + "Некорректный тип предварительной проверки." ;; +esac +jq -e --arg id "$bundle_id" '.schemaVersion == 1 and (.bundles | index($id) != null)' \ + "$BUNDLES" >/dev/null 2>&1 || broray_api_error \ + "404 Not Found" "ROUTES_BUNDLE_NOT_FOUND" "Набор маршрутов не найден." + +lock_rc=0 +broray_routes_api_lock_acquire "preflight:$action" "$bundle_id" || lock_rc=$? +case "$lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; +esac + +resolved_values="$(broray_routes_operation_preflight_resolve_action "$bundle_id" "$action")" || { + rc=$? + [ "$rc" -eq 2 ] && broray_api_error "409 Conflict" "ROUTES_RESUME_NOT_READY" \ + "Для этого набора нет операции, которую можно продолжить." + broray_api_error "400 Bad Request" "ROUTES_PREFLIGHT_ACTION_INVALID" \ + "Не удалось определить тип операции." +} +resolved_action="$(printf '%s' "$resolved_values" | cut -f1)" +saved_operation="$(printf '%s' "$resolved_values" | cut -f2)" + +if [ "$resolved_action" = export ]; then + if ! "$CLI" plan "$bundle_id" >"$PLAN_FILE" 2>"$ERROR_FILE"; then + details="$(tail -n 40 "$ERROR_FILE" 2>/dev/null)" + if printf '%s\n' "$details" | grep -Fq 'Другая операция с маршрутами уже выполняется'; then + broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая операция с маршрутами уже выполняется." "$details" + fi + broray_api_error "409 Conflict" "ROUTES_PREFLIGHT_PLAN_FAILED" \ + "Предварительная проверка установки в Keenetic не завершена." "$details" + fi +else + broray_routes_operation_preflight_delete_plan "$bundle_id" "$PLAN_FILE" || + broray_api_error "409 Conflict" "ROUTES_PREFLIGHT_DELETE_FAILED" \ + "Предварительная проверка удаления из Keenetic не завершена." +fi + +broray_routes_operation_preflight_finalize \ + "$bundle_id" "$action" "$resolved_action" "$saved_operation" \ + "$PLAN_FILE" "$RESULT_FILE" || broray_api_error \ + "500 Internal Server Error" "ROUTES_PREFLIGHT_BUILD_FAILED" \ + "Не удалось сформировать результат предварительной проверки." + +result_json="$(jq -c . "$RESULT_FILE")" || broray_api_error \ + "500 Internal Server Error" "ROUTES_PREFLIGHT_READ_FAILED" \ + "Не удалось прочитать результат предварительной проверки." +broray_api_success "$result_json" diff --git a/root/opt/broray/web-new/api/routes/progress.cgi b/root/opt/broray/web-new/api/routes/progress.cgi new file mode 100755 index 0000000..4c75b20 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/progress.cgi @@ -0,0 +1,96 @@ +#!/opt/bin/ash + +set -u + +PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" +export PATH + +AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" +BUNDLES="/opt/broray/routes/bundles.json" +PROGRESS_LIBRARY="/opt/broray/lib/routes-operation-progress.sh" +PROGRESS_FILE="/tmp/broray-routes-progress-$$.json" + +cleanup() +{ + rm -f "$PROGRESS_FILE" +} + +trap cleanup EXIT HUP INT TERM + +if [ ! -r "$AUTH_COMMON" ]; then + printf '%s\r\n' 'Status: 500 Internal Server Error' + printf '%s\r\n' 'Content-Type: application/json; charset=utf-8' + printf '%s\r\n' 'Cache-Control: no-store' + printf '\r\n' + printf '%s\n' \ + '{"success":false,"data":null,"error":{"code":"AUTH_MODULE_UNAVAILABLE","message":"Модуль авторизации недоступен."}}' + exit 0 +fi + +. "$AUTH_COMMON" + +broray_api_require_method GET +broray_api_require_session + +query="${QUERY_STRING:-}" +case "$query" in + bundleId=*) bundle_id="${query#bundleId=}" ;; + *) + broray_api_error \ + "400 Bad Request" \ + "ROUTES_BUNDLE_REQUIRED" \ + "Не указан идентификатор набора маршрутов." + ;; +esac + +case "$bundle_id" in + ''|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) + broray_api_error \ + "400 Bad Request" \ + "ROUTES_BUNDLE_INVALID" \ + "Некорректный идентификатор набора маршрутов." + ;; +esac + +if [ ! -r "$BUNDLES" ] || + ! jq -e --arg bundleId "$bundle_id" ' + (.schemaVersion == 1) and + ((.bundles | type) == "array") and + (.bundles | index($bundleId) != null) + ' "$BUNDLES" >/dev/null 2>&1 +then + broray_api_error \ + "404 Not Found" \ + "ROUTES_BUNDLE_NOT_FOUND" \ + "Набор маршрутов не найден." +fi + +[ -r "$PROGRESS_LIBRARY" ] || + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_PROGRESS_UNAVAILABLE" \ + "Модуль прогресса операций недоступен." + +. "$PROGRESS_LIBRARY" + +broray_routes_progress_read "$bundle_id" >"$PROGRESS_FILE" || + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_PROGRESS_INVALID" \ + "Состояние прогресса операции повреждено." + +jq -e --arg bundleId "$bundle_id" ' + (.schemaVersion == 1) and + (.kind == "routes") and + (.bundleId == $bundleId) and + ((.running | type) == "boolean") and + ((.current | type) == "number") and + ((.total | type) == "number") and + ((.percent | type) == "number") +' "$PROGRESS_FILE" >/dev/null 2>&1 || + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_PROGRESS_INVALID" \ + "Состояние прогресса операции повреждено." + +broray_api_success "$(jq -c . "$PROGRESS_FILE")" diff --git a/root/opt/broray/web-new/api/routes/resume.cgi b/root/opt/broray/web-new/api/routes/resume.cgi new file mode 100755 index 0000000..5ec8998 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/resume.cgi @@ -0,0 +1,5 @@ +#!/opt/bin/ash + +ROUTES_ACTION="resume" +export ROUTES_ACTION +exec /opt/broray/lib/routes-web-action.sh diff --git a/root/opt/broray/web-new/api/routes/status.cgi b/root/opt/broray/web-new/api/routes/status.cgi index 2208a7e..122243f 100755 --- a/root/opt/broray/web-new/api/routes/status.cgi +++ b/root/opt/broray/web-new/api/routes/status.cgi @@ -3,12 +3,16 @@ AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" BUNDLES="/opt/broray/routes/bundles.json" PRESENCE_LIBRARY="/opt/broray/lib/routes-router-presence.sh" +PROGRESS_LIBRARY="/opt/broray/lib/routes-operation-progress.sh" +API_LOCK_LIBRARY="/opt/broray/lib/routes-api-operation.sh" ERROR_FILE="/tmp/broray-routes-status-$$.err" PRESENCE_FILE="/tmp/broray-routes-presence-$$.json" +PROGRESS_FILE="/tmp/broray-routes-progress-status-$$.json" +GLOBAL_OPERATION_FILE="/tmp/broray-routes-global-operation-$$.json" cleanup() { - rm -f "$ERROR_FILE" "$PRESENCE_FILE" + rm -f "$ERROR_FILE" "$PRESENCE_FILE" "$PROGRESS_FILE" "$GLOBAL_OPERATION_FILE" } trap cleanup EXIT HUP INT TERM @@ -82,7 +86,9 @@ if ! jq -e \ ((.availableVersion == null) or ((.availableVersion | type) == "object")) and ((.downloadedVersion == null) or ((.downloadedVersion | type) == "object")) and ((.installedVersion == null) or ((.installedVersion | type) == "object")) and - ((.lastError == null) or ((.lastError | type) == "string") or ((.lastError | type) == "object")) + ((.lastError == null) or ((.lastError | type) == "string") or ((.lastError | type) == "object")) and + ((.lastVerifiedAt == null) or ((.lastVerifiedAt | type) == "string")) and + ((.verifyResult == null) or ((.verifyResult | type) == "object")) ' "$STATE_FILE" >/dev/null 2>"$ERROR_FILE" then details="$(tail -n 20 "$ERROR_FILE" 2>/dev/null)" @@ -119,9 +125,54 @@ if [ -r "$PRESENCE_LIBRARY" ]; then fi fi +progress_json="$( + jq -n --arg bundleId "$bundle_id" ' + { + schemaVersion: 1, + kind: "routes", + bundleId: $bundleId, + operation: null, + phase: "idle", + current: 0, + total: 0, + percent: 0, + currentRoute: null, + message: "Операция не выполняется.", + running: false, + success: null, + rolledBack: false, + pid: null, + startedAt: null, + updatedAt: null, + completedAt: null + } + ' +)" + +if [ -r "$PROGRESS_LIBRARY" ]; then + . "$PROGRESS_LIBRARY" + if broray_routes_progress_read "$bundle_id" >"$PROGRESS_FILE" && + jq -e 'type == "object"' "$PROGRESS_FILE" >/dev/null 2>&1 + then + progress_json="$(jq -c . "$PROGRESS_FILE")" + fi +fi + +global_operation_json='{"active":false,"pid":null,"scope":null,"action":null,"bundleId":null,"startedAt":null,"stale":false}' +if [ -r "$API_LOCK_LIBRARY" ]; then + . "$API_LOCK_LIBRARY" + if broray_routes_api_lock_read_json >"$GLOBAL_OPERATION_FILE" && + jq -e 'type == "object"' "$GLOBAL_OPERATION_FILE" >/dev/null 2>&1 + then + global_operation_json="$(jq -c . "$GLOBAL_OPERATION_FILE")" + fi +fi + data_json="$( jq -c \ - --argjson router_presence "$presence_json" ' + --argjson router_presence "$presence_json" \ + --argjson operation_progress "$progress_json" \ + --argjson global_operation "$global_operation_json" ' { schemaVersion, bundleId, @@ -131,18 +182,22 @@ data_json="$( installedVersion, routeCount, lastCheckedAt, + lastVerifiedAt, lastDownloadedAt, lastExportedAt, lastDeletedAt, lastError, checkResult, + verifyResult, downloadResult, exportBuild, preflight, exportResult, deleteResult, updatedAt, - routerPresence: $router_presence + routerPresence: $router_presence, + operationProgress: $operation_progress, + globalOperation: $global_operation } ' "$STATE_FILE" )" || { diff --git a/root/opt/broray/web-new/api/routes/stop.cgi b/root/opt/broray/web-new/api/routes/stop.cgi new file mode 100755 index 0000000..2925945 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/stop.cgi @@ -0,0 +1,50 @@ +#!/opt/bin/ash + +set -u + +AUTH_COMMON="/opt/broray/web-new/api/auth-common.sh" +PROGRESS_LIBRARY="/opt/broray/lib/routes-operation-progress.sh" +BUNDLES="/opt/broray/routes/bundles.json" + +if [ ! -r "$AUTH_COMMON" ]; then + printf '%s\r\n' 'Status: 500 Internal Server Error' + printf '%s\r\n' 'Content-Type: application/json; charset=utf-8' + printf '%s\r\n' 'Cache-Control: no-store' + printf '\r\n' + printf '%s\n' '{"success":false,"data":null,"error":{"code":"AUTH_MODULE_UNAVAILABLE","message":"Модуль авторизации недоступен."}}' + exit 0 +fi +. "$AUTH_COMMON" +broray_api_require_method POST +broray_api_require_session + +[ -r "$PROGRESS_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_PROGRESS_UNAVAILABLE" \ + "Модуль прогресса маршрутов недоступен." +. "$PROGRESS_LIBRARY" + +query="${QUERY_STRING:-}" +case "$query" in bundleId=*) bundle_id="${query#bundleId=}" ;; *) bundle_id="" ;; esac +case "$bundle_id" in + ''|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) + broray_api_error "400 Bad Request" "ROUTES_BUNDLE_INVALID" \ + "Некорректный идентификатор набора маршрутов." + ;; +esac +jq -e --arg id "$bundle_id" '.bundles | index($id) != null' "$BUNDLES" >/dev/null 2>&1 || + broray_api_error "404 Not Found" "ROUTES_BUNDLE_NOT_FOUND" "Набор маршрутов не найден." + +if ! broray_routes_progress_request_stop "$bundle_id"; then + rc=$? + case "$rc" in + 2|3) broray_api_error "409 Conflict" "ROUTES_STOP_NOT_RUNNING" \ + "Операция с этим набором сейчас не выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_STOP_FAILED" \ + "Не удалось запросить безопасную остановку операции." ;; + esac +fi + +data_json="$(broray_routes_progress_read "$bundle_id" | jq -c '.')" || + broray_api_error "500 Internal Server Error" "ROUTES_PROGRESS_READ_FAILED" \ + "Не удалось прочитать состояние операции." +broray_api_success "$data_json" diff --git a/root/opt/broray/web-new/api/routes/verify.cgi b/root/opt/broray/web-new/api/routes/verify.cgi new file mode 100644 index 0000000..2d4bc85 --- /dev/null +++ b/root/opt/broray/web-new/api/routes/verify.cgi @@ -0,0 +1,404 @@ +#!/opt/bin/ash + +set -u + +PATH="/opt/broray/bin:/opt/sbin:/opt/bin:/usr/sbin:/usr/bin:/sbin:/bin" +export PATH + +ROOT="${BRORAY_ROOT:-/opt/broray}" +AUTH_COMMON="$ROOT/web-new/api/auth-common.sh" +API_LOCK_LIBRARY="$ROOT/lib/routes-api-operation.sh" +CLI="$ROOT/bin/broray-routes" +BUNDLES="$ROOT/routes/bundles.json" +STATE_DIR="$ROOT/routes/state" + +BUILD_OUTPUT="/tmp/broray-routes-verify-build-$$.out" +BUILD_ERROR="/tmp/broray-routes-verify-build-$$.err" +PLAN_OUTPUT="/tmp/broray-routes-verify-plan-$$.json" +PLAN_ERROR="/tmp/broray-routes-verify-plan-$$.err" +STATE_NEW="" + +cleanup() +{ + rm -f \ + "$BUILD_OUTPUT" \ + "$BUILD_ERROR" \ + "$PLAN_OUTPUT" \ + "$PLAN_ERROR" + [ -n "$STATE_NEW" ] && rm -f "$STATE_NEW" + command -v broray_routes_api_lock_release >/dev/null 2>&1 && + broray_routes_api_lock_release +} + +trap cleanup EXIT HUP INT TERM + +if [ ! -r "$AUTH_COMMON" ]; then + printf '%s\r\n' 'Status: 500 Internal Server Error' + printf '%s\r\n' 'Content-Type: application/json; charset=utf-8' + printf '%s\r\n' 'Cache-Control: no-store' + printf '\r\n' + printf '%s\n' \ + '{"success":false,"data":null,"error":{"code":"AUTH_MODULE_UNAVAILABLE","message":"Модуль авторизации недоступен."}}' + exit 0 +fi + +. "$AUTH_COMMON" + +broray_api_require_method POST +broray_api_require_session + +if [ ! -x "$CLI" ]; then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_CLI_UNAVAILABLE" \ + "Команда управления маршрутами недоступна." +fi + +query="${QUERY_STRING:-}" +case "$query" in + bundleId=*) bundle_id="${query#bundleId=}" ;; + *) + broray_api_error \ + "400 Bad Request" \ + "ROUTES_BUNDLE_REQUIRED" \ + "Не указан идентификатор набора маршрутов." + ;; +esac + +case "$bundle_id" in + ""|*[!a-z0-9_-]*|????????????????????????????????????????????????????????????????*) + broray_api_error \ + "400 Bad Request" \ + "ROUTES_BUNDLE_INVALID" \ + "Некорректный идентификатор набора маршрутов." + ;; +esac + +if [ ! -r "$BUNDLES" ] || + ! jq -e \ + --arg bundle_id "$bundle_id" ' + (.schemaVersion == 1) and + ((.bundles | type) == "array") and + (.bundles | index($bundle_id) != null) + ' "$BUNDLES" >/dev/null 2>&1 +then + broray_api_error \ + "404 Not Found" \ + "ROUTES_BUNDLE_NOT_FOUND" \ + "Набор маршрутов не найден." +fi + +[ -r "$API_LOCK_LIBRARY" ] || broray_api_error \ + "500 Internal Server Error" "ROUTES_API_LOCK_UNAVAILABLE" \ + "Модуль блокировки операций недоступен." +. "$API_LOCK_LIBRARY" +lock_rc=0 +broray_routes_api_lock_acquire "verify" "$bundle_id" || lock_rc=$? +case "$lock_rc" in + 0) ;; + 2) broray_api_error "409 Conflict" "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." ;; + *) broray_api_error "500 Internal Server Error" "ROUTES_API_LOCK_FAILED" \ + "Не удалось установить блокировку операции." ;; +esac + +STATE_FILE="$STATE_DIR/$bundle_id.json" +STATE_NEW="$STATE_FILE.new.$$" +if [ ! -r "$STATE_FILE" ]; then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_STATE_UNAVAILABLE" \ + "Локальное состояние маршрутов недоступно." +fi + +if ! jq -e \ + --arg bundle_id "$bundle_id" ' + (.schemaVersion == 1) and + (.bundleId == $bundle_id) and + ((.downloadedVersion == null) or ((.downloadedVersion | type) == "object")) +' "$STATE_FILE" >/dev/null 2>&1 +then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_STATE_INVALID" \ + "Локальное состояние маршрутов повреждено." +fi + +if ! jq -e '.downloadedVersion != null' "$STATE_FILE" >/dev/null 2>&1; then + broray_api_error \ + "409 Conflict" \ + "ROUTES_SET_NOT_DOWNLOADED" \ + "Сначала скачайте набор маршрутов." +fi + +now="$(date '+%Y-%m-%dT%H:%M:%S%z')" + +if ! "$CLI" build-export "$bundle_id" >"$BUILD_OUTPUT" 2>"$BUILD_ERROR"; then + details="$( + { + tail -n 20 "$BUILD_ERROR" 2>/dev/null + tail -n 20 "$BUILD_OUTPUT" 2>/dev/null + } | tail -n 30 + )" + + if printf '%s\n' "$details" | grep -Fq 'Другая операция с маршрутами уже выполняется'; then + broray_api_error \ + "409 Conflict" \ + "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." \ + "$details" + fi + + message="Локальный набор маршрутов не прошёл проверку. Скачайте файлы заново." + + if jq \ + --arg now "$now" \ + --arg message "$message" \ + --arg details "$details" ' + .lastVerifiedAt = $now | + .verifyResult = { + result: "invalid_local", + success: false, + message: $message, + checkedAt: $now, + contentSha256: (.downloadedVersion.contentSha256 // null), + local: { + valid: false, + routeCount: (.routeCount // 0), + sourceFileCount: (.downloadedVersion.sourceFileCount // 0), + duplicateRouteCount: null, + invalidRouteCount: null + }, + keenetic: { + checked: false, + available: null, + status: "not_checked", + expectedRouteCount: null, + presentRouteCount: null, + missingRouteCount: null, + conflictCount: null, + externalRouteCount: null, + complete: null, + updatePending: null + } + } | + .lastError = { + code: "ROUTES_SET_INVALID", + message: $message, + details: $details + } | + .updatedAt = $now + ' "$STATE_FILE" >"$STATE_NEW" && + chmod 644 "$STATE_NEW" 2>/dev/null && + mv "$STATE_NEW" "$STATE_FILE" + then + : + else + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_VERIFY_STATE_WRITE_FAILED" \ + "Не удалось сохранить результат проверки набора." + fi + + broray_api_error \ + "422 Unprocessable Entity" \ + "ROUTES_SET_INVALID" \ + "$message" \ + "$details" +fi + +if ! "$CLI" plan "$bundle_id" >"$PLAN_OUTPUT" 2>"$PLAN_ERROR"; then + details="$( + { + tail -n 20 "$PLAN_ERROR" 2>/dev/null + tail -n 20 "$PLAN_OUTPUT" 2>/dev/null + } | tail -n 30 + )" + + if printf '%s\n' "$details" | grep -Fq 'Другая операция с маршрутами уже выполняется'; then + broray_api_error \ + "409 Conflict" \ + "ROUTES_OPERATION_BUSY" \ + "Другая конфликтующая операция уже выполняется." \ + "$details" + fi + + message="Локальный набор исправен, но проверить его состояние в Keenetic не удалось." + + if jq \ + --arg now "$now" \ + --arg message "$message" \ + --arg details "$details" ' + .lastVerifiedAt = $now | + .verifyResult = { + result: "router_unavailable", + success: false, + message: $message, + checkedAt: $now, + contentSha256: (.downloadedVersion.contentSha256 // null), + local: { + valid: true, + routeCount: (.routeCount // 0), + sourceFileCount: (.downloadedVersion.sourceFileCount // 0), + duplicateRouteCount: 0, + invalidRouteCount: 0 + }, + keenetic: { + checked: false, + available: false, + status: "unavailable", + expectedRouteCount: null, + presentRouteCount: null, + missingRouteCount: null, + conflictCount: null, + externalRouteCount: null, + complete: null, + updatePending: null + } + } | + .lastError = { + code: "ROUTES_VERIFY_ROUTER_FAILED", + message: $message, + details: $details + } | + .updatedAt = $now + ' "$STATE_FILE" >"$STATE_NEW" && + chmod 644 "$STATE_NEW" 2>/dev/null && + mv "$STATE_NEW" "$STATE_FILE" + then + : + else + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_VERIFY_STATE_WRITE_FAILED" \ + "Не удалось сохранить результат проверки набора." + fi + + broray_api_error \ + "502 Bad Gateway" \ + "ROUTES_VERIFY_ROUTER_FAILED" \ + "$message" \ + "$details" +fi + +if ! jq -e \ + --arg bundle_id "$bundle_id" ' + (.schemaVersion == 1) and + (.bundleId == $bundle_id) and + ((.mode | type) == "string") and + ((.canApply | type) == "boolean") and + ((.summary | type) == "object") and + ((.summary.total | type) == "number") +' "$PLAN_OUTPUT" >/dev/null 2>&1 +then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_VERIFY_PLAN_INVALID" \ + "Результат проверки Keenetic повреждён." +fi + +if ! jq \ + --arg now "$now" \ + --slurpfile plan "$PLAN_OUTPUT" ' + $plan[0] as $p | + (.installedVersion != null) as $installed | + ( + $installed and + ((.downloadedVersion.contentSha256 // "") != (.installedVersion.contentSha256 // "")) + ) as $updatePending | + ($p.summary.total // 0) as $total | + ($p.summary.managedExisting // 0) as $present | + ($p.summary.toCreate // 0) as $missing | + ($p.summary.conflicts // 0) as $conflicts | + ($p.summary.externalExisting // 0) as $external | + ( + if ($p.canApply | not) then "conflict" + elif ($installed | not) then "not_installed" + elif $updatePending then "update_pending" + elif ($missing > 0) or (($p.summary.sharedToRestore // 0) > 0) then "restore_required" + else "complete" + end + ) as $keeneticStatus | + ( + $installed and + ($updatePending | not) and + $p.canApply and + ($missing == 0) and + ($conflicts == 0) and + ($external == 0) + ) as $complete | + ( + if $keeneticStatus == "conflict" then + "Локальный набор исправен, но в Keenetic обнаружены конфликты: " + ($conflicts | tostring) + "." + elif $keeneticStatus == "not_installed" then + "Набор исправен: " + ($total | tostring) + " маршрутов, ошибок и дубликатов нет. В Keenetic набор не установлен." + elif $keeneticStatus == "update_pending" then + "Набор исправен. Загруженная версия содержит " + ($total | tostring) + " маршрутов и ожидает обновления в Keenetic." + elif $keeneticStatus == "restore_required" then + "Набор исправен. В Keenetic найдено " + ($present | tostring) + " из " + ($total | tostring) + " маршрутов; отсутствует " + ($missing | tostring) + "." + else + "Набор исправен. В Keenetic найдено " + ($total | tostring) + " из " + ($total | tostring) + " маршрутов." + end + ) as $message | + .lastVerifiedAt = $now | + .verifyResult = { + result: $keeneticStatus, + success: ($keeneticStatus != "conflict"), + message: $message, + checkedAt: $now, + contentSha256: (.downloadedVersion.contentSha256 // null), + local: { + valid: true, + routeCount: $total, + sourceFileCount: (.downloadedVersion.sourceFileCount // 0), + duplicateRouteCount: 0, + invalidRouteCount: 0 + }, + keenetic: { + checked: true, + available: true, + status: $keeneticStatus, + expectedRouteCount: $total, + presentRouteCount: $present, + missingRouteCount: $missing, + conflictCount: $conflicts, + externalRouteCount: $external, + complete: $complete, + updatePending: $updatePending, + mode: $p.mode, + canApply: $p.canApply, + targetInterface: $p.targetInterface, + managedMetric: $p.managedMetric + } + } | + .lastError = null | + .operation = { + type: "verify", + completedAt: $now, + output: $message + } | + .updatedAt = $now +' "$STATE_FILE" >"$STATE_NEW" +then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_VERIFY_STATE_BUILD_FAILED" \ + "Не удалось сформировать результат проверки набора." +fi + +chmod 644 "$STATE_NEW" 2>/dev/null || true +if ! mv "$STATE_NEW" "$STATE_FILE"; then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_VERIFY_STATE_WRITE_FAILED" \ + "Не удалось сохранить результат проверки набора." +fi + +if ! data_json="$(jq -c . "$STATE_FILE")"; then + broray_api_error \ + "500 Internal Server Error" \ + "ROUTES_STATE_READ_FAILED" \ + "Не удалось прочитать результат проверки набора." +fi + +broray_api_success "$data_json" diff --git a/root/opt/broray/web-new/assets/css/allpage.css b/root/opt/broray/web-new/assets/css/allpage.css index 939e31c..30b121c 100644 --- a/root/opt/broray/web-new/assets/css/allpage.css +++ b/root/opt/broray/web-new/assets/css/allpage.css @@ -864,6 +864,7 @@ label, } .sidebar-link, +.sidebar-support, .sidebar-logout { display: flex; width: 100%; @@ -886,6 +887,7 @@ label, } .sidebar-link:hover, +.sidebar-support:hover, .sidebar-logout:hover { color: var(--ui-text); background: var(--ui-surface-muted); @@ -943,10 +945,21 @@ label, text-overflow: ellipsis; } +.sidebar-support, .sidebar-logout { border-color: var(--ui-border); } +.sidebar-support { + color: var(--ui-active); +} + +.sidebar-support .sidebar-link-icon { + border-color: var(--ui-success-border); + color: var(--ui-success); + background: var(--ui-success-soft); +} + .app-layout.sidebar-is-collapsed .sidebar-brand-copy, .app-layout.sidebar-is-collapsed .sidebar-link-label, .app-layout.sidebar-is-collapsed .sidebar-user-copy { @@ -968,6 +981,7 @@ label, } .app-layout.sidebar-is-collapsed .sidebar-link, +.app-layout.sidebar-is-collapsed .sidebar-support, .app-layout.sidebar-is-collapsed .sidebar-logout, .app-layout.sidebar-is-collapsed .sidebar-user { justify-content: center; @@ -1406,6 +1420,53 @@ label, cursor: wait; } + +.support-modal { + width: min(100%, 620px); +} + +.support-modal-content, +.broray-support-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: 22px; + margin-top: 20px; +} + +.support-qr-link { + display: grid; + width: min(220px, 100%); + padding: 10px; + place-items: center; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-md); + background: var(--ui-on-error); +} + +.support-qr-image { + display: block; + width: 100%; + height: auto; +} + +.support-modal-copy, +.broray-support-copy { + display: grid; + gap: 12px; +} + +.support-modal-copy p, +.broray-support-copy p { + margin: 0; +} + +.support-direct-link { + overflow-wrap: anywhere; + font-size: 13px; + font-weight: 680; +} + /* ========================================================= 9. Shared content compatibility ========================================================= */ @@ -2196,6 +2257,15 @@ pre { font-size: clamp(28px, 10vw, 38px); } + .support-modal-content, + .broray-support-panel { + grid-template-columns: 1fr; + } + + .support-qr-link { + justify-self: center; + } + .modal-actions { align-items: stretch; flex-direction: column-reverse; @@ -4650,6 +4720,34 @@ body.modal-open { .page-broray .broray-protocols-card, .page-broray .broray-capabilities-card, .page-broray .broray-project-card, + +.page-broray .broray-support-details { + overflow: hidden; + border: 1px solid var(--ui-success-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-success-soft); +} + +.page-broray .broray-support-details > summary { + border: 0; + border-radius: 0; + list-style: none; + cursor: pointer; +} + +.page-broray .broray-support-details > summary::-webkit-details-marker { + display: none; +} + +.page-broray .broray-support-details[open] > summary { + border-bottom: 1px solid var(--ui-success-border); +} + +.page-broray .broray-support-panel { + margin: 0; + padding: 16px; +} + .page-broray .broray-operation-card, .page-broray .broray-danger-zone { min-width: 0; @@ -4759,6 +4857,15 @@ body.modal-open { gap: 10px; } +.page-broray #reinstall-current { + grid-column: 1 / -1; +} + +.page-broray .broray-reinstall-note { + grid-column: 1 / -1; + margin: -8px 0 0; +} + .page-broray .broray-information-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -4946,6 +5053,170 @@ body.modal-open { background: var(--ui-success-soft); } + +.page-broray .broray-support-details { + overflow: hidden; + border: 1px solid var(--ui-success-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-success-soft); +} + +.page-broray .broray-support-details > summary { + border: 0; + border-radius: 0; + list-style: none; + cursor: pointer; +} + +.page-broray .broray-support-details > summary::-webkit-details-marker { + display: none; +} + +.page-broray .broray-support-details[open] > summary { + border-bottom: 1px solid var(--ui-success-border); +} + +.page-broray .broray-support-panel { + margin: 0; + padding: 16px; +} + + +.page-broray .broray-cleanup-card { + margin-bottom: 18px; +} + +.page-broray .broray-cleanup-options { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; +} + +.page-broray .broray-cleanup-option { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: start; + gap: 11px; + padding: 13px 14px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface-muted); + cursor: pointer; +} + +.page-broray .broray-cleanup-option:has(input:checked) { + border-color: var(--ui-active-border); + background: var(--ui-active-soft); +} + +.page-broray .broray-cleanup-option input { + width: 18px; + height: 18px; + margin: 2px 0 0; + accent-color: var(--ui-active); +} + +.page-broray .broray-cleanup-option span, +.page-broray .broray-cleanup-option strong, +.page-broray .broray-cleanup-option small { + display: block; +} + +.page-broray .broray-cleanup-option small { + margin-top: 4px; + color: var(--ui-text-muted); + line-height: 1.45; +} + +.page-broray .broray-cleanup-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 16px; +} + +.page-broray .broray-cleanup-result { + margin-top: 16px; + padding: 14px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface-muted); +} + +.page-broray .broray-cleanup-summary { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin: 0; +} + +.page-broray .broray-cleanup-summary > div { + min-width: 0; + padding: 10px 11px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-xs); + background: var(--ui-surface); +} + +.page-broray .broray-cleanup-summary dt { + color: var(--ui-text-muted); + font-size: 12px; +} + +.page-broray .broray-cleanup-summary dd { + margin: 5px 0 0; + font-weight: 760; +} + +.page-broray .broray-cleanup-details { + margin-top: 12px; +} + +.page-broray .broray-cleanup-details summary { + display: flex; + align-items: center; + gap: 8px; + color: var(--ui-text-secondary); + font-weight: 700; + cursor: pointer; +} + +.page-broray .broray-cleanup-candidates { + display: grid; + gap: 7px; + max-height: 260px; + margin: 12px 0 0; + overflow: auto; + padding: 0; + list-style: none; +} + +.page-broray .broray-cleanup-candidates li { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 14px; + padding: 8px 9px; + border-radius: var(--ui-radius-xs); + background: var(--ui-surface-input); +} + +.page-broray .broray-cleanup-candidates code { + min-width: 0; + overflow-wrap: anywhere; +} + +.page-broray .broray-cleanup-candidates span { + flex: 0 0 auto; + color: var(--ui-text-muted); + font-size: 12px; +} + +.page-broray .broray-cleanup-candidates .is-empty { + color: var(--ui-text-muted); +} + .page-broray .broray-operation-card, .page-broray .broray-danger-zone { margin-bottom: 18px; @@ -5106,6 +5377,8 @@ body.modal-open { .page-broray .broray-page-heading, .page-broray .broray-overview-card, .page-broray .broray-update-card, + .page-broray .broray-cleanup-options, + .page-broray .broray-cleanup-summary, .page-broray .broray-information-grid, .page-broray .broray-danger-grid { grid-template-columns: 1fr; @@ -5163,6 +5436,7 @@ body.modal-open { text-align: left; } + .page-broray .broray-cleanup-actions .button, .page-broray .broray-operation-actions .button, .page-broray .broray-danger-option .button { width: 100%; @@ -5344,3 +5618,364 @@ body.modal-open { } } /* END ROUTES ACTIONS HOTFIX 20260726 */ + +/* BEGIN ROUTES OPERATION PROGRESS 2.2.0 */ +.page-routes .route-card-progress { + display: grid; + gap: 9px; + padding: 13px; + border: 1px solid var(--ui-active-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-active-soft); +} + +.page-routes .route-card-progress[hidden] { + display: none; +} + +.page-routes .route-card-progress.is-success { + border-color: var(--ui-success-border); + background: var(--ui-success-soft); +} + +.page-routes .route-card-progress.is-error { + border-color: var(--ui-error-border); + background: var(--ui-error-soft); +} + +.page-routes .route-card-progress-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 14px; +} + +.page-routes .route-card-progress-title { + min-width: 0; + color: var(--ui-text); + font-size: 13px; + line-height: 1.35; +} + +.page-routes .route-card-progress-counter { + flex: 0 0 auto; + color: var(--ui-text); + font-size: 14px; + font-variant-numeric: tabular-nums; + font-weight: 800; + white-space: nowrap; +} + +.page-routes .route-card-progress-bar { + width: 100%; + height: 10px; + border: 0; + border-radius: 999px; + overflow: hidden; + accent-color: var(--ui-active); + background: var(--ui-surface-input); +} + +.page-routes .route-card-progress-bar::-webkit-progress-bar { + border-radius: 999px; + background: var(--ui-surface-input); +} + +.page-routes .route-card-progress-bar::-webkit-progress-value { + border-radius: 999px; + background: var(--ui-active); +} + +.page-routes .route-card-progress-bar::-moz-progress-bar { + border-radius: 999px; + background: var(--ui-active); +} + +.page-routes .route-card-progress.is-success .route-card-progress-bar { + accent-color: var(--ui-success); +} + +.page-routes .route-card-progress.is-success .route-card-progress-bar::-webkit-progress-value, +.page-routes .route-card-progress.is-success .route-card-progress-bar::-moz-progress-bar { + background: var(--ui-success); +} + +.page-routes .route-card-progress.is-error .route-card-progress-bar { + accent-color: var(--ui-error); +} + +.page-routes .route-card-progress.is-error .route-card-progress-bar::-webkit-progress-value, +.page-routes .route-card-progress.is-error .route-card-progress-bar::-moz-progress-bar { + background: var(--ui-error); +} + +.page-routes .route-card-progress-message, +.page-routes .route-card-progress-route { + margin: 0; + color: var(--ui-text-secondary); + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.page-routes .route-card-progress-route { + color: var(--ui-text-muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-variant-numeric: tabular-nums; +} + +@media (max-width: 560px) { + .page-routes .route-card-progress-header { + align-items: flex-start; + flex-direction: column; + gap: 5px; + } +} +/* END ROUTES OPERATION PROGRESS 2.2.0 */ + +/* BEGIN ROUTES SAFE STOP AND RESUME 2.2.0 */ +.page-routes .route-card-progress.is-paused { + border-color: var(--ui-warning-border); + background: var(--ui-warning-soft); +} + +.page-routes .route-card-progress-controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + padding-top: 2px; +} + +.page-routes .route-card-progress-controls .button { + min-height: 36px; +} + +.page-routes .route-bundle-card.is-paused { + border-color: var(--ui-warning-border); +} + +@media (max-width: 560px) { + .page-routes .route-card-progress-controls, + .page-routes .route-card-progress-controls .button { + width: 100%; + } +} +/* END ROUTES SAFE STOP AND RESUME 2.2.0 */ + +.page-routes #confirm-message { + white-space: pre-line; + line-height: 1.55; +} + +/* BEGIN ROUTES DNS-OVER-TLS 2.2.0 */ +.page-routes .routes-dot-card { + display: grid; + gap: 18px; + margin-bottom: 18px; + padding: 22px; +} + +.page-routes .routes-dot-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 18px; +} + +.page-routes .routes-dot-heading-copy { + min-width: 0; +} + +.page-routes .routes-dot-heading h2 { + margin: 4px 0 7px; + font-size: clamp(23px, 2.2vw, 30px); +} + +.page-routes .routes-dot-heading p, +.page-routes .routes-dot-tip { + margin: 0; + color: var(--ui-text-secondary); + font-size: 14px; + line-height: 1.55; +} + +.page-routes .routes-dot-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.page-routes .routes-dot-summary > div { + display: grid; + gap: 3px; + padding: 12px 14px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface-muted); +} + +.page-routes .routes-dot-summary strong { + color: var(--ui-text); + font-size: 22px; + font-variant-numeric: tabular-nums; +} + +.page-routes .routes-dot-summary span { + color: var(--ui-text-muted); + font-size: 11px; +} + +.page-routes .routes-dot-notice { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 10px; + align-items: start; + padding: 13px 14px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface-muted); +} + +.page-routes .routes-dot-notice.status-success { + border-color: var(--ui-success-border); + background: var(--ui-success-soft); +} + +.page-routes .routes-dot-notice.status-warning { + border-color: var(--ui-warning-border); + background: var(--ui-warning-soft); +} + +.page-routes .routes-dot-notice.status-error { + border-color: var(--ui-error-border); + background: var(--ui-error-soft); +} + +.page-routes .routes-dot-notice p { + margin: 0; + font-size: 13px; + line-height: 1.5; +} + +.page-routes .routes-dot-server-list { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.page-routes .routes-dot-server { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + min-width: 0; + padding: 13px 14px; + border: 1px solid var(--ui-border); + border-radius: var(--ui-radius-sm); + background: var(--ui-surface-muted); +} + +.page-routes .routes-dot-server.is-selected { + border-color: var(--ui-active-border); + background: var(--ui-active-soft); +} + +.page-routes .routes-dot-server input { + width: 18px; + height: 18px; + accent-color: var(--ui-active); +} + +.page-routes .routes-dot-server-copy { + min-width: 0; +} + +.page-routes .routes-dot-server-copy strong, +.page-routes .routes-dot-server-copy span, +.page-routes .routes-dot-server-copy code { + display: block; + overflow-wrap: anywhere; +} + +.page-routes .routes-dot-server-copy strong { + margin-bottom: 3px; + font-size: 13px; +} + +.page-routes .routes-dot-server-copy span, +.page-routes .routes-dot-server-copy code { + color: var(--ui-text-muted); + font-size: 11px; +} + +.page-routes .routes-dot-server-state { + display: grid; + justify-items: end; + gap: 4px; + font-size: 10px; + white-space: nowrap; +} + +.page-routes .routes-dot-server-state .is-ok { color: var(--ui-success); } +.page-routes .routes-dot-server-state .is-warning { color: var(--ui-warning); } +.page-routes .routes-dot-server-state .is-error { color: var(--ui-error); } + +.page-routes .routes-dot-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.page-routes .routes-dot-actions .button[data-recommended-action="true"] { + border-color: var(--ui-active); + color: var(--ui-active-text); + background: var(--ui-active); +} + +.page-routes .routes-dot-details { + border-top: 1px solid var(--ui-border); + padding-top: 12px; +} + +.page-routes .routes-dot-details summary { + cursor: pointer; + color: var(--ui-text-secondary); + font-size: 13px; +} + +.page-routes .routes-dot-details dl { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 18px; + margin: 14px 0 0; +} + +.page-routes .routes-dot-details dl > div { + display: flex; + justify-content: space-between; + gap: 12px; + padding-bottom: 7px; + border-bottom: 1px solid var(--ui-border); +} + +.page-routes .routes-dot-details dt, +.page-routes .routes-dot-details dd { + margin: 0; + font-size: 11px; +} + +.page-routes .routes-dot-details dt { color: var(--ui-text-muted); } +.page-routes .routes-dot-details dd { color: var(--ui-text-secondary); text-align: right; } + +@media (max-width: 760px) { + .page-routes .routes-dot-summary, + .page-routes .routes-dot-server-list, + .page-routes .routes-dot-actions, + .page-routes .routes-dot-details dl { + grid-template-columns: 1fr; + } + .page-routes .routes-dot-heading { + flex-direction: column; + } +} +/* END ROUTES DNS-OVER-TLS 2.2.0 */ diff --git a/root/opt/broray/web-new/assets/images/support/cloudtips-qr.svg b/root/opt/broray/web-new/assets/images/support/cloudtips-qr.svg new file mode 100644 index 0000000..65ef336 --- /dev/null +++ b/root/opt/broray/web-new/assets/images/support/cloudtips-qr.svg @@ -0,0 +1,2 @@ + +QR-код поддержки BROrayОткрывает страницу CloudTips для поддержки проекта BROray. \ No newline at end of file diff --git a/root/opt/broray/web-new/assets/js/app-shell.js b/root/opt/broray/web-new/assets/js/app-shell.js index 4701dc7..b69ccd1 100644 --- a/root/opt/broray/web-new/assets/js/app-shell.js +++ b/root/opt/broray/web-new/assets/js/app-shell.js @@ -15,6 +15,11 @@ var mobileButton = document.getElementById("mobile-menu-button"); var backdrop = document.getElementById("sidebar-backdrop"); var logoutButton = document.getElementById("logout-button"); + var supportButton = document.getElementById("support-button"); + var supportRoot = document.getElementById("support-root"); + var supportBackdrop = document.getElementById("support-backdrop"); + var supportClose = document.getElementById("support-close"); + var supportPreviousFocus = null; function isMobile() { return window.innerWidth <= MOBILE_BREAKPOINT; @@ -110,12 +115,42 @@ }); if (backdrop) backdrop.addEventListener("click", closeMobileMenu); document.addEventListener("keydown", function (event) { - if (event.key === "Escape") closeMobileMenu(); + if (event.key !== "Escape") return; + if (supportRoot && !supportRoot.hidden) closeSupport(); + else closeMobileMenu(); }); window.addEventListener("resize", syncLayout); syncLayout(); } + + function openSupport() { + if (!supportRoot) return; + supportPreviousFocus = document.activeElement; + closeMobileMenu(); + supportRoot.hidden = false; + document.body.classList.add("modal-open"); + if (supportClose) supportClose.focus(); + } + + function closeSupport() { + if (!supportRoot || supportRoot.hidden) return; + supportRoot.hidden = true; + document.body.classList.remove("modal-open"); + if (supportPreviousFocus && typeof supportPreviousFocus.focus === "function") { + supportPreviousFocus.focus(); + } + supportPreviousFocus = null; + } + + function bindSupport() { + if (!supportButton || !supportRoot || supportRoot.dataset.staticSupportBound === "true") return; + supportRoot.dataset.staticSupportBound = "true"; + supportButton.addEventListener("click", openSupport); + if (supportBackdrop) supportBackdrop.addEventListener("click", closeSupport); + if (supportClose) supportClose.addEventListener("click", closeSupport); + } + function bindLogout() { if (!logoutButton || logoutButton.dataset.staticLogoutBound === "true") return; logoutButton.dataset.staticLogoutBound = "true"; @@ -129,5 +164,6 @@ activateNavigation(); bindShell(); + bindSupport(); bindLogout(); })(); diff --git a/root/opt/broray/web-new/assets/js/broray.js b/root/opt/broray/web-new/assets/js/broray.js index 12478b9..6d56bff 100644 --- a/root/opt/broray/web-new/assets/js/broray.js +++ b/root/opt/broray/web-new/assets/js/broray.js @@ -9,7 +9,8 @@ var state = { info: null, pollTimer: null, - busy: false + busy: false, + cleanupPlan: null }; var app = document.getElementById("app"); var loader = document.getElementById("page-loader"); @@ -41,6 +42,21 @@ : date.toLocaleString("ru-RU"); } + function formatBytes(value) { + var bytes = Math.max(0, Number(value) || 0); + var units = ["Б", "КБ", "МБ", "ГБ"]; + var index = 0; + + while (bytes >= 1024 && index < units.length - 1) { + bytes /= 1024; + index += 1; + } + + return new Intl.NumberFormat("ru-RU", { + maximumFractionDigits: index === 0 ? 0 : 1 + }).format(bytes) + " " + units[index]; + } + function errorMessage(error) { if (error && error.payload && error.payload.error) { return error.payload.error.message || @@ -151,9 +167,15 @@ byId("check-update").disabled = state.busy; byId("install-update").disabled = state.busy || !info || !info.updateAvailable; + byId("reinstall-current").disabled = state.busy || !info || !info.reinstallSupported; byId("restore-backup").disabled = state.busy || !info || !info.lastBackup; byId("uninstall-normal").disabled = state.busy; byId("uninstall-full").disabled = state.busy; + byId("cleanup-plan").disabled = state.busy; + byId("cleanup-run").disabled = state.busy || !state.cleanupPlan || state.cleanupPlan.candidateCount < 1; + ["cleanup-temp", "cleanup-backups", "cleanup-route-backups", "cleanup-logs"].forEach(function (id) { + byId(id).disabled = state.busy; + }); } function setBusy(busy) { @@ -262,6 +284,7 @@ setText("build-description", "Сборка: " + (info.build || "не определена")); setText("architecture", info.architecture); setText("update-channel", info.updateChannel === "stable" ? "Стабильный" : info.updateChannel); + setText("installed-package-version", info.installedPackageVersion); setText( "available-version", info.updateAvailable ? (info.availableVersion || "Доступно") : "Не требуется" @@ -289,6 +312,7 @@ function operationLabel(operation) { return { update: "Обновление", + reinstall: "Переустановка", restore: "Восстановление", uninstall: "Удаление" }[operation] || "Операция"; @@ -476,6 +500,146 @@ return window.BROrayDialogs.confirm(options); } + function cleanupOptions() { + return { + temp: byId("cleanup-temp").checked, + backups: byId("cleanup-backups").checked, + routeBackups: byId("cleanup-route-backups").checked, + logs: byId("cleanup-logs").checked + }; + } + + function invalidateCleanupPlan() { + state.cleanupPlan = null; + byId("cleanup-result").hidden = true; + byId("cleanup-state").textContent = "Не проверено"; + byId("cleanup-state").className = "status-badge status-neutral"; + byId("cleanup-state").setAttribute("data-icon", "storage"); + applyControlState(); + } + + function renderCleanupPlan(plan) { + var list = byId("cleanup-candidates"); + var badge = byId("cleanup-state"); + + state.cleanupPlan = plan; + setText("cleanup-count", plan.candidateCount || 0); + setText("cleanup-bytes", formatBytes(plan.estimatedBytes)); + setText("cleanup-expiry", "действует " + (plan.ttlSeconds || 120) + " секунд"); + list.replaceChildren(); + (plan.candidates || []).forEach(function (candidate) { + var item = document.createElement("li"); + var path = document.createElement("code"); + var size = document.createElement("span"); + + path.textContent = candidate.path; + size.textContent = formatBytes(candidate.sizeBytes); + item.append(path, size); + list.appendChild(item); + }); + if (!(plan.candidates || []).length) { + var empty = document.createElement("li"); + empty.textContent = "Подходящих файлов не найдено."; + empty.className = "is-empty"; + list.appendChild(empty); + } + badge.textContent = plan.candidateCount > 0 ? "Готово к очистке" : "Очистка не требуется"; + badge.className = "status-badge " + (plan.candidateCount > 0 ? "status-warning" : "status-success"); + badge.setAttribute("data-icon", plan.candidateCount > 0 ? "storage" : "status"); + byId("cleanup-result").hidden = false; + applyControlState(); + } + + async function planCleanup(event) { + var button = event.currentTarget; + + if (state.busy) { + return; + } + hidePageError(); + setBusy(true); + setButtonBusy(button, true, "Проверка…"); + try { + var plan = await request("/api/broray/cleanup-plan.cgi", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-BROray-Request": "1" + }, + body: JSON.stringify(cleanupOptions()) + }); + renderCleanupPlan(plan); + toast( + plan.candidateCount > 0 + ? "План очистки сформирован." + : "Очистка не требуется.", + plan.candidateCount > 0 ? "info" : "success" + ); + } catch (error) { + invalidateCleanupPlan(); + showPageError(errorMessage(error)); + toast(errorMessage(error), "error"); + } finally { + setBusy(false); + setButtonBusy(button, false); + applyControlState(); + } + } + + async function runCleanup(event) { + var button = event.currentTarget; + var plan = state.cleanupPlan; + var confirmed; + + if (!plan || state.busy) { + return; + } + confirmed = await confirmAction({ + eyebrow: "Безопасная очистка", + title: "Удалить найденные служебные файлы?", + message: "Будет удалено объектов: " + plan.candidateCount + ". Ожидаемое освобождение: " + formatBytes(plan.estimatedBytes) + ". Неизвестные файлы и последние резервные копии не затрагиваются.", + confirmText: "Очистить", + variant: "primary", + icon: "delete" + }); + if (!confirmed) { + return; + } + + hidePageError(); + setBusy(true); + setButtonBusy(button, true, "Очистка…"); + try { + var result = await request("/api/broray/cleanup.cgi", { + method: "POST", + headers: { + "Accept": "application/json", + "Content-Type": "application/json", + "X-BROray-Request": "1" + }, + body: JSON.stringify({ token: plan.token }) + }); + state.cleanupPlan = null; + byId("cleanup-state").textContent = "Очищено"; + byId("cleanup-state").className = "status-badge status-success"; + byId("cleanup-state").setAttribute("data-icon", "status"); + setText("cleanup-count", result.deletedCount || 0); + setText("cleanup-bytes", formatBytes(result.freedBytes)); + setText("cleanup-expiry", "завершено"); + byId("cleanup-candidates").replaceChildren(); + toast("Очистка завершена: освобождено " + formatBytes(result.freedBytes) + ".", "success"); + } catch (error) { + invalidateCleanupPlan(); + showPageError(errorMessage(error)); + toast(errorMessage(error), "error"); + } finally { + setBusy(false); + setButtonBusy(button, false); + applyControlState(); + } + } + async function installUpdate(event) { var confirmed = await confirmAction({ eyebrow: "Обновление", @@ -497,6 +661,28 @@ } } + async function reinstallCurrent(event) { + var version = state.info && (state.info.installedPackageVersion || state.info.version); + var confirmed = await confirmAction({ + eyebrow: "Восстановительная переустановка", + title: "Переустановить текущую версию BROray?", + message: "Будет повторно установлен пакет " + (version || "текущей версии") + ". Перед изменениями создаются полный снимок и отдельная копия пользовательских данных. При ошибке BROray автоматически вернёт исходное состояние.", + confirmText: "Переустановить", + variant: "primary", + icon: "restore" + }); + + if (confirmed) { + startAction( + "/api/broray/reinstall.cgi", + "Восстановительная переустановка запущена.", + null, + event.currentTarget, + "Переустановка…" + ); + } + } + async function restoreBackup(event) { var confirmed = await confirmAction({ eyebrow: "Восстановление", @@ -557,7 +743,17 @@ function bind() { byId("check-update").addEventListener("click", checkUpdate); byId("install-update").addEventListener("click", installUpdate); + byId("reinstall-current").addEventListener("click", reinstallCurrent); byId("restore-backup").addEventListener("click", restoreBackup); + byId("cleanup-plan").addEventListener("click", planCleanup); + byId("cleanup-run").addEventListener("click", function (event) { + runCleanup(event).catch(function (error) { + toast(errorMessage(error), "error"); + }); + }); + ["cleanup-temp", "cleanup-backups", "cleanup-route-backups", "cleanup-logs"].forEach(function (id) { + byId(id).addEventListener("change", invalidateCleanupPlan); + }); byId("uninstall-normal").addEventListener("click", function (event) { uninstall("normal", event.currentTarget).catch(function (error) { toast(errorMessage(error), "error"); diff --git a/root/opt/broray/web-new/assets/js/routes-dot.js b/root/opt/broray/web-new/assets/js/routes-dot.js new file mode 100644 index 0000000..226b1e6 --- /dev/null +++ b/root/opt/broray/web-new/assets/js/routes-dot.js @@ -0,0 +1,329 @@ +(function () { + "use strict"; + + var status = null; + var selected = Object.create(null); + var initialized = false; + var busy = false; + var TEST_TTL_MS = 10 * 60 * 1000; + + function byId(id) { return document.getElementById(id); } + function text(id, value) { var node = byId(id); if (node) node.textContent = value === null || value === undefined || value === "" ? "—" : String(value); } + function formatDate(value) { + if (!value) return "—"; + var date = new Date(value); + return isNaN(date.getTime()) ? String(value) : date.toLocaleString("ru-RU"); + } + function iconScan(node) { if (window.BROrayIcons) window.BROrayIcons.scan(node || document); } + + function selectedIds() { + return Object.keys(selected).filter(function (id) { return selected[id] === true; }); + } + + function serverTestFresh(test, nowMs) { + if (!test || !test.testedEpoch) return false; + return (Number(nowMs) - Number(test.testedEpoch) * 1000) <= TEST_TTL_MS; + } + + function testFresh(test) { + return serverTestFresh(test, Date.now()); + } + + function recommendedAction(data, ids, nowMs) { + var chosen; + var testedOk; + var matches; + if (!data || data.runningConfigAvailable !== true) return "refresh"; + if (!ids || !ids.length) return "test"; + chosen = (data.servers || []).filter(function (server) { return ids.indexOf(server.id) !== -1; }); + testedOk = chosen.length === ids.length && chosen.every(function (server) { + return server.test && server.test.ok === true && serverTestFresh(server.test, nowMs); + }); + if (data.drift === true) return testedOk ? "apply" : "test"; + if (!testedOk) return "test"; + matches = chosen.every(function (server) { return server.present === true; }) && + (data.managed || []).every(function (managed) { + return chosen.some(function (server) { return server.address === managed.address && server.sni === managed.sni; }); + }); + return matches ? "test" : "apply"; + } + + function testPresentation(server) { + var test = server.test; + if (!test) return { text: "Не проверен", className: "is-warning" }; + if (!testFresh(test)) return { text: "Проверка устарела", className: "is-warning" }; + if (test.ok === true) return { text: test.latencyMs !== null ? "Доступен · " + test.latencyMs + " мс" : "Доступен", className: "is-ok" }; + if (test.status === "unavailable") return { text: "Проверка недоступна", className: "is-warning" }; + return { text: "Ошибка TLS", className: "is-error" }; + } + + function createServer(server) { + var label = document.createElement("label"); + var input = document.createElement("input"); + var copy = document.createElement("span"); + var name = document.createElement("strong"); + var provider = document.createElement("span"); + var endpoint = document.createElement("code"); + var state = document.createElement("span"); + var test = document.createElement("span"); + var installed = document.createElement("span"); + var presentation = testPresentation(server); + + label.className = "routes-dot-server" + (selected[server.id] ? " is-selected" : ""); + input.type = "checkbox"; + input.checked = selected[server.id] === true; + input.dataset.serverId = server.id; + input.setAttribute("aria-label", "Выбрать " + server.name); + input.addEventListener("change", function () { + selected[server.id] = input.checked; + render(); + }); + + copy.className = "routes-dot-server-copy"; + name.textContent = server.name; + provider.textContent = server.provider; + endpoint.textContent = server.address + " · " + server.sni; + copy.append(name, provider, endpoint); + + state.className = "routes-dot-server-state"; + test.className = presentation.className; + test.textContent = presentation.text; + installed.className = server.present ? "is-ok" : "is-warning"; + installed.textContent = server.present ? (server.managed ? "Управляется BROray" : "Уже есть в Keenetic") : "Не установлен"; + state.append(test, installed); + label.append(input, copy, state); + return label; + } + + function selectedServers() { + if (!status) return []; + return status.servers.filter(function (server) { return selected[server.id] === true; }); + } + + function desiredMatches() { + var servers = selectedServers(); + if (!servers.length) return false; + return servers.every(function (server) { return server.present === true; }) && + status.managed.every(function (managed) { + return servers.some(function (server) { return server.address === managed.address && server.sni === managed.sni; }); + }); + } + + function allSelectedTestedOk() { + var servers = selectedServers(); + return servers.length > 0 && servers.every(function (server) { return server.test && server.test.ok === true && testFresh(server.test); }); + } + + function setRecommended(id) { + ["routes-dot-test", "routes-dot-apply", "routes-dot-refresh"].forEach(function (buttonId) { + var button = byId(buttonId); + if (!button) return; + button.classList.remove("button-primary"); + button.classList.add("button-secondary"); + button.removeAttribute("data-recommended-action"); + }); + var target = byId(id); + if (target) { + target.classList.remove("button-secondary"); + target.classList.add("button-primary"); + target.setAttribute("data-recommended-action", "true"); + } + } + + function setBadge(kind, message) { + var badge = byId("routes-dot-status"); + var notice = byId("routes-dot-notice"); + if (badge) { + badge.className = "status-badge " + (kind === "success" ? "status-badge-success" : kind === "warning" ? "status-badge-warning" : kind === "error" ? "status-badge-danger" : "status-badge-neutral"); + badge.textContent = kind === "success" ? "Настроено" : kind === "warning" ? "Требует действия" : kind === "error" ? "Ошибка" : "Не настроено"; + badge.setAttribute("data-icon", kind === "error" ? "warning" : "status"); + } + if (notice) notice.className = "routes-dot-notice status-" + (kind === "neutral" ? "neutral" : kind); + text("routes-dot-message", message); + iconScan(badge); + } + + function render() { + var list = byId("routes-dot-servers"); + var ids = selectedIds(); + var servers; + var tested; + var installed; + var external; + var message; + var kind; + var recommended; + + if (!status || !list) return; + list.textContent = ""; + status.servers.forEach(function (server) { list.appendChild(createServer(server)); }); + servers = selectedServers(); + tested = servers.filter(function (server) { return server.test && server.test.ok === true && testFresh(server.test); }).length; + installed = servers.filter(function (server) { return server.present === true; }).length; + external = Math.max(0, Number(status.actual.dot.length || 0) - Number(status.managed.length || 0)); + + text("routes-dot-selected", ids.length); + text("routes-dot-tested", tested + " из " + ids.length); + text("routes-dot-installed", installed + " из " + ids.length); + text("routes-dot-capacity", status.actual.totalSecure + " из " + status.maxServers); + text("routes-dot-last-tested", formatDate(status.lastTestedAt)); + text("routes-dot-last-applied", formatDate(status.lastAppliedAt)); + text("routes-dot-last-deleted", formatDate(status.lastDeletedAt)); + text("routes-dot-external-dot", external); + text("routes-dot-doh", status.actual.dohCount); + + if (status.runningConfigAvailable !== true) { + kind = "error"; + message = "Не удалось прочитать конфигурацию Keenetic. Экспорт и удаление недоступны."; + recommended = "routes-dot-refresh"; + } else if (!ids.length) { + kind = "warning"; + message = "Выберите хотя бы один DNS-over-TLS сервер. Для устойчивости рекомендуется несколько провайдеров."; + recommended = "routes-dot-test"; + } else if (status.drift === true) { + kind = "warning"; + message = "Часть записей BROray отсутствует в Keenetic. Проверьте серверы и восстановите конфигурацию."; + recommended = allSelectedTestedOk() ? "routes-dot-apply" : "routes-dot-test"; + } else if (!allSelectedTestedOk()) { + kind = "warning"; + message = "Перед экспортом проверьте TLS-соединение и имя сертификата выбранных серверов."; + recommended = "routes-dot-test"; + } else if (!desiredMatches()) { + kind = "warning"; + message = "Проверка завершена. Выбранную конфигурацию можно экспортировать в Keenetic."; + recommended = "routes-dot-apply"; + } else { + kind = "success"; + message = "DNS-over-TLS настроен и соответствует выбранному списку. Чужие DoT/DoH записи не изменяются."; + recommended = "routes-dot-test"; + } + if (status.lastError && status.lastError.message) { + kind = "error"; + message = status.lastError.message; + } + setBadge(kind, message); + setRecommended(recommended); + + ["routes-dot-test", "routes-dot-apply", "routes-dot-refresh", "routes-dot-delete"].forEach(function (id) { + var button = byId(id); + if (button) button.disabled = busy || (id !== "routes-dot-refresh" && status.runningConfigAvailable !== true) || ((id === "routes-dot-test" || id === "routes-dot-apply") && !ids.length) || (id === "routes-dot-delete" && !status.managed.length); + }); + iconScan(list); + } + + function handleError(error) { + if (error && error.status === 401 && window.BROrayUI) { + window.BROrayUI.redirectToLogin(); + return; + } + setBadge("error", error && error.message ? error.message : "Операция DNS-over-TLS завершилась ошибкой."); + if (window.BROrayUI) window.BROrayUI.toast(error && error.message ? error.message : "Ошибка DNS-over-TLS.", "error"); + } + + async function loadStatus(preserveSelection) { + var payload = await window.BROrayUI.apiRequest("/api/routes/dot-status.cgi", { method: "GET" }); + status = payload.data; + if (!initialized || preserveSelection !== true) { + selected = Object.create(null); + (status.selectedIds || []).forEach(function (id) { selected[id] = true; }); + initialized = true; + } + render(); + } + + async function runAction(action, body) { + busy = true; + render(); + try { + var payload = await window.BROrayUI.apiRequest("/api/routes/dot-" + action + ".cgi", { method: "POST", body: body || {} }); + status = payload.data; + if (window.BROrayUI) window.BROrayUI.toast(action === "test" ? "Проверка DNS-over-TLS завершена." : action === "apply" ? "DNS-over-TLS экспортирован в Keenetic." : "Записи DNS-over-TLS BROray удалены.", "success"); + } finally { + busy = false; + render(); + } + } + + async function testServers() { + try { await runAction("test", { serverIds: selectedIds(), allowUntested: false }); } + catch (error) { handleError(error); } + } + + async function applyServers() { + var ids = selectedIds(); + var providers = Object.create(null); + selectedServers().forEach(function (server) { providers[server.provider] = true; }); + if (Object.keys(providers).length < 2 && window.BROrayDialogs) { + var oneProvider = await window.BROrayDialogs.confirm({ + eyebrow: "DNS-over-TLS", + title: "Использовать одного провайдера?", + message: "Для устойчивой работы рекомендуется выбрать серверы разных DNS-провайдеров. Продолжить с текущим выбором?", + confirmText: "Продолжить", + icon: "security" + }); + if (!oneProvider) return; + } + try { + await runAction("apply", { serverIds: ids, allowUntested: false }); + } catch (error) { + if (error && error.code === "DOT_TEST_CONFIRMATION_REQUIRED" && window.BROrayDialogs) { + var confirmed = await window.BROrayDialogs.confirm({ + eyebrow: "Предупреждение", + title: "Экспортировать непроверенные серверы?", + message: "Один или несколько выбранных серверов не прошли свежую TLS-проверку. Экспорт может нарушить разрешение DNS. Продолжить осознанно?", + confirmText: "Экспортировать", + icon: "warning" + }); + if (confirmed) { + try { await runAction("apply", { serverIds: ids, allowUntested: true }); } + catch (secondError) { handleError(secondError); } + } + return; + } + handleError(error); + } + } + + async function deleteServers() { + var confirmed = true; + if (window.BROrayDialogs) { + confirmed = await window.BROrayDialogs.confirm({ + eyebrow: "Опасное действие", + title: "Удалить DNS-over-TLS BROray?", + message: "Будут удалены только записи, которые создал BROray. Другие DoT/DoH серверы Keenetic сохранятся.", + confirmText: "Удалить", + variant: "danger", + icon: "delete" + }); + } + if (!confirmed) return; + try { await runAction("delete", {}); } + catch (error) { handleError(error); } + } + + function bind() { + var test = byId("routes-dot-test"); + var apply = byId("routes-dot-apply"); + var refresh = byId("routes-dot-refresh"); + var remove = byId("routes-dot-delete"); + if (test) test.addEventListener("click", testServers); + if (apply) apply.addEventListener("click", applyServers); + if (refresh) refresh.addEventListener("click", function () { busy = true; render(); loadStatus(true).catch(handleError).finally(function () { busy = false; render(); }); }); + if (remove) remove.addEventListener("click", deleteServers); + } + + + if (window.BROrayDotTestMode) { + window.BROrayDotTestHooks = { + serverTestFresh: serverTestFresh, + recommendedAction: recommendedAction, + testPresentation: testPresentation + }; + } + + document.addEventListener("DOMContentLoaded", function () { + if (!byId("routes-dot-card") || !window.BROrayUI) return; + bind(); + loadStatus(false).catch(handleError); + }); +})(); diff --git a/root/opt/broray/web-new/assets/js/routes.js b/root/opt/broray/web-new/assets/js/routes.js index 4b38846..4d58c0c 100644 --- a/root/opt/broray/web-new/assets/js/routes.js +++ b/root/opt/broray/web-new/assets/js/routes.js @@ -2,8 +2,13 @@ "use strict"; var REQUEST_TIMEOUT_MS = 180000; + var LONG_OPERATION_TIMEOUT_MS = 8 * 60 * 60 * 1000; + var PROGRESS_POLL_INTERVAL_MS = 750; var states = Object.create(null); var operationRunning = false; + var globalOperation = null; + var progressWatchers = Object.create(null); + var longOperationRequests = Object.create(null); var expandedBundles = Object.create(null); var ROUTE_LOGOS = { @@ -230,6 +235,47 @@ return Boolean(state && state.downloadedVersion && downloadRequired(state)); } + function verificationResult(state) { + return state && state.verifyResult && typeof state.verifyResult === "object" + ? state.verifyResult + : null; + } + + function localSetInvalid(state) { + var result = verificationResult(state); + return Boolean(result && result.local && result.local.valid === false && + state && state.downloadedVersion && + String(result.contentSha256 || "") === String(state.downloadedVersion.contentSha256 || "")); + } + + function verificationCurrent(state) { + var result = verificationResult(state); + if (!state || !state.downloadedVersion || !result || !result.local) return false; + return result.local.valid === true && + String(result.contentSha256 || "") === String(state.downloadedVersion.contentSha256 || ""); + } + + function verificationRequired(state) { + return Boolean(state && state.downloadedVersion && + !localSetInvalid(state) && !verificationCurrent(state)); + } + + function verificationConflict(state) { + var result = verificationResult(state); + return Boolean(verificationCurrent(state) && result && result.keenetic && + result.keenetic.status === "conflict"); + } + + function verificationRetryRequired(state) { + var result = verificationResult(state); + return Boolean(verificationCurrent(state) && result && result.keenetic && + (result.keenetic.available === false || result.keenetic.status === "unavailable")); + } + + function downloadActionRequired(state) { + return downloadRequired(state) || localSetInvalid(state); + } + function keeneticAction(state) { if (!state || !state.downloadedVersion) return null; if (hasRouterDrift(state)) { @@ -246,22 +292,39 @@ function nextAction(state) { var keenetic = keeneticAction(state); - if (hasRouterDrift(state) && keenetic) return "export"; + var progress = operationProgress(state); + if (progress && progress.resumable && !progress.running) return "resume"; if (!state || !state.availableVersion) return "check"; - if (downloadRequired(state)) return "download"; + if (downloadActionRequired(state)) return "download"; + if (verificationRequired(state) || verificationRetryRequired(state)) return "verify"; + if (verificationConflict(state)) return null; + if (hasRouterDrift(state) && keenetic) return "export"; if (keenetic) return "export"; - return null; + return "check"; } - function checkLabel(state) { - return state && state.lastCheckedAt ? "Проверить обновления" : "Найти маршруты"; + function checkLabel() { + return "Проверить обновление"; } function statusPresentation(state) { var action; + var progress = operationProgress(state); + if (progress && progress.resumable && !progress.running) { + return {text: progress.stoppedByUser ? "Остановлено" : "Можно продолжить", className: "status-badge-warning", icon: "restore"}; + } + if (localSetInvalid(state)) { + return {text: "Набор повреждён", className: "status-badge-danger", icon: "status"}; + } + if (verificationRetryRequired(state)) { + return {text: "Проверка не завершена", className: "status-badge-warning", icon: "status"}; + } if (state && state.lastError) { return {text: "Ошибка", className: "status-badge-danger", icon: "status"}; } + if (verificationConflict(state)) { + return {text: "Обнаружен конфликт", className: "status-badge-danger", icon: "status"}; + } if (hasRouterDrift(state)) { return {text: "Требуется восстановление", className: "status-badge-warning", icon: "restore"}; } @@ -271,6 +334,10 @@ } return {text: "Доступно обновление", className: "status-badge-warning", icon: "update"}; } + if (verificationRequired(state)) { + return {text: "Требуется проверка", className: "status-badge-warning", icon: "status"}; + } + action = keeneticAction(state); if (action && action.mode === "update") { return {text: "Готово обновление", className: "status-badge-warning", icon: "update"}; @@ -309,6 +376,11 @@ var lastError; var parts = []; + if (localSetInvalid(state)) { + return verificationResult(state).message || + "Локальный набор не прошёл проверку. Скачайте файлы заново."; + } + if (state && state.lastError) { lastError = typeof state.lastError === "string" ? state.lastError @@ -316,6 +388,11 @@ return lastError || "Последняя операция завершилась ошибкой."; } + if (verificationConflict(state)) { + return verificationResult(state).message || + "Локальный набор исправен, но в Keenetic обнаружен конфликт."; + } + if (hasRouterDrift(state)) { presence = routerPresence(state) || {}; return "Маршруты были установлены ранее, но сейчас в Keenetic отсутствуют " + @@ -324,7 +401,10 @@ ". Нажмите «Восстановить в Keenetic»."; } - if (downloadRequired(state)) { + if (downloadActionRequired(state)) { + if (localSetInvalid(state)) { + return "Локальный набор повреждён. Нажмите «Скачать заново»."; + } if (state.checkResult && state.checkResult.sourceChanged) parts.push(fileChangeText(state)); if (state.checkResult && state.checkResult.routesChanged) parts.push(routeChangeText(state)); if (state.checkResult && state.checkResult.routesChanged === false && state.downloadedVersion) { @@ -337,6 +417,10 @@ return parts.filter(Boolean).join(" "); } + if (verificationRequired(state)) { + return "Файлы загружены. Нажмите «Проверить набор», чтобы проверить локальные данные и их фактическое состояние в Keenetic."; + } + action = keeneticAction(state); if (action && action.mode === "update") { parts.push("Новая версия файлов загружена."); @@ -345,7 +429,9 @@ return parts.filter(Boolean).join(" "); } if (action && action.mode === "install") { - return "Маршруты загружены. Нажмите «Установить в Keenetic»."; + return (verificationResult(state) && verificationResult(state).message + ? verificationResult(state).message + " " + : "") + "Нажмите «Установить в Keenetic»."; } if (isActuallyInstalled(state)) { if (state.checkResult && state.checkResult.result === "source_changed_routes_unchanged") { @@ -359,7 +445,7 @@ if (state && state.availableVersion) { return "Источник проверен. Нажмите «Скачать»."; } - return "Нажмите «Найти маршруты», чтобы BROray нашёл и проверил все доступные BAT-файлы этого ресурса."; + return "Нажмите «Проверить обновление», чтобы BROray проверил источник и нашёл доступные BAT-файлы этого ресурса."; } function button(bundleId, action, text, variant, icon) { @@ -392,6 +478,12 @@ var status = create("span", "status-badge status-loading", "Загрузка…"); var metrics = create("div", "route-card-metrics"); var notice = create("div", "route-card-notice status-neutral"); + var progressBox = create("section", "route-card-progress"); + var progressHeader = create("div", "route-card-progress-header"); + var progressBar = create("progress", "route-card-progress-bar"); + var progressMessage = create("p", "route-card-progress-message", "Подготовка операции…"); + var progressRoute = create("span", "route-card-progress-route", ""); + var progressControls = create("div", "route-card-progress-controls"); var actions = create("div", "route-card-actions"); var details = create("div", "route-card-details"); var detailsGrid = create("div", "route-details-grid"); @@ -442,12 +534,34 @@ notice.firstChild.setAttribute("data-icon", "status"); notice.lastChild.setAttribute("data-field", "message"); + progressBox.hidden = true; + progressBox.setAttribute("data-field", "operation-progress"); + progressBox.setAttribute("role", "status"); + progressBox.setAttribute("aria-live", "polite"); + progressHeader.append( + create("strong", "route-card-progress-title", "Операция с маршрутами"), + create("span", "route-card-progress-counter", "0 из 0") + ); + progressHeader.firstChild.setAttribute("data-field", "operation-progress-title"); + progressHeader.lastChild.setAttribute("data-field", "operation-progress-counter"); + progressBar.max = 1; + progressBar.value = 0; + progressBar.setAttribute("data-field", "operation-progress-bar"); + progressMessage.setAttribute("data-field", "operation-progress-message"); + progressRoute.setAttribute("data-field", "operation-progress-route"); + progressControls.append( + button(bundle.id, "stop", "Остановить после текущего маршрута", "button-secondary", "stop"), + button(bundle.id, "resume", "Продолжить", "button-primary", "restore") + ); + progressBox.append(progressHeader, progressBar, progressMessage, progressRoute, progressControls); + detailsButton = button(bundle.id, "toggle-details", "Подробнее", "button-secondary", "chevron"); detailsButton.setAttribute("aria-expanded", "false"); detailsButton.setAttribute("aria-controls", "route-details-" + bundle.id); actions.append( - button(bundle.id, "check", "Найти маршруты", "button-secondary", "search"), + button(bundle.id, "check", "Проверить обновление", "button-secondary", "update"), button(bundle.id, "download", "Скачать", "button-secondary", "backup"), + button(bundle.id, "verify", "Проверить набор", "button-secondary", "status"), button(bundle.id, "export", "Установить в Keenetic", "button-secondary", "routes"), detailsButton ); @@ -457,7 +571,10 @@ detailsGrid.append( dataRow("Установленная версия", "installed-version"), dataRow("Доступная версия", "available-version"), - dataRow("Последняя проверка", "last-checked"), + dataRow("Проверка обновления", "last-checked"), + dataRow("Проверка набора", "last-verified"), + dataRow("Локальный набор", "local-verification"), + dataRow("Состояние в Keenetic", "keenetic-verification"), dataRow("Добавлено файлов", "files-added"), dataRow("Изменено файлов", "files-changed"), dataRow("Удалено файлов", "files-removed"), @@ -479,7 +596,7 @@ danger.append(create("div", "route-danger-copy"), button(bundle.id, "delete", "Удалить из Keenetic", "button-danger-outline", "delete")); danger.firstChild.append(create("strong", "", "Удаление набора"), create("p", "", "Общие маршруты, принадлежащие другим установленным наборам, будут сохранены.")); details.append(detailsGrid, sourceBlock, technical, danger); - card.append(summary, metrics, notice, actions, details); + card.append(summary, metrics, notice, progressBox, actions, details); return card; } @@ -503,6 +620,8 @@ if (!node) return; node.classList.remove("button-primary", "button-secondary"); node.classList.add(primary ? "button-primary" : "button-secondary"); + if (primary) node.setAttribute("data-recommended-action", "true"); + else node.removeAttribute("data-recommended-action"); } function setStatus(card, presentation) { @@ -514,6 +633,44 @@ if (window.BROrayIcons) window.BROrayIcons.scan(node); } + function bundleName(bundleId) { + var match = BUNDLES.filter(function (item) { return item.id === bundleId; })[0]; + return match ? match.name : bundleId; + } + + function globalOperationMessage(operation) { + var action; + var name; + if (!operation || operation.active !== true) return ""; + action = String(operation.action || "operation"); + name = operation.bundleId ? " «" + bundleName(operation.bundleId) + "»" : ""; + if (operation.scope === "system") { + if (action === "update") return "Сейчас выполняется обновление BROray. Операции с маршрутами временно недоступны."; + if (action === "restore") return "Сейчас выполняется восстановление BROray. Операции с маршрутами временно недоступны."; + if (action === "uninstall") return "Сейчас выполняется удаление BROray. Операции с маршрутами временно недоступны."; + if (action === "cleanup") return "Сейчас выполняется очистка BROray. Операции с маршрутами временно недоступны."; + if (action === "reinstall") return "Сейчас выполняется переустановка BROray. Операции с маршрутами временно недоступны."; + return "Сейчас выполняется системная операция BROray. Операции с маршрутами временно недоступны."; + } + if (action.indexOf("preflight:") === 0) return "Выполняется предварительная проверка" + name + ". Остальные действия временно заблокированы."; + if (action.indexOf("custom:") === 0) return "Выполняется операция с пользовательскими маршрутами. Остальные действия временно заблокированы."; + if (action === "check") return "Проверяется обновление" + name + ". Остальные действия временно заблокированы."; + if (action === "download") return "Загружаются маршруты" + name + ". Остальные действия временно заблокированы."; + if (action === "verify" || action === "plan") return "Проверяется набор" + name + ". Остальные действия временно заблокированы."; + if (action === "delete") return "Удаляются маршруты" + name + ". Остальные действия временно заблокированы."; + if (action === "resume") return "Продолжается операция" + name + ". Остальные действия временно заблокированы."; + return "Выполняется операция с маршрутами" + name + ". Остальные действия временно заблокированы."; + } + + function setGlobalOperationNotice(card, operation) { + var box = card.querySelector('[data-field="notice-box"]'); + if (!box) return; + box.className = "route-card-notice status-warning"; + box.firstChild.setAttribute("data-icon", "status"); + setField(card, "message", globalOperationMessage(operation)); + if (window.BROrayIcons) window.BROrayIcons.scan(box); + } + function setNotice(card, state) { var box = card.querySelector('[data-field="notice-box"]'); var status = statusPresentation(state); @@ -528,13 +685,112 @@ if (window.BROrayIcons) window.BROrayIcons.scan(box); } + function operationProgress(state) { + return state && state.operationProgress && typeof state.operationProgress === "object" + ? state.operationProgress + : null; + } + + function operationProgressTitle(progress) { + if (!progress) return "Операция с маршрутами"; + if (progress.operation === "delete") return "Удаление маршрутов из Keenetic"; + if (progress.operation === "update") return "Обновление маршрутов в Keenetic"; + if (progress.operation === "restore") return "Восстановление маршрутов в Keenetic"; + return "Установка маршрутов в Keenetic"; + } + + function initialOperationProgress(bundleId, action) { + var state = states[bundleId] || {}; + var existing = operationProgress(state); + var keenetic = keeneticAction(state); + var operation = action === "delete" ? "delete" : + action === "resume" && existing && existing.operation ? existing.operation : + (keenetic && keenetic.mode ? keenetic.mode : "install"); + return { + schemaVersion: 2, + kind: "routes", + bundleId: bundleId, + operation: operation, + phase: "preparing", + current: action === "resume" && existing ? Number(existing.current || 0) : 0, + total: action === "resume" && existing ? Number(existing.total || 0) : 0, + percent: action === "resume" && existing ? Number(existing.percent || 0) : 0, + currentRoute: null, + message: "Подготовка безопасного плана операции.", + running: true, + success: null, + rolledBack: false, + resumable: false, + stopRequested: false, + stoppedByUser: false, + resumed: action === "resume" + }; + } + + function renderOperationProgress(card, progress) { + var box = card ? card.querySelector('[data-field="operation-progress"]') : null; + var bar; + var current; + var total; + var route; + if (!box) return; + if (!progress || progress.phase === "idle") { + box.hidden = true; + box.className = "route-card-progress"; + return; + } + + current = Math.max(0, Number(progress.current || 0)); + total = Math.max(0, Number(progress.total || 0)); + if (total > 0 && current > total) current = total; + box.hidden = false; + box.className = "route-card-progress"; + if (progress.running) box.classList.add("is-running"); + else if (progress.resumable) box.classList.add("is-paused"); + else if (progress.success === true) box.classList.add("is-success"); + else if (progress.success === false) box.classList.add("is-error"); + + setField(card, "operation-progress-title", operationProgressTitle(progress)); + setField(card, "operation-progress-counter", current + " из " + total); + setField(card, "operation-progress-message", progress.message || "Операция выполняется."); + route = progress.currentRoute ? "Текущий маршрут: " + progress.currentRoute : ""; + var routeNode = card.querySelector('[data-field="operation-progress-route"]'); + if (routeNode) { + routeNode.textContent = route; + routeNode.hidden = !route; + } + bar = card.querySelector('[data-field="operation-progress-bar"]'); + if (bar) { + if (total > 0) { + bar.max = total; + bar.value = current; + } else { + bar.max = 1; + bar.removeAttribute("value"); + } + bar.setAttribute("aria-label", operationProgressTitle(progress) + ": " + current + " из " + total); + } + var stopButton = card.querySelector('[data-action="stop"]'); + var resumeButton = card.querySelector('[data-action="resume"]'); + if (stopButton) { + stopButton.hidden = !progress.running; + stopButton.disabled = Boolean(progress.stopRequested); + setButtonLabel(stopButton, progress.stopRequested ? "Остановка запрошена…" : "Остановить после текущего маршрута", "stop"); + } + if (resumeButton) { + resumeButton.hidden = !(progress.resumable && !progress.running); + resumeButton.disabled = progress.running; + setButtonVariant(resumeButton, progress.resumable && !progress.running); + } + } + function renderSourceFiles(card, state) { var list = card.querySelector('[data-field="source-files"]'); var files = sourceFiles(state); if (!list) return; list.textContent = ""; if (!files.length) { - list.appendChild(create("li", "route-source-empty", "Список ещё не получен. Нажмите «Найти маршруты».")); + list.appendChild(create("li", "route-source-empty", "Список ещё не получен. Нажмите «Проверить обновление».")); return; } files.forEach(function (file) { @@ -564,8 +820,9 @@ return output || "Последняя операция завершилась ошибкой."; } if (state && state.exportResult) return "Последняя установка: " + (state.exportResult.message || "завершена") + "."; + if (state && state.verifyResult) return state.verifyResult.message || "Проверка набора завершена."; if (state && state.downloadResult) return state.downloadResult.message || "Файлы маршрутов загружены."; - if (state && state.checkResult) return state.checkResult.message || "Поиск обновлений завершён."; + if (state && state.checkResult) return state.checkResult.message || "Проверка обновления завершена."; if (state && state.deleteResult) return "Последнее удаление: " + (state.deleteResult.message || "завершено") + "."; return "Нет данных о выполненных операциях."; } @@ -581,12 +838,19 @@ var keenetic = keeneticAction(state); var checkButton; var downloadButton; + var verifyButton; var keeneticButton; var deleteButton; + var stopButton; + var resumeButton; var detailsButton; var actions; var orderedButtons; - var busy = Boolean(busyBundles[bundleId]); + var progress = operationProgress(state); + var progressRunning = Boolean(progress && progress.running); + var progressResumable = Boolean(progress && progress.resumable && !progress.running); + var globalBusy = Boolean(globalOperation && globalOperation.active && !progressRunning); + var busy; var expected = presence && presence.registered ? Number(presence.expectedRouteCount || 0) : 0; var present = presence && presence.registered ? Number(presence.presentRouteCount || 0) : 0; var targetInterface = (state.exportBuild && state.exportBuild.targetInterface) || @@ -597,14 +861,39 @@ if (!card) return; states[bundleId] = state; - setStatus(card, statusPresentation(state)); + if (progressRunning) { + operationRunning = bundleId; + busyBundles[bundleId] = progress.operation || "export"; + } + busy = Boolean(busyBundles[bundleId]) || Boolean(operationRunning) || Boolean(globalOperation && globalOperation.active) || progressResumable; + setStatus(card, progressRunning + ? {text: progress && progress.stopRequested ? "Останавливается" : "Выполняется", className: "status-badge-warning", icon: "update"} + : globalBusy + ? {text: "Операция выполняется", className: "status-badge-warning", icon: "status"} + : statusPresentation(state)); setNotice(card, state); + if (globalBusy) setGlobalOperationNotice(card, globalOperation); + renderOperationProgress(card, progress); setField(card, "route-count", state.routeCount || 0); setField(card, "source-count", files.length); setField(card, "presence-count", presence && presence.registered ? present + "/" + expected : "—"); setField(card, "installed-version", formatVersion(state.installedVersion, "Не установлено")); setField(card, "available-version", formatVersion(state.availableVersion, "Не проверялась")); setField(card, "last-checked", formatDate(state.lastCheckedAt)); + setField(card, "last-verified", formatDate(state.lastVerifiedAt)); + setField(card, "local-verification", localSetInvalid(state) + ? "Повреждён" + : verificationCurrent(state) ? "Исправен" : "Не проверен"); + setField(card, "keenetic-verification", verificationCurrent(state) && verificationResult(state).keenetic + ? ({ + complete: "Соответствует", + not_installed: "Не установлен", + update_pending: "Ожидает обновления", + restore_required: "Требуется восстановление", + conflict: "Конфликт", + unavailable: "Недоступен" + }[verificationResult(state).keenetic.status] || "Проверен") + : "Не проверен"); setField(card, "files-added", (fileChanges.addedFiles || []).length || 0); setField(card, "files-changed", (fileChanges.changedFiles || []).length || 0); setField(card, "files-removed", (fileChanges.removedFiles || []).length || 0); @@ -618,20 +907,30 @@ checkButton = card.querySelector('[data-action="check"]'); downloadButton = card.querySelector('[data-action="download"]'); + verifyButton = card.querySelector('[data-action="verify"]'); keeneticButton = card.querySelector('[data-action="export"]'); deleteButton = card.querySelector('[data-action="delete"]'); + stopButton = card.querySelector('[data-action="stop"]'); + resumeButton = card.querySelector('[data-action="resume"]'); detailsButton = card.querySelector('[data-action="toggle-details"]'); - setButtonLabel(checkButton, busyBundles[bundleId] === "check" ? "Поиск обновлений…" : checkLabel(state), "search"); + setButtonLabel(checkButton, busyBundles[bundleId] === "check" ? "Проверка обновления…" : checkLabel(state), "update"); checkButton.disabled = busy; checkButton.hidden = false; setButtonVariant(checkButton, next === "check"); - setButtonLabel(downloadButton, state.downloadedVersion ? "Обновить файлы" : "Скачать", state.downloadedVersion ? "update" : "backup"); - downloadButton.disabled = busy || !downloadRequired(state); - downloadButton.hidden = !downloadRequired(state); + setButtonLabel(downloadButton, + localSetInvalid(state) ? "Скачать заново" : state.downloadedVersion ? "Обновить файлы" : "Скачать", + state.downloadedVersion ? "update" : "backup"); + downloadButton.disabled = busy || !downloadActionRequired(state); + downloadButton.hidden = !downloadActionRequired(state); setButtonVariant(downloadButton, next === "download"); + setButtonLabel(verifyButton, busyBundles[bundleId] === "verify" ? "Проверка набора…" : "Проверить набор", "status"); + verifyButton.disabled = busy || !state.downloadedVersion; + verifyButton.hidden = !state.downloadedVersion; + setButtonVariant(verifyButton, next === "verify"); + if (keenetic) { setButtonLabel(keeneticButton, keenetic.text, keenetic.icon); keeneticButton.disabled = busy; @@ -643,21 +942,33 @@ setButtonVariant(keeneticButton, false); } - deleteButton.disabled = busy || !state.installedVersion; + deleteButton.disabled = busy || progressResumable || !state.installedVersion; + if (stopButton) { + stopButton.hidden = !progressRunning; + stopButton.disabled = Boolean(progress && progress.stopRequested); + } + if (resumeButton) { + resumeButton.hidden = !progressResumable; + resumeButton.disabled = progressRunning; + setButtonVariant(resumeButton, progressResumable); + } detailsButton.disabled = false; actions = checkButton.parentElement; orderedButtons = next === "export" - ? [keeneticButton, checkButton, downloadButton, detailsButton] - : next === "download" - ? [downloadButton, checkButton, keeneticButton, detailsButton] - : [checkButton, downloadButton, keeneticButton, detailsButton]; + ? [keeneticButton, verifyButton, checkButton, downloadButton, detailsButton] + : next === "verify" + ? [verifyButton, checkButton, downloadButton, keeneticButton, detailsButton] + : next === "download" + ? [downloadButton, checkButton, verifyButton, keeneticButton, detailsButton] + : [checkButton, verifyButton, downloadButton, keeneticButton, detailsButton]; orderedButtons.forEach(function (node) { if (node && node.parentElement === actions) actions.appendChild(node); }); card.classList.toggle("has-warning", hasRouterDrift(state)); - card.classList.toggle("has-error", Boolean(state.lastError)); + card.classList.toggle("has-error", Boolean(state.lastError) && !progressResumable); + card.classList.toggle("is-paused", progressResumable); } function renderLoadError(bundleId, error) { @@ -672,7 +983,7 @@ }); checkButton = card.querySelector('[data-action="check"]'); if (checkButton) { - setButtonLabel(checkButton, "Найти маршруты", "search"); + setButtonLabel(checkButton, "Проверить обновление", "update"); setButtonVariant(checkButton, true); checkButton.disabled = false; } @@ -700,7 +1011,9 @@ byId("routes-total-count").textContent = String(BUNDLES.length); if (message) { - message.textContent = loaded < BUNDLES.length + message.textContent = globalOperation && globalOperation.active + ? globalOperationMessage(globalOperation) + : loaded < BUNDLES.length ? "Загружено " + loaded + " из " + BUNDLES.length + " наборов." : actions > 0 ? "Некоторые наборы требуют действия. Подробная причина показана в карточке." @@ -710,7 +1023,10 @@ } if (status) { - if (loaded < BUNDLES.length) { + if (globalOperation && globalOperation.active) { + status.className = "status-badge status-badge-warning"; + status.textContent = "Операция выполняется"; + } else if (loaded < BUNDLES.length) { status.className = "status-badge status-loading"; status.textContent = "Загрузка…"; } else if (actions > 0) { @@ -735,10 +1051,71 @@ renderSummary(); } + function loadProgress(bundleId) { + return request("/api/routes/progress.cgi?bundleId=" + encodeURIComponent(bundleId), { + method: "GET", + credentials: "same-origin" + }).then(function (progress) { + var state = states[bundleId] || {bundleId: bundleId}; + state.operationProgress = progress; + states[bundleId] = state; + if (progress && progress.running) { + operationRunning = bundleId; + busyBundles[bundleId] = progress.operation || "export"; + } + renderCard(bundleId, state); + renderSummary(); + return progress; + }); + } + + function stopProgressWatcher(bundleId) { + if (progressWatchers[bundleId]) { + window.clearTimeout(progressWatchers[bundleId]); + delete progressWatchers[bundleId]; + } + } + + function watchProgress(bundleId) { + function poll() { + delete progressWatchers[bundleId]; + loadProgress(bundleId).then(function (progress) { + if ((progress && progress.running) || longOperationRequests[bundleId]) { + progressWatchers[bundleId] = window.setTimeout(poll, PROGRESS_POLL_INTERVAL_MS); + return; + } + delete busyBundles[bundleId]; + if (operationRunning === bundleId) operationRunning = false; + renderAll(); + loadState(bundleId).catch(function (error) { + renderLoadError(bundleId, error); + }); + }).catch(function (error) { + if (error && error.status === 401) { + window.BROrayUI.redirectToLogin(); + return; + } + if (longOperationRequests[bundleId] || operationRunning === bundleId) { + progressWatchers[bundleId] = window.setTimeout(poll, PROGRESS_POLL_INTERVAL_MS); + } + }); + } + + if (progressWatchers[bundleId]) return; + progressWatchers[bundleId] = window.setTimeout(poll, 150); + } + function loadState(bundleId) { return request("/api/routes/status.cgi?bundleId=" + encodeURIComponent(bundleId), {method: "GET", credentials: "same-origin"}).then(function (state) { + var progress = operationProgress(state); + if (state && state.globalOperation && state.globalOperation.active) { + globalOperation = state.globalOperation; + } else if (!operationRunning && !Object.keys(longOperationRequests).length) { + globalOperation = null; + } renderCard(bundleId, state); renderSummary(); + if (progress && progress.running) watchProgress(bundleId); return state; }); } @@ -758,21 +1135,25 @@ } function operationText(action, bundleId) { - if (action === "check") return "Поиск обновлений…"; + if (action === "check") return "Проверка обновления…"; + if (action === "verify") return "Проверка набора…"; if (action === "download") return "Загрузка…"; if (action === "export") { return hasRouterDrift(states[bundleId]) ? "Восстановление…" : states[bundleId] && states[bundleId].installedVersion ? "Обновление…" : "Установка…"; } if (action === "delete") return "Удаление…"; + if (action === "resume") return "Продолжение…"; return "Выполнение…"; } function successMessage(action, bundle) { - if (action === "check") return "Поиск обновлений «" + bundle.name + "» завершён."; + if (action === "check") return "Проверка обновления «" + bundle.name + "» завершена."; + if (action === "verify") return "Проверка набора «" + bundle.name + "» завершена."; if (action === "download") return "Файлы маршрутов «" + bundle.name + "» загружены."; if (action === "export") return "Маршруты «" + bundle.name + "» применены в Keenetic."; if (action === "delete") return "Маршруты «" + bundle.name + "» удалены."; + if (action === "resume") return "Операция «" + bundle.name + "» продолжена."; return "Операция завершена."; } @@ -789,65 +1170,110 @@ card.classList.toggle("is-expanded", !expanded); } - function confirmDelete(bundle) { - if (!window.BROrayDialogs || typeof window.BROrayDialogs.confirm !== "function") { - return Promise.reject(new Error("Фирменное окно подтверждения недоступно.")); - } - return window.BROrayDialogs.confirm({ - eyebrow: "Опасное действие", - title: "Удаление маршрутов", - message: "Удалить набор «" + bundle.name + "» из Keenetic? Общие маршруты других установленных наборов будут сохранены.", - confirmText: "Удалить", - cancelText: "Отмена", - variant: "danger" - }); + function formatKilobytes(value) { + var number = Number(value || 0); + if (number >= 1024) return (number / 1024).toFixed(number >= 10240 ? 0 : 1) + " МБ"; + return number + " КБ"; } - function syncPlanMessage(plan) { - var summary = plan.summary || {}; - if (plan.mode === "update") { - return "Новая версия: добавлено " + Number(summary.addedRoutes || 0) + - ", удалено " + Number(summary.removedRoutes || 0) + - ", без изменений " + Number(summary.unchangedRoutes || 0) + - ". Из Keenetic будут удалены " + Number(summary.toDelete || 0) + - " маршрутов. Ещё " + Number(summary.sharedKept || 0) + - " используются другими наборами и сохранятся."; - } - if (plan.mode === "restore") { - return "В Keenetic будут восстановлены " + - Number(summary.toCreate || 0) + " маршрутов."; + function preflightTitle(bundle, preflight) { + var operation = preflight && preflight.operation; + if (operation === "delete") return "Удалить «" + bundle.name + "» из Keenetic"; + if (operation === "update") return "Обновить «" + bundle.name + "» в Keenetic"; + if (operation === "restore") return "Восстановить «" + bundle.name + "» в Keenetic"; + return "Установить «" + bundle.name + "» в Keenetic"; + } + + function preflightMessage(preflight) { + var checks = preflight && preflight.checks ? preflight.checks : {}; + var summary = preflight && preflight.summary ? preflight.summary : {}; + var storage = checks.storage || {}; + var keenetic = checks.keenetic || {}; + var localSet = checks.localSet || {}; + var lines = []; + lines.push(preflight && preflight.message ? preflight.message : "Предварительная проверка завершена."); + lines.push(""); + lines.push((checks.operationLock && checks.operationLock.ok ? "✓" : "✕") + " Конфликтующих операций нет"); + lines.push((checks.ndmc && checks.ndmc.ok ? "✓" : "✕") + " Команда ndmc доступна"); + lines.push((keenetic.ok ? "✓" : "✕") + " Keenetic: " + + (keenetic.ok ? String(keenetic.interface || "ProxyN") + " подключён и находится в состоянии up" : "интерфейс недоступен")); + lines.push((storage.ok ? "✓" : "✕") + " Свободное место: " + formatKilobytes(storage.freeKb) + + "; требуется не менее " + formatKilobytes(storage.requiredKb)); + lines.push((localSet.ok ? "✓" : "✕") + " Локальный набор: " + Number(localSet.routeCount || 0) + + " маршрутов; ошибок " + Number(localSet.invalidRouteCount || 0) + + ", дубликатов " + Number(localSet.duplicateRouteCount || 0)); + lines.push(""); + lines.push("Всего в наборе: " + Number(summary.total || 0)); + if (Number(summary.alreadyPresent || 0) > 0) lines.push("Уже присутствует в Keenetic: " + Number(summary.alreadyPresent || 0)); + if (Number(summary.toCreate || 0) > 0) lines.push("Будет добавлено: " + Number(summary.toCreate || 0)); + if (Number(summary.toDelete || 0) > 0) lines.push("Будет удалено: " + Number(summary.toDelete || 0)); + if (Number(summary.sharedKept || 0) > 0) lines.push("Общие маршруты будут сохранены: " + Number(summary.sharedKept || 0)); + if (Number(summary.alreadyAbsent || 0) > 0) lines.push("Уже отсутствует: " + Number(summary.alreadyAbsent || 0)); + if (Number(summary.externalKept || 0) > 0) lines.push("Внешние маршруты не затрагиваются: " + Number(summary.externalKept || 0)); + lines.push("Конфликты: " + Number(summary.conflicts || 0)); + if (preflight && preflight.requestedAction === "resume" && preflight.resume) { + lines.push("Продолжение с позиции: " + Number(preflight.resume.current || 0) + " из " + Number(preflight.resume.total || 0)); } - return "В Keenetic будут установлены " + Number(summary.total || 0) + " маршрутов."; + return lines.join("\n"); } - function confirmSync(bundle, plan) { - var state = states[bundle.id]; - var action = keeneticAction(state); - if (!plan.canApply) return Promise.reject(new Error(plan.message || "План содержит конфликты.")); - if (plan.mode === "none") return Promise.resolve(false); + function confirmPreflight(bundle, preflight) { + var danger = preflight && preflight.operation === "delete"; + if (!preflight || preflight.ready !== true) { + return Promise.reject(new Error(preflightMessage(preflight))); + } if (!window.BROrayDialogs || typeof window.BROrayDialogs.confirm !== "function") { return Promise.reject(new Error("Фирменное окно подтверждения недоступно.")); } return window.BROrayDialogs.confirm({ - eyebrow: "Маршруты Keenetic", - title: action ? action.text : "Применить маршруты", - message: syncPlanMessage(plan), - confirmText: action ? action.text : "Продолжить", - cancelText: "Отмена" + eyebrow: "Предварительная проверка", + title: preflightTitle(bundle, preflight), + message: preflightMessage(preflight), + confirmText: preflight.requestedAction === "resume" ? "Продолжить" : + (danger ? "Удалить" : (preflight.operation === "update" ? "Обновить" : + (preflight.operation === "restore" ? "Восстановить" : "Установить"))), + cancelText: "Отмена", + variant: danger ? "danger" : "primary", + icon: danger ? "delete" : "security" }); } - function executeOperation(bundle, action, buttonNode) { + function executeOperation(bundle, action, buttonNode, preflightToken) { + var isLongOperation = action === "export" || action === "delete" || action === "resume"; + var timeout = isLongOperation ? LONG_OPERATION_TIMEOUT_MS : REQUEST_TIMEOUT_MS; + var state = states[bundle.id] || {bundleId: bundle.id}; + var url = "/api/routes/" + action + ".cgi?bundleId=" + encodeURIComponent(bundle.id); + + if (isLongOperation) { + url += "&preflightToken=" + encodeURIComponent(preflightToken || ""); + } busyBundles[bundle.id] = action; - renderCard(bundle.id, states[bundle.id] || {}); + operationRunning = bundle.id; + globalOperation = {active:true, bundleId:bundle.id, action:action}; + if (isLongOperation) { + longOperationRequests[bundle.id] = true; + state.operationProgress = initialOperationProgress(bundle.id, action); + states[bundle.id] = state; + watchProgress(bundle.id); + } + renderAll(); setButtonLabel(buttonNode, operationText(action, bundle.id), buttonNode.getAttribute("data-icon")); buttonNode.setAttribute("aria-busy", "true"); - return withTimeout(request("/api/routes/" + action + ".cgi?bundleId=" + encodeURIComponent(bundle.id), { + return withTimeout(request(url, { method: "POST", credentials: "same-origin", headers: {"Accept": "application/json"} - }), REQUEST_TIMEOUT_MS).then(function (state) { - states[bundle.id] = state; - window.BROrayUI.toast(successMessage(action, bundle), "success"); + }), timeout).then(function (newState) { + if (states[bundle.id] && states[bundle.id].operationProgress && !newState.operationProgress) { + newState.operationProgress = states[bundle.id].operationProgress; + } + states[bundle.id] = newState; + if (newState.operationProgress && newState.operationProgress.resumable) { + window.BROrayUI.toast(newState.operationProgress.message || "Операция приостановлена и может быть продолжена.", "warning"); + } else if (action === "verify" && newState.verifyResult && newState.verifyResult.success === false) { + window.BROrayUI.toast(newState.verifyResult.message || "Проверка набора выявила проблему.", "warning"); + } else { + window.BROrayUI.toast(successMessage(action, bundle), "success"); + } }).catch(function (error) { if (error && (error.status === 401 || error.code === "AUTH_REQUIRED" || error.code === "SESSION_REQUIRED")) { window.BROrayUI.redirectToLogin(); @@ -855,32 +1281,74 @@ } window.BROrayUI.toast(error && error.message ? error.message : "Операция с маршрутами завершилась ошибкой.", "error"); }).then(function () { + delete longOperationRequests[bundle.id]; delete busyBundles[bundle.id]; + if (operationRunning === bundle.id) operationRunning = false; + globalOperation = null; buttonNode.removeAttribute("aria-busy"); + if (isLongOperation) { + return loadProgress(bundle.id).catch(function () { return null; }).then(function () { + return loadState(bundle.id).catch(function (error) { renderLoadError(bundle.id, error); }); + }); + } return loadState(bundle.id).catch(function (error) { renderLoadError(bundle.id, error); }); }); } - function prepareSync(bundle, buttonNode) { - busyBundles[bundle.id] = "plan"; - renderCard(bundle.id, states[bundle.id] || {}); - return withTimeout(request("/api/routes/plan.cgi?bundleId=" + encodeURIComponent(bundle.id), { + function prepareLongOperation(bundle, action, buttonNode) { + var originalIcon = buttonNode.getAttribute("data-icon"); + busyBundles[bundle.id] = "preflight"; + operationRunning = bundle.id; + globalOperation = {active:true, bundleId:bundle.id, action:"preflight"}; + renderAll(); + setButtonLabel(buttonNode, "Предварительная проверка…", "security"); + buttonNode.setAttribute("aria-busy", "true"); + + return withTimeout(request("/api/routes/preflight.cgi?bundleId=" + encodeURIComponent(bundle.id) + + "&action=" + encodeURIComponent(action), { method: "POST", credentials: "same-origin", headers: {"Accept": "application/json"} - }), REQUEST_TIMEOUT_MS).then(function (plan) { + }), REQUEST_TIMEOUT_MS).then(function (preflight) { delete busyBundles[bundle.id]; - renderCard(bundle.id, states[bundle.id] || {}); - if (plan.mode === "none") { - window.BROrayUI.toast("Изменения в Keenetic не требуются.", "success"); - return false; - } - return confirmSync(bundle, plan); - }).then(function (confirmed) { - if (confirmed) return executeOperation(bundle, "export", buttonNode); - return null; + if (operationRunning === bundle.id) operationRunning = false; + globalOperation = null; + buttonNode.removeAttribute("aria-busy"); + renderAll(); + return confirmPreflight(bundle, preflight).then(function (confirmed) { + if (!confirmed) return null; + return executeOperation(bundle, action, buttonNode, preflight.token); + }); }).catch(function (error) { delete busyBundles[bundle.id]; - renderCard(bundle.id, states[bundle.id] || {}); - window.BROrayUI.toast(error && error.message ? error.message : "Не удалось подготовить план установки.", "error"); + if (operationRunning === bundle.id) operationRunning = false; + globalOperation = null; + buttonNode.removeAttribute("aria-busy"); + setButtonLabel(buttonNode, operationText(action, bundle.id).replace("…", ""), originalIcon); + renderAll(); + window.BROrayUI.toast(error && error.message ? error.message : "Предварительная проверка не завершена.", "error"); + return null; + }); + } + + function requestStop(bundle) { + var state = states[bundle.id] || {bundleId: bundle.id}; + var progress = operationProgress(state); + if (!progress || !progress.running || progress.stopRequested) return; + progress.stopRequested = true; + progress.phase = "stopping"; + progress.message = "Остановка запрошена. Текущий маршрут будет завершён."; + renderCard(bundle.id, state); + request("/api/routes/stop.cgi?bundleId=" + encodeURIComponent(bundle.id), { + method: "POST", credentials: "same-origin", headers: {"Accept": "application/json"} + }).then(function (newProgress) { + state.operationProgress = newProgress; + states[bundle.id] = state; + renderCard(bundle.id, state); + watchProgress(bundle.id); + window.BROrayUI.toast("Остановка будет выполнена после текущего маршрута.", "warning"); + }).catch(function (error) { + progress.stopRequested = false; + renderCard(bundle.id, state); + window.BROrayUI.toast(error && error.message ? error.message : "Не удалось запросить остановку.", "error"); }); } @@ -895,18 +1363,16 @@ return; } autoCheckCancelled = true; - if (busyBundles[bundleId]) return; - if (action === "delete") { - confirmDelete(bundle).then(function (confirmed) { - if (confirmed) executeOperation(bundle, action, buttonNode); - }).catch(function (error) { window.BROrayUI.toast(error.message, "error"); }); + if (action === "stop") { + requestStop(bundle); return; } - if (action === "export") { - prepareSync(bundle, buttonNode); + if (busyBundles[bundleId]) return; + if (action === "resume" || action === "delete" || action === "export") { + prepareLongOperation(bundle, action, buttonNode); return; } - executeOperation(bundle, action, buttonNode); + executeOperation(bundle, action, buttonNode, null); } function staleForAutomaticCheck(state) { @@ -921,7 +1387,9 @@ if (autoCheckCancelled || index >= queue.length) return Promise.resolve(); bundle = queue[index]; busyBundles[bundle.id] = "check"; - renderCard(bundle.id, states[bundle.id] || {}); + operationRunning = bundle.id; + globalOperation = {active:true, bundleId:bundle.id, action:"check"}; + renderAll(); return withTimeout(request("/api/routes/check.cgi?bundleId=" + encodeURIComponent(bundle.id), { method: "POST", credentials: "same-origin", headers: {"Accept": "application/json"} }), REQUEST_TIMEOUT_MS).then(function (state) { @@ -933,6 +1401,8 @@ } }).then(function () { delete busyBundles[bundle.id]; + if (operationRunning === bundle.id) operationRunning = false; + globalOperation = null; if (states[bundle.id]) renderCard(bundle.id, states[bundle.id]); renderSummary(); return runAutomaticChecks(queue, index + 1); @@ -940,7 +1410,9 @@ } function startAutomaticChecks() { - var queue = BUNDLES.filter(function (bundle) { return staleForAutomaticCheck(states[bundle.id]); }); + var queue; + if (operationRunning) return; + queue = BUNDLES.filter(function (bundle) { return staleForAutomaticCheck(states[bundle.id]); }); if (queue.length) runAutomaticChecks(queue, 0); } @@ -962,6 +1434,7 @@ revealApplication(session); return loadAllStates(); }).then(function () { + renderAll(); startAutomaticChecks(); }).catch(function (error) { if (error && error.status === 401) { @@ -973,6 +1446,28 @@ }); } + if (window.BROrayTestMode === true) { + window.BROrayRoutesTestHooks = { + sameRouteVersion: sameRouteVersion, + sameSourceVersion: sameSourceVersion, + downloadRequired: downloadRequired, + downloadActionRequired: downloadActionRequired, + localSetInvalid: localSetInvalid, + verificationCurrent: verificationCurrent, + verificationRequired: verificationRequired, + verificationConflict: verificationConflict, + verificationRetryRequired: verificationRetryRequired, + keeneticAction: keeneticAction, + nextAction: nextAction, + statusPresentation: statusPresentation, + messageFor: messageFor, + checkLabel: checkLabel, + preflightMessage: preflightMessage, + preflightTitle: preflightTitle, + globalOperationMessage: globalOperationMessage + }; + } + if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", initialize, {once: true}); } else { diff --git a/root/opt/broray/web-new/broray.html b/root/opt/broray/web-new/broray.html index c66cad9..d6e3c2f 100644 --- a/root/opt/broray/web-new/broray.html +++ b/root/opt/broray/web-new/broray.html @@ -1,5 +1,5 @@ - + @@ -11,11 +11,11 @@ - - - + + + - +