#!/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.21"
REMOTE_SCRIPT_URL="https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh"

# === 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)..."
opkg update >>"$INSTALL_LOG" 2>&1
# ВАЖНО: 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.
opkg install ca-bundle coreutils-base64 lua uclient-fetch >>"$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..."
  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) + АВТО-ОТКАТ, если sing-box не поднимется.
if uci -q get podkop.main >/dev/null 2>&1 && command -v podkop >/dev/null 2>&1; then
  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 \
    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; do
    uci -q add_list podkop.rift_torrent.user_domains="$_d"
  done
  uci commit podkop
  /etc/init.d/podkop reload >/dev/null 2>&1
  # авто-откат: если sing-box не поднялся за 6с — убираем секцию и возвращаем как было
  _sb_ok=0
  _i=0; while [ "$_i" -lt 6 ]; do sleep 1; pgrep -f sing-box >/dev/null 2>&1 && { _sb_ok=1; break; }; _i=$((_i+1)); done
  if [ "$_sb_ok" = "1" ]; then
    logi "[4.8/10] Торрент мимо VPN: секция exclusion rift_torrent применена (sing-box жив)"
  else
    uci -q delete podkop.rift_torrent; uci commit podkop; /etc/init.d/podkop reload >/dev/null 2>&1
    logi "[4.8/10] ОТКАТ: sing-box не поднялся с секцией rift_torrent — секция удалена, конфиг восстановлен"
  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
#!/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).." -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 {}
    return {
      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"} },
    }
  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, с вариантами-протоколами.
