diff --git a/luci-app-openclash/Makefile b/luci-app-openclash/Makefile index 8d2c91b1e..556ed418d 100644 --- a/luci-app-openclash/Makefile +++ b/luci-app-openclash/Makefile @@ -1,8 +1,8 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-openclash -PKG_VERSION:=0.47.133 -PKG_RELEASE:=7 +PKG_VERSION:=0.47.156 +PKG_RELEASE:=8 PKG_MAINTAINER:=vernesong PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) @@ -112,10 +112,10 @@ define Package/$(PKG_NAME)/postrm rm -rf /etc/config/openclash >/dev/null 2>&1 rm -rf /tmp/openclash.log >/dev/null 2>&1 rm -rf /tmp/openclash_start.log >/dev/null 2>&1 - rm -rf /tmp/openclash_last_version >/dev/null 2>&1 rm -rf /tmp/openclash.change >/dev/null 2>&1 - rm -rf /tmp/clash_last_version >/dev/null 2>&1 rm -rf /usr/share/openclash >/dev/null 2>&1 + rm -rf /tmp/openclash_version_history.json >/dev/null 2>&1 + rm -rf /tmp/openclash_cdn_info.json >/dev/null 2>&1 rm -rf ${DNSMASQ_CONF_DIR}/dnsmasq_openclash_custom_domain.conf >/dev/null 2>&1 rm -rf ${DNSMASQ_CONF_DIR}/dnsmasq_openclash_chnroute_pass.conf >/dev/null 2>&1 rm -rf ${DNSMASQ_CONF_DIR}/dnsmasq_openclash_chnroute6_pass.conf >/dev/null 2>&1 @@ -123,6 +123,9 @@ define Package/$(PKG_NAME)/postrm rm -rf /tmp/openclash_announcement >/dev/null 2>&1 rm -rf /www/luci-static/resources/openclash >/dev/null 2>&1 rm -rf /tmp/oix* >/dev/null 2>&1 + rm -rf /tmp/openclash_oix_version.json >/dev/null 2>&1 + rm -rf /tmp/openclash_version_history_* >/dev/null 2>&1 + rm -rf /tmp/openclash_cdn_info_* >/dev/null 2>&1 sed -i '/OpenClash Append/,/OpenClash Append End/d' "/usr/lib/lua/luci/model/network.lua" >/dev/null 2>&1 sed -i '/.*kB maximum content size*/c\HTTP_MAX_CONTENT = 1024*100 -- 100 kB maximum content size' /usr/lib/lua/luci/http.lua >/dev/null 2>&1 sed -i '/.*kB maximum content size*/c\export let HTTP_MAX_CONTENT = 1024*100; // 100 kB maximum content size' /usr/share/ucode/luci/http.uc >/dev/null 2>&1 diff --git a/luci-app-openclash/luasrc/controller/openclash.lua b/luci-app-openclash/luasrc/controller/openclash.lua index 95a2cfd75..5edbb20c1 100644 --- a/luci-app-openclash/luasrc/controller/openclash.lua +++ b/luci-app-openclash/luasrc/controller/openclash.lua @@ -19,12 +19,9 @@ function index() entry({"admin", "services", "openclash", "del_start_log"},call("action_del_start_log")) entry({"admin", "services", "openclash", "close_all_connection"},call("action_close_all_connection")) entry({"admin", "services", "openclash", "reload_firewall"},call("action_reload_firewall")) - entry({"admin", "services", "openclash", "lastversion"},call("action_lastversion")) entry({"admin", "services", "openclash", "save_corever_branch"},call("action_save_corever_branch")) entry({"admin", "services", "openclash", "update"},call("action_update")) - entry({"admin", "services", "openclash", "get_last_version"},call("action_get_last_version")) - entry({"admin", "services", "openclash", "update_info"},call("action_update_info")) - entry({"admin", "services", "openclash", "update_ma"},call("action_update_ma")) + entry({"admin", "services", "openclash", "last_version"},call("action_last_version")) entry({"admin", "services", "openclash", "opupdate"},call("action_opupdate")) entry({"admin", "services", "openclash", "coreupdate"},call("action_coreupdate")) entry({"admin", "services", "openclash", "flush_dns_cache"}, call("action_flush_dns_cache")) @@ -87,6 +84,9 @@ function index() entry({"admin", "services", "openclash", "log"},cbi("openclash/log"),_("Server Logs"), 90).leaf = true entry({"admin", "services", "openclash", "myip_check"}, call("action_myip_check")) entry({"admin", "services", "openclash", "website_check"}, call("action_website_check")) + entry({"admin", "services", "openclash", "version_history"}, call("action_version_history")) + entry({"admin", "services", "openclash", "addr_info"}, call("action_cdn_info")) + entry({"admin", "services", "openclash", "save_github_address_mod"}, call("action_save_github_address_mod")) entry({"admin", "services", "openclash", "proxy_info"}, call("action_proxy_info")) entry({"admin", "services", "openclash", "oc_settings"}, call("action_oc_settings")) entry({"admin", "services", "openclash", "switch_oc_setting"}, call("action_switch_oc_setting")) @@ -180,32 +180,25 @@ local function db_foward_ssl() return fs.uci_get_config("config", "dashboard_forward_ssl") or 0 end -local function check_lastversion() - SYS.exec("bash /usr/share/openclash/openclash_version.sh 2>/dev/null") - return SYS.exec("sed -n '/^https:/,$p' /tmp/openclash_last_version 2>/dev/null") -end - -local function startlog() - local info = "" - if fs.access("/tmp/openclash_start.log") then - info = SYS.exec("sed -n '$p' /tmp/openclash_start.log 2>/dev/null") - if string.len(info) > 0 then - info = trans_line(info) +local function coremodel() + local rel = fs.readfile("/etc/openwrt_release") + if rel then + local arch = rel:match("DISTRIB_ARCH='([^']+)'") + if arch and arch ~= "" then + return arch end end - return info -end - -local function coremodel() - if opkg and opkg.info("libc") and opkg.info("libc")["libc"] then - return opkg.info("libc")["libc"]["Architecture"] - else - if fs.pkg_type() == "opkg" then - return SYS.exec("rm -f /var/lock/opkg.lock && opkg status libc 2>/dev/null |grep 'Architecture' |awk -F ': ' '{print $2}' 2>/dev/null") - else - return SYS.exec("rm -f /lib/apk/db/lock && apk list libc 2>/dev/null |awk '{print $2}'") + if opkg then + local info = opkg.info("libc") + if info and info["libc"] and info["libc"]["Architecture"] then + return info["libc"]["Architecture"] end end + if fs.pkg_type() == "opkg" then + return fs.read_pkg_field("libc", "Architecture") + else + return fs.read_pkg_field("libc", "A") + end end local function check_core() @@ -265,6 +258,8 @@ local function sanitize_subconverter_version_text(text) return text end +local ov = dofile("/usr/share/openclash/openclash_version.lua") + local function coremetacv() local v = "0" if not fs.access(meta_core_path) then @@ -278,25 +273,44 @@ local function coremetacv() return v end +function release_branch() + return fs.uci_get_config("config", "release_branch") or "master" +end + +local function smart_enable() + return fs.uci_get_config("config", "smart_enable") or "0" +end + +local function is_oix() + local token = fs.uci_get_config("config", "oix_token") + return token ~= nil and token ~= "" +end + +local function corever() + return fs.uci_get_config("config", "core_version") or "0" +end + local function corelv() local core_meta_lv = "" local core_smart_enable = fs.uci_get_config("config", "smart_enable") or "0" - local core_type = fs.uci_get_config("config", "core_type") or "Meta" local oix_token = fs.uci_get_config("config", "oix_token") or "" - if fs.access("/tmp/clash_last_version") then - if core_type == "Oix" or oix_token ~= "" then - core_meta_lv = SYS.exec("sed -n 1p /tmp/clash_last_version 2>/dev/null |tr -d '\n'") + + local cache = ov.fetch_version_history(release_branch(), false) + if cache then + if oix_token ~= "" then + core_meta_lv = cache.oix_ver or "" elseif core_smart_enable == "1" then - core_meta_lv = SYS.exec("sed -n 2p /tmp/clash_last_version 2>/dev/null |tr -d '\n'") + core_meta_lv = (cache.core_smart and cache.core_smart[1] and cache.core_smart[1].version) or "" else - core_meta_lv = SYS.exec("sed -n 1p /tmp/clash_last_version 2>/dev/null |tr -d '\n'") + core_meta_lv = (cache.core_meta and cache.core_meta[1] and cache.core_meta[1].version) or "" end - else - core_meta_lv = "loading..." end - action_get_last_version() - return core_meta_lv + if core_meta_lv and core_meta_lv ~= "" then + return core_meta_lv + end + + return "loading..." end local function opcv() @@ -306,9 +320,9 @@ local function opcv() v = info["luci-app-openclash"]["Version"] else if fs.pkg_type() == "opkg" then - v = SYS.exec("rm -f /var/lock/opkg.lock && opkg status luci-app-openclash 2>/dev/null |grep 'Version' |awk -F 'Version: ' '{print $2}' |tr -d '\n'") + v = fs.read_pkg_field("luci-app-openclash", "Version") else - v = SYS.exec("rm -f /lib/apk/db/lock && apk list luci-app-openclash 2>/dev/null|grep 'installed' | grep -oE '[0-9]+(\\.[0-9]+)*' | head -1 |tr -d '\n'") + v = fs.read_pkg_field("luci-app-openclash", "V"):match("[%d%.]+") or "" end end if v and v ~= "" then @@ -321,14 +335,12 @@ end local function oplv() local oplv = "" - if fs.access("/tmp/openclash_last_version") then - oplv = SYS.exec("sed -n 1p /tmp/openclash_last_version 2>/dev/null |tr -d '\n'") - else - oplv = "loading..." + local cache = ov.fetch_version_history(release_branch(), false) + if cache and cache.plugin and cache.plugin[1] and cache.plugin[1].version then + return cache.plugin[1].version end - action_get_last_version() - return oplv + return "loading..." end local function opup() @@ -342,18 +354,6 @@ local function coreup() return SYS.call(string.format("/usr/share/openclash/openclash_core.sh '%s' >/dev/null 2>&1 &", type)) end -local function corever() - return fs.uci_get_config("config", "core_version") or "0" -end - -local function release_branch() - return fs.uci_get_config("config", "release_branch") or "master" -end - -local function smart_enable() - return fs.uci_get_config("config", "smart_enable") or "0" -end - local function save_corever_branch() if HTTP.formvalue("core_ver") then uci:set("openclash", "config", "core_version", HTTP.formvalue("core_ver")) @@ -368,55 +368,47 @@ local function save_corever_branch() return "success" end -local function upchecktime() - local corecheck = os.date("%Y-%m-%d %H:%M:%S",fs.mtime("/tmp/clash_last_version")) - local opcheck - if not corecheck or corecheck == "" then - opcheck = os.date("%Y-%m-%d %H:%M:%S",fs.mtime("/tmp/openclash_last_version")) - if not opcheck or opcheck == "" then - return "1" - else - return opcheck - end - else - return corecheck - end -end - function core_download() - local cdn_url = HTTP.formvalue("url") - if cdn_url then - SYS.call(string.format("bash /usr/share/openclash/openclash_core.sh 'Meta' '%s' >/dev/null 2>&1 &", cdn_url)) + local download_url = HTTP.formvalue("download_url") + local core_type = is_oix() and "Oix" or "Meta" + + if download_url and download_url ~= "" then + SYS.call(string.format("bash /usr/share/openclash/openclash_core.sh '%s' '%s' >/dev/null 2>&1 &", core_type, download_url)) else - SYS.call("bash /usr/share/openclash/openclash_core.sh 'Meta' >/dev/null 2>&1 &") + SYS.call(string.format("bash /usr/share/openclash/openclash_core.sh '%s' >/dev/null 2>&1 &", core_type)) end end function action_flush_dns_cache() - local state = 0 + local fake_ip_state = "" + local dns_state = "" if is_running() then local daip = daip() local dase = dase() or "" local cn_port = cn_port() - if not daip or not cn_port then return end - fake_ip_state = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPOST http://"%s":"%s"/cache/fakeip/flush', dase, daip, cn_port)) - dns_state = SYS.exec(string.format('curl -sL -m 3 --retry 2-H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPOST http://"%s":"%s"/cache/dns/flush', dase, daip, cn_port)) + if daip and cn_port then + fake_ip_state = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPOST http://"%s":"%s"/cache/fakeip/flush', dase, daip, cn_port)) + dns_state = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPOST http://"%s":"%s"/cache/dns/flush', dase, daip, cn_port)) + end end HTTP.prepare_content("application/json") HTTP.write_json({ - flush_status = dns_state; + fakeip_flush = fake_ip_state; + dns_flush = dns_state; + flush_status = (fake_ip_state == "" and dns_state == "") and "" or (fake_ip_state ~= "" and fake_ip_state or dns_state); }) end function action_flush_smart_cache() - local state = 0 + local flush_state = "" if is_running() then local daip = daip() local dase = dase() or "" local cn_port = cn_port() - if not daip or not cn_port then return end - flush_state = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPOST http://"%s":"%s"/cache/smart/flush', dase, daip, cn_port)) + if daip and cn_port then + flush_state = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPOST http://"%s":"%s"/cache/smart/flush', dase, daip, cn_port)) + end end HTTP.prepare_content("application/json") HTTP.write_json({ @@ -478,7 +470,27 @@ end function action_one_key_update() local cdn_url = HTTP.formvalue("url") - if cdn_url then + local download_url = HTTP.formvalue("download_url") + local version = HTTP.formvalue("version") + local sha = HTTP.formvalue("sha") + local update_type = HTTP.formvalue("update_type") + + if update_type == "plugin" then + if download_url and download_url ~= "" then + return SYS.call(string.format("bash /usr/share/openclash/openclash_update.sh 'plugin_update' '%s' '%s' >/dev/null 2>&1 &", + cdn_url or "", download_url)) + elseif cdn_url and cdn_url ~= "" then + return SYS.call(string.format("bash /usr/share/openclash/openclash_update.sh 'plugin_update' '%s' >/dev/null 2>&1 &", cdn_url)) + else + return SYS.call("bash /usr/share/openclash/openclash_update.sh 'plugin_update' >/dev/null 2>&1 &") + end + end + + if download_url and download_url ~= "" then + -- Full download URL: pass as $3 to the update script + return SYS.call(string.format("bash /usr/share/openclash/openclash_update.sh 'one_key_update' '%s' '%s' >/dev/null 2>&1 &", + cdn_url or "", download_url)) + elseif cdn_url and cdn_url ~= "" then return SYS.call(string.format("bash /usr/share/openclash/openclash_update.sh 'one_key_update' '%s' >/dev/null 2>&1 &", cdn_url)) else return SYS.call("bash /usr/share/openclash/openclash_update.sh 'one_key_update' >/dev/null 2>&1 &") @@ -898,9 +910,16 @@ function sub_info_get() end end - local function _fork_provider_curl(url) + local function fork_provider_curl(url) local fdi, fdo = nixio.pipe() if not fdi or not fdo then return nil end + local cmd = string.format( + 'OUT=$(curl -sLI -X GET -m 5 --retry 2 -w "http_code=%%{http_code}" -H "User-Agent: %s"%s "%s" 2>/dev/null); ' + .. 'CODE=$(echo "$OUT" | grep -o "http_code=[0-9]*" | head -1 | cut -d= -f2); ' + .. 'if [ -n "$OUT" ] && [ "$CODE" = "200" ]; then echo "$OUT"; ' + .. 'else curl -sLI -X GET -m 5 --retry 2 -w "http_code=%%{http_code}" -H "User-Agent: Quantumultx"%s "%s" 2>/dev/null; fi', + sub_ua, header_args, url, header_args, url + ) local child = nixio.fork() if child > 0 then fdo:close() @@ -909,13 +928,6 @@ function sub_info_get() nixio.dup(fdo, nixio.stdout) fdi:close() fdo:close() - local cmd = string.format( - 'OUT=$(curl -sLI -X GET -m 5 --retry 2 -w "http_code=%%{http_code}" -H "User-Agent: %s"%s "%s" 2>/dev/null); ' - .. 'CODE=$(echo "$OUT" | grep -o "http_code=[0-9]*" | head -1 | cut -d= -f2); ' - .. 'if [ -n "$OUT" ] && [ "$CODE" = "200" ]; then echo "$OUT"; ' - .. 'else curl -sLI -X GET -m 5 --retry 2 -w "http_code=%%{http_code}" -H "User-Agent: Quantumultx"%s "%s" 2>/dev/null; fi', - sub_ua, header_args, url, header_args, url - ) nixio.exec("/bin/sh", "-c", cmd) else if fdi then fdi:close() end @@ -928,7 +940,7 @@ function sub_info_get() local jobs = {} for i, provider in ipairs(providers) do - local job = _fork_provider_curl(provider.url) + local job = fork_provider_curl(provider.url) if job then job.idx = i job.name = provider.name @@ -938,13 +950,19 @@ function sub_info_get() if #jobs > 0 then local done_count = 0 - local max_wait = 120 + local delay = 50000000 + local max_wait = 40 for _ = 1, max_wait do for _, job in ipairs(jobs) do if not job.done then - local buf = job.fdi:read(4096) + local buf = try_read(job.fdi, 4096) if buf then job.buf = job.buf .. buf end if nixio.waitpid(job.pid, "nohang") then + while true do + local b = try_read(job.fdi, 4096) + if not b then break end + job.buf = job.buf .. b + end pcall(job.fdi.close, job.fdi) job.done = true done_count = done_count + 1 @@ -952,7 +970,8 @@ function sub_info_get() end end if done_count >= #jobs then break end - nixio.nanosleep(0, 10000000) + nixio.nanosleep(0, delay) + delay = math.min(delay * 2, 200000000) end for _, job in ipairs(jobs) do if not job.done then @@ -1181,9 +1200,13 @@ function action_toolbar_show() if not daip or not cn_port then return end -- Parallel curl helper - local function _fork_curl(endpoint) + local function fork_curl(endpoint) local fdi, fdo = nixio.pipe() if not fdi or not fdo then return nil end + local cmd = string.format( + 'curl -sL -m 3 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XGET "http://%s:%s/%s"', + dase, daip, cn_port, endpoint + ) local child = nixio.fork() if child > 0 then fdo:close() @@ -1192,10 +1215,6 @@ function action_toolbar_show() nixio.dup(fdo, nixio.stdout) fdi:close() fdo:close() - local cmd = string.format( - 'curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XGET "http://%s:%s/%s"', - dase, daip, cn_port, endpoint - ) nixio.exec("/bin/sh", "-c", cmd) else if fdi then fdi:close() end @@ -1204,31 +1223,43 @@ function action_toolbar_show() end end - local t_job = _fork_curl("traffic") - local c_job = _fork_curl("connections") + local t_job = fork_curl("traffic") + local c_job = fork_curl("connections") if t_job and c_job then local t_done, c_done = false, false - local max_wait = 60 + local delay = 50000000 + local max_wait = 40 for _ = 1, max_wait do if not t_done then - local buf = t_job.fdi:read(4096) + local buf = try_read(t_job.fdi, 4096) if buf then t_job.buf = t_job.buf .. buf end if nixio.waitpid(t_job.pid, "nohang") then + while true do + local b = try_read(t_job.fdi, 4096) + if not b then break end + t_job.buf = t_job.buf .. b + end pcall(t_job.fdi.close, t_job.fdi) t_done = true end end if not c_done then - local buf = c_job.fdi:read(4096) + local buf = try_read(c_job.fdi, 4096) if buf then c_job.buf = c_job.buf .. buf end if nixio.waitpid(c_job.pid, "nohang") then + while true do + local b = try_read(c_job.fdi, 4096) + if not b then break end + c_job.buf = c_job.buf .. b + end pcall(c_job.fdi.close, c_job.fdi) c_done = true end end if t_done and c_done then break end - nixio.nanosleep(0, 10000000) + nixio.nanosleep(0, delay) + delay = math.min(delay * 2, 200000000) end if not t_done then pcall(t_job.fdi.close, t_job.fdi) @@ -1512,484 +1543,77 @@ function action_status() HTTP.write_json(result) end -function action_lastversion() - HTTP.prepare_content("application/json") - HTTP.write_json({ - lastversion = check_lastversion(); - }) -end +-- Streaming write. +-- New LuCI: luci.http.write = L.print() (http.lua) is C-stdio buffered +-- (musl 4096B / glibc 8192B), io.flush() can't reach it -> use L.http:write. +-- Old LuCI (18.06): no L global; HTTP.write = coroutine.yield, immediate. +-- CRITICAL: on old LuCI HTTP.write = coroutine.yield, and Lua 5.1 cannot +-- yield across a C function boundary. Wrapping write_padded() in pcall() +-- throws "attempt to yield across C-call boundary" -> pcall swallows the +-- error and the chunk is silently lost while the rest of the script keeps +-- running (backend logs look fine but the frontend receives nothing). +-- Callers MUST call write_padded() directly, never through pcall. +-- 8192-space first line only helps the old buffered path. +-- Ref: openwrt/luci master: libs/luci-lib-base/luasrc/http.lua +-- modules/luci-base/ucode/http.uc, htdocs/cgi-bin/luci +-- immortalwrt/luci 18.06-k5.4: modules/luci-base/luasrc/http.lua +-- jow-/ucode: vm.c (uc_vm_insn_print), lib/fs.c, main.c +-- +-- Nginx mode (ImmortalWrt luci-nginx / luci-ssl-nginx): LuCI is not run by +-- uhttpd directly; nginx forwards /cgi-bin/luci over the uwsgi protocol to +-- the uwsgi-cgi plugin (luci-webui vassal), which forks the CGI and pipes its +-- stdout back through nginx. The CGI-side flush (io.flush / L.http:write) +-- only gets data into the pipe; nginx still buffers the whole response body +-- by default (uwsgi_buffering on, ~8KB) and only pushes it once the buffer +-- fills or the response ends, so the 8192-space first line alone cannot keep +-- the stream alive. To make nginx forward every chunk in real time, the CGI +-- response must carry the "X-Accel-Buffering: no" header (honored by nginx +-- proxy/fastcgi/uwsgi modules). It is emitted unconditionally on the very +-- first write, before any body, so it lands in the response header block. +-- Backend detection via SERVER_SOFTWARE was deliberately dropped: the value +-- is not reliable (custom uwsgi_params may pre-set it to "nginx", defeating +-- the check). The header is only meaningful to nginx and is stripped by it +-- before reaching the client; under uhttpd it is simply passed through and +-- ignored, so sending it always is harmless. +local write_padded_first = true -function action_start() - HTTP.prepare_content("application/json") - HTTP.write_json({ - startlog = startlog(); - }) -end - -function action_get_last_version() - if not process_status("/usr/share/openclash/clash_version.sh") then - if tonumber(os.time() - (fs.mtime("/tmp/clash_last_version") or 0)) > 1800 then - SYS.call("bash /usr/share/openclash/clash_version.sh &") - end - end - if not process_status("/usr/share/openclash/openclash_version.sh") then - if tonumber(os.time() - (fs.mtime("/tmp/openclash_last_version") or 0)) > 1800 then - SYS.call("bash /usr/share/openclash/openclash_version.sh &") - end - end -end - -function action_update() - HTTP.prepare_content("application/json") - HTTP.write_json({ - coremodel = coremodel(), - coremetacv = coremetacv(), - corelv = corelv(), - opcv = opcv(), - oplv = oplv(), - upchecktime = upchecktime(); - }) -end - -function action_update_info() - HTTP.prepare_content("application/json") - HTTP.write_json({ - corever = corever(), - release_branch = release_branch(), - smart_enable = smart_enable(); - }) -end - -function action_update_ma() - HTTP.prepare_content("application/json") - HTTP.write_json({ - oplv = oplv(), - pkg_type = fs.pkg_type(), - oix_core = fs.uci_get_config("config", "oix_token") ~= "", - corelv = corelv(), - corever = corever(); - }) -end - -function action_opupdate() - HTTP.prepare_content("application/json") - HTTP.write_json({ - opup = opup(); - }) -end - -function action_check_core() - HTTP.prepare_content("application/json") - HTTP.write_json({ - core_status = check_core(); - }) -end - -function action_coreupdate() - HTTP.prepare_content("application/json") - HTTP.write_json({ - coreup = coreup(); - }) -end - -function action_close_all_connection() - return SYS.call("sh /usr/share/openclash/openclash_history_get.sh 'close_all_conection'") -end - -function action_reload_firewall() - return SYS.call("/etc/init.d/openclash reload 'manual' >/dev/null 2>&1 &") -end - -function action_download_rule() - HTTP.prepare_content("application/json") - HTTP.write_json({ - rule_download_status = download_rule(); - }) -end - -function action_refresh_log() - HTTP.prepare_content("application/json") - local logfile = "/tmp/openclash.log" - local log_len = tonumber(HTTP.formvalue("log_len")) or 0 - local core_refresh = HTTP.formvalue("core_refresh") == "true" - - if not fs.access(logfile) then - HTTP.write_json({ - len = 0, - update = false, - core_log = "", - oc_log = "" - }) - return - end - - local total_lines = tonumber(SYS.exec("wc -l < " .. logfile)) or 0 - - if total_lines == log_len and log_len > 0 then - HTTP.write_json({ - len = total_lines, - update = false, - core_log = "", - oc_log = "" - }) - return - end - - local exclude_pattern = "UDP%-Receive%-Buffer%-Size|^Sec%-Fetch%-Mode|^User%-Agent|^Access%-Control|^Accept|^Origin|^Referer|^Connection|^Pragma|^Cache%-" - local core_pattern = "level=|^time=" - local limit = 1000 - local start_line = (log_len > 0 and total_lines > log_len) and (log_len + 1) or 1 - local read_count = math.max(0, total_lines - start_line + 1) - local core_cmd, oc_cmd, core_raw, oc_raw - local core_logs = {} - local oc_logs = {} - - core_cmd = string.format( - "sed -n '%d,%dp' '%s' | grep -v -E '%s' | grep -E '%s' | tail -n %d", - start_line, start_line + read_count - 1, logfile, exclude_pattern, core_pattern, limit - ) - - oc_cmd = string.format( - "sed -n '%d,%dp' '%s' | grep -v -E '%s' | grep -v -E '%s' | tail -n %d", - start_line, start_line + read_count - 1, logfile, exclude_pattern, core_pattern, limit - ) - - if core_refresh then - core_raw = SYS.exec(core_cmd) - end - - oc_raw = SYS.exec(oc_cmd) - - if core_raw and core_raw ~= "" then - for line in core_raw:gmatch("[^\n]+") do - core_logs[#core_logs + 1] = line - end - end - - if oc_raw and oc_raw ~= "" then - for line in oc_raw:gmatch("[^\n]+") do - if not string.match(string.sub(line, 1, 19), "%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d") then - line = os.date("%Y-%m-%d %H:%M:%S") .. ' [Fatal] ' .. line - end - oc_logs[#oc_logs + 1] = trans_line(line) - end - end - - if #core_logs > limit then - core_logs = {table.unpack(core_logs, #core_logs - limit + 1)} - end - if #oc_logs > limit then - oc_logs = {table.unpack(oc_logs, #oc_logs - limit + 1)} - end - - local core_log = #core_logs > 0 and table.concat(core_logs, "\n") or "" - local oc_log = #oc_logs > 0 and table.concat(oc_logs, "\n") or "" - - HTTP.write_json({ - len = total_lines, - update = true, - core_log = core_log, - oc_log = oc_log - }) -end - -function action_del_log() - local log_type = HTTP.formvalue("type") - if log_type == "debug" then - SYS.exec(": > /tmp/openclash_debug.log") - else - SYS.exec(": > /tmp/openclash.log") - end - return -end - -function action_del_start_log() - SYS.exec("echo '##FINISH##' > /tmp/openclash_start.log") - return -end - -function split(str,delimiter) - local dLen = string.len(delimiter) - local newDeli = '' - for i=1,dLen,1 do - newDeli = newDeli .. "["..string.sub(delimiter,i,i).."]" - end - - local locaStart,locaEnd = string.find(str,newDeli) - local arr = {} - local n = 1 - while locaStart ~= nil - do - if locaStart>0 then - arr[n] = string.sub(str,1,locaStart-1) - n = n + 1 - end - - str = string.sub(str,locaEnd+1,string.len(str)) - locaStart,locaEnd = string.find(str,newDeli) - end - if str ~= nil then - arr[n] = str - end - return arr -end - -function action_diag_connection() - local addr = HTTP.formvalue("addr") - if addr and (datatype.hostname(addr) or datatype.ipaddr(addr)) then - local cmd = string.format("/usr/share/openclash/openclash_debug_getcon.lua %s", addr) - HTTP.prepare_content("text/plain") - local util = io.popen(cmd) - if util and util ~= "" then - while true do - local ln = util:read("*l") - if not ln then break end - HTTP.write(ln) - HTTP.write("\n") - end - util:close() - end - return - end - HTTP.status(500, "Bad address") -end - -function action_diag_dns() - local addr = HTTP.formvalue("addr") - if addr and datatype.hostname(addr)then - local cmd = string.format("/usr/share/openclash/openclash_debug_dns.lua %s", addr) - HTTP.prepare_content("text/plain") - local util = io.popen(cmd) - if util and util ~= "" then - while true do - local ln = util:read("*l") - if not ln then break end - HTTP.write(ln) - HTTP.write("\n") - end - util:close() - end - return - end - HTTP.status(500, "Bad address") -end - -function action_gen_debug_logs() - local gen_log = SYS.call("/usr/share/openclash/openclash_debug.sh") - if not gen_log then return end - local logfile = "/tmp/openclash_debug.log" - if not fs.access(logfile) then - return - end - HTTP.prepare_content("text/plain; charset=utf-8") - local file=io.open(logfile, "r+") - file:seek("set") - local info = "" - for line in file:lines() do - if info ~= "" then - info = info.."\n"..line +local function write_padded(data) + if write_padded_first then + if L and L.http then + L.http:header("X-Accel-Buffering", "no") else - info = line + HTTP.header("X-Accel-Buffering", "no") end - end - file:close() - HTTP.write(info) -end - -function action_get_debug_logs() - local logfile = "/tmp/openclash_debug.log" - if not fs.access(logfile) then - return - end - HTTP.prepare_content("text/plain; charset=utf-8") - HTTP.write(fs.readfile(logfile) or "") -end - -function action_backup() - local config = SYS.call("cp /etc/config/openclash /etc/openclash/openclash >/dev/null 2>&1") - local reader = ltn12_popen("tar -C '/etc/openclash/' -cz . 2>/dev/null") - - HTTP.header( - 'Content-Disposition', 'attachment; filename="Backup-OpenClash-%s-%s-%s.tar.gz"' %{ - device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") - }) - - HTTP.prepare_content("application/x-targz") - luci.ltn12.pump.all(reader, HTTP.write) - SYS.call("rm -rf /etc/openclash/openclash >/dev/null 2>&1") -end - -function action_backup_ex_core() - local config = SYS.call("cp /etc/config/openclash /etc/openclash/openclash >/dev/null 2>&1") - local reader = ltn12_popen("echo 'core' > /tmp/oc_exclude.txt && tar -C '/etc/openclash/' -X '/tmp/oc_exclude.txt' -cz . 2>/dev/null") - - HTTP.header( - 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Exclude-Cores-%s-%s-%s.tar.gz"' %{ - device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") - }) - - HTTP.prepare_content("application/x-targz") - luci.ltn12.pump.all(reader, HTTP.write) - SYS.call("rm -rf /etc/openclash/openclash >/dev/null 2>&1") -end - -function action_backup_only_config() - local reader = ltn12_popen("tar -C '/etc/openclash' -cz './config' 2>/dev/null") - - HTTP.header( - 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Config-%s-%s-%s.tar.gz"' %{ - device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") - }) - - HTTP.prepare_content("application/x-targz") - luci.ltn12.pump.all(reader, HTTP.write) -end - -function action_backup_only_core() - local reader = ltn12_popen("tar -C '/etc/openclash' -cz './core' 2>/dev/null") - - HTTP.header( - 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Cores-%s-%s-%s.tar.gz"' %{ - device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") - }) - - HTTP.prepare_content("application/x-targz") - luci.ltn12.pump.all(reader, HTTP.write) -end - -function action_backup_only_rule() - local reader = ltn12_popen("tar -C '/etc/openclash' -cz './rule_provider' 2>/dev/null") - - HTTP.header( - 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Only-Rule-Provider-%s-%s-%s.tar.gz"' %{ - device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") - }) - - HTTP.prepare_content("application/x-targz") - luci.ltn12.pump.all(reader, HTTP.write) -end - -function action_backup_only_proxy() - local reader = ltn12_popen("tar -C '/etc/openclash' -cz './proxy_provider' 2>/dev/null") - - HTTP.header( - 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Proxy-Provider-%s-%s-%s.tar.gz"' %{ - device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") - }) - - HTTP.prepare_content("application/x-targz") - luci.ltn12.pump.all(reader, HTTP.write) -end - -function ltn12_popen(command) - - local fdi, fdo = nixio.pipe() - local pid = nixio.fork() - - if pid > 0 then - fdo:close() - local close - return function() - local buffer = fdi:read(2048) - local wpid, stat = nixio.waitpid(pid, "nohang") - if not close and wpid and stat == "exited" then - close = true - end - - if buffer and #buffer > 0 then - return buffer - elseif close then - fdi:close() - return nil - end + if L and L.http then + L.http:write(string.rep(" ", 8192) .. "\n") + L.http:write(data .. "\n") + else + HTTP.write(string.rep(" ", 8192) .. "\n") + HTTP.write(data .. "\n") end - elseif pid == 0 then - nixio.dup(fdo, nixio.stdout) - fdi:close() - fdo:close() - nixio.exec("/bin/sh", "-c", command) - end -end - -function create_file() - local file_name = HTTP.formvalue("filename") - local file_path = HTTP.formvalue("filepath")..file_name - fs.writefile(file_path, "") - if not fs.isfile(file_path) then - HTTP.status(500, "Create File Failed") - end - return -end - -function rename_file() - local new_file_name = HTTP.formvalue("new_file_name") - local file_path = HTTP.formvalue("file_path") - local old_file_name = HTTP.formvalue("file_name") - local old_file_path = file_path .. old_file_name - local new_file_path = file_path .. new_file_name - local old_run_file_path = "/etc/openclash/" .. old_file_name - local new_run_file_path = "/etc/openclash/" .. new_file_name - if fs.rename(old_file_path, new_file_path) then - if file_path == "/etc/openclash/config/" then - if fs.uci_get_config("config", "config_path") == old_file_path then - uci:set("openclash", "config", "config_path", new_file_path) - end - - if fs.isfile(old_run_file_path) then - fs.rename(old_run_file_path, new_run_file_path) - end - - fs.config_refs(old_file_name, new_file_name) - end - HTTP.status(200, "Rename File Successful") + write_padded_first = false else - HTTP.status(500, "Rename File Failed") + if L and L.http then + L.http:write(data .. "\n") + else + HTTP.write(data .. "\n") + end end - return + io.flush() end -function manual_stream_unlock_test() - local type = HTTP.formvalue("type") - local cmd = string.format('/usr/share/openclash/openclash_streaming_unlock.lua "%s"', type) - HTTP.prepare_content("text/plain; charset=utf-8") - local util = io.popen(cmd) - if util and util ~= "" then - while true do - local ln = util:read("*l") - if ln then - HTTP.write(trans_line(ln)) - HTTP.write("\n") - end - if not process_status("openclash_streaming_unlock.lua "..type) or not process_status("openclash_streaming_unlock.lua ") then - break - end +function try_read(fd, maxlen) + local pfds = { + { fd = fd, events = nixio.poll_flags("in", "hup", "err") } + } + local nfds = nixio.poll(pfds, 0) + if nfds and nfds > 0 then + local buf = fd:read(maxlen or 4096) + if buf and #buf > 0 then + return buf end - util:close() - return end - HTTP.status(500, "Something Wrong While Testing...") -end - -function all_proxies_stream_test() - local type = HTTP.formvalue("type") - local cmd = string.format('/usr/share/openclash/openclash_streaming_unlock.lua "%s" "%s"', type, "all") - HTTP.prepare_content("text/plain; charset=utf-8") - local util = io.popen(cmd) - if util and util ~= "" then - while true do - local ln = util:read("*l") - if ln then - HTTP.write(trans_line(ln)) - HTTP.write("\n") - end - if not process_status("openclash_streaming_unlock.lua "..type) or not process_status("openclash_streaming_unlock.lua ") then - break - end - end - util:close() - return - end - HTTP.status(500, "Something Wrong While Testing...") + return nil end function trans_line(data) @@ -2068,6 +1692,626 @@ function process_status(name) return result ~= nil and result ~= "" and not result:match("^%s*$") end +local START_SCRIPT_PATTERNS = { + ["init"] = "/etc/init.d/[o]penclash", + ["openclash.sh"] = "[o]penclash\\.sh", + ["openclash_core.sh"] = "[o]penclash_core\\.sh", + ["openclash_update.sh"] = "[o]penclash_update\\.sh", +} + +local function stream_log_and_parse(reader) + local buf = "" + + while true do + local chunk = reader() + if not chunk then break end + buf = buf .. chunk + while true do + local nl = buf:find("\n") + if not nl then break end + local line = buf:sub(1, nl - 1) + buf = buf:sub(nl + 1) + line = line:gsub("^%s+", ""):gsub("%s+$", "") + if line ~= "" then + if line == "##FINISHED##" or line == "##CONTINUE##" then + write_padded(line) + else + write_padded(trans_line(line)) + end + end + end + end + reader.kill() +end + +function action_start() + HTTP.prepare_content("text/plain; charset=utf-8") + local logfile = "/tmp/openclash_start.log" + local pattern = START_SCRIPT_PATTERNS[HTTP.formvalue("script")] + + local cmd + if pattern then + cmd = string.format( + "logfile='%s'; pattern='%s'; " .. + "if [ \"$(ps --version 2>&1 | grep -c procps-ng)\" -eq 1 ]; then PS_CMD='ps -efw'; else PS_CMD='ps -w'; fi; " .. + "bytes=$(wc -c < \"$logfile\" 2>/dev/null); bytes=${bytes:-0}; " .. + "[ \"$bytes\" -gt 0 ] && tail -c +1 \"$logfile\" 2>/dev/null; " .. + "seen=0; [ \"$bytes\" -gt 0 ] && seen=1; " .. + "elapsed=0; " .. + "while true; do " .. + "new_bytes=$(wc -c < \"$logfile\" 2>/dev/null); new_bytes=${new_bytes:-0}; " .. + "if [ \"$new_bytes\" -gt \"$bytes\" ] 2>/dev/null; then " .. + "if [ \"$(tail -c 1 \"$logfile\" 2>/dev/null)\" = \"$(printf '\\n')\" ]; then " .. + "tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null; bytes=$new_bytes; " .. + "else tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null | awk '{ if (p) printf \"%%s\\n\", prev; prev = $0; p = 1 }'; " .. + "bytes=$((new_bytes - $(tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null | awk 'END { print length($0) }'))); " .. + "fi; " .. + "elif [ \"$new_bytes\" -lt \"$bytes\" ] 2>/dev/null; then " .. + "if [ \"$(tail -c 1 \"$logfile\" 2>/dev/null)\" = \"$(printf '\\n')\" ]; then " .. + "tail -c +1 \"$logfile\" 2>/dev/null; bytes=$new_bytes; " .. + "else tail -c +1 \"$logfile\" 2>/dev/null | awk '{ if (p) printf \"%%s\\n\", prev; prev = $0; p = 1 }'; " .. + "bytes=$((new_bytes - $(tail -c +1 \"$logfile\" 2>/dev/null | awk 'END { print length($0) }'))); " .. + "fi; " .. + "fi; " .. + "liveness=$($PS_CMD | grep -v grep | grep \"$pattern\" | grep -v \"openclash_start\" | grep -c \"^\"); " .. + "[ \"$liveness\" -gt 0 ] 2>/dev/null && seen=1; " .. + "if [ \"$seen\" -eq 1 ]; then " .. + "if [ \"$liveness\" = \"0\" ]; then echo '##FINISHED##'; exit 0; fi; " .. + "if [ \"$elapsed\" -ge 50 ]; then echo '##CONTINUE##'; exit 0; fi; " .. + "else " .. + "if [ \"$elapsed\" -ge 5 ]; then echo '##FINISHED##'; exit 0; fi; " .. + "fi; " .. + "sleep 1; elapsed=$((elapsed + 1)); " .. + "done", + logfile, pattern + ) + else + cmd = string.format( + "logfile='%s'; " .. + "bytes=$(wc -c < \"$logfile\" 2>/dev/null); bytes=${bytes:-0}; " .. + "[ \"$bytes\" -gt 0 ] && tail -c +1 \"$logfile\" 2>/dev/null; " .. + "observed=0; [ \"$bytes\" -gt 0 ] && observed=1; " .. + "elapsed=0; idle=0; " .. + "while true; do " .. + "new_bytes=$(wc -c < \"$logfile\" 2>/dev/null); new_bytes=${new_bytes:-0}; " .. + "if [ \"$new_bytes\" -gt \"$bytes\" ] 2>/dev/null; then " .. + "if [ \"$(tail -c 1 \"$logfile\" 2>/dev/null)\" = \"$(printf '\\n')\" ]; then " .. + "tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null; bytes=$new_bytes; " .. + "else tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null | awk '{ if (p) printf \"%%s\\n\", prev; prev = $0; p = 1 }'; " .. + "bytes=$((new_bytes - $(tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null | awk 'END { print length($0) }'))); " .. + "fi; observed=1; idle=0; " .. + "elif [ \"$new_bytes\" -lt \"$bytes\" ] 2>/dev/null; then " .. + "if [ \"$(tail -c 1 \"$logfile\" 2>/dev/null)\" = \"$(printf '\\n')\" ]; then " .. + "tail -c +1 \"$logfile\" 2>/dev/null; bytes=$new_bytes; " .. + "else tail -c +1 \"$logfile\" 2>/dev/null | awk '{ if (p) printf \"%%s\\n\", prev; prev = $0; p = 1 }'; " .. + "bytes=$((new_bytes - $(tail -c +1 \"$logfile\" 2>/dev/null | awk 'END { print length($0) }'))); " .. + "fi; observed=1; idle=0; " .. + "else idle=$((idle + 1)); fi; " .. + "if [ \"$observed\" -eq 1 ] && [ \"$idle\" -ge 10 ]; then echo '##FINISHED##'; exit 0; fi; " .. + "if [ \"$elapsed\" -ge 50 ]; then echo '##FINISHED##'; exit 0; fi; " .. + "if [ \"$observed\" -eq 0 ] && [ \"$elapsed\" -ge 3 ]; then echo '##FINISHED##'; exit 0; fi; " .. + "sleep 1; elapsed=$((elapsed + 1)); " .. + "done", + logfile + ) + end + + local reader = ltn12_popen(cmd) + if not reader then return end + + stream_log_and_parse(reader) +end + +function action_update() + HTTP.prepare_content("application/json") + HTTP.write_json({ + coremodel = coremodel(), + corever = corever(), + release_branch = release_branch(), + smart_enable = smart_enable(), + oix_core = is_oix(), + pkg_type = fs.pkg_type(), + coremetacv = coremetacv(), + opcv = opcv(), + github_address_mod = fs.uci_get_config("config", "github_address_mod") or "0", + cdn_list = fs.cdn_list(); + }) +end + +function action_save_github_address_mod() + local value = HTTP.formvalue("value") or "" + uci:set("openclash", "config", "github_address_mod", value) + uci:commit("openclash") + HTTP.prepare_content("application/json") + HTTP.write_json({ + success = true; + }) +end + +function action_last_version() + HTTP.prepare_content("application/json") + HTTP.write_json({ + corelv = corelv(), + oplv = oplv(); + }) +end + +function action_opupdate() + HTTP.prepare_content("application/json") + HTTP.write_json({ + opup = opup(); + }) +end + +function action_check_core() + HTTP.prepare_content("application/json") + HTTP.write_json({ + core_status = check_core(); + }) +end + +function action_coreupdate() + HTTP.prepare_content("application/json") + HTTP.write_json({ + coreup = coreup(); + }) +end + +function action_close_all_connection() + return SYS.call("sh /usr/share/openclash/openclash_history_get.sh 'close_all_conection'") +end + +function action_reload_firewall() + return SYS.call("/etc/init.d/openclash reload 'manual' >/dev/null 2>&1 &") +end + +function action_download_rule() + HTTP.prepare_content("application/json") + HTTP.write_json({ + rule_download_status = download_rule(); + }) +end + +function action_refresh_log() + HTTP.prepare_content("application/json") + local logfile = "/tmp/openclash.log" + local log_len = tonumber(HTTP.formvalue("log_len")) or 0 + local core_refresh = HTTP.formvalue("core_refresh") == "true" + + if not fs.access(logfile) then + HTTP.write_json({ + len = 0, + update = false, + core_log = "", + oc_log = "" + }) + return + end + + local total_lines = tonumber(SYS.exec("wc -l < " .. logfile)) or 0 + + if total_lines == log_len and log_len > 0 then + HTTP.write_json({ + len = total_lines, + update = false, + core_log = "", + oc_log = "" + }) + return + end + + local exclude_pattern = "UDP%-Receive%-Buffer%-Size|^Sec%-Fetch%-Mode|^User%-Agent|^Access%-Control|^Accept|^Origin|^Referer|^Connection|^Pragma|^Cache%-" + local core_pattern = "level=|^time=" + local limit = core_refresh and 1000 or 2000 + local start_line = (log_len > 0 and total_lines > log_len) and (log_len + 1) or 1 + local read_count = math.max(0, total_lines - start_line + 1) + local core_raw, oc_raw + local oc_truncated = false + local core_truncated = false + local core_log = "" + local oc_log = "" + + local sed_range = string.format("sed -n '%d,%dp' '%s'", start_line, start_line + read_count - 1, logfile) + + local oc_cmd = string.format( + "%s | grep -v -E '%s' | grep -v -E '%s' | tail -n %d", + sed_range, exclude_pattern, core_pattern, limit + 1 + ) + + oc_raw = SYS.exec(oc_cmd) + + if oc_raw and oc_raw ~= "" then + local oc_logs = {} + local oc_count = 0 + for line in oc_raw:gmatch("[^\n]+") do + oc_count = oc_count + 1 + if not string.match(string.sub(line, 1, 19), "%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d") then + line = os.date("%Y-%m-%d %H:%M:%S") .. ' [Fatal] ' .. line + end + oc_logs[#oc_logs + 1] = trans_line(line) + end + + oc_truncated = (oc_count > limit) + if oc_truncated and #oc_logs > limit then + local kept = {} + for i = #oc_logs - limit + 1, #oc_logs do + kept[#kept + 1] = oc_logs[i] + end + oc_logs = kept + end + oc_log = #oc_logs > 0 and table.concat(oc_logs, "\n") or "" + end + + if core_refresh then + local core_cmd = string.format( + "%s | grep -v -E '%s' | grep -E '%s' | tail -n %d", + sed_range, exclude_pattern, core_pattern, limit + 1 + ) + core_raw = SYS.exec(core_cmd) + + if core_raw and core_raw ~= "" then + local _, core_count = core_raw:gsub("\n", "") + if core_raw:sub(-1) ~= "\n" then + core_count = core_count + 1 + end + + core_truncated = (core_count > limit) + if core_truncated then + core_log = core_raw:match("\n(.+)") or "" + core_log = core_log:gsub("\n$", "") + else + core_log = core_raw:gsub("\n$", "") + end + end + end + + if core_truncated and core_log ~= "" then + core_log = "...\n" .. core_log + end + if oc_truncated and oc_log ~= "" then + oc_log = "...\n" .. oc_log + end + + HTTP.write_json({ + len = total_lines, + update = true, + core_log = core_log, + oc_log = oc_log + }) +end + +function action_del_log() + local log_type = HTTP.formvalue("type") + if log_type == "debug" then + SYS.exec(": > /tmp/openclash_debug.log") + else + SYS.exec(": > /tmp/openclash.log") + end + return +end + +function action_del_start_log() + fs.writefile("/tmp/openclash_start.log", "") +end + +function action_diag_connection() + local addr = HTTP.formvalue("addr") + if addr and (datatype.hostname(addr) or datatype.ipaddr(addr)) then + local cmd = string.format("/usr/share/openclash/openclash_debug_getcon.lua %s", addr) + HTTP.prepare_content("text/plain") + local util = io.popen(cmd) + if util and util ~= "" then + while true do + local ln = util:read("*l") + if not ln then break end + write_padded(ln) + end + util:close() + end + return + end + HTTP.status(500, "Bad address") +end + +function action_diag_dns() + local addr = HTTP.formvalue("addr") + if addr and datatype.hostname(addr)then + local cmd = string.format("/usr/share/openclash/openclash_debug_dns.lua %s", addr) + HTTP.prepare_content("text/plain") + local util = io.popen(cmd) + if util and util ~= "" then + while true do + local ln = util:read("*l") + if not ln then break end + write_padded(ln) + end + util:close() + end + return + end + HTTP.status(500, "Bad address") +end + +function action_gen_debug_logs() + HTTP.prepare_content("text/plain; charset=utf-8") + local logfile = "/tmp/openclash_debug.log" + + local cmd = string.format( + "logfile='%s'; : > \"$logfile\"; " .. + "/usr/share/openclash/openclash_debug.sh >/dev/null 2>&1 & DEBUG_PID=$!; " .. + "bytes=0; elapsed=0; while true; do " .. + "new_bytes=$(wc -c < \"$logfile\" 2>/dev/null); new_bytes=${new_bytes:-0}; " .. + "if [ \"$new_bytes\" -gt \"$bytes\" ] 2>/dev/null; then " .. + "if [ \"$(tail -c 1 \"$logfile\" 2>/dev/null)\" = \"$(printf '\\n')\" ]; then " .. + "tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null; bytes=$new_bytes; " .. + "else tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null | awk '{ if (p) printf \"%%s\\n\", prev; prev = $0; p = 1 }'; " .. + "bytes=$((new_bytes - $(tail -c +$((bytes + 1)) \"$logfile\" 2>/dev/null | awk 'END { print length($0) }'))); " .. + "fi; elapsed=0; fi; " .. + "if ! kill -0 $DEBUG_PID 2>/dev/null; then " .. + "exit 0; " .. + "fi; sleep 1; elapsed=$((elapsed + 1)); " .. + "if [ $elapsed -ge 60 ]; then exit 0; fi; done", + logfile + ) + local reader = ltn12_popen(cmd) + if not reader then return end + + local buf = "" + + while true do + local chunk = reader() + if not chunk then break end + buf = buf .. chunk + while true do + local nl = buf:find("\n") + if not nl then break end + local line = buf:sub(1, nl - 1) + buf = buf:sub(nl + 1) + write_padded(line) + end + end + reader.kill() +end + +function action_get_debug_logs() + local logfile = "/tmp/openclash_debug.log" + if not fs.access(logfile) then + return + end + HTTP.prepare_content("text/plain; charset=utf-8") + local reader = ltn12_popen("exec cat '" .. logfile .. "'") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() +end + +function action_backup() + local config = SYS.call("cp /etc/config/openclash /etc/openclash/openclash >/dev/null 2>&1") + local reader = ltn12_popen("exec tar -C '/etc/openclash/' -cz . 2>/dev/null") + + HTTP.header( + 'Content-Disposition', 'attachment; filename="Backup-OpenClash-%s-%s-%s.tar.gz"' %{ + device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") + }) + + HTTP.prepare_content("application/x-targz") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() + SYS.call("rm -rf /etc/openclash/openclash >/dev/null 2>&1") +end + +function action_backup_ex_core() + local config = SYS.call("cp /etc/config/openclash /etc/openclash/openclash >/dev/null 2>&1") + local reader = ltn12_popen("echo 'core' > /tmp/oc_exclude.txt && exec tar -C '/etc/openclash/' -X '/tmp/oc_exclude.txt' -cz . 2>/dev/null") + + HTTP.header( + 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Exclude-Cores-%s-%s-%s.tar.gz"' %{ + device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") + }) + + HTTP.prepare_content("application/x-targz") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() + SYS.call("rm -rf /etc/openclash/openclash >/dev/null 2>&1") +end + +function action_backup_only_config() + local reader = ltn12_popen("exec tar -C '/etc/openclash' -cz './config' 2>/dev/null") + + HTTP.header( + 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Config-%s-%s-%s.tar.gz"' %{ + device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") + }) + + HTTP.prepare_content("application/x-targz") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() +end + +function action_backup_only_core() + local reader = ltn12_popen("exec tar -C '/etc/openclash' -cz './core' 2>/dev/null") + + HTTP.header( + 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Cores-%s-%s-%s.tar.gz"' %{ + device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") + }) + + HTTP.prepare_content("application/x-targz") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() +end + +function action_backup_only_rule() + local reader = ltn12_popen("exec tar -C '/etc/openclash' -cz './rule_provider' 2>/dev/null") + + HTTP.header( + 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Only-Rule-Provider-%s-%s-%s.tar.gz"' %{ + device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") + }) + + HTTP.prepare_content("application/x-targz") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() +end + +function action_backup_only_proxy() + local reader = ltn12_popen("exec tar -C '/etc/openclash' -cz './proxy_provider' 2>/dev/null") + + HTTP.header( + 'Content-Disposition', 'attachment; filename="Backup-OpenClash-Proxy-Provider-%s-%s-%s.tar.gz"' %{ + device_name, device_arh, os.date("%Y-%m-%d-%H-%M-%S") + }) + + HTTP.prepare_content("application/x-targz") + luci.ltn12.pump.all(reader, HTTP.write) + reader.kill() +end + +function ltn12_popen(command) + local fdi, fdo = nixio.pipe() + if not fdi or not fdo then return nil end + + local pid = nixio.fork() + + if pid > 0 then + fdo:close() + local reaped = false + + local function pipe_read() + local ok_r, buffer = pcall(try_read, fdi, 16384) + if ok_r and buffer then + return buffer + end + + if not reaped then + local ok_w, wpid = pcall(nixio.waitpid, pid, "nohang") + if ok_w and wpid then + reaped = true + end + end + + if reaped then + local ok_l, last = pcall(try_read, fdi, 16384) + if ok_l and last then + return last + end + pcall(fdi.close, fdi) + return nil + else + nixio.nanosleep(0, 50000000) + return "" + end + end + + local function pipe_kill() + if not reaped then + pcall(nixio.kill, pid, nixio.const.SIGTERM) + nixio.nanosleep(0, 100000000) + pcall(nixio.kill, pid, nixio.const.SIGKILL) + nixio.waitpid(pid) + reaped = true + end + pcall(fdi.close, fdi) + end + + -- Lua 5.1 does not allow setting fields on functions. + -- Use a callable table: reader() invokes __call, reader.kill() is a table field. + local wrapper = { kill = pipe_kill } + setmetatable(wrapper, { __call = function(_, ...) return pipe_read(...) end }) + return wrapper + elseif pid == 0 then + nixio.dup(fdo, nixio.stdout) + fdi:close() + fdo:close() + nixio.exec("/bin/sh", "-c", command) + os.exit(127) + else + fdi:close() + fdo:close() + return nil + end +end + +function create_file() + local file_name = HTTP.formvalue("filename") + local file_path = HTTP.formvalue("filepath")..file_name + fs.writefile(file_path, "") + if not fs.isfile(file_path) then + HTTP.status(500, "Create File Failed") + end + return +end + +function rename_file() + local new_file_name = HTTP.formvalue("new_file_name") + local file_path = HTTP.formvalue("file_path") + local old_file_name = HTTP.formvalue("file_name") + local old_file_path = file_path .. old_file_name + local new_file_path = file_path .. new_file_name + local old_run_file_path = "/etc/openclash/" .. old_file_name + local new_run_file_path = "/etc/openclash/" .. new_file_name + if fs.rename(old_file_path, new_file_path) then + if file_path == "/etc/openclash/config/" then + if fs.uci_get_config("config", "config_path") == old_file_path then + uci:set("openclash", "config", "config_path", new_file_path) + end + + if fs.isfile(old_run_file_path) then + fs.rename(old_run_file_path, new_run_file_path) + end + + fs.config_refs(old_file_name, new_file_name) + end + HTTP.status(200, "Rename File Successful") + else + HTTP.status(500, "Rename File Failed") + end + return +end + +function manual_stream_unlock_test() + local type = HTTP.formvalue("type") + local cmd = string.format('/usr/share/openclash/openclash_streaming_unlock.lua "%s"', type) + HTTP.prepare_content("text/plain; charset=utf-8") + local util = io.popen(cmd) + if util and util ~= "" then + while true do + local ln = util:read("*l") + if not ln then break end + if ln ~= "" then + write_padded(trans_line(ln)) + end + if not process_status("openclash_streaming_unlock.lua "..type) or not process_status("openclash_streaming_unlock.lua ") then + break + end + end + util:close() + return + end + HTTP.status(500, "Something Wrong While Testing...") +end + +function all_proxies_stream_test() + local type = HTTP.formvalue("type") + local cmd = string.format('/usr/share/openclash/openclash_streaming_unlock.lua "%s" "%s"', type, "all") + HTTP.prepare_content("text/plain; charset=utf-8") + local util = io.popen(cmd) + if util and util ~= "" then + while true do + local ln = util:read("*l") + if not ln then break end + if ln ~= "" then + write_padded(trans_line(ln)) + end + if not process_status("openclash_streaming_unlock.lua "..type) or not process_status("openclash_streaming_unlock.lua ") then + break + end + end + util:close() + return + end + HTTP.status(500, "Something Wrong While Testing...") +end + function action_announcement() if not fs.access("/tmp/openclash_announcement") or fs.readfile("/tmp/openclash_announcement") == "" or fs.mtime("/tmp/openclash_announcement") < (os.time() - 86400) then local HTTP_CODE = SYS.exec("curl -SsL -m 5 -w '%{http_code}' -o /tmp/openclash_announcement https://raw.githubusercontent.com/vernesong/OpenClash/dev/announcement 2>/dev/null") @@ -2085,34 +2329,35 @@ end function action_myip_check() local result = {} local random = math.random(100000000) + HTTP.prepare_content("text/plain; charset=utf-8") local services = { { - name = "upaiyun", - url = string.format("https://pubstatic.b0.upaiyun.com/?_upnode&z=%d", random), + name = "pcol", + url = string.format("https://whois.pconline.com.cn/ipJson.jsp?json=true&z=%d", random), parser = function(data) if data and data ~= "" then - local ok, upaiyun_json = pcall(json.parse, data) - if ok and upaiyun_json and upaiyun_json.remote_addr then + -- json.parse tolerates GBK bytes in string values (JSON structure is ASCII) + local ok, parsed = pcall(json.parse, data) + if ok and parsed and parsed.ip then local geo_parts = {} - if upaiyun_json.remote_addr_location then - if upaiyun_json.remote_addr_location.country and upaiyun_json.remote_addr_location.country ~= "" then - table.insert(geo_parts, upaiyun_json.remote_addr_location.country) - end - if upaiyun_json.remote_addr_location.province and upaiyun_json.remote_addr_location.province ~= "" then - table.insert(geo_parts, upaiyun_json.remote_addr_location.province) - end - if upaiyun_json.remote_addr_location.city and upaiyun_json.remote_addr_location.city ~= "" then - table.insert(geo_parts, upaiyun_json.remote_addr_location.city) - end - if upaiyun_json.remote_addr_location.isp and upaiyun_json.remote_addr_location.isp ~= "" then - table.insert(geo_parts, upaiyun_json.remote_addr_location.isp) + if parsed.pro and parsed.pro ~= "" then + table.insert(geo_parts, parsed.pro) + end + if parsed.city and parsed.city ~= "" then + table.insert(geo_parts, parsed.city) + end + if parsed.addr and parsed.addr ~= "" then + local isp = string.match(parsed.addr, "%s(%S+)$") + if isp then + table.insert(geo_parts, isp) end end - + local geo = table.concat(geo_parts, " ") return { - ip = upaiyun_json.remote_addr, - geo = table.concat(geo_parts, " ") + ip = parsed.ip, + geo = HTTP.urlencode(geo), + raw = true } end end @@ -2142,7 +2387,7 @@ function action_myip_check() }, { name = "ipsb", - url = string.format("https://api-ipv4.ip.sb/geoip?z=%d", random), + url = string.format("https://api.ip.sb/geoip?z=%d", random), parser = function(data) if data and data ~= "" then local ok, ipsb_json = pcall(json.parse, data) @@ -2188,6 +2433,12 @@ function action_myip_check() return nil end + local ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + local cmd = string.format( + 'curl -SsL -m 10 -A "%s" "%s" 2>/dev/null', + ua, service.url + ) + local pid = nixio.fork() if pid > 0 then @@ -2198,12 +2449,7 @@ function action_myip_check() fdi = fdi, closed = false, reader = function() - local buffer = fdi:read(4096) - if buffer and #buffer > 0 then - return buffer - else - return nil - end + return try_read(fdi, 4096) end, close = function() if fdi and not fdi.closed then @@ -2217,10 +2463,6 @@ function action_myip_check() fdi:close() fdo:close() - local cmd = string.format( - 'curl -SsL -m 5 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "%s" 2>/dev/null', - service.url - ) nixio.exec("/bin/sh", "-c", cmd) else if fdi then fdi:close() end @@ -2230,13 +2472,21 @@ function action_myip_check() end local queries = {} + local pending_services = {} + local MAX_CONCURRENT = 3 for _, service in ipairs(services) do - local query = create_concurrent_query(service) + table.insert(pending_services, service) + end + + for _ = 1, MAX_CONCURRENT do + if #pending_services == 0 then break end + local s = table.remove(pending_services, 1) + local query = create_concurrent_query(s) if query then - queries[service.name] = { + queries[s.name] = { query = query, - parser = service.parser, + parser = s.parser, data = "" } end @@ -2250,75 +2500,132 @@ function action_myip_check() return end - local max_iterations = 140 + local max_iterations = 150 local iteration = 0 - local delay = 10000000 -- 10ms initial + local delay = 50000000 local completed = {} while iteration < max_iterations do iteration = iteration + 1 + local new_queries = {} for name, info in pairs(queries) do if not completed[name] then - local wpid, stat = nixio.waitpid(info.query.pid, "nohang") - local buffer = info.query.reader() + local ok_w, wpid = pcall(nixio.waitpid, info.query.pid, "nohang") + local ok_r, buffer = pcall(info.query.reader) - if buffer then + if ok_r and buffer then info.data = info.data .. buffer end - if wpid then + if ok_w and wpid then pcall(info.query.close) completed[name] = true local parsed_result = info.parser(info.data) if parsed_result then result[name] = parsed_result + local ok_j, jdata = pcall(json.stringify, { + service = name, + ip = parsed_result.ip, + geo = parsed_result.geo, + raw = parsed_result.raw + }) + if ok_j and jdata then write_padded(jdata) end end - queries[name] = nil + while #pending_services > 0 do + local next_s = table.remove(pending_services, 1) + local next_q = create_concurrent_query(next_s) + if next_q then + new_queries[next_s.name] = { + query = next_q, + parser = next_s.parser, + data = "" + } + break + end + end else - local still_running = SYS.call(string.format("kill -0 %d 2>/dev/null", info.query.pid)) == 0 - if not still_running then + local ok_k, still_running = pcall(nixio.kill, info.query.pid, 0) + if not (ok_k and still_running) then pcall(info.query.close) completed[name] = true local parsed_result = info.parser(info.data) if parsed_result then result[name] = parsed_result + local ok_j, jdata = pcall(json.stringify, { + service = name, + ip = parsed_result.ip, + geo = parsed_result.geo, + raw = parsed_result.raw + }) + if ok_j and jdata then write_padded(jdata) end end - queries[name] = nil + while #pending_services > 0 do + local next_s = table.remove(pending_services, 1) + local next_q = create_concurrent_query(next_s) + if next_q then + new_queries[next_s.name] = { + query = next_q, + parser = next_s.parser, + data = "" + } + break + end + end end end end end - local remaining_count = 0 - for _ in pairs(queries) do - remaining_count = remaining_count + 1 + for name, _ in pairs(queries) do + if completed[name] then + queries[name] = nil + end + end + for name, info in pairs(new_queries) do + queries[name] = info end - if remaining_count == 0 then + if next(queries) == nil then break end nixio.nanosleep(0, delay) - delay = math.min(delay * 2, 100000000) -- grow to 100ms max + delay = math.min(delay * 2, 200000000) end for name, info in pairs(queries) do if not completed[name] then result[name] = { ip = "", geo = "", error = "timeout" } + write_padded(json.stringify({ service = name, error = "timeout" })) pcall(nixio.kill, info.query.pid, nixio.const.SIGTERM) - pcall(nixio.waitpid, info.query.pid, 0) + local reaped = false + for _ = 1, 20 do + local ok_w, wpid = pcall(nixio.waitpid, info.query.pid, "nohang") + if ok_w and wpid then reaped = true break end + local ok_k, alive = pcall(nixio.kill, info.query.pid, 0) + if not (ok_k and alive) then + pcall(nixio.waitpid, info.query.pid, 0) + reaped = true + break + end + nixio.nanosleep(0, 50000000) + end + if not reaped then + pcall(nixio.kill, info.query.pid, nixio.const.SIGKILL) + pcall(nixio.waitpid, info.query.pid, 0) + end pcall(info.query.close) end end - if result.ipify and result.ipify.ip then + if result.ipify and result.ipify.ip and result.ipify.ip ~= "" then local geo_cmd = string.format( - 'curl -sL -m 5 --retry 2 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "https://api-ipv4.ip.sb/geoip/%s" 2>/dev/null', + 'curl -sL -m 10 --retry 2 -A "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" "https://api.ip.sb/geoip/%s" 2>/dev/null', result.ipify.ip ) local geo_data = SYS.exec(geo_cmd) @@ -2333,16 +2640,17 @@ function action_myip_check() if geo_json.isp and geo_json.isp ~= "" then table.insert(geo_parts, geo_json.isp) end - result.ipify.geo = table.concat(geo_parts, " ") + local geo = table.concat(geo_parts, " ") + result.ipify.geo = geo + write_padded(json.stringify({ service = "ipify", geo = geo })) end end end - HTTP.prepare_content("application/json") - HTTP.write_json(result) + write_padded(json.stringify({ complete = true })) end -function latency_test(addr) +function latency_test(addr, on_result) local result = { success = false, response_time = 0, error = "" } if not addr then @@ -2355,79 +2663,201 @@ function latency_test(addr) end local urls = {} - if addr == "raw.githubusercontent.com" then - table.insert(urls, "https://raw.githubusercontent.com/vernesong/OpenClash/dev/img/logo.png") - elseif addr:match("jsdelivr%.net$") then - table.insert(urls, "https://" .. addr .. "/gh/vernesong/OpenClash@dev/img/logo.png") - else - table.insert(urls, "https://" .. addr .. "/favicon.ico") - end - + table.insert(urls, "https://" .. addr .. "/favicon.ico") table.insert(urls, "https://" .. addr) - for i, test_url in ipairs(urls) do - local cmd = string.format( - 'curl -sI -m 5 --connect-timeout 3 --retry 2 -w "%%{http_code},%%{time_total},%%{time_connect},%%{time_appconnect}" "%s" -o /dev/null 2>/dev/null', - test_url - ) - - local output = SYS.exec(cmd) - if output and output ~= "" then - local http_code, time_total, time_connect, time_appconnect = - output:match("(%d+),([%d%.]+),([%d%.]+),([%d%.]+)") - - if not http_code then - http_code, time_total, time_appconnect = output:match("(%d+),([%d%.]+),([%d%.]+)") - time_connect = nil - end - - if http_code and tonumber(http_code) then - local code = tonumber(http_code) - local response_time = 0 - - if time_appconnect and tonumber(time_appconnect) and tonumber(time_appconnect) > 0 then - response_time = math.floor(tonumber(time_appconnect) * 1000) - elseif time_connect and tonumber(time_connect) and tonumber(time_connect) > 0 then - response_time = math.floor(tonumber(time_connect) * 1000) - else - response_time = math.floor((tonumber(time_total) or 0) * 1000) - end - - if (code >= 200 and code < 400) or code == 403 or code == 404 then - result.success = true - result.response_time = response_time - return result - else - if i == #urls then - result.success = false - result.error = "HTTP " .. code - result.response_time = response_time - return result - end - end + local queries = {} + for _, test_url in ipairs(urls) do + local fdi, fdo = nixio.pipe() + if fdi and fdo then + local cmd = string.format( + 'curl -sI -m 10 --connect-timeout 3 -w "%%{http_code},%%{time_total},%%{time_connect},%%{time_appconnect}" "%s" -o /dev/null 2>/dev/null', + test_url + ) + local pid = nixio.fork() + if pid > 0 then + fdo:close() + table.insert(queries, { pid = pid, fdi = fdi, data = "" }) + elseif pid == 0 then + nixio.dup(fdo, nixio.stdout) + fdi:close() + fdo:close() + nixio.exec("/bin/sh", "-c", cmd) else - if i == #urls then - result.success = false - result.error = "Invalid response" - return result - end - end - else - if i == #urls then - result.success = false - result.error = "No response" - return result + if fdi then fdi:close() end + if fdo then fdo:close() end end end end + local function parse_output(data) + if not data or data == "" then + return nil, "No response" + end + local http_code, time_total, time_connect, time_appconnect = + data:match("(%d+),([%d%.]+),([%d%.]+),([%d%.]+)") + if not http_code then + http_code, time_total, time_appconnect = data:match("(%d+),([%d%.]+),([%d%.]+)") + end + if http_code and tonumber(http_code) then + local code = tonumber(http_code) + local rt = 0 + if time_appconnect and tonumber(time_appconnect) > 0 then + rt = math.floor(tonumber(time_appconnect) * 1000) + elseif time_connect and tonumber(time_connect) > 0 then + rt = math.floor(tonumber(time_connect) * 1000) + else + rt = math.floor((tonumber(time_total) or 0) * 1000) + end + if (code >= 200 and code < 400) or code == 403 or code == 404 then + return rt, nil + else + return rt, "HTTP " .. code + end + end + return nil, "Invalid response" + end + + local completed = {} + local delay = 50000000 + local max_iter = 140 + local iter = 0 + local first_success = nil + local last_failure = { response_time = 0, error = "No response" } + + while iter < max_iter do + iter = iter + 1 + for i, q in ipairs(queries) do + if not completed[i] then + local ok_r, buf = pcall(try_read, q.fdi, 4096) + if ok_r and buf then q.data = q.data .. buf end + local ok_w, wpid = pcall(nixio.waitpid, q.pid, "nohang") + if ok_w and wpid then + while true do + local ok_b, b = pcall(try_read, q.fdi, 4096) + if not ok_b or not b then break end + q.data = q.data .. b + end + pcall(q.fdi.close, q.fdi) + completed[i] = true + local rt, err = parse_output(q.data) + if rt and not err then + if not first_success then + first_success = rt + end + else + last_failure.response_time = rt or 0 + last_failure.error = err or "No response" + end + else + local ok_k, alive = pcall(nixio.kill, q.pid, 0) + if not (ok_k and alive) then + while true do + local ok_b, b = pcall(try_read, q.fdi, 4096) + if not ok_b or not b then break end + q.data = q.data .. b + end + pcall(q.fdi.close, q.fdi) + completed[i] = true + local rt, err = parse_output(q.data) + if rt and not err then + if not first_success then + first_success = rt + end + else + last_failure.response_time = rt or 0 + last_failure.error = err or "No response" + end + end + end + end + end + + if first_success then + for i, q in ipairs(queries) do + if not completed[i] then + pcall(nixio.kill, q.pid, nixio.const.SIGTERM) + local reaped = false + for _ = 1, 20 do + local ok_w, wpid = pcall(nixio.waitpid, q.pid, "nohang") + if ok_w and wpid then reaped = true break end + local ok_k, alive = pcall(nixio.kill, q.pid, 0) + if not (ok_k and alive) then + pcall(nixio.waitpid, q.pid, 0) + reaped = true + break + end + nixio.nanosleep(0, 50000000) + end + if not reaped then + pcall(nixio.kill, q.pid, nixio.const.SIGKILL) + pcall(nixio.waitpid, q.pid, 0) + end + pcall(q.fdi.close, q.fdi) + end + end + result.success = true + result.response_time = first_success + result.error = "" + if on_result then on_result(result) end + return result + end + + local remaining = 0 + for i, _ in ipairs(queries) do + if not completed[i] then remaining = remaining + 1 end + end + if remaining == 0 then break end + + nixio.nanosleep(0, delay) + delay = math.min(delay * 2, 200000000) + end + + for i, q in ipairs(queries) do + if not completed[i] then + pcall(nixio.kill, q.pid, nixio.const.SIGTERM) + local reaped = false + for _ = 1, 20 do + local ok_w, wpid = pcall(nixio.waitpid, q.pid, "nohang") + if ok_w and wpid then reaped = true break end + local ok_k, alive = pcall(nixio.kill, q.pid, 0) + if not (ok_k and alive) then + pcall(nixio.waitpid, q.pid, 0) + reaped = true + break + end + nixio.nanosleep(0, 50000000) + end + if not reaped then + pcall(nixio.kill, q.pid, nixio.const.SIGKILL) + pcall(nixio.waitpid, q.pid, 0) + end + pcall(q.fdi.close, q.fdi) + end + end + + result.success = false + result.response_time = last_failure.response_time + result.error = last_failure.error + if on_result then on_result(result) end return result end function action_website_check() + local domains_raw = HTTP.formvalue("domains") local domain = HTTP.formvalue("domain") - if not domain then + local domain_list = {} + if domains_raw and domains_raw ~= "" then + for d in domains_raw:gmatch("[^,]+") do + d = d:gsub("^%s+", ""):gsub("%s+$", "") + if d ~= "" then domain_list[#domain_list + 1] = d end + end + elseif domain and domain ~= "" then + domain_list[#domain_list + 1] = domain + end + + if #domain_list == 0 then HTTP.prepare_content("application/json") HTTP.write_json({ success = false, @@ -2437,10 +2867,697 @@ function action_website_check() return end - local result = latency_test(domain) + HTTP.prepare_content("text/plain; charset=utf-8") - HTTP.prepare_content("application/json") - HTTP.write_json(result) + if #domain_list == 1 then + latency_test(domain_list[1], function(r) + write_padded(json.stringify(r)) + end) + return + end + + local MAX_CONCURRENT_DOMAINS = 2 + local pending_domains = {} + for _, d in ipairs(domain_list) do + table.insert(pending_domains, d) + end + + local function launch_domain(d) + local urls = { + "https://" .. d .. "/favicon.ico", + "https://" .. d + } + local sub_queries = {} + for _, test_url in ipairs(urls) do + local fdi, fdo = nixio.pipe() + if fdi and fdo then + local cmd = string.format( + 'curl -sI -m 10 --connect-timeout 3 -w "%%{http_code},%%{time_total},%%{time_connect},%%{time_appconnect}" "%s" -o /dev/null 2>/dev/null', + test_url + ) + local pid = nixio.fork() + if pid > 0 then + fdo:close() + sub_queries[#sub_queries + 1] = { pid = pid, fdi = fdi, data = "", done = false } + elseif pid == 0 then + nixio.dup(fdo, nixio.stdout) + fdi:close() + fdo:close() + nixio.exec("/bin/sh", "-c", cmd) + else + if fdi then fdi:close() end + if fdo then fdo:close() end + end + end + end + return sub_queries + end + + local function kill_domain_queries(sub_queries) + for _, sq in ipairs(sub_queries) do + if not sq.done then + pcall(nixio.kill, sq.pid, nixio.const.SIGTERM) + local reaped = false + for _ = 1, 20 do + local ok_w, wpid = pcall(nixio.waitpid, sq.pid, "nohang") + if ok_w and wpid then reaped = true break end + local ok_k, alive = pcall(nixio.kill, sq.pid, 0) + if not (ok_k and alive) then + pcall(nixio.waitpid, sq.pid, 0) + reaped = true + break + end + nixio.nanosleep(0, 50000000) + end + if not reaped then + pcall(nixio.kill, sq.pid, nixio.const.SIGKILL) + pcall(nixio.waitpid, sq.pid, 0) + end + pcall(sq.fdi.close, sq.fdi) + sq.done = true + end + end + end + + local function parse_latency(data) + if not data or data == "" then return nil, "No response" end + local http_code, time_total, time_connect, time_appconnect = + data:match("(%d+),([%d%.]+),([%d%.]+),([%d%.]+)") + if not http_code then + http_code, time_total, time_appconnect = data:match("(%d+),([%d%.]+),([%d%.]+)") + end + if http_code and tonumber(http_code) then + local code = tonumber(http_code) + local rt = 0 + if time_appconnect and tonumber(time_appconnect) > 0 then + rt = math.floor(tonumber(time_appconnect) * 1000) + elseif time_connect and tonumber(time_connect) > 0 then + rt = math.floor(tonumber(time_connect) * 1000) + else + rt = math.floor((tonumber(time_total) or 0) * 1000) + end + if (code >= 200 and code < 400) or code == 403 or code == 404 then + return rt, nil + else + return rt, "HTTP " .. code + end + end + return nil, "Invalid response" + end + + local function check_sub_query(sq) + local ok_r, buf = pcall(try_read, sq.fdi, 4096) + if ok_r and buf then sq.data = sq.data .. buf end + local ok_w, wpid = pcall(nixio.waitpid, sq.pid, "nohang") + if ok_w and wpid then + while true do + local ok_b, b = pcall(try_read, sq.fdi, 4096) + if not ok_b or not b then break end + sq.data = sq.data .. b + end + pcall(sq.fdi.close, sq.fdi) + sq.done = true + return parse_latency(sq.data) + else + local ok_k, alive = pcall(nixio.kill, sq.pid, 0) + if not (ok_k and alive) then + while true do + local ok_b, b = pcall(try_read, sq.fdi, 4096) + if not ok_b or not b then break end + sq.data = sq.data .. b + end + pcall(sq.fdi.close, sq.fdi) + sq.done = true + return parse_latency(sq.data) + end + end + return nil, nil -- still running + end + + local active_domains = {} -- { [domain] = { sub_queries, domain_done } } + local domains_completed = {} + local delay = 50000000 + local max_iter = 150 + local iter = 0 + + while #pending_domains > 0 and #active_domains < MAX_CONCURRENT_DOMAINS do + local d = table.remove(pending_domains, 1) + local sqs = launch_domain(d) + if #sqs > 0 then + active_domains[d] = { sub_queries = sqs, domain_done = false } + else + domains_completed[d] = true + write_padded(json.stringify({ domain = d, success = false, response_time = 0, error = "Failed to launch" })) + end + end + + while iter < max_iter do + iter = iter + 1 + + for d, ad in pairs(active_domains) do + if not ad.domain_done then + local first_success_result = nil + local last_failure_result = nil + local all_done = true + + for _, sq in ipairs(ad.sub_queries) do + if not sq.done then + local rt, err = check_sub_query(sq) + if rt and not err then + first_success_result = { success = true, response_time = rt, error = "" } + elseif rt or err then + last_failure_result = { success = false, response_time = rt or 0, error = err or "No response" } + else + all_done = false + end + end + end + + if first_success_result or all_done then + ad.domain_done = true + domains_completed[d] = true + kill_domain_queries(ad.sub_queries) + + local result = first_success_result or last_failure_result or { success = false, response_time = 0, error = "No response" } + result.domain = d + write_padded(json.stringify(result)) + + while #pending_domains > 0 do + local next_d = table.remove(pending_domains, 1) + local sqs = launch_domain(next_d) + if #sqs > 0 then + active_domains[next_d] = { sub_queries = sqs, domain_done = false } + break + else + domains_completed[next_d] = true + write_padded(json.stringify({ domain = next_d, success = false, response_time = 0, error = "Failed to launch" })) + end + end + end + end + end + + local all_done = true + for _, d in ipairs(domain_list) do + if not domains_completed[d] then all_done = false; break end + end + if all_done then break end + + nixio.nanosleep(0, delay) + delay = math.min(delay * 2, 200000000) + end + + for d, ad in pairs(active_domains) do + if not domains_completed[d] then + kill_domain_queries(ad.sub_queries) + domains_completed[d] = true + write_padded(json.stringify({ domain = d, success = false, response_time = 0, error = "timeout" })) + end + end + + for _, d in ipairs(pending_domains) do + write_padded(json.stringify({ domain = d, success = false, response_time = 0, error = "timeout" })) + end +end + +function action_version_history() + local branch = HTTP.formvalue("branch") or "master" + local force = HTTP.formvalue("force") == "1" + local parsed = ov.fetch_version_history(branch, force) + + HTTP.prepare_content("text/plain; charset=utf-8") + if parsed.plugin then + for _, entry in ipairs(parsed.plugin) do + entry.type = "plugin" + write_padded(json.stringify(entry)) + end + end + if parsed.core_meta then + for _, entry in ipairs(parsed.core_meta) do + entry.type = "core_meta" + write_padded(json.stringify(entry)) + end + end + if parsed.core_smart then + for _, entry in ipairs(parsed.core_smart) do + entry.type = "core_smart" + write_padded(json.stringify(entry)) + end + end + local complete_line = {complete = true} + if parsed.error then + complete_line.error = parsed.error + end + write_padded(json.stringify(complete_line)) +end + +-- action_cdn_info: stream per-CDN plugin/core version + latency via forked +-- subprocesses (text/plain JSON-lines, one line per CDN + a complete line). +-- LuCI ucode-bridge caveats that were fixed here: +-- * fork children must not call uci/fs.uci_get_config (inherits the parent +-- uci lock -> deadlock); build all URLs and the shell cmd in the parent. +-- * children must do zero Lua work and exec immediately, otherwise the +-- leftover LuCI/ucode runtime renders a 500 page into the pipe. +-- * MAX_CONCURRENT=4; curl -m 5 and max_iter=250: the poll loop waits at +-- most ~50s (delay ramps 50ms->200ms), covering 15 CDNs / 4 waves x 15s +-- (2 curls + core fallback at 5s each), still within the uhttpd 60s +-- script_timeout; core curl falls back to raw.githubusercontent.com +-- when the CDN fails. +function action_cdn_info() + HTTP.prepare_content("text/plain; charset=utf-8") + local cdns_raw = HTTP.formvalue("addrs") + local branch = HTTP.formvalue("branch") or "dev" + local plugin_ver = HTTP.formvalue("plugin_ver") or "" + local core_ver = HTTP.formvalue("core_ver") or "" + + if not cdns_raw or cdns_raw == "" then + write_padded('{"complete":true,"error":"Missing addrs parameter"}') + return + end + + local cdns = {} + local seen = {} + for c in cdns_raw:gmatch("[^,]+") do + c = c:gsub("^%s+", ""):gsub("%s+$", "") + if c ~= "" and not seen[c] then + seen[c] = true + table.insert(cdns, c) + end + end + + if #cdns == 0 then + write_padded('{"complete":true,"error":"No valid CDNs"}') + return + end + + -- Read cache (skip if forced refresh) + local force = HTTP.formvalue("force") == "1" + local merge = HTTP.formvalue("merge") == "1" + local cur_oix = is_oix() + local function version_ident(v) + if v ~= "" and v ~= "__latest__" then return v end + return nil + end + local p_ident = version_ident(plugin_ver) + local c_ident = version_ident(core_ver) + local ver_key + if p_ident and c_ident then + ver_key = (p_ident == c_ident) and p_ident or (p_ident .. "_" .. c_ident) + elseif p_ident then + ver_key = p_ident + elseif c_ident then + ver_key = c_ident + else + ver_key = "latest" + end + local cache_key = branch .. "_" .. ver_key + local cache_file = "/tmp/openclash_cdn_info.json" + local parsed_cache = nil + if fs.access(cache_file) then + local cached = fs.readfile(cache_file) + if cached then + local ok, parsed = pcall(json.parse, cached) + if ok and parsed and type(parsed) == "table" then + local entry = parsed[cache_key] + if entry and entry.cached_at and entry.oix == cur_oix then + local ttl = entry.cache_ttl or 300 + if (os.time() - entry.cached_at) < ttl then + parsed_cache = entry + end + end + end + end + end + + if not force and not merge and parsed_cache then + if parsed_cache.result then + for cdn, info in pairs(parsed_cache.result) do + info.addr = cdn + write_padded(json.stringify(info)) + end + end + local complete_line = {complete = true} + if parsed_cache.result and parsed_cache.result.error then + complete_line.error = parsed_cache.result.error + end + write_padded(json.stringify(complete_line)) + return + end + + local function classify_cdn(url) + if not url or url == "" then return "raw" end + if url:match("raw%.githubusercontent%.com") then return "raw" end + if url:match("jsdelivr") or url:match("fastly") or url:match("testingcf") then return "jsdelivr" end + if url:match("dl%.dler%.io") then return "dler" end + return "proxy" + end + + local function build_version_url(cdn, file_type) + if file_type == "core" and is_oix() then + local oix_version = "https://github.com/vernesong/mihomo-oix/releases/download/Pre-Alpha/version.txt" + local oix_dler = "https://dl.dler.io/mihomo-oix/version.txt?tag=Pre-Alpha" + local ctype = classify_cdn(cdn) + if ctype == "dler" then + return oix_dler + elseif ctype == "proxy" then + return cdn .. oix_version + elseif ctype == "jsdelivr" then + return oix_dler + end + return oix_version + end + + local file = file_type == "plugin" and branch .. "/version" or branch .. "/core_version" + local ref + if file_type == "plugin" then + ref = (plugin_ver ~= "" and plugin_ver ~= "__latest__") and plugin_ver or "package" + else + ref = (core_ver ~= "" and core_ver ~= "__latest__") and core_ver or "core" + end + local ctype = classify_cdn(cdn) + + if ctype == "raw" then + return "https://raw.githubusercontent.com/vernesong/OpenClash/" .. ref .. "/" .. file + elseif ctype == "jsdelivr" then + return cdn .. "gh/vernesong/OpenClash@" .. ref .. "/" .. file + else + return cdn .. "https://raw.githubusercontent.com/vernesong/OpenClash/" .. ref .. "/" .. file + end + end + + local function parse_cdn_data(data) + if not data or data == "" then return nil end + local ok, parsed = pcall(json.parse, data) + if not ok or not parsed or type(parsed) ~= "table" then return nil end + if parsed.plugin_ver and not ov.is_valid_version(parsed.plugin_ver) then parsed.plugin_ver = "" end + if parsed.core_meta_ver and not ov.is_valid_version(parsed.core_meta_ver) then parsed.core_meta_ver = "" end + if parsed.core_smart_ver and not ov.is_valid_version(parsed.core_smart_ver) then parsed.core_smart_ver = "" end + return parsed + end + + local queries = {} + local result = {} + local pending_cdns = {} + local MAX_CONCURRENT = 4 + local active = 0 + local completed = {} + local delay = 50000000 + local max_iter = 250 + local iter = 0 + + local oix_mode, oix_core_ver, oix_core_error = ov.prepare_oix_cdn_data(force) + + if merge and parsed_cache and parsed_cache.result then + for cdn, info in pairs(parsed_cache.result) do + if seen[cdn] and type(info) == "table" and not result[cdn] then + result[cdn] = info + result[cdn].addr = cdn + write_padded(json.stringify(info)) + end + end + end + + for _, cdn in ipairs(cdns) do + if not result[cdn] then + table.insert(pending_cdns, cdn) + end + end + + local function launch_cdn(cdn) + pcall(io.flush) + local plugin_url = build_version_url(cdn, "plugin") + local core_url = build_version_url(cdn, "core") + local raw_core_url = "" + if not is_oix() then + local raw_ref = (core_ver ~= "" and core_ver ~= "__latest__") and core_ver or "core" + raw_core_url = "https://raw.githubusercontent.com/vernesong/OpenClash/" .. raw_ref .. "/" .. branch .. "/core_version" + end + local cmd = string.format([[ +PLUGIN_VER="" +CORE_META_VER="%s" +CORE_SMART_VER="" +CORE_ERR="%s" +OIX_MODE="%s" +RAW_CORE_URL="%s" +LATENCY="null" + +PLUGIN_RAW=$(curl -sL -m 5 -w '\n%%{http_code} %%{time_starttransfer}' "%s" 2>/dev/null) +P_EXIT=$? + +if [ $P_EXIT -eq 0 ] && [ -n "$PLUGIN_RAW" ]; then + P_CODE=$(echo "$PLUGIN_RAW" | tail -1 | awk '{print $1}') + P_TIME=$(echo "$PLUGIN_RAW" | tail -1 | awk '{printf "%%d", $2 * 1000}') + if [ "$P_CODE" -ge 200 ] 2>/dev/null && [ "$P_CODE" -lt 400 ] 2>/dev/null && [ "$P_TIME" -gt 0 ] 2>/dev/null; then + PLUGIN_VER=$(echo "$PLUGIN_RAW" | sed '$d' | head -1 | tr -d '\n\r') + LATENCY=$P_TIME + else + [ "$P_CODE" = "404" ] && LATENCY=-3 || LATENCY=-2 + fi +elif [ $P_EXIT -ne 0 ]; then + LATENCY=-1 +else + LATENCY=-2 +fi + +CORE_RAW=$(curl -sL -m 5 -w '\n%%{http_code} %%{time_starttransfer}' "%s" 2>/dev/null) +C_EXIT=$? +if [ $C_EXIT -ne 0 ] && [ -n "$RAW_CORE_URL" ]; then + CORE_RAW=$(curl -sL -m 5 -w '\n%%{http_code} %%{time_starttransfer}' "$RAW_CORE_URL" 2>/dev/null) + C_EXIT=$? +fi + +if [ $C_EXIT -eq 0 ] && [ -n "$CORE_RAW" ]; then + C_CODE=$(echo "$CORE_RAW" | tail -1 | awk '{print $1}') + C_TIME=$(echo "$CORE_RAW" | tail -1 | awk '{printf "%%d", $2 * 1000}') + if [ "$C_CODE" -ge 200 ] 2>/dev/null && [ "$C_CODE" -lt 400 ] 2>/dev/null && [ "$C_TIME" -gt 0 ] 2>/dev/null; then + CORE_META_VER=$(echo "$CORE_RAW" | sed '$d' | sed -n '1p' | tr -d '\n\r') + CORE_SMART_VER=$(echo "$CORE_RAW" | sed '$d' | sed -n '2p' | tr -d '\n\r') + if [ "$LATENCY" = "null" ] || [ "$C_TIME" -lt "$LATENCY" ] 2>/dev/null; then + LATENCY=$C_TIME + fi + elif [ "$LATENCY" != "null" ] && [ "$LATENCY" != "-3" ]; then + : + else + [ "$C_CODE" = "404" ] && LATENCY=-3 || LATENCY=-2 + fi +elif [ $C_EXIT -ne 0 ]; then + [ "$LATENCY" = "null" ] && LATENCY=-1 +else + [ "$LATENCY" = "null" ] && LATENCY=-2 +fi + +printf '{"plugin_ver":"%%s","core_meta_ver":"%%s","core_smart_ver":"%%s","latency":%%s,"core_error":"%%s"}\n' \ + "$PLUGIN_VER" "$CORE_META_VER" "$CORE_SMART_VER" "${LATENCY:-null}" "$CORE_ERR" +]], oix_core_ver, oix_core_error, oix_mode and "1" or "0", raw_core_url, plugin_url, core_url) + local fdi, fdo = nixio.pipe() + if fdi and fdo then + local pid = nixio.fork() + if pid > 0 then + fdo:close() + queries[cdn] = { pid = pid, fdi = fdi, data = "" } + active = active + 1 + elseif pid == 0 then + nixio.dup(fdo, nixio.stdout) + fdi:close() + fdo:close() + nixio.exec("/bin/sh", "-c", cmd) + else + if fdi then fdi:close() end + if fdo then fdo:close() end + end + end + end + + while active < MAX_CONCURRENT do + local next_cdn = nil + for i, cdn in ipairs(pending_cdns) do + if not completed[cdn] then + next_cdn = cdn + table.remove(pending_cdns, i) + break + end + end + if not next_cdn then break end + launch_cdn(next_cdn) + end + + if next(queries) == nil then + if next(result) == nil then + write_padded('{"complete":true,"error":"Failed to create queries"}') + return + end + end + + iter = 0 + + while iter < max_iter do + iter = iter + 1 + + for cdn, q in pairs(queries) do + if not completed[cdn] then + local ok_r, buf = pcall(try_read, q.fdi, 4096) + if ok_r and buf then q.data = q.data .. buf end + local ok_w, wpid = pcall(nixio.waitpid, q.pid, "nohang") + if ok_w and wpid then + while true do + local ok_b, b = pcall(try_read, q.fdi, 4096) + if not ok_b or not b then break end + q.data = q.data .. b + end + pcall(q.fdi.close, q.fdi) + completed[cdn] = true + active = active - 1 + local parsed = parse_cdn_data(q.data) + if parsed then result[cdn] = parsed end + if not result[cdn] then + result[cdn] = { plugin_ver = "", core_meta_ver = "", latency = -1 } + end + result[cdn].addr = cdn + local ok_j, jdata = pcall(json.stringify, result[cdn]) + if ok_j and jdata then write_padded(jdata) end + queries[cdn] = nil + else + local ok_k, alive = pcall(nixio.kill, q.pid, 0) + if not (ok_k and alive) then + while true do + local ok_b, b = pcall(try_read, q.fdi, 4096) + if not ok_b or not b then break end + q.data = q.data .. b + end + pcall(q.fdi.close, q.fdi) + completed[cdn] = true + active = active - 1 + local parsed = parse_cdn_data(q.data) + if parsed then result[cdn] = parsed end + if not result[cdn] then + result[cdn] = { plugin_ver = "", core_meta_ver = "", latency = -1 } + end + result[cdn].addr = cdn + local ok_j, jdata = pcall(json.stringify, result[cdn]) + if ok_j and jdata then write_padded(jdata) end + queries[cdn] = nil + end + end + end + end + + while active < MAX_CONCURRENT do + local next_cdn = nil + for i, cdn in ipairs(pending_cdns) do + if not completed[cdn] then + next_cdn = cdn + table.remove(pending_cdns, i) + break + end + end + if not next_cdn then break end + launch_cdn(next_cdn) + end + + local remaining = 0 + for _ in pairs(queries) do remaining = remaining + 1 end + for _ in pairs(pending_cdns) do remaining = remaining + 1 end + if remaining == 0 then break end + + nixio.nanosleep(0, delay) + delay = math.min(delay * 2, 200000000) + end + + for cdn, q in pairs(queries) do + if not completed[cdn] then + pcall(nixio.kill, q.pid, nixio.const.SIGTERM) + local reaped = false + for _ = 1, 20 do + local ok_w, wpid = pcall(nixio.waitpid, q.pid, "nohang") + if ok_w and wpid then reaped = true break end + local ok_k, alive = pcall(nixio.kill, q.pid, 0) + if not (ok_k and alive) then + pcall(nixio.waitpid, q.pid, 0) + reaped = true + break + end + nixio.nanosleep(0, 50000000) + end + if not reaped then + pcall(nixio.kill, q.pid, nixio.const.SIGKILL) + pcall(nixio.waitpid, q.pid, 0) + end + while true do + local ok_b, b = pcall(try_read, q.fdi, 4096) + if not ok_b or not b then break end + q.data = q.data .. b + end + pcall(q.fdi.close, q.fdi) + local parsed = parse_cdn_data(q.data) + if parsed then result[cdn] = parsed end + if not result[cdn] then + result[cdn] = { plugin_ver = "", core_meta_ver = "", latency = -1 } + end + result[cdn].addr = cdn + local ok_j, jdata = pcall(json.stringify, result[cdn]) + if ok_j and jdata then write_padded(jdata) end + end + end + + for _, cdn in ipairs(pending_cdns) do + if not result[cdn] then + result[cdn] = { plugin_ver = "", core_meta_ver = "", latency = -1 } + result[cdn].addr = cdn + local ok_j, jdata = pcall(json.stringify, result[cdn]) + if ok_j and jdata then write_padded(jdata) end + end + end + + -- Determine cache TTL + local cache_ttl = 300 + local has_data = false + local all_stale = true + for _, v in pairs(result) do + if (v.plugin_ver and v.plugin_ver ~= "") or (v.core_meta_ver and v.core_meta_ver ~= "") or (v.latency and v.latency > 0) then + has_data = true + all_stale = false + break + end + if v.latency ~= -3 then + all_stale = false + end + end + if not has_data then + cache_ttl = 5 + end + if all_stale and next(result) ~= nil then + result.error = "version_stale" + write_padded('{"complete":true,"error":"version_stale"}') + else + write_padded('{"complete":true}') + end + + local cdn_cache = {} + if fs.access(cache_file) then + local cached = fs.readfile(cache_file) + if cached then + local ok, parsed = pcall(json.parse, cached) + if ok and parsed and type(parsed) == "table" then + cdn_cache = parsed + end + end + end + local now = os.time() + for k, v in pairs(cdn_cache) do + if type(v) == "table" and v.cached_at then + local ttl = v.cache_ttl or 300 + if now - v.cached_at > ttl then + cdn_cache[k] = nil + end + end + end + cdn_cache[cache_key] = { + result = result, + cache_ttl = cache_ttl, + cached_at = now, + oix = cur_oix + } + fs.writefile(cache_file, json.stringify(cdn_cache)) end function action_proxy_info(internal) @@ -2450,7 +3567,6 @@ function action_proxy_info(internal) auth_pass = "" } - local mixed_port = fs.uci_get_config("config", "mixed_port") if mixed_port and mixed_port ~= "" then result.mixed_port = mixed_port @@ -2614,12 +3730,9 @@ function action_switch_oc_setting() config['sniffer']['skip-domain'] = ['+.apple.com', 'Mijia Cloud', 'dlg.io.mi.com'] end - temp_path = config_path + '.tmp' - File.open(temp_path, 'w') { |f| YAML.dump(config, f) } - File.rename(temp_path, config_path) + YAML.dump(config, config_path) rescue => e - File.unlink(temp_path) if File.exist?(temp_path) exit 1 end " 2>/dev/null @@ -2642,12 +3755,9 @@ function action_switch_oc_setting() config ||= {} config['sniffer'] = { 'enable' => false } - temp_path = config_path + '.tmp' - File.open(temp_path, 'w') { |f| YAML.dump(config, f) } - File.rename(temp_path, config_path) + YAML.dump(config, config_path) rescue => e - File.unlink(temp_path) if File.exist?(temp_path) exit 1 end " 2>/dev/null @@ -2696,12 +3806,9 @@ function action_switch_oc_setting() end end - temp_path = config_path + '.tmp' - File.open(temp_path, 'w') { |f| YAML.dump(config, f) } - File.rename(temp_path, config_path) + YAML.dump(config, config_path) rescue => e - File.unlink(temp_path) if File.exist?(temp_path) exit 1 end " 2>/dev/null @@ -3092,42 +4199,42 @@ function generate_pac_content(proxy_ip, proxy_port, auth_user, auth_pass) local pac_script = string.format([[ // OpenClash PAC File -var _failureCount = 0; -var _lastCheckTime = 0; -var _isProxyDown = false; -var _checkInterval = 300000; // 5分钟 = 300000毫秒 +var failureCount = 0; +var lastCheckTime = 0; +var isProxyDown = false; +var checkInterval = 300000; // 5分钟 = 300000毫秒 // Access Check -function _checkNetworkConnectivity() { +function checkNetworkConnectivity() { var currentTime = Date.now(); - if (currentTime - _lastCheckTime < _checkInterval) { - return !_isProxyDown; + if (currentTime - lastCheckTime < checkInterval) { + return !isProxyDown; } - _lastCheckTime = currentTime; + lastCheckTime = currentTime; try { var test1 = dnsResolve("www.gstatic.com"); var test2 = dnsResolve("captive.apple.com"); if (test1 || test2) { - if (_isProxyDown) { - _isProxyDown = false; - _failureCount = 0; + if (isProxyDown) { + isProxyDown = false; + failureCount = 0; } return true; } else { - _failureCount++; - if (_failureCount >= 3) { - _isProxyDown = true; + failureCount++; + if (failureCount >= 3) { + isProxyDown = true; } return false; } } catch (e) { - _failureCount++; - if (_failureCount >= 3) { - _isProxyDown = true; + failureCount++; + if (failureCount >= 3) { + isProxyDown = true; } return false; } @@ -3153,7 +4260,7 @@ function FindProxyForURL(url, host) { %s } - if (_checkNetworkConnectivity()) { + if (checkNetworkConnectivity()) { return "%s"; } else { return "DIRECT"; @@ -4576,13 +5683,20 @@ function oix_params_get() local params_file = home_dir .. "/.oix_params" local default_params_file = home_dir .. "/.oix_default_params" - if fs.access(params_file) then + local uci_params = fs.uci_get_config("config", "oix_params") + if uci_params and uci_params ~= "" then + result.params = uci_params + elseif fs.access(params_file) then local content = fs.readfile(params_file) if content then result.params = content:gsub("%s+", "") end end - if fs.access(default_params_file) then + + local uci_default = fs.uci_get_config("config", "oix_default_params") + if uci_default and uci_default ~= "" then + result.default_params = uci_default + elseif fs.access(default_params_file) then local content = fs.readfile(default_params_file) if content then result.default_params = content:gsub("%s+", "") @@ -4594,6 +5708,7 @@ function oix_params_get() end local function fetch_oix_sub(token) + write_padded('{"stage":"fetching_sub","text":"' .. luci.i18n.translate("Fetching subscription...") .. '"}') local get_sub = string.format("curl -sL -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -X POST https://oix-api.dler.io/api/v1/managed/clash", token) local sub_info = SYS.exec(get_sub) if sub_info then sub_info = json.parse(sub_info) end @@ -4622,11 +5737,14 @@ local function fetch_oix_sub(token) break end if sub_info[v] then + write_padded('{"stage":"downloading_config","text":"' .. luci.i18n.translate("Downloading config...") .. '"}') SYS.exec(string.format('curl -sL -m 10 --retry 2 --user-agent "clash" "%s" -o "/etc/openclash/config/oixCloud - smart.yaml" >/dev/null 2>&1', sub_info[v])) local core = coremetacv() if core ~= "0" and not string.match(core, "oix") then + write_padded('{"stage":"downloading_core","text":"' .. luci.i18n.translate("Downloading core...") .. '"}') SYS.exec("/usr/share/openclash/openclash_core.sh Oix") else + write_padded('{"stage":"restarting","text":"' .. luci.i18n.translate("Restarting...") .. '"}') SYS.call("/etc/init.d/openclash restart >/dev/null 2>&1 &") end end @@ -4637,24 +5755,27 @@ local function fetch_oix_sub(token) end function oix_login() + HTTP.prepare_content("text/plain; charset=utf-8") local result, info, token local input_token = HTTP.formvalue("token") local email = fs.uci_get_config("config", "oix_email") local passwd = fs.uci_get_config("config", "oix_passwd") if input_token and input_token ~= "" then -- Token direct login mode - uci:set("openclash", "config", "oix_token", input_token) - uci:commit("openclash") + write_padded('{"stage":"saving_token","text":"' .. luci.i18n.translate("Saving token...") .. '"}') token = input_token if fetch_oix_sub(token) then - result = 200 + uci:set("openclash", "config", "oix_token", input_token) + uci:commit("openclash") + write_padded('{"stage":"done","result":200}') else - result = "invalid token" + write_padded('{"stage":"error","result":' .. json.stringify(luci.i18n.translate("invalid token")) .. '}') end else -- Email/password login mode token = fs.uci_get_config("config", "oix_token") if email and passwd then + write_padded('{"stage":"logging_in","text":"' .. luci.i18n.translate("Logging in...") .. '"}') info = SYS.exec(string.format("curl -sL -H 'Content-Type: application/json' -H 'User-Agent: OpenClash for oixCloud' -d '{\"email\":\"%s\", \"passwd\":\"%s\", \"token_expire\":\"365\" }' -X POST https://oix-api.dler.io/api/v1/login", email, passwd)) if info then info = json.parse(info) @@ -4668,6 +5789,7 @@ function oix_login() uci:commit("openclash") result = info.ret fetch_oix_sub(token) + write_padded('{"stage":"done","result":200}') else uci:delete("openclash", "config", "oix_token") uci:commit("openclash") @@ -4676,25 +5798,19 @@ function oix_login() if info and info.msg then result = info.msg else - result = "login failed" + result = luci.i18n.translate("login failed") end - HTTP.prepare_content("application/json") - HTTP.write_json({result = result}) - return + write_padded('{"stage":"error","result":' .. json.stringify(result) .. '}') end else uci:delete("openclash", "config", "oix_token") uci:commit("openclash") fs.unlink("/tmp/oix_checkin") fs.unlink("/tmp/oix_info") - result = "email or passwd is wrong" - HTTP.prepare_content("application/json") - HTTP.write_json({result = result}) - return + result = luci.i18n.translate("email or passwd is wrong") + write_padded('{"stage":"error","result":' .. json.stringify(result) .. '}') end end - HTTP.prepare_content("application/json") - HTTP.write_json({result = result}) end function oix_logout(oldtoken) @@ -4714,6 +5830,7 @@ function oix_logout(oldtoken) uci:delete("openclash", "config", "oix_checkin_multiple") uci:delete("openclash", "config", "oix_params") uci:delete("openclash", "config", "oix_default_params") + uci:delete("openclash", "config", "oix_show_info_page") uci:commit("openclash") fs.unlink("/tmp/oix_checkin") fs.unlink("/tmp/oix_info") @@ -4733,6 +5850,7 @@ function oix_logout(oldtoken) uci:delete("openclash", "config", "oix_checkin_multiple") uci:delete("openclash", "config", "oix_params") uci:delete("openclash", "config", "oix_default_params") + uci:delete("openclash", "config", "oix_show_info_page") end uci:commit("openclash") fs.unlink("/tmp/oix_checkin") diff --git a/luci-app-openclash/luasrc/model/cbi/openclash/client.lua b/luci-app-openclash/luasrc/model/cbi/openclash/client.lua index 439231301..bb55f9393 100644 --- a/luci-app-openclash/luasrc/model/cbi/openclash/client.lua +++ b/luci-app-openclash/luasrc/model/cbi/openclash/client.lua @@ -13,12 +13,12 @@ m.reset = false m.submit = false m:section(SimpleSection).template = "openclash/status" -if fs.uci_get_config("config", "oix_token") and fs.uci_get_config("config", "oix_show_info_page") ~= "0" then +if fs.uci_get_config("config", "oix_token") and fs.uci_get_config("config", "oix_show_info_page") == "1" then m:append(Template("openclash/oixcloud")) end m:append(Template("openclash/myip")) m:append(Template("openclash/developer")) -m:append(Template("openclash/select_git_cdn")) +m:append(Template("openclash/update")) m:append(Template("openclash/config_edit")) m:append(Template("openclash/config_upload")) diff --git a/luci-app-openclash/luasrc/model/cbi/openclash/config-overwrite.lua b/luci-app-openclash/luasrc/model/cbi/openclash/config-overwrite.lua index 8ef9f6e0d..d1438421a 100644 --- a/luci-app-openclash/luasrc/model/cbi/openclash/config-overwrite.lua +++ b/luci-app-openclash/luasrc/model/cbi/openclash/config-overwrite.lua @@ -93,12 +93,15 @@ o:value("https://cp.cloudflare.com/generate_204") o:value("http://captive.apple.com/generate_204") o.default = "0" -o = s:taboption("settings", Value, "github_address_mod", translate("Github Address Modify")) -o.description = translate("Modify The Github Address In The Config And OpenClash With Proxy(CDN) To Prevent File Download Failed. Format Reference:").." ".."https://ghfast.top/" +o = s:taboption("settings", Value, "github_address_mod", font_red..bold_on..translate("GitHub Address Proxy")..bold_off..font_off) +o.description = translate("Proxy The GitHub Address In The Config And OpenClash With Proxy(CDN) To Prevent File Download Failed.").." "..translate("Format Reference:").." ".."https://ghfast.top/" o:value("0", translate("Disable")) o:value("https://fastly.jsdelivr.net/") o:value("https://testingcf.jsdelivr.net/") o:value("https://cdn.jsdelivr.net/") +for _, cdn in ipairs(fs.cdn_list()) do + o:value(cdn) +end o.default = "0" o = s:taboption("settings", ListValue, "log_level", translate("Log Level")) @@ -406,6 +409,14 @@ o = s:taboption("smart", Flag, "smart_prefer_asn", font_red..bold_on..translate( o.description = translate("Select Nodes Force Lookup and Use Target ASN Info First For More Stable Experience") o.default = 0 +o = s:taboption("smart", Value, "smart_tolerance", translate("Smart Group Tolerance").."(ms)") +o.description = translate("Sort When Proxies Delays Within Tolerance are Treated as Equal, Preventing Jitter") +o:value("0", translate("Disable")) +o:value("100") +o:value("150") +o.datatype = "uinteger" +o.default = "0" + o = s:taboption("smart", Flag, "smart_enable_lgbm", font_red..bold_on..translate("Enable LightGBM Model")..bold_off..font_off) o.description = font_red..bold_on..translate("Use LightGBM Model To Predict Weight")..bold_off..font_off o.default = 0 diff --git a/luci-app-openclash/luasrc/model/cbi/openclash/config.lua b/luci-app-openclash/luasrc/model/cbi/openclash/config.lua index fd616a0a1..ffa333997 100644 --- a/luci-app-openclash/luasrc/model/cbi/openclash/config.lua +++ b/luci-app-openclash/luasrc/model/cbi/openclash/config.lua @@ -42,8 +42,9 @@ ful.submit = false sul =ful:section(SimpleSection, "") o = sul:option(FileUpload, "") o.template = "openclash/upload" -um = sul:option(DummyValue, "", nil) -um.template = "openclash/dvalue" +o.cfgvalue = function(self, section) + return self.value +end local dir, fd, clash dir = "/etc/openclash/config/" @@ -72,7 +73,7 @@ HTTP.setfilehandler( end if not fd then - um.value = translate("upload file error.") + o.value = translate("upload file error.") return end end @@ -93,11 +94,11 @@ HTTP.setfilehandler( default_config_set(ymlname .. ".yaml") end end - um.value = translate("File saved to") .. ' "/etc/openclash/config/"' + o.value = translate("File saved to") .. ' "/etc/openclash/config/"' elseif fp == "proxy-provider" then - um.value = translate("File saved to") .. ' "/etc/openclash/proxy_provider/"' + o.value = translate("File saved to") .. ' "/etc/openclash/proxy_provider/"' elseif fp == "rule-provider" then - um.value = translate("File saved to") .. ' "/etc/openclash/rule_provider/"' + o.value = translate("File saved to") .. ' "/etc/openclash/rule_provider/"' elseif fp == "clash_meta" then local archive_path = core_dir .. meta.file if string.lower(string.sub(meta.file, -7, -1)) == ".tar.gz" then @@ -133,20 +134,20 @@ HTTP.setfilehandler( os.execute(string.format("chmod 4755 '/etc/openclash/core/%s' >/dev/null 2>&1", fp)) os.execute(string.format("rm -rf %s >/dev/null 2>&1", core_dir)) - um.value = translate("File saved to") .. ' "/etc/openclash/core/"' + o.value = translate("File saved to") .. ' "/etc/openclash/core/"' elseif fp == "backup-file" then os.execute("tar -C '/etc/openclash/' -xzf %s >/dev/null 2>&1" % (backup_dir .. meta.file)) os.execute("mv /etc/openclash/openclash /etc/config/openclash >/dev/null 2>&1") fs.unlink(backup_dir .. meta.file) - um.value = translate("Backup File Restore Successful!") + o.value = translate("Backup File Restore Successful!") end end end ) if HTTP.formvalue("upload") then - if not um.value then - um.value = translate("No Specify Upload File") + if not o.value then + o.value = translate("No Specify Upload File") end end @@ -305,9 +306,7 @@ btnapply.write = function(self, t) HTTP.close() elseif action == "remove" then local file_name = fs.basename(e[t].name) - fs.config_refs(file_name) - fs.unlink("/etc/openclash/history/"..fs.filename(e[t].name)..".db") fs.unlink("/etc/openclash/"..file_name) local a=fs.unlink("/etc/openclash/config/"..file_name) diff --git a/luci-app-openclash/luasrc/model/cbi/openclash/settings.lua b/luci-app-openclash/luasrc/model/cbi/openclash/settings.lua index 1142c997f..f28544611 100644 --- a/luci-app-openclash/luasrc/model/cbi/openclash/settings.lua +++ b/luci-app-openclash/luasrc/model/cbi/openclash/settings.lua @@ -76,9 +76,9 @@ s:tab("rules_update", translate("Rules Update")) s:tab("geo_update", translate("GEO Update")) s:tab("chnr_update", translate("Chnroute Update")) s:tab("auto_restart", translate("Auto Restart")) -s:tab("version_update", translate("Version Update")) -s:tab("developer", translate("Developer Settings")) s:tab("debug", translate("Core Tests")) +s:tab("developer", translate("Developer Settings")) +s:tab("version_update", translate("Version Update")) s:tab("oixcloud", translate("oixCloud")) o = s:taboption("op_mode", ListValue, "en_mode", font_red..bold_on..translate("Select Mode")..bold_off..font_off) @@ -1394,8 +1394,9 @@ function o.write(self, section, value) end ---- version update -core_update = s:taboption("version_update", DummyValue, "", nil) -core_update.template = "openclash/update" +version_update_panel = s:taboption("version_update", DummyValue, "", nil) +version_update_panel.template = "openclash/update" +version_update_panel.version_tab = true ---- developer o = s:taboption("developer", Value, "firewall_custom") @@ -1464,8 +1465,7 @@ o.write = function() HTTP.redirect(DISP.build_url("admin", "services", "openclash")) end -m:append(Template("openclash/config_editor")) m:append(Template("openclash/toolbar_show")) -m:append(Template("openclash/select_git_cdn")) +m:append(Template("openclash/config_editor")) return m diff --git a/luci-app-openclash/luasrc/openclash.lua b/luci-app-openclash/luasrc/openclash.lua index 919280e27..b8f4a3636 100644 --- a/luci-app-openclash/luasrc/openclash.lua +++ b/luci-app-openclash/luasrc/openclash.lua @@ -335,16 +335,24 @@ function get_resourse_mtime(path) local found = find_case_insensitive_path(path) if found then real_path = found + elseif uci_get_config("config", "small_flash_memory") == "1" then + local fallback_path = path:gsub("^/etc/openclash/", "/tmp/etc/openclash/") + local fallback_found = find_case_insensitive_path(fallback_path) + if fallback_found then + real_path = fallback_found + else + return "File Not Exist" + end else return "File Not Exist" end end local file = fs.readlink(real_path) or real_path - local resourse_etag_version = SYS.exec(string.format("source /usr/share/openclash/openclash_etag.sh && GET_ETAG_TIMESTAMP_BY_PATH '%s'", real_path)) + local resourse_etag_version = SYS.exec(string.format("source /usr/share/openclash/openclash_etag.sh && GET_ETAG_TIMESTAMP_BY_PATH '%s'", file)) if resourse_etag_version and resourse_etag_version ~= "" then return resourse_etag_version end - local resourse_version = os.date("%Y-%m-%d %H:%M:%S", mtime(real_path)) + local resourse_version = os.date("%Y-%m-%d %H:%M:%S", mtime(file)) if resourse_version and resourse_version ~= "" then return resourse_version end @@ -600,32 +608,60 @@ function pkg_type() end end ---- Returns the installed version of luci-app-openclash. --- Supports both opkg and apk package managers. --- @return String containing the version number, or "0" if not found --- NOTE: The module-level cache (_oc_version_cache) provides per-request --- deduplication. In OpenWrt LuCI CGI mode each HTTP request spawns a new Lua --- process, so cross-request caching is impossible. --- Since require() returns the same module instance (package.loaded), the --- first call caches the result and subsequent calls avoid redundant shell --- commands. No time-based expiration is needed — the version is immutable --- for the lifetime of a request. -local _oc_version_cache = nil +--- Reads the CDN proxy address list from /usr/share/openclash/res/cdn.list. +-- Lines starting with "#" are treated as comments and skipped. +-- @return Table of CDN address strings (deduplicated, in file order) +function cdn_list() + local list = {} + local seen = {} + local raw = fs.readfile("/usr/share/openclash/res/cdn.list") + if raw then + for line in raw:gmatch("[^\r\n]+") do + line = line:gsub("^%s+", ""):gsub("%s+$", "") + if line ~= "" and line:sub(1, 1) ~= "#" and not seen[line] then + seen[line] = true + list[#list + 1] = line + end + end + end + return list +end + +--- Read a field of an installed package directly from the package database +-- (/usr/lib/opkg/status or /lib/apk/db/installed), avoiding the opkg/apk +-- binaries and their lock files (/var/lock/opkg.lock, /lib/apk/db/lock). +-- Safe to call repeatedly/concurrently and never blocks on a lock. +-- @param pkg Package name, e.g. "luci-app-openclash" or "libc" +-- @param field Field name; opkg: "Version"/"Architecture", apk: "V"/"A" +-- @return String field value, or "" when not found +function read_pkg_field(pkg, field) + local text + if pkg_type() == "opkg" then + text = fs.readfile("/usr/lib/opkg/status") + else + text = fs.readfile("/lib/apk/db/installed") + end + if not text then return "" end + text = text:gsub("\r\n", "\n") + + local prefix = (pkg_type() == "opkg") and ("Package: " .. pkg .. "\n") or ("P:" .. pkg .. "\n") + local start = text:find(prefix, 1, true) + if not start then return "" end + local block_end = text:find("\n\n", start + #prefix, true) or #text + local block = text:sub(start + #prefix, block_end - 1) + local colon = (pkg_type() == "opkg") and ": " or ":" + local val = block:match("\n" .. field .. colon .. "([^\r\n]+)") + if not val then + val = block:match("^" .. field .. colon .. "([^\r\n]+)") + end + return val or "" +end function oc_version() - if _oc_version_cache ~= nil then - return _oc_version_cache - end - local v - if pkg_type() == "opkg" then - v = SYS.exec("rm -f /var/lock/opkg.lock && opkg status luci-app-openclash 2>/dev/null |grep '^Version:' |awk '{print $2}' |tr -d '\n'") - else - v = SYS.exec("rm -f /lib/apk/db/lock && apk info luci-app-openclash 2>/dev/null |grep '^luci-app-openclash-[0-9]' |sed 's/luci-app-openclash-//' |tr -d '\n'") - end + local v = read_pkg_field("luci-app-openclash", pkg_type() == "opkg" and "Version" or "V") if v == "" then v = "0" end - _oc_version_cache = v return v end diff --git a/luci-app-openclash/luasrc/view/openclash/cfg_check.htm b/luci-app-openclash/luasrc/view/openclash/cfg_check.htm index 1fc0496b7..afd176f95 100644 --- a/luci-app-openclash/luasrc/view/openclash/cfg_check.htm +++ b/luci-app-openclash/luasrc/view/openclash/cfg_check.htm @@ -4,11 +4,11 @@ local val = self:cfgvalue(section) if val == translate("Enable") or val == translate("Enabled") or val == translate("Config Normal") or val == translate("Exist") then %> -
+
<% else %> -
+
<% end write(pcdata(val)) diff --git a/luci-app-openclash/luasrc/view/openclash/common.htm b/luci-app-openclash/luasrc/view/openclash/common.htm deleted file mode 100644 index 97a4b63e7..000000000 --- a/luci-app-openclash/luasrc/view/openclash/common.htm +++ /dev/null @@ -1,50 +0,0 @@ - diff --git a/luci-app-openclash/luasrc/view/openclash/config_edit.htm b/luci-app-openclash/luasrc/view/openclash/config_edit.htm index b92cd98e3..9541975c7 100644 --- a/luci-app-openclash/luasrc/view/openclash/config_edit.htm +++ b/luci-app-openclash/luasrc/view/openclash/config_edit.htm @@ -11,7 +11,7 @@ <%:Loading...%>
-
-