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 развивается как открытый проект. Поддержка помогает оплачивать инфраструктуру, тестирование и выпуск обновлений.
+
+
+
+
+
+
+
[Поддержать 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 развивается как открытый проект. Поддержка помогает оплачивать инфраструктуру, тестирование и выпуск обновлений.
+
+
+
+
+
+
+
+- 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 @@
-
-
-
+
+
+
-
+