#!/bin/sh # === RIFT PANEL INSTALLER & UPDATER (V3.10) === # Install: sh <(wget -O - https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh) PANEL_VERSION="4.9" 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/6] Остановка веб-сервера..." uci -q delete uhttpd.podkop_panel uci commit uhttpd >/dev/null 2>&1 /etc/init.d/uhttpd restart >/dev/null 2>&1 echo "[2/6] Удаление cron задач..." (crontab -l 2>/dev/null | grep -Fv "autoupdate_sub" | grep -Fv "autoupdate_panel" | grep -Fv "/etc/podkop_data/") | crontab - echo "[3/6] Удаление файлов панели..." rm -rf /www/podkop_panel echo "[4/6] Удаление данных..." rm -rf /etc/podkop_data echo "[5/6] Удаление конфигурации..." rm -f /etc/config/podkop_subs echo "[6/6] Очистка temp..." rm -f /tmp/podkop_sub*.body /tmp/podkop_sub*.err /tmp/rift_*.sh /tmp/rift_*.err 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} ===" # 1) deps echo "[1/9] Установка пакетов..." opkg update >/dev/null 2>&1 opkg install ca-bundle coreutils-base64 lua uclient-fetch curl >/dev/null 2>&1 || true # 2) structure echo "[2/9] Настройка системы..." 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 # Generate HWID if not exists if [ ! -f /etc/podkop_data/hwid ]; then 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") HWID=$(echo "$MAC" | md5sum | cut -c1-32) echo "$HWID" > /etc/podkop_data/hwid fi # 3) uhttpd echo "[3/9] Настройка веб-сервера (порт 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 echo "[4/9] Очистка 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 del_list dhcp.@dnsmasq[0].rebind_domain='rift' uci commit dhcp >/dev/null 2>&1 # 5) Patch podkop for XHTTP support echo "[5/9] Патч podkop для XHTTP..." FACADE="/usr/lib/podkop/sing_box_config_facade.sh" MANAGER="/usr/lib/podkop/sing_box_config_manager.sh" if [ -f "$FACADE" ] && ! grep -q "xhttp" "$FACADE" 2>/dev/null; then sed -i '/^\s*\*)/i\ xhttp | splithttp)\ log "Mapping XHTTP transport to HTTP for sing-box" "info"\ local xhttp_path xhttp_host\ xhttp_path=$(url_get_query_param "$url" "path")\ xhttp_host=$(url_get_query_param "$url" "host")\ config=$(\ sing_box_cm_set_http_transport_for_outbound "$config" "$outbound_tag" "$xhttp_path" "$xhttp_host"\ )\ ;;' "$FACADE" echo " -> sing_box_config_facade.sh patched" fi if [ -f "$MANAGER" ] && ! grep -q "set_http_transport_for_outbound" "$MANAGER" 2>/dev/null; then cat >> "$MANAGER" << 'PATCH' sing_box_cm_set_http_transport_for_outbound() { local config="$1" tag="$2" path="$3" host="$4" echo "$config" | jq \ --arg tag "$tag" \ --arg path "$path" \ --arg host "$host" \ '.outbounds |= map( if .tag == $tag then . + { transport: ( { type: "http" } + (if $path != "" then {path: $path} else {} end) + (if $host != "" then {host: [$host]} else {} end) ) } else . end )' } PATCH echo " -> sing_box_config_manager.sh patched" fi # 6) Backend (RPC) echo "[6/9] Запись Backend..." 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) -- io.popen can return nil on BusyBox if the shell rejects the command -- (notably ARG_MAX overflow when the caller pipes a huge string in). -- Without this guard the whole RPC crashed with "attempt to index nil". if not h then return "" end 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 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 | 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) -- UA must NOT be "v2rayNG/*": Remnawave maps that UA to XRAY_JSON response -- (hardcoded, not via SRR rules). Routers can't parse JSON — they expect -- base64 vless list. "RIFT-Router/" falls through to Fallback XRAY_BASE64. local ua = "RIFT-Router/3.10" 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 local rc = os.execute(cmd) return (rc==0) or (rc==true) 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) -- See fetch_to_file: avoid "v2rayNG/*" to get XRAY_BASE64 instead of JSON. local ua = "RIFT-Router/3.10" 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 osver = get_os_version() local headers = { "x-hwid: " .. hwid, "x-device-os: OpenWRT", "x-ver-os: " .. osver, "x-device-model: " .. model } local ok = fetch_to_file(url, out, err, headers) if not ok then ok = fetch_to_file(url, out, err) end return ok end local function smart_fetch_with_headers(url, out, hdr_file) local hwid = get_hwid() local model = get_device_model() local osver = get_os_version() local headers = { "x-hwid: " .. hwid, "x-device-os: OpenWRT", "x-ver-os: " .. osver, "x-device-model: " .. model } local ok = fetch_with_headers(url, out, hdr_file, headers) if not ok then ok = fetch_with_headers(url, out, hdr_file) end return ok end -- Extract subscription info from saved headers local function extract_sub_info(hdr_file) local info = {expire="", title="", interval=""} local raw = exec_read("cat "..hdr_file.." 2>/dev/null") raw = raw:gsub("\r", "") -- strip CR from curl output -- profile-title (base64 encoded) local pt = raw:match("profile%-title:%s*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 = raw:match("subscription%-userinfo:%s*(.-)%s*$") if sui then local exp_ts = sui:match("expire=(%d+)") if exp_ts 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 = "Истекла" end end local dl = sui:match("download=(%d+)") local total = sui:match("total=(%d+)") if dl and total then info.traffic_used = math.floor(tonumber(dl)/1073741824*100)/100 info.traffic_total = math.floor(tonumber(total)/1073741824*100)/100 end end local pi = raw:match("profile%-update%-interval:%s*(%d+)") if pi then info.interval = pi 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") local REMOTE_SCRIPT_URL="https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh" local function url_decode(s) if not s then return "" end return s:gsub("%%(%x%x)",function(h)return string.char(tonumber(h,16))end) end local function url_get_param(url, param) local val = url:match("[?&]" .. param .. "=([^&#]*)") if val then return url_decode(val) end return "" end -- FIX: parse links line-by-line, not by whitespace pattern local function parse_links_from_text(text) local out={} if not text then return out end for line in text:gmatch("[^\n\r]+") do line = trim(line) -- match protocol at START of line if line:match("^[a-z]+://") then out[#out+1] = line end end return out 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 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() local unsupported = false if transport == "grpc" then transport_label = "gRPC" elseif transport == "xhttp" or transport == "splithttp" then transport_label = "XHTTP" unsupported = true -- sing-box does not support xhttp elseif transport == "ws" then transport_label = "WS" end local service_name = url_get_param(line, "serviceName") local path = url_get_param(line, "path") local mode = url_get_param(line, "mode") local flow = url_get_param(line, "flow") return { name=name, host=host, type=ti, transport=transport, transport_label=transport_label, security=security, service_name=service_name, path=path, mode=mode, flow=flow, unsupported=unsupported, full_url=line } end -- Filter and dedup -- Phone-only BS-bypass hosts come from the panel's sub-proxy auto-injection -- (tag pattern "🇽🇽📱 Обход БС #N.N " or xray "BS_*" outbound tag). -- Routers can't use them — they need the operator-whitelist trick that only -- works on mobile. Two reliable markers, both unambiguous: -- • 📱 emoji — present on every phone-only key, never on a regular one -- • "BS_" — xray internal tag prefix added by sub-proxy -- Lua patterns are byte-oriented and NOT unicode-aware, so we deliberately -- avoid Cyrillic word-boundary tricks (they false-positive on words like -- "обсуждение" because %w is ASCII-only). local function should_skip(name) if not name then return false end if name:match("📱") then return true end if name:match("BS_") then return true end return false end local function is_xhttp(transport) local t = (transport or ""):lower() return t == "xhttp" or t == "splithttp" end local function get_url_key(url) -- strip fragment for dedup return (url:match("^([^#]+)") or url) end -- Returns (nodes, stats). Stats lets the caller log exactly why each link -- was dropped — without this the panel just silently said "Серверы не найдены" -- and we had no way to tell from the router whether the upstream sent BS-only, -- xhttp-only, or genuinely broke. local function parse_nodes(text) local nodes={} local seen={} local stats={total=0, accepted=0, dup=0, bs=0, xhttp=0, other=0} local links=parse_links_from_text(text or "") for _,u in ipairs(links) do stats.total = stats.total + 1 local node = link_to_node(u) local key = get_url_key(u) if seen[key] then stats.dup = stats.dup + 1 elseif should_skip(node.name) then stats.bs = stats.bs + 1 elseif is_xhttp(node.transport) then stats.xhttp = stats.xhttp + 1 else seen[key] = true nodes[#nodes+1] = node stats.accepted = stats.accepted + 1 end end return nodes, stats 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) -- BusyBox shell ARG_MAX (~128KB) overflows when the encoded body is large, -- which made io.popen return nil and the whole RPC die. Write to a tmp file -- and decode from there — no shell-argument size limit involved. local tmp = "/tmp/.podkop_b64." .. tostring(os.time()) .. "." .. tostring(math.random(1,99999)) local f = io.open(tmp, "w") if not f then return "" end f:write(t) f:close() local dec = exec_read("base64 -d " .. tmp .. " 2>/dev/null") os.remove(tmp) return dec or "" 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(REMOTE_SCRIPT_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(REMOTE_SCRIPT_URL, tmp, err) exec_silent("sh "..tmp) os.remove(tmp); os.remove(err) print('{"status":"ok"}') 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 cp=uci_get("podkop","main","proxy_string") local r=exec_silent("pgrep -f podkop") local rn=(r==0)or(r==true) local dp=(cp or ""):gsub("%%20"," ") print(to_json({ nodes=db.nodes, expire=db.expire or "Нет данных", sub_title=db.sub_title or "", sub_expire=db.sub_expire or "", sub_traffic=db.sub_traffic or "", updated=db.updated or "Никогда", active_url=dp, running=rn })) os.exit(0) end if method=="update_subs" then local url=params.url if not url or url=="" then url=trim(uci_get("podkop_subs","config","url")) end if not url or url=="" then print('{"status":"error","msg":"URL не найден!"}') os.exit(0) end exec_silent("uci -q delete podkop_subs.config.url") uci_set("podkop_subs","config","url",url) exec_silent("uci commit podkop_subs") local body="/tmp/podkop_sub.body" local err="/tmp/podkop_sub.err" local ok = smart_fetch(url, body, err) local raw = exec_read("cat "..body.." 2>/dev/null") if (not ok) or raw=="" then print(to_json({status="error", msg="Ошибка загрузки подписки"})) os.remove(body); os.remove(err); os.exit(0) end -- Capture headers with curl (BusyBox wget doesn't support -S) local hdr_file="/tmp/podkop_sub.hdr" local sub_info = {expire="", title="", interval=""} os.execute("curl -sI "..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) -- try raw first, then base64 local nodes, stats = parse_nodes(raw) local source = "raw" local decoded_len = 0 if #nodes == 0 then local decoded = try_decode_base64(raw) decoded_len = #decoded if decoded ~= "" then nodes, stats = parse_nodes(decoded) source = "base64" end end -- Always write last_update.log: this is overwritten on each run (single block -- on flash, no append-only growth) so we can diagnose "Серверы не найдены" -- without sshing in. Includes raw/decoded size, parse source, and per-skip -- counts so we can tell at a glance whether the upstream sent BS-only, -- xhttp-only, or nothing parseable at all. local lf = io.open("/etc/podkop_data/last_update.log","w") if lf then lf:write("=== RIFT v3.10 update ===\n") lf:write("time: "..os.date("%Y-%m-%d %H:%M:%S").."\n") lf:write("url: "..url.."\n") lf:write("body_size: "..#raw.." bytes\n") lf:write("source: "..source.."\n") if source == "base64" then lf:write("decoded_size:"..decoded_len.." bytes\n") end lf:write("parsed: "..stats.total.." links\n") lf:write(" accepted: "..stats.accepted.."\n") lf:write(" skipped BS/phone: "..stats.bs.."\n") lf:write(" skipped xhttp: "..stats.xhttp.."\n") lf:write(" duplicates: "..stats.dup.."\n") lf:write("sub_title: "..(sub_info.title or "").."\n") lf:write("sub_expire: "..(sub_info.expire or "").."\n") lf:close() end if #nodes == 0 then print(to_json({ status="error", msg="Серверы не найдены", diag={body_size=#raw, source=source, parsed=stats.total, skipped_bs=stats.bs, skipped_xhttp=stats.xhttp} })) os.remove(body); os.remove(err); os.exit(0) end -- Build traffic string local traffic_str = "" if sub_info.traffic_used then traffic_str = sub_info.traffic_used.." / "..sub_info.traffic_total.." GB" end local db={ expire=sub_info.expire ~= "" and sub_info.expire or "Нет данных", sub_title=sub_info.title or "", sub_expire=sub_info.expire or "", sub_traffic=traffic_str, updated=os.date("%Y-%m-%d %H:%M:%S"), nodes=nodes } local f=io.open("/etc/podkop_data/nodes.lua","w") if f then f:write("return "..serialize(db)) f:close() print(to_json({ status="ok", count=#nodes, expire=db.expire, sub_title=db.sub_title, sub_traffic=traffic_str, diag={parsed=stats.total, skipped_bs=stats.bs, skipped_xhttp=stats.xhttp} })) else print('{"status":"error","msg":"Ошибка записи"}') end os.remove(body); os.remove(err); os.exit(0) end if method=="apply" then if params.node_url then local cu=params.node_url:gsub(" ","%%20") uci_set("podkop","main","proxy_string",cu) exec_silent("uci commit podkop") exec_silent("/etc/init.d/podkop restart") print('{"status":"ok"}') else print('{"status":"error","msg":"node_url пуст"}') 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 if method=="get_network" then local c={} 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>=4 then c[#c+1]={ip=p[3],name=p[4],mac=p[2]} end end f:close() end local vl={} for w in (exec_read("uci -q get podkop.main.fully_routed_ips") or ""):gmatch("%S+") do vl[#vl+1]=w end local dl={} for w in (exec_read("uci -q get podkop.main.user_domains") or ""):gmatch("%S+") do dl[#dl+1]=w end print(to_json({clients=c,vpn_ips=vl,domains=dl})) os.exit(0) end if method=="manage_vpn" then local ip=params.ip; local a=params.action if ip and a and ip:match("^%d+%.%d+%.%d+%.%d+$") then if a=="add" then exec_silent("uci add_list podkop.main.fully_routed_ips="..shq(ip)) elseif a=="del" then exec_silent("uci del_list podkop.main.fully_routed_ips="..shq(ip)) end exec_silent("uci commit podkop"); exec_silent("/etc/init.d/podkop restart") print('{"status":"ok"}') else print('{"status":"error","msg":"bad ip"}') end 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 if method=="get_sub_url" then print(to_json({url=uci_get("podkop_subs","config","url")})) os.exit(0) end if method=="get_logs" then local lines = params.lines or "50" local log_output = exec_read("logread -e podkop 2>/dev/null | tail -n " .. lines) if log_output == "" then log_output = exec_read("logread 2>/dev/null | grep -i 'podkop\\|sing-box' | tail -n " .. lines) end print(to_json({logs=log_output})) 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 echo "[7/9] Запись Frontend..." cat <<'EOF' > /www/podkop_panel/index.html RIFT Panel
Загрузка...

