#!/bin/sh # === RIFT PANEL INSTALLER & UPDATER (v4.12) === # Install: sh <(wget -O - https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh) # # 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.12" 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 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 opkg install ca-bundle coreutils-base64 lua uclient-fetch curl >>"$INSTALL_LOG" 2>&1 || true logi " -> lua=$(command -v lua || echo НЕТ) uclient-fetch=$(command -v uclient-fetch || echo НЕТ) curl=$(command -v curl || 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 -> всегда перезаписать. # Применяется при ближайшем рестарте podkop (откат ниже / первое подключение узла). RIFT_DNS_TYPE="udp" RIFT_DNS_SERVER="77.88.8.8" RIFT_DNS_BOOTSTRAP="77.88.8.8" RIFT_DNS_FORCE="0" logi "[4.5/10] DNS-дефолты podkop (force=${RIFT_DNS_FORCE})..." if uci -q get podkop.settings >/dev/null 2>&1; then _dns_def(){ [ -n "$2" ] || return 0; local c; c="$(uci -q get podkop.settings.$1)"; if [ "$RIFT_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)" # 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]/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 local function fetch_to_file(url, out, err, extra_headers) exec_silent("rm -f "..out.." "..err) 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 local cmd if 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.12-RIFT" local hwid = get_hwid() local model = get_device_model() local osver = get_os_version() local hdr = " --header="..shq("x-hwid: "..hwid).. " --header="..shq("x-device-os: OpenWRT").. " --header="..shq("x-ver-os: "..osver).. " --header="..shq("x-device-model: "..model) local cmd if 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 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 вместо двух (cron дёргает раз в час — экономим RAM-лог). dlog("fetch_sub rc="..tostring(rc).." ok="..tostring(okrc).." hwid="..hwid.." os="..osver.." 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 рендерит как
. Без хоста, без транспорта, без кнопки. 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 -- 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) local raw = exec_read("head -n 5 "..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={nodes={}} end if type(db.nodes)~="table" then db.nodes={} end local r=exec_silent("pgrep -f podkop") local rn=(r==0)or(r==true) -- активный узел — по сохранённому ключу (apply пишет /etc/podkop_data/active_key) local active_key=exec_read("cat /etc/podkop_data/active_key 2>/dev/null") -- в UI sb не нужен (тяжёлый) — отдаём узлы без поля sb local view={} for i,n in ipairs(db.nodes) do if n.is_separator then view[i]={is_separator=true, name=n.name} else view[i]={name=n.name, host=n.host, port=n.port, type=n.type, transport=n.transport, transport_label=n.transport_label, security=n.security, flow=n.flow, key=n.key} end end print(to_json({ nodes=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, 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.12-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 local nodes, stats = build_nodes_from_xray(data) -- считаем реальные узлы (не разделители) local real=0 for _,n in ipairs(nodes) do if not n.is_separator then real=real+1 end end dlog(string.format("update_subs: parsed real=%d sep=%d skip=%d tcp=%d grpc=%d xhttp=%d hy2=%d", real, stats.sep or 0, stats.skip or 0, stats.tcp or 0, stats.grpc or 0, stats.xhttp or 0, stats.hysteria2 or 0)) if real == 0 then dlog("update_subs: no real 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 раз в час — -- иначе износ флеша). Сравниваем хэш XRAY_JSON-ответа. 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(), nodes=nodes, 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, real="..real) -- v4.12: если применённый узел несовместим со штатным ядром (XHTTP из версии -- на extended) — podkop не соберёт конфиг ("invalid, aborted"), VPN ляжет. -- Авто-переключаем на рабочий узел того же сервера (или первый доступный). -- Только при пересборке списка (не в hash-skip ветке), т.е. разово при апгрейде. 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 prev_host = (exec_read("cat /etc/podkop_data/active_key 2>/dev/null") or ""):match("^([^:]+)") local pick for _,n in ipairs(nodes) do if not n.is_separator and n.sb then if prev_host and n.host == prev_host then pick = n; break end if not pick then pick = n end end end if pick then uci_set("podkop","main","outbound_json",to_json(pick.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.key or ""); af:close() end restart_podkop_service() dlog("update_subs: несовместимый XHTTP-узел заменён на "..tostring(pick.name).." ["..tostring(pick.transport).."]") 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.7 JSON-пайплайн: подключение по индексу узла. Узел уже несёт готовый -- sing-box outbound (db.nodes[i].sb). Все транспорты идут через outbound_json -- (extended ядро умеет TCP/gRPC/XHTTP/HY2). URL-режим больше не используется. local idx = tonumber(params.idx) if not idx then dlog("apply: idx пуст") print('{"status":"error","msg":"idx пуст"}') os.exit(0) end local s,db=pcall(dofile,"/etc/podkop_data/nodes.lua") if not s or type(db)~="table" or type(db.nodes)~="table" then dlog("apply: nodes.lua недоступен") print('{"status":"error","msg":"Список узлов пуст, обновите подписку"}') os.exit(0) end local node = db.nodes[idx+1] -- JS forEach 0-based -> Lua 1-based if not node or node.is_separator or not node.sb then dlog("apply: узел idx="..tostring(idx).." не найден/разделитель") print('{"status":"error","msg":"Узел не найден"}') os.exit(0) end dlog("apply: idx="..idx.." name="..tostring(node.name).." key="..tostring(node.key).." transport="..tostring(node.transport)) local outbound = to_json(node.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 -- запоминаем активный узел для подсветки в UI (стабильный ключ host:port:transport) local af=io.open("/etc/podkop_data/active_key","w") if af then af:write(node.key or ""); af:close() end -- podkop — конфигуратор (init-скрипт завершается сразу), sing-box поднимается -- ~1-2с после restart. Ждём и ретраим, иначе pgrep ловит окно перезапуска -- и пишет ложное «не виден» (трафик при этом идёт). Только для лога. 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=node.transport, name=node.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=="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 -- Набор «включённых» MAC (с именами) из vpn_macs.list local macs = read_vpn_macs() local macname = {} for _, m in ipairs(macs) do macname[m.mac] = m.name or "" end -- Помечаем онлайн-клиентов флагом enabled for _, c in ipairs(clients) do c.enabled = (macname[c.mac] ~= nil) end -- Включённые, но сейчас офлайн (есть в списке, но нет среди онлайн-клиентов) local offline = {} 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 -- Домены 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})) os.exit(0) end if method=="manage_vpn" then -- v4.8: оперируем MAC-ом. Watcher-демон сам переотразит MAC->IP. local mac = (params.mac or ""):lower() local name = params.name or "" local a = params.action if mac == "" or not a then print('{"status":"error","msg":"mac and action 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: "..a.." mac="..mac.." name="..name) -- Немедленная пересборка IP-списка (не ждём 30с цикла watcher) exec_silent("/usr/local/sbin/rift-mac-vpn-watcher --once 2>/dev/null") print('{"status":"ok"}') 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 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 = "===== 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 print('{"status":"error","msg":"unknown method"}') EOF # 7) Frontend logi "[7/10] Запись Frontend..." cat <<'EOF' > /www/podkop_panel/index.html RIFT Panel

Логи Podkop

Загрузка...

Системные настройки

Версия панели:
По умолчанию:
RIFT

АКТИВНОЕ ПОДКЛЮЧЕНИЕ

...

СЕРВЕРЫ

Полный VPN для устройств

Привязка по MAC — IP может меняться, привязка сохранится.

Домены через VPN

DNS

Устройство

Загрузка...
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 RIFT Panel
RIFT Panel

Открываем панель через чистый адрес http://rift.lan/. Внутри используется порт 2017, но в строке браузера остается только домен.

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 (формат: "MACHOSTNAME" по строке). # Mode: --once (одно прохождение) или без аргумента (бесконечный цикл через 30s). VPN_MACS_FILE="/etc/podkop_data/vpn_macs.list" MODE="$1" resolve_one_pass() { [ -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 /etc/init.d/rift-mac-vpn-watcher start >/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 # ---------------------------------------------------------------------------- # 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 # После рестарта uhttpd пересобираем список узлов новым конвертером # (nodes.lua мы удалили выше) — чтобы XHTTP-узлы сразу исчезли, не ждать cron. sleep 3 /etc/podkop_data/autoupdate_sub.sh >/dev/null 2>&1 ) >/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 "================================================="