#!/bin/sh
# === RIFT PANEL INSTALLER & UPDATER (v4.14) ===
# Install: sh <(wget -O - https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh)
#
# v4.14 changes (группировка серверов по IP):
#   - Узлы группируются по РЕАЛЬНОМУ IP сервера: 6in1/6in1g/6in1x -> одна строка «Индия»
#     с чипами протоколов tcp/grpc/hy2 (xhttp серый — на штатном ядре не работает).
#   - Имя строки берётся из ЭМОДЗИ-флага (🇩🇪->Германия) + тег AI/Games + счётчик.
#   - Дефолт-подключение TCP; клик по чипу = смена транспорта; активный обведён зелёным.
#   - Кнопка «Доступность протоколов»: TCP-connect к серверу -> чипы зелёные/красные.
#   - Кнопка «Переподключение» на строке активного сервера, если его конфиг сменился.
#   - Резолв IP через nslookup 1.1.1.1 (мимо fakeip), кэш в nodes.lua, только при смене подписки.
#   - Подробные диагностические логи по всему пути (resolve/group/apply/avail).
#
# v4.13 changes (критичные фиксы клиентов):
#   - НЕ ставим/апгрейдим curl (opkg ломал его: curl новее libcurl -> "symbol not found"),
#     podkop не мог качать github-списки -> у клиентов резалось Telegram/Discord. + авто-починка
#     битого curl (opkg install libcurl4).
#   - DNS: миграция dns_type=doh -> udp (TSPU режет DoH: домены/github не резолвятся).
#   - update_subs авто-переключает несовместимый XHTTP-узел (после отката ядра) на рабочий.
#   - ретраи fetch (до 3x) против TSPU SSL-EOF.
#   - подписка обновляется раз в час; убран лишний флеш-коммит podkop_subs.
# v4.10 changes:
#   - Откат ядра extended -> штатный sing-box (extended ломал routed-трафик)
#   - XHTTP-узлы скрыты (работают только на extended)
#   - DNS-блок в панели: тип (udp/dot/doh) + сервер + bootstrap; пуш дефолтов из скрипта
#   - «Полный VPN» по MAC-адресу (watcher переставляет IP при переподключении)
#   - Системные настройки: конфигурируемый URL обновления (по умолчанию router.rift.monster)

PANEL_VERSION="4.28"
REMOTE_SCRIPT_URL="https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh"

# v4.22: защита от ПАРАЛЛЕЛЬНЫХ запусков (дубли cron / гонка при записи rpc → битый
# файл → "Permission denied" из-за шебанга-огрызка). Второй запуск тихо выходит.
if command -v flock >/dev/null 2>&1; then
  exec 9>/tmp/rift_panel_install.lock
  flock -n 9 || { echo "RIFT: установка уже выполняется — выход"; exit 0; }
fi

# === MENU: detect existing installation ===
if [ -f /etc/podkop_data/version ]; then
  INSTALLED_VER="$(cat /etc/podkop_data/version 2>/dev/null)"
  echo "================================================="
  echo " RIFT Panel v${INSTALLED_VER} обнаружена"
  echo "================================================="
  echo " 1) Обновить панель до v${PANEL_VERSION}"
  echo " 2) Полностью удалить панель"
  echo " 3) Выход"
  echo "================================================="
  printf "Выберите действие [1/2/3]: "
  read -r CHOICE
  case "$CHOICE" in
    2)
      echo "=== УДАЛЕНИЕ RIFT PANEL ==="
      echo "[1/7] Остановка веб-сервера..."
      uci -q delete uhttpd.podkop_panel
      uci commit uhttpd >/dev/null 2>&1
      echo "[2/8] Удаление cron задач..."
      (crontab -l 2>/dev/null | grep -Fv "autoupdate_sub" | grep -Fv "autoupdate_panel" | grep -Fv "/etc/podkop_data/") | crontab -
      echo "[3/8] Остановка MAC-VPN watcher (v4.7+)..."
      [ -x /etc/init.d/rift-mac-vpn-watcher ] && {
        /etc/init.d/rift-mac-vpn-watcher stop >/dev/null 2>&1
        /etc/init.d/rift-mac-vpn-watcher disable >/dev/null 2>&1
      }
      rm -f /etc/init.d/rift-mac-vpn-watcher /usr/local/sbin/rift-mac-vpn-watcher
      [ -x /etc/init.d/rift-diag ] && {
        /etc/init.d/rift-diag stop >/dev/null 2>&1
        /etc/init.d/rift-diag disable >/dev/null 2>&1
      }
      rm -f /etc/init.d/rift-diag /usr/local/sbin/rift-diag /tmp/rift_diag_buf.jsonl /tmp/rift_diag_once.*
      echo "[4/8] Удаление файлов панели..."
      rm -rf /www/podkop_panel
      echo "[5/8] Восстановление главной страницы..."
      if [ -f /etc/podkop_data/openwrt_index_backup.html ]; then
        cp /etc/podkop_data/openwrt_index_backup.html /www/index.html
      fi
      echo "[6/8] Удаление данных..."
      rm -rf /etc/podkop_data
      echo "[7/8] Удаление конфигурации..."
      rm -f /etc/config/podkop_subs
      uci -q delete dhcp.rift_panel_domain
      uci commit dhcp >/dev/null 2>&1
      echo "[8/8] Очистка temp..."
      rm -f /tmp/podkop_sub*.body /tmp/podkop_sub*.err /tmp/rift_*.sh /tmp/rift_*.err
      # Рестарты сети (uhttpd/dnsmasq) откладываем в фон на 3с: при запуске через
      # `sh <(wget ...)` скрипт стримится по pipe, и рестарт dnsmasq/uhttpd ЗДЕСЬ
      # рвёт ещё не докачанный хвост wget → "stalled / Connection timed out".
      # Отложенный фон даёт wget докачать и sh дочитать pipe до конца.
      # dnsmasq: reload (мягко, без обрыва DNS-соединений) вместо restart.
      ( sleep 3
        /etc/init.d/uhttpd restart >/dev/null 2>&1
        /etc/init.d/dnsmasq reload  >/dev/null 2>&1
      ) >/dev/null 2>&1 &
      echo "================================================="
      echo "RIFT Panel полностью удалена."
      echo "Podkop остался нетронутым."
      echo "================================================="
      exit 0
      ;;
    3)
      echo "Выход."
      exit 0
      ;;
    *)
      echo "Обновление до v${PANEL_VERSION}..."
      ;;
  esac
fi

echo "=== УСТАНОВКА RIFT PANEL v${PANEL_VERSION} ==="

get_singbox_version() {
  /usr/bin/sing-box version 2>/dev/null | head -n 1
}


# === Подробный лог установки (для диагностики) ===
mkdir -p /etc/podkop_data 2>/dev/null
INSTALL_LOG=/etc/podkop_data/install.log
# install.log на ФЛЕШЕ (append) рос бы при каждом апгрейде. Обрезаем до последних
# ~16 КБ перед новой записью — сохраняем историю пары апгрейдов, но не копим на флеше.
if [ -f "$INSTALL_LOG" ] && [ "$(wc -c < "$INSTALL_LOG" 2>/dev/null || echo 0)" -gt 32768 ]; then
  tail -c 16384 "$INSTALL_LOG" > "$INSTALL_LOG.tmp" 2>/dev/null && mv -f "$INSTALL_LOG.tmp" "$INSTALL_LOG" 2>/dev/null
fi
logi(){ echo "$*"; echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$INSTALL_LOG" 2>/dev/null; }
{
  echo "================================================================"
  echo " RIFT Panel install/upgrade v${PANEL_VERSION}  @ $(date '+%Y-%m-%d %H:%M:%S')"
  echo " OpenWrt: $(grep -E 'DISTRIB_(RELEASE|ARCH)' /etc/openwrt_release 2>/dev/null | tr '\n' ' ')"
  echo " uname:   $(uname -m) / $(uname -r)"
  echo " model:   $(cat /tmp/sysinfo/model 2>/dev/null)"
  echo " sing-box(before): $(/usr/bin/sing-box version 2>/dev/null | head -1)"
  if [ -s /etc/podkop_data/hwid ]; then echo " режим: ОБНОВЛЕНИЕ (hwid сохраняется)"; else echo " режим: ЧИСТАЯ УСТАНОВКА"; fi
  echo "================================================================"
} >> "$INSTALL_LOG" 2>/dev/null

# 1) deps
logi "[1/10] Установка пакетов (opkg)..."
# v4.26: при обновлении все пакеты уже стоят — opkg не трогаем вовсе (раньше opkg update
# каждый раз ходил на downloads.openwrt.org и при плохой связи «вешал» установку на шаге 1).
_rift_opkg_update(){ if command -v timeout >/dev/null 2>&1; then timeout 180 opkg update >>"$INSTALL_LOG" 2>&1 || logi "  -> opkg update не завершился за 180 с (продолжаю)"; else opkg update >>"$INSTALL_LOG" 2>&1; fi; }
_rift_need=""
for _p in ca-bundle coreutils-base64 lua uclient-fetch; do opkg list-installed 2>/dev/null | grep -q "^$_p " || _rift_need="$_rift_need $_p"; done
[ -z "$_rift_need" ] && logi "  -> пакеты уже стоят — opkg update пропущен"
[ -n "$_rift_need" ] && { logi "  -> нужно поставить:$_rift_need"; _rift_opkg_update; }
# ВАЖНО: curl НЕ ставим/апгрейдим. opkg ставит curl новее, чем установленный libcurl
# (напр. curl 8.19 vs libcurl 8.12) → ABI-облом "symbol not found" → curl не запускается,
# а podkop им проверяет/качает community-списки с github → списки не грузятся, у клиента
# режется Telegram/Discord (DC по IP). curl уже есть как зависимость podkop.
[ -n "$_rift_need" ] && { opkg install $_rift_need >>"$INSTALL_LOG" 2>&1 || true; }
# Если curl уже сломан прошлой версией панели — чиним апгрейдом libcurl4 под текущий curl.
if command -v curl >/dev/null 2>&1 && ! curl --version >/dev/null 2>&1; then
  logi "  -> curl сломан (libcurl рассинхрон) — апгрейд libcurl4..."
  [ -z "$_rift_need" ] && _rift_opkg_update
  opkg install libcurl4 >>"$INSTALL_LOG" 2>&1
  if curl --version >/dev/null 2>&1; then logi "  -> curl починен: $(curl --version 2>/dev/null | head -1)"; else logi "  -> ВНИМАНИЕ: curl всё ещё сломан"; fi
fi
logi "  -> lua=$(command -v lua || echo НЕТ) uclient-fetch=$(command -v uclient-fetch || echo НЕТ) curl=$(curl --version >/dev/null 2>&1 && echo OK || echo СЛОМАН/НЕТ)"

# 2) structure
logi "[2/10] Настройка системы..."
mkdir -p /www/podkop_panel/cgi-bin
mkdir -p /etc/podkop_data
touch /etc/config/podkop_subs
if [ ! -s /etc/config/podkop_subs ]; then
  echo "config podkop_subs 'config'" > /etc/config/podkop_subs
fi
echo "${PANEL_VERSION}" > /etc/podkop_data/version

# v4.8: источник обновлений панели (правится в «Системных настройках»).
# Ставим дефолт только если не задан — юзерский выбор переживает апгрейды.
if [ -z "$(uci -q get podkop_subs.config.update_url)" ]; then
  uci set podkop_subs.config.update_url='https://router.rift.monster/rift.sh'
  uci commit podkop_subs
  logi "  -> update_url по умолчанию: https://router.rift.monster/rift.sh"
fi

# Generate HWID from multiple hardware sources (tamper-resistant)
generate_hwid() {
  local _mac _board _model _cpuinfo _mtd_factory _dmi _salt _raw
  _mac=$(cat /sys/class/net/br-lan/address 2>/dev/null || cat /sys/class/net/eth0/address 2>/dev/null || echo "00:00:00:00:00:00")
  _board=$(cat /tmp/sysinfo/board_name 2>/dev/null || echo "generic")
  _model=$(cat /tmp/sysinfo/model 2>/dev/null || echo "router")
  _cpuinfo=$(grep -E "^(Serial|Hardware|machine|system type)" /proc/cpuinfo 2>/dev/null | head -4)
  # Factory calibration data from MTD — contains burned-in MAC, unique per device, survives MAC spoofing
  _mtd_factory=""
  if [ -e /dev/mtd2ro ]; then
    _mtd_factory=$(dd if=/dev/mtd2ro bs=1 count=64 skip=4 2>/dev/null | md5sum 2>/dev/null | cut -c1-32)
  elif [ -e /dev/mtd0ro ]; then
    _mtd_factory=$(dd if=/dev/mtd0ro bs=1 count=64 skip=4 2>/dev/null | md5sum 2>/dev/null | cut -c1-32)
  fi
  # DMI serial for x86 routers
  _dmi=$(cat /sys/class/dmi/id/product_serial 2>/dev/null || echo "")
  _salt="RiFT-hW1d-s4Lt-v2"
  _raw="${_salt}|${_mac}|${_board}|${_model}|${_cpuinfo}|${_mtd_factory}|${_dmi}"
  if command -v sha256sum >/dev/null 2>&1; then
    printf '%s' "$_raw" | sha256sum | cut -c1-16
  else
    printf '%s' "$_raw" | md5sum | cut -c1-16
  fi
}
# Сохраняем существующий HWID при апгрейде: device в Remnawave = (hwid,user),
# смена hwid = новое устройство → превышение лимита у клиента. Генерим только на чистой установке.
if [ -s /etc/podkop_data/hwid ]; then
  HWID=$(cat /etc/podkop_data/hwid)
  logi "  -> HWID сохранён при апгрейде: ${HWID} (устройство в Remnawave не меняется)"
else
  HWID=$(generate_hwid)
  echo "$HWID" > /etc/podkop_data/hwid
  logi "  -> HWID сгенерирован (чистая установка): ${HWID}"
fi

# 3) uhttpd
logi "[3/10] Настройка веб-сервера (порт 2017)..."
uci -q delete uhttpd.podkop_panel
uci set uhttpd.podkop_panel=uhttpd
uci add_list uhttpd.podkop_panel.listen_http='0.0.0.0:2017'
uci set uhttpd.podkop_panel.home='/www/podkop_panel'
uci set uhttpd.podkop_panel.rfc1918_filter='0'
uci set uhttpd.podkop_panel.max_requests='10'
uci set uhttpd.podkop_panel.cgi_prefix='/cgi-bin'
uci commit uhttpd >/dev/null 2>&1

# 4) remove rift domain
logi "[4/10] Очистка DNS..."
for s in $(uci show dhcp 2>/dev/null | sed -n "s/^\(dhcp\.@domain\[[0-9]\+\]\)=domain.*/\1/p"); do
  [ "$(uci -q get ${s}.name)" = "rift" ] && uci delete "$s"
done
uci -q delete dhcp.rift_panel_domain
uci -q del_list dhcp.@dnsmasq[0].rebind_domain='rift'
uci commit dhcp >/dev/null 2>&1

# 4.5) DNS-дефолты podkop (оператор задаёт здесь; чинит старые podkop без 77.88.8.8).
#   RIFT_DNS_FORCE=0 -> проставляем только если ключ пуст/отсутствует; =1 -> всегда перезаписать.
#   RIFT_DNS_MIGRATE_DOH=1 -> если сейчас dns_type=doh (в РФ TSPU режет DoH: домены/github не
#     резолвятся, у клиента отваливается Telegram/Discord) — принудительно перевести на udp,
#     НЕ трогая тех, кто уже на udp/dot. Это массовый фикс v4.12.
RIFT_DNS_TYPE="udp"
RIFT_DNS_SERVER="77.88.8.8"
RIFT_DNS_BOOTSTRAP="77.88.8.8"
RIFT_DNS_FORCE="0"
RIFT_DNS_MIGRATE_DOH="1"
logi "[4.5/10] DNS-дефолты podkop (force=${RIFT_DNS_FORCE}, migrate_doh=${RIFT_DNS_MIGRATE_DOH})..."
if uci -q get podkop.settings >/dev/null 2>&1; then
  _dns_force="$RIFT_DNS_FORCE"
  if [ "$RIFT_DNS_MIGRATE_DOH" = "1" ] && [ "$(uci -q get podkop.settings.dns_type)" = "doh" ]; then
    _dns_force="1"
    logi "  -> текущий DNS=doh (режется TSPU) — принудительно перевожу на udp-дефолты"
  fi
  _dns_def(){ [ -n "$2" ] || return 0; local c; c="$(uci -q get podkop.settings.$1)"; if [ "$_dns_force" = "1" ] || [ -z "$c" ]; then uci set podkop.settings.$1="$2"; logi "  -> podkop.settings.$1=$2 (было: '${c:-пусто}')"; fi; }
  _dns_def dns_type "$RIFT_DNS_TYPE"
  _dns_def dns_server "$RIFT_DNS_SERVER"
  _dns_def bootstrap_dns_server "$RIFT_DNS_BOOTSTRAP"
  uci commit podkop
else
  logi "  -> секция podkop.settings не найдена (podkop не установлен?) — пропуск"
fi

# 4.6) Списки DNS для выпадающих меню панели (перезаписываются при каждом обновлении —
#   так оператор «пушит» свои DNS с апгрейдом). Формат строки: "value|Подпись".
#   Верх списка — наши/рекомендуемые, ниже — публичные. Оператор правит здесь.
cat > /etc/podkop_data/dns_servers.list <<'DEOF'
77.88.8.8|Yandex (77.88.8.8)
77.88.8.7|Yandex Family (77.88.8.7)
8.8.8.8|Google (8.8.8.8)
1.1.1.1|Cloudflare (1.1.1.1)
9.9.9.9|Quad9 (9.9.9.9)
dns.adguard-dns.com|AdGuard (dns.adguard-dns.com)
DEOF
cat > /etc/podkop_data/dns_bootstraps.list <<'DEOF'
77.88.8.8|Yandex (77.88.8.8)
8.8.8.8|Google (8.8.8.8)
1.1.1.1|Cloudflare (1.1.1.1)
9.9.9.9|Quad9 (9.9.9.9)
DEOF
logi "  -> списки DNS для панели обновлены (dns_servers.list / dns_bootstraps.list)"

# 4.7) GitHub-блокировка в РФ: podkop не может напрямую качать .srs-списки маршрутизации
# с github (raw.githubusercontent). Включаем ШТАТНУЮ опцию podkop — качать списки ЧЕРЕЗ
# VPN-туннель (github достаётся с выходной ноды, где не заблокирован).
# Проверено на podkop 0.7.21: "GitHub is unavailable -> ✅ passed (via proxy) -> Lists update completed".
# Секция 'main' = основная proxy-секция podkop (у всех наших клиентов). Идемпотентно.
if uci -q get podkop.settings >/dev/null 2>&1; then
  _dlp_now="$(uci -q get podkop.settings.download_lists_via_proxy)"
  uci set podkop.settings.download_lists_via_proxy='1'
  uci set podkop.settings.download_lists_via_proxy_section='main'
  uci commit podkop
  logi "[4.7/10] Списки маршрутизации через туннель: download_lists_via_proxy=1 section=main (было: '${_dlp_now:-пусто}') — обход github-блокировки"
else
  logi "[4.7/10] podkop.settings не найдена — пропуск download_lists_via_proxy"
fi

# 4.8) v4.20: ТОРРЕНТ МИМО VPN. На нодах tBlocker банит IP клиента (60 мин, все
# соединения дропаются через conntrack -> у клиента "падает хост + Discord") за тег
# TORRENT, который ставится по обращению к домену трекера (rutracker.org и т.п.).
# Штатная секция podkop типа 'exclusion' уводит эти домены в direct-out (мимо VPN):
# direct-out резолвит их через реальный DNS (route.default_domain_resolver), fakeip не мешает.
# Идемпотентно (секция rift_torrent). v4.23: УМНЫЙ откат — секция локальная (домены,
# github НЕ трогает) и сама sing-box не роняет. Откатываем ТОЛЬКО если доказано, что
# виновата именно она (сняли секцию → sing-box поднялся). Если sing-box лежит по ДРУГОЙ
# причине (github-.srs/узел) — секцию ОСТАВЛЯЕМ, применится при восстановлении туннеля.
# (Прежняя логика ложно удаляла секцию, когда sing-box падал из-за community_lists.)
RIFT_TORR_DOMAINS="rutracker.org rutracker.net rutracker.cc rutracker.nl rutor.org rutor.info rutor.is new-rutor.org nnmclub.to nnm-club.me nnm-club.ws nnmclub.ro kinozal.tv kinozal.me kinozal.guru rustorka.com rustorka.net unionpeer.org 1337x.to thepiratebay.org torrent.by tracker.opentrackr.org tracker.openbittorrent.com router.bittorrent.com router.utorrent.com dht.transmissionbt.com tracker.torrent.eu.org open.stealth.si"
_rift_apply_torr(){
  uci -q set podkop.rift_torrent=section
  uci -q set podkop.rift_torrent.connection_type='exclusion'
  uci -q set podkop.rift_torrent.user_domain_list_type='dynamic'
  uci -q delete podkop.rift_torrent.user_domains 2>/dev/null
  for _d in $RIFT_TORR_DOMAINS; do uci -q add_list podkop.rift_torrent.user_domains="$_d"; done
  uci commit podkop
}
_rift_sb_up(){ _i=0; while [ "$_i" -lt 8 ]; do sleep 1; pgrep -f sing-box >/dev/null 2>&1 && return 0; _i=$((_i+1)); done; return 1; }
# v4.26: rift.surf (кинотеатр RIFT TV, открывается только через VPN) — в «Сайты через VPN»
# ОДИН раз (метка /etc/podkop_data/rift_surf_added): если пользователь потом удалит домен,
# при следующих обновлениях он не вернётся. Режим списка text (свой текст) не трогаем.
if uci -q get podkop.main >/dev/null 2>&1 && [ ! -f /etc/podkop_data/rift_surf_added ]; then
  _udt="$(uci -q get podkop.main.user_domain_list_type)"
  if [ -n "$_udt" ] && [ "$_udt" != "dynamic" ]; then
    logi "[4.8/10] rift.surf: список доменов в режиме '$_udt' (настроен пользователем) — не трогаю"
  else
    case " $(uci -q get podkop.main.user_domains) " in
      *" rift.surf "*) logi "[4.8/10] rift.surf уже в списке" ;;
      *) uci -q set podkop.main.user_domain_list_type='dynamic'
         uci -q add_list podkop.main.user_domains='rift.surf'
         uci commit podkop
         logi "[4.8/10] rift.surf (RIFT TV) добавлен в «Сайты через VPN»" ;;
    esac
  fi
  : > /etc/podkop_data/rift_surf_added
fi
if uci -q get podkop.main >/dev/null 2>&1 && command -v podkop >/dev/null 2>&1; then
  _rift_apply_torr
  /etc/init.d/podkop reload >/dev/null 2>&1
  # v4.24: БЕЗ отката. Секция ЛОКАЛЬНАЯ (домены, github не тянет) → sing-box уронить не
  # может; откат ей не нужен и ВРЕДЕН (на медленных роутерах, где sing-box стартует >8с,
  # давал ЛОЖНОЕ удаление + лишние reload/обрывы). Проверка ниже — ТОЛЬКО для лога,
  # секцию не трогаем ни при каком исходе (она безопасна и должна остаться у всех).
  if _rift_sb_up; then
    logi "[4.8/10] Торрент мимо VPN: секция exclusion rift_torrent применена (sing-box жив)"
  else
    logi "[4.8/10] Торрент-секция записана; sing-box сейчас не поднят (причина НЕ в ней — github/узел) — применится при восстановлении туннеля"
  fi
else
  logi "[4.8/10] podkop.main/podkop нет — пропуск торрент-исключения"
fi

# 5) v4.8: extended sing-box ОТКАЧЕН на штатный.
# Причина: extended 1.13.x у части юзеров ломал routed-трафик (Telegram и др.).
# Штатный sing-box тянет TCP/gRPC/HY2 через outbound_json (проверено на роутере).
# XHTTP на штатном НЕ работает — такие узлы панель скрывает (см. Backend/Frontend).
logi "[5/10] sing-box: возврат на штатный (extended отключён)..."
SB_VER_NOW="$(get_singbox_version)"
case "$SB_VER_NOW" in
  *extended*)
    logi "  -> обнаружен extended ($SB_VER_NOW) — откатываю на штатный..."
    /etc/init.d/podkop stop >/dev/null 2>&1
    BK="/etc/podkop_data/sing-box.stock.bak"
    BK_VER=""
    [ -s "$BK" ] && BK_VER="$("$BK" version 2>/dev/null | head -1)"
    case "$BK_VER" in
      "")
        logi "  -> бэкапа нет/битый — переустановка штатного через opkg..."
        opkg update >>"$INSTALL_LOG" 2>&1
        opkg install --force-reinstall sing-box >>"$INSTALL_LOG" 2>&1
        ;;
      *extended*)
        logi "  -> бэкап тоже extended — переустановка через opkg..."
        opkg update >>"$INSTALL_LOG" 2>&1
        opkg install --force-reinstall sing-box >>"$INSTALL_LOG" 2>&1
        ;;
      *)
        cp -f "$BK" /usr/bin/sing-box && chmod +x /usr/bin/sing-box
        logi "  -> восстановлен штатный из бэкапа ($BK_VER)"
        ;;
    esac
    /etc/init.d/podkop start >/dev/null 2>&1
    logi "  -> после отката: $(get_singbox_version)"
    ;;
  "")
    logi "  -> sing-box не найден — ставлю штатный через opkg..."
    opkg update >>"$INSTALL_LOG" 2>&1
    opkg install sing-box >>"$INSTALL_LOG" 2>&1
    logi "  -> установлен: $(get_singbox_version)"
    ;;
  *)
    logi "  -> уже штатный ($SB_VER_NOW) — откат не нужен"
    ;;
esac

# 6) Backend (RPC)
logi "[6/10] Запись Backend (RPC панели)..."
cat <<'EOF' > /www/podkop_panel/cgi-bin/rpc.tmp
#!/usr/bin/lua

function trim(s) return (tostring(s or ""):gsub("^%s*(.-)%s*$", "%1")) end
function shq(s) s=tostring(s or "") return "'"..s:gsub("'", "'\\''").."'" end

local function is_array(tbl)
  if type(tbl) ~= "table" then return false end
  local n, max = 0, 0
  for k,_ in pairs(tbl) do
    if type(k) ~= "number" then return false end
    if k <= 0 or (k % 1) ~= 0 then return false end
    if k > max then max = k end
    n = n + 1
  end
  if n == 0 then return true end
  return max == n
end

function to_json(val)
  local t=type(val)
  if t=="table" then
    local parts={}
    if is_array(val) then
      for i=1,#val do parts[#parts+1]=to_json(val[i]) end
      return "["..table.concat(parts,",").."]"
    else
      for k,v in pairs(val) do parts[#parts+1]='"'..k..'":'..to_json(v) end
      return "{"..table.concat(parts,",").."}"
    end
  elseif t=="string" then
    val=val:gsub("\\","\\\\"):gsub('"','\\"'):gsub("\n","\\n"):gsub("\r","")
    return '"'..val..'"'
  elseif t=="number" or t=="boolean" then
    return tostring(val)
  else
    return "null"
  end
end

function serialize(val)
  local t=type(val)
  if t=="table" then
    local parts={}
    for k,v in pairs(val) do
      local key=(type(k)=="number") and "" or ('["'..k..'"]=')
      parts[#parts+1]=key..serialize(v)
    end
    return "{"..table.concat(parts,",").."}"
  elseif t=="string" then
    return string.format("%q", val)
  else
    return tostring(val)
  end
end

function exec_read(cmd)
  local h=io.popen(cmd)
  local r=h:read("*a")
  h:close()
  return r and trim(r) or ""
end
function exec_silent(cmd) return os.execute(cmd..">/dev/null 2>&1") end

-- Подробный лог работы панели (в RAM /tmp, без износа флеша). Виден через RPC get_logs.
-- РОТАЦИЯ: cron дёргает update_subs раз в час → лог рос бы бесконечно в tmpfs (RAM).
-- При превышении лимита обрезаем до последних строк. RAM роутера мало — не копим.
RIFT_PANEL_LOG = "/tmp/rift_panel.log"
local RIFT_LOG_MAX = 65536   -- 64 КБ потолок (примерно 700 строк истории — с запасом)
function dlog(msg)
  -- дешёвая проверка размера через io (без вызова shell)
  local sz = 0
  local sf = io.open(RIFT_PANEL_LOG, "r")
  if sf then sz = sf:seek("end") or 0; sf:close() end
  if sz > RIFT_LOG_MAX then
    -- оставляем последние ~32 КБ, отбрасываем начало (tail-обрезка без shell)
    local rf = io.open(RIFT_PANEL_LOG, "r")
    if rf then
      rf:seek("set", sz - 32768)
      local tail = rf:read("*a") or ""
      rf:close()
      -- срезаем до первого перевода строки (чтобы не рвать строку посередине)
      tail = tail:gsub("^[^\n]*\n", "")
      local wf = io.open(RIFT_PANEL_LOG, "w")
      if wf then wf:write(tail); wf:close() end
    end
  end
  local f = io.open(RIFT_PANEL_LOG, "a")
  if f then
    f:write(os.date("!%Y-%m-%dT%H:%M:%SZ").."  "..tostring(msg).."\n")
    f:close()
  end
end

function uci_get(c,s,o) return exec_read("uci -q get "..c.."."..s.."."..o) end
function uci_set(c,s,o,v)
  local safe=tostring(v or ""):gsub("'","'\\''")
  exec_silent("uci set "..c.."."..s.."."..o.."='"..safe.."'")
end

local function parse_ver(v)
  local t={}
  for n in tostring(v or ""):gmatch("(%d+)") do t[#t+1]=tonumber(n) end
  while #t<3 do t[#t+1]=0 end
  return t
end
local function cmp_ver(a,b)
  local A=parse_ver(a); local B=parse_ver(b)
  for i=1,3 do
    if A[i]>B[i] then return 1 end
    if A[i]<B[i] then return -1 end
  end
  return 0
end

local function cmd_exists(bin)
  local r = exec_silent("command -v "..bin)
  return (r==0) or (r==true)
end

local HAS_UCLIENT = cmd_exists("uclient-fetch")

local function generate_hwid()
  local mac = exec_read("cat /sys/class/net/br-lan/address 2>/dev/null || cat /sys/class/net/eth0/address 2>/dev/null || echo '00:00:00:00:00:00'")
  local board = exec_read("cat /tmp/sysinfo/board_name 2>/dev/null || echo 'generic'")
  local model = exec_read("cat /tmp/sysinfo/model 2>/dev/null || echo 'router'")
  local cpuinfo = exec_read("grep -E '^(Serial|Hardware|machine|system type)' /proc/cpuinfo 2>/dev/null | head -4")
  local mtd = ""
  local r2 = exec_silent("test -e /dev/mtd2ro")
  local r0 = exec_silent("test -e /dev/mtd0ro")
  if (r2==0) or (r2==true) then
    mtd = exec_read("dd if=/dev/mtd2ro bs=1 count=64 skip=4 2>/dev/null | md5sum 2>/dev/null | cut -c1-32")
  elseif (r0==0) or (r0==true) then
    mtd = exec_read("dd if=/dev/mtd0ro bs=1 count=64 skip=4 2>/dev/null | md5sum 2>/dev/null | cut -c1-32")
  end
  local dmi = exec_read("cat /sys/class/dmi/id/product_serial 2>/dev/null || echo ''")
  local salt = "RiFT-hW1d-s4Lt-v2"
  local raw = salt.."|"..mac.."|"..board.."|"..model.."|"..cpuinfo.."|"..mtd.."|"..dmi
  local hwid
  if cmd_exists("sha256sum") then
    hwid = exec_read("printf %s "..shq(raw).." | sha256sum | cut -c1-16")
  else
    hwid = exec_read("printf %s "..shq(raw).." | md5sum | cut -c1-16")
  end
  return hwid ~= "" and hwid or "unknown"
end

local _cached_hwid = nil
local function get_hwid()
  if _cached_hwid then return _cached_hwid end
  -- Сначала читаем сохранённый hwid: апгрейд не должен менять привязку устройства
  -- (device в Remnawave = hwid+user, новый hwid = превышение лимита у клиента).
  local rf = io.open("/etc/podkop_data/hwid","r")
  if rf then
    local v = rf:read("*l"); rf:close()
    if v and v ~= "" then _cached_hwid = v; return v end
  end
  _cached_hwid = generate_hwid()
  -- update file for cron script consistency
  local f = io.open("/etc/podkop_data/hwid","w")
  if f then f:write(_cached_hwid); f:close() end
  return _cached_hwid
end

local function get_device_model()
  local f = io.open("/tmp/sysinfo/model","r")
  if f then local m=trim(f:read("*a")); f:close(); return m end
  return "OpenWrt Router"
end

local function get_os_version()
  local v = exec_read("cat /etc/openwrt_release 2>/dev/null | grep DISTRIB_RELEASE | cut -d\"'\" -f2")
  if v == "" then v = exec_read("uname -r") end
  return v
end

-- v4.14: ПРЯМОЙ фетч СВОИХ доменов (sub/router.rift.monster) — мимо fakeip/туннеля.
-- Роутер резолвит наши .monster-домены в fakeip (198.18.x), а к fakeip у router-output
-- НЕТ маршрута -> uclient-fetch падает "Operation not permitted"; подписка/обновление не
-- качаются, если активная нода/туннель мертвы (death-спираль). Решение доказано на роутере:
-- резолвим реальный IP прямым upstream и тянем curl --resolve (SNI сохраняется, идёт напрямую).
local function host_of_url(u) return (tostring(u or ""):match("^https?://([^/:]+)") or "") end
local function direct_ip(host)
  if not host or host=="" then return "" end
  local pre = cmd_exists("timeout") and "timeout 3 " or ""
  local AWK="| awk '/^Address/{print $NF}' | grep -Eo '^[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+$' | grep -v '^127[.]' | grep -v '^198[.]18[.]' | tail -1"
  local ip=exec_read(pre.."nslookup "..shq(host).." 77.88.8.8 2>/dev/null "..AWK)
  if ip=="" then ip=exec_read(pre.."nslookup "..shq(host).." 8.8.8.8 2>/dev/null "..AWK) end
  return ip
end
local _has_curl_ok=nil
local function curl_ok()
  if _has_curl_ok==nil then _has_curl_ok = cmd_exists("curl") and (os.execute("curl --version >/dev/null 2>&1")==0) end
  return _has_curl_ok
end

local function fetch_to_file(url, out, err, extra_headers)
  exec_silent("rm -f "..out.." "..err)
  local ua = "v2rayNG/1.8.19"
  local hdr = ""       -- uclient-fetch/wget формат (--header=)
  local hdr_curl = ""  -- curl формат (-H)
  if extra_headers then
    for _,h in ipairs(extra_headers) do
      hdr = hdr .. " --header=" .. shq(h)
      hdr_curl = hdr_curl .. " -H " .. shq(h)
    end
  end
  local host = host_of_url(url)
  local rip = direct_ip(host)
  local cmd
  if rip~="" and curl_ok() then
    -- прямой фетч на реальный IP (мимо fakeip/туннеля) — обновление панели работает всегда
    cmd = "curl -s -m 25 -A "..shq(ua)..hdr_curl.." --resolve "..shq(host..":443:"..rip).." -o "..out.." "..shq(url).." 2>"..err
  elseif HAS_UCLIENT then
    cmd = "uclient-fetch -q -O "..out.." --header="..shq("User-Agent: "..ua)..hdr.." "..shq(url).." 2>"..err
  else
    cmd = "wget -q -T 25 -U "..shq(ua)..hdr.." -O "..out.." "..shq(url).." 2>"..err
  end
  -- v4.12: до 3 попыток — TSPU периодически рвёт TLS ("SSL connection EOF"),
  -- следующая попытка обычно проходит. Успех = rc==0 И файл непустой.
  local ok = false
  for attempt=1,3 do
    local rc = os.execute(cmd)
    ok = (rc==0) or (rc==true)
    if ok then
      local f=io.open(out,"r"); local sz=f and (f:seek("end") or 0) or 0; if f then f:close() end
      if sz>0 then break end
      ok=false
    end
    if attempt<3 then os.execute("sleep 1") end
  end
  return ok
end

-- Fetch and capture response headers (for subscription info)
local function fetch_with_headers(url, out, hdr_file, extra_headers)
  exec_silent("rm -f "..out.." "..hdr_file)
  local ua = "v2rayNG/1.8.19"
  local hdr = ""
  if extra_headers then
    for _,h in ipairs(extra_headers) do hdr = hdr .. " --header=" .. shq(h) end
  end
  -- wget -S writes headers to stderr
  local cmd = "wget -q -S -T 25 -U "..shq(ua)..hdr.." -O "..out.." "..shq(url).." 2>"..hdr_file
  local rc = os.execute(cmd)
  return (rc==0) or (rc==true)
end

local function smart_fetch(url, out, err)
  local hwid = get_hwid()
  local model = get_device_model()
  local headers = {
    "x-hwid: " .. hwid,
    "x-device-model: " .. model
  }
  local ok = fetch_to_file(url, out, err, headers)
  return ok
end

local function smart_fetch_with_headers(url, out, hdr_file)
  local hwid = get_hwid()
  local model = get_device_model()
  local headers = {
    "x-hwid: " .. hwid,
    "x-device-model: " .. model
  }
  local ok = fetch_with_headers(url, out, hdr_file, headers)
  return ok
end

-- Подписка для роутера в JSON: UA с префиксом "Happ" -> Remnawave отдаёт XRAY_JSON
-- (единственный формат со всеми транспортами, включая HY2 и XHTTP). HWID-заголовки —
-- полный набор, иначе Remnawave вернёт заглушки HWIDNotSupported вместо узлов.
local function fetch_subscription_json(url, out, err)
  exec_silent("rm -f "..out.." "..err)
  local ua = "Happ/4.14-RIFT"
  local hwid = get_hwid()
  local model = get_device_model()
  local osver = get_os_version()
  -- заголовки в двух форматах: uclient-fetch (--header=) и curl (-H)
  local hdr_uc =
      " --header="..shq("x-hwid: "..hwid)..
      " --header="..shq("x-device-os: OpenWRT")..
      " --header="..shq("x-ver-os: "..osver)..
      " --header="..shq("x-device-model: "..model)
  local hdr_curl =
      " -H "..shq("x-hwid: "..hwid)..
      " -H "..shq("x-device-os: OpenWRT")..
      " -H "..shq("x-ver-os: "..osver)..
      " -H "..shq("x-device-model: "..model)
  local host = host_of_url(url)
  local rip = direct_ip(host)
  local via, cmd = "uclient", nil
  if rip~="" and curl_ok() then
    via = "curl-direct "..rip
    cmd = "curl -s -m 25 -A "..shq(ua)..hdr_curl.." --resolve "..shq(host..":443:"..rip).." -D /tmp/podkop_sub.hdr -o "..out.." "..shq(url).." 2>"..err
  elseif HAS_UCLIENT then
    cmd = "uclient-fetch -q -O "..out.." --header="..shq("User-Agent: "..ua)..hdr_uc.." "..shq(url).." 2>"..err
  else
    cmd = "wget -q -T 25 -U "..shq(ua)..hdr_uc.." -O "..out.." "..shq(url).." 2>"..err
  end
  -- до 3 попыток против TSPU SSL-EOF. Успех = rc==0 И файл непустой.
  local rc, okrc = nil, false
  for attempt=1,3 do
    rc = os.execute(cmd)
    okrc = (rc==0) or (rc==true)
    if okrc then
      local f=io.open(out,"r"); local sz=f and (f:seek("end") or 0) or 0; if f then f:close() end
      if sz>0 then break end
      okrc=false
    end
    if attempt<3 then os.execute("sleep 1") end
  end
  dlog("fetch_sub rc="..tostring(rc).." ok="..tostring(okrc).." via="..via.." hwid="..hwid.." url="..url)
  return okrc
end

local function get_singbox_version()
  return exec_read("/usr/bin/sing-box version 2>/dev/null | head -n 1")
end

local function singbox_supports_xhttp()
  local cfg = [[{
  "log": { "disabled": true },
  "inbounds": [],
  "outbounds": [
    {
      "type": "vless",
      "tag": "test-out",
      "server": "example.com",
      "server_port": 443,
      "uuid": "11111111-1111-1111-1111-111111111111",
      "tls": {
        "enabled": true,
        "server_name": "example.com",
        "insecure": true,
        "alpn": ["h2", "http/1.1"]
      },
      "transport": {
        "type": "xhttp",
        "path": "/",
        "mode": "auto",
        "host": "example.com",
        "x_padding_bytes": "100-1000"
      }
    }
  ]
}]]
  local tmp = "/tmp/rift_xhttp_check.json"
  local f = io.open(tmp, "w")
  if not f then return false, "Не удалось создать временный конфиг" end
  f:write(cfg)
  f:close()
  local rc = os.execute("/usr/bin/sing-box check -c "..tmp.." >/tmp/rift_xhttp_check.log 2>&1")
  os.remove(tmp)
  local logs = exec_read("tail -n 20 /tmp/rift_xhttp_check.log 2>/dev/null")
  os.remove("/tmp/rift_xhttp_check.log")
  return (rc==0) or (rc==true), logs
end

-- Extract subscription info from saved headers
local function extract_sub_info(hdr_file)
  local info = {expire="", title="", interval="", traffic_used="", traffic_total="", traffic_label=""}
  local raw = exec_read("cat "..hdr_file.." 2>/dev/null")
  raw = raw:gsub("\r", "")  -- strip CR from curl output
  local headers = {}
  for line in raw:gmatch("[^\n]+") do
    local k, v = line:match("^([%w%-]+):%s*(.*)$")
    if k and v then headers[k:lower()] = trim(v) end
  end
  -- profile-title (base64 encoded)
  local pt = (headers["profile-title"] or ""):match("^base64:([A-Za-z0-9%+/=]+)")
  if pt then
    local decoded = exec_read("printf %s "..shq(pt).." | base64 -d 2>/dev/null")
    info.title = decoded or ""
    -- extract expire like "29D,22H" or time info
    local expire_match = decoded:match("(%d+[DdДд][%s,]*%d*[HhЧч]*)")
    if expire_match then info.expire = expire_match end
  end
  -- subscription-userinfo header
  local sui = headers["subscription-userinfo"] or ""
  if sui then
    local exp_ts = sui:match("expire=(%d+)")
    if exp_ts and tonumber(exp_ts) and tonumber(exp_ts) > 0 then
      info.expire_ts = tonumber(exp_ts)
      local diff = tonumber(exp_ts) - os.time()
      if diff > 0 then
        local days = math.floor(diff/86400)
        local hours = math.floor((diff%86400)/3600)
        info.expire = days.."д "..hours.."ч"
      else
        info.expire = "Expired"
      end
    elseif exp_ts == "0" then
      info.expire = "No expiry"
    end
    local ul = tonumber(sui:match("upload=(%d+)") or "0") or 0
    local dl = sui:match("download=(%d+)")
    local total = sui:match("total=(%d+)")
    if dl then
      local used_gb = math.floor(((tonumber(dl) or 0) + ul)/1073741824*100)/100
      info.traffic_used = tostring(used_gb)
      if total then
        local total_num = tonumber(total) or 0
        if total_num > 0 then
          local total_gb = math.floor(total_num/1073741824*100)/100
          info.traffic_total = tostring(total_gb)
          info.traffic_label = info.traffic_used.." / "..info.traffic_total.." GB"
        else
          info.traffic_total = "unlimited"
          info.traffic_label = info.traffic_used.." / unlimited"
        end
      else
        info.traffic_label = info.traffic_used.." GB"
      end
    end
  end
  local pi = headers["profile-update-interval"]
  if pi then info.interval = pi end
  local web = headers["profile-web-page-url"]
  if web then info.web_page_url = web end
  return info
end

local qs=os.getenv("QUERY_STRING") or ""
local params={}
for k,v in string.gmatch(qs,"([^&=]+)=([^&=]*)") do
  params[k]=v:gsub("%%(%x%x)",function(h)return string.char(tonumber(h,16))end)
end
local method=params.method

print("Content-type: application/json; charset=utf-8\n")

-- v4.8: URL обновления панели настраивается (uci podkop_subs.config.update_url)
-- и правится через «Системные настройки» — если домен умрёт, юзер впишет другой.
local DEFAULT_UPDATE_URL="https://router.rift.monster/rift.sh"
-- Версия парсера/конвертера. Инсталлятор подставляет сюда PANEL_VERSION.
-- Если версия сменилась — update_subs ПЕРЕсоберёт nodes.lua, даже если хэш
-- подписки прежний (иначе изменения парсера, напр. скрытие XHTTP, не применятся
-- к уже сохранённому списку).
local PARSER_VER="__RIFT_PARSER_VER__"
local function get_update_url()
  local u = trim(uci_get("podkop_subs","config","update_url"))
  if u == "" then u = DEFAULT_UPDATE_URL end
  return u
end
local DNS_PROTECT_TYPE="doh"
local DNS_PROTECT_SERVER="8.8.8.8"
local DNS_PROTECT_BOOTSTRAP="9.9.9.9"
local DNS_FALLBACK_TYPE="udp"
local DNS_FALLBACK_SERVER="8.8.8.8"
local DNS_FALLBACK_BOOTSTRAP="77.88.8.1"

local function url_decode(s)
  if not s then return "" end
  local out = s:gsub("%%(%x%x)",function(h)return string.char(tonumber(h,16))end)
  return out
end

local function url_get_param(url, param)
  local val = url:match("[?&]" .. param .. "=([^&#]*)")
  if val then return url_decode(val) end
  return ""
end

local function normalize_proxy_stream(text)
  local out = tostring(text or "")
  out = out:gsub("\r", "\n")
  out = out:gsub("vmess://", "__RIFT_VMESS__")
  out = out:gsub("vless://", "__RIFT_VLESS__")
  out = out:gsub("trojan://", "__RIFT_TROJAN__")
  out = out:gsub("hysteria2://", "__RIFT_HYSTERIA2__")
  out = out:gsub("hy2://", "__RIFT_HY2__")
  out = out:gsub("tuic://", "__RIFT_TUIC__")
  out = out:gsub("ss://", "\nss://")
  out = out:gsub("__RIFT_VMESS__", "\nvmess://")
  out = out:gsub("__RIFT_VLESS__", "\nvless://")
  out = out:gsub("__RIFT_TROJAN__", "\ntrojan://")
  out = out:gsub("__RIFT_HYSTERIA2__", "\nhysteria2://")
  out = out:gsub("__RIFT_HY2__", "\nhy2://")
  out = out:gsub("__RIFT_TUIC__", "\ntuic://")
  out = out:gsub("^\n+", "")
  return out
end

local function parse_links_from_text(text)
  local out={}
  text = normalize_proxy_stream(text)
  if not text or text == "" then return out end
  for line in text:gmatch("[^\n\r]+") do
    line = trim(line)
    if line:match("^[a-z]+://") then
      out[#out+1] = line
    end
  end
  return out
end

local function put_if(tbl, key, value)
  if value ~= nil and value ~= "" then
    tbl[key] = value
  end
end

-- Decorative separator detector: узел является "вертикальным разделителем"
-- если его NAME состоит только из: U+3164 (ㅤ Hangul Filler),
-- U+2800 (⠀ Braille blank), U+00A0 (NBSP), обычных пробелов, табов.
-- Такие записи в подписке Remnawave используются как визуальные разделители
-- кластеров. Их хост = фейковый (например c3:443 или "Кластер 3:443").
-- Они НЕ должны быть кликабельными — рендерим как тонкую линию.
local function is_decorative_separator(name)
  if not name or name == "" then return false end
  -- Каждый ㅤ (U+3164) в UTF-8 = 3 байта E3 85 A4
  -- Каждый ⠀ (U+2800) = 3 байта E2 A0 80
  -- NBSP (U+00A0) = 2 байта C2 A0
  -- Strip известных whitespace-нашествий и проверяем что остался пустой стринг
  local stripped = name
    :gsub("\227\133\164", "")   -- U+3164 (Lua 5.1: только \ddd, не \xHH)
    :gsub("\226\160\128", "")   -- U+2800 Braille blank
    :gsub("\194\160", "")        -- NBSP
    :gsub("%s", "")              -- ASCII whitespace (space, tab, etc)
  return stripped == ""
end

local function link_to_node(line)
  local proto=(line:match("^(%w+)://") or "LINK"):upper()

  -- Extract name: everything after the FIRST # (greedy to end)
  local ne=line:match("#(.+)$")
  local name="Server"
  if ne then name=url_decode(ne) end

  -- Early-out: если name — декоративный разделитель, возвращаем узел-маркер
  -- который UI рендерит как <hr>. Без хоста, без транспорта, без кнопки.
  if is_decorative_separator(name) then
    return {
      name = name,
      is_separator = true,
      full_url = line
    }
  end

  local host=line:match("@(.-):") or line:match("://([^/:#%?]+)") or "unknown"

  local transport = url_get_param(line, "type")
  if transport == "" then transport = "tcp" end
  transport = transport:lower()

  local security = url_get_param(line, "security")
  local ti = proto
  if security == "reality" then ti = "Reality" end

  local transport_label = transport:upper()
  if transport == "grpc" then transport_label = "gRPC"
  elseif transport == "xhttp" or transport == "splithttp" then
    transport_label = "XHTTP"
  elseif transport == "ws" then transport_label = "WS"
  end

  local service_name = url_get_param(line, "serviceName")
  local path = url_get_param(line, "path")
  if path == "" then path = url_get_param(line, "bx") end
  if path == "" then path = url_get_param(line, "spx") end
  local mode = url_get_param(line, "mode")
  local flow = url_get_param(line, "flow")
  local extra = url_get_param(line, "extra")

  local node = {
    name = name,
    host = host,
    type = ti,
    transport = transport,
    full_url = line
  }
  put_if(node, "transport_label", transport_label ~= transport:upper() and transport_label or "")
  put_if(node, "security", security)
  put_if(node, "service_name", service_name)
  put_if(node, "path", path)
  put_if(node, "mode", mode)
  put_if(node, "flow", flow)
  put_if(node, "extra", extra)
  return node
end

local SKIP_NAME_PLAIN = string.char(0xD0,0xBE,0xD0,0xB1,0xD1,0x85,0xD0,0xBE,0xD0,0xB4,0x20,0xD0,0xB1,0xD1,0x81)
local PHONE_ICON = string.char(0xF0,0x9F,0x93,0xB1)
-- Маркер смарт-хоста «АВТО-СМАРТ» (balancer 3-в-1). На роутере smart-профиль бесполезен:
-- панель берёт первый inbound из XRAY_JSON-элемента (gRPC-заглушку), balancer не собирается.
-- Скрываем его из списка узлов визуально. ВАЖНО: в XRAY_JSON-подписке эмодзи 🔁 из remark
-- обрезается панелью — остаётся голое «АВТО-СМАРТ», поэтому фильтруем по КИРИЛЛИЧЕСКОМУ тексту,
-- а не по эмодзи. Lua 5.1 не поддерживает \xHH — задаём байтами UTF-8 через string.char.
-- "АВТО-СМАРТ" = D0 90 D0 92 D0 A2 D0 9E 2D D0 A1 D0 9C D0 90 D0 A0 D0 A2
local SMART_NAME = string.char(0xD0,0x90,0xD0,0x92,0xD0,0xA2,0xD0,0x9E,0x2D,0xD0,0xA1,0xD0,0x9C,0xD0,0x90,0xD0,0xA0,0xD0,0xA2)

-- Filter and dedup
local function should_skip(name)
  if not name then return false end
  local lower = tostring(name):lower():gsub("%s+", " ")
  -- Filter phone-only entries
  if lower:find(SKIP_NAME_PLAIN, 1, true) then return true end
  if tostring(name):find(PHONE_ICON, 1, true) then return true end
  -- Filter smart-host (АВТО-СМАРТ, balancer) — не показываем на роутере
  if tostring(name):find(SMART_NAME, 1, true) then return true end
  return false
end

local function get_url_key(url)
  -- strip fragment for dedup
  return (url:match("^([^#]+)") or url)
end

local function parse_nodes(text)
  local nodes={}
  local seen={}
  local links=parse_links_from_text(text or "")
  for _,u in ipairs(links) do
    local node = link_to_node(u)
    local key = get_url_key(u)
    if not seen[key] and not should_skip(node.name) then
      seen[key] = true
      nodes[#nodes+1] = node
    end
  end
  return nodes
end

-- =====================================================================
-- JSON-пайплайн v4.7: XRAY_JSON (формат Happ) -> sing-box outbound.
-- Парсер и конвертер протестированы оффлайн на реальной подписке +
-- живой трафик через sing-box-extended (TCP/gRPC/XHTTP/HY2). См. README.
-- =====================================================================

-- Мини JSON-декодер (без внешних зависимостей). Поддержка object/array/string/
-- number/bool/null, \uXXXX (вкл. суррогатные пары) и сырых UTF-8 байт в строках.
local function json_decode(s)
  if not s or s == "" then return nil, "empty" end
  local pos, len = 1, #s
  local decode_value
  local function err(m) error("json:"..m.."@"..pos) end
  local function skip_ws()
    while pos <= len do
      local c = s:byte(pos)
      if c==32 or c==9 or c==10 or c==13 then pos=pos+1 else break end
    end
  end
  local function decode_string()
    pos = pos + 1
    local buf = {}
    while pos <= len do
      local c = s:byte(pos)
      if c == 34 then pos = pos + 1; return table.concat(buf)
      elseif c == 92 then
        local n = s:byte(pos+1)
        if n == 117 then
          local cp = tonumber(s:sub(pos+2,pos+5),16) or 0
          pos = pos + 6
          if cp >= 0xD800 and cp <= 0xDBFF and s:sub(pos,pos+1)=="\\u" then
            local lo = tonumber(s:sub(pos+2,pos+5),16) or 0
            if lo >= 0xDC00 and lo <= 0xDFFF then
              cp = 0x10000 + (cp-0xD800)*0x400 + (lo-0xDC00); pos = pos + 6
            end
          end
          if cp < 0x80 then buf[#buf+1]=string.char(cp)
          elseif cp < 0x800 then buf[#buf+1]=string.char(0xC0+math.floor(cp/0x40),0x80+(cp%0x40))
          elseif cp < 0x10000 then buf[#buf+1]=string.char(0xE0+math.floor(cp/0x1000),0x80+(math.floor(cp/0x40)%0x40),0x80+(cp%0x40))
          else buf[#buf+1]=string.char(0xF0+math.floor(cp/0x40000),0x80+(math.floor(cp/0x1000)%0x40),0x80+(math.floor(cp/0x40)%0x40),0x80+(cp%0x40)) end
        else
          local map = {[34]='"',[92]='\\',[47]='/',[98]='\b',[102]='\f',[110]='\n',[114]='\r',[116]='\t'}
          buf[#buf+1] = map[n] or string.char(n or 63); pos = pos + 2
        end
      else buf[#buf+1] = string.char(c); pos = pos + 1 end
    end
    err("unterminated")
  end
  local function decode_number()
    local start = pos
    while pos <= len do
      local c = s:byte(pos)
      if (c>=48 and c<=57) or c==45 or c==43 or c==46 or c==101 or c==69 then pos=pos+1 else break end
    end
    return tonumber(s:sub(start, pos-1))
  end
  local function decode_object()
    pos = pos + 1; local obj = {}; skip_ws()
    if s:byte(pos) == 125 then pos=pos+1; return obj end
    while true do
      skip_ws()
      if s:byte(pos) ~= 34 then err("key") end
      local key = decode_string(); skip_ws()
      if s:byte(pos) ~= 58 then err("colon") end
      pos = pos + 1; obj[key] = decode_value(); skip_ws()
      local c = s:byte(pos)
      if c == 44 then pos=pos+1 elseif c == 125 then pos=pos+1; return obj else err("obj") end
    end
  end
  local function decode_array()
    pos = pos + 1; local arr = {}; skip_ws()
    if s:byte(pos) == 93 then pos=pos+1; return arr end
    while true do
      arr[#arr+1] = decode_value(); skip_ws()
      local c = s:byte(pos)
      if c == 44 then pos=pos+1 elseif c == 93 then pos=pos+1; return arr else err("arr") end
    end
  end
  decode_value = function()
    skip_ws()
    local c = s:byte(pos)
    if c == 123 then return decode_object()
    elseif c == 91 then return decode_array()
    elseif c == 34 then return decode_string()
    elseif c == 116 then pos=pos+4; return true
    elseif c == 102 then pos=pos+5; return false
    elseif c == 110 then pos=pos+4; return nil
    else return decode_number() end
  end
  local ok, result = pcall(decode_value)
  if not ok then return nil, result end
  return result
end

-- xray vless outbound -> базовая часть sing-box vless outbound
local function ob_vless_common(o)
  local v = o.settings.vnext[1]
  local u = v.users[1]
  local ss = o.streamSettings
  local rs = ss.realitySettings or {}
  local fp = rs.fingerprint
  if not fp or fp == "" then fp = "qq" end
  local ob = {
    type = "vless",
    server = v.address,
    server_port = v.port,
    uuid = u.id,
    tls = {
      enabled = true,
      server_name = rs.serverName or "",
      utls = { enabled = true, fingerprint = fp },
      reality = { enabled = true, public_key = rs.publicKey or "", short_id = rs.shortId or "" },
    },
  }
  return ob, ss, u
end

-- xray outbound -> sing-box outbound (таблица). nil если протокол не поддержан.
local function convert_outbound(o)
  local p = o.protocol
  local ss = o.streamSettings or {}
  local net = ss.network
  if p == "vless" and net == "tcp" then
    local ob, _, u = ob_vless_common(o)
    local flow = u.flow or ""
    if flow ~= "" then ob.flow = flow end
    return ob
  elseif p == "vless" and net == "grpc" then
    local ob = ob_vless_common(o)
    local g = ss.grpcSettings or {}
    ob.transport = { type="grpc", service_name = g.serviceName or "" }
    return ob
  elseif p == "vless" and (net == "xhttp" or net == "splithttp") then
    -- v4.8: XHTTP работает ТОЛЬКО на sing-box-extended, который мы откатили.
    -- На штатном ядре xhttp-outbound невалиден → узел пропускаем целиком
    -- (у каждого сервера в подписке всё равно есть tcp/grpc-варианты).
    return nil
  elseif p == "hysteria" then
    local s = o.settings or {}
    local hs = ss.hysteriaSettings or {}
    local ts = ss.tlsSettings or {}
    local ob = {
      type = "hysteria2",
      server = s.address,
      server_port = s.port,
      password = hs.auth or "",
      tls = { enabled = true, server_name = ts.serverName or "", alpn = ts.alpn or {"h3"} },
    }
    if ts.allowInsecure == true then ob.tls.insecure = true end
    -- v4.26: обфускация salamander (Xray 26: streamSettings.finalmask.udp[]). Без неё
    -- сервер с salamander молчит — в 4.24 HY2 на роутерах не подключался (таймаут).
    local fm = ss.finalmask
    if type(fm) == "table" and type(fm.udp) == "table" then
      for _, u in ipairs(fm.udp) do
        if type(u) == "table" and u.type == "salamander" and type(u.settings) == "table"
           and u.settings.password and u.settings.password ~= "" then
          ob.obfs = { type = "salamander", password = u.settings.password }
          break
        end
      end
    end
    return ob
  end
  return nil
end

local function transport_of(sb)
  if sb.type == "hysteria2" then return "hysteria2", "HY2" end
  local tt = sb.transport and sb.transport.type
  if tt == "grpc" then return "grpc", "gRPC" end
  if tt == "xhttp" then return "xhttp", "XHTTP" end
  return "tcp", "TCP"
end

-- =====================================================================
-- v4.14: ГРУППИРОВКА СЕРВЕРОВ ПО РЕАЛЬНОМУ IP.
-- 6in1/6in1g/6in1x -> один IP 107.181.139.68 -> одна строка «Индия» с
-- вариантами tcp/grpc/hy2 (+xhttp серый). Имя строки — из эмодзи-флага.
-- Протестировано офлайн на реальной подписке (lua5.1): 14 строк, все IP резолвятся.
-- =====================================================================

-- имя страны из эмодзи-флага (2 regional indicator: 🇩🇪 = F0 9F 87 A9 F0 9F 87 AA -> "DE")
local FLAG_NAMES={DE="Германия",FI="Финляндия",NL="Нидерланды",US="США",IN="Индия",RU="Россия",
  SE="Швеция",PL="Польша",GB="Британия",FR="Франция",TR="Турция",JP="Япония",SG="Сингапур",
  NO="Норвегия",CH="Швейцария",AT="Австрия",CA="Канада",GE="Грузия",AM="Армения",KZ="Казахстан",
  AE="ОАЭ",HK="Гонконг",ES="Испания",IT="Италия",LT="Литва",LV="Латвия",EE="Эстония",NZ="Н.Зеландия"}
local function flag_of(s)
  s=tostring(s or "")
  for i=1,#s-7 do
    if s:byte(i)==0xF0 and s:byte(i+1)==0x9F and s:byte(i+2)==0x87
       and s:byte(i+4)==0xF0 and s:byte(i+5)==0x9F and s:byte(i+6)==0x87 then
      local b1,b2=s:byte(i+3),s:byte(i+7)
      if b1>=0xA6 and b1<=0xBF and b2>=0xA6 and b2<=0xBF then
        return s:sub(i,i+7), string.char(b1-0xA6+65)..string.char(b2-0xA6+65)
      end
    end
  end
  return "",""
end
local function tag_of(s)
  s=tostring(s or "")
  if s:find("AI") then return " AI" end
  if s:find("Games") then return " Games" end
  -- "Резерв" (UTF-8 байты)
  if s:find(string.char(0xD0,0xA0,0xD0,0xB5,0xD0,0xB7,0xD0,0xB5,0xD1,0x80,0xD0,0xB2),1,true) then return " Резерв" end
  return ""
end
local function name_from_flag(s)
  local emoji,code=flag_of(s)
  if emoji=="" then return nil end          -- нет флага -> не сервер (мусор/разделитель)
  return emoji.." "..(FLAG_NAMES[code] or code)..tag_of(s)
end

-- host/port/net из сырого xray-outbound (для ВСЕХ транспортов, включая xhttp)
local function raw_endpoint(o)
  if o.protocol=="vless" then
    local v=(o.settings and o.settings.vnext and o.settings.vnext[1]) or {}
    local net=(o.streamSettings and o.streamSettings.network) or "tcp"
    if net=="splithttp" then net="xhttp" end
    return v.address, v.port, net
  elseif o.protocol=="hysteria" then
    local s=o.settings or {}
    return s.address, s.port, "hy2"
  end
  return nil
end

-- резолв host -> реальный IP (МИМО fakeip). Кэш на время процесса, timeout-guard.
local _ipcache={}
local _has_timeout=nil
local function resolve_ip(host)
  if not host or host=="" then return "" end
  if _ipcache[host]~=nil then return _ipcache[host] end
  if _has_timeout==nil then _has_timeout = cmd_exists("timeout") end
  local pre = _has_timeout and "timeout 3 " or ""
  -- [.] = литеральная точка в ERE (не требует экранирования, безопасно и в Lua и в shell)
  local AWK="| awk '/^Address/{print $NF}' | grep -Eo '^[0-9]+[.][0-9]+[.][0-9]+[.][0-9]+$' | grep -v '^127[.]' | tail -1"
  -- Локальный резолвер ПЕРВЫМ: он быстрый, а node-домены идут direct -> реальный IP.
  -- 1.1.1.1 — фолбэк, только если локальный пуст или вернул fakeip (198.18.x).
  -- (так не виснем на таймаутах, если ISP режет 1.1.1.1:53.)
  local ip=exec_read(pre.."nslookup "..shq(host).." 2>/dev/null "..AWK)
  if ip=="" or ip:match("^198%.18%.") then
    ip=exec_read(pre.."nslookup "..shq(host).." 1.1.1.1 2>/dev/null "..AWK)
  end
  if ip:match("^198%.18%.") then ip="" end   -- всё ещё fakeip -> нерезолвнуто (не группируем неверно)
  _ipcache[host]=ip
  return ip
end

-- XRAY_JSON -> список серверов, сгруппированных по IP, с вариантами-протоколами.
-- v4.26: РАЗБОР ПО ЗАПИСЯМ ПОДПИСКИ (вместо склейки по IP из 4.14).
-- Подписка 4.0: запись = логический сервер (свои -tcp/-grpc/-hy2/-xhttp + каскад -c*),
-- следом «└─ Запасной» = каскад этой страны, записи с 🎮 — игровые HY2.
-- Имя показываем ДОСЛОВНО из подписки: убираем только флаг в начале (рисуем картинкой),
-- невидимые U+200B и знак ветки «└─». Скрыто: «Автоподключение» (балансер по нескольким
-- серверам), всё после разделителя «👇 …» (обход БС) и записи с 📶. XHTTP не храним.
local ZWSP=string.char(0xE2,0x80,0x8B)
local TREE=string.char(0xE2,0x94,0x94,0xE2,0x94,0x80)                 -- └─
local GAME_MARK=string.char(0xF0,0x9F,0x8E,0xAE)                     -- 🎮
local BS_MARK=string.char(0xF0,0x9F,0x93,0xB6)                       -- 📶
local SEP_MARK=string.char(0xF0,0x9F,0x91,0x87)                      -- 👇
local AUTO_NAME=string.char(0xD0,0x90,0xD0,0xB2,0xD1,0x82,0xD0,0xBE,0xD0,0xBF,0xD0,0xBE,0xD0,0xB4,0xD0,0xBA,0xD0,0xBB,0xD1,0x8E,0xD1,0x87) -- «Автоподключ»
local TR_LABEL={tcp="TCP",grpc="gRPC",hy2="HY2"}
local TRS={tcp=true,grpc=true,hy2=true,xhttp=true}

local function clean_name(remark)
  local s=tostring(remark or ""):gsub(ZWSP,"")
  local emoji,code=flag_of(s)
  if emoji~="" and s:sub(1,#emoji)==emoji then s=s:sub(#emoji+1) else code="" end
  s=s:gsub("^%s+",""):gsub("%s+$","")
  local tree=false
  if s:sub(1,#TREE)==TREE then tree=true; s=s:sub(#TREE+1):gsub("^%s+","") end
  return s, code, tree
end

-- "7c3nl1-tcp" -> "7c3nl1", false ; "7c3nl1-ctcp" -> "7c3nl1", true ; "proxy" -> nil
local function split_tag(tag)
  local b,rest=tostring(tag or ""):match("^(.+)%-(%w+)$")
  if not b then return nil end
  if TRS[rest] then return b,false end
  if rest:sub(1,1)=="c" and TRS[rest:sub(2)] then return b,true end
  return nil
end

-- sing-box откажется стартовать с битым ключом — такой вариант не берём вовсе
local function sb_valid(sb)
  if sb.type=="vless" then
    if not sb.uuid or sb.uuid=="" then return false end
    local r=sb.tls and sb.tls.reality
    if r and r.enabled then
      local pk=r.public_key or ""
      if #pk~=43 or not pk:match("^[%w_%-]+$") then return false end
    end
  elseif sb.type=="hysteria2" then
    if not sb.password or sb.password=="" then return false end
  end
  return sb.server and sb.server~="" and tonumber(sb.server_port)~=nil
end

local TR_ORDER={"tcp","grpc","hy2"}
local function build_servers_from_xray(arr)
  local list, used = {}, {}
  local stats={servers=0,tcp=0,grpc=0,hy2=0,xhttp=0,sep=0,skip=0,noip=0}
  if type(arr)~="table" then return list, stats end
  local in_bs, last_main = false, nil
  for _,item in ipairs(arr) do
    local remark=tostring(item.remarks or "")
    local name,code,tree=clean_name(remark)
    if is_decorative_separator(remark) then
      stats.sep=stats.sep+1
    elseif in_bs or name:sub(1,#SEP_MARK)==SEP_MARK then
      in_bs=true; stats.skip=stats.skip+1
    elseif remark:find(BS_MARK,1,true) or remark:find(AUTO_NAME,1,true) or should_skip(remark) then
      stats.skip=stats.skip+1
    else
      local prim, casc, bases, nb = {}, {}, {}, 0
      for _,ob in ipairs(item.outbounds or {}) do
        local host,port,net = raw_endpoint(ob)
        if host and host~="" and net then
          local b,isc=split_tag(ob.tag)
          local r={ob=ob, host=host, port=port, net=net, base=b}
          if isc then casc[#casc+1]=r
          else prim[#prim+1]=r; if b and not bases[b] then bases[b]=true; nb=nb+1 end end
        end
      end
      if nb>1 then
        stats.skip=stats.skip+1                      -- балансер по нескольким серверам (Авто)
      else
        local use=(#prim>0) and prim or casc         -- у «Запасного» только каскадные
        local kind="main"
        if tree and last_main then kind="backup"
        elseif remark:find(GAME_MARK,1,true) then kind="game" end
        local S={name=(name~="" and name or remark), flag=code, kind=kind, variants={}, order={}}
        for _,r in ipairs(use) do
          if r.net=="xhttp" then stats.xhttp=stats.xhttp+1
          elseif not S.variants[r.net] then
            local sb=convert_outbound(r.ob)
            if sb and sb_valid(sb) then
              sb.tag="proxy"
              S.variants[r.net]={transport=r.net, label=TR_LABEL[r.net] or r.net:upper(), host=r.host, port=r.port, sb=sb}
              stats[r.net]=(stats[r.net] or 0)+1
            end
          end
        end
        for _,t in ipairs(TR_ORDER) do if S.variants[t] then S.order[#S.order+1]=t end end
        if #S.order==0 then
          stats.skip=stats.skip+1
        else
          local u=use[1]
          local k=(kind=="backup" and "c:" or kind=="game" and "g:" or "m:")
          k=k..(u.base or (u.host..":"..tostring(u.port)..":"..u.net))
          if used[k] then local n=2; while used[k.."#"..n] do n=n+1 end; k=k.."#"..n end
          used[k]=true; S.key=k
          if kind=="backup" then S.parent=last_main.key end
          if kind=="main" then last_main=S end
          list[#list+1]=S; stats.servers=stats.servers+1
        end
      end
    end
  end
  return list, stats
end

-- XRAY_JSON массив (от Happ) -> список узлов панели с готовым sing-box outbound.
-- Разделители (Hangul Filler / Braille / фейк-хосты) помечаются is_separator.
local function build_nodes_from_xray(arr)
  local nodes, seen = {}, {}
  local stats = { tcp=0, grpc=0, xhttp=0, hysteria2=0, sep=0, skip=0 }
  if type(arr) ~= "table" then return nodes, stats end
  for _,item in ipairs(arr) do
    local name = item.remarks or ""
    if is_decorative_separator(name) then
      stats.sep = stats.sep + 1
      nodes[#nodes+1] = { name = name, is_separator = true }
    elseif should_skip(name) then
      stats.skip = stats.skip + 1
    else
      local proxy
      for _,ob in ipairs(item.outbounds or {}) do
        if ob.protocol == "vless" or ob.protocol == "hysteria" then proxy = ob; break end
      end
      if proxy then
        local sb = convert_outbound(proxy)
        -- пропускаем мусорные/фейковые узлы без reality-ключа (это тоже разделители)
        local bad = sb and sb.tls and sb.tls.reality and sb.tls.reality.enabled
                    and (not sb.tls.reality.public_key or sb.tls.reality.public_key == "")
        if sb and not bad then
          sb.tag = "proxy"
          local tr, trl = transport_of(sb)
          local key = (sb.server or "")..":"..tostring(sb.server_port or "")..":"..tr
          if not seen[key] then
            seen[key] = true
            stats[tr] = (stats[tr] or 0) + 1
            nodes[#nodes+1] = {
              name = name,
              host = sb.server,
              port = sb.server_port,
              type = (sb.type == "hysteria2") and "HY2" or "Reality",
              transport = tr,
              transport_label = trl,
              security = (sb.tls and sb.tls.reality) and "reality" or "tls",
              flow = sb.flow,
              key = key,
              sb = sb,
            }
          end
        end
      end
    end
  end
  return nodes, stats
end

local function json_unescape(s)
  s = tostring(s or "")
  s = s:gsub("\\/", "/")
  s = s:gsub('\\"', '"')
  s = s:gsub("\\\\", "\\")
  return s
end

local function extract_extra_string(extra, key)
  extra = trim(extra or "")
  if extra == "" or extra == "null" then return "" end
  local val = extra:match('"'..key..'"%s*:%s*"(.-)"')
  if not val then return "" end
  return json_unescape(val)
end

local function extract_extra_number(extra, key)
  extra = trim(extra or "")
  if extra == "" or extra == "null" then return "" end
  return extra:match('"'..key..'"%s*:%s*(-?%d+)') or ""
end

local function extract_extra_boolean(extra, key)
  extra = trim(extra or "")
  if extra == "" or extra == "null" then return nil end
  local val = extra:match('"'..key..'"%s*:%s*(true|false)')
  if val == "true" then return true end
  if val == "false" then return false end
  return nil
end

local function split_csv(value)
  local out = {}
  for item in tostring(value or ""):gmatch("[^,]+") do
    item = trim(item)
    if item ~= "" then out[#out+1] = item end
  end
  return out
end

local function parse_vless_link(url)
  local clean = url:gsub("#.*$", "")
  local uuid = clean:match("^vless://([^@]+)@") or ""
  local host = clean:match("@([^:/#%?]+)") or clean:match("://([^/:#%?]+)") or ""
  local port = clean:match("@[^:/#%?]+:(%d+)") or clean:match("://[^/:#%?]+:(%d+)") or "443"
  return {
    uuid = url_decode(uuid),
    host = host,
    port = tonumber(port) or 443
  }
end

local function build_tls_settings_for_vless(url, transport_type)
  local security = url_get_param(url, "security")
  if security ~= "tls" and security ~= "reality" then return nil end

  local tls = { enabled = true }
  local sni = url_get_param(url, "sni")
  if sni ~= "" then tls.server_name = sni end

  local alpn = split_csv(url_get_param(url, "alpn"))
  if #alpn == 0 and transport_type == "xhttp" then
    alpn = { "h2", "http/1.1" }
  end
  if #alpn > 0 then tls.alpn = alpn end

  local fingerprint = url_get_param(url, "fp")
  if fingerprint ~= "" then
    tls.utls = {
      enabled = true,
      fingerprint = fingerprint
    }
  end

  if security == "reality" then
    local public_key = url_get_param(url, "pbk")
    if public_key ~= "" then
      tls.reality = {
        enabled = true,
        public_key = public_key,
        short_id = url_get_param(url, "sid")
      }
    end
  else
    local insecure = url_get_param(url, "allowInsecure")
    if insecure == "" then insecure = url_get_param(url, "insecure") end
    if insecure == "1" or insecure == "true" then tls.insecure = true end
  end

  return tls
end

local function build_xhttp_common_parts(url)
  local parsed = parse_vless_link(url)
  if parsed.uuid == "" or parsed.host == "" then
    return nil, nil, "Failed to parse XHTTP link"
  end

  local extra = url_get_param(url, "extra")
  local extra_path = extract_extra_string(extra, "path")
  local extra_host = extract_extra_string(extra, "host")
  local extra_mode = extract_extra_string(extra, "mode")

  local path = url_get_param(url, "path")
  if path == "" then path = url_get_param(url, "bx") end
  if path == "" then path = url_get_param(url, "spx") end
  if path == "" then path = extra_path end
  if path == "" then path = "/" end

  local host_header = url_get_param(url, "host")
  if host_header == "" then host_header = extra_host end
  if host_header == "" then host_header = url_get_param(url, "sni") end
  if host_header == "" then host_header = parsed.host end

  local mode = url_get_param(url, "mode")
  if mode == "" then mode = extra_mode end
  if mode == "" then mode = "auto" end

  local outbound = {
    type = "vless",
    server = parsed.host,
    server_port = parsed.port,
    uuid = parsed.uuid
  }

  local flow = url_get_param(url, "flow")
  if flow ~= "" then outbound.flow = flow end

  local packet_encoding = url_get_param(url, "packetEncoding")
  if packet_encoding ~= "" then outbound.packet_encoding = packet_encoding end

  return outbound, {
    extra = extra,
    path = path,
    host_header = host_header,
    mode = mode
  }
end

local function build_xhttp_raw_outbound(url)
  local outbound, ctx, err = build_xhttp_common_parts(url)
  if not outbound then return nil, err end

  outbound.tls = build_tls_settings_for_vless(url, "xhttp")

  local transport = {
    type = "xhttp",
    path = ctx.path,
    mode = ctx.mode,
    x_padding_bytes = url_get_param(url, "x_padding_bytes")
  }
  if transport.x_padding_bytes == "" then transport.x_padding_bytes = "100-1000" end
  if ctx.host_header ~= "" then transport.host = ctx.host_header end

  local extra = ctx.extra
  if extra ~= "" and extra ~= "null" then
    local x_padding_bytes = extract_extra_string(extra, "xPaddingBytes")
    if x_padding_bytes == "" then x_padding_bytes = extract_extra_number(extra, "xPaddingBytes") end
    if x_padding_bytes ~= "" then transport.x_padding_bytes = tostring(x_padding_bytes) end

    local no_grpc_header = extract_extra_boolean(extra, "noGRPCHeader")
    if no_grpc_header ~= nil then transport.no_grpc_header = no_grpc_header end

    local no_sse_header = extract_extra_boolean(extra, "noSSEHeader")
    if no_sse_header ~= nil then transport.no_sse_header = no_sse_header end

    local sc_max_each_post_bytes = extract_extra_number(extra, "scMaxEachPostBytes")
    if sc_max_each_post_bytes ~= "" then transport.sc_max_each_post_bytes = tonumber(sc_max_each_post_bytes) end

    local sc_min_posts_interval_ms = extract_extra_number(extra, "scMinPostsIntervalMs")
    if sc_min_posts_interval_ms ~= "" then transport.sc_min_posts_interval_ms = tonumber(sc_min_posts_interval_ms) end

    local sc_stream_up_server_secs = extract_extra_string(extra, "scStreamUpServerSecs")
    if sc_stream_up_server_secs == "" then sc_stream_up_server_secs = extract_extra_number(extra, "scStreamUpServerSecs") end
    if sc_stream_up_server_secs ~= "" then transport.sc_stream_up_server_secs = tostring(sc_stream_up_server_secs) end

    local xmux_blob = extra:match('"xmux"%s*:%s*(%b{})')
    if xmux_blob and xmux_blob ~= "" then
      local xmux = {}
      local function xmux_set_string(src_key, dst_key)
        local val = extract_extra_string(xmux_blob, src_key)
        if val == "" then val = extract_extra_number(xmux_blob, src_key) end
        if val ~= "" then xmux[dst_key] = tostring(val) end
      end
      local function xmux_set_number(src_key, dst_key)
        local val = extract_extra_number(xmux_blob, src_key)
        if val ~= "" then xmux[dst_key] = tonumber(val) end
      end
      xmux_set_string("maxConcurrency", "max_concurrency")
      xmux_set_number("maxConnections", "max_connections")
      xmux_set_number("cMaxReuseTimes", "c_max_reuse_times")
      xmux_set_string("hMaxRequestTimes", "h_max_request_times")
      xmux_set_string("hMaxReusableSecs", "h_max_reusable_secs")
      xmux_set_number("hKeepAlivePeriod", "h_keep_alive_period")
      if next(xmux) then transport.xmux = xmux end
    end
  end

  outbound.transport = transport
  return to_json(outbound), "XHTTP enabled via native JSON outbound"
end

local function build_xhttp_http_compat_outbound(url)
  local parsed = parse_vless_link(url)
  if parsed.uuid == "" or parsed.host == "" then
    return nil, "Failed to parse XHTTP link"
  end

  local extra = url_get_param(url, "extra")
  local extra_path = extract_extra_string(extra, "path")
  local extra_host = extract_extra_string(extra, "host")
  local extra_mode = extract_extra_string(extra, "mode")

  local outbound = {
    type = "vless",
    server = parsed.host,
    server_port = parsed.port,
    uuid = parsed.uuid
  }

  local flow = url_get_param(url, "flow")
  if flow ~= "" then outbound.flow = flow end

  local packet_encoding = url_get_param(url, "packetEncoding")
  if packet_encoding ~= "" then outbound.packet_encoding = packet_encoding end

  outbound.tls = build_tls_settings_for_vless(url, "http")

  local path = url_get_param(url, "path")
  if path == "" then path = url_get_param(url, "bx") end
  if path == "" then path = url_get_param(url, "spx") end
  if path == "" then path = extra_path end
  if path == "" then path = "/" end

  local host_header = url_get_param(url, "host")
  if host_header == "" then host_header = extra_host end
  if host_header == "" then host_header = url_get_param(url, "sni") end
  if host_header == "" then host_header = parsed.host end

  outbound.transport = {
    type = "http",
    path = path
  }
  if host_header ~= "" then
    outbound.transport.host = { host_header }
  end

  local mode = url_get_param(url, "mode")
  if mode == "" then mode = extra_mode end

  local warning = "XHTTP enabled via compatible JSON outbound (transport=http)"
  if (extra ~= "" and extra ~= "null") or (mode ~= "" and mode ~= "auto") then
    warning = warning .. "; advanced XHTTP options are not fully supported by sing-box"
  end

  return to_json(outbound), warning
end

local function build_xhttp_outbounds(url)
  local raw_json, raw_warning = build_xhttp_raw_outbound(url)
  if not raw_json then return nil, nil, raw_warning end
  local compat_json, compat_warning = build_xhttp_http_compat_outbound(url)
  if not compat_json then return nil, nil, compat_warning end
  return raw_json, compat_json, raw_warning
end

local function build_url_connection_key(url)
  url = trim(url or "")
  if url == "" then return "" end
  local parsed = parse_vless_link(url)
  local transport = url_get_param(url, "type")
  if transport == "" then transport = "tcp" end
  transport = transport:lower()
  local extra = url_get_param(url, "extra")
  local host_header = url_get_param(url, "host")
  if host_header == "" then host_header = extract_extra_string(extra, "host") end
  if host_header == "" then host_header = url_get_param(url, "sni") end
  local path = url_get_param(url, "path")
  if path == "" then path = url_get_param(url, "bx") end
  if path == "" then path = url_get_param(url, "spx") end
  if path == "" then path = extract_extra_string(extra, "path") end
  local mode = url_get_param(url, "mode")
  if mode == "" then mode = extract_extra_string(extra, "mode") end
  return table.concat({
    "vless",
    parsed.uuid:lower(),
    parsed.host:lower(),
    tostring(parsed.port or ""),
    transport,
    url_get_param(url, "security"):lower(),
    host_header:lower(),
    path,
    url_get_param(url, "serviceName"),
    mode,
    url_get_param(url, "flow"),
    url_get_param(url, "sni"),
    url_get_param(url, "pbk"),
    url_get_param(url, "sid")
  }, "|")
end

local function json_extract_string(blob, key)
  local val = tostring(blob or ""):match('"'..key..'"%s*:%s*"(.-)"')
  return json_unescape(val or "")
end

local function json_extract_number(blob, key)
  return tostring(blob or ""):match('"'..key..'"%s*:%s*(%d+)') or ""
end

local function build_outbound_connection_key(blob)
  blob = tostring(blob or "")
  if blob == "" then return "" end
  local transport_blob = blob:match('"transport"%s*:%s*(%b{})') or ""
  local tls_blob = blob:match('"tls"%s*:%s*(%b{})') or ""
  local reality_blob = tls_blob:match('"reality"%s*:%s*(%b{})') or ""
  local transport = json_extract_string(transport_blob, "type"):lower()
  if transport == "" then transport = "tcp" end
  if transport == "http" then transport = "xhttp" end
  local host_header = json_extract_string(transport_blob, "host")
  if host_header == "" then
    host_header = transport_blob:match('"host"%s*:%s*%[%s*"(.-)"')
    host_header = json_unescape(host_header or "")
  end
  return table.concat({
    "vless",
    json_extract_string(blob, "uuid"):lower(),
    json_extract_string(blob, "server"):lower(),
    json_extract_number(blob, "server_port"),
    transport,
    reality_blob ~= "" and "reality" or (tls_blob ~= "" and "tls" or ""),
    host_header:lower(),
    json_extract_string(transport_blob, "path"),
    json_extract_string(transport_blob, "service_name"),
    json_extract_string(transport_blob, "mode"),
    json_extract_string(blob, "flow"),
    json_extract_string(tls_blob, "server_name"),
    json_extract_string(reality_blob, "public_key"),
    json_extract_string(reality_blob, "short_id")
  }, "|")
end

local function detect_active_url_from_outbound(outbound_json, nodes)
  local target_key = build_outbound_connection_key(outbound_json)
  if target_key == "" then return "" end
  for _, node in ipairs(nodes or {}) do
    if build_url_connection_key(node.full_url or "") == target_key then
      return node.full_url or ""
    end
  end
  return ""
end

local function restart_podkop_service()
  local rc = os.execute("/etc/init.d/podkop restart >/tmp/rift_apply.log 2>&1")
  return (rc==0) or (rc==true)
end

local function b64_urlsafefix(s)
  s = (s or ""):gsub("%s+","")
  s = s:gsub("-", "+"):gsub("_", "/")
  while (#s % 4) ~= 0 do s = s .. "=" end
  return s
end

local function try_decode_base64(raw)
  if not raw then return "" end
  local t = raw:gsub("%s+","")
  if #t < 16 then return "" end
  if not t:match("^[%w%+/%=_%-%s]+$") then return "" end
  t = b64_urlsafefix(t)
  local in_file = "/tmp/rift_b64_in.txt"
  local out_file = "/tmp/rift_b64_out.txt"
  local f = io.open(in_file, "w")
  if not f then return "" end
  f:write(t)
  f:close()
  os.execute("base64 -d "..in_file.." >"..out_file.." 2>/dev/null")
  local dec = exec_read("cat "..out_file.." 2>/dev/null")
  os.remove(in_file)
  os.remove(out_file)
  return dec or ""
end

local function hash_text(raw)
  raw = tostring(raw or "")
  if raw == "" then return "" end
  local in_file = "/tmp/rift_hash_in.txt"
  local f = io.open(in_file, "w")
  if not f then return "" end
  f:write(raw)
  f:close()
  local sig = exec_read("md5sum "..in_file.." 2>/dev/null | cut -d' ' -f1")
  os.remove(in_file)
  return sig or ""
end

local function get_dns_protection_state()
  local dns_type = trim(uci_get("podkop", "settings", "dns_type"))
  local dns_server = trim(uci_get("podkop", "settings", "dns_server"))
  local bootstrap_dns_server = trim(uci_get("podkop", "settings", "bootstrap_dns_server"))
  local active = dns_type == DNS_PROTECT_TYPE and dns_server == DNS_PROTECT_SERVER and bootstrap_dns_server == DNS_PROTECT_BOOTSTRAP
  local secure = dns_type == "doh" or dns_type == "dot"
  return {
    active = active,
    secure = secure,
    dns_type = dns_type ~= "" and dns_type or "unknown",
    dns_server = dns_server,
    bootstrap_dns_server = bootstrap_dns_server
  }
end

local function save_dns_backup_if_needed()
  local current = get_dns_protection_state()
  if current.active then return end
  uci_set("podkop_subs", "config", "dns_backup_type", current.dns_type)
  uci_set("podkop_subs", "config", "dns_backup_server", current.dns_server)
  uci_set("podkop_subs", "config", "dns_backup_bootstrap", current.bootstrap_dns_server)
  exec_silent("uci commit podkop_subs")
end

local function apply_dns_profile(enable)
  if enable then
    save_dns_backup_if_needed()
    uci_set("podkop", "settings", "dns_type", DNS_PROTECT_TYPE)
    uci_set("podkop", "settings", "dns_server", DNS_PROTECT_SERVER)
    uci_set("podkop", "settings", "bootstrap_dns_server", DNS_PROTECT_BOOTSTRAP)
  else
    local backup_type = trim(uci_get("podkop_subs", "config", "dns_backup_type"))
    local backup_server = trim(uci_get("podkop_subs", "config", "dns_backup_server"))
    local backup_bootstrap = trim(uci_get("podkop_subs", "config", "dns_backup_bootstrap"))
    if backup_type == "" then backup_type = DNS_FALLBACK_TYPE end
    if backup_server == "" then backup_server = DNS_FALLBACK_SERVER end
    if backup_bootstrap == "" then backup_bootstrap = DNS_FALLBACK_BOOTSTRAP end
    uci_set("podkop", "settings", "dns_type", backup_type)
    uci_set("podkop", "settings", "dns_server", backup_server)
    uci_set("podkop", "settings", "bootstrap_dns_server", backup_bootstrap)
  end
  exec_silent("uci commit podkop")
  local restarted = restart_podkop_service()
  return restarted, get_dns_protection_state()
end

-- RPC METHODS --

if method=="get_panel_info" then
  local f=io.open("/etc/podkop_data/version","r")
  local v=f and f:read("*a") or "0.0"
  if f then f:close() end
  print(to_json({version=trim(v), hwid=get_hwid(), device_model=get_device_model()}))
  os.exit(0)
end

if method=="check_for_update" then
  local tmp="/tmp/rift_remote.sh"
  local err="/tmp/rift_remote.err"
  local ok = fetch_to_file(get_update_url(), tmp, err)
  if not ok then
    print(to_json({status="error", msg="Не удалось скачать"}))
    os.remove(tmp); os.remove(err); os.exit(0)
  end
  local rs = exec_read("cat "..tmp)
  local rv = rs:match('PANEL_VERSION="([%d%.]+)"')
  local f=io.open("/etc/podkop_data/version","r")
  local lv=f and trim(f:read("*a")) or "0.0"
  if f then f:close() end
  if rv and cmp_ver(rv,lv)==1 then
    print(to_json({status="update_available",local_v=lv,remote_v=rv}))
  else
    print(to_json({status="up_to_date",local_v=lv,remote_v=rv or lv}))
  end
  os.remove(tmp); os.remove(err); os.exit(0)
end

if method=="perform_update" then
  local tmp="/tmp/rift_update.sh"
  local err="/tmp/rift_update.err"
  fetch_to_file(get_update_url(), tmp, err)
  -- PANEL_VERSION= лежит глубже 5-й строки (длинный comment-хедер), поэтому
  -- грепаем ВЕСЬ файл, а не head -n 5 — иначе кнопка «Обновить» ложно падает
  -- с "Update script download failed" на валидном скрипте.
  local raw = exec_read("grep -m1 PANEL_VERSION= "..tmp.." 2>/dev/null")
  if raw == "" or not raw:match('PANEL_VERSION="') then
    print('{"status":"error","msg":"Update script download failed"}')
    os.remove(tmp); os.remove(err); os.exit(0)
  end
  -- v4.22: маркер целостности в конце файла — отсекаем недокачанный установщик
  -- (иначе обрезанный скрипт клобберит rpc → панель "Permission denied").
  local eofok = trim(exec_read("tail -c 200 "..tmp.." 2>/dev/null | grep -c RIFT_PANEL_EOF"))
  if eofok == "" or eofok == "0" then
    print('{"status":"error","msg":"Update script incomplete (truncated download)"}')
    os.remove(tmp); os.remove(err); os.exit(0)
  end
  exec_silent("sed -i 's/\r$//' "..tmp)
  exec_silent("sh "..tmp)
  os.remove(tmp); os.remove(err)
  print('{"status":"ok"}')
  os.exit(0)
end

if method=="get_singbox_status" then
  local version = get_singbox_version()
  local ok, details = singbox_supports_xhttp()
  print(to_json({
    version = version,
    xhttp_supported = ok,
    flavor = ok and "extended" or "regular",
    details = details or ""
  }))
  os.exit(0)
end

if method=="get_nodes" then
  local s,db=pcall(dofile,"/etc/podkop_data/nodes.lua")
  if not s or type(db)~="table" then db={servers={}} end
  local servers = (type(db.servers)=="table") and db.servers or {}
  local r=exec_silent("pgrep -f podkop")
  local rn=(r==0)or(r==true)
  -- active_key формат v4.14: "key<TAB>transport"
  local ak=exec_read("cat /etc/podkop_data/active_key 2>/dev/null")
  local active_key = ak:match("^([^\t]+)") or ""
  local active_tr  = ak:match("\t(%S+)") or ""
  local applied_hash = exec_read("cat /etc/podkop_data/active_sb 2>/dev/null")
  -- отдаём серверы без тяжёлого sb (только отображение + признаки)
  local view={}
  for i,S in ipairs(servers) do
    local vlist={}
    for _,t in ipairs(S.order or {}) do
      local V=S.variants[t]
      if V then vlist[#vlist+1]={transport=V.transport, label=V.label, unsupported=(V.unsupported and true or false)} end
    end
    -- reconnect: у активного сервера применённый sb уже не совпадает со свежим (сменились ключи/IP)
    local needs_reconnect=false
    if active_key~="" and S.key==active_key and active_tr~="" then
      local V=S.variants[active_tr]
      if V and V.sb and applied_hash~="" then
        if hash_text(to_json(V.sb))~=applied_hash then needs_reconnect=true end
      end
    end
    view[i]={name=S.name, flag=S.flag or "", kind=S.kind or "main", parent=S.parent or "", key=S.key, variants=vlist, needs_reconnect=needs_reconnect}
  end
  print(to_json({
    servers=view,
    expire=db.expire or "No data",
    sub_title=db.sub_title or "",
    sub_expire=db.sub_expire or "",
    sub_traffic=db.sub_traffic or "",
    sub_expire_ts=db.sub_expire_ts or 0,
    sub_used_gb=db.sub_used_gb or 0,
    sub_total_gb=db.sub_total_gb or 0,
    updated=db.updated or "Never",
    updated_epoch=db.updated_epoch or 0,
    active_key=active_key,
    active_transport=active_tr,
    running=rn
  }))
  os.exit(0)
end

if method=="update_subs" then
  local url=params.url
  local stored=trim(uci_get("podkop_subs","config","url"))
  if not url or url=="" then url=stored end
  if not url or url=="" then
    dlog("update_subs: URL not found")
    print('{"status":"error","msg":"URL not found"}')
    os.exit(0)
  end
  -- Пишем URL в uci ТОЛЬКО если он реально изменился. Иначе cron (раз в час)
  -- коммитил бы podkop_subs в флеш ~24 раз/сутки впустую — износ флеша.
  if url ~= stored then
    exec_silent("uci -q delete podkop_subs.config.url")
    uci_set("podkop_subs","config","url",url)
    exec_silent("uci commit podkop_subs")
    dlog("update_subs: url changed -> saved")
  end
  dlog("update_subs: start url="..url)

  -- JSON-пайплайн: тянем XRAY_JSON (Happ-UA) — единственный формат со всеми
  -- транспортами (TCP/gRPC/XHTTP/HY2). Конвертим в sing-box outbound на роутере.
  local body="/tmp/podkop_sub.body"
  local err="/tmp/podkop_sub.err"
  os.remove("/tmp/podkop_sub.hdr")
  local ok = fetch_subscription_json(url, body, err)
  local raw = exec_read("cat "..body.." 2>/dev/null")
  dlog("update_subs: fetched ok="..tostring(ok).." bytes="..tostring(#raw))

  if (not ok) or raw=="" then
    local ferr = exec_read("tail -n 3 "..err.." 2>/dev/null")
    dlog("update_subs: download failed err="..ferr)
    print(to_json({status="error", msg="Subscription download failed"}))
    os.remove(body); os.remove(err); os.exit(0)
  end

  -- Заголовки подписки (срок/имя/трафик) — отдельным HEAD-запросом тем же UA.
  local hdr_file="/tmp/podkop_sub.hdr"
  local sub_info = {expire="", title="", interval=""}
  -- v4.26: заголовки берём из основного запроса (curl -D); HEAD — только если его нет
  if exec_read("grep -ci '^subscription-userinfo' "..hdr_file.." 2>/dev/null")=="" or exec_read("grep -ci '^subscription-userinfo' "..hdr_file.." 2>/dev/null")=="0" then
    os.execute("curl -sI -A 'Happ/4.14-RIFT' "..shq(url).." >"..hdr_file.." 2>/dev/null")
  end
  local hdr_raw = exec_read("cat "..hdr_file.." 2>/dev/null")
  if hdr_raw ~= "" then sub_info = extract_sub_info(hdr_file) end
  os.remove(hdr_file)

  -- Парсим XRAY_JSON и конвертим в узлы с готовым sing-box outbound
  local data, jerr = json_decode(raw)
  if type(data) ~= "table" then
    dlog("update_subs: JSON parse failed: "..tostring(jerr).." head="..raw:sub(1,80))
    print(to_json({status="error", msg="Subscription is not valid JSON (ожидался XRAY_JSON)"}))
    os.remove(body); os.remove(err); os.exit(0)
  end
  -- v4.14: группируем узлы по IP в серверы с вариантами-протоколами
  local servers, stats = build_servers_from_xray(data)
  local real = stats.servers
  dlog(string.format("update_subs: servers=%d tcp=%d grpc=%d hy2=%d xhttp=%d sep=%d skip=%d noip=%d",
       real, stats.tcp, stats.grpc, stats.hy2, stats.xhttp, stats.sep, stats.skip, stats.noip))

  if real == 0 then
    dlog("update_subs: no servers (возможно HWID-заглушки или истёкшая подписка)")
    print(to_json({status="error", msg="No servers found"}))
    os.remove(body); os.remove(err); os.exit(0)
  end

  local traffic_str = (sub_info.traffic_label and sub_info.traffic_label ~= "") and sub_info.traffic_label or ""
  local source_hash = hash_text(raw)

  -- Не переписываем nodes.lua если подписка не изменилась (cron раз в час — износ флеша).
  local has_existing, existing = pcall(dofile, "/etc/podkop_data/nodes.lua")
  if has_existing and type(existing)=="table" and existing.source_hash==source_hash
     and existing.parser_ver==PARSER_VER then
    dlog("update_subs: подписка не изменилась (hash+parser совпали) — nodes.lua не переписываю")
    print(to_json({status="ok", count=real, expire=existing.expire or "No data",
      sub_title=existing.sub_title or "", sub_traffic=existing.sub_traffic or "",
      updated=existing.updated or "", updated_epoch=existing.updated_epoch or 0, changed=false}))
    os.remove(body); os.remove(err); os.exit(0)
  end

  local db={
    expire=sub_info.expire ~= "" and sub_info.expire or "No data",
    sub_title=sub_info.title or "",
    sub_expire=sub_info.expire or "",
    sub_traffic=traffic_str,
    sub_expire_ts=sub_info.expire_ts or 0,
    sub_used_gb=tonumber(sub_info.traffic_used) or 0,
    sub_total_gb=(sub_info.traffic_total=="unlimited") and 0 or (tonumber(sub_info.traffic_total) or 0),
    updated=os.date("!%Y-%m-%dT%H:%M:%SZ"),
    updated_epoch=os.time(),
    servers=servers,
    source_hash=source_hash,
    parser_ver=PARSER_VER
  }
  local f=io.open("/etc/podkop_data/nodes.lua","w")
  if f then
    f:write("return "..serialize(db))
    f:close()
    dlog("update_subs: nodes.lua written, servers="..real)
    -- Совместимость ядра: если в podkop сейчас применён xhttp/http (из старой версии
    -- на extended) — штатный sing-box не соберёт конфиг. Переставляем на рабочий
    -- tcp/grpc/hy2-вариант активного сервера (или первого доступного). Разово при апгрейде.
    local cur_ob = trim(uci_get("podkop","main","outbound_json"))
    if cur_ob:find('"type":"xhttp"',1,true) or cur_ob:find('"type":"splithttp"',1,true)
       or cur_ob:find('"type":"http"',1,true) then
      local akey=(exec_read("cat /etc/podkop_data/active_key 2>/dev/null") or ""):match("^([^\t]+)")
      local pick
      for _,S in ipairs(servers) do
        local v=S.variants.tcp or S.variants.grpc or S.variants.hy2
        if v and v.sb then
          if akey and S.key==akey then pick={S=S,v=v}; break end
          if not pick then pick={S=S,v=v} end
        end
      end
      if pick then
        uci_set("podkop","main","outbound_json",to_json(pick.v.sb))
        exec_silent("uci set podkop.main.proxy_config_type='outbound'")
        exec_silent("uci -q delete podkop.main.proxy_string")
        exec_silent("uci commit podkop")
        local af=io.open("/etc/podkop_data/active_key","w"); if af then af:write((pick.S.key or "").."\t"..(pick.v.transport or "tcp")); af:close() end
        local sf=io.open("/etc/podkop_data/active_sb","w"); if sf then sf:write(hash_text(to_json(pick.v.sb))); sf:close() end
        restart_podkop_service()
        dlog("update_subs: несовместимый xhttp/http заменён на "..tostring(pick.S.name).." ["..tostring(pick.v.transport).."]")
      end
    end
    -- v4.26: перенос активного подключения. Ключи серверов сменились (4.24: "ip:<IP>",
    -- 4.26: "m:/c:/g:<тег>") — находим текущий сервер по адресу+протоколу из применённого
    -- outbound_json. podkop НЕ трогаем (VPN не рвётся), кроме одного случая: активный HY2
    -- без salamander, а сервер её требует — такой туннель мёртв, переприменяем исправленный.
    local ak_raw = exec_read("cat /etc/podkop_data/active_key 2>/dev/null")
    local akey = ak_raw:match("^([^\t]+)") or ""
    local known = false
    for _,S in ipairs(servers) do if S.key==akey then known=true; break end end
    local cur = trim(uci_get("podkop","main","outbound_json"))
    if not known and cur ~= "" then
      local cur_server = cur:match('"server":"(.-)"')
      local cur_tr = "tcp"
      if cur:find('"type":"hysteria2"',1,true) then cur_tr="hy2"
      elseif cur:find('"type":"grpc"',1,true) then cur_tr="grpc" end
      for _,S in ipairs(servers) do
        local V=S.variants[cur_tr]
        if V and V.sb and V.host==cur_server then
          local nsb=to_json(V.sb)
          local secret=V.sb.uuid or V.sb.password or ""
          local same = secret~="" and cur:find(secret,1,true)~=nil
          if same and V.sb.obfs and not cur:find('"obfs"',1,true) then
            uci_set("podkop","main","outbound_json",nsb)
            exec_silent("uci commit podkop")
            restart_podkop_service()
            cur=nsb
            dlog("update_subs: HY2 без salamander переприменён с обфускацией ("..tostring(S.name)..")")
          end
          local af=io.open("/etc/podkop_data/active_key","w"); if af then af:write(S.key.."\t"..cur_tr); af:close() end
          local sf=io.open("/etc/podkop_data/active_sb","w")
          if sf then sf:write(hash_text(same and nsb or cur)); sf:close() end
          dlog("update_subs: активный перенесён на "..S.key.." ["..cur_tr.."] host="..tostring(cur_server)..(same and "" or " (конфиг сменился — нужен «Переподключить»)"))
          break
        end
      end
    end
    print(to_json({status="ok", count=real, expire=db.expire, sub_title=db.sub_title, sub_traffic=traffic_str, updated=db.updated, updated_epoch=db.updated_epoch, changed=true}))
  else
    dlog("update_subs: nodes.lua write FAILED")
    print('{"status":"error","msg":"Write failed"}')
  end
  os.remove(body); os.remove(err); os.exit(0)
end

if method=="apply" then
  -- v4.14: подключение по (key сервера, transport). Вариант несёт готовый sing-box outbound.
  local key = params.key
  local tr  = params.transport or "tcp"
  if not key or key=="" then
    dlog("apply: key пуст"); print('{"status":"error","msg":"key пуст"}'); os.exit(0)
  end
  local s,db=pcall(dofile,"/etc/podkop_data/nodes.lua")
  if not s or type(db)~="table" or type(db.servers)~="table" then
    dlog("apply: nodes.lua недоступен"); print('{"status":"error","msg":"Список серверов пуст, обновите подписку"}'); os.exit(0)
  end
  local S
  for _,x in ipairs(db.servers) do if x.key==key then S=x; break end end
  if not S then
    dlog("apply: сервер key="..tostring(key).." не найден"); print('{"status":"error","msg":"Сервер не найден"}'); os.exit(0)
  end
  local V=S.variants[tr]
  if not V or not V.sb then
    -- транспорт недоступен (xhttp) или отсутствует — берём первый рабочий по порядку
    local ntr
    for _,t in ipairs(S.order or {}) do local vv=S.variants[t]; if vv and vv.sb then ntr=t; V=vv; break end end
    if V and V.sb then tr=ntr
    else dlog("apply: у "..tostring(S.name).." нет рабочего транспорта")
      print(to_json({status="error", msg="Протокол недоступен на роутере"})); os.exit(0) end
  end
  dlog("apply: "..tostring(S.name).." key="..key.." transport="..tr)

  local outbound = to_json(V.sb)
  exec_silent("uci set podkop.main.proxy_config_type='outbound'")
  exec_silent("uci -q delete podkop.main.proxy_string")
  uci_set("podkop","main","outbound_json",outbound)
  exec_silent("uci commit podkop")
  dlog("apply: outbound_json записан ("..#outbound.." байт), рестарт podkop...")

  if restart_podkop_service() then
    local af=io.open("/etc/podkop_data/active_key","w"); if af then af:write(key.."\t"..tr); af:close() end
    local sf=io.open("/etc/podkop_data/active_sb","w"); if sf then sf:write(hash_text(outbound)); sf:close() end
    -- sing-box поднимается ~1-2с после restart — ждём/ретраим для лога
    local up = false
    for _=1,4 do
      os.execute("sleep 1")
      local r = exec_silent("pgrep -f sing-box")
      if (r==0) or (r==true) then up = true; break end
    end
    dlog("apply: podkop рестартован, sing-box "..(up and "RUNNING" or "не виден"))
    print(to_json({status="ok", transport=tr, name=S.name}))
  else
    local alog = exec_read("tail -n 8 /tmp/rift_apply.log 2>/dev/null")
    dlog("apply: podkop НЕ перезапустился. log: "..alog)
    print(to_json({status="error", msg="Podkop не смог перезапуститься", log=alog}))
  end
  os.exit(0)
end

if method=="check_availability" then
  -- v4.27: проверка ПО ОЧЕРЕДИ и БЕЗ TLS. 18.10 у владельца: параллельные TLS-проверки к зарубежным хостингам ТСПУ
  -- обрывал (rc=28 к 3 хостам; по одному — 60 мс) — это могло задеть и настоящий VPN пользователя. Теперь: адрес
  -- напрямую через 77.88.8.8 (direct_ip, мимо FakeIP), обычный HTTP на ip:443 (сервер отвечает 400 и закрывает) —
  -- меряем время TCP-соединения, TLS-отпечатка нет. Одинаковые host:port — один замер. Не ответившие — второй заход.
  local s,db=pcall(dofile,"/etc/podkop_data/nodes.lua")
  if not s or type(db)~="table" or type(db.servers)~="table" then
    print('{"status":"error","msg":"нет серверов"}'); os.exit(0)
  end
  local has_curl = curl_ok()
  local probe, order, keyHp = {}, {}, {}
  for _,S in ipairs(db.servers) do
    local host, port
    for _,t in ipairs(S.order or {}) do
      local V=S.variants[t]
      if V and V.host then host=V.host; port=tonumber(V.port) or 443; break end
    end
    if host and host:match("^[%w%.%-]+$") then
      local hp=host..":"..port
      keyHp[S.key]=hp
      if not probe[hp] then probe[hp]={host=host, port=port}; order[#order+1]=hp end
    end
  end
  local function try(x)
    local ip = x.ip
    if not ip then ip=direct_ip(x.host); x.ip=ip end
    if has_curl then
      local target = (ip~="" and ip) or x.host
      local v=tonumber(exec_read("curl -s -o /dev/null --connect-timeout 3 -m 3 -w '%{time_connect}' "..shq("http://"..target..":"..x.port.."/").." 2>/dev/null")) or 0
      return v>0 and math.floor(v*1000+0.5) or nil
    end
    local v=tonumber(exec_read("ping -c 1 -W 3 "..shq((ip~="" and ip) or x.host).." 2>/dev/null | sed -n 's/.*time=\\([0-9.]*\\).*/\\1/p'")) or 0
    return v>0 and math.floor(v+0.5) or nil
  end
  local res, nfail = {}, 0
  for _,hp in ipairs(order) do res[hp]=try(probe[hp]) or false; if not res[hp] then nfail=nfail+1 end end
  if nfail>0 and nfail<#order then
    for _,hp in ipairs(order) do if not res[hp] then res[hp]=try(probe[hp]) or false end end
  end
  local results, ms = {}, {}
  for key,hp in pairs(keyHp) do
    if res[hp] then results[key]="ok"; ms[key]=res[hp] else results[key]="fail" end
  end
  dlog("check_availability: хостов="..#order.." не ответили="..nfail.." (curl="..tostring(has_curl)..")")
  print(to_json({status="ok", avail=results, ms=ms}))
  os.exit(0)
end

if method=="test_variants" then
  -- v4.27: честная проверка протоколов ВЫБРАННОЙ страны: для каждого варианта (основной + «Запасной») отдельный
  -- временный sing-box на 127.0.0.1:10999 (только этот outbound) и запрос через него. podkop/текущий VPN не трогаются.
  -- По очереди (память роутера), до ~9 с на вариант. Время = полный запрос через VPN (generate_204).
  local key=params.key or ""
  local s,db=pcall(dofile,"/etc/podkop_data/nodes.lua")
  if not s or type(db)~="table" or type(db.servers)~="table" then print('{"status":"error","msg":"нет серверов"}'); os.exit(0) end
  if not cmd_exists("sing-box") or not curl_ok() then print('{"status":"error","msg":"нет sing-box или curl"}'); os.exit(0) end
  local list={}
  for _,S in ipairs(db.servers) do if S.key==key or (S.parent and S.parent==key) then list[#list+1]=S end end
  if #list==0 then print('{"status":"error","msg":"сервер не найден"}'); os.exit(0) end
  local cfg, out = "/tmp/rift_vt.json", {}
  exec_silent("pkill -f 'sing-box run -c /tmp/rift_vt.json'")   -- зависший прошлый тест (двойной клик / обрыв CGI)
  for _,S in ipairs(list) do
    for _,t in ipairs(S.order or {}) do
      local V=S.variants[t]
      if V and V.sb then
        local f=io.open(cfg,"w")
        f:write('{"log":{"level":"error"},"inbounds":[{"type":"mixed","tag":"vt-in","listen":"127.0.0.1","listen_port":10999}],"outbounds":['..to_json(V.sb)..']}'); f:close()
        local pid=exec_read("sing-box run -c "..cfg.." >/dev/null 2>&1 & echo $!")
        os.execute("sleep 2")
        local r=exec_read("curl -s -o /dev/null -m 6 -w '%{http_code} %{time_total}' -x socks5h://127.0.0.1:10999 https://www.google.com/generate_204 2>/dev/null")
        if pid:match("^%d+$") then os.execute("kill "..pid.." 2>/dev/null; sleep 1") end
        local code, tt = r:match("^(%d+)%s+([%d%.]+)")
        local ok = code=="204"
        out[#out+1]={key=S.key, kind=S.kind or "main", transport=t, label=V.label or t, ok=ok, ms=ok and math.floor((tonumber(tt) or 0)*1000+0.5) or nil}
      end
    end
  end
  os.remove(cfg)
  dlog("test_variants: "..key.." вариантов="..#out)
  print(to_json({status="ok", results=out}))
  os.exit(0)
end

if method=="ping" then
  local host=params.host
  if host and host:match("^[a-zA-Z0-9%.%-]+$") then
    local res=exec_silent("ping -c 1 -W 2 "..host)
    local ms="timeout"
    local s="fail"
    if (res==0)or(res==true) then
      local out=exec_read("ping -c 1 -W 2 "..host.." 2>/dev/null")
      local val=out:match("time=([%d%.]+)")
      if val then ms=math.floor(tonumber(val)).." ms" end
      s="ok"
    end
    print(to_json({status=s,time=ms,host=host}))
  else
    print('{"status":"error","msg":"bad host"}')
  end
  os.exit(0)
end

if method=="ping_all" then
  local s,db=pcall(dofile,"/etc/podkop_data/nodes.lua")
  if not s or type(db)~="table" then db={nodes={}} end
  local results={}
  local done_hosts={}
  for _,node in ipairs(db.nodes or {}) do
    local h=node.host
    if h and h~="" and not done_hosts[h] and h:match("^[a-zA-Z0-9%.%-]+$") then
      done_hosts[h]=true
      local res=exec_silent("ping -c 1 -W 2 "..h)
      local ms="timeout"
      if (res==0)or(res==true) then
        local out=exec_read("ping -c 1 -W 2 "..h.." 2>/dev/null")
        local val=out:match("time=([%d%.]+)")
        if val then ms=math.floor(tonumber(val)).." ms" end
      end
      results[h]=ms
    end
  end
  print(to_json({status="ok",pings=results}))
  os.exit(0)
end

-- ===========================================================================
-- v4.8: «Полный VPN» по MAC-адресу устройства.
-- Панель хранит MAC+hostname в /etc/podkop_data/vpn_macs.list. Watcher-демон
-- (rift-mac-vpn-watcher) каждые 30с мапит MAC->текущий IP через ip neigh +
-- dhcp.leases и синхронит podkop.main.fully_routed_ips. При переподключении
-- устройства IP меняется — watcher сам переставит новый IP, привязка не слетает.
-- UI оперирует MAC, а не IP.
-- ===========================================================================
local VPN_MACS_FILE = "/etc/podkop_data/vpn_macs.list"

-- Прочитать saved MAC-список. Формат: "MAC\tHOSTNAME" по строке.
local function read_vpn_macs()
  local list = {}
  local f = io.open(VPN_MACS_FILE, "r")
  if not f then return list end
  for line in f:lines() do
    line = trim(line)
    if line ~= "" then
      local mac, name = line:match("^(%S+)%s*(.*)$")
      if mac and mac:match("^[%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:][%x:]$") then
        list[#list+1] = {mac = mac:lower(), name = name or ""}
      end
    end
  end
  f:close()
  return list
end

local function write_vpn_macs(list)
  local f = io.open(VPN_MACS_FILE, "w")
  if not f then return false end
  for _, m in ipairs(list) do
    f:write((m.mac or "") .. "\t" .. (m.name or "") .. "\n")
  end
  f:close()
  return true
end

-- Найти текущий IPv4 для MAC через ip neigh + dhcp.leases.
-- Возвращает "" если устройство сейчас не онлайн.
local function ip_for_mac(mac)
  if not mac or mac == "" then return "" end
  local mac_lo = mac:lower()
  -- Сначала ip neigh (отражает реальное состояние сети)
  local out = exec_read("ip -4 neigh show 2>/dev/null")
  for line in out:gmatch("[^\n]+") do
    local ip, ll = line:match("^(%d+%.%d+%.%d+%.%d+).-lladdr%s+(%S+)")
    if ip and ll and ll:lower() == mac_lo then
      return ip
    end
  end
  -- Fallback: /tmp/dhcp.leases
  local f = io.open("/tmp/dhcp.leases","r")
  if f then
    for line in f:lines() do
      local p = {}
      for w in line:gmatch("%S+") do p[#p+1] = w end
      if p[2] and p[2]:lower() == mac_lo and p[3] then
        f:close()
        return p[3]
      end
    end
    f:close()
  end
  return ""
end

if method=="get_network" then
  -- Активные клиенты (объединяем dhcp.leases и ip neigh, dedup по MAC)
  local clients = {}
  local seen_mac = {}
  -- 1) Источник имён — dhcp.leases (там есть hostname)
  local f = io.open("/tmp/dhcp.leases","r")
  if f then
    for line in f:lines() do
      local p = {}
      for w in line:gmatch("%S+") do p[#p+1] = w end
      if p[2] and p[3] and not seen_mac[p[2]:lower()] then
        seen_mac[p[2]:lower()] = true
        clients[#clients+1] = {mac = p[2]:lower(), ip = p[3], name = p[4] or p[3]}
      end
    end
    f:close()
  end
  -- 2) Дополним из ip neigh (устройства со статической IP / без DHCP)
  local out = exec_read("ip -4 neigh show 2>/dev/null")
  for line in out:gmatch("[^\n]+") do
    local ip, dev, ll = line:match("^(%d+%.%d+%.%d+%.%d+)%s+dev%s+(%S+).-lladdr%s+(%S+)")
    -- Только LAN-интерфейсы (br-lan/br-*), НЕ wan — иначе в списке всплывает
    -- upstream-шлюз (напр. 192.168.7.1 на dev wan, тоже 192.168.x).
    if ip and dev and ll and not dev:match("wan") and not seen_mac[ll:lower()] then
      if ip:match("^192%.168%.") or ip:match("^10%.") or ip:match("^172%.") then
        seen_mac[ll:lower()] = true
        clients[#clients+1] = {mac = ll:lower(), ip = ip, name = ip}
      end
    end
  end
  -- v4.14: режим полного VPN — "mac" (умный, watcher MAC->IP) или "ip" (простой, прямые IP)
  local mode = trim(uci_get("podkop_subs","config","fullvpn_mode")); if mode ~= "ip" then mode = "mac" end
  local offline = {}
  if mode == "ip" then
    -- enabled = текущий IP клиента есть в fully_routed_ips; офлайн = routed IP без онлайн-клиента
    local routed, online_ip = {}, {}
    for w in (exec_read("uci -q get podkop.main.fully_routed_ips") or ""):gmatch("%S+") do routed[w]=true end
    for _, c in ipairs(clients) do c.enabled = (routed[c.ip]==true); online_ip[c.ip]=true end
    for ip,_ in pairs(routed) do
      if not online_ip[ip] then offline[#offline+1] = {ip = ip, name = ip} end
    end
  else
    -- MAC-режим: enabled по vpn_macs.list, офлайн = MAC из списка без онлайн-клиента
    local macs = read_vpn_macs()
    local macname = {}
    for _, m in ipairs(macs) do macname[m.mac] = m.name or "" end
    for _, c in ipairs(clients) do c.enabled = (macname[c.mac] ~= nil) end
    for _, m in ipairs(macs) do
      if not seen_mac[m.mac] then offline[#offline+1] = {mac = m.mac, name = m.name ~= "" and m.name or m.mac} end
    end
  end
  -- Домены
  local domains = {}
  for w in (exec_read("uci -q get podkop.main.user_domains") or ""):gmatch("%S+") do
    domains[#domains+1] = w
  end
  print(to_json({clients=clients, vpn_offline=offline, domains=domains, mode=mode}))
  os.exit(0)
end

if method=="manage_vpn" then
  local mode = trim(uci_get("podkop_subs","config","fullvpn_mode")); if mode ~= "ip" then mode = "mac" end
  local a = params.action
  if not a then print('{"status":"error","msg":"action required"}'); os.exit(0) end

  if mode == "ip" then
    -- IP-режим (старый): пишем ТЕКУЩИЙ IP устройства прямо в fully_routed_ips, без MAC/watcher.
    local ip = params.ip or ""
    if not ip:match("^%d+%.%d+%.%d+%.%d+$") then
      print('{"status":"error","msg":"bad ip"}'); os.exit(0)
    end
    -- del+add -> ровно одна запись (без дублей)
    exec_silent("uci del_list podkop.main.fully_routed_ips="..shq(ip))
    if a == "add" then exec_silent("uci add_list podkop.main.fully_routed_ips="..shq(ip)) end
    exec_silent("uci commit podkop")
    exec_silent("/etc/init.d/podkop reload")
    dlog("manage_vpn[ip]: "..a.." ip="..ip)
    print('{"status":"ok"}'); os.exit(0)
  end

  -- MAC-режим (умный, watcher сам переотразит MAC->IP)
  local mac = (params.mac or ""):lower()
  local name = params.name or ""
  if mac == "" then print('{"status":"error","msg":"mac required"}'); os.exit(0) end
  if not mac:match("^%x%x:%x%x:%x%x:%x%x:%x%x:%x%x$") then
    print('{"status":"error","msg":"bad mac format"}'); os.exit(0)
  end
  local list = read_vpn_macs()
  if a == "add" then
    local found = false
    for _, m in ipairs(list) do if m.mac == mac then m.name = name; found = true; break end end
    if not found then list[#list+1] = {mac = mac, name = name} end
  elseif a == "del" then
    local new = {}
    for _, m in ipairs(list) do if m.mac ~= mac then new[#new+1] = m end end
    list = new
  else
    print('{"status":"error","msg":"bad action"}'); os.exit(0)
  end
  if not write_vpn_macs(list) then
    print('{"status":"error","msg":"cannot write vpn_macs.list"}'); os.exit(0)
  end
  dlog("manage_vpn[mac]: "..a.." mac="..mac.." name="..name)
  exec_silent("/usr/local/sbin/rift-mac-vpn-watcher --once 2>/dev/null")
  print('{"status":"ok"}')
  os.exit(0)
end

if method=="set_vpn_mode" then
  local m = params.mode
  if m ~= "mac" and m ~= "ip" then
    print('{"status":"error","msg":"mode must be mac/ip"}'); os.exit(0)
  end
  uci_set("podkop_subs","config","fullvpn_mode",m)
  exec_silent("uci commit podkop_subs")
  -- mac: watcher снова берёт управление -> пересобрать fully_routed_ips из vpn_macs.list.
  -- ip: watcher-guard сам перестанет трогать список, текущие IP остаются как есть.
  if m == "mac" then exec_silent("/usr/local/sbin/rift-mac-vpn-watcher --once 2>/dev/null") end
  dlog("set_vpn_mode: "..m)
  print(to_json({status="ok", mode=m}))
  os.exit(0)
end

if method=="manage_domain" then
  local d=params.domain; local a=params.action
  if d and a and d:match("^[a-zA-Z0-9%.%-]+$") then
    if a=="add" then
      exec_silent("uci set podkop.main.user_domain_list_type='dynamic'")
      exec_silent("uci add_list podkop.main.user_domains="..shq(d))
    elseif a=="del" then
      exec_silent("uci del_list podkop.main.user_domains="..shq(d))
    end
    exec_silent("uci commit podkop"); exec_silent("/etc/init.d/podkop restart")
    print('{"status":"ok"}')
  else print('{"status":"error","msg":"bad domain"}') end
  os.exit(0)
end

-- v4.8: полноценный DNS-блок (как в podkop): тип (udp/dot/doh) + сервер + bootstrap.
-- Пишем в podkop.settings.{dns_type,dns_server,bootstrap_dns_server}. Значения
-- валидирует и конфиг генерит сам podkop; порт по типу (udp53/dot853/doh443).

-- Читает список DNS для выпадающих меню из файла (строки "value|Подпись").
-- Инсталлятор перезаписывает эти файлы при каждом апгрейде («пуш» от оператора).
local function read_dns_options(path, fallback)
  local out = {}
  local f = io.open(path, "r")
  if f then
    for line in f:lines() do
      line = trim(line)
      if line ~= "" and line:sub(1,1) ~= "#" then
        local v, l = line:match("^(.-)|(.+)$")
        if not v or v == "" then v = line; l = line end
        out[#out+1] = { v = trim(v), l = trim(l) }
      end
    end
    f:close()
  end
  if #out == 0 then return fallback end
  return out
end

if method=="get_dns_settings" then
  local servers = read_dns_options("/etc/podkop_data/dns_servers.list", {
    {v="77.88.8.8", l="Yandex (77.88.8.8)"},
    {v="8.8.8.8",   l="Google (8.8.8.8)"},
    {v="1.1.1.1",   l="Cloudflare (1.1.1.1)"},
    {v="9.9.9.9",   l="Quad9 (9.9.9.9)"},
  })
  local bootstraps = read_dns_options("/etc/podkop_data/dns_bootstraps.list", {
    {v="77.88.8.8", l="Yandex (77.88.8.8)"},
    {v="8.8.8.8",   l="Google (8.8.8.8)"},
    {v="1.1.1.1",   l="Cloudflare (1.1.1.1)"},
    {v="9.9.9.9",   l="Quad9 (9.9.9.9)"},
  })
  print(to_json({
    dns_type = trim(uci_get("podkop","settings","dns_type")),
    dns_server = trim(uci_get("podkop","settings","dns_server")),
    bootstrap_dns_server = trim(uci_get("podkop","settings","bootstrap_dns_server")),
    servers = servers,
    bootstraps = bootstraps
  }))
  os.exit(0)
end

if method=="set_dns_settings" then
  local dtype   = trim(params.dns_type or "")
  local dserver = trim(params.dns_server or "")
  local dboot   = trim(params.bootstrap or "")
  -- тип строго из набора podkop (иначе facade делает fatal exit)
  if dtype ~= "udp" and dtype ~= "dot" and dtype ~= "doh" then
    print('{"status":"error","msg":"dns_type должен быть udp/dot/doh"}'); os.exit(0)
  end
  if dserver == "" then
    print('{"status":"error","msg":"DNS-сервер пуст"}'); os.exit(0)
  end
  -- dns_server: IP или URL/hostname (для DoH можно https://.../dns-query)
  if not dserver:match("^[%w%.%:%-%_/%?=&]+$") then
    print('{"status":"error","msg":"Некорректный DNS-сервер"}'); os.exit(0)
  end
  -- bootstrap ОБЯЗАН быть IPv4 (podkop им резолвит hostname DoH/DoT по UDP:53)
  if dboot == "" then dboot = "77.88.8.8" end
  if not dboot:match("^%d+%.%d+%.%d+%.%d+$") then
    print('{"status":"error","msg":"Bootstrap DNS должен быть IPv4"}'); os.exit(0)
  end
  uci_set("podkop","settings","dns_type",dtype)
  uci_set("podkop","settings","dns_server",dserver)
  uci_set("podkop","settings","bootstrap_dns_server",dboot)
  exec_silent("uci commit podkop")
  dlog("set_dns: type="..dtype.." server="..dserver.." bootstrap="..dboot)
  local ok = restart_podkop_service()
  print(to_json({
    status = ok and "ok" or "error",
    dns_type = dtype, dns_server = dserver, bootstrap_dns_server = dboot,
    msg = ok and "DNS обновлён" or "podkop не перезапустился после смены DNS"
  }))
  os.exit(0)
end

if method=="get_sub_url" then
  print(to_json({url=uci_get("podkop_subs","config","url")}))
  os.exit(0)
end

-- Системные настройки: URL источника обновлений панели.
if method=="get_update_url" then
  local f=io.open("/etc/podkop_data/version","r")
  local v=f and trim(f:read("*a")) or "?"
  if f then f:close() end
  print(to_json({url=get_update_url(), default=DEFAULT_UPDATE_URL, panel_version=v}))
  os.exit(0)
end

if method=="set_update_url" then
  local u = trim(params.url or "")
  if u == "" then u = DEFAULT_UPDATE_URL end
  if not u:match("^https?://[%w%.%-:/%?=&_%%]+$") then
    print('{"status":"error","msg":"Некорректный URL (нужен http/https)"}'); os.exit(0)
  end
  uci_set("podkop_subs","config","update_url",u)
  exec_silent("uci commit podkop_subs")
  dlog("set_update_url: "..u)
  print(to_json({status="ok", url=u}))
  os.exit(0)
end

if method=="get_logs" then
  local lines = tostring(params.lines or "80"):gsub("%D","")
  if lines == "" then lines = "80" end
  -- v4.14: диагностический блок сверху — чтобы чётко видеть состояние
  local diag = "===== ДИАГНОСТИКА =====\n"
    .."версия панели: "..exec_read("cat /etc/podkop_data/version 2>/dev/null").."\n"
    .."sing-box: "..get_singbox_version().."\n"
    .."curl: "..exec_read("curl --version 2>/dev/null | head -1").."\n"
    .."podkop DNS: type="..trim(uci_get("podkop","settings","dns_type")).." server="..trim(uci_get("podkop","settings","dns_server")).."\n"
    .."active_key: "..exec_read("cat /etc/podkop_data/active_key 2>/dev/null").."\n"
  local sok,db=pcall(dofile,"/etc/podkop_data/nodes.lua")
  if sok and type(db)=="table" and type(db.servers)=="table" then
    diag=diag.."серверов: "..#db.servers.." (обновлено "..tostring(db.updated or "?")..")\n"
    for _,S in ipairs(db.servers) do
      local ts={}
      for _,t in ipairs(S.order or {}) do ts[#ts+1]=(S.variants[t] and S.variants[t].unsupported) and (t.."(x)") or t end
      diag=diag.."  "..tostring(S.flag or "").." "..tostring(S.name).."  ["..tostring(S.kind or "?").." "..tostring(S.key).."]  "..table.concat(ts,"/").."\n"
    end
  else
    diag=diag.."серверов: nodes.lua нет/пуст — обновите подписку\n"
  end
  local panel = exec_read("tail -n "..lines.." "..RIFT_PANEL_LOG.." 2>/dev/null")
  local sys = exec_read("logread 2>/dev/null | grep -iE 'podkop|sing-box' | tail -n "..lines)
  local out = diag.."\n===== RIFT panel.log =====\n"..(panel~="" and panel or "(пусто)").."\n\n===== podkop / sing-box (logread) =====\n"..(sys~="" and sys or "(пусто)")
  print(to_json({logs=out}))
  os.exit(0)
end

if method=="get_hwid_info" then
  print(to_json({hwid=get_hwid(), device_model=get_device_model(), os_version=get_os_version(), os_type="OpenWRT"}))
  os.exit(0)
end

-- v4.15: режим диагностики. Демон rift-diag непрерывно собирает снапшоты и шлёт
-- батчи на router.rift.monster/diag (curl --resolve, мимо туннеля) по двум триггерам:
-- раз в час ИЛИ при буфере >= порога. Панель только флаг diag_mode + Router ID (HWID).
if method=="get_diag_mode" then
  local en = trim(uci_get("podkop_subs","config","diag_mode"))
  local last = trim(exec_read("logread 2>/dev/null | grep 'rift-diag' | grep -iE 'sent ok' | tail -1"))
  local run = exec_silent("pgrep -f '/usr/local/sbin/rift-diag' >/dev/null 2>&1")
  local buf = trim(exec_read("wc -c < /tmp/rift_diag_buf.jsonl 2>/dev/null"))
  local started = tonumber(trim(uci_get("podkop_subs","config","diag_started")))
  print(to_json({
    status="ok",
    enabled=(en=="1"),
    hwid=get_hwid(),
    running=((run==0) or (run==true)),
    last_send=last,
    buf_bytes=(tonumber(buf) or 0),
    started=(started or 0),
    ttl=86400,
    now=os.time()
  }))
  os.exit(0)
end

if method=="set_diag_mode" then
  local on = trim(tostring(params.on or ""))
  local label = trim(tostring(params.label or ""))
  if label ~= "" then
    label = label:gsub('[^%w%s%.#%-_]','')
    uci_set("podkop_subs","config","diag_label",label)
  end
  if on=="1" or on=="true" then
    uci_set("podkop_subs","config","diag_mode","1")
    uci_set("podkop_subs","config","diag_started",tostring(os.time()))
    exec_silent("uci commit podkop_subs")
    -- гарантируем, что демон запущен (на случай если он ещё не поднят)
    exec_silent("/etc/init.d/rift-diag enable >/dev/null 2>&1; /etc/init.d/rift-diag start >/dev/null 2>&1")
    -- мгновенная проверка связи + первый батч (в фоне, не блокируем RPC)
    exec_silent("/usr/local/sbin/rift-diag --once >/dev/null 2>&1 &")
    dlog("set_diag_mode: ON hwid="..get_hwid())
    print(to_json({status="ok", enabled=true, hwid=get_hwid()}))
  else
    uci_set("podkop_subs","config","diag_mode","0")
    exec_silent("uci commit podkop_subs")
    dlog("set_diag_mode: OFF")
    print(to_json({status="ok", enabled=false, hwid=get_hwid()}))
  end
  os.exit(0)
end

print('{"status":"error","msg":"unknown method"}')
-- RIFT_RPC_EOF (сентинел целостности; проверяется перед mv rpc.tmp -> rpc)
EOF

# 7) Frontend
logi "[7/10] Запись Frontend..."
cat <<'EOF' > /www/podkop_panel/index.html
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>RIFT</title>
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2055%2064%22%3E%3Cpath%20fill=%22%23000%22%20d=%22M20.3394%200.282413C24.7689%20-0.413414%2029.3055%200.183039%2033.4048%202.00019C39.9365%204.89567%2044.7801%2010.6272%2046.5464%2017.55L54.3745%2048.2316L54.4556%2048.6008C55.2462%2052.2015%2052.8223%2055.719%2049.1763%2056.2629L48.8218%2056.3156C47.132%2056.5676%2045.4194%2056.0375%2044.1675%2054.8752L43.6822%2054.424C42.9542%2053.7479%2041.9969%2053.3723%2041.0034%2053.3723C39.3999%2053.3723%2037.9567%2054.3458%2037.355%2055.8322L36.5064%2057.9279C35.8183%2059.627%2034.5159%2061.0046%2032.8579%2061.7863L31.9556%2062.2111C30.3098%2062.987%2028.4816%2063.2917%2026.6734%2063.091L24.3941%2062.8371C22.6926%2062.6482%2021.1447%2061.7628%2020.1197%2060.3918L17.7915%2057.2785C16.2735%2055.2483%2013.1828%2055.4013%2011.8726%2057.5715L11.521%2058.0441C9.93168%2060.1782%207.17614%2061.0901%204.62746%2060.3254L4.41164%2060.26C1.95424%2059.5228%200.23588%2057.3069%200.133323%2054.7434L0.967307%2039.5168C1.0964%2037.1609%201.04949%2034.7984%200.827659%2032.4494L0.0991429%2024.7316C-0.16165%2021.9702%200.0932144%2019.1844%200.85012%2016.5158C3.23394%208.11217%2010.2872%201.86163%2018.9165%200.506046L20.3394%200.282413ZM10.1597%2018.925C7.65146%2018.925%205.6177%2021.7647%205.6177%2025.2668C5.61785%2028.7687%207.65156%2031.6076%2010.1597%2031.6076C12.6676%2031.6073%2014.7005%2028.7685%2014.7007%2025.2668C14.7007%2021.7649%2012.6677%2018.9253%2010.1597%2018.925ZM23.355%2018.925C20.8469%2018.925%2018.8132%2021.764%2018.813%2025.2658C18.813%2028.7679%2020.8468%2031.6076%2023.355%2031.6076C25.8631%2031.6075%2027.896%2028.7678%2027.896%2025.2658C27.8958%2021.7641%2025.863%2018.9251%2023.355%2018.925Z%22/%3E%3C/svg%3E">
<style>
/* RIFT Panel 4.26 — дизайн согласован с владельцем 17.10.2026 (макет «RIFT Router 4.26»). Только светлая тема (решение владельца).
   Шрифты системные: на роутере без VPN внешние шрифты могут не загрузиться. */
:root{
  --bg:#EEF1F5;--surface:#FFFFFF;--surface-2:#F5F7FA;--line:#E2E7EE;--text:#0D1626;--muted:#69758A;
  --accent:#1E5BFF;--accent-2:#47A6FF;--accent-soft:#E7EEFF;--on-accent:#FFFFFF;
  --ok:#12A150;--ok-soft:#E3F6EB;--warn:#C27A00;--warn-soft:#FFF3DC;--bad:#D93A3A;--bad-soft:#FDE7E7;--off:#DCE2EA;
  --f-ui:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
  --f-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
  --r:22px;--rc:12px;--sh:0 1px 2px rgba(13,22,38,.04),0 8px 24px rgba(13,22,38,.05);--ov:rgba(238,241,245,.82);color-scheme:light}
*{box-sizing:border-box}
html,body{margin:0}
body{background:var(--bg);color:var(--text);font-family:var(--f-ui);font-size:15px;line-height:1.45;-webkit-font-smoothing:antialiased}
button,input,select{font:inherit;color:inherit}
button{cursor:pointer}
button:disabled{cursor:default;opacity:.55}
:focus-visible{outline:2px solid var(--accent);outline-offset:2px}
[hidden]{display:none!important}
@media (prefers-reduced-motion:reduce){*{transition:none!important;animation:none!important}}
.mono{font-family:var(--f-mono);font-size:11.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--muted)}
.num{font-variant-numeric:tabular-nums}
.app{max-width:1180px;margin:0 auto;padding:20px max(16px,env(safe-area-inset-right)) 96px max(16px,env(safe-area-inset-left));display:grid;gap:18px}
.top{display:flex;align-items:center;gap:12px}
.logo{width:32px;height:38px;color:#F5F7FA;flex:0 0 auto}
.logo svg{width:100%;height:100%;display:block}
.top h1{margin:0;font-size:17px;font-weight:650}
.top .sub{font-size:12.5px;color:var(--muted)}
.sp{flex:1}
.cols{display:grid;gap:18px;grid-template-columns:minmax(0,1fr)}
@media (min-width:980px){.cols{grid-template-columns:minmax(0,1.08fr) minmax(0,.92fr);align-items:start}}
.stack{display:grid;gap:18px;min-width:0}
/* герой */
.hero{position:relative;overflow:hidden;border-radius:28px;background:var(--accent);color:var(--on-accent);padding:22px 22px 20px;display:grid;gap:18px}
.hero::before,.hero::after{content:"";position:absolute;border-radius:50%;pointer-events:none}
.hero::before{width:420px;height:420px;right:-170px;top:-190px;border:58px solid var(--accent-2);opacity:.55}
.hero::after{width:250px;height:250px;right:-60px;top:-90px;border:40px solid #9FD3FF;opacity:.35}
.hero>*{position:relative}
.live{display:inline-flex;align-items:center;gap:7px;padding:5px 11px;border-radius:999px;background:rgba(255,255,255,.16);font-size:12.5px;font-weight:600;justify-self:start}
.live i{width:8px;height:8px;border-radius:50%;background:#7CF0AE;box-shadow:0 0 0 4px rgba(124,240,174,.25)}
.live.off i{background:#FFB4B4;box-shadow:0 0 0 4px rgba(255,180,180,.25)}
.hero-main{display:flex;align-items:center;gap:14px;min-width:0}
.hflag{width:52px;height:52px;border-radius:50%;overflow:hidden;flex:0 0 auto;box-shadow:0 0 0 3px rgba(255,255,255,.9);background:rgba(255,255,255,.2)}
.hero h2{margin:0;font-size:30px;line-height:1.1;font-weight:700;letter-spacing:-.01em;white-space:pre-wrap;overflow-wrap:anywhere}
.hero .meta{font-size:13px;opacity:.85;margin-top:3px}
.hero-actions{display:flex;flex-wrap:wrap;gap:8px}
.hbtn{border:0;border-radius:12px;padding:10px 14px;font-weight:600;font-size:14px;background:rgba(255,255,255,.14);color:var(--on-accent)}
.hbtn.attn{background:#fff;color:#0D1626}
.seg-h{display:inline-flex;padding:3px;border-radius:12px;background:rgba(255,255,255,.14)}
.seg-h button{border:0;background:transparent;color:var(--on-accent);padding:7px 12px;border-radius:9px;font-size:13px;font-weight:600;opacity:.85;white-space:pre}
.seg-h button[aria-pressed="true"]{background:rgba(255,255,255,.95);color:#0D1626;opacity:1}
/* плитки */
.tiles{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}
.tile{background:var(--surface);border-radius:var(--r);padding:16px;display:grid;gap:22px;box-shadow:var(--sh);min-width:0}
.tile svg{width:22px;height:22px}
.tile .l{font-size:13px;color:var(--muted);white-space:pre;overflow:hidden;text-overflow:ellipsis}
.tile .v{font-size:19px;font-weight:650;margin-top:2px}
.tile .v small{font-size:13px;font-weight:500;color:var(--muted)}
.bar{height:6px;border-radius:4px;background:var(--surface-2);overflow:hidden;margin-top:8px}
.bar i{display:block;height:100%;background:var(--accent);border-radius:4px}
.bar i.warn{background:var(--warn)}
/* карточки */
.card{background:var(--surface);border-radius:var(--r);box-shadow:var(--sh);min-width:0}
.card-h{display:flex;align-items:center;gap:8px;padding:16px 18px 6px;flex-wrap:wrap}
.card-h h3{margin:0;font-size:17px;font-weight:650}
.card-b{padding:8px 18px 18px;display:grid;gap:12px}
.btn{border:1px solid var(--line);background:var(--surface);border-radius:var(--rc);padding:8px 12px;font-size:13.5px;font-weight:600;display:inline-flex;align-items:center;gap:6px;white-space:nowrap;color:var(--text)}
.btn svg{width:16px;height:16px}
.btn.primary{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
.btn.soft{background:var(--accent-soft);border-color:transparent;color:var(--accent)}
.btn.danger{color:var(--bad)}
.spin{animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
/* серверы */
.grp{padding:6px 18px 16px;display:grid;gap:10px}
.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
@media (min-width:560px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
@media (min-width:980px){.grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media (min-width:1120px){.grid{grid-template-columns:repeat(3,minmax(0,1fr))}}
.cty{display:flex;align-items:center;gap:10px;padding:10px 12px;border-radius:16px;border:1px solid var(--line);background:var(--surface);text-align:left;min-width:0;transition:border-color .15s,background .15s;color:var(--text)}
.cty:hover{border-color:var(--accent)}
.cty[aria-pressed="true"]{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
.fl{width:34px;height:34px;border-radius:50%;overflow:hidden;flex:0 0 auto;box-shadow:0 0 0 1px var(--line);background:var(--surface-2);display:grid;place-items:center;font-size:11px;font-weight:700;color:var(--muted)}
.cty[aria-pressed="true"] .fl{box-shadow:0 0 0 2px rgba(255,255,255,.9)}
.fl svg,.hflag svg{width:100%;height:100%;display:block}
.tx{min-width:0;display:grid}
.tx b{font-weight:600;font-size:14.5px;overflow:hidden;text-overflow:ellipsis;white-space:pre}
.tx span{font-family:var(--f-mono);font-size:11.5px;color:var(--muted)}
.tx span.g,.tx i.g{color:var(--ok)}.tx span.y,.tx i.y{color:var(--warn)}.tx span.r,.tx i.r{color:var(--bad)}
.tx i{font-style:normal;white-space:nowrap}.tx i.bk{opacity:.85}
.tx .pg{display:grid;gap:1px}
.tx .pg i{display:flex;gap:6px;white-space:nowrap}.tx .pg i u{text-decoration:none;color:var(--muted);min-width:62px}
.cty[aria-pressed="true"] .tx .pg i u{color:rgba(255,255,255,.7)}
.tx .pg i.cur{font-weight:700}
.vt{display:grid;gap:6px;padding:10px 12px;border-radius:14px;background:rgba(255,255,255,.12)}
.vt-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;font-size:13.5px}
.vt-row b{min-width:78px;font-weight:600;opacity:.9}
.vt-row span{font-variant-numeric:tabular-nums;white-space:nowrap}
.vt-row span.bad{color:#FFC9C9}
.vt-act{display:flex;align-items:center;gap:10px;margin-top:2px}
.vt-act button{border:0;border-radius:10px;padding:7px 12px;font-weight:600;font-size:13px;background:rgba(255,255,255,.95);color:#0D1626}
.vt-act small{opacity:.75;font-size:12px}
.cty[aria-pressed="true"] .tx i{color:rgba(255,255,255,.85)}
.cty[aria-pressed="true"] .tx span{color:rgba(255,255,255,.85)}
@media (max-width:520px){
  .grp{padding:4px 12px 14px}.card-h{padding:14px 14px 6px}
  .grid{gap:6px}.cty{padding:9px 10px;gap:8px}.fl{width:30px;height:30px}
  .tx b{white-space:pre-wrap;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;line-height:1.25;font-size:14px}
  .card-h .btn .bl{display:none}.card-h .btn{padding:8px 10px}
}
.grid.game .cty{padding:8px 10px;border-radius:14px}
.grid.game .fl{width:26px;height:26px;font-size:9px}
.grid.game .tx b{font-size:13.5px}
.foot{display:flex;align-items:center;gap:8px;padding:12px 18px;border-top:1px solid var(--line);color:var(--muted);font-family:var(--f-mono);font-size:11.5px;letter-spacing:.04em;text-transform:uppercase}
.foot svg{width:15px;height:15px}
.empty{padding:22px 18px;display:grid;gap:12px}
.empty p{margin:0;color:var(--muted);font-size:14px}
/* вкладки */
.tabs{display:none}
@media (min-width:980px){.tabs{display:flex;gap:4px;padding:4px;border-radius:14px;background:var(--surface);box-shadow:var(--sh)}
.tabs button{flex:1;border:0;background:transparent;padding:9px 10px;border-radius:10px;font-weight:600;font-size:14px;color:var(--muted);display:inline-flex;align-items:center;justify-content:center;gap:7px}
.tabs button svg{width:17px;height:17px}
.tabs button[aria-selected="true"]{background:var(--accent-soft);color:var(--accent)}}
.nav{position:fixed;left:0;right:0;bottom:0;z-index:5;background:var(--surface);border-top:1px solid var(--line);display:grid;grid-template-columns:repeat(4,1fr);padding:6px max(8px,env(safe-area-inset-right)) calc(6px + env(safe-area-inset-bottom)) max(8px,env(safe-area-inset-left))}
.nav button{border:0;background:transparent;display:grid;justify-items:center;gap:2px;padding:6px 0;font-size:11.5px;font-weight:600;color:var(--muted)}
.nav button svg{width:22px;height:22px}
.nav button[aria-selected="true"]{color:var(--accent)}
@media (min-width:980px){.nav{display:none}.app{padding-bottom:40px}}
@media (max-width:979px){.srv-desk{display:none}}
@media (min-width:980px){.srv-mob{display:none!important}}
/* устройства/сайты/настройки */
.seg{display:inline-flex;padding:3px;border-radius:12px;background:var(--surface-2)}
.seg button{border:0;background:transparent;padding:7px 12px;border-radius:9px;font-size:13px;font-weight:600;color:var(--muted)}
.seg button[aria-pressed="true"]{background:var(--surface);color:var(--text);box-shadow:0 1px 2px rgba(13,22,38,.1)}
.hint{font-size:13px;color:var(--muted);margin:0}
.dev{display:grid;grid-template-columns:38px minmax(0,1fr) auto;gap:12px;align-items:center;padding:10px 0;border-top:1px solid var(--line)}
.dev:first-child{border-top:0}
.dev.offline{opacity:.6}
.dev .ic{width:38px;height:38px;border-radius:12px;background:var(--surface-2);display:grid;place-items:center}
.dev .ic svg{width:19px;height:19px}
.dev .n{font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.dev .d{font-family:var(--f-mono);font-size:11.5px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.sw{position:relative;width:44px;height:26px;border-radius:999px;background:var(--off);border:0;transition:background .15s;flex:0 0 auto;padding:0}
.sw::after{content:"";position:absolute;top:3px;left:3px;width:20px;height:20px;border-radius:50%;background:#fff;box-shadow:0 1px 3px rgba(0,0,0,.25);transition:transform .15s}
.sw[aria-checked="true"]{background:var(--accent)}
.sw[aria-checked="true"]::after{transform:translateX(18px)}
.add{display:flex;gap:8px}
.inp{flex:1;min-width:0;height:42px;border:1px solid var(--line);border-radius:var(--rc);background:var(--surface-2);padding:0 12px;color:var(--text)}
.doms{display:flex;flex-wrap:wrap;gap:6px}
.dom{display:inline-flex;align-items:center;gap:6px;padding:6px 6px 6px 11px;border-radius:10px;background:var(--surface-2);font-size:13.5px}
.dom button{border:0;background:transparent;width:22px;height:22px;border-radius:6px;display:grid;place-items:center;color:var(--muted);padding:0}
.dom button:hover{background:var(--bad-soft);color:var(--bad)}
.dom button svg{width:14px;height:14px}
.auto{display:flex;gap:10px;align-items:flex-start;padding:12px;border-radius:14px;background:var(--surface-2);font-size:13px}
.auto svg{width:18px;height:18px;flex:0 0 auto;color:var(--ok);margin-top:1px}
.row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:12px;align-items:center;padding:12px 0;border-top:1px solid var(--line)}
.row.first{border-top:0}
.row .t{font-weight:600}
.row .s{font-size:12.5px;color:var(--muted);overflow-wrap:anywhere}
.presets{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.preset{border:1px solid var(--line);background:var(--surface);border-radius:14px;padding:10px 12px;text-align:left;display:grid;gap:2px;color:var(--text);min-width:0}
.preset b{font-weight:600;font-size:14px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.preset span{font-family:var(--f-mono);font-size:11px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.preset[aria-pressed="true"]{border-color:var(--accent);background:var(--accent-soft)}
.field{display:grid;gap:6px}
.field label{font-size:13px;color:var(--muted)}
.pill{display:inline-flex;align-items:center;gap:6px;padding:4px 10px;border-radius:999px;font-size:12.5px;font-weight:600;white-space:nowrap}
.pill.ok{background:var(--ok-soft);color:var(--ok)}.pill.warn{background:var(--warn-soft);color:var(--warn)}
details summary{cursor:pointer;color:var(--accent);font-weight:600;font-size:13.5px;list-style:none}
details summary::-webkit-details-marker{display:none}
.copy{font-family:var(--f-mono);font-size:12px}
/* журнал */
.sheet{position:fixed;inset:0;z-index:20;background:rgba(8,12,20,.45);display:grid;place-items:end center;padding:0}
.sheet .box{width:min(900px,100%);max-height:86vh;background:var(--surface);border-radius:22px 22px 0 0;display:grid;grid-template-rows:auto minmax(0,1fr);box-shadow:var(--sh)}
@media (min-width:980px){.sheet{place-items:center;padding:24px}.sheet .box{border-radius:22px}}
.sheet pre{margin:0;padding:14px 18px 18px;overflow:auto;font-family:var(--f-mono);font-size:11.5px;line-height:1.5;white-space:pre-wrap;word-break:break-word;color:var(--text)}
/* призрак при применении (как в 4.24, в цветах 4.26) */
.ghost-ov{position:fixed;inset:0;z-index:40;display:grid;place-items:center;background:var(--ov);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.ghost-ov .g{width:56px;height:65px;color:#000;animation:ghostFloat 2s ease-in-out infinite}
.ghost-ov .g svg{width:100%;height:100%;display:block}
@keyframes ghostFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-16px)}}
.toast{position:fixed;left:50%;bottom:calc(84px + env(safe-area-inset-bottom));transform:translateX(-50%) translateY(20px);background:var(--text);color:var(--bg);padding:10px 16px;border-radius:12px;font-size:14px;font-weight:600;opacity:0;transition:all .2s;pointer-events:none;z-index:30;max-width:calc(100% - 32px)}
.toast.show{opacity:1;transform:translateX(-50%)}
.toast.err{background:var(--bad);color:#fff}
@media (min-width:980px){.toast{bottom:28px}}
</style>
</head>
<body>
<div class="app">
  <header class="top">
    <div class="logo" aria-hidden="true"><svg viewBox="0 0 55 64" aria-hidden="true"><path fill="currentColor" d="M20.3394 0.282413C24.7689 -0.413414 29.3055 0.183039 33.4048 2.00019C39.9365 4.89567 44.7801 10.6272 46.5464 17.55L54.3745 48.2316L54.4556 48.6008C55.2462 52.2015 52.8223 55.719 49.1763 56.2629L48.8218 56.3156C47.132 56.5676 45.4194 56.0375 44.1675 54.8752L43.6822 54.424C42.9542 53.7479 41.9969 53.3723 41.0034 53.3723C39.3999 53.3723 37.9567 54.3458 37.355 55.8322L36.5064 57.9279C35.8183 59.627 34.5159 61.0046 32.8579 61.7863L31.9556 62.2111C30.3098 62.987 28.4816 63.2917 26.6734 63.091L24.3941 62.8371C22.6926 62.6482 21.1447 61.7628 20.1197 60.3918L17.7915 57.2785C16.2735 55.2483 13.1828 55.4013 11.8726 57.5715L11.521 58.0441C9.93168 60.1782 7.17614 61.0901 4.62746 60.3254L4.41164 60.26C1.95424 59.5228 0.23588 57.3069 0.133323 54.7434L0.967307 39.5168C1.0964 37.1609 1.04949 34.7984 0.827659 32.4494L0.0991429 24.7316C-0.16165 21.9702 0.0932144 19.1844 0.85012 16.5158C3.23394 8.11217 10.2872 1.86163 18.9165 0.506046L20.3394 0.282413ZM10.1597 18.925C7.65146 18.925 5.6177 21.7647 5.6177 25.2668C5.61785 28.7687 7.65156 31.6076 10.1597 31.6076C12.6676 31.6073 14.7005 28.7685 14.7007 25.2668C14.7007 21.7649 12.6677 18.9253 10.1597 18.925ZM23.355 18.925C20.8469 18.925 18.8132 21.764 18.813 25.2658C18.813 28.7679 20.8468 31.6076 23.355 31.6076C25.8631 31.6075 27.896 28.7678 27.896 25.2658C27.8958 21.7641 25.863 18.9251 23.355 18.925Z"/></svg></div>
    <div><h1>RIFT</h1><div class="sub" id="topSub">Панель роутера</div></div>
    <div class="sp"></div>
  </header>

  <div class="cols">
    <div class="stack">
      <section class="hero" aria-label="Подключение">
        <span class="live off" id="hLive"><i></i><span>Загрузка…</span></span>
        <div class="hero-main">
          <div class="hflag" id="hFlag"></div>
          <div style="min-width:0"><h2 id="hName">—</h2><div class="meta num" id="hMeta"></div></div>
        </div>
        <div class="hero-actions">
          <div class="seg-h" id="hRoute" role="group" aria-label="Маршрут" hidden></div>
          <div class="seg-h" id="hProto" role="group" aria-label="Протокол" hidden></div>
          <button class="hbtn" id="reconBtn" hidden>Переподключить</button>
        </div>
        <div class="vt" id="vt" hidden></div>
      </section>

      <div class="tiles">
        <div class="tile">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="3" y="5" width="18" height="16" rx="3"/><path d="M3 10h18M8 3v4M16 3v4"/></svg>
          <div style="min-width:0"><div class="l" id="tSubName">Подписка</div><div class="v" id="tSubVal">—</div></div>
        </div>
        <div class="tile">
          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 17l6-6 4 4 8-8"/><path d="M15 7h6v6"/></svg>
          <div style="min-width:0"><div class="l" id="tTrName">Трафик</div><div class="v num" id="tTrVal">—</div><div class="bar" id="tTrBar" hidden><i></i></div></div>
        </div>
      </div>

      <section class="card srv-desk" aria-label="Серверы"><div data-srvcard></div></section>
    </div>

    <div class="stack">
      <div class="tabs" role="tablist" id="tabs"></div>
      <section class="card srv-mob" data-pane="servers" aria-label="Серверы"><div data-srvcard></div></section>

      <section class="card" data-pane="devices" aria-label="Устройства">
        <div class="card-h"><h3>Устройства</h3><span class="sp"></span>
          <div class="seg" role="group" aria-label="Как узнавать устройство" id="modeSeg"><button data-mode="mac" aria-pressed="true">По MAC</button><button data-mode="ip" aria-pressed="false">По IP</button></div>
        </div>
        <div class="card-b">
          <p class="hint" id="modeHint"></p>
          <div id="devs"></div>
        </div>
      </section>

      <section class="card" data-pane="sites" aria-label="Сайты">
        <div class="card-h"><h3>Сайты через VPN</h3></div>
        <div class="card-b">
          <p class="hint">Остальные устройства открывают через VPN только эти сайты и заблокированные сервисы. Поддомены включаются сами.</p>
          <form class="add" id="addForm"><input class="inp" id="domIn" placeholder="example.com" autocomplete="off" autocapitalize="off" aria-label="Домен"><button class="btn primary" type="submit">Добавить</button></form>
          <div class="doms" id="doms"></div>
          <div class="auto"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"><path d="M20 6L9 17l-5-5"/></svg><div><b>Уже работают сами:</b> Telegram, YouTube, Instagram, Discord, Google Play и другие заблокированные сервисы. Российские сайты и торрент-трекеры идут напрямую, мимо VPN.</div></div>
        </div>
      </section>

      <section class="card" data-pane="settings" aria-label="Настройки">
        <div class="card-h"><h3>Настройки</h3></div>
        <div class="card-b">
          <div class="row first"><div style="min-width:0"><div class="t">Подписка</div><div class="s copy" id="subMasked">—</div></div><button class="btn" id="subEditBtn">Изменить</button></div>
          <form class="add" id="subForm" hidden><input class="inp" id="subIn" placeholder="https://sub.rift.monster/…" autocomplete="off" autocapitalize="off" aria-label="Ссылка подписки"><button class="btn primary" type="submit">Сохранить</button></form>
          <div>
            <div class="row"><div><div class="t">DNS</div><div class="s">Через какой сервер роутер узнаёт адреса сайтов</div></div><div class="seg" role="group" aria-label="Протокол DNS" id="dnsType"><button data-t="udp">UDP</button><button data-t="doh">DoH</button><button data-t="dot">DoT</button></div></div>
            <div class="presets" id="dnsPresets"></div>
            <details style="margin-top:10px" id="dnsMore"><summary>Свой сервер и bootstrap</summary>
              <form id="dnsForm" style="display:grid;gap:10px;margin-top:10px">
                <div class="field"><label for="dnsSrv">DNS-сервер</label><input class="inp" id="dnsSrv" autocomplete="off"></div>
                <div class="field"><label for="dnsBoot">Bootstrap DNS (IPv4)</label><input class="inp" id="dnsBoot" autocomplete="off"></div>
                <div><button class="btn primary" type="submit">Сохранить DNS</button></div>
              </form>
            </details>
          </div>
          <div class="row"><div style="min-width:0"><div class="t">Обновление панели</div><div class="s" id="updText">—</div></div><div id="updAct"><button class="btn" id="updCheck">Проверить</button></div></div>
          <div class="row"><div style="min-width:0"><div class="t">Диагностика</div><div class="s" id="diagText">Отправляет нам технический журнал роутера на 24 часа, чтобы найти причину сбоя</div></div><button class="sw" role="switch" aria-checked="false" aria-label="Диагностика" id="diagSw"></button></div>
          <div class="row"><div style="min-width:0"><div class="t">ID роутера</div><div class="s copy num" id="hwid">—</div></div><button class="btn" id="copyHwid">Скопировать</button></div>
          <div class="row"><div><div class="t">Журнал работы</div><div class="s">Для техподдержки</div></div><button class="btn" id="logsBtn">Открыть</button></div>
          <div class="row"><div style="min-width:0"><div class="t">Об устройстве</div><div class="s num" id="about">—</div></div></div>
          <details><summary>Источник обновлений</summary>
            <form class="add" id="updUrlForm" style="margin-top:10px"><input class="inp" id="updUrl" autocomplete="off" aria-label="Адрес обновлений"><button class="btn" type="submit">Сохранить</button></form>
          </details>
        </div>
      </section>
    </div>
  </div>
</div>

<nav class="nav" role="tablist" id="nav"></nav>
<div class="sheet" id="sheet" hidden><div class="box" role="dialog" aria-label="Журнал работы">
  <div class="card-h" style="padding-bottom:10px;border-bottom:1px solid var(--line)"><h3>Журнал работы</h3><span class="sp"></span><button class="btn" id="logsCopy">Скопировать</button><button class="btn" id="logsRefresh">Обновить</button><button class="btn" id="logsClose">Закрыть</button></div>
  <pre id="logsPre">Загрузка…</pre>
</div></div>
<div class="ghost-ov" id="ghost" hidden role="status"><div class="g"><svg viewBox="0 0 55 64" aria-hidden="true"><path fill="currentColor" d="M20.3394 0.282413C24.7689 -0.413414 29.3055 0.183039 33.4048 2.00019C39.9365 4.89567 44.7801 10.6272 46.5464 17.55L54.3745 48.2316L54.4556 48.6008C55.2462 52.2015 52.8223 55.719 49.1763 56.2629L48.8218 56.3156C47.132 56.5676 45.4194 56.0375 44.1675 54.8752L43.6822 54.424C42.9542 53.7479 41.9969 53.3723 41.0034 53.3723C39.3999 53.3723 37.9567 54.3458 37.355 55.8322L36.5064 57.9279C35.8183 59.627 34.5159 61.0046 32.8579 61.7863L31.9556 62.2111C30.3098 62.987 28.4816 63.2917 26.6734 63.091L24.3941 62.8371C22.6926 62.6482 21.1447 61.7628 20.1197 60.3918L17.7915 57.2785C16.2735 55.2483 13.1828 55.4013 11.8726 57.5715L11.521 58.0441C9.93168 60.1782 7.17614 61.0901 4.62746 60.3254L4.41164 60.26C1.95424 59.5228 0.23588 57.3069 0.133323 54.7434L0.967307 39.5168C1.0964 37.1609 1.04949 34.7984 0.827659 32.4494L0.0991429 24.7316C-0.16165 21.9702 0.0932144 19.1844 0.85012 16.5158C3.23394 8.11217 10.2872 1.86163 18.9165 0.506046L20.3394 0.282413ZM10.1597 18.925C7.65146 18.925 5.6177 21.7647 5.6177 25.2668C5.61785 28.7687 7.65156 31.6076 10.1597 31.6076C12.6676 31.6073 14.7005 28.7685 14.7007 25.2668C14.7007 21.7649 12.6677 18.9253 10.1597 18.925ZM23.355 18.925C20.8469 18.925 18.8132 21.764 18.813 25.2658C18.813 28.7679 20.8468 31.6076 23.355 31.6076C25.8631 31.6075 27.896 28.7678 27.896 25.2658C27.8958 21.7641 25.863 18.9251 23.355 18.925Z"/></svg></div></div>
<div class="toast" id="toast" role="status"></div>

<script>
"use strict";
// ---------- RPC ----------
async function api(method,params={}){
  params.method=method;
  const qs=Object.keys(params).map(k=>k+'='+encodeURIComponent(params[k])).join('&');
  const resp=await fetch('/cgi-bin/rpc?'+qs,{cache:'no-store'});
  const text=await resp.text();
  let data;try{data=JSON.parse(text)}catch(e){throw new Error('Ответ роутера не прочитан: '+text.slice(0,120))}
  if(data&&data.status==='error')throw new Error(data.msg||'Ошибка');
  return data;
}
const $=id=>document.getElementById(id);
const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const svg=p=>`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${p}</svg>`;
let tt;function toast(m,err){const t=$('toast');t.textContent=m;t.classList.toggle('err',!!err);t.classList.add('show');clearTimeout(tt);tt=setTimeout(()=>t.classList.remove('show'),err?5000:2400)}
let ghostN=0;
function ghostOn(t){ghostN++;$('ghost').setAttribute('aria-label',(t||'Применяю').replace(/\n/g,' '));$('ghost').hidden=false}
function ghostOff(){ghostN=Math.max(0,ghostN-1);if(!ghostN)$('ghost').hidden=true}
// действие с призраком: ошибки показываем тостом, призрак убираем всегда
async function withGhost(t,fn){ghostOn(t);try{return await fn()}catch(e){toast(e.message,true)}finally{ghostOff()}}
async function busy(btn,fn){if(btn){btn.disabled=true}try{return await fn()}catch(e){toast(e.message,true)}finally{if(btn)btn.disabled=false}}

// ---------- флаги (картинки: в Windows эмодзи-флагов нет) ----------
const R=(c,y,h)=>`<rect y="${y}" width="3" height="${h}" fill="${c}"/>`, H3=(a,b,c)=>R(a,0,1)+R(b,1,1)+R(c,2,1);
const V3=(a,b,c)=>`<rect width="1" height="3" fill="${a}"/><rect x="1" width="1" height="3" fill="${b}"/><rect x="2" width="1" height="3" fill="${c}"/>`;
const NC=(bg,cr)=>`<rect width="3" height="3" fill="${bg}"/><rect x=".85" width=".5" height="3" fill="${cr}"/><rect y="1.2" width="3" height=".5" fill="${cr}"/>`;
const FL={NL:H3('#AE1C28','#fff','#21468B'),DE:H3('#000','#DD0000','#FFCE00'),RU:H3('#fff','#0039A6','#D52B1E'),IN:H3('#FF9933','#fff','#138808')+'<circle cx="1.5" cy="1.5" r=".32" fill="none" stroke="#000080" stroke-width=".07"/>',
 AT:H3('#ED2939','#fff','#ED2939'),LT:H3('#FDB913','#006A44','#C1272D'),EE:H3('#0072CE','#000','#fff'),AM:H3('#D90012','#0033A0','#F2A800'),
 LV:R('#9E3039',0,1.2)+R('#fff',1.2,.6)+R('#9E3039',1.8,1.2),PL:R('#fff',0,1.5)+R('#DC143C',1.5,1.5),
 FR:V3('#0055A4','#fff','#EF4135'),IT:V3('#009246','#fff','#CE2B37'),FI:NC('#fff','#003580'),SE:NC('#006AA7','#FECC00'),NO:NC('#BA0C2F','#fff')+'<rect x=".95" width=".3" height="3" fill="#00205B"/><rect y="1.3" width="3" height=".3" fill="#00205B"/>',
 TR:'<rect width="3" height="3" fill="#E30A17"/><circle cx="1.25" cy="1.5" r=".62" fill="#fff"/><circle cx="1.4" cy="1.5" r=".5" fill="#E30A17"/><circle cx="1.98" cy="1.5" r=".16" fill="#fff"/>',
 US:'<rect width="3" height="3" fill="#fff"/><g fill="#B22234"><rect width="3" height=".23"/><rect y=".46" width="3" height=".23"/><rect y=".92" width="3" height=".23"/><rect y="1.38" width="3" height=".23"/><rect y="1.84" width="3" height=".23"/><rect y="2.3" width="3" height=".23"/><rect y="2.76" width="3" height=".24"/></g><rect width="1.4" height="1.61" fill="#3C3B6E"/>',
 GB:'<rect width="3" height="3" fill="#012169"/><path d="M0 0L3 3M3 0L0 3" stroke="#fff" stroke-width=".6"/><path d="M0 0L3 3M3 0L0 3" stroke="#C8102E" stroke-width=".2"/><rect x="1.2" width=".6" height="3" fill="#fff"/><rect y="1.2" width="3" height=".6" fill="#fff"/><rect x="1.32" width=".36" height="3" fill="#C8102E"/><rect y="1.32" width="3" height=".36" fill="#C8102E"/>',
 JP:'<rect width="3" height="3" fill="#fff"/><circle cx="1.5" cy="1.5" r=".7" fill="#BC002D"/>',CH:'<rect width="3" height="3" fill="#DA291C"/><rect x="1.3" y=".7" width=".4" height="1.6" fill="#fff"/><rect x=".7" y="1.3" width="1.6" height=".4" fill="#fff"/>',
 CA:V3('#D80621','#fff','#D80621')+'<circle cx="1.5" cy="1.5" r=".35" fill="#D80621"/>',SG:R('#EF3340',0,1.5)+R('#fff',1.5,1.5)+'<circle cx=".9" cy=".75" r=".4" fill="#fff"/><circle cx="1.05" cy=".75" r=".35" fill="#EF3340"/>',
 HK:'<rect width="3" height="3" fill="#DE2910"/><circle cx="1.5" cy="1.5" r=".55" fill="#fff"/><circle cx="1.5" cy="1.5" r=".25" fill="#DE2910"/>',ES:R('#AA151B',0,.75)+R('#F1BF00',.75,1.5)+R('#AA151B',2.25,.75),
 KZ:'<rect width="3" height="3" fill="#00AFCA"/><circle cx="1.5" cy="1.4" r=".5" fill="#FEC50C"/>',GE:'<rect width="3" height="3" fill="#fff"/><rect x="1.2" width=".6" height="3" fill="#FF0000"/><rect y="1.2" width="3" height=".6" fill="#FF0000"/>',
 AE:'<rect width="3" height="1" fill="#00732F"/><rect y="1" width="3" height="1" fill="#fff"/><rect y="2" width="3" height="1" fill="#000"/><rect width=".9" height="3" fill="#FF0000"/>',
 EU:'<rect width="3" height="3" fill="#003399"/><circle cx="1.5" cy="1.5" r=".8" fill="none" stroke="#FFCC00" stroke-width=".18" stroke-dasharray=".12 .3"/>'};
const flag=c=>FL[c]?`<svg viewBox="0 0 3 3" preserveAspectRatio="xMidYMid slice" aria-hidden="true">${FL[c]}</svg>`:esc(c||'•');

// ---------- состояние ----------
let NODES=null, MS={}, AVAIL={}, NET=null, DNS=null;
let tab='servers';
const wide=()=>matchMedia('(min-width:980px)').matches;
// группы для отображения: страны (с их «Запасным») и игровые, порядок — как в подписке
function groups(){
  const all=(NODES&&NODES.servers)||[]; const byKey={}; all.forEach(s=>byKey[s.key]=s);
  const mains=[],games=[];
  all.forEach(s=>{if(s.kind==='game')games.push(s);else if(s.kind==='backup'){const p=byKey[s.parent];if(p)p._backup=s;else mains.push(s)}else mains.push(s)});
  return {mains,games,byKey};
}
function activeInfo(){
  const g=groups(); const ak=NODES&&NODES.active_key; const s=ak&&g.byKey[ak];
  if(!s)return {g,entry:null,main:null,route:'main'};
  if(s.kind==='backup'){const p=g.byKey[s.parent];return {g,entry:s,main:p||s,route:'backup'}}
  return {g,entry:s,main:s,route:'main'};
}
const pingOf=k=>AVAIL[k]==='fail'?null:(MS[k]!=null?MS[k]:undefined);
const pingCls=p=>p===null?'r':p===undefined?'':p<80?'g':p<160?'y':'';
const pingTxt=p=>p===null?'нет ответа':p===undefined?'':p+' мс';

// ---------- серверы ----------
function srvCard(){
  const {g,main,entry}=activeInfo();
  if(!NODES||!NODES.servers||!NODES.servers.length){
    return `<div class="card-h"><h3>Серверы</h3></div><div class="empty"><p>${NODES&&NODES.updated&&NODES.updated!=='Never'?'В подписке нет серверов для роутера. Проверьте срок подписки и нажмите «Обновить».':'Вставьте ссылку подписки RIFT — список серверов появится здесь.'}</p>
      <form class="add" data-subform><input class="inp" placeholder="https://sub.rift.monster/…" autocomplete="off" autocapitalize="off" aria-label="Ссылка подписки"><button class="btn primary" type="submit">Сохранить</button></form></div>`;
  }
  const {route}=activeInfo();
  const tile=(s,game)=>{const on=main&&(s.key===main.key);const p=pingOf(s.key);const b=s._backup;let sub;
    if(!b){sub=`<span class="${pingCls(p)}">${pingTxt(p)||(game?'HY2':'&nbsp;')}</span>`}
    else{const bp=pingOf(b.key);const line=(lab,v,cur)=>`<i class="${pingCls(v)}${cur?' cur':''}"><u>${lab}</u>${v===undefined?'…':v===null?'нет':v+' мс'}</i>`;
      sub=`<span class="pg">${line('Основной',p,on&&route==='main')}${line(esc(b.name),bp,on&&route==='backup')}</span>`}
    return `<button class="cty" data-key="${esc(s.key)}" aria-pressed="${!!on}"><span class="fl">${flag(s.flag)}</span><span class="tx"><b>${esc(s.name)}</b>${sub}</span></button>`};
  return `<div class="card-h"><h3>Серверы</h3><span class="sp"></span>
      ${checkAvail.busy?`<button class="btn" disabled aria-label="Проверяю доступность">${svg('<path d="M21 12a9 9 0 1 1-6.2-8.6"/>').replace('<svg','<svg class="spin"')}<span class="bl">Проверяю…</span></button>`
        :`<button class="btn" data-act="check" aria-label="Проверить доступность">${svg('<path d="M22 12h-4l-3 9L9 3l-3 9H2"/>')}<span class="bl">Проверить</span></button>`}
      <button class="btn soft" data-act="update" aria-label="Обновить подписку">${svg('<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>')}<span class="bl">Обновить</span></button></div>
    <div class="grp"><div class="grid">${g.mains.map(s=>tile(s,false)).join('')}</div></div>
    ${g.games.length?`<div class="grp"><div class="grid game">${g.games.map(s=>tile(s,true)).join('')}</div></div>`:''}
    <div class="foot">${svg('<path d="M21 12a9 9 0 1 1-3-6.7L21 8"/><path d="M21 3v5h-5"/>')}<span>${esc(updatedText())}</span></div>`;
}
function updatedText(){const e=NODES&&NODES.updated_epoch;if(!e)return 'Подписка ещё не загружена';const d=Math.max(0,Math.round(Date.now()/1000-e));
  const t=d<90?'только что':d<3600?Math.round(d/60)+' мин назад':d<86400?Math.round(d/3600)+' ч назад':Math.round(d/86400)+' дн назад';return 'Подписка обновлена '+t}

function renderHero(){
  const {main,entry,route}=activeInfo(); const run=NODES&&NODES.running;
  const live=$('hLive'); live.classList.toggle('off',!(run&&entry)); live.querySelector('span').textContent=entry?(run?'Подключено':'VPN не запущен'):'Сервер не выбран';
  $('hFlag').innerHTML=main?flag(main.flag):''; $('hName').textContent=main?main.name:'Выберите сервер';
  const tr=NODES&&NODES.active_transport; const lab=entry&&(entry.variants.find(v=>v.transport===tr)||{}).label;
  const p=entry?pingOf(entry.key):undefined;
  $('hMeta').textContent=entry?[route==='backup'?entry.name:'',lab||tr,pingTxt(p)].filter(Boolean).join(' · '):'';
  const hr=$('hRoute'), hp=$('hProto');
  const b=main&&main._backup;
  hr.hidden=!(main&&b&&main.kind!=='game');
  if(!hr.hidden) hr.innerHTML=`<button data-route="main" aria-pressed="${route==='main'}">Основной</button><button data-route="backup" aria-pressed="${route==='backup'}">${esc(b.name)}</button>`;
  renderVT(main);
  const vs=(main&&route==='main')?main.variants:[];
  hp.hidden=vs.length<2; if(!hp.hidden) hp.innerHTML=vs.map(v=>`<button data-tr="${esc(v.transport)}" aria-pressed="${v.transport===tr}">${esc(v.label)}</button>`).join('');
  const rb=$('reconBtn'); rb.hidden=!entry; rb.classList.toggle('attn',!!(entry&&entry.needs_reconnect));
  rb.textContent=entry&&entry.needs_reconnect?'Переподключить · данные сервера обновились':'Переподключить';
}
// честная проверка протоколов выбранной страны (RPC test_variants): результаты храним по стране
const VT={}; let vtBusy=false;
function renderVT(main){
  const box=$('vt'); if(!main||main.kind==='game'){box.hidden=true;return}
  const b=main._backup, res=VT[main.key];
  const cell=(e,v)=>{if(!res)return `<span>${esc(v.label)} —</span>`;const r=res.find(x=>x.key===e.key&&x.transport===v.transport);
    return r?(r.ok?`<span>${esc(v.label)} ${r.ms} мс</span>`:`<span class="bad">${esc(v.label)} не работает</span>`):`<span>${esc(v.label)} —</span>`};
  const row=(lab,e)=>`<div class="vt-row"><b>${esc(lab)}</b>${e.variants.map(v=>cell(e,v)).join('')}</div>`;
  box.innerHTML=row('Основной',main)+(b?row(b.name,b):'')+`<div class="vt-act"><button id="vtBtn" ${vtBusy?'disabled':''}>${vtBusy?svg('<path d="M21 12a9 9 0 1 1-6.2-8.6"/>').replace('<svg','<svg class="spin" style="width:14px;height:14px;vertical-align:-2px;margin-right:6px"')+'Проверяю…':'Проверить протоколы'}</button><small>${vtBusy?'≈ 10 секунд, VPN не прерывается':res?'отклик через VPN':'проверит каждый вариант по-настоящему'}</small></div>`;
  box.hidden=false;
}
document.addEventListener('click',async e=>{if(!e.target.closest('#vtBtn')||vtBusy)return;const {main}=activeInfo();if(!main)return;
  vtBusy=true;renderVT(main);
  try{const r=await api('test_variants',{key:main.key});VT[main.key]=r.results||[];const bad=VT[main.key].filter(x=>!x.ok).length;toast(bad?`Не работает вариантов: ${bad}`:'Все варианты работают')}
  catch(err){toast(err.message,true)}finally{vtBusy=false;renderVT(activeInfo().main)}});

function renderTiles(){
  const n=NODES||{}; $('tSubName').textContent=n.sub_title||'Подписка';
  const ts=+n.sub_expire_ts||0, now=Date.now()/1000;
  if(ts>0){const days=Math.floor((ts-now)/86400);const d=new Date(ts*1000);const ds=('0'+d.getDate()).slice(-2)+'.'+('0'+(d.getMonth()+1)).slice(-2)+'.'+d.getFullYear();
    $('tSubVal').innerHTML=ts<now?'<span style="color:var(--bad)">Истекла</span>':`до ${ds} <small>· ${days} дн</small>`}
  else $('tSubVal').textContent=n.updated_epoch?'Без срока':'—';
  const used=+n.sub_used_gb||0,total=+n.sub_total_gb||0, bar=$('tTrBar');
  if(!n.updated_epoch){$('tTrName').textContent='Трафик';$('tTrVal').textContent='—';bar.hidden=true;return}
  const fmt=g=>g>=1000?(g/1024).toFixed(2).replace('.',',')+' ТБ':(Math.round(g*10)/10).toString().replace('.',',')+' ГБ';
  if(total>0){$('tTrName').textContent='Трафик';$('tTrVal').innerHTML=`${fmt(used)} <small>из ${fmt(total)}</small>`;bar.hidden=false;const pc=Math.min(100,used/total*100);bar.firstChild.style.width=pc+'%';bar.firstChild.className=pc>85?'warn':''}
  else{$('tTrName').textContent='Трафик · безлимит';$('tTrVal').innerHTML=`${fmt(used)} <small>из ∞</small>`;bar.hidden=true}
}
function renderServers(){document.querySelectorAll('[data-srvcard]').forEach(b=>b.innerHTML=srvCard());renderHero();renderTiles()}

// Совместимость со списком 4.24 (если первая пересборка после обновления ещё не прошла):
// убираем XHTTP-заглушки и достаём флаг из эмодзи в начале имени.
function normNodes(n){(n.servers||[]).forEach(s=>{s.variants=(s.variants||[]).filter(v=>!v.unsupported);
  if(!s.flag){const cps=Array.from(String(s.name||''));const a=cps[0]&&cps[0].codePointAt(0),b=cps[1]&&cps[1].codePointAt(0);
    if(a>=0x1F1E6&&a<=0x1F1FF&&b>=0x1F1E6&&b<=0x1F1FF){s.flag=String.fromCharCode(a-0x1F1E6+65,b-0x1F1E6+65);s.name=cps.slice(2).join('').trim()}}});
  n.servers=(n.servers||[]).filter(s=>s.variants.length);return n}
async function loadNodes(){NODES=normNodes(await api('get_nodes'));renderServers()}
async function checkAvail(btn){
  if(!NODES||!NODES.servers||!NODES.servers.length)return;
  if(btn){checkAvail.busy=true;renderServers();btn=null}   // кнопка перерисуется в состоянии «Проверяю…»
  const done=()=>{if(checkAvail.busy){checkAvail.busy=false;renderServers()}};
  try{await busy(null,async()=>{const r=await api('check_availability');const av=r.avail||{};const vals=Object.values(av);
    // никто не ответил = у роутера нет связи/DNS (напр. сразу после обновления или переподключения), а не у серверов — не красим всё
    if(vals.length&&!vals.some(v=>v==='ok')){AVAIL={};MS={};renderServers();if(checkAvail.busy)toast('Не удалось проверить — у роутера сейчас нет связи, попробуйте через минуту',true);else if((checkAvail.n=(checkAvail.n||0)+1)<=3)setTimeout(()=>checkAvail(),20000);return}
    checkAvail.n=0;AVAIL=av;MS=r.ms||{};renderServers();
    if(checkAvail.busy){const ok=vals.filter(v=>v==='ok').length;toast(`Отвечают ${ok} из ${vals.length}`)}})}finally{done()}
}
async function connect(key,tr,label){
  const s=groups().byKey[key];if(!s)return;
  if(!tr||!s.variants.some(v=>v.transport===tr))tr=s.variants[0]&&s.variants[0].transport;
  await withGhost('Подключаю\n'+(label||s.name),async()=>{const r=await api('apply',{key,transport:tr});await loadNodes();setTimeout(()=>checkAvail(),6000);toast('Подключено: '+(label||s.name)+(r.transport?' · '+(s.variants.find(v=>v.transport===r.transport)||{}).label:''))});
}
async function updateSubs(btn,url){
  await withGhost(url?'Загружаю подписку':'Обновляю подписку',async()=>{const p=url?{url}:{};const r=await api('update_subs',p);await loadNodes();loadSub();toast((r.changed===false?'Подписка без изменений':'Подписка обновлена')+' · серверов: '+(r.count||0));checkAvail()});
}

// ---------- вкладки ----------
const TABS=[['servers','Серверы','<path d="M4 6h16M4 12h16M4 18h10"/>'],['devices','Устройства','<rect x="3" y="4" width="18" height="12" rx="2"/><path d="M8 20h8M12 16v4"/>'],['sites','Сайты','<circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/>'],['settings','Настройки','<circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/>']];
function renderTabs(){
  if(wide()&&tab==='servers')tab='devices';
  $('tabs').innerHTML=TABS.slice(1).map(([k,n,p])=>`<button role="tab" data-tab="${k}" aria-selected="${tab===k}">${svg(p)}${n}</button>`).join('');
  $('nav').innerHTML=TABS.map(([k,n,p])=>`<button role="tab" data-tab="${k}" aria-selected="${tab===k}">${svg(p)}${n}</button>`).join('');
  document.querySelectorAll('[data-pane]').forEach(p=>p.hidden=p.dataset.pane!==tab);
}
try{const mq=matchMedia('(min-width:980px)'),fn=()=>{if(!wide()&&tab==='devices'&&!userTab)tab='servers';renderTabs()};mq.addEventListener?mq.addEventListener('change',fn):mq.addListener(fn)}catch(_){}
let userTab=false;

// ---------- устройства ----------
const DI={phone:'<rect x="7" y="2" width="10" height="20" rx="2"/><path d="M11 18h2"/>',pc:'<rect x="4" y="5" width="16" height="11" rx="2"/><path d="M2 19h20"/>',tv:'<rect x="3" y="5" width="18" height="12" rx="2"/><path d="M8 21h8"/>',game:'<rect x="2" y="7" width="20" height="10" rx="5"/><path d="M7 12h3M8.5 10.5v3M15 11h.01M17 13h.01"/>',other:'<circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/>'};
function devIcon(n){n=(n||'').toLowerCase();if(/iphone|android|pixel|galaxy|redmi|xiaomi|phone|huawei|honor|poco/.test(n))return DI.phone;if(/ps[345]|playstation|xbox|nintendo|switch/.test(n))return DI.game;if(/tv|lg|samsung|bravia|webos|tizen|mibox|chromecast|stick/.test(n))return DI.tv;if(/mac|book|pc|desktop|laptop|win|ubuntu/.test(n))return DI.pc;return DI.other}
function renderDevs(){
  if(!NET){$('devs').innerHTML='<p class="hint">Загрузка…</p>';return}
  const mode=NET.mode==='ip'?'ip':'mac';
  $('modeSeg').querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.mode===mode));
  $('modeHint').innerHTML=mode==='mac'?'Включённое устройство пускает <b>весь</b> свой трафик через VPN, а не только сайты из списка. Узнаём по MAC — работает, даже если роутер выдал ему новый IP.':'Узнаём по IP-адресу — для приставок и ТВ, которых не видно по MAC. Закрепите за устройством постоянный IP в настройках роутера, иначе после смены адреса VPN для него выключится.';
  const rows=(NET.clients||[]).map(c=>({...c,online:true})).concat((NET.vpn_offline||[]).map(c=>({...c,enabled:true,online:false})));
  if(!rows.length){$('devs').innerHTML='<p class="hint">Роутер пока не видит устройств в сети.</p>';return}
  $('devs').innerHTML=rows.map((c,i)=>`<div class="dev${c.online?'':' offline'}"><div class="ic">${svg(devIcon(c.name))}</div><div style="min-width:0"><div class="n">${esc(c.name||c.ip||c.mac)}</div><div class="d num">${esc([c.online?c.ip:'не в сети',c.mac].filter(Boolean).join(' · '))}</div></div><button class="sw" role="switch" aria-checked="${!!c.enabled}" aria-label="${esc(c.name||c.ip)}: весь трафик через VPN" data-dev="${i}"></button></div>`).join('');
  renderDevs.rows=rows;
}
async function loadNet(){NET=await api('get_network');renderDevs();renderDoms()}

// ---------- сайты ----------
function renderDoms(){const d=(NET&&NET.domains)||[];$('doms').innerHTML=d.length?d.map(x=>`<span class="dom">${esc(x)}<button aria-label="Убрать ${esc(x)}" data-rm="${esc(x)}">${svg('<path d="M18 6L6 18M6 6l12 12"/>')}</button></span>`).join(''):'<p class="hint">Список пуст.</p>'}

// ---------- настройки ----------
function renderDns(){
  if(!DNS)return; const t=DNS.dns_type||'udp';
  $('dnsType').querySelectorAll('button').forEach(b=>b.setAttribute('aria-pressed',b.dataset.t===t));
  const list=(DNS.servers||[]).slice(0,6);
  $('dnsPresets').innerHTML=list.map(o=>{const m=String(o.l||o.v).match(/^(.*?)\s*\((.*)\)\s*$/);return `<button class="preset" data-dns="${esc(o.v)}" aria-pressed="${o.v===DNS.dns_server}"><b>${esc(m?m[1]:o.l||o.v)}</b><span>${esc(m?m[2]:o.v)}</span></button>`}).join('');
  $('dnsSrv').value=DNS.dns_server||''; $('dnsBoot').value=DNS.bootstrap_dns_server||'77.88.8.8';
}
async function saveDns(type,server,boot,btn){
  await withGhost('Применяю DNS',async()=>{const r=await api('set_dns_settings',{dns_type:type,dns_server:server,bootstrap:boot||DNS.bootstrap_dns_server||'77.88.8.8'});DNS.dns_type=r.dns_type;DNS.dns_server=r.dns_server;DNS.bootstrap_dns_server=r.bootstrap_dns_server;renderDns();toast('DNS сохранён')});
}
async function loadSub(){try{const r=await api('get_sub_url');const u=r.url||'';$('subMasked').textContent=u?u.replace(/^https?:\/\//,'').replace(/\/([^\/]{4,})$/,(m,id)=>'/••••••••'+id.slice(-4)):'не указана';$('subIn').value=u}catch(e){}}
async function loadSettings(){
  try{DNS=await api('get_dns_settings');renderDns()}catch(e){}
  try{const r=await api('get_update_url');$('updUrl').value=r.url||'';$('updText').textContent='Версия '+(r.panel_version||'?')+' · автообновление ночью';$('topSub').textContent=($('topSub').dataset.model||'Роутер')+' · панель '+(r.panel_version||'')}catch(e){}
  try{const d=await api('get_diag_mode');$('diagSw').setAttribute('aria-checked',!!d.enabled);diagText(d)}catch(e){}
  try{const h=await api('get_hwid_info');$('hwid').textContent=h.hwid||'—';$('topSub').dataset.model=h.device_model||'Роутер';
    let sb='';try{sb=(await api('get_singbox_status')).version||''}catch(e){}
    $('about').textContent=[h.device_model,'OpenWrt '+(h.os_version||''),sb&&('sing-box '+String(sb).replace(/^sing-box version /,''))].filter(Boolean).join(' · ');
    const v=$('updText').textContent.match(/Версия ([\d.]+)/);$('topSub').textContent=(h.device_model||'Роутер')+(v?' · панель '+v[1]:'')}catch(e){}
}
function diagText(d){if(d.enabled&&d.started){const left=Math.max(0,(d.started+(d.ttl||86400))-Date.now()/1000);$('diagText').textContent='Включена · выключится сама через '+Math.floor(left/3600)+' ч '+Math.floor(left%3600/60)+' мин'}else $('diagText').textContent='Отправляет нам технический журнал роутера на 24 часа, чтобы найти причину сбоя'}
async function openLogs(){$('sheet').hidden=false;$('logsPre').textContent='Загрузка…';try{const r=await api('get_logs',{lines:150});$('logsPre').textContent=r.logs||'Журнал пуст'}catch(e){$('logsPre').textContent=e.message}}

// ---------- события ----------
document.addEventListener('click',async e=>{
  const t=e.target.closest('[data-tab]');if(t){tab=t.dataset.tab;userTab=true;renderTabs();try{localStorage.setItem('rift_tab',tab)}catch(_){}return}
  const c=e.target.closest('.cty');if(c){const s=groups().byKey[c.dataset.key];if(!s)return;
    // страна, у которой основной не отвечает, а «Запасной» есть — сразу через «Запасной»
    if(s.kind!=='game'&&AVAIL[s.key]==='fail'&&s._backup){connect(s._backup.key,'tcp',s.name+' · '+s._backup.name);return}
    const cur=NODES.active_transport; connect(s.key,cur,s.name);return}
  const rt=e.target.closest('[data-route]');if(rt){const {main}=activeInfo();if(!main)return;if(rt.dataset.route==='backup'&&main._backup)connect(main._backup.key,'tcp',main.name+' · '+main._backup.name);else connect(main.key,'tcp',main.name);return}
  const tr=e.target.closest('[data-tr]');if(tr){const {main}=activeInfo();if(main)connect(main.key,tr.dataset.tr,main.name);return}
  const a=e.target.closest('[data-act]');if(a){if(a.dataset.act==='check')checkAvail(a);else updateSubs(a);return}
  const d=e.target.closest('[data-dev]');if(d){const c2=renderDevs.rows[+d.dataset.dev];const on=d.getAttribute('aria-checked')!=='true';
    const p=NET.mode==='ip'?{action:on?'add':'del',ip:c2.ip}:{action:on?'add':'del',mac:c2.mac,name:c2.name||''};
    if(NET.mode==='ip'&&!c2.ip){toast('Устройство не в сети — нет IP',true);return}
    d.disabled=true;await withGhost('Применяю для\n'+(c2.name||c2.ip),async()=>{await api('manage_vpn',p);await loadNet();toast((c2.name||c2.ip)+(on?': весь трафик через VPN':': только сайты из списка'))});d.disabled=false;return}
  const m=e.target.closest('[data-mode]');if(m){if(NET&&NET.mode===m.dataset.mode)return;await withGhost('Меняю режим устройств',async()=>{await api('set_vpn_mode',{mode:m.dataset.mode});await loadNet();toast(m.dataset.mode==='mac'?'Устройства узнаём по MAC':'Устройства узнаём по IP')});return}
  const rm=e.target.closest('[data-rm]');if(rm){const x=rm.dataset.rm;rm.disabled=true;await withGhost('Убираю '+x,async()=>{await api('manage_domain',{action:'del',domain:x});await loadNet();toast(x+' убран')});return}
  const ps=e.target.closest('[data-dns]');if(ps){saveDns(DNS.dns_type||'udp',ps.dataset.dns,null,ps);return}
  const dt=e.target.closest('#dnsType button');if(dt){if(DNS&&DNS.dns_type!==dt.dataset.t)saveDns(dt.dataset.t,DNS.dns_server||'77.88.8.8',null,dt);return}
});
document.addEventListener('submit',e=>{
  const f=e.target;e.preventDefault();
  if(f.matches('[data-subform]')){const u=f.querySelector('input').value.trim();if(!/^https?:\/\//.test(u)){toast('Вставьте ссылку целиком, начиная с https://',true);return}updateSubs(f.querySelector('button'),u);return}
  if(f.id==='subForm'){const u=$('subIn').value.trim();if(!/^https?:\/\//.test(u)){toast('Вставьте ссылку целиком, начиная с https://',true);return}updateSubs(f.querySelector('button'),u).then(()=>{f.hidden=true});return}
  if(f.id==='addForm'){let v=$('domIn').value.trim().toLowerCase().replace(/^https?:\/\//,'').replace(/[\/?#].*$/,'').replace(/^\*\./,'');
    if(!/^[a-z0-9-]+(\.[a-z0-9-]+)+$/.test(v)){toast('Введите домен, например example.com',true);return}
    if(NET&&(NET.domains||[]).includes(v)){toast(v+' уже в списке');return}
    withGhost('Добавляю '+v,async()=>{await api('manage_domain',{action:'add',domain:v});$('domIn').value='';await loadNet();toast(v+' — через VPN')});return}
  if(f.id==='dnsForm'){saveDns(DNS.dns_type||'udp',$('dnsSrv').value.trim(),$('dnsBoot').value.trim(),f.querySelector('button'));return}
  if(f.id==='updUrlForm'){busy(f.querySelector('button'),async()=>{const r=await api('set_update_url',{url:$('updUrl').value.trim()});$('updUrl').value=r.url;toast('Источник обновлений сохранён')});return}
});
$('subEditBtn').onclick=()=>{$('subForm').hidden=!$('subForm').hidden;if(!$('subForm').hidden)$('subIn').focus()};
$('reconBtn').onclick=()=>{const {entry,main}=activeInfo();if(entry)connect(entry.key,NODES.active_transport,main?main.name:entry.name)};
$('copyHwid').onclick=()=>{const v=$('hwid').textContent;const done=()=>toast('ID скопирован');try{navigator.clipboard.writeText(v).then(done,()=>{selText($('hwid'));toast('Выделено — скопируйте вручную')})}catch(_){selText($('hwid'));toast('Выделено — скопируйте вручную')}};
function selText(el){const r=document.createRange();r.selectNodeContents(el);const s=getSelection();s.removeAllRanges();s.addRange(r)}
$('diagSw').onclick=async e=>{const sw=e.currentTarget;const on=sw.getAttribute('aria-checked')!=='true';await withGhost(on?'Включаю диагностику':'Выключаю диагностику',async()=>{await api('set_diag_mode',{on:on?1:0});const d=await api('get_diag_mode');sw.setAttribute('aria-checked',!!d.enabled);diagText(d);toast(d.enabled?'Диагностика включена на 24 часа':'Диагностика выключена')})};
$('updCheck').onclick=e=>busy(e.currentTarget,async()=>{const r=await api('check_for_update');if(r.status==='update_available'){$('updText').textContent='Доступна версия '+r.remote_v+' (у вас '+r.local_v+')';$('updAct').innerHTML='<button class="btn primary" id="updDo">Обновить</button>';$('updDo').onclick=()=>{ghostOn('Обновляю панель\nстраница перезагрузится сама');api('perform_update').catch(()=>{}).finally(()=>setTimeout(()=>location.reload(),6000))}}else{toast('Панель актуальна · '+r.local_v)}});
$('logsBtn').onclick=openLogs;$('logsRefresh').onclick=openLogs;$('logsClose').onclick=()=>$('sheet').hidden=true;
$('sheet').onclick=e=>{if(e.target===$('sheet'))$('sheet').hidden=true};
$('logsCopy').onclick=()=>{const v=$('logsPre').textContent;try{navigator.clipboard.writeText(v).then(()=>toast('Журнал скопирован'),()=>{selText($('logsPre'));toast('Выделено — скопируйте вручную')})}catch(_){selText($('logsPre'));toast('Выделено — скопируйте вручную')}};
document.addEventListener('keydown',e=>{if(e.key==='Escape')$('sheet').hidden=true});

// ---------- старт ----------
(async function(){
  try{const t=localStorage.getItem('rift_tab');if(t){tab=t;userTab=true}}catch(_){}
  renderTabs();renderDevs();
  try{await loadNodes()}catch(e){toast(e.message,true);NODES={servers:[]};renderServers()}
  checkAvail();loadNet().catch(e=>toast(e.message,true));loadSub();loadSettings();
  setInterval(()=>{if(!ghostN)api('get_nodes').then(n=>{NODES=normNodes(n);renderServers()}).catch(()=>{})},30000);
})();
</script>
</body>
</html>
EOF

# Router domain entry + clean URL wrapper via main uhttpd
logi "[8/10] Настройка домена rift.lan..."
ROUTER_IP="$(uci -q get network.lan.ipaddr)"
[ -z "$ROUTER_IP" ] && ROUTER_IP="192.168.1.1"
uci -q delete dhcp.rift_panel_domain
uci set dhcp.rift_panel_domain=domain
uci set dhcp.rift_panel_domain.name='rift.lan'
uci set dhcp.rift_panel_domain.ip="$ROUTER_IP"
uci commit dhcp >/dev/null 2>&1

if [ -f /www/index.html ] && [ ! -f /etc/podkop_data/openwrt_index_backup.html ]; then
  cp /www/index.html /etc/podkop_data/openwrt_index_backup.html
fi

cat <<'EOF' > /www/index.html
<!DOCTYPE html>
<html lang="ru">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2055%2064%22%3E%3Cpath%20fill=%22%23000%22%20d=%22M20.3394%200.282413C24.7689%20-0.413414%2029.3055%200.183039%2033.4048%202.00019C39.9365%204.89567%2044.7801%2010.6272%2046.5464%2017.55L54.3745%2048.2316L54.4556%2048.6008C55.2462%2052.2015%2052.8223%2055.719%2049.1763%2056.2629L48.8218%2056.3156C47.132%2056.5676%2045.4194%2056.0375%2044.1675%2054.8752L43.6822%2054.424C42.9542%2053.7479%2041.9969%2053.3723%2041.0034%2053.3723C39.3999%2053.3723%2037.9567%2054.3458%2037.355%2055.8322L36.5064%2057.9279C35.8183%2059.627%2034.5159%2061.0046%2032.8579%2061.7863L31.9556%2062.2111C30.3098%2062.987%2028.4816%2063.2917%2026.6734%2063.091L24.3941%2062.8371C22.6926%2062.6482%2021.1447%2061.7628%2020.1197%2060.3918L17.7915%2057.2785C16.2735%2055.2483%2013.1828%2055.4013%2011.8726%2057.5715L11.521%2058.0441C9.93168%2060.1782%207.17614%2061.0901%204.62746%2060.3254L4.41164%2060.26C1.95424%2059.5228%200.23588%2057.3069%200.133323%2054.7434L0.967307%2039.5168C1.0964%2037.1609%201.04949%2034.7984%200.827659%2032.4494L0.0991429%2024.7316C-0.16165%2021.9702%200.0932144%2019.1844%200.85012%2016.5158C3.23394%208.11217%2010.2872%201.86163%2018.9165%200.506046L20.3394%200.282413ZM10.1597%2018.925C7.65146%2018.925%205.6177%2021.7647%205.6177%2025.2668C5.61785%2028.7687%207.65156%2031.6076%2010.1597%2031.6076C12.6676%2031.6073%2014.7005%2028.7685%2014.7007%2025.2668C14.7007%2021.7649%2012.6677%2018.9253%2010.1597%2018.925ZM23.355%2018.925C20.8469%2018.925%2018.8132%2021.764%2018.813%2025.2658C18.813%2028.7679%2020.8468%2031.6076%2023.355%2031.6076C25.8631%2031.6075%2027.896%2028.7678%2027.896%2025.2658C27.8958%2021.7641%2025.863%2018.9251%2023.355%2018.925Z%22/%3E%3C/svg%3E">
  <title>RIFT Panel</title>
  <style>
    :root {
      color-scheme: dark;
      --bg: #0a0e1a;
      --card: #10182a;
      --line: rgba(133, 217, 254, 0.16);
      --text: #e8edf2;
      --muted: rgba(232, 237, 242, 0.62);
      --accent-a: #0068ff;
      --accent-b: #85d9fe;
    }

    * { box-sizing: border-box; }
    html, body {
      margin: 0;
      width: 100%;
      height: 100%;
      overflow: hidden;
      background:
        radial-gradient(circle at top, rgba(133, 217, 254, 0.12), transparent 28%),
        var(--bg);
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
    }

    body.redirecting {
      display: grid;
      place-items: center;
      padding: 20px;
    }

    .shell {
      position: fixed;
      inset: 0;
      display: none;
      background: var(--bg);
    }

    .loader {
      position: absolute;
      inset: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      background:
        radial-gradient(circle at top, rgba(133, 217, 254, 0.08), transparent 28%),
        rgba(10, 14, 26, 0.96);
      z-index: 2;
      transition: opacity .25s ease;
    }

    .loader.hidden {
      opacity: 0;
      pointer-events: none;
    }

    .loader-card {
      width: min(420px, calc(100vw - 32px));
      padding: 24px;
      border-radius: 22px;
      border: 1px solid var(--line);
      background: rgba(16, 24, 42, 0.82);
      box-shadow: 0 18px 48px rgba(0, 0, 0, 0.36);
      color: var(--text);
    }

    .loader-card strong {
      display: block;
      font-size: 18px;
      margin-bottom: 8px;
      letter-spacing: .02em;
    }

    .loader-card p {
      margin: 0;
      color: var(--muted);
      font-size: 14px;
      line-height: 1.6;
    }

    .loader-bar {
      margin-top: 18px;
      height: 4px;
      border-radius: 999px;
      overflow: hidden;
      background: rgba(255, 255, 255, 0.08);
    }

    .loader-bar::before {
      content: "";
      display: block;
      width: 42%;
      height: 100%;
      border-radius: inherit;
      background: linear-gradient(90deg, var(--accent-a), var(--accent-b));
      animation: drift 1.2s ease-in-out infinite;
    }

    iframe {
      width: 100%;
      height: 100%;
      border: 0;
      display: block;
      background: var(--bg);
    }

    .fallback {
      max-width: 420px;
      padding: 24px;
      border-radius: 20px;
      border: 1px solid var(--line);
      background: rgba(16, 24, 42, 0.82);
      color: var(--text);
      box-shadow: 0 18px 48px rgba(0, 0, 0, 0.36);
    }

    .fallback a {
      color: #9fe5ff;
    }

    @keyframes drift {
      0% { transform: translateX(-110%); }
      100% { transform: translateX(280%); }
    }
  </style>
  <script>
    (function () {
      var host = (location.hostname || '').toLowerCase();
      if (host === 'rift.lan' || host === 'rift') {
        document.addEventListener('DOMContentLoaded', function () {
          var shell = document.getElementById('rift-shell');
          var frame = document.getElementById('rift-frame');
          var loader = document.getElementById('rift-loader');
          if (!shell || !frame) return;
          shell.style.display = 'block';
          frame.src = 'http://' + host + ':2017/';
          frame.addEventListener('load', function () {
            if (loader) loader.classList.add('hidden');
          });
        });
      } else {
        location.replace('/cgi-bin/luci/');
      }
    })();
  </script>
</head>
<body>
  <div id="rift-shell" class="shell">
    <div id="rift-loader" class="loader">
      <div class="loader-card">
        <strong>RIFT Panel</strong>
        <p>Открываем панель через чистый адрес <code>http://rift.lan/</code>. Внутри используется порт 2017, но в строке браузера остается только домен.</p>
        <div class="loader-bar"></div>
      </div>
    </div>
    <iframe
      id="rift-frame"
      title="RIFT Panel"
      loading="eager"
      referrerpolicy="no-referrer"
    ></iframe>
  </div>
  <noscript>
    <div class="fallback">
      Для открытия RIFT Panel нужен JavaScript.
      Прямая ссылка: <a href="http://rift.lan:2017/">http://rift.lan:2017/</a>
    </div>
  </noscript>
</body>
</html>
EOF

# 8) Auto-update subscription (every 5 min)
logi "[9/10] Настройка автообновления..."
cat <<'AEOF' > /etc/podkop_data/autoupdate_sub.sh
#!/bin/sh
URL="$(uci -q get podkop_subs.config.url)"
[ -z "$URL" ] && exit 0
TMP="/tmp/rift_autoupdate_rpc.json"
if command -v uclient-fetch >/dev/null 2>&1; then
  uclient-fetch -q -O "$TMP" "http://127.0.0.1:2017/cgi-bin/rpc?method=update_subs" 2>/dev/null || { rm -f "$TMP"; exit 0; }
else
  wget -q -O "$TMP" "http://127.0.0.1:2017/cgi-bin/rpc?method=update_subs" 2>/dev/null || { rm -f "$TMP"; exit 0; }
fi
grep -q '"status":"ok"' "$TMP" 2>/dev/null || { rm -f "$TMP"; exit 0; }
logger -t "rift-panel" "Subscription metadata refreshed via local RPC"
rm -f "$TMP"
AEOF
chmod +x /etc/podkop_data/autoupdate_sub.sh

# 8.5) MAC-VPN watcher (v4.8): синхронит podkop.main.fully_routed_ips с vpn_macs.list.
logi "[9.5/10] Установка MAC-VPN watcher..."
mkdir -p /usr/local/sbin   # на минимальных OpenWrt этой папки может не быть
touch /etc/podkop_data/vpn_macs.list

cat <<'WEOF' > /usr/local/sbin/rift-mac-vpn-watcher
#!/bin/sh
# rift-mac-vpn-watcher: каждые 30 сек переотражает MAC->IP в podkop.main.fully_routed_ips.
# Storage: /etc/podkop_data/vpn_macs.list (формат: "MAC<TAB>HOSTNAME" по строке).
# Mode: --once (одно прохождение) или без аргумента (бесконечный цикл через 30s).
VPN_MACS_FILE="/etc/podkop_data/vpn_macs.list"
MODE="$1"

resolve_one_pass() {
  # v4.14: два режима полного VPN. В режиме "ip" список fully_routed_ips ведёт панель
  # напрямую (ручные IP) — watcher НЕ трогает его, иначе затрёт ручные записи.
  [ "$(uci -q get podkop_subs.config.fullvpn_mode)" = "ip" ] && return 0
  [ -s "$VPN_MACS_FILE" ] || {
    cur="$(uci -q get podkop.main.fully_routed_ips)"
    [ -n "$cur" ] && {
      uci -q delete podkop.main.fully_routed_ips
      uci commit podkop
      /etc/init.d/podkop reload >/dev/null 2>&1
      logger -t rift-mac-vpn "Cleared fully_routed_ips (list empty)"
    }
    return 0
  }
  NEW_IPS=""
  while IFS=$(printf '\t') read -r MAC NAME; do
    [ -z "$MAC" ] && continue
    IP="$(ip -4 neigh show 2>/dev/null | awk -v m="$(echo "$MAC" | tr A-Z a-z)" 'tolower($5)==m && $1 ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ {print $1; exit}')"
    if [ -z "$IP" ]; then
      IP="$(awk -v m="$(echo "$MAC" | tr A-Z a-z)" 'tolower($2)==m {print $3; exit}' /tmp/dhcp.leases 2>/dev/null)"
    fi
    [ -n "$IP" ] && NEW_IPS="$NEW_IPS $IP"
  done < "$VPN_MACS_FILE"
  NEW_SORTED="$(echo "$NEW_IPS" | tr ' ' '\n' | awk 'NF' | sort -u | tr '\n' ' ' | sed 's/ $//')"
  CUR_SORTED="$(uci -q get podkop.main.fully_routed_ips | tr ' ' '\n' | awk 'NF' | sort -u | tr '\n' ' ' | sed 's/ $//')"
  if [ "$NEW_SORTED" != "$CUR_SORTED" ]; then
    uci -q delete podkop.main.fully_routed_ips
    for ip in $NEW_SORTED; do
      uci add_list podkop.main.fully_routed_ips="$ip"
    done
    uci commit podkop
    /etc/init.d/podkop reload >/dev/null 2>&1
    logger -t rift-mac-vpn "fully_routed_ips updated: [$NEW_SORTED]"
  fi
}

if [ "$MODE" = "--once" ]; then
  resolve_one_pass
  exit 0
fi
while :; do
  resolve_one_pass
  sleep 30
done
WEOF
chmod +x /usr/local/sbin/rift-mac-vpn-watcher

cat <<'IEOF' > /etc/init.d/rift-mac-vpn-watcher
#!/bin/sh /etc/rc.common
# RIFT MAC-VPN watcher: keeps podkop.main.fully_routed_ips in sync with vpn_macs.list
USE_PROCD=1
START=99
STOP=10
start_service() {
    procd_open_instance
    procd_set_param command /usr/local/sbin/rift-mac-vpn-watcher
    procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-0}
    procd_set_param stdout 1
    procd_set_param stderr 1
    procd_close_instance
}
reload_service() {
    stop
    start
}
IEOF
chmod +x /etc/init.d/rift-mac-vpn-watcher
/etc/init.d/rift-mac-vpn-watcher enable >/dev/null 2>&1
# ВАЖНО: restart, а не start. При апгрейде watcher уже запущен со СТАРЫМ кодом;
# `start` для procd = no-op (сервис уже up) -> старый скрипт продолжает крутиться
# (напр. без ip-guard v4.14 -> чистит fully_routed_ips в IP-режиме). restart грузит новый.
/etc/init.d/rift-mac-vpn-watcher restart >/dev/null 2>&1

# Миграция: если vpn_macs.list пуст, но в podkop есть старые fully_routed_ips —
# восстанавливаем MAC по текущему ip neigh / dhcp.leases (разово при апгрейде).
if [ ! -s /etc/podkop_data/vpn_macs.list ]; then
  OLD_IPS="$(uci -q get podkop.main.fully_routed_ips)"
  if [ -n "$OLD_IPS" ]; then
    logi "  -> миграция fully_routed_ips IP->MAC..."
    for ip in $OLD_IPS; do
      MAC="$(ip -4 neigh show "$ip" 2>/dev/null | awk '/lladdr/ {print $5; exit}')"
      [ -z "$MAC" ] && MAC="$(awk -v ip="$ip" '$3==ip {print $2; exit}' /tmp/dhcp.leases 2>/dev/null)"
      NAME="$(awk -v ip="$ip" '$3==ip {print $4; exit}' /tmp/dhcp.leases 2>/dev/null)"
      [ -z "$NAME" ] && NAME="$ip"
      [ -n "$MAC" ] && printf '%s\t%s\n' "$MAC" "$NAME" >> /etc/podkop_data/vpn_macs.list
    done
  fi
fi
/usr/local/sbin/rift-mac-vpn-watcher --once >/dev/null 2>&1
# ----------------------------------------------------------------------------

# ============================================================================
# v4.15: Демон диагностики (rift-diag). Непрерывно собирает снапшоты роутера и
# шлёт батчи на router.rift.monster/diag (curl --resolve, мимо туннеля) по двум
# триггерам: раз в час ИЛИ при буфере >= порога. Работает ТОЛЬКО когда
# podkop_subs.config.diag_mode=1; иначе холостой ход (procd доволен, нагрузки нет).
# ============================================================================
cat <<'DIAGEOF' > /usr/local/sbin/rift-diag
#!/bin/sh
# rift-diag: непрерывный сбор диагностики роутера RIFT.
# Работает пока uci podkop_subs.config.diag_mode=1. Снапшот каждые SNAP_INT сек в
# буфер (RAM); ОТПРАВКА на сервер по ДВУМ триггерам: раз в SEND_INT сек ИЛИ при буфере
# >= MAX_BYTES. При успехе отправки буфер чистится. Отправка НАПРЯМУЮ (curl --resolve,
# мимо fakeip/туннеля) — иначе при сбое туннеля отчёт бы не ушёл.
# Router ID = HWID. Тяжёлые пробы (реальный статус активного транспорта, TLS, списки)
# раз в HEAVY_EVERY циклов; событийный снимок при сбое.

HWID="$(cat /etc/podkop_data/hwid 2>/dev/null)"
MODEL="$(cat /tmp/sysinfo/model 2>/dev/null)"
BUF="/tmp/rift_diag_buf.jsonl"
DIAG_HOST="router.rift.monster"
SNAP_INT=30            # снапшот каждые 30с
HEAVY_EVERY=10         # тяжёлые пробы каждые ~5 мин (10*30с)
MAX_BYTES=524288       # порог отправки по объёму: 512 КБ
SEND_INT=3600          # или раз в час
DIAG_TTL=86400         # авто-выключение режима через 24ч (чтобы не засыпать логами)

# экранирование в JSON-строку: сперва удаляем ВСЕ управляющие байты (0x00-0x1F, вкл.
# ESC/tab/nl/cr) — иначе ANSI-коды из логов sing-box ломают JSON; затем срезаем остаток
# ANSI ([31m и т.п.) и экранируем \ и ".
je(){ printf '%s' "$1" | tr -d '\000-\037' | sed 's/\[[0-9;]*m//g; s/\\/\\\\/g; s/"/\\"/g'; }

# реальный IP хоста мимо fakeip (для прямой отправки + резолв-проб)
resolve_direct(){
  local h="$1" ip pre=""
  command -v timeout >/dev/null 2>&1 && pre="timeout 3 "
  ip=$($pre nslookup "$h" 77.88.8.8 2>/dev/null | awk '/^Address/{print $NF}' | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -v '^127\.' | grep -v '^198\.18\.' | tail -1)
  [ -z "$ip" ] && ip=$($pre nslookup "$h" 8.8.8.8 2>/dev/null | awk '/^Address/{print $NF}' | grep -Eo '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -v '^127\.' | tail -1)
  echo "$ip"
}

# потери % и средний RTT мс до host: "loss rtt"
ping_stat(){
  local out
  out=$(ping -c 3 -W 2 "$1" 2>/dev/null)
  local loss rtt
  loss=$(echo "$out" | sed -n 's/.* \([0-9]*\)% packet loss.*/\1/p' | head -1)
  rtt=$(echo "$out" | sed -n 's|.*= [0-9.]*/\([0-9.]*\)/.*|\1|p' | head -1)
  [ -z "$loss" ] && loss=100
  [ -z "$rtt" ] && rtt=0
  echo "$loss $rtt"
}

# --- ЛЁГКИЙ снапшот (каждые 30с) ---
light_snapshot(){
  local t lt act sb_run sb_rss load memfree ct wan_up wan_ip
  t=$(date +%s); lt=$(date '+%Y-%m-%dT%H:%M:%S')
  act=$(cat /etc/podkop_data/active_key 2>/dev/null | tr '\t' '|')
  pgrep -f sing-box >/dev/null 2>&1 && sb_run=1 || sb_run=0
  sb_rss=$(grep -m1 VmRSS /proc/$(pgrep -f sing-box 2>/dev/null | head -1)/status 2>/dev/null | grep -oE '[0-9]+' | head -1)
  [ -z "$sb_rss" ] && sb_rss=0
  load=$(cut -d' ' -f1 /proc/loadavg 2>/dev/null)
  memfree=$(awk '/MemAvailable/{print $2}' /proc/meminfo 2>/dev/null)
  ct=$(cat /proc/sys/net/netfilter/nf_conntrack_count 2>/dev/null)
  wan_up=$(ip -4 addr show 2>/dev/null | grep -qE 'wan|pppoe' && echo 1 || echo 1)
  wan_ip=$(ip -4 route get 8.8.8.8 2>/dev/null | grep -oE 'src [0-9.]+' | awk '{print $2}')
  # потери/RTT: шлюз, DNS-сервер, внешний
  local gw; gw=$(ip -4 route show default 2>/dev/null | awk '/default/{print $3; exit}')
  set -- $(ping_stat "${gw:-127.0.0.1}"); local lgw="$1"
  set -- $(ping_stat 77.88.8.8); local ldns="$1" rdns="$2"
  set -- $(ping_stat 8.8.8.8); local lext="$1" rext="$2"
  # DNS резолв-проба + fakeip
  local yt; yt=$(nslookup youtube.com 127.0.0.1 2>/dev/null | awk '/^Address/{a=$NF} END{print a}')
  local dns_ok=0; echo "$yt" | grep -qE '^[0-9]' && dns_ok=1
  local yt_fakeip=0; echo "$yt" | grep -q '^198\.18\.' && yt_fakeip=1
  printf '{"t":%s,"lt":"%s","act":"%s","sb_run":%s,"sb_rss":%s,"load":"%s","memfree":%s,"ct":%s,"wan_ip":"%s","l_gw":%s,"l_dns":%s,"r_dns":%s,"l_ext":%s,"r_ext":%s,"dns_ok":%s,"yt_fakeip":%s}\n' \
    "$t" "$(je "$lt")" "$(je "$act")" "$sb_run" "$sb_rss" "$(je "$load")" "${memfree:-0}" "${ct:-0}" "$(je "$wan_ip")" \
    "${lgw:-100}" "${ldns:-100}" "${rdns:-0}" "${lext:-100}" "${rext:-0}" "$dns_ok" "$yt_fakeip" >> "$BUF"
}

# --- ЖИВОЙ мониторинг активного узла (без лишних процессов) ---
# host активного узла из active_key ("<key>\t<transport>", key вида "ip:1.2.3.4").
active_host(){ local a; a=$(cat /etc/podkop_data/active_key 2>/dev/null); a=${a%%	*}; echo "${a#ip:}"; }
# TCP:443 фронта активного узла ЖИВ прямо сейчас? (разделяет "нода упала" vs "TSPU режет хендшейк")
active_tcp(){
  local h tc; h=$(active_host)
  echo "$h" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' || { echo "na"; return; }
  command -v curl >/dev/null 2>&1 || { echo "na"; return; }
  tc=$(curl -s -o /dev/null --connect-timeout 3 -m 5 -w '%{time_connect}' "https://$h:443/" 2>/dev/null)
  awk -v v="$tc" 'BEGIN{exit !(v+0>0)}' && echo ok || echo fail
}

# --- ТЯЖЁЛЫЙ снапшот (реальный статус туннеля + сервера + списки + ошибки) ---
# Формат podkop global_check: строки-вердикты с ✅/❌/⚠️. Ключевые:
#   "Sing-box process running"  ✅/❌  — жив ли процесс
#   "...work with FakeIP"       ✅/❌  — реально ли идёт трафик через туннель
#   "Main DNS"                  ✅/❌  — DNS
# (check_proxy на 0.7.21 бесполезен — печатает дамп конфига, не тест связности.)
heavy_snapshot(){
  local avail nft sberr gc sb_proc fakeip gdns gc_fail gc_fails
  gc=/tmp/rift_diag_gc
  # ПРИМ.: podkop check_proxy на 0.7.21 печатает ДАМП КОНФИГА, а не тест связности —
  # не используем. Реальные вердикты берём из global_check (✅/❌).
  podkop global_check >"$gc" 2>&1
  sb_proc=0; grep 'Sing-box process running' "$gc" 2>/dev/null | grep -q '✅' && sb_proc=1
  fakeip=0;  grep -i 'FakeIP' "$gc" 2>/dev/null | grep -q '✅' && fakeip=1   # реально ли идёт трафик через туннель
  gdns=0;    grep 'Main DNS' "$gc" 2>/dev/null | grep -q '✅' && gdns=1
  gc_fail=$(grep -c '❌' "$gc" 2>/dev/null)
  gc_fails=$(grep '❌' "$gc" 2>/dev/null | sed 's/❌//g; s/^[[:space:]]*//' | tr '\n' ';' | cut -c1-500)
  rm -f "$gc"
  # доступность всех серверов (TCP) — из панельного RPC (обходит все узлы, потому только тут)
  avail=$(uclient-fetch -qT 90 -O - "http://127.0.0.1:2017/cgi-bin/rpc?method=check_availability" 2>/dev/null | sed -n 's/.*"avail":\(.*\)}$/\1/p')
  [ -z "$avail" ] && avail='{}'
  # ТОЧНАЯ причина обрыва — из живых логов sing-box. Reason ("operation timed out",
  # "not permitted", "reality handshake" и т.п.) стоит в КОНЦЕ длинной строки, поэтому
  # берём ХВОСТ (последние ~180 симв.), а не начало — иначе причина обрезается.
  sberr=$(logread 2>/dev/null | grep -iE 'sing.?box|xray' | grep -iE 'error|fail|timeout|reset|refused|reality|handshake|EOF|deadline|unreachable|closed|no route|not permitted' | tail -8 | awk '{print (length>180?substr($0,length-179):$0)}' | tr '\n' ';')
  # ЧТО перезапускает sing-box/трогает роутинг (podkop: reload/restart/обновление списков)
  local pklog; pklog=$(logread 2>/dev/null | grep -iE 'podkop' | grep -iE 'reload|restart|start|stop|list|update|download|reconfig' | tail -6 | cut -c1-200 | tr '\n' ';')
  # размеры nft-сетов роутинга (fakeip/domains) — пусто => списки не загрузились
  nft=$(nft list ruleset 2>/dev/null | grep -icE 'elements = \{|podkop')
  # ЖИВ ли порт 443 активного узла сейчас (нода-даун vs TSPU-режет-хендшейк)
  local act_tcp ahost; ahost=$(active_host); act_tcp=$(active_tcp)
  printf '{"t":%s,"heavy":1,"act_host":"%s","act_tcp":"%s","sb_proc":%s,"fakeip_ok":%s,"gc_dns_ok":%s,"gc_fail":%s,"gc_fails":"%s","nft_sets":%s,"pk_log":"%s","sb_err":"%s","srv":%s}\n' \
    "$(date +%s)" "$(je "$ahost")" "$act_tcp" "$sb_proc" "$fakeip" "$gdns" "${gc_fail:-0}" "$(je "$gc_fails")" "${nft:-0}" "$(je "$pklog")" "$(je "$sberr")" "$avail" >> "$BUF"
}

# --- ОТПРАВКА батча (curl --resolve, мимо туннеля); при успехе чистим буфер ---
send_batch(){
  [ -s "$BUF" ] || return 0
  local rip; rip=$(resolve_direct "$DIAG_HOST")
  [ -z "$rip" ] && return 1
  command -v curl >/dev/null 2>&1 || return 1
  local code
  code=$(curl -s -m 30 -o /dev/null -w '%{http_code}' \
    --resolve "$DIAG_HOST:443:$rip" \
    -H "X-HWID: $HWID" -H "X-Model: $MODEL" -H "X-Panel: $(cat /etc/podkop_data/version 2>/dev/null)" \
    -H "X-Label: $(uci -q get podkop_subs.config.diag_label)" \
    -H "Content-Type: application/x-ndjson" \
    --data-binary @"$BUF" "https://$DIAG_HOST/diag" 2>/dev/null)
  if [ "$code" = "200" ]; then
    : > "$BUF"           # успех -> чистим буфер
    logger -t rift-diag "batch sent ok ($code)"
    return 0
  fi
  logger -t rift-diag "batch send failed ($code)"
  return 1
}

# --- РЕЖИМ --once: мгновенная проверка связи + первый батч (при включении из панели) ---
# Отдельный буфер, чтобы не конфликтовать с основным демоном (общий $BUF).
if [ "$1" = "--once" ]; then
  BUF="/tmp/rift_diag_once.$$"
  : > "$BUF"
  printf '{"t":%s,"event":"diag_start","hwid":"%s","panel":"%s","model":"%s"}\n' \
    "$(date +%s)" "$(je "$HWID")" "$(je "$(cat /etc/podkop_data/version 2>/dev/null)")" "$(je "$MODEL")" >> "$BUF"
  light_snapshot
  send_batch   # быстрый первый батч: связь + Router ID подтверждаются сразу
  heavy_snapshot
  if send_batch; then echo "diag --once: отправлено"; else echo "diag --once: отправка не удалась"; fi
  rm -f "$BUF"
  exit 0
fi

# --- ГЛАВНЫЙ ЦИКЛ (демон) ---
: > "$BUF"
cycle=0
last_send=$(date +%s)
was_on=0
while :; do
  if [ "$(uci -q get podkop_subs.config.diag_mode)" != "1" ]; then
    # режим выключен: добить остаток один раз и идти в холостой ход (procd доволен)
    [ "$was_on" = "1" ] && { send_batch; was_on=0; }
    sleep 30
    continue
  fi
  was_on=1
  # авто-выключение через 24ч: забытый режим не должен засыпать логами
  started=$(uci -q get podkop_subs.config.diag_started)
  if [ -n "$started" ] && [ "$(( $(date +%s) - started ))" -ge "$DIAG_TTL" ]; then
    logger -t rift-diag "auto-off after ${DIAG_TTL}s (24h)"
    heavy_snapshot
    printf '{"t":%s,"event":"diag_autooff_24h"}\n' "$(date +%s)" >> "$BUF"
    send_batch
    uci -q set podkop_subs.config.diag_mode='0'; uci -q commit podkop_subs
    was_on=0
    continue
  fi
  light_snapshot
  cycle=$((cycle+1))
  do_heavy=0; alert=0
  [ $((cycle % HEAVY_EVERY)) -eq 0 ] && do_heavy=1
  # СОБЫТИЕ: аномалия в лёгком снимке (sing-box упал / нет DNS / нет fakeip) —
  # немедленно снимаем тяжёлый (за ТОЧНОЙ причиной: логи sing-box + статус узла).
  tail -1 "$BUF" 2>/dev/null | grep -qE '"sb_run":0|"dns_ok":0|"yt_fakeip":0' && { do_heavy=1; alert=1; }
  [ "$do_heavy" = "1" ] && heavy_snapshot
  # тяжёлый показал отказ активного узла/туннеля -> тоже алерт
  tail -1 "$BUF" 2>/dev/null | grep -qE '"act_tcp":"fail"|"fakeip_ok":0|"gc_fail":[1-9]|"sb_err":"[^"]' && alert=1
  # отправка: СРАЗУ при алерте (кулдаун 120с, чтобы не спамить), иначе объём/час
  now=$(date +%s)
  bytes=$(wc -c < "$BUF" 2>/dev/null || echo 0)
  if [ "$alert" = "1" ] && [ $((now - last_send)) -ge 120 ]; then
    logger -t rift-diag "alert -> immediate send"
    send_batch && last_send=$(date +%s)
  elif [ "$bytes" -ge "$MAX_BYTES" ] || [ $((now - last_send)) -ge "$SEND_INT" ]; then
    send_batch && last_send=$(date +%s)
  fi
  sleep "$SNAP_INT"
done
DIAGEOF
chmod +x /usr/local/sbin/rift-diag
sed -i 's/\r$//' /usr/local/sbin/rift-diag

cat <<'IEOF' > /etc/init.d/rift-diag
#!/bin/sh /etc/rc.common
# RIFT diagnostics collector: idles unless podkop_subs.config.diag_mode=1
USE_PROCD=1
START=99
STOP=10
start_service() {
    procd_open_instance
    procd_set_param command /usr/local/sbin/rift-diag
    procd_set_param respawn ${respawn_threshold:-3600} ${respawn_timeout:-5} ${respawn_retry:-0}
    procd_set_param stdout 1
    procd_set_param stderr 1
    procd_close_instance
}
reload_service() {
    stop
    start
}
IEOF
chmod +x /etc/init.d/rift-diag
/etc/init.d/rift-diag enable >/dev/null 2>&1
# restart (не start): при апгрейде демон уже мог крутиться со старым кодом -> грузим новый.
/etc/init.d/rift-diag restart >/dev/null 2>&1
# ----------------------------------------------------------------------------

# Panel auto-update (daily)
cat <<'PEOF' > /etc/podkop_data/autoupdate_panel.sh
#!/bin/sh
# URL источника обновлений берём из uci (правится в «Системных настройках»).
URL="$(uci -q get podkop_subs.config.update_url)"
[ -z "$URL" ] && URL="https://router.rift.monster/rift.sh"
TMP="/tmp/rift_remote.sh"
if command -v uclient-fetch >/dev/null 2>&1; then
  uclient-fetch -q -O "$TMP" "$URL" 2>/dev/null || { rm -f "$TMP"; exit 0; }
else
  wget -q -O "$TMP" "$URL" 2>/dev/null || { rm -f "$TMP"; exit 0; }
fi
grep -q '^PANEL_VERSION="' "$TMP" 2>/dev/null || { rm -f "$TMP"; exit 0; }
# v4.22: маркер ЦЕЛОСТНОСТИ в конце файла. Обрыв закачки проходит проверку PANEL_VERSION
# (она на 30-й строке файла в 190+ КБ) → раньше запускался ОБРЕЗАННЫЙ установщик и
# клобберил rpc. Маркер в самом конце отсекает недокачанный файл.
tail -c 200 "$TMP" 2>/dev/null | grep -q 'RIFT_PANEL_EOF' || { rm -f "$TMP"; exit 0; }
sed -i 's/\r$//' "$TMP"
LOCAL_V="$(cat /etc/podkop_data/version 2>/dev/null)"
REMOTE_V="$(sed -n 's/^PANEL_VERSION="\([^"]*\)".*/\1/p' "$TMP" | head -1)"
[ -n "$REMOTE_V" ] && [ -n "$LOCAL_V" ] && [ "$REMOTE_V" != "$LOCAL_V" ] && sh "$TMP" >/dev/null 2>&1
rm -f "$TMP"
PEOF
chmod +x /etc/podkop_data/autoupdate_panel.sh

# 9) cron
logi "[10/10] Настройка cron..."
(crontab -l 2>/dev/null | grep -Fv "autoupdate_sub" | grep -Fv "autoupdate_panel" | grep -Fv "/etc/podkop_data/") | crontab -
# Подписка обновляется раз в час (в :07) — меньше сети и /tmp-churn на роутере.
# Список узлов и так переписывается только при реальном изменении подписки.
(crontab -l 2>/dev/null; echo "7 * * * * /etc/podkop_data/autoupdate_sub.sh"; echo "13 4 * * * /etc/podkop_data/autoupdate_panel.sh") | crontab -

# finish
# v4.22: rpc пишется в .tmp, затем АТОМАРНАЯ замена через mv — но только после
# lua-проверки синтаксиса. Обрыв закачки/битый heredoc оставит битый rpc.tmp,
# а БОЕВОЙ rpc не тронется (раньше клобберился в огрызок "#!/usr/bin" → "Permission denied").
chmod +x /www/podkop_panel/cgi-bin/rpc.tmp
sed -i 's/\r$//' /www/podkop_panel/cgi-bin/rpc.tmp
# Подставляем версию парсера — при апгрейде первый update_subs пересоберёт nodes.lua
# (parser_ver в сохранённом списке не совпадёт → перезапись, даже если хэш подписки
# прежний). Так изменения конвертера (напр. скрытие XHTTP) применяются к старому списку.
# nodes.lua НЕ удаляем: если фоновый refresh ниже не сработает, лучше показать старый
# список, чем пустой (cron теперь раз в час). Пересборка произойдёт при первом же update_subs.
sed -i "s/__RIFT_PARSER_VER__/${PANEL_VERSION}/" /www/podkop_panel/cgi-bin/rpc.tmp
# Две проверки: (1) сентинел RIFT_RPC_EOF в конце = файл дописан целиком
# (lua loadfile сам по себе НЕ ловит огрызок "#!/usr/bin" — Lua глотает #-shebang и
# считает пустой чанк валидным); (2) lua loadfile = синтаксис корректен.
if tail -c 120 /www/podkop_panel/cgi-bin/rpc.tmp 2>/dev/null | grep -q 'RIFT_RPC_EOF' \
   && lua -e 'assert(loadfile("/www/podkop_panel/cgi-bin/rpc.tmp"))' 2>/dev/null; then
  mv -f /www/podkop_panel/cgi-bin/rpc.tmp /www/podkop_panel/cgi-bin/rpc
  logi "  -> rpc обновлён атомарно (сентинел + lua-проверка пройдены)"
else
  rm -f /www/podkop_panel/cgi-bin/rpc.tmp
  logi "  -> ВНИМАНИЕ: rpc.tmp неполон/битый — боевой rpc НЕ тронут (обрыв закачки?)"
fi
/etc/init.d/uhttpd enable >/dev/null 2>&1
# Рестарты сети откладываем в фон на 3с: при `sh <(wget ...)` рестарт dnsmasq
# здесь рвёт ещё не докачанный хвост pipe → "stalled / timed out". Фон даёт
# wget докачать. dnsmasq: reload (мягко) вместо restart.
( sleep 3
  /etc/init.d/uhttpd restart >/dev/null 2>&1
  /etc/init.d/dnsmasq reload  >/dev/null 2>&1
  # v4.14: пересобираем nodes.lua новой схемой (servers). При апгрейде 4.13->4.14
  # старый nodes.lua = схема db.nodes, get_nodes её не читает -> список пуст, пока
  # не отработает update_subs. РЕТРАИМ до 4 раз, пока в nodes.lua не появится
  # "servers" — чтобы не оставить пустой список, если первый прогон упал
  # (uhttpd не готов / сеть моргнула). VPN при этом не рвётся (outbound_json цел).
  i=0
  while [ "$i" -lt 4 ]; do
    sleep 4
    /etc/podkop_data/autoupdate_sub.sh >/dev/null 2>&1
    grep -qF "[\"parser_ver\"]=\"${PANEL_VERSION}\"" /etc/podkop_data/nodes.lua 2>/dev/null && break
    i=$((i+1))
  done
) >/dev/null 2>&1 &

logi "================================================="
logi "ГОТОВО! RIFT Panel v${PANEL_VERSION}"
logi "Доступ: http://${ROUTER_IP}:2017"
logi "Домен: http://rift.lan/"
logi "HWID: $(cat /etc/podkop_data/hwid 2>/dev/null)"
logi "sing-box(after): $(/usr/bin/sing-box version 2>/dev/null | head -1)"
logi "Авто-обновление подписки: раз в час"
logi "Лог установки: ${INSTALL_LOG}"
logi "Лог работы панели: /tmp/rift_panel.log (или кнопка «Логи» в панели)"
logi "================================================="
# RIFT_PANEL_EOF
