#!/bin/sh set -eu EXPECTED_ARCH='aarch64_cortex-a53' EXPECTED_TARGET='mediatek/filogic' PANEL_URL='https://router.vectra-pro.net' CONTROL_URL='https://api.vectra-pro.net' FEED_URL='https://api.vectra-pro.net/artifacts/openwrt/stable/aarch64_cortex-a53/' BASELINE_URL='https://router.vectra-pro.net/api/install/ax3000t-passwall2-baseline.uci' PASSWALL_RELEASE_TAG='26.4.10-1' PASSWALL_MIRROR_URL='https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/' STAGE_MARGIN_BYTES='1048576' OVERLAY_PATH='/overlay' WORKDIR='/tmp/vectra-bootstrap-work' BACKUP_ROOT='/root/vectra-bootstrap-backup' OPKG_TMP_DIR="$WORKDIR/opkg-tmp" REQUIRED_MANAGED_PACKAGES='xray-core v2ray-geoip v2ray-geosite geoview chinadns-ng tcping luci-app-passwall2' ALLOWED_INTERNAL_DEPENDENTS='tcping xray-core geoview v2ray-geoip v2ray-geosite chinadns-ng luci-app-passwall2 sing-box hysteria' REQUIRED_RUNTIME_PACKAGES='dnsmasq-full kmod-nft-socket kmod-nft-tproxy' OPTIONAL_RUNTIME_PACKAGES='kmod-nft-nat' CONTROLLER_PACKAGES='vectra-controller-agent luci-app-vectra-controller' OPENWRT_FEED_PREREQS='libc coreutils coreutils-base64 coreutils-nohup curl ip-full libuci-lua lua luci-compat luci-lib-jsonc resolveip unzip luci-lua-runtime' OPENWRT_MIRROR_URL='https://api.vectra-pro.net/openwrt-cache' OPENWRT_FEED_PROXY_ACTIVE='0' log() { printf '%s\n' "$*" } bytes_or_zero() { value="$1" case "$value" in ''|*[!0-9]*) printf '0\n' ;; *) printf '%s\n' "$value" ;; esac } run_opkg() { opkg -t "$OPKG_TMP_DIR" "$@" } require_cmd() { command -v "$1" >/dev/null 2>&1 || { log "Не найдена команда: $1" exit 1 } } require_cmd wget require_cmd opkg require_cmd opkg-key require_cmd uci ARCH="$(awk -F"'" '/^DISTRIB_ARCH=/{print $2; exit}' /etc/openwrt_release 2>/dev/null || true)" TARGET="$(awk -F"'" '/^DISTRIB_TARGET=/{print $2; exit}' /etc/openwrt_release 2>/dev/null || true)" CURRENT_ARCH='unknown' CURRENT_TARGET='unknown' if [ -n "$ARCH" ]; then CURRENT_ARCH="$ARCH" fi if [ -n "$TARGET" ]; then CURRENT_TARGET="$TARGET" fi if [ "$ARCH" != "$EXPECTED_ARCH" ]; then log "Скрипт подготовлен для $EXPECTED_ARCH. Текущая архитектура: $CURRENT_ARCH" exit 1 fi if [ "$TARGET" != "$EXPECTED_TARGET" ]; then log "Скрипт подготовлен для $EXPECTED_TARGET. Текущий target: $CURRENT_TARGET" exit 1 fi ensure_opkg_architecture() { arch_name="$1" arch_conf='/etc/opkg/arch.conf' mkdir -p /etc/opkg [ -f "$arch_conf" ] || : > "$arch_conf" ensure_arch_line() { name="$1" priority="$2" if ! grep -Eq "^[[:space:]]*arch[[:space:]]+${name}([[:space:]]|$)" "$arch_conf" 2>/dev/null; then printf 'arch %s %s\n' "$name" "$priority" >> "$arch_conf" log "opkg arch.conf: добавил архитектуру $name" fi } ensure_arch_line all 1 ensure_arch_line noarch 1 ensure_arch_line "$arch_name" 100 if ! opkg print-architecture 2>/dev/null | awk '{ if ($1 == "arch") print $2; else print $1 }' | grep -qx "$arch_name"; then log "opkg всё ещё не видит архитектуру $arch_name; зависимости вроде libc могут не разрешиться." fi } PREVIOUS_SELECTED_NODE="$(uci -q get passwall2.vectra_global.node || uci -q get passwall2.@global[0].node || true)" mkdir -p "$WORKDIR" "$BACKUP_ROOT" "$OPKG_TMP_DIR" restore_openwrt_feeds() { if [ "$OPENWRT_FEED_PROXY_ACTIVE" = '1' ] && [ -f "$BACKUP_ROOT/distfeeds.conf.orig" ]; then cp "$BACKUP_ROOT/distfeeds.conf.orig" /etc/opkg/distfeeds.conf 2>/dev/null || true OPENWRT_FEED_PROXY_ACTIVE='0' log 'opkg distfeeds возвращены к штатным OpenWrt-зеркалам.' fi } cleanup() { restore_openwrt_feeds rm -rf "$WORKDIR" } trap cleanup EXIT INT TERM STAMP="$(date +%Y%m%d-%H%M%S 2>/dev/null || echo now)" BACKUP_DIR="$BACKUP_ROOT/$STAMP" mkdir -p "$BACKUP_DIR" [ -f /etc/config/passwall2 ] && cp /etc/config/passwall2 "$BACKUP_DIR/passwall2" || true [ -f /etc/config/passwall2_server ] && cp /etc/config/passwall2_server "$BACKUP_DIR/passwall2_server" || true [ -f /etc/config/vectra-controller ] && cp /etc/config/vectra-controller "$BACKUP_DIR/vectra-controller" || true [ -f /etc/config/dhcp ] && cp /etc/config/dhcp "$BACKUP_DIR/dhcp" || true [ -f /etc/opkg/arch.conf ] && cp /etc/opkg/arch.conf "$BACKUP_DIR/opkg-arch.conf" || true [ -f /etc/opkg/customfeeds.conf ] && cp /etc/opkg/customfeeds.conf "$BACKUP_DIR/customfeeds.conf" || true log "Бэкап сохранён в $BACKUP_DIR" ensure_opkg_architecture "$ARCH" wget -qO "$WORKDIR/vectra.pub" "$FEED_URL/vectra.pub" opkg-key add "$WORKDIR/vectra.pub" >/dev/null 2>&1 || true if [ -f /etc/opkg/customfeeds.conf ]; then grep -v '/artifacts/openwrt/' /etc/opkg/customfeeds.conf > "$WORKDIR/customfeeds.conf" || true else : > "$WORKDIR/customfeeds.conf" fi printf '%s\n' "src/gz vectra $FEED_URL" >> "$WORKDIR/customfeeds.conf" cat "$WORKDIR/customfeeds.conf" > /etc/opkg/customfeeds.conf if ! run_opkg update; then log 'opkg update завершился с предупреждениями; продолжаю, потому что package-specific preflight проверит нужные пакеты отдельно.' fi package_status_field() { pkg="$1" field="$2" awk -F': ' -v pkg="$pkg" -v field="$field" ' /^Package:/ { current = ($2 == pkg); next } current && $1 == field { print $2; exit } ' /usr/lib/opkg/status 2>/dev/null } package_installed() { status="$(package_status_field "$1" Status || true)" printf '%s\n' "$status" | grep -Eq '(^| )installed($| )' } package_version() { package_status_field "$1" Version || true } package_installed_size_bytes() { bytes_or_zero "$(package_status_field "$1" Installed-Size || true)" } package_available() { run_opkg list "$1" 2>/dev/null | grep -q "^$1 - " } openwrt_base_prereq_present() { pkg="$1" case "$pkg" in libc) [ -e /lib/libc.so ] || ls /lib/ld-musl-*.so.1 >/dev/null 2>&1 ;; *) return 1 ;; esac } get_free_bytes() { path="$1" bytes="$(df -kP "$path" 2>/dev/null | awk 'NR == 2 { print $4 * 1024; exit }')" bytes_or_zero "$bytes" } download_file() { url="$1" destination="$2" wget -qO "$destination" "$url" || { log "Не удалось скачать $url" exit 1 } } package_index_field() { pkg="$1" field="$2" awk -F': ' -v pkg="$pkg" -v field="$field" ' $1 == "Package" { if (match_pkg && value != "") { print value exit } match_pkg = ($2 == pkg) value = "" next } match_pkg && $1 == field && value == "" { value = $2 } END { if (value != "") { print value } } ' "$PACKAGES_FILE" } feed_package_field() { pkg="$1" field="$2" run_opkg info "$pkg" 2>/dev/null | awk -F': ' -v pkg="$pkg" -v field="$field" ' $1 == "Package" { if (match_pkg && value != "") { print value exit } match_pkg = ($2 == pkg) value = "" next } match_pkg && $1 == field && value == "" { value = $2 } END { if (value != "") { print value } } ' } feed_package_installed_size_bytes() { bytes_or_zero "$(feed_package_field "$1" Installed-Size || true)" } feed_package_download_size_bytes() { bytes_or_zero "$(feed_package_field "$1" Size || true)" } feed_package_storage_budget_bytes() { pkg="$1" installed_size="$(feed_package_installed_size_bytes "$pkg")" if [ "$installed_size" -gt 0 ]; then printf '%s\n' "$installed_size" return 0 fi feed_package_download_size_bytes "$pkg" } vectra_package_field() { value="$(feed_package_field "$1" "$2" || true)" if [ -n "$value" ]; then printf '%s\n' "$value" return 0 fi package_index_field "$1" "$2" } managed_package_field() { pkg="$1" field="$2" case "$pkg" in tcping) case "$field" in version) printf '%s\n' '0.3-r1' ;; download_size_bytes) printf '%s\n' '4339' ;; installed_size_bytes) printf '%s\n' '71680' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/tcping_0.3-r1_aarch64_cortex-a53.ipk' ;; *) return 1 ;; esac ;; xray-core) case "$field" in version) printf '%s\n' '26.3.27-r1' ;; download_size_bytes) printf '%s\n' '10777362' ;; installed_size_bytes) printf '%s\n' '30320640' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/xray-core_26.3.27-r1_aarch64_cortex-a53.ipk' ;; *) return 1 ;; esac ;; geoview) case "$field" in version) printf '%s\n' '0.2.5-r1' ;; download_size_bytes) printf '%s\n' '2740538' ;; installed_size_bytes) printf '%s\n' '7208960' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/geoview_0.2.5-r1_aarch64_cortex-a53.ipk' ;; *) return 1 ;; esac ;; v2ray-geoip) case "$field" in version) printf '%s\n' '202603260032.1' ;; download_size_bytes) printf '%s\n' '4040459' ;; installed_size_bytes) printf '%s\n' '19773440' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/v2ray-geoip_202603260032.1_all.ipk' ;; *) return 1 ;; esac ;; v2ray-geosite) case "$field" in version) printf '%s\n' '202603292224.1' ;; download_size_bytes) printf '%s\n' '3456591' ;; installed_size_bytes) printf '%s\n' '10536960' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/v2ray-geosite_202603292224.1_all.ipk' ;; *) return 1 ;; esac ;; chinadns-ng) case "$field" in version) printf '%s\n' '2025.08.09-r1' ;; download_size_bytes) printf '%s\n' '269754' ;; installed_size_bytes) printf '%s\n' '522240' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/chinadns-ng_2025.08.09-r1_aarch64_cortex-a53.ipk' ;; *) return 1 ;; esac ;; luci-app-passwall2) case "$field" in version) printf '%s\n' '26.4.10-r1' ;; download_size_bytes) printf '%s\n' '325772' ;; installed_size_bytes) printf '%s\n' '1300480' ;; url) printf '%s\n' 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/luci-app-passwall2_26.4.10-r1_all.ipk' ;; *) return 1 ;; esac ;; *) return 1 ;; esac } managed_package_at_target() { package_installed "$1" && [ "$(package_version "$1")" = "$(managed_package_field "$1" version)" ] } managed_package_needs_replacement() { package_installed "$1" && [ "$(package_version "$1")" != "$(managed_package_field "$1" version)" ] } passwall_config_parses() { uci show passwall2 >/dev/null 2>&1 } passwall_config_exists() { [ -f /etc/config/passwall2 ] } passwall_runtime_ready_for_reuse() { package_installed luci-app-passwall2 || return 1 passwall_config_parses || return 1 [ -x /etc/init.d/passwall2 ] || return 1 [ -f /usr/share/passwall2/rule_update.lua ] || return 1 [ -f /usr/share/passwall2/subscribe.lua ] || return 1 return 0 } runtime_package_needs_install() { pkg="$1" case "$pkg" in dnsmasq-full) ! package_installed dnsmasq-full ;; *) ! package_installed "$pkg" ;; esac } PACKAGES_FILE="$WORKDIR/Packages" download_file "$FEED_URL/Packages" "$PACKAGES_FILE" vectra_package_version() { vectra_package_field "$1" Version } vectra_package_installed_size_bytes() { bytes_or_zero "$(vectra_package_field "$1" Installed-Size)" } vectra_package_download_size_bytes() { bytes_or_zero "$(vectra_package_field "$1" Size)" } vectra_package_storage_budget_bytes() { pkg="$1" installed_size="$(vectra_package_installed_size_bytes "$pkg")" if [ "$installed_size" -gt 0 ]; then printf '%s\n' "$installed_size" return 0 fi vectra_package_download_size_bytes "$pkg" } controller_package_at_target() { pkg="$1" target_version="$(vectra_package_version "$pkg")" package_installed "$pkg" && [ "$(package_version "$pkg")" = "$target_version" ] } whatdepends_packages() { pkg="$1" opkg whatdepends "$pkg" 2>/dev/null | awk '/^[[:space:]]+[[:alnum:]_.+-]+([[:space:]]|$)/ { print $1 }' | awk '!seen[$0]++' } list_unexpected_dependents() { pkg="$1" whatdepends_packages "$pkg" | while IFS= read -r dependent; do [ -n "$dependent" ] || continue [ "$dependent" = "$pkg" ] && continue case " $ALLOWED_INTERNAL_DEPENDENTS " in *" $dependent "*) ;; *) printf '%s\n' "$dependent" ;; esac done } package_depended_on_by_passwall_app() { pkg="$1" whatdepends_packages "$pkg" | grep -qx 'luci-app-passwall2' } install_optional_openwrt_packages() { for pkg in "$@"; do if ! package_available "$pkg"; then continue fi if package_installed "$pkg"; then continue fi run_opkg install "$pkg" || true done } install_dnsmasq_full() { if package_installed dnsmasq-full; then rm -f /etc/config/dhcp-opkg log '[dnsmasq-full] уже установлен; пропускаю.' return 0 fi dhcp_backup='' if [ -f /etc/config/dhcp ]; then dhcp_backup="$WORKDIR/dhcp.before-dnsmasq-full" cp /etc/config/dhcp "$dhcp_backup" fi if package_installed dnsmasq; then log 'Заменяю штатный dnsmasq на dnsmasq-full для PassWall2...' run_opkg remove dnsmasq || { log 'Не удалось удалить штатный dnsmasq перед установкой dnsmasq-full' exit 1 } fi rm -f /etc/config/dhcp /etc/config/dhcp-opkg run_opkg install dnsmasq-full || { log 'Не удалось установить dnsmasq-full' exit 1 } if [ -n "$dhcp_backup" ] && [ -f "$dhcp_backup" ]; then cp "$dhcp_backup" /etc/config/dhcp fi rm -f /etc/config/dhcp-opkg /etc/init.d/dnsmasq restart >/dev/null 2>&1 || true } require_feed_package() { pkg="$1" if openwrt_base_prereq_present "$pkg"; then return 0 fi if package_installed "$pkg"; then return 0 fi package_available "$pkg" || { log "Обязательный пакет не установлен и не найден в доступных OpenWrt feeds: $pkg" exit 1 } } require_feed_package_storage_metadata() { pkg="$1" storage_budget="$(feed_package_storage_budget_bytes "$pkg")" download_size="$(feed_package_download_size_bytes "$pkg")" [ "$storage_budget" -gt 0 ] || { log "Предупреждение: не удалось определить storage budget для OpenWrt пакета $pkg (opkg info без Installed-Size/Size); пропускаю storage-aware проверку для него." return 0 } [ "$download_size" -gt 0 ] || { log "Предупреждение: не удалось определить Size для OpenWrt пакета $pkg; пропускаю storage-aware проверку для него." return 0 } } require_feed_packages() { for pkg in "$@"; do require_feed_package "$pkg" done } require_feed_packages_with_storage() { for pkg in "$@"; do require_feed_package "$pkg" require_feed_package_storage_metadata "$pkg" done } require_remote_ipk() { pkg="$1" url="$2" wget -q --spider "$url" || { log "В зеркале PassWall2 не найден обязательный пакет: $pkg ($url)" exit 1 } } require_vectra_package() { pkg="$1" version="$(vectra_package_version "$pkg" || true)" installed_size="$(vectra_package_installed_size_bytes "$pkg")" download_size="$(vectra_package_download_size_bytes "$pkg")" [ -n "$version" ] || { log "Не удалось найти $pkg в подписанном feed Vectra" exit 1 } [ "$download_size" -gt 0 ] || { log "Не удалось определить Size для пакета Vectra $pkg. Storage-aware preflight остановлен." exit 1 } } try_openwrt_feed_proxy() { [ -n "$OPENWRT_MIRROR_URL" ] || return 1 [ "$OPENWRT_FEED_PROXY_ACTIVE" = '1' ] && return 1 [ -f /etc/opkg/distfeeds.conf ] || return 1 grep -q 'downloads.openwrt.org' /etc/opkg/distfeeds.conf || return 1 mkdir -p "$BACKUP_ROOT" if [ ! -f "$BACKUP_ROOT/distfeeds.conf.orig" ]; then cp /etc/opkg/distfeeds.conf "$BACKUP_ROOT/distfeeds.conf.orig" || return 1 fi log "OpenWrt feeds недоступны напрямую (частая причина — битый IPv6 до downloads.openwrt.org); переключаю opkg на кеширующий прокси Vectra: $OPENWRT_MIRROR_URL" sed -e "s#https://downloads.openwrt.org#$OPENWRT_MIRROR_URL#g" -e "s#http://downloads.openwrt.org#$OPENWRT_MIRROR_URL#g" "$BACKUP_ROOT/distfeeds.conf.orig" > /etc/opkg/distfeeds.conf || { cp "$BACKUP_ROOT/distfeeds.conf.orig" /etc/opkg/distfeeds.conf 2>/dev/null || true return 1 } OPENWRT_FEED_PROXY_ACTIVE='1' run_opkg update >/dev/null 2>&1 || true return 0 } ensure_openwrt_feeds_usable() { sentinel_pkg='dnsmasq-full' if package_installed "$sentinel_pkg"; then return 0 fi if package_available "$sentinel_pkg"; then return 0 fi if try_openwrt_feed_proxy && package_available "$sentinel_pkg"; then log 'OpenWrt feeds доступны через кеширующий прокси Vectra; продолжаю полную установку (PassWall2/Xray + контроллер).' return 0 fi log "OpenWrt feeds недоступны: $sentinel_pkg не виден в opkg lists после opkg update." if [ "$BOOTSTRAP_CLASSIFICATION" = 'fresh install' ]; then CONTROLLER_ONLY_BOOTSTRAP='1' PASSWALL_APP_REMOVAL_REQUIRED='0' log 'Свежая установка при недоступных OpenWrt feeds: dnsmasq-full и kmod-* сейчас не скачать с downloads.openwrt.org.' log 'Продолжаю controller-only bootstrap: ставлю контроллер Vectra (его зависимости есть в базовом образе) без PassWall2/Xray.' log 'PassWall2/Xray довключим из панели Vectra управляемой задачей после первого check-in, когда фиды снова доступны.' return 0 fi log 'Bootstrap не может продолжаться: dnsmasq-full и kmod-* надо ставить из downloads.openwrt.org, а opkg update не смог скачать metadata.' log 'Проверьте сеть на роутере и перезапустите bootstrap:' log ' - ip route show default' log ' - cat /etc/resolv.conf' log ' - ping -c 2 -W 5 downloads.openwrt.org' log ' - nslookup downloads.openwrt.org' log 'Если резолвер не отвечает, временно добавьте публичный DNS и повторите запуск:' log " echo 'nameserver 1.1.1.1' >> /etc/resolv.conf" log 'Bootstrap прерван до любых изменений.' exit 1 } detect_bootstrap_classification() { installed_required='0' outdated_required='0' missing_required='0' for pkg in $REQUIRED_MANAGED_PACKAGES; do if package_installed "$pkg"; then installed_required=$((installed_required + 1)) if ! managed_package_at_target "$pkg"; then outdated_required=$((outdated_required + 1)) fi else missing_required=$((missing_required + 1)) fi done if passwall_config_exists && ! passwall_config_parses; then printf '%s\n' 'repair broken PassWall config' elif [ "$installed_required" -eq 0 ]; then printf '%s\n' 'fresh install' elif [ "$outdated_required" -gt 0 ]; then printf '%s\n' 'upgrade existing PassWall stack' elif [ "$missing_required" -gt 0 ]; then printf '%s\n' 'repair drifted managed packages' else printf '%s\n' 'upgrade existing PassWall stack' fi } update_max_stage_bytes() { candidate="$1" if [ "$candidate" -gt "$MAX_STAGE_REQUIRED_BYTES" ]; then MAX_STAGE_REQUIRED_BYTES="$candidate" fi } mark_controller_only_bootstrap() { pkg="$1" available_bytes="$2" required_bytes="$3" reclaim_bytes="$4" CONTROLLER_ONLY_BOOTSTRAP='1' PASSWALL_APP_REMOVAL_REQUIRED='0' log "Недостаточно места на /overlay для стартового PassWall2/Xray stack ($pkg): доступно ${available_bytes} B, требуется ${required_bytes} B." if [ "$reclaim_bytes" -gt 0 ]; then log "Даже после reclaim $pkg доступно только $((available_bytes + reclaim_bytes)) B." fi log 'Продолжаю controller-only bootstrap: ставлю контроллер Vectra без PassWall2/Xray.' log 'PassWall2/Xray обновим позже из панели Vectra через контроллер после первого check-in.' } is_passwall_bootstrap_package() { case " $REQUIRED_MANAGED_PACKAGES $REQUIRED_RUNTIME_PACKAGES " in *" $1 "*) return 0 ;; *) return 1 ;; esac } overlay_fail() { pkg="$1" available_bytes="$2" required_bytes="$3" reclaim_bytes="$4" if [ "$BOOTSTRAP_CLASSIFICATION" = 'fresh install' ] && is_passwall_bootstrap_package "$pkg"; then mark_controller_only_bootstrap "$pkg" "$available_bytes" "$required_bytes" "$reclaim_bytes" return 0 fi log "Недостаточно места на /overlay для $pkg: доступно ${available_bytes} B, требуется ${required_bytes} B." if [ "$reclaim_bytes" -gt 0 ]; then log "Даже после reclaim $pkg доступно только $((available_bytes + reclaim_bytes)) B." fi log "Режим bootstrap: $BOOTSTRAP_CLASSIFICATION" log 'Bootstrap прерван до любых изменений.' exit 1 } simulate_overlay_step() { pkg="$1" required_bytes="$2" reclaim_bytes="$3" available_bytes="$SIMULATED_OVERLAY_FREE_BYTES" [ "$required_bytes" -gt 0 ] || { log "Предупреждение: не удалось определить storage budget для $pkg (opkg info без Installed-Size/Size); пропускаю overlay-симуляцию для него." return 0 } if [ $((available_bytes + reclaim_bytes)) -lt "$required_bytes" ]; then overlay_fail "$pkg" "$available_bytes" "$required_bytes" "$reclaim_bytes" return 1 fi SIMULATED_OVERLAY_FREE_BYTES=$((available_bytes + reclaim_bytes - required_bytes)) return 0 } detect_fresh_passwall_overlay_shortage() { [ "$BOOTSTRAP_CLASSIFICATION" = 'fresh install' ] || return 0 simulated_overlay_bytes="$CURRENT_OVERLAY_FREE_BYTES" for pkg in $REQUIRED_MANAGED_PACKAGES; do target_bytes="$(managed_package_field "$pkg" installed_size_bytes)" [ "$target_bytes" -gt 0 ] || continue if [ "$simulated_overlay_bytes" -lt "$target_bytes" ]; then mark_controller_only_bootstrap "$pkg" "$simulated_overlay_bytes" "$target_bytes" '0' return 0 fi simulated_overlay_bytes=$((simulated_overlay_bytes - target_bytes)) done } run_preflight_checks() { log 'Проверяю prerequisites bootstrap...' CURRENT_STAGE_FREE_BYTES="$(get_free_bytes "$WORKDIR")" CURRENT_OVERLAY_FREE_BYTES="$(get_free_bytes "$OVERLAY_PATH")" BOOTSTRAP_CLASSIFICATION="$(detect_bootstrap_classification)" REUSE_EXISTING_PASSWALL_STACK='0' CONTROLLER_ONLY_BOOTSTRAP='0' PASSWALL_APP_REMOVAL_REQUIRED='0' if [ "$BOOTSTRAP_CLASSIFICATION" != 'fresh install' ] && passwall_runtime_ready_for_reuse; then REUSE_EXISTING_PASSWALL_STACK='1' fi detect_fresh_passwall_overlay_shortage if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then require_remote_ipk tcping 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/tcping_0.3-r1_aarch64_cortex-a53.ipk' require_remote_ipk xray-core 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/xray-core_26.3.27-r1_aarch64_cortex-a53.ipk' require_remote_ipk geoview 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/geoview_0.2.5-r1_aarch64_cortex-a53.ipk' require_remote_ipk v2ray-geoip 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/v2ray-geoip_202603260032.1_all.ipk' require_remote_ipk v2ray-geosite 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/v2ray-geosite_202603292224.1_all.ipk' require_remote_ipk chinadns-ng 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/chinadns-ng_2025.08.09-r1_aarch64_cortex-a53.ipk' require_remote_ipk luci-app-passwall2 'https://api.vectra-pro.net/artifacts/bootstrap/passwall2/26.4.10-1/aarch64_cortex-a53/luci-app-passwall2_26.4.10-r1_all.ipk' ensure_openwrt_feeds_usable fi if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then require_feed_packages $OPENWRT_FEED_PREREQS require_feed_packages_with_storage $REQUIRED_RUNTIME_PACKAGES fi require_vectra_package vectra-controller-agent require_vectra_package luci-app-vectra-controller MAX_STAGE_REQUIRED_BYTES='0' if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && [ "$REUSE_EXISTING_PASSWALL_STACK" != '1' ]; then for pkg in $REQUIRED_MANAGED_PACKAGES; do if ! managed_package_at_target "$pkg"; then download_bytes="$(managed_package_field "$pkg" download_size_bytes)" update_max_stage_bytes $((download_bytes + STAGE_MARGIN_BYTES)) fi done fi if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then for pkg in $REQUIRED_RUNTIME_PACKAGES; do if runtime_package_needs_install "$pkg"; then download_bytes="$(feed_package_download_size_bytes "$pkg")" update_max_stage_bytes $((download_bytes + STAGE_MARGIN_BYTES)) fi done fi for pkg in $CONTROLLER_PACKAGES; do if ! controller_package_at_target "$pkg"; then download_bytes="$(vectra_package_download_size_bytes "$pkg")" update_max_stage_bytes $((download_bytes + STAGE_MARGIN_BYTES)) fi done if [ "$CURRENT_STAGE_FREE_BYTES" -lt "$MAX_STAGE_REQUIRED_BYTES" ]; then blocking_stage_pkg='unknown' if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && [ "$REUSE_EXISTING_PASSWALL_STACK" != '1' ]; then for pkg in $REQUIRED_MANAGED_PACKAGES; do if ! managed_package_at_target "$pkg"; then candidate_bytes=$(( $(managed_package_field "$pkg" download_size_bytes) + STAGE_MARGIN_BYTES )) if [ "$CURRENT_STAGE_FREE_BYTES" -lt "$candidate_bytes" ]; then blocking_stage_pkg="$pkg" break fi fi done fi if [ "$blocking_stage_pkg" = 'unknown' ] && [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then for pkg in $REQUIRED_RUNTIME_PACKAGES; do if runtime_package_needs_install "$pkg"; then candidate_bytes=$(( $(feed_package_download_size_bytes "$pkg") + STAGE_MARGIN_BYTES )) if [ "$CURRENT_STAGE_FREE_BYTES" -lt "$candidate_bytes" ]; then blocking_stage_pkg="$pkg" break fi fi done fi if [ "$blocking_stage_pkg" = 'unknown' ]; then for pkg in $CONTROLLER_PACKAGES; do if ! controller_package_at_target "$pkg"; then candidate_bytes=$(( $(vectra_package_download_size_bytes "$pkg") + STAGE_MARGIN_BYTES )) if [ "$CURRENT_STAGE_FREE_BYTES" -lt "$candidate_bytes" ]; then blocking_stage_pkg="$pkg" break fi fi done fi log "Недостаточно staging-space в $WORKDIR: доступно ${CURRENT_STAGE_FREE_BYTES} B, требуется не меньше ${MAX_STAGE_REQUIRED_BYTES} B. Блокирующий пакет: $blocking_stage_pkg." log "Режим bootstrap: $BOOTSTRAP_CLASSIFICATION" log 'Bootstrap прерван до любых изменений.' exit 1 fi PASSWALL_APP_REMOVAL_REQUIRED='0' if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && [ "$REUSE_EXISTING_PASSWALL_STACK" != '1' ]; then for pkg in $REQUIRED_MANAGED_PACKAGES; do [ "$pkg" = 'luci-app-passwall2' ] && continue if managed_package_needs_replacement "$pkg"; then unexpected="$(list_unexpected_dependents "$pkg" | tr '\n' ' ' | awk '{$1=$1; print}')" if [ -n "$unexpected" ]; then log "Автоматический reclaim для $pkg запрещён: внешние зависимости -> $unexpected" log 'Bootstrap прерван до любых изменений.' exit 1 fi if package_depended_on_by_passwall_app "$pkg"; then PASSWALL_APP_REMOVAL_REQUIRED='1' fi fi done if package_installed luci-app-passwall2 && ! managed_package_at_target luci-app-passwall2; then PASSWALL_APP_REMOVAL_REQUIRED='1' fi fi SIMULATED_OVERLAY_FREE_BYTES="$CURRENT_OVERLAY_FREE_BYTES" if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && runtime_package_needs_install dnsmasq-full; then dnsmasq_target_bytes="$(feed_package_storage_budget_bytes dnsmasq-full)" dnsmasq_reclaim_bytes='0' if package_installed dnsmasq; then dnsmasq_reclaim_bytes="$(package_installed_size_bytes dnsmasq)" fi simulate_overlay_step dnsmasq-full "$dnsmasq_target_bytes" "$dnsmasq_reclaim_bytes" || true fi if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then for pkg in kmod-nft-socket kmod-nft-tproxy; do [ "$CONTROLLER_ONLY_BOOTSTRAP" = '1' ] && break if runtime_package_needs_install "$pkg"; then target_bytes="$(feed_package_storage_budget_bytes "$pkg")" simulate_overlay_step "$pkg" "$target_bytes" '0' || true fi done fi if [ "$CONTROLLER_ONLY_BOOTSTRAP" = '1' ]; then SIMULATED_OVERLAY_FREE_BYTES="$CURRENT_OVERLAY_FREE_BYTES" fi if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && [ "$PASSWALL_APP_REMOVAL_REQUIRED" = '1' ] && package_installed luci-app-passwall2; then SIMULATED_OVERLAY_FREE_BYTES=$((SIMULATED_OVERLAY_FREE_BYTES + $(package_installed_size_bytes luci-app-passwall2))) fi if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && [ "$REUSE_EXISTING_PASSWALL_STACK" != '1' ]; then for pkg in $REQUIRED_MANAGED_PACKAGES; do [ "$CONTROLLER_ONLY_BOOTSTRAP" = '1' ] && break [ "$pkg" = 'luci-app-passwall2' ] && continue if managed_package_at_target "$pkg"; then continue fi reclaim_bytes='0' if package_installed "$pkg"; then reclaim_bytes="$(package_installed_size_bytes "$pkg")" fi target_bytes="$(managed_package_field "$pkg" installed_size_bytes)" simulate_overlay_step "$pkg" "$target_bytes" "$reclaim_bytes" || true done if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ] && { [ "$PASSWALL_APP_REMOVAL_REQUIRED" = '1' ] || ! managed_package_at_target luci-app-passwall2; }; then target_bytes="$(managed_package_field luci-app-passwall2 installed_size_bytes)" simulate_overlay_step luci-app-passwall2 "$target_bytes" '0' || true fi fi for pkg in $CONTROLLER_PACKAGES; do if controller_package_at_target "$pkg"; then continue fi reclaim_bytes='0' if package_installed "$pkg"; then reclaim_bytes="$(package_installed_size_bytes "$pkg")" fi target_bytes="$(vectra_package_storage_budget_bytes "$pkg")" simulate_overlay_step "$pkg" "$target_bytes" "$reclaim_bytes" || true done log "Режим bootstrap: $BOOTSTRAP_CLASSIFICATION" log "Свободно в staging ($WORKDIR): ${CURRENT_STAGE_FREE_BYTES} B" log "Свободно на /overlay: ${CURRENT_OVERLAY_FREE_BYTES} B" log "Максимальный staging budget: ${MAX_STAGE_REQUIRED_BYTES} B" if [ "$CONTROLLER_ONLY_BOOTSTRAP" = '1' ]; then log 'Controller-only lane: стартовый PassWall2/Xray stack не помещается, тяжёлые пакеты будут обновлены позже из панели.' elif [ "$REUSE_EXISTING_PASSWALL_STACK" = '1' ]; then log 'Reuse lane: существующий PassWall2 уже выглядит рабочим, тяжёлый managed refresh будет пропущен.' fi if [ "$PASSWALL_APP_REMOVAL_REQUIRED" = '1' ]; then log 'Для успешного upgrade скрипт временно снимет luci-app-passwall2 и затем поставит его обратно последним шагом.' fi } log "Использую зеркальные пакеты PassWall2 $PASSWALL_RELEASE_TAG из $PASSWALL_MIRROR_URL" run_preflight_checks if [ "$CONTROLLER_ONLY_BOOTSTRAP" = '1' ]; then log 'Controller-only bootstrap: пропускаю dnsmasq-full и PassWall2 runtime-пакеты в установщике.' else log 'Подготавливаю dnsmasq-full для PassWall2...' install_dnsmasq_full fi install_required_openwrt_packages() { for pkg in "$@"; do if package_installed "$pkg"; then log "[$pkg] уже установлен; пропускаю." continue fi log "[$pkg] устанавливаю из OpenWrt feed..." run_opkg install "$pkg" || { log "Не удалось установить обязательный OpenWrt пакет $pkg" exit 1 } done } if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then log 'Устанавливаю обязательные OpenWrt runtime-пакеты для PassWall2...' install_required_openwrt_packages kmod-nft-socket kmod-nft-tproxy fi download_and_install_managed_ipk() { pkg="$1" url="$(managed_package_field "$pkg" url)" destination="$WORKDIR/$pkg.ipk" download_file "$url" "$destination" run_opkg install "$destination" || { rm -f "$destination" log "Не удалось установить $pkg из $url" exit 1 } rm -f "$destination" } install_missing_managed_ipk() { pkg="$1" managed_package_at_target "$pkg" && return 0 package_installed "$pkg" && return 0 log "[$pkg] отсутствует, доустанавливаю $(managed_package_field "$pkg" version) из зеркала PassWall2..." download_and_install_managed_ipk "$pkg" } refresh_managed_package_package_based() { pkg="$1" target_version="$(managed_package_field "$pkg" version)" if managed_package_at_target "$pkg"; then log "[$pkg] версия $target_version уже установлена; пропускаю package-refresh." return 0 fi if package_installed "$pkg"; then log "[$pkg] сначала пробую in-place package install для $(package_version "$pkg") -> $target_version" destination="$WORKDIR/$pkg.ipk" download_file "$(managed_package_field "$pkg" url)" "$destination" if run_opkg install "$destination"; then rm -f "$destination" return 0 fi rm -f "$destination" fi if package_installed "$pkg"; then log "[$pkg] обновляю package-path: $(package_version "$pkg") -> $target_version" run_opkg remove "$pkg" || { log "Не удалось снять старую версию $pkg перед package-refresh" exit 1 } else log "[$pkg] отсутствует, ставлю $target_version через package-path" fi download_and_install_managed_ipk "$pkg" } passwall_component_name_for_pkg() { case "$1" in xray-core) printf '%s\n' 'xray' ;; geoview) printf '%s\n' 'geoview' ;; sing-box) printf '%s\n' 'sing-box' ;; hysteria) printf '%s\n' 'hysteria' ;; *) return 1 ;; esac } passwall_component_updater_available() { [ -f /usr/lib/lua/luci/passwall2/api.lua ] || return 1 [ -f /usr/lib/lua/luci/passwall2/com.lua ] || return 1 command -v lua >/dev/null 2>&1 || return 1 command -v curl >/dev/null 2>&1 || return 1 return 0 } refresh_passwall_component_via_builtin_updater() { pkg="$1" component="$(passwall_component_name_for_pkg "$pkg" || true)" [ -n "$component" ] || return 1 passwall_component_updater_available || return 1 log "[$pkg] пытаюсь low-memory update через встроенный PassWall App Update ($component)..." lua - "$component" <<'LUA' local component = arg[1] local ok, api = pcall(require, 'luci.passwall2.api') if not ok or type(api) ~= 'table' then io.stderr:write('passwall api unavailable\n') os.exit(1) end local check = api.to_check('', component) if type(check) ~= 'table' or check.code ~= 0 then io.stderr:write((check and check.error) or 'component check failed') io.stderr:write('\n') os.exit(1) end if not check.has_update then os.exit(0) end local data = check.data or {} local download_size_kb = tonumber(data.size or 0) if download_size_kb and download_size_kb > 0 then download_size_kb = download_size_kb / 1024 else download_size_kb = nil end local download = api.to_download(component, data.browser_download_url, download_size_kb) if type(download) ~= 'table' or download.code ~= 0 then io.stderr:write((download and download.error) or 'component download failed') io.stderr:write('\n') os.exit(1) end local file = download.file if download.zip then local extracted = api.to_extract(component, file, data.subfix) if type(extracted) ~= 'table' or extracted.code ~= 0 then io.stderr:write((extracted and extracted.error) or 'component extract failed') io.stderr:write('\n') os.exit(1) end file = extracted.file end local moved = api.to_move(component, file) if type(moved) ~= 'table' or moved.code ~= 0 then io.stderr:write((moved and moved.error) or 'component move failed') io.stderr:write('\n') os.exit(1) end os.exit(0) LUA } package_upgrade_impossible_for_overlay() { pkg="$1" target_bytes="$(managed_package_field "$pkg" installed_size_bytes)" reclaim_bytes='0' if package_installed "$pkg"; then reclaim_bytes="$(package_installed_size_bytes "$pkg")" fi required_bytes=$((target_bytes - reclaim_bytes)) if [ "$required_bytes" -lt 0 ]; then required_bytes='0' fi available_bytes="$(get_free_bytes "$OVERLAY_PATH")" [ "$available_bytes" -lt "$required_bytes" ] } extract_xray_binary_from_ipk() { destination_ipk="$WORKDIR/xray-core-target.ipk" extract_root="$WORKDIR/xray-ipk-extract" rm -rf "$extract_root" "$destination_ipk" mkdir -p "$extract_root" download_file "$(managed_package_field xray-core url)" "$destination_ipk" gzip -dc "$destination_ipk" > "$extract_root/outer.tar" || return 1 tar -xf "$extract_root/outer.tar" -C "$extract_root" ./data.tar.gz || return 1 mkdir -p "$extract_root/data" tar -xzf "$extract_root/data.tar.gz" -C "$extract_root/data" ./usr/bin/xray || return 1 [ -f "$extract_root/data/usr/bin/xray" ] || return 1 printf '%s\n' "$extract_root/data/usr/bin/xray" } refresh_xray_binary_from_ipk_payload() { extracted_binary="$(extract_xray_binary_from_ipk)" || { log '[xray-core] не удалось извлечь runtime binary из target IPK payload.' return 1 } if ! [ -f "$extracted_binary" ]; then log '[xray-core] extracted runtime binary не найден после распаковки target IPK.' return 1 fi if ! "$extracted_binary" version >/dev/null 2>&1; then log '[xray-core] extracted runtime binary не проходит валидацию version command.' return 1 fi new_binary_size="$(wc -c < "$extracted_binary" | tr -d ' ')" old_binary_size='0' if [ -f /usr/bin/xray ]; then old_binary_size="$(wc -c < /usr/bin/xray | tr -d ' ')" fi available_bytes="$(get_free_bytes "$OVERLAY_PATH")" required_bytes=$((new_binary_size - old_binary_size)) if [ "$required_bytes" -lt 0 ]; then required_bytes='0' fi if [ "$available_bytes" -lt "$required_bytes" ]; then log '[xray-core] даже binary-only refresh не помещается в overlay.' return 1 fi if [ -x /etc/init.d/passwall2 ]; then /etc/init.d/passwall2 stop >/dev/null 2>&1 || true fi cp "$extracted_binary" /usr/bin/xray || return 1 chmod 0755 /usr/bin/xray || true if [ -x /etc/init.d/passwall2 ]; then /etc/init.d/passwall2 restart >/dev/null 2>&1 || /etc/init.d/passwall2 start >/dev/null 2>&1 || true fi log '[xray-core] runtime binary обновлён напрямую из target IPK payload.' return 0 } refresh_reuse_lane_heavy_component() { pkg="$1" if ! managed_package_needs_replacement "$pkg"; then return 0 fi if refresh_passwall_component_via_builtin_updater "$pkg"; then log "[$pkg] успешно обновлён через встроенный PassWall App Update." return 0 fi if package_upgrade_impossible_for_overlay "$pkg"; then if [ "$pkg" = 'xray-core' ] && refresh_xray_binary_from_ipk_payload; then return 0 fi log "[$pkg] package-refresh физически не помещается в overlay; сохраняю low-storage runtime-convergence как допустимый результат." return 0 fi log "[$pkg] встроенный updater не довёл компонент до результата; переключаюсь на package-refresh fallback." refresh_managed_package_package_based "$pkg" } refresh_reuse_lane_passwall_state() { if managed_package_needs_replacement luci-app-passwall2; then refresh_managed_package_package_based luci-app-passwall2 fi for pkg in xray-core geoview sing-box hysteria; do if managed_package_needs_replacement "$pkg"; then refresh_reuse_lane_heavy_component "$pkg" fi done } refresh_bootstrap_xray_runtime_via_builtin_updater() { if ! package_installed xray-core; then log '[xray-core] пакет не установлен; runtime update через App Update пропущен.' return 0 fi if ! passwall_component_updater_available; then log '[xray-core] встроенный PassWall App Update недоступен; оставляю package-версию как fallback.' return 0 fi if refresh_passwall_component_via_builtin_updater xray-core; then log '[xray-core] runtime доведён через встроенный PassWall App Update.' return 0 fi log '[xray-core] встроенный PassWall App Update не довёл runtime до новой версии; bootstrap продолжается с package-версией.' return 0 } count_preserved_subscriptions() { [ -f /etc/config/passwall2 ] || { printf '0\n' return 0 } uci show passwall2 2>/dev/null | awk -F'[.=]' '/=subscribe_list$/{count++} END {print count + 0}' } refresh_existing_subscriptions() { subscribe_count="$(count_preserved_subscriptions)" if [ "$subscribe_count" -eq 0 ]; then log 'Подписок в текущем PassWall2 не найдено; refresh подписок не нужен.' return 0 fi if [ ! -f /usr/share/passwall2/subscribe.lua ]; then log 'subscribe.lua не найден; refresh подписок пропущен.' return 0 fi log 'Пробую обновить текущие подписки перед автопривязкой shunt...' lua /usr/share/passwall2/subscribe.lua start all >/dev/null 2>&1 || log 'Автообновление подписок не удалось; продолжу с уже импортированными нодами.' } append_preserved_passwall_sections() { source="$1" destination="$2" [ -f "$source" ] || return 0 awk -v allowed_types='nodes subscribe_list' -f - "$source" >> "$destination" <<'AWK' function is_allowed(type, list, idx, count) { count = split(allowed_types, list, " ") for (idx = 1; idx <= count; idx++) { if (list[idx] == type) { return 1 } } return 0 } function flush_block() { if (capture && block != "") { printf "\n%s", block } block = "" } /^config[ \t]+/ { flush_block() block = $0 "\n" section_type = $2 section_name = "" if (match($0, /'[^']+'/)) { section_name = substr($0, RSTART + 1, RLENGTH - 2) } capture = is_allowed(section_type) && !(section_type == "nodes" && section_name == "myshunt") next } { if (block != "") { block = block $0 "\n" } } END { flush_block() } AWK } apply_passwall_baseline() { mkdir -p "$WORKDIR/uci" wget -qO "$WORKDIR/uci/passwall2" "$BASELINE_URL" cp "$WORKDIR/uci/passwall2" "$WORKDIR/uci/passwall2.pristine" if ! uci -c "$WORKDIR/uci" show passwall2 >/dev/null 2>"$WORKDIR/baseline-validate.stderr"; then log 'Не удалось проверить baseline PassWall2 перед применением в /etc/config/passwall2' [ -s "$WORKDIR/baseline-validate.stderr" ] && cat "$WORKDIR/baseline-validate.stderr" exit 1 fi if [ "$BOOTSTRAP_CLASSIFICATION" = 'repair broken PassWall config' ]; then log 'Текущий passwall2 config повреждён: пытаюсь salvage подписки и ноды из raw backup перед baseline-apply.' if [ -f "$BACKUP_DIR/passwall2" ]; then append_preserved_passwall_sections "$BACKUP_DIR/passwall2" "$WORKDIR/uci/passwall2" if ! uci -c "$WORKDIR/uci" show passwall2 >/dev/null 2>"$WORKDIR/broken-passwall-salvage.stderr"; then log 'Raw salvage из повреждённого passwall2 не прошёл валидацию; продолжаю с чистым baseline.' cp "$WORKDIR/uci/passwall2.pristine" "$WORKDIR/uci/passwall2" else log 'Raw salvage из повреждённого passwall2 успешно добавил recoverable подписки и ноды.' fi else log 'Backup повреждённого passwall2 не найден; продолжаю с чистым baseline.' fi elif [ -f "$BACKUP_DIR/passwall2" ]; then log 'Сохраняю существующие подписки и ноды PassWall2 из текущего конфига...' append_preserved_passwall_sections "$BACKUP_DIR/passwall2" "$WORKDIR/uci/passwall2" if ! uci -c "$WORKDIR/uci" show passwall2 >/dev/null 2>"$WORKDIR/preserved-passwall-validate.stderr"; then log 'Не удалось объединить baseline PassWall2 с существующими подписками и нодами' [ -s "$WORKDIR/preserved-passwall-validate.stderr" ] && cat "$WORKDIR/preserved-passwall-validate.stderr" exit 1 fi fi cp "$WORKDIR/uci/passwall2" /etc/config/passwall2 } node_exists() { node_id="$1" [ -n "$node_id" ] || return 1 uci -q get "passwall2.$node_id.protocol" >/dev/null 2>&1 } normalize_remark() { printf '%s\n' "$1" | awk '{$1=$1; print}' } count_nodes_by_remark() { wanted="$1" wanted_normalized="$(normalize_remark "$wanted")" count='0' for id in $(uci show passwall2 2>/dev/null | awk -F'[.=]' '/=nodes$/{print $2}'); do [ "$id" = 'myshunt' ] && continue protocol="$(uci -q get "passwall2.$id.protocol" || true)" [ "$protocol" = '_shunt' ] && continue remark="$(uci -q get "passwall2.$id.remarks" || true)" remark_normalized="$(normalize_remark "$remark")" if [ "$remark_normalized" = "$wanted_normalized" ]; then count=$((count + 1)) fi done printf '%s\n' "$count" } find_unique_node_by_remark() { wanted="$1" wanted_normalized="$(normalize_remark "$wanted")" node_id='' match_count='0' for id in $(uci show passwall2 2>/dev/null | awk -F'[.=]' '/=nodes$/{print $2}'); do [ "$id" = 'myshunt' ] && continue protocol="$(uci -q get "passwall2.$id.protocol" || true)" [ "$protocol" = '_shunt' ] && continue remark="$(uci -q get "passwall2.$id.remarks" || true)" remark_normalized="$(normalize_remark "$remark")" if [ "$remark_normalized" = "$wanted_normalized" ]; then node_id="$id" match_count=$((match_count + 1)) fi done if [ "$match_count" -eq 1 ]; then printf '%s\n' "$node_id" return 0 fi return 1 } rebind_myshunt_from_remarks() { SHUNT_ID='myshunt' SHUNT_REBIND_COMPLETE='1' changes_made='0' if [ "$(uci -q get "passwall2.$SHUNT_ID.protocol" || true)" != '_shunt' ]; then log 'Shunt-узел myshunt не найден после применения baseline; автопривязка пропущена.' SHUNT_REBIND_COMPLETE='0' return 0 fi apply_shunt_slot() { slot="$1" wanted="$2" node_id="$(find_unique_node_by_remark "$wanted" || true)" if [ -n "$node_id" ]; then uci set "passwall2.$SHUNT_ID.$slot=$node_id" changes_made='1' log "[$slot] автопривязка по remark: $wanted -> $node_id" return 0 fi match_count="$(count_nodes_by_remark "$wanted")" SHUNT_REBIND_COMPLETE='0' if [ "$match_count" -gt 1 ]; then log "[$slot] найдено несколько нод с remark: $wanted. Слот оставлен без изменений." else log "[$slot] remark '$wanted' пока не найден. Слот оставлен без изменений." fi } apply_shunt_slot 'WorldProxy' '🇩🇪⚡Германия YouTube 🚫Ad🚫' apply_shunt_slot 'YouTube' '🇷🇺⚡Россия YouTube 🚫Ad🚫' apply_shunt_slot 'Special' '🇫🇮 ⚡⚡ Финляндия Xhttp Gaming' apply_shunt_slot 'Tiktok' '🇧🇾 Беларусь' apply_shunt_slot 'default_node' '🇩🇪⚡Германия YouTube 🚫Ad🚫' if [ "$changes_made" = '1' ]; then uci commit passwall2 fi } refresh_passwall_managed_stack() { if [ "$PASSWALL_APP_REMOVAL_REQUIRED" = '1' ] && package_installed luci-app-passwall2; then log 'Освобождаю место: временно снимаю luci-app-passwall2 перед обновлением PassWall stack...' if [ -x /etc/init.d/passwall2 ]; then /etc/init.d/passwall2 stop >/dev/null 2>&1 || true fi run_opkg remove luci-app-passwall2 || { log 'Не удалось временно снять luci-app-passwall2 перед reclaim-upgrade' exit 1 } fi for pkg in $REQUIRED_MANAGED_PACKAGES; do [ "$pkg" = 'luci-app-passwall2' ] && continue if managed_package_at_target "$pkg"; then log "[$pkg] версия $(managed_package_field "$pkg" version) уже установлена; пропускаю." continue fi if package_installed "$pkg"; then log "[$pkg] снимаю $(package_version "$pkg") перед установкой $(managed_package_field "$pkg" version)..." run_opkg remove "$pkg" || { log "Не удалось снять старую версию $pkg" exit 1 } fi log "[$pkg] устанавливаю $(managed_package_field "$pkg" version) из зеркала PassWall2..." download_and_install_managed_ipk "$pkg" done if [ "$PASSWALL_APP_REMOVAL_REQUIRED" = '1' ] || ! managed_package_at_target luci-app-passwall2; then if package_installed luci-app-passwall2; then run_opkg remove luci-app-passwall2 || { log 'Не удалось снять старую версию luci-app-passwall2' exit 1 } fi log "[luci-app-passwall2] устанавливаю $(managed_package_field luci-app-passwall2 version) последним шагом..." download_and_install_managed_ipk luci-app-passwall2 else log '[luci-app-passwall2] версия уже актуальна; пропускаю.' fi } install_controller_packages() { for pkg in $CONTROLLER_PACKAGES; do target_version="$(vectra_package_version "$pkg")" if controller_package_at_target "$pkg"; then log "[$pkg] версия $target_version уже установлена; пропускаю." continue fi if [ "$pkg" = 'vectra-controller-agent' ] && [ -x /etc/init.d/vectra-controller ]; then /etc/init.d/vectra-controller stop >/dev/null 2>&1 || true fi if package_installed "$pkg"; then log "[$pkg] снимаю $(package_version "$pkg") перед установкой $target_version..." run_opkg remove "$pkg" || { log "Не удалось снять старую версию $pkg" exit 1 } fi log "[$pkg] устанавливаю $target_version из подписанного feed Vectra до запуска PassWall2..." run_opkg install "$pkg" || { log "Не удалось установить пакет Vectra $pkg" exit 1 } done } passwall_bootstrap_ready_to_start() { selected_node="$(uci -q get passwall2.vectra_global.node || true)" case "$selected_node" in ''|'_direct'|'_default'|'_blackhole') log "PassWall2 start guard: выбранный узел '$selected_node' не является сервером; оставляю PassWall2 выключенным." return 1 ;; esac if ! node_exists "$selected_node"; then log "PassWall2 start guard: выбранный узел '$selected_node' не найден; оставляю PassWall2 выключенным." return 1 fi selected_protocol="$(uci -q get "passwall2.$selected_node.protocol" || true)" if [ "$selected_protocol" != '_shunt' ]; then return 0 fi default_node="$(uci -q get "passwall2.$selected_node.default_node" || true)" case "$default_node" in ''|'_direct'|'_default'|'_blackhole') log "PassWall2 start guard: shunt Default/default_node='$default_node' не привязан к серверу; оставляю PassWall2 выключенным." return 1 ;; esac if ! node_exists "$default_node"; then log "PassWall2 start guard: shunt Default/default_node '$default_node' не найден; оставляю PassWall2 выключенным." return 1 fi if [ "$(uci -q get "passwall2.$default_node.protocol" || true)" = '_shunt' ]; then log "PassWall2 start guard: shunt Default/default_node '$default_node' указывает на другой shunt; оставляю PassWall2 выключенным." return 1 fi return 0 } start_passwall_after_bootstrap() { if ! passwall_bootstrap_ready_to_start; then uci set passwall2.vectra_global.enabled='0' || true uci commit passwall2 || true /etc/init.d/passwall2 stop >/dev/null 2>&1 || true return 0 fi /etc/init.d/passwall2 enable >/dev/null 2>&1 || true lua /usr/share/passwall2/rule_update.lua log geoip,geosite || true /etc/init.d/passwall2 running >/dev/null 2>&1 && /etc/init.d/passwall2 restart || /etc/init.d/passwall2 start } if [ "$CONTROLLER_ONLY_BOOTSTRAP" != '1' ]; then log 'Устанавливаю и обновляю managed stack PassWall2 с учётом реального места на overlay...' if [ "$REUSE_EXISTING_PASSWALL_STACK" = '1' ]; then log 'Reuse lane: оставляю уже рабочий PassWall2-стек без тяжёлого package refresh.' for pkg in $REQUIRED_MANAGED_PACKAGES; do install_missing_managed_ipk "$pkg" done log 'Reuse lane: довожу PassWall app и тяжёлые компоненты до результата адаптивным update-path...' refresh_reuse_lane_passwall_state else refresh_passwall_managed_stack fi log 'Проверяю, нужен ли runtime-апдейт Xray через встроенный PassWall App Update...' refresh_bootstrap_xray_runtime_via_builtin_updater log 'Устанавливаю доступные дополнительные OpenWrt-пакеты...' install_optional_openwrt_packages kmod-nft-nat apply_passwall_baseline refresh_existing_subscriptions rebind_myshunt_from_remarks if [ "$BOOTSTRAP_CLASSIFICATION" != 'fresh install' ] && [ "$SHUNT_REBIND_COMPLETE" != '1' ]; then if node_exists "$PREVIOUS_SELECTED_NODE" && [ "$PREVIOUS_SELECTED_NODE" != 'myshunt' ]; then uci set passwall2.vectra_global.node="$PREVIOUS_SELECTED_NODE" uci commit passwall2 log "Shunt-автопривязка неполная; сохраняю прежний рабочий узел: $PREVIOUS_SELECTED_NODE" else log 'Shunt-автопривязка неполная и безопасный предыдущий узел не найден; оставляю baseline как есть.' fi fi log 'Устанавливаю пакеты контроллера Vectra до запуска PassWall2...' install_controller_packages start_passwall_after_bootstrap else log 'Устанавливаю пакеты контроллера Vectra без запуска PassWall2...' install_controller_packages log 'Controller-only bootstrap: пропускаю установку/настройку PassWall2, baseline, подписки и запуск сервиса.' log 'После первого check-in запусти обновление PassWall2/Xray из панели Vectra; контроллер выполнит это управляемой задачей.' fi uci batch <<'EOF' set vectra-controller.main.enabled='1' set vectra-controller.main.control_url='https://api.vectra-pro.net' set vectra-controller.main.panel_url='https://router.vectra-pro.net' set vectra-controller.main.poll_interval='45s' set vectra-controller.main.request_timeout='10s' set vectra-controller.main.state_path='/etc/vectra-controller/state.json' set vectra-controller.main.status_path='/var/run/vectra-controller/status.json' set vectra-controller.main.config_render_path='/var/run/vectra-controller/config.json' commit vectra-controller EOF /etc/init.d/vectra-controller enable >/dev/null 2>&1 || true /etc/init.d/vectra-controller restart log 'Готово.' if [ "$CONTROLLER_ONLY_BOOTSTRAP" = '1' ]; then log "1. Контроллер подключён к $CONTROL_URL" log '2. PassWall2/Xray не менялись во время установки из-за малого /overlay; обновление делаем позже управляемо из панели Vectra.' log '3. После первого check-in откройте веб-панель, примите импорт и запустите обновление PassWall2/Xray.' else log "1. Локальный baseline PassWall2 применён из $BASELINE_URL" log "2. Контроллер подключён к $CONTROL_URL" log '2.5. Подписки и imported nodes сохранены; shunt-слоты привязаны автоматически только при точном уникальном совпадении remark' log "3. После первого check-in откройте веб-панель и примите импорт как эталон" fi