local TR_ORDER={"tcp","grpc","hy2","xhttp"}
local function build_servers_from_xray(arr)
  local bykey, order = {}, {}
  local stats={servers=0,tcp=0,grpc=0,hy2=0,xhttp=0,sep=0,skip=0,noip=0}
  if type(arr)~="table" then return {}, stats end
  for _,item in ipairs(arr) do
    local name=item.remarks or ""
    if is_decorative_separator(name) then stats.sep=stats.sep+1
    elseif should_skip(name) then stats.skip=stats.skip+1
    else
      for _,ob in ipairs(item.outbounds or {}) do
        local host,port,net = raw_endpoint(ob)
        if host and host~="" and net then
          local nm=name_from_flag(name)
          if nm then
            local ip=resolve_ip(host)
            local gkey=(ip~="") and ("ip:"..ip) or ("host:"..host)
            local S=bykey[gkey]
            if not S then
              S={key=gkey, ip=ip, name=nm, variants={}}
              bykey[gkey]=S; order[#order+1]=gkey; stats.servers=stats.servers+1
              if ip=="" then stats.noip=stats.noip+1 end
            end
            if #nm>#S.name then S.name=nm end
            if not S.variants[net] then
              local label=(net=="hy2" and "HY2") or (net=="grpc" and "gRPC") or (net=="xhttp" and "XHTTP") or "TCP"
              local V={transport=net, label=label, host=host, port=port}
              if net=="xhttp" then
                V.unsupported=true                       -- штатный sing-box не умеет xhttp: sb НЕ храним (память)
                S.variants[net]=V; stats.xhttp=stats.xhttp+1
              else
                local sb=convert_outbound(ob)            -- tcp/grpc/hy2 -> валидный sing-box outbound
                if sb then
                  local bad=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 bad then sb=nil end
                end
                if sb then sb.tag="proxy"; V.sb=sb; S.variants[net]=V; stats[net]=(stats[net] or 0)+1 end
              end
            end
          end
        end
      end
    end
  end
  local list={}
  for _,k in ipairs(order) do
    local S=bykey[k]
    local ord={}
    for _,t in ipairs(TR_ORDER) do if S.variants[t] then ord[#ord+1]=t end end
    S.order=ord
    list[#list+1]=S
  end
  -- счётчик #N для одинаковых имён (разные серверы одной страны)
  local cnt={}
  for _,S in ipairs(list) do cnt[S.name]=(cnt[S.name] or 0)+1 end
  local seen={}
  for _,S in ipairs(list) do
    if (cnt[S.name] or 0)>1 then seen[S.name]=(seen[S.name] or 0)+1; S.name=S.name.." #"..seen[S.name] 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
  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, ip=S.ip, 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 "",
    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"
  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=""}
  os.execute("curl -sI -A 'Happ/4.13-RIFT' "..shq(url).." >"..hdr_file.." 2>/dev/null")
  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,
    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
    -- Миграция активного узла со СТАРОГО active_key (4.13: "host:port:transport", без \t)
    -- на новый ("key<TAB>transport") — по host+transport из текущего podkop outbound_json.
    -- Без этого после апгрейда 4.13->4.14 «Активное подключение» пустое, узел не подсвечен.
    -- Выполняем ТОЛЬКО если формат ещё старый (не перетираем валидный новый + reconnect-базу).
    local ak_raw = exec_read("cat /etc/podkop_data/active_key 2>/dev/null")
    if ak_raw ~= "" and not ak_raw:find("\t",1,true) then
      local cur = trim(uci_get("podkop","main","outbound_json"))
      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"
      elseif cur:find('"type":"xhttp"',1,true) then cur_tr="xhttp" end
      if cur_server and cur_server~="" then
        for _,S in ipairs(servers) do
          local V=S.variants[cur_tr]
          if V and V.sb and V.host==cur_server then
            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(to_json(V.sb))); sf:close() end
            dlog("update_subs: миграция active_key 4.13->4.14: "..S.key.." ["..cur_tr.."] (host="..cur_server..")")
            break
          end
        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
  -- Лёгкий connect-тест per-сервер. tcp/grpc/hy2 у нас на одном IP:443 → одна
  -- проверка на сервер, результат красит все подключаемые чипы. xhttp пропускаем.
  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 = cmd_exists("curl") and (os.execute("curl --version >/dev/null 2>&1")==0)
  local results={}
  local n=0
  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 not V.unsupported then host=V.host; port=V.port or 443; break end
    end
    local ok=false
    if host then
      if has_curl then
        local tc=exec_read("curl -sk -o /dev/null --connect-timeout 3 -m 5 -w '%{time_connect}' "..shq("https://"..host..":"..tostring(port)).." 2>/dev/null")
        ok = (tonumber(tc) or 0) > 0
      else
        local rc=exec_silent("ping -c 1 -W 3 "..shq(host)); ok=(rc==0)or(rc==true)
      end
    end
    results[S.key] = ok and "ok" or "fail"
    n=n+1
  end
  dlog("check_availability: проверено серверов="..n.." (curl="..tostring(has_curl)..")")
  print(to_json({status="ok", avail=results}))
  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.name).."  ["..tostring(S.ip~="" and S.ip or "?").."]  "..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"}')
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, maximum-scale=1.0, user-scalable=no">
  <title>RIFT Panel</title>
  <link id="favicon" rel="icon" href="">
  <style>
    :root{--bg:#0A0E1A;--card:#111827;--text:#E8EDF2;--text-sec:#7B8D9E;--accent:#85D9FE;--grad1:#0068FF;--grad2:#85D9FE;--green:#00E676;--red:#FF5252;--orange:#FFB74D;--border:rgba(0,104,255,.15);--shadow:0 4px 24px rgba(0,0,0,.5)}
    @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');
    *{box-sizing:border-box;margin:0;padding:0}
    body{font-family:'Inter',-apple-system,BlinkMacSystemFont,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
    .container{max-width:520px;margin:0 auto;padding:16px}
    .header{text-align:center;padding:20px 0 12px}
    .logo-img{width:64px;height:64px;border-radius:14px;margin-bottom:8px}
    .header h1{font-size:28px;font-weight:800;background:linear-gradient(135deg,var(--grad1),var(--grad2));-webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:1px}
    .header .version{font-size:11px;color:var(--text-sec);margin-top:4px}
    .card{background:var(--card);border:1px solid var(--border);border-radius:16px;padding:20px;margin-bottom:14px;box-shadow:var(--shadow)}
    h3{margin:0 0 14px;font-weight:700;font-size:14px;color:var(--text-sec);text-transform:uppercase;letter-spacing:.8px}
    .active-card{background:linear-gradient(135deg,#0068FF 0%,#85D9FE 100%);border:none;text-align:center;color:#fff;position:relative;overflow:hidden}
    .active-card::before{content:'';position:absolute;top:-50%;left:-50%;width:200%;height:200%;background:radial-gradient(circle,rgba(255,255,255,.08) 0%,transparent 70%);animation:pulse 4s ease-in-out infinite;pointer-events:none}
    @keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.05)}}
    .active-card h3{color:rgba(255,255,255,.7)}
    .server-big{font-size:18px;font-weight:800;margin:8px 0 4px;display:block;word-break:break-word}
    .server-meta{color:rgba(255,255,255,.75);font-size:12px;display:block;margin-top:4px}
    .status-dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px;vertical-align:middle}
    .status-dot.on{background:var(--green);box-shadow:0 0 8px var(--green)}
    .status-dot.off{background:var(--red);box-shadow:0 0 8px var(--red)}
    .btn{border:none;border-radius:12px;font-weight:700;font-size:13px;cursor:pointer;transition:all .15s;outline:none}
    .btn-primary{background:linear-gradient(135deg,var(--grad1),var(--grad2));color:#fff;padding:10px 18px}
    .btn-primary:active{transform:scale(.97)}
    .btn-outline{background:rgba(0,104,255,.08);border:1px solid rgba(0,104,255,.3);color:var(--accent);padding:8px 14px;font-size:12px}
    .btn-outline:active{background:rgba(0,104,255,.18)}
    .btn-danger{background:rgba(255,82,82,.1);border:1px solid rgba(255,82,82,.25);color:var(--red);padding:6px 12px;font-size:11px}
    .btn-active{background:linear-gradient(135deg,var(--green),#00C853);color:#fff;padding:8px 14px;font-size:12px;border:none}
    .btn-full{width:100%;padding:12px;margin-top:12px;font-size:14px}
    .list-row{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 0;border-bottom:1px solid var(--border)}
    .list-row:last-child{border-bottom:none;padding-bottom:0}
    /* Декоративный разделитель кластеров (узлы с name = только U+3164/U+2800/spaces) */
    .node-separator{height:8px;border-bottom:1px dashed var(--border);margin:6px 0;opacity:.55}
    .node-separator:last-child{display:none}
    .node-info{flex:1;min-width:0}
    .item-name{font-weight:600;font-size:13px;color:var(--text);display:flex;align-items:center;gap:6px;flex-wrap:wrap;word-break:break-word}
    .item-title{display:inline-flex;align-items:center;gap:6px;min-width:0}
    .item-title-text{display:inline-block;min-width:0}
    .item-sub{display:block;font-size:11px;color:var(--text-sec);margin-top:4px;letter-spacing:.04em;text-transform:uppercase}
    .transport-badge{display:inline-block;font-size:9px;font-weight:800;padding:2px 6px;border-radius:6px;margin-left:6px;vertical-align:middle}
    .badge-tcp{background:rgba(0,212,255,.12);color:var(--accent)}
    .badge-grpc{background:rgba(255,183,77,.12);color:var(--orange)}
    .badge-xhttp{background:rgba(0,230,118,.12);color:var(--green)}
    .badge-ws{background:rgba(156,39,176,.12);color:#CE93D8}
    .ping-text{font-size:10px;color:var(--text-sec);margin-top:1px;display:block}
    .list-row.active-row{background:linear-gradient(135deg,rgba(0,104,255,.15),rgba(133,217,254,.08));border:1px solid rgba(0,104,255,.35);border-radius:12px;margin:4px -8px;padding:12px 8px}
    .ping-ok{color:var(--green)}
    .ping-bad{color:var(--red)}
    .sub-info{font-size:11px;color:rgba(255,255,255,.6);margin-top:4px;display:block}
    .input-group{display:flex;gap:8px;margin-top:12px}
    input[type=text]{background:rgba(255,255,255,.05);border:1px solid var(--border);color:var(--text);padding:10px 12px;border-radius:10px;width:100%;font-size:12px;font-family:inherit;outline:none}
    .dns-select{background:rgba(255,255,255,.05);border:1px solid var(--border);color:var(--text);padding:10px 12px;border-radius:10px;width:100%;font-size:12px;font-family:inherit;outline:none}
    .dns-select:focus{border-color:var(--accent)}
    .dns-select option{background:var(--card);color:var(--text)}
    .dns-label{font-size:11px;color:var(--text-sec);letter-spacing:.02em}
    input[type=text]:focus{border-color:var(--accent)}
    input[type=text]::placeholder{color:var(--text-sec)}
    .preloader-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(10,14,26,.85);backdrop-filter:blur(8px);z-index:9999;display:none;flex-direction:column;justify-content:center;align-items:center}
    .ghost-loader{width:55px;height:64px;animation:ghostFloat 2s ease-in-out infinite}
    .ghost-loader img{width:100%;height:100%}
    @keyframes ghostFloat{0%,100%{transform:translateY(0)}50%{transform:translateY(-16px)}}
    .loader-text{color:var(--accent);font-size:12px;margin-top:16px;opacity:.7;letter-spacing:1px}
    .toast{display:none;position:fixed;left:12px;right:12px;bottom:12px;padding:12px 16px;border-radius:12px;background:var(--card);border:1px solid var(--border);color:var(--text);z-index:10000;font-size:12px;box-shadow:0 8px 32px rgba(0,0,0,.5)}
    .logs-modal{display:none;position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.8);backdrop-filter:blur(8px);z-index:9998;overflow-y:auto}
    .logs-content{max-width:560px;margin:40px auto;padding:20px;background:var(--card);border-radius:16px;border:1px solid var(--border)}
    .logs-text{font-family:'Courier New',monospace;font-size:11px;line-height:1.6;color:var(--green);background:rgba(0,0,0,.3);padding:12px;border-radius:8px;max-height:60vh;overflow-y:auto;white-space:pre-wrap;word-break:break-all}
    .logs-header{display:flex;justify-content:space-between;align-items:center;margin-bottom:12px}
    .hwid-block{font-size:11px;color:var(--text-sec);padding:8px 12px;background:rgba(0,0,0,.2);border-radius:8px;margin-top:8px;word-break:break-all}
    .hwid-block b{color:var(--accent)}
    .empty-state{text-align:center;padding:20px 0;color:var(--text-sec);font-size:13px}
    .node-actions{flex-shrink:0}
    .flag-icon,.flag-fallback{width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;border-radius:50%;overflow:hidden;box-shadow:0 0 0 1px rgba(0,0,0,.18) inset;flex:0 0 auto}
    .flag-icon svg{display:block;width:100%;height:100%}
    .flag-fallback{font-size:10px;font-weight:700;background:var(--border)}
    .row-chevron{color:var(--text-muted,#8a8f98);font-size:22px;line-height:1;padding:0 4px;flex:0 0 auto}
    .list-row.clickable{cursor:pointer}
    .list-row.clickable:active{opacity:.7}
    .flag-fallback{font-size:8px;font-weight:800;background:rgba(255,255,255,.06);color:var(--accent);letter-spacing:.04em}
    .active-name-wrap{display:inline-flex;align-items:center;gap:8px;justify-content:center;flex-wrap:wrap}
    /* v4.14: строки-серверы + чипы протоколов */
    .srv-row{display:flex;align-items:flex-start;justify-content:space-between;gap:8px;padding:14px 0;border-bottom:1px solid var(--border)}
    .srv-row:last-child{border-bottom:none}
    .srv-main{flex:1;min-width:0;cursor:pointer}
    .srv-name{font-weight:700;font-size:14px;color:var(--text);display:flex;align-items:center;gap:8px;word-break:break-word}
    .srv-ip{font-size:10px;color:var(--text-sec);margin-top:2px;letter-spacing:.03em}
    .chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
    .chip{font-size:11px;font-weight:700;padding:5px 11px;border-radius:9px;border:1px solid var(--border);background:rgba(255,255,255,.04);color:var(--text-sec);cursor:pointer;transition:all .12s}
    .chip:active{transform:scale(.95)}
    .chip.ok{color:var(--green)}
    .chip.fail{color:var(--red)}
    .chip.sel{border-color:var(--green);box-shadow:0 0 0 1px var(--green) inset;color:var(--green)}
    .chip.dis{opacity:.38;cursor:not-allowed;text-decoration:line-through}
    .srv-right{flex-shrink:0;display:flex;flex-direction:column;align-items:flex-end;gap:6px}
    .btn-recon{background:rgba(255,183,77,.14);border:1px solid rgba(255,183,77,.45);color:var(--orange);padding:6px 10px;font-size:11px;border-radius:9px;cursor:pointer;font-weight:700;white-space:nowrap}
    .btn-recon:active{transform:scale(.96)}
  </style>
</head>
<body>
  <div id="preloader" class="preloader-overlay"><div class="ghost-loader"><img id="loader_img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTUiIGhlaWdodD0iNjQiIHZpZXdCb3g9IjAgMCA1NSA2NCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPHBhdGggZD0iTTIwLjMzOTQgMC4yODI0MTNDMjQuNzY4OSAtMC40MTM0MTQgMjkuMzA1NSAwLjE4MzAzOSAzMy40MDQ4IDIuMDAwMTlDMzkuOTM2NSA0Ljg5NTY3IDQ0Ljc4MDEgMTAuNjI3MiA0Ni41NDY0IDE3LjU1TDU0LjM3NDUgNDguMjMxNkw1NC40NTU2IDQ4LjYwMDhDNTUuMjQ2MiA1Mi4yMDE1IDUyLjgyMjMgNTUuNzE5IDQ5LjE3NjMgNTYuMjYyOUw0OC44MjE4IDU2LjMxNTZDNDcuMTMyIDU2LjU2NzYgNDUuNDE5NCA1Ni4wMzc1IDQ0LjE2NzUgNTQuODc1Mkw0My42ODIyIDU0LjQyNEM0Mi45NTQyIDUzLjc0NzkgNDEuOTk2OSA1My4zNzIzIDQxLjAwMzQgNTMuMzcyM0MzOS4zOTk5IDUzLjM3MjMgMzcuOTU2NyA1NC4zNDU4IDM3LjM1NSA1NS44MzIyTDM2LjUwNjQgNTcuOTI3OUMzNS44MTgzIDU5LjYyNyAzNC41MTU5IDYxLjAwNDYgMzIuODU3OSA2MS43ODYzTDMxLjk1NTYgNjIuMjExMUMzMC4zMDk4IDYyLjk4NyAyOC40ODE2IDYzLjI5MTcgMjYuNjczNCA2My4wOTFMMjQuMzk0MSA2Mi44MzcxQzIyLjY5MjYgNjIuNjQ4MiAyMS4xNDQ3IDYxLjc2MjggMjAuMTE5NyA2MC4zOTE4TDE3Ljc5MTUgNTcuMjc4NUMxNi4yNzM1IDU1LjI0ODMgMTMuMTgyOCA1NS40MDEzIDExLjg3MjYgNTcuNTcxNUwxMS41MjEgNTguMDQ0MUM5LjkzMTY4IDYwLjE3ODIgNy4xNzYxNCA2MS4wOTAxIDQuNjI3NDYgNjAuMzI1NEw0LjQxMTY0IDYwLjI2QzEuOTU0MjQgNTkuNTIyOCAwLjIzNTg4IDU3LjMwNjkgMC4xMzMzMjMgNTQuNzQzNEwwLjk2NzMwNyAzOS41MTY4QzEuMDk2NCAzNy4xNjA5IDEuMDQ5NDkgMzQuNzk4NCAwLjgyNzY1OSAzMi40NDk0TDAuMDk5MTQyOSAyNC43MzE2Qy0wLjE2MTY1IDIxLjk3MDIgMC4wOTMyMTQ0IDE5LjE4NDQgMC44NTAxMiAxNi41MTU4QzMuMjMzOTQgOC4xMTIxNyAxMC4yODcyIDEuODYxNjMgMTguOTE2NSAwLjUwNjA0NkwyMC4zMzk0IDAuMjgyNDEzWk0xMC4xNTk3IDE4LjkyNUM3LjY1MTQ2IDE4LjkyNSA1LjYxNzcgMjEuNzY0NyA1LjYxNzcgMjUuMjY2OEM1LjYxNzg1IDI4Ljc2ODcgNy42NTE1NiAzMS42MDc2IDEwLjE1OTcgMzEuNjA3NkMxMi42Njc2IDMxLjYwNzMgMTQuNzAwNSAyOC43Njg1IDE0LjcwMDcgMjUuMjY2OEMxNC43MDA3IDIxLjc2NDkgMTIuNjY3NyAxOC45MjUzIDEwLjE1OTcgMTguOTI1Wk0yMy4zNTUgMTguOTI1QzIwLjg0NjkgMTguOTI1IDE4LjgxMzIgMjEuNzY0IDE4LjgxMyAyNS4yNjU4QzE4LjgxMyAyOC43Njc5IDIwLjg0NjggMzEuNjA3NiAyMy4zNTUgMzEuNjA3NkMyNS44NjMxIDMxLjYwNzUgMjcuODk2IDI4Ljc2NzggMjcuODk2IDI1LjI2NThDMjcuODk1OCAyMS43NjQxIDI1Ljg2MyAxOC45MjUxIDIzLjM1NSAxOC45MjVaIiBmaWxsPSJ3aGl0ZSIvPgo8L3N2Zz4K" alt=""></div></div>
  <div id="toast" class="toast"></div>
  <div id="logsModal" class="logs-modal" onclick="if(event.target===this)closeLogs()">
    <div class="logs-content">
      <div class="logs-header">
        <h3 style="margin:0">&#1051;&#1086;&#1075;&#1080; Podkop</h3>
        <button class="btn btn-outline" onclick="closeLogs()">&#1047;&#1072;&#1082;&#1088;&#1099;&#1090;&#1100;</button>
      </div>
      <div>
        <button class="btn btn-outline" onclick="loadLogs()" style="margin-bottom:10px">&#1054;&#1073;&#1085;&#1086;&#1074;&#1080;&#1090;&#1100;</button>
        <button class="btn btn-outline" onclick="loadLogs(200)" style="margin-bottom:10px;margin-left:6px">&#1041;&#1086;&#1083;&#1100;&#1096;&#1077;</button>
      </div>
      <div id="logs_text" class="logs-text">&#1047;&#1072;&#1075;&#1088;&#1091;&#1079;&#1082;&#1072;...</div>
    </div>
  </div>
  <div id="settingsModal" class="logs-modal" onclick="if(event.target===this)closeSettings()">
    <div class="logs-content">
      <div class="logs-header">
        <h3 style="margin:0">&#1057;&#1080;&#1089;&#1090;&#1077;&#1084;&#1085;&#1099;&#1077; &#1085;&#1072;&#1089;&#1090;&#1088;&#1086;&#1081;&#1082;&#1080;</h3>
        <button class="btn btn-outline" onclick="closeSettings()">&#1047;&#1072;&#1082;&#1088;&#1099;&#1090;&#1100;</button>
      </div>
      <div style="display:flex;flex-direction:column;gap:10px">
        <div style="font-size:12px;color:var(--text-sec)">&#1042;&#1077;&#1088;&#1089;&#1080;&#1103; &#1087;&#1072;&#1085;&#1077;&#1083;&#1080;: <b id="set_version" style="color:var(--accent)">&#8212;</b></div>
        <label style="font-size:12px;color:var(--text-sec)">&#1048;&#1089;&#1090;&#1086;&#1095;&#1085;&#1080;&#1082; &#1086;&#1073;&#1085;&#1086;&#1074;&#1083;&#1077;&#1085;&#1080;&#1081; (URL)</label>
        <input type="text" id="update_url" placeholder="https://router.rift.monster/rift.sh">
        <div style="font-size:10px;color:var(--text-sec)">&#1055;&#1086; &#1091;&#1084;&#1086;&#1083;&#1095;&#1072;&#1085;&#1080;&#1102;: <span id="update_url_default"></span></div>
        <div style="display:flex;gap:8px;flex-wrap:wrap">
          <button class="btn btn-primary" onclick="saveUpdateUrl()">&#1057;&#1086;&#1093;&#1088;&#1072;&#1085;&#1080;&#1090;&#1100; URL</button>
          <button class="btn btn-outline" onclick="checkForUpdates()">&#1055;&#1088;&#1086;&#1074;&#1077;&#1088;&#1080;&#1090;&#1100; &#1086;&#1073;&#1085;&#1086;&#1074;&#1083;&#1077;&#1085;&#1080;&#1077;</button>
        </div>
      </div>
    </div>
  </div>
  <div class="container">
    <header class="header" style="padding:12px 0 8px">
      <img class="logo-img" src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTczIiBoZWlnaHQ9IjE3MyIgdmlld0JveD0iMCAwIDE3MyAxNzMiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIxNzMiIGhlaWdodD0iMTczIiByeD0iMjUiIGZpbGw9InVybCgjcGFpbnQwX2xpbmVhcl82OV8yKSIvPgo8cGF0aCBkPSJNNzkuMTQxNiA1Ni40MTAyQzgzLjU3MTEgNTUuNzE0MyA4OC4xMDc3IDU2LjMxMDggOTIuMjA3IDU4LjEyNzlDOTguNzM4NyA2MS4wMjM0IDEwMy41ODIgNjYuNzU0OSAxMDUuMzQ5IDczLjY3NzdMMTEzLjE3NyAxMDQuMzU5TDExMy4yNTggMTA0LjcyOUMxMTQuMDQ4IDEwOC4zMjkgMTExLjYyNSAxMTEuODQ3IDEwNy45NzkgMTEyLjM5MUwxMDcuNjI0IDExMi40NDNDMTA1LjkzNCAxMTIuNjk1IDEwNC4yMjIgMTEyLjE2NSAxMDIuOTcgMTExLjAwM0wxMDIuNDg0IDExMC41NTJDMTAxLjc1NiAxMDkuODc2IDEwMC43OTkgMTA5LjUgOTkuODA1NyAxMDkuNUM5OC4yMDIxIDEwOS41IDk2Ljc1OSAxMTAuNDc0IDk2LjE1NzIgMTExLjk2TDk1LjMwODYgMTE0LjA1NkM5NC42MjA2IDExNS43NTUgOTMuMzE4MiAxMTcuMTMyIDkxLjY2MDIgMTE3LjkxNEw5MC43NTc4IDExOC4zMzlDODkuMTEyMSAxMTkuMTE1IDg3LjI4MzggMTE5LjQxOSA4NS40NzU2IDExOS4yMTlMODMuMTk2MyAxMTguOTY1QzgxLjQ5NDggMTE4Ljc3NiA3OS45NDcgMTE3Ljg5MSA3OC45MjE5IDExNi41Mkw3Ni41OTM4IDExMy40MDZDNzUuMDc1NyAxMTEuMzc2IDcxLjk4NSAxMTEuNTI5IDcwLjY3NDggMTEzLjY5OUw3MC4zMjMyIDExNC4xNzJDNjguNzMzOSAxMTYuMzA2IDY1Ljk3ODQgMTE3LjIxOCA2My40Mjk3IDExNi40NTNMNjMuMjEzOSAxMTYuMzg4QzYwLjc1NjUgMTE1LjY1MSA1OS4wMzgxIDExMy40MzUgNTguOTM1NSAxMTAuODcxTDU5Ljc2OTUgOTUuNjQ0NUM1OS44OTg2IDkzLjI4ODYgNTkuODUxNyA5MC45MjYyIDU5LjYyOTkgODguNTc3MUw1OC45MDE0IDgwLjg1OTRDNTguNjQwNiA3OC4wOTc5IDU4Ljg5NTQgNzUuMzEyMSA1OS42NTIzIDcyLjY0MzZDNjIuMDM2MiA2NC4yMzk5IDY5LjA4OTQgNTcuOTg5NCA3Ny43MTg4IDU2LjYzMzhMNzkuMTQxNiA1Ni40MTAyWk02OC45NjE5IDc1LjA1MjdDNjYuNDUzNyA3NS4wNTI3IDY0LjQxOTkgNzcuODkyNSA2NC40MTk5IDgxLjM5NDVDNjQuNDIwMSA4NC44OTY0IDY2LjQ1MzggODcuNzM1NCA2OC45NjE5IDg3LjczNTRDNzEuNDY5OSA4Ny43MzUxIDczLjUwMjggODQuODk2MiA3My41MDI5IDgxLjM5NDVDNzMuNTAyOSA3Ny44OTI3IDcxLjQ3IDc1LjA1MyA2OC45NjE5IDc1LjA1MjdaTTgyLjE1NzIgNzUuMDUyN0M3OS42NDkxIDc1LjA1MjcgNzcuNjE1NCA3Ny44OTE3IDc3LjYxNTIgODEuMzkzNkM3Ny42MTUyIDg0Ljg5NTYgNzkuNjQ5IDg3LjczNTQgODIuMTU3MiA4Ny43MzU0Qzg0LjY2NTQgODcuNzM1MiA4Ni42OTgyIDg0Ljg5NTUgODYuNjk4MiA4MS4zOTM2Qzg2LjY5ODEgNzcuODkxOCA4NC42NjUzIDc1LjA1MjkgODIuMTU3MiA3NS4wNTI3WiIgZmlsbD0id2hpdGUiLz4KPGRlZnM+CjxsaW5lYXJHcmFkaWVudCBpZD0icGFpbnQwX2xpbmVhcl82OV8yIiB4MT0iMTUxLjk3IiB5MT0iNS40OTc2MiIgeDI9IjIuMjg1NjgiIHkyPSIxOTMuODc1IiBncmFkaWVudFVuaXRzPSJ1c2VyU3BhY2VPblVzZSI+CjxzdG9wIG9mZnNldD0iMC4zMDA5MzEiIHN0b3AtY29sb3I9IiMwMDY4RkYiLz4KPHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjODVEOUZFIi8+CjwvbGluZWFyR3JhZGllbnQ+CjwvZGVmcz4KPC9zdmc+Cg==" alt="RIFT">
    </header>
    <div class="card active-card">
      <h3>&#1040;&#1050;&#1058;&#1048;&#1042;&#1053;&#1054;&#1045; &#1055;&#1054;&#1044;&#1050;&#1051;&#1070;&#1063;&#1045;&#1053;&#1048;&#1045;</h3>
      <span class="server-big" id="active_name">...</span>
      <span class="server-meta" id="sub_name"></span>
      <span class="server-meta" id="sub_expire"></span>
      <span class="server-meta" id="sub_traffic"></span>
      <span class="server-meta" id="sub_updated" style="opacity:.6"></span>
      <button class="btn btn-full" style="background:rgba(255,255,255,.18);color:#fff;border:1px solid rgba(255,255,255,.2)" onclick="updateSubs()">&#1054;&#1073;&#1085;&#1086;&#1074;&#1080;&#1090;&#1100; &#1087;&#1086;&#1076;&#1087;&#1080;&#1089;&#1082;&#1091;</button>
    </div>
    <div class="card">
      <div style="display:flex;justify-content:space-between;align-items:center">
        <h3>&#1057;&#1045;&#1056;&#1042;&#1045;&#1056;&#1067;</h3>
        <button class="btn btn-outline" onclick="checkAvailability()" style="font-size:11px;padding:4px 10px" id="availBtn">&#1044;&#1086;&#1089;&#1090;&#1091;&#1087;&#1085;&#1086;&#1089;&#1090;&#1100;</button>
      </div>
      <div id="nodes_list"></div>
      <div class="input-group">
        <input type="text" id="sub_url" placeholder="&#1057;&#1089;&#1099;&#1083;&#1082;&#1072; &#1085;&#1072; &#1087;&#1086;&#1076;&#1087;&#1080;&#1089;&#1082;&#1091;...">
        <button class="btn btn-primary" onclick="saveUrl()">&#1057;&#1086;&#1093;&#1088;&#1072;&#1085;&#1080;&#1090;&#1100;</button>
      </div>
    </div>
    <div class="card">
      <h3>&#1055;&#1086;&#1083;&#1085;&#1099;&#1081; VPN &#1076;&#1083;&#1103; &#1091;&#1089;&#1090;&#1088;&#1086;&#1081;&#1089;&#1090;&#1074;</h3>
      <div style="display:flex;gap:6px;margin-bottom:8px">
        <button class="btn btn-outline" id="mode_mac" style="flex:1;font-size:12px;padding:8px 6px" onclick="setVpnMode('mac')">MAC</button>
        <button class="btn btn-outline" id="mode_ip" style="flex:1;font-size:12px;padding:8px 6px" onclick="setVpnMode('ip')">IP</button>
      </div>
      <div id="vpn_hint" style="font-size:11px;color:var(--text-sec);margin-bottom:8px"></div>
      <div id="vpn_list" style="margin-top:4px"></div>
    </div>
    <div class="card">
      <h3>&#1044;&#1086;&#1084;&#1077;&#1085;&#1099; &#1095;&#1077;&#1088;&#1077;&#1079; VPN</h3>
      <div class="input-group">
        <input type="text" id="new_domain" placeholder="domain.com">
        <button class="btn btn-primary" onclick="addDomain()">+</button>
      </div>
      <div id="domains_list" style="margin-top:10px"></div>
    </div>
    <div class="card">
      <h3>DNS</h3>
      <div style="display:flex;flex-direction:column;gap:6px">
        <label class="dns-label">&#1058;&#1080;&#1087; &#1087;&#1088;&#1086;&#1090;&#1086;&#1082;&#1086;&#1083;&#1072; DNS</label>
        <select id="dns_type" class="dns-select">
          <option value="doh">DNS &#1095;&#1077;&#1088;&#1077;&#1079; HTTPS (DoH)</option>
          <option value="dot">DNS &#1095;&#1077;&#1088;&#1077;&#1079; TLS (DoT)</option>
          <option value="udp">UDP (&#1053;&#1077;&#1079;&#1072;&#1097;&#1080;&#1097;&#1105;&#1085;&#1085;&#1099;&#1081; DNS)</option>
        </select>
        <label class="dns-label" style="margin-top:8px">DNS-&#1089;&#1077;&#1088;&#1074;&#1077;&#1088;</label>
        <select id="dns_server" class="dns-select"></select>
        <label class="dns-label" style="margin-top:8px">Bootstrap DNS-&#1089;&#1077;&#1088;&#1074;&#1077;&#1088;</label>
        <select id="dns_bootstrap" class="dns-select"></select>
        <button class="btn btn-primary" style="margin-top:10px" onclick="saveDns()">&#1057;&#1086;&#1093;&#1088;&#1072;&#1085;&#1080;&#1090;&#1100; DNS</button>
      </div>
    </div>
    <div class="card">
      <h3>&#1059;&#1089;&#1090;&#1088;&#1086;&#1081;&#1089;&#1090;&#1074;&#1086;</h3>
      <div id="hwid_info" class="hwid-block">&#1047;&#1072;&#1075;&#1088;&#1091;&#1079;&#1082;&#1072;...</div>
    </div>
    <div class="card">
      <h3>Диагностика</h3>
      <div style="font-size:11px;color:var(--text-sec);margin-bottom:10px">Собирает подробные логи роутера и отправляет их в поддержку RIFT для поиска причины сбоя. Включайте только по просьбе поддержки. Автоматически отключится через 24 часа.</div>
      <div style="display:flex;gap:8px;margin-bottom:10px">
        <button class="btn btn-outline" id="diag_on" style="flex:1;font-size:12px;padding:8px 6px" onclick="setDiag(1)">Включить</button>
        <button class="btn btn-outline" id="diag_off" style="flex:1;font-size:12px;padding:8px 6px" onclick="setDiag(0)">Выключить</button>
      </div>
      <div id="diag_status" style="font-size:11px;color:var(--text-sec)">&#8212;</div>
    </div>
    <div class="card" style="text-align:center">
      <button class="btn btn-outline" onclick="openLogs()" style="margin:4px">&#1051;&#1086;&#1075;&#1080;</button>
      <button class="btn btn-outline" onclick="openSettings()" style="margin:4px">&#1057;&#1080;&#1089;&#1090;&#1077;&#1084;&#1085;&#1099;&#1077; &#1085;&#1072;&#1089;&#1090;&#1088;&#1086;&#1081;&#1082;&#1080;</button>
    </div>
  </div>
<script>
  function esc(s){let d=document.createElement('div');d.textContent=s;return d.innerHTML;}
  function normalizeUrl(url){return(url||"").replace(/#.*$/,'');}
  function safeDecode(value){try{return decodeURIComponent(value||'');}catch(e){return String(value||'');}}
  function getParam(url,key){const m=String(url||'').match(new RegExp('[?&]'+key+'=([^&#]*)'));return m?safeDecode(m[1]):'';}
  function buildConnectionKey(url){
    const raw=String(url||'').trim();
    const base=raw.replace(/#.*$/,'');
    const proto=(base.match(/^([a-z0-9+.-]+):\/\//i)||[])[1]||'';
    const user=(base.match(/:\/\/([^@/?#]+)@/)||[])[1]||'';
    const host=(base.match(/@([^:/?#]+)|:\/\/([^:/?#]+)/)||[]);
    const port=(base.match(/@[^:/?#]+:(\d+)|:\/\/[^:/?#]+:(\d+)/)||[]);
    const extra=getParam(base,'extra');
    const extraHost=(String(extra).match(/\"host\"\s*:\s*\"(.*?)\"/)||[])[1]||'';
    const extraPath=(String(extra).match(/\"path\"\s*:\s*\"(.*?)\"/)||[])[1]||'';
    const extraMode=(String(extra).match(/\"mode\"\s*:\s*\"(.*?)\"/)||[])[1]||'';
    const hostHeader=(getParam(base,'host')||safeDecode(extraHost)||getParam(base,'sni')).toLowerCase();
    return [
      proto.toLowerCase(),
      safeDecode(user).toLowerCase(),
      (host[1]||host[2]||'').toLowerCase(),
      port[1]||port[2]||'',
      getParam(base,'type').toLowerCase()||'tcp',
      getParam(base,'security').toLowerCase(),
      hostHeader,
      getParam(base,'path')||getParam(base,'bx')||getParam(base,'spx')||safeDecode(extraPath),
      getParam(base,'serviceName'),
      getParam(base,'mode')||safeDecode(extraMode),
      getParam(base,'flow'),
      getParam(base,'sni'),
      getParam(base,'pbk'),
      getParam(base,'sid')
    ].join('|');
  }
  // SVG-флаги только для нужных стран: Германия, Финляндия, США.
  // Если в имени узла появится другой emoji-флаг — getFlagCode вернёт код страны,
  // и UI отрендерит fallback (двухбуквенный chip через CSS класс .flag-fallback).
  const FLAG_SVG={
    DE:'<svg viewBox="0 0 18 12" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="12" fill="#FFCE00"/><rect width="18" height="8" fill="#DD0000"/><rect width="18" height="4" fill="#111"/></svg>',
    FI:'<svg viewBox="0 0 18 12" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="12" fill="#FFFFFF"/><rect x="5" width="3" height="12" fill="#003580"/><rect y="4" width="18" height="3" fill="#003580"/></svg>',
    US:'<svg viewBox="0 0 18 12" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="12" fill="#B22234"/><rect y="1" width="18" height="1" fill="#FFFFFF"/><rect y="3" width="18" height="1" fill="#FFFFFF"/><rect y="5" width="18" height="1" fill="#FFFFFF"/><rect y="7" width="18" height="1" fill="#FFFFFF"/><rect y="9" width="18" height="1" fill="#FFFFFF"/><rect y="11" width="18" height="1" fill="#FFFFFF"/><rect width="8" height="6.5" fill="#3C3B6E"/></svg>',
    RU:'<svg viewBox="0 0 18 12" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg"><rect width="18" height="12" fill="#FFFFFF"/><rect y="4" width="18" height="4" fill="#0039A6"/><rect y="8" width="18" height="4" fill="#D52B1E"/></svg>'
  };
  let globalServers=[],activeKey="",activeTransport="",domains=[],availData={},dnsProtectionState=null,vpnMode='mac';
  function stripFlag(name){return String(name||'').replace(/[\u{1F1E6}-\u{1F1FF}]{2}/gu,'').replace(/^\s+/,'').trim();}
  function transportLabel(t){t=String(t||'').toLowerCase();if(t==='grpc')return 'gRPC';if(t==='hy2'||t==='hysteria2')return 'HY2';if(t==='xhttp'||t==='splithttp')return 'XHTTP';return 'TCP';}
  const RU={
    request_failed:'\u041e\u0448\u0438\u0431\u043a\u0430',
    regular_singbox:'\u041e\u0431\u043d\u0430\u0440\u0443\u0436\u0435\u043d \u043e\u0431\u044b\u0447\u043d\u044b\u0439 sing-box. \u041f\u0440\u0438 \u043f\u0435\u0440\u0432\u043e\u043c XHTTP-\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0438 \u043f\u0430\u043d\u0435\u043b\u044c \u0430\u0432\u0442\u043e\u043c\u0430\u0442\u0438\u0447\u0435\u0441\u043a\u0438 \u043f\u043e\u0441\u0442\u0430\u0432\u0438\u0442 sing-box-extended.',
    subscription:'\u041f\u043e\u0434\u043f\u0438\u0441\u043a\u0430',
    expire:'\u0414\u0435\u0439\u0441\u0442\u0432\u0443\u0435\u0442 \u0434\u043e',
    traffic:'\u0422\u0440\u0430\u0444\u0438\u043a',
    updated:'\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e',
    just_now:'\u0442\u043e\u043b\u044c\u043a\u043e \u0447\u0442\u043e',
    no_expiry:'\u0411\u0435\u0437 \u0441\u0440\u043e\u043a\u0430',
    expired:'\u0418\u0441\u0442\u0451\u043a',
    unlimited:'\u0411\u0435\u0437\u043b\u0438\u043c\u0438\u0442',
    no_active:'\u041d\u0435\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f',
    no_servers:'\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u0443\u0441\u0442 \u2014 \u0434\u043e\u0431\u0430\u0432\u044c\u0442\u0435 \u043f\u043e\u0434\u043f\u0438\u0441\u043a\u0443',
    reconnect:'\u041f\u0435\u0440\u0435\u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c',
    connect:'\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c',
    ping:'\u041f\u0438\u043d\u0433',
    updated_ok:'\u041e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u043e',
    saved_ok:'\u0421\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u043e',
    servers_word:'\u0441\u0435\u0440\u0432\u0435\u0440\u043e\u0432',
    update_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f',
    save_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f',
    connect_to:'\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a',
    connect_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f',
    ping_updated:'\u041f\u0438\u043d\u0433 \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d',
    no_devices:'\u041d\u0435\u0442 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432',
    vpn_on:'VPN',
    offline:'офлайн',
    settings_saved:'Настройки сохранены',
    settings_save_failed:'Ошибка сохранения настроек',
    settings_failed:'Ошибка загрузки настроек',
    enable:'\u0412\u043a\u043b\u044e\u0447\u0438\u0442\u044c',
    empty_list:'\u0421\u043f\u0438\u0441\u043e\u043a \u043f\u0443\u0441\u0442',
    remove:'\u0423\u0434\u0430\u043b\u0438\u0442\u044c',
    no_logs:'\u041b\u043e\u0433\u0438 \u043f\u0443\u0441\u0442\u044b',
    error:'\u041e\u0448\u0438\u0431\u043a\u0430',
    loading:'\u0417\u0430\u0433\u0440\u0443\u0437\u043a\u0430...',
    server_fallback:'\u0421\u0435\u0440\u0432\u0435\u0440',
    proxy_word:'PROXY',
    bootstrap_label:'Bootstrap DNS',
    network_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u0435\u0442\u0438',
    domain_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u0434\u043e\u043c\u0435\u043d\u0430',
    updates_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u043e\u0431\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f',
    data_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u0437\u0430\u0433\u0440\u0443\u0437\u043a\u0438',
    model:'\u041c\u043e\u0434\u0435\u043b\u044c',
    dns_unavailable:'DNS \u0441\u0442\u0430\u0442\u0443\u0441 \u043d\u0435\u0434\u043e\u0441\u0442\u0443\u043f\u0435\u043d',
    dns_read_failed:'\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0447\u0438\u0442\u0430\u0442\u044c DNS \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438 Podkop',
    retry:'\u041f\u043e\u0432\u0442\u043e\u0440',
    dns_protected:'\u0417\u0430\u0449\u0438\u0449\u0435\u043d\u043e',
    dns_custom_secure:'\u0421\u0432\u043e\u0439 \u0437\u0430\u0449\u0438\u0449\u0435\u043d\u043d\u044b\u0439 DNS',
    dns_unprotected:'DNS \u043d\u0435 \u0437\u0430\u0449\u0438\u0449\u0435\u043d',
    disable:'\u041e\u0442\u043a\u043b\u044e\u0447\u0438\u0442\u044c',
    activate:'\u0410\u043a\u0442\u0438\u0432\u0438\u0440\u043e\u0432\u0430\u0442\u044c',
    dns_profile_updated:'DNS \u043f\u0440\u043e\u0444\u0438\u043b\u044c \u043e\u0431\u043d\u043e\u0432\u043b\u0451\u043d',
    dns_toggle_failed:'\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0435\u0440\u0435\u043a\u043b\u044e\u0447\u0438\u0442\u044c DNS',
    dns_enabled:'\u0417\u0430\u0449\u0438\u0442\u0430 DNS \u0432\u043a\u043b\u044e\u0447\u0435\u043d\u0430',
    dns_disabled:'\u0417\u0430\u0449\u0438\u0442\u0430 DNS \u043e\u0442\u043a\u043b\u044e\u0447\u0435\u043d\u0430',
    dns_saved:'DNS \u0441\u043e\u0445\u0440\u0430\u043d\u0451\u043d',
    dns_save_failed:'\u041e\u0448\u0438\u0431\u043a\u0430 \u0441\u043e\u0445\u0440\u0430\u043d\u0435\u043d\u0438\u044f DNS',
    dns_server_empty:'\u0423\u043a\u0430\u0436\u0438\u0442\u0435 DNS-\u0441\u0435\u0440\u0432\u0435\u0440',
    check_singbox:'\u041f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c sing-box \u0438 \u043f\u0440\u0438 \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e\u0441\u0442\u0438 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u0438\u0442\u044c sing-box extended \u0434\u043b\u044f XHTTP? \u042d\u0442\u043e \u043c\u043e\u0436\u0435\u0442 \u043f\u0435\u0440\u0435\u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c podkop.',
    check_completed:'\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u0437\u0430\u0432\u0435\u0440\u0448\u0435\u043d\u0430',
    before:'\u0414\u043e',
    after:'\u041f\u043e\u0441\u043b\u0435',
    update_available:'\u0414\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u043d\u043e\u0432\u0430\u044f \u0432\u0435\u0440\u0441\u0438\u044f ',
    current_version:'\u0443 \u0432\u0430\u0441',
    latest_version:'\u0423 \u0432\u0430\u0441 \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u0432\u0435\u0440\u0441\u0438\u044f',
    seconds_ago:'\u0441\u0435\u043a \u043d\u0430\u0437\u0430\u0434',
    minutes_ago:'\u043c\u0438\u043d \u043d\u0430\u0437\u0430\u0434',
    hours_ago:'\u0447 \u043d\u0430\u0437\u0430\u0434',
    days_ago:'\u0434 \u043d\u0430\u0437\u0430\u0434'
  };
  function showToast(msg,ms=6000){const el=document.getElementById('toast');el.textContent=msg;el.style.display='block';clearTimeout(window.__t);window.__t=setTimeout(()=>{el.style.display='none';},ms);}
  function showLoader(){document.getElementById('preloader').style.display='flex';}
  function hideLoader(){document.getElementById('preloader').style.display='none';}
  function renderFlag(code){
    code=String(code||'').toUpperCase();
    if(!code)return '';
    if(FLAG_SVG[code])return `<span class="flag-icon" title="${esc(code)}">${FLAG_SVG[code]}</span>`;
    return `<span class="flag-fallback" title="${esc(code)}">${esc(code)}</span>`;
  }
  function getFlagCode(name){
    const raw=String(name||'');
    const lead=raw.match(/(^|\s)([A-Z]{2})(?=\s|$)/);
    const pair=raw.match(/[\u{1F1E6}-\u{1F1FF}]{2}/u);
    if(pair){
      const code=[...pair[0]].map(ch=>String.fromCharCode(ch.codePointAt(0)-0x1F1E6+65)).join('');
      if(code)return code;
    }
    return lead?lead[2]:'';
  }
  function cleanNodeName(name){
    let s=String(name||RU.server_fallback);
    s=s.replace(/[\u{1F1E6}-\u{1F1FF}]{2}/gu,' ');
    s=s.replace(/(^|\s)[A-Z]{2}(?=\s|$)/g,' ');
    s=s.replace(/[\u3164]/gu,' ');
    s=s.replace(/^[\s\-]+/u,' ');   // \u043E\u0441\u0442\u0430\u0432\u043B\u044F\u0435\u043C \u2514\u2500 (U+2500-257F) \u2014 \u044D\u0442\u043E \u0434\u0435\u0440\u0435\u0432\u043E \u043F\u043E\u0434-\u0443\u0437\u043B\u043E\u0432 \u0432 \u043C\u0430\u043A\u0435\u0442\u0435
    s=s.replace(/\s+/g,' ').trim();
    return s||RU.server_fallback;
  }
  function getNodeProtocol(node){
    return (String(node.transport||'')==='hysteria2')?'HYSTERIA':'VLESS';
  }
  function getNodeSecurity(node){
    const s=String(node.security||'').trim().toUpperCase();
    if(s)return s;
    return (String(node.transport||'')==='hysteria2')?'TLS':'REALITY';
  }
  function getNodeTransport(node){
    const t=String(node.transport||'tcp').toLowerCase();
    if(t==='hysteria2')return 'HYSTERIA';
    if(t==='grpc')return 'GRPC';
    if(t==='xhttp'||t==='splithttp')return 'XHTTP';
    return 'TCP';
  }
  function getNodeMeta(node){
    // Формат как в макете: VLESS / TCP / REALITY / JSON  (HY2: HYSTERIA / HYSTERIA / TLS / JSON)
    return [getNodeProtocol(node),getNodeTransport(node),getNodeSecurity(node),'JSON'].join(' / ');
  }
  function describeNode(node){
    const title=cleanNodeName(node&&node.name||RU.server_fallback);
    const code=getFlagCode(node&&node.name||'');
    return {title,code,flagHtml:renderFlag(code)};
  }
  // Заполняет <select> опциями из списка [{v,l}], выбирает current.
  // Если current нет в списке (кастомное значение уже стоит в podkop) — добавляем его.
  function fillSelect(el,list,current){
    if(!el)return;
    el.innerHTML='';
    let found=false;
    (list||[]).forEach(o=>{
      const opt=document.createElement('option');
      opt.value=o.v; opt.textContent=o.l||o.v;
      if(o.v===current)found=true;
      el.appendChild(opt);
    });
    if(current&&!found){
      const opt=document.createElement('option');
      opt.value=current; opt.textContent=current+' (текущий)';
      el.appendChild(opt);
    }
    if(current)el.value=current;
  }
  function renderDnsSettings(s){
    dnsProtectionState=s||null;
    const typeEl=document.getElementById('dns_type');
    if(typeEl){
      const t=String((s&&s.dns_type)||'').toLowerCase();
      if(t==='udp'||t==='dot'||t==='doh')typeEl.value=t;
    }
    fillSelect(document.getElementById('dns_server'), s&&s.servers, s&&s.dns_server);
    fillSelect(document.getElementById('dns_bootstrap'), s&&s.bootstraps, s&&s.bootstrap_dns_server);
  }
  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("RPC: "+text.slice(0,160));}
    if(data&&data.status==="error") throw new Error(data.msg||RU.request_failed);
    return data;
  }
  window.onload=async function(){
    try{
      const logo=document.querySelector('.logo-img');
      const loader=document.getElementById('loader_img');
      const favicon=document.getElementById('favicon');
      if(logo&&favicon)favicon.href=logo.src;
      if(logo&&loader&&(!loader.getAttribute('src')||loader.getAttribute('src')===''))loader.src=logo.src;
    }catch(e){}
    try{const r=await api('get_sub_url');if(r.url)document.getElementById('sub_url').value=r.url;}catch(e){}
    try{const r=await api('get_hwid_info');document.getElementById('hwid_info').innerHTML='<b>HWID:</b> '+(r.hwid||'?')+'<br><b>OS:</b> '+(r.os_type||'')+' '+(r.os_version||'')+'<br><b>'+RU.model+':</b> '+(r.device_model||'?');}catch(e){}
    await loadData();await loadNetwork();await loadDnsSettings();
    try{await loadDiag();}catch(e){}
  };
  function relativeTime(input){
    if(!input)return '';
    let sec=0;
    if(typeof input==='number' && input>0){
      sec=Math.floor(Date.now()/1000-input);
    }else{
      const d=new Date(String(input));
      if(Number.isNaN(d.getTime()))return '';
      sec=Math.floor((Date.now()-d.getTime())/1000);
    }
    if(sec<0)sec=0;
    if(sec<5)return RU.just_now;
    if(sec<60)return sec+' '+RU.seconds_ago;
    if(sec<3600)return Math.floor(sec/60)+' '+RU.minutes_ago;
    if(sec<86400)return Math.floor(sec/3600)+' '+RU.hours_ago;
    return Math.floor(sec/86400)+' '+RU.days_ago;
  }
  function formatSubExpire(value){
    const v=String(value||'').trim();
    if(!v)return '';
    if(v==='No expiry')return RU.no_expiry;
    if(v==='Expired')return RU.expired;
    return v;
  }
  function formatSubTraffic(value){
    const v=String(value||'').trim();
    if(!v)return '';
    return v.replace(/unlimited/gi,RU.unlimited);
  }
  function extractSubId(url){
    if(!url)return '';
    const parts=url.replace(/\/$/,'').split('/').filter(p=>p);
    if(parts.length>=2)return parts[parts.length-2];
    return parts[parts.length-1]||'';
  }
  async function loadData(){
    try{
      const d=await api('get_nodes');
      globalServers=Array.isArray(d.servers)?d.servers:[];
      activeKey=d.active_key||"";
      activeTransport=d.active_transport||"";
      const subUrl=document.getElementById('sub_url').value||'';
      const subId=extractSubId(subUrl);
      const subTitle=String(d.sub_title||'').trim()||subId;
      document.getElementById('sub_name').innerText=subTitle?(RU.subscription+': '+subTitle):'';
      document.getElementById('sub_expire').innerText=d.sub_expire?(RU.expire+': '+formatSubExpire(d.sub_expire)):'';
      document.getElementById('sub_traffic').innerText=d.sub_traffic?(RU.traffic+': '+formatSubTraffic(d.sub_traffic)):'';
      document.getElementById('sub_updated').innerText=(d.updated_epoch||d.updated)?(RU.updated+' '+relativeTime(Number(d.updated_epoch)||d.updated)):'';
      let activeHtml=RU.no_active;
      if(activeKey&&globalServers.length){
        const S=globalServers.find(x=>(x.key||"")===activeKey);
        if(S){
          activeHtml=`<span class="active-name-wrap">${renderFlag(getFlagCode(S.name))}<span>${esc(stripFlag(S.name))}${activeTransport?' · '+esc(transportLabel(activeTransport)):''}</span></span>`;
        }
      }
      document.getElementById('active_name').innerHTML=activeHtml;
      renderNodes();
    }catch(e){showToast(RU.data_failed+': '+e.message);}
  }
  function renderNodes(){
    const div=document.getElementById("nodes_list");
    if(!globalServers.length){div.innerHTML='<div class="empty-state">'+RU.no_servers+'</div>';return;}
    let h="";
    globalServers.forEach((S)=>{
      const isActiveSrv=(S.key||"")===activeKey;
      const code=getFlagCode(S.name);
      const title=stripFlag(S.name);
      const av=availData[S.key];   // 'ok' | 'fail' | undefined
      const encKey=encodeURIComponent(S.key);
      let chips="";
      (S.variants||[]).forEach(v=>{
        const t=v.transport;
        const sel=(isActiveSrv && t===activeTransport);
        let cls="chip";
        if(v.unsupported){cls+=" dis";}
        else{
          if(av==='ok')cls+=" ok"; else if(av==='fail')cls+=" fail";
          if(sel)cls+=" sel";
        }
        const oc=v.unsupported?'title="XHTTP не работает на роутере"':`onclick="event.stopPropagation();connect('${encKey}','${t}')"`;
        chips+=`<span class="${cls}" ${oc}>${esc(transportLabel(t))}</span>`;
      });
      const recon=S.needs_reconnect?`<button class="btn-recon" onclick="event.stopPropagation();reconnect('${encKey}')">${RU.reconnect}</button>`:'';
      h+=`<div class="srv-row"><div class="srv-main" onclick="connectDefault('${encKey}')"><div class="srv-name">${renderFlag(code)}<span>${esc(title)}</span></div><div class="chips">${chips}</div></div><div class="srv-right">${recon}</div></div>`;
    });
    div.innerHTML=h;
  }
  async function updateSubs(){showLoader();try{const r=await api('update_subs',{});showToast(`${RU.updated_ok}: ${r.count||"?"} ${RU.servers_word}`);await loadData();}catch(e){showToast(RU.update_failed+': '+e.message,10000);}finally{hideLoader();}}
  async function saveUrl(){const u=document.getElementById('sub_url').value;if(!u)return;showLoader();try{const r=await api('update_subs',{url:u});showToast(`${RU.saved_ok}: ${r.count||"?"} ${RU.servers_word}`);await loadData();}catch(e){showToast(RU.save_failed+': '+e.message,10000);}finally{hideLoader();}}
  async function connect(encKey,transport){
    const key=decodeURIComponent(encKey);
    const S=globalServers.find(x=>(x.key||"")===key);
    const title=S?stripFlag(S.name):key;
    if(!confirm(`${RU.connect_to} ${title} (${transportLabel(transport)})?`))return;
    showLoader();
    try{
      const r=await api('apply',{key,transport});
      showToast(`${title} · ${transportLabel(r.transport||transport)}`,6000);
      await new Promise(r=>setTimeout(r,2500));await loadData();
    }catch(e){showToast(RU.connect_failed+': '+e.message,10000);}finally{hideLoader();}
  }
  function connectDefault(encKey){connect(encKey,'tcp');}
  function reconnect(encKey){connect(encKey,activeTransport||'tcp');}
  async function checkAvailability(){
    const btn=document.getElementById('availBtn');const t0=btn.textContent;btn.textContent='...';btn.disabled=true;
    try{const r=await api('check_availability');if(r.avail){availData=r.avail;renderNodes();showToast('Проверено');}}
    catch(e){showToast('Доступность: '+e.message,8000);}
    finally{btn.textContent=t0;btn.disabled=false;}
  }
  function renderVpnMode(mode){
    vpnMode=(mode==='ip')?'ip':'mac';
    const mm=document.getElementById('mode_mac'),mi=document.getElementById('mode_ip');
    if(mm)mm.className='btn '+(vpnMode==='mac'?'btn-active':'btn-outline');
    if(mi)mi.className='btn '+(vpnMode==='ip'?'btn-active':'btn-outline');
    const h=document.getElementById('vpn_hint');
    if(h)h.textContent=(vpnMode==='mac')
      ?'Устройство привязывается по MAC-адресу. Даже если его IP в сети поменяется — полный VPN останется включённым.'
      :'Устройство привязывается по текущему IP-адресу. Если IP поменяется — включите устройство заново.';
  }
  async function loadNetwork(){
    try{
      const d=await api('get_network');
      const c=d.clients||[];
      const off=Array.isArray(d.vpn_offline)?d.vpn_offline:[];
      domains=Array.isArray(d.domains)?d.domains:[];
      renderVpnMode(d.mode);
      let vh="";
      // онлайн-устройства (LAN): кнопка вкл/выкл. В mac-режиме привязка по MAC,
      // в ip-режиме пишем текущий IP. Бэкенд сам выбирает по режиму — шлём и mac, и ip.
      c.forEach(x=>{
        const nm=encodeURIComponent(x.name||x.mac||x.ip);
        const btn=x.enabled
          ?`<button class="btn btn-active" onclick="toggleVpn('${x.mac||''}','${x.ip||''}','del','${nm}')">${RU.vpn_on}</button>`
          :`<button class="btn btn-outline" onclick="toggleVpn('${x.mac||''}','${x.ip||''}','add','${nm}')">${RU.enable}</button>`;
        vh+=`<div class="list-row"><div class="node-info"><span class="item-name">${esc(x.name)}</span><span class="item-sub">${esc(x.ip)} · ${esc(x.mac)}</span></div><div class="node-actions">${btn}</div></div>`;
      });
      // включённые, но сейчас офлайн (mac: привязка по MAC; ip: routed-IP без онлайн-клиента)
      off.forEach(x=>{
        const nm=encodeURIComponent(x.name||x.mac||x.ip);
        const sub=x.mac?(esc(x.mac)+' · '+RU.offline):(esc(x.ip)+' · '+RU.offline);
        vh+=`<div class="list-row" style="opacity:.55"><div class="node-info"><span class="item-name">${esc(x.name)}</span><span class="item-sub">${sub}</span></div><div class="node-actions"><button class="btn btn-active" onclick="toggleVpn('${x.mac||''}','${x.ip||''}','del','${nm}')">${RU.vpn_on}</button></div></div>`;
      });
      document.getElementById("vpn_list").innerHTML=vh||'<div class="empty-state">'+RU.no_devices+'</div>';
      let dh="";domains.forEach(dom=>{dh+=`<div class="list-row"><div class="node-info"><span class="item-name">${esc(dom)}</span></div><div class="node-actions"><button class="btn btn-danger" onclick="manageDomain('${dom}','del')">${RU.remove}</button></div></div>`;});
      document.getElementById('domains_list').innerHTML=dh||'<div class="empty-state">'+RU.empty_list+'</div>';
    }catch(e){showToast(RU.network_failed+': '+e.message);}
  }
  async function loadDnsSettings(){
    try{
      const r=await api('get_dns_settings');
      renderDnsSettings(r);
    }catch(e){
      renderDnsSettings(null);
      showToast('DNS: '+e.message,8000);
    }
  }
  async function saveDns(){
    const dns_type=document.getElementById('dns_type').value;
    const dns_server=document.getElementById('dns_server').value.trim();
    const bootstrap=document.getElementById('dns_bootstrap').value.trim();
    if(!dns_server){showToast(RU.dns_server_empty,8000);return;}
    showLoader();
    try{
      await api('set_dns_settings',{dns_type,dns_server,bootstrap});
      await loadDnsSettings();   // перечитываем со списками (ответ set не содержит опций)
      showToast(RU.dns_saved,8000);
    }catch(e){
      showToast(RU.dns_save_failed+': '+e.message,10000);
    }finally{hideLoader();}
  }
  async function toggleVpn(mac,ip,a,name){showLoader();try{await api('manage_vpn',{mac,ip,action:a,name:decodeURIComponent(name||'')});await new Promise(r=>setTimeout(r,1500));await loadNetwork();}catch(e){showToast('VPN: '+e.message);}finally{hideLoader();}}
  async function setVpnMode(m){showLoader();try{await api('set_vpn_mode',{mode:m});await new Promise(r=>setTimeout(r,1200));await loadNetwork();}catch(e){showToast('Режим VPN: '+e.message,8000);}finally{hideLoader();}}
  function renderDiag(d){
    const on=!!(d&&d.enabled);
    const bOn=document.getElementById('diag_on'),bOff=document.getElementById('diag_off');
    if(bOn)bOn.classList.toggle('btn-primary',on);
    if(bOff)bOff.classList.toggle('btn-primary',!on);
    let s='';
    if(on){s='<b style="color:var(--green)">Включена</b>';}
    else{s='<b>Выключена</b>';}
    if(d&&d.hwid)s+='<br><b>Router ID:</b> '+esc(d.hwid);
    if(on&&d&&d.started&&d.ttl){
      const rem=(Number(d.started)+Number(d.ttl))-(Number(d.now)||Math.floor(Date.now()/1000));
      if(rem>0){const h=Math.floor(rem/3600),mn=Math.floor((rem%3600)/60);s+='<br>авто-выключение через '+(h>0?h+' ч ':'')+mn+' мин';}
    }
    if(on&&d&&d.last_send){const m=String(d.last_send).match(/(\d{2}:\d{2}:\d{2})/);if(m)s+='<br>последняя отправка: '+m[1];}
    const el=document.getElementById('diag_status');if(el)el.innerHTML=s;
  }
  async function loadDiag(){try{const d=await api('get_diag_mode');renderDiag(d);}catch(e){}}
  async function setDiag(on){showLoader();try{const d=await api('set_diag_mode',{on:String(on)});renderDiag(d);showToast(on?('Диагностика включена. Router ID: '+(d.hwid||'?')):'Диагностика выключена',7000);await new Promise(r=>setTimeout(r,800));await loadDiag();}catch(e){showToast('Диагностика: '+e.message,8000);}finally{hideLoader();}}
  async function manageDomain(d,a){showLoader();try{await api('manage_domain',{domain:d,action:a});await new Promise(r=>setTimeout(r,2000));await loadNetwork();}catch(e){showToast(RU.domain_failed+': '+e.message);}finally{hideLoader();}}
  function addDomain(){const d=document.getElementById('new_domain').value;if(d)manageDomain(d,'add');document.getElementById('new_domain').value="";}
  function openLogs(){document.getElementById('logsModal').style.display='block';loadLogs();}
  function closeLogs(){document.getElementById('logsModal').style.display='none';}
  function openSettings(){document.getElementById('settingsModal').style.display='block';loadSettings();}
  function closeSettings(){document.getElementById('settingsModal').style.display='none';}
  async function loadSettings(){try{const r=await api('get_update_url');document.getElementById('update_url').value=r.url||'';document.getElementById('update_url_default').textContent=r.default||'';document.getElementById('set_version').textContent=r.panel_version||'—';}catch(e){showToast(RU.settings_failed+': '+e.message,8000);}}
  async function saveUpdateUrl(){const u=document.getElementById('update_url').value.trim();showLoader();try{const r=await api('set_update_url',{url:u});document.getElementById('update_url').value=r.url||u;showToast(RU.settings_saved,6000);}catch(e){showToast(RU.settings_save_failed+': '+e.message,10000);}finally{hideLoader();}}
  async function loadLogs(lines){try{const r=await api('get_logs',{lines:lines||50});const el=document.getElementById('logs_text');el.textContent=r.logs||RU.no_logs;el.scrollTop=el.scrollHeight;}catch(e){document.getElementById('logs_text').textContent=RU.error+': '+e.message;}}
  async function checkForUpdates(){showLoader();try{const r=await api('check_for_update');if(r.status==='update_available'){if(confirm(`${RU.update_available}${r.remote_v} (${RU.current_version} ${r.local_v}).`)){await api('perform_update');await new Promise(r=>setTimeout(r,4000));location.reload();}}else showToast(`${RU.latest_version} (${r.local_v}).`);}catch(e){showToast(RU.updates_failed+': '+e.message);}finally{hideLoader();}}
</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">
  <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; }
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
chmod +x /www/podkop_panel/cgi-bin/rpc
sed -i 's/\r$//' /www/podkop_panel/cgi-bin/rpc
# Подставляем версию парсера — при апгрейде первый 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
/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 -q '"servers"' /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 "================================================="