📋 Логи Podkop

Загрузка...
RIFT

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

...

🌐 Серверы

🔒 VPN для устройства

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

ℹ️ Устройство

Загрузка...
EOF # 8) Auto-update subscription (hourly) echo "[8/9] Настройка автообновления..." cat <<'AEOF' > /etc/podkop_data/autoupdate_sub.sh #!/bin/sh URL="$(uci -q get podkop_subs.config.url)" [ -z "$URL" ] && exit 0 HWID="$(cat /etc/podkop_data/hwid 2>/dev/null)" MODEL="$(cat /tmp/sysinfo/model 2>/dev/null || echo 'OpenWrt')" OSVER="$(cat /etc/openwrt_release 2>/dev/null | grep DISTRIB_RELEASE | cut -d"'" -f2)" BODY="/tmp/podkop_sub_auto.body" ERR="/tmp/podkop_sub_auto.err" if command -v uclient-fetch >/dev/null 2>&1; then # UA "RIFT-Router/" — see comment in rpc fetch_to_file. Avoids the # built-in v2rayNG → XRAY_JSON mapping in Remnawave; we need base64 vless. uclient-fetch -q -O "$BODY" --header="User-Agent: RIFT-Router/3.10" --header="x-hwid: $HWID" --header="x-device-os: OpenWRT" --header="x-ver-os: $OSVER" --header="x-device-model: $MODEL" "$URL" 2>"$ERR" || { rm -f "$BODY" "$ERR"; exit 0; } else wget -q -T 25 -U "RIFT-Router/3.10" --header="x-hwid: $HWID" --header="x-device-os: OpenWRT" --header="x-ver-os: $OSVER" --header="x-device-model: $MODEL" -O "$BODY" "$URL" 2>"$ERR" || { rm -f "$BODY" "$ERR"; exit 0; } fi RAW_SIZE="$(wc -c < "$BODY" 2>/dev/null || echo 0)" [ "$RAW_SIZE" -lt 16 ] && { rm -f "$BODY" "$ERR"; exit 0; } # If the body has no "://", treat it as base64 and decode via tmp file # (printf '%s' "$huge" overflows BusyBox shell ARG_MAX on large subs and # silently produces empty output — same root cause as the RPC bug). TEXT_FILE="$BODY" SOURCE="raw" if ! grep -q "://" "$BODY"; then CLEAN="/tmp/podkop_sub_auto.b64" tr -d '\n\r\t ' < "$BODY" | sed 's/-/+/g;s/_/\//g' > "$CLEAN" CL_LEN=$(wc -c < "$CLEAN"); PAD=$(( (4 - CL_LEN % 4) % 4 )) while [ "$PAD" -gt 0 ]; do printf '=' >> "$CLEAN"; PAD=$((PAD-1)); done DEC="/tmp/podkop_sub_auto.dec" base64 -d "$CLEAN" > "$DEC" 2>/dev/null rm -f "$CLEAN" if grep -q "://" "$DEC" 2>/dev/null; then TEXT_FILE="$DEC" SOURCE="base64" else rm -f "$DEC" "$BODY" "$ERR"; exit 0 fi fi COUNT=$(grep -c "://" "$TEXT_FILE") # Lua parses from the file (not from an embedded string) so we never have # to escape body contents into the shell-quoted -e argument. lua - "$TEXT_FILE" "$RAW_SIZE" "$SOURCE" "$URL" <<'LEOF' 2>/dev/null local fpath, raw_size, source, url = arg[1], arg[2], arg[3], arg[4] local fh = io.open(fpath, "r") if not fh then os.exit(1) end local text = fh:read("*a") or "" fh:close() local stats = {total=0, accepted=0, dup=0, bs=0, xhttp=0} local nodes, seen = {}, {} local function should_skip(name) if not name then return false end if name:match("📱") then return true end if name:match("BS_") then return true end return false end for line in text:gmatch("[^\n\r]+") do line = line:match("^%s*(.-)%s*$") local proto = line:match("^(%w+)://") if proto then stats.total = stats.total + 1 local key = line:match("^([^#]+)") or line if seen[key] then stats.dup = stats.dup + 1 else local ne = line:match("#(.+)$") local name = "Server" if ne then name = ne:gsub("%%(%x%x)", function(h) return string.char(tonumber(h,16)) end) end local transport = line:match("[?&]type=([^&#]*)") or "tcp" transport = transport:lower() if should_skip(name) then stats.bs = stats.bs + 1 elseif transport == "xhttp" or transport == "splithttp" then stats.xhttp = stats.xhttp + 1 else seen[key] = true local host = line:match("@(.-)[:?]") or line:match("://([^/:#?]+)") or "unknown" local security = line:match("[?&]security=([^&#]*)") or "" local tl = transport:upper() if transport == "grpc" then tl = "gRPC" end local ti = proto:upper(); if security == "reality" then ti = "Reality" end nodes[#nodes+1] = { name=name, host=host, type=ti, transport=transport, transport_label=tl, security=security, service_name = line:match("[?&]serviceName=([^&#]*)") or "", path = line:match("[?&]path=([^&#]*)") or "", mode = line:match("[?&]mode=([^&#]*)") or "", flow = line:match("[?&]flow=([^&#]*)") or "", full_url=line, } stats.accepted = stats.accepted + 1 end end end end local function ser(v) local t = type(v) if t == "table" then local p = {} for k, vv in pairs(v) do p[#p+1] = (type(k) == "number" and "" or ("[\""..k.."\"]=")) .. ser(vv) end return "{" .. table.concat(p, ",") .. "}" elseif t == "string" then return string.format("%q", v) else return tostring(v) end end if #nodes > 0 then local db = { expire = "Нет данных", updated = os.date("%Y-%m-%d %H:%M:%S"), nodes = nodes, } local fout = io.open("/etc/podkop_data/nodes.lua", "w") if fout then fout:write("return " .. ser(db)); fout:close() end end -- last_update.log: overwritten each run, single block on flash, no growth local lf = io.open("/etc/podkop_data/last_update.log", "w") if lf then lf:write("=== RIFT v3.10 update (cron) ===\n") lf:write("time: " .. os.date("%Y-%m-%d %H:%M:%S") .. "\n") lf:write("url: " .. url .. "\n") lf:write("body_size: " .. raw_size .. " bytes\n") lf:write("source: " .. source .. "\n") lf:write("parsed: " .. stats.total .. " links\n") lf:write(" accepted: " .. stats.accepted .. "\n") lf:write(" skipped BS/phone: " .. stats.bs .. "\n") lf:write(" skipped xhttp: " .. stats.xhttp .. "\n") lf:write(" duplicates: " .. stats.dup .. "\n") lf:close() end LEOF # Cleanup our own tmp files only. Don't touch /tmp/.podkop_b64.* — # those belong to the RPC's try_decode_base64 and could be in active use # by a concurrent panel request. rm -f "$BODY" "$ERR" /tmp/podkop_sub_auto.dec /tmp/podkop_sub_auto.b64 logger -t "rift-panel" "Subscription updated: $COUNT links seen, source=$SOURCE" AEOF chmod +x /etc/podkop_data/autoupdate_sub.sh # Panel auto-update (daily) cat <<'PEOF' > /etc/podkop_data/autoupdate_panel.sh #!/bin/sh TMP="/tmp/rift_remote.sh" if command -v uclient-fetch >/dev/null 2>&1; then uclient-fetch -q -O "$TMP" "https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh" 2>/dev/null || { rm -f "$TMP"; exit 0; } else wget -q -O "$TMP" "https://raw.githubusercontent.com/RIFT-VPN/Router/refs/heads/main/rift.sh" 2>/dev/null || { rm -f "$TMP"; exit 0; } fi 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 echo "[9/9] Настройка cron..." (crontab -l 2>/dev/null | grep -Fv "autoupdate_sub" | grep -Fv "autoupdate_panel" | grep -Fv "/etc/podkop_data/") | crontab - # Subscription every hour at :17 (off-peak minute, avoids :00 clock-aligned spike). # Switched from */5 to hourly to cut flash wear ~12× — nodes.lua now gets ~720 # writes/month instead of ~8600 — important for cheap router flash. # Panel check stays daily at 04:13. (crontab -l 2>/dev/null; echo "17 * * * * /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 /etc/init.d/uhttpd enable >/dev/null 2>&1 /etc/init.d/uhttpd restart >/dev/null 2>&1 /etc/init.d/dnsmasq restart >/dev/null 2>&1 # Trigger one subscription refresh right now so users upgrading from 3.8 # don't wait up to an hour for the new parser to rebuild nodes.lua. # Safe if the URL is unset — the script exits silently. /etc/podkop_data/autoupdate_sub.sh >/dev/null 2>&1 & ROUTER_IP="$(uci -q get network.lan.ipaddr)" [ -z "$ROUTER_IP" ] && ROUTER_IP="192.168.1.1" echo "=================================================" echo "ГОТОВО! RIFT Panel v${PANEL_VERSION}" echo "Доступ: http://${ROUTER_IP}:2017" echo "HWID: $(cat /etc/podkop_data/hwid 2>/dev/null)" echo "Авто-обновление подписки: раз в час" echo "================================================="