module("luci.controller.openclash", package.seeall) function index() if not nixio.fs.access("/etc/config/openclash") then return end local page page = entry({"admin", "services", "openclash"}, alias("admin", "services", "openclash", "client"), _("OpenClash"), 50) page.dependent = true page.acl_depends = { "luci-app-openclash" } entry({"admin", "services", "openclash", "client"},form("openclash/client"),_("Overviews"), 20).leaf = true entry({"admin", "services", "openclash", "conn_status"},call("action_conn_status")).leaf=true entry({"admin", "services", "openclash", "status"},call("action_status")).leaf=true entry({"admin", "services", "openclash", "startlog"},call("action_start")).leaf=true entry({"admin", "services", "openclash", "refresh_log"},call("action_refresh_log")) entry({"admin", "services", "openclash", "del_log"},call("action_del_log")) 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", "save_corever_branch"},call("action_save_corever_branch")) entry({"admin", "services", "openclash", "update"},call("action_update")) 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")) entry({"admin", "services", "openclash", "flush_smart_cache"}, call("action_flush_smart_cache")) entry({"admin", "services", "openclash", "update_config"}, call("action_update_config")) entry({"admin", "services", "openclash", "download_rule"}, call("action_download_rule")) entry({"admin", "services", "openclash", "restore"}, call("action_restore_config")) entry({"admin", "services", "openclash", "backup"}, call("action_backup")) entry({"admin", "services", "openclash", "backup_ex_core"}, call("action_backup_ex_core")) entry({"admin", "services", "openclash", "backup_only_core"}, call("action_backup_only_core")) entry({"admin", "services", "openclash", "backup_only_config"}, call("action_backup_only_config")) entry({"admin", "services", "openclash", "backup_only_rule"}, call("action_backup_only_rule")) entry({"admin", "services", "openclash", "backup_only_proxy"}, call("action_backup_only_proxy")) entry({"admin", "services", "openclash", "remove_all_core"}, call("action_remove_all_core")) entry({"admin", "services", "openclash", "one_key_update"}, call("action_one_key_update")) entry({"admin", "services", "openclash", "one_key_update_check"}, call("action_one_key_update_check")) entry({"admin", "services", "openclash", "switch_mode"}, call("action_switch_mode")) entry({"admin", "services", "openclash", "op_mode"}, call("action_op_mode")) entry({"admin", "services", "openclash", "sub_info_get"}, call("sub_info_get")) entry({"admin", "services", "openclash", "config_name"}, call("action_config_name")) entry({"admin", "services", "openclash", "switch_config"}, call("action_switch_config")) entry({"admin", "services", "openclash", "toolbar_show"}, call("action_toolbar_show")) entry({"admin", "services", "openclash", "toolbar_show_sys"}, call("action_toolbar_show_sys")) entry({"admin", "services", "openclash", "diag_connection"}, call("action_diag_connection")) entry({"admin", "services", "openclash", "diag_dns"}, call("action_diag_dns")) entry({"admin", "services", "openclash", "gen_debug_logs"}, call("action_gen_debug_logs")) entry({"admin", "services", "openclash", "get_debug_logs"}, call("action_get_debug_logs")) entry({"admin", "services", "openclash", "log_level"}, call("action_log_level")) entry({"admin", "services", "openclash", "switch_log"}, call("action_switch_log")) entry({"admin", "services", "openclash", "rule_mode"}, call("action_rule_mode")) entry({"admin", "services", "openclash", "switch_rule_mode"}, call("action_switch_rule_mode")) entry({"admin", "services", "openclash", "switch_run_mode"}, call("action_switch_run_mode")) entry({"admin", "services", "openclash", "dashboard_type"}, call("action_dashboard_type")) entry({"admin", "services", "openclash", "switch_dashboard"}, call("action_switch_dashboard")) entry({"admin", "services", "openclash", "delete_dashboard"}, call("action_delete_dashboard")) entry({"admin", "services", "openclash", "default_dashboard"}, call("action_default_dashboard")) entry({"admin", "services", "openclash", "get_run_mode"}, call("action_get_run_mode")) entry({"admin", "services", "openclash", "create_file"}, call("create_file")) entry({"admin", "services", "openclash", "rename_file"}, call("rename_file")) entry({"admin", "services", "openclash", "manual_stream_unlock_test"}, call("manual_stream_unlock_test")) entry({"admin", "services", "openclash", "all_proxies_stream_test"}, call("all_proxies_stream_test")) entry({"admin", "services", "openclash", "set_subinfo_url"}, call("set_subinfo_url")) entry({"admin", "services", "openclash", "check_core"}, call("action_check_core")) entry({"admin", "services", "openclash", "core_download"}, call("core_download")) entry({"admin", "services", "openclash", "announcement"}, call("action_announcement")) entry({"admin", "services", "openclash", "settings"},cbi("openclash/settings"),_("Plugin Settings"), 30).leaf = true entry({"admin", "services", "openclash", "config-overwrite"},cbi("openclash/config-overwrite"),_("Overwrite Settings"), 40).leaf = true entry({"admin", "services", "openclash", "config-subscribe"},cbi("openclash/config-subscribe"),_("Config Subscribe"), 60).leaf = true entry({"admin", "services", "openclash", "servers"},cbi("openclash/servers"),nil).leaf = true entry({"admin", "services", "openclash", "other-rules-edit"},cbi("openclash/other-rules-edit"), nil).leaf = true entry({"admin", "services", "openclash", "custom-dns-edit"},cbi("openclash/custom-dns-edit"), nil).leaf = true entry({"admin", "services", "openclash", "other-file-edit"},cbi("openclash/other-file-edit"), nil).leaf = true entry({"admin", "services", "openclash", "proxy-provider-file-manage"},form("openclash/proxy-provider-file-manage"), nil).leaf = true entry({"admin", "services", "openclash", "rule-providers-file-manage"},form("openclash/rule-providers-file-manage"), nil).leaf = true entry({"admin", "services", "openclash", "config-subscribe-edit"},cbi("openclash/config-subscribe-edit"), nil).leaf = true entry({"admin", "services", "openclash", "servers-config"},cbi("openclash/servers-config"), nil).leaf = true entry({"admin", "services", "openclash", "groups-config"},cbi("openclash/groups-config"), nil).leaf = true entry({"admin", "services", "openclash", "proxy-provider-config"},cbi("openclash/proxy-provider-config"), nil).leaf = true entry({"admin", "services", "openclash", "config"},form("openclash/config"),_("Config Manage"), 80).leaf = true 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")) entry({"admin", "services", "openclash", "generate_pac"}, call("action_generate_pac")) entry({"admin", "services", "openclash", "action"}, call("action_oc_action")) entry({"admin", "services", "openclash", "config_file_list"}, call("action_config_file_list")) entry({"admin", "services", "openclash", "config_file_read"}, call("action_config_file_read")) entry({"admin", "services", "openclash", "config_file_save"}, call("action_config_file_save")) entry({"admin", "services", "openclash", "upload_config"}, call("action_upload_config")) entry({"admin", "services", "openclash", "add_subscription"}, call("action_add_subscription")) entry({"admin", "services", "openclash", "subconverter_version"}, call("action_subconverter_version")) entry({"admin", "services", "openclash", "generate_age_key"}, call("action_generate_age_key")) entry({"admin", "services", "openclash", "cal_age_public_key"}, call("action_cal_age_public_key")) entry({"admin", "services", "openclash", "add_age_config"}, call("action_add_age_config")) entry({"admin", "services", "openclash", "upload_overwrite"}, call("action_upload_overwrite")) entry({"admin", "services", "openclash", "overwrite_subscribe_info"}, call("action_overwrite_subscribe_info")) entry({"admin", "services", "openclash", "overwrite_file_list"}, call("action_overwrite_file_list")) entry({"admin", "services", "openclash", "delete_overwrite_file"}, call("delete_overwrite_file")) entry({"admin", "services", "openclash", "get_subscribe_data"}, call("action_get_subscribe_data")) entry({"admin", "services", "openclash", "get_subscribe_info_data"}, call("action_get_subscribe_info_data")) entry({"admin", "services", "openclash", "oix_info"}, call("oix_info")) entry({"admin", "services", "openclash", "oix_checkin"}, call("oix_checkin")) entry({"admin", "services", "openclash", "oix_logout"}, call("oix_logout")) entry({"admin", "services", "openclash", "oix_login"}, call("oix_login")) entry({"admin", "services", "openclash", "oix_login_info_save"}, call("oix_login_info_save")) entry({"admin", "services", "openclash", "oix_params_sync"}, call("oix_params_sync")) entry({"admin", "services", "openclash", "oix_params_get"}, call("oix_params_get")) end local SYS = require "luci.sys" local HTTP = require "luci.http" local DISP = require "luci.dispatcher" local UTIL = require "luci.util" local fs = require "luci.openclash" local json = require "luci.jsonc" local uci = require("luci.model.uci").cursor() local datatype = require "luci.cbi.datatypes" local opkg local device_name = uci:get("system", "@system[0]", "hostname") local device_arh = SYS.exec("uname -m |tr -d '\n'") if pcall(require, "luci.model.ipkg") then opkg = require "luci.model.ipkg" else opkg = nil end local core_path_mode = fs.uci_get_config("config", "small_flash_memory") if core_path_mode ~= "1" then meta_core_path="/etc/openclash/core/clash_meta" else meta_core_path="/tmp/etc/openclash/core/clash_meta" end local function is_running() return SYS.call("pidof clash >/dev/null") == 0 end local CONFIG_PATH_PREFIX = "/etc/openclash/config/" local CONFIG_PATH_PREFIX_LEN = #CONFIG_PATH_PREFIX + 1 local function is_start() return process_status("/etc/init.d/openclash") end local function cn_port() return fs.uci_get_config("config", "cn_port") or "9090" end local function mode() return fs.uci_get_config("config", "en_mode") end local function daip() return fs.lanip() end local function dase() return fs.uci_get_config("config", "dashboard_password") end local function db_foward_domain() return fs.uci_get_config("config", "dashboard_forward_domain") end local function db_foward_port() return fs.uci_get_config("config", "dashboard_forward_port") end local function db_foward_ssl() return fs.uci_get_config("config", "dashboard_forward_ssl") or 0 end 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 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() if not fs.access(meta_core_path) then return "0" else return "1" end end local function parse_subconverter_version_url(raw_url) raw_url = (raw_url or ""):gsub("^%s+", ""):gsub("%s+$", "") if raw_url == "" then return nil, "empty" end if not raw_url:match("^[a-zA-Z][a-zA-Z0-9+%.%-]*://") then raw_url = "https://" .. raw_url end local scheme, authority, path = raw_url:match("^(https?)://([^/?#]+)([^?#]*)") if not scheme or not authority then return nil, "invalid" end if authority:find("@", 1, true) then return nil, "invalid" end if authority:match("%s") then return nil, "invalid" end if raw_url:find("?", 1, true) or raw_url:find("#", 1, true) then return nil, "invalid" end local host, port if authority:sub(1, 1) == "[" then host, port = authority:match("^(%[[0-9a-fA-F:%.]+%]):?(%d*)$") else host, port = authority:match("^([^:]+):?(%d*)$") end if not host or host == "" then return nil, "invalid" end if port and port ~= "" then port = tonumber(port) if not port or port < 1 or port > 65535 then return nil, "invalid" end end path = (path and path ~= "") and path or "/" if path ~= "/version" and not path:match("/version$") then return nil, "invalid" end return scheme .. "://" .. authority .. path end local function sanitize_subconverter_version_text(text) text = tostring(text or ""):gsub("\r", "\n") local lines = {} for line in text:gmatch("[^\n]+") do line = line:gsub("^%s+", ""):gsub("%s+$", "") if line ~= "" then lines[#lines + 1] = line end end text = table.concat(lines, " "):gsub("%s+", " "):gsub("^%s+", ""):gsub("%s+$", "") if text == "" or #text > 220 then return "" end if text:match("<%s*/?%s*[%a!][^>]*>") then return "" end if not text:lower():match("subconverter") and not text:lower():match("backend") and not text:lower():match("version") and not text:match("v?%d+%.%d+") then return "" end 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 return v else v = SYS.exec(string.format("%s -v 2>/dev/null |awk -F ' ' '{print $3}' |head -1 |tr -d '\n'", meta_core_path)) if not v or v == "" then return "0" end end 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 oix_token = fs.uci_get_config("config", "oix_token") or "" 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 = (cache.core_smart and cache.core_smart[1] and cache.core_smart[1].version) or "" else core_meta_lv = (cache.core_meta and cache.core_meta[1] and cache.core_meta[1].version) or "" end end if core_meta_lv and core_meta_lv ~= "" then return core_meta_lv end return "loading..." end local function opcv() local v local info = opkg and opkg.info("luci-app-openclash") if info and info["luci-app-openclash"] and info["luci-app-openclash"]["Version"] and info["luci-app-openclash"]["Installed-Time"] then v = info["luci-app-openclash"]["Version"] else if fs.pkg_type() == "opkg" then v = fs.read_pkg_field("luci-app-openclash", "Version") else v = fs.read_pkg_field("luci-app-openclash", "V"):match("[%d%.]+") or "" end end if v and v ~= "" then return "v" .. v else return "0" end end local function oplv() local oplv = "" 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 return "loading..." end local function opup() return SYS.call("bash /usr/share/openclash/openclash_update.sh >/dev/null 2>&1 &") end local function coreup() uci:set("openclash", "config", "enable", "1") uci:commit("openclash") local type = HTTP.formvalue("core_type") return SYS.call(string.format("/usr/share/openclash/openclash_core.sh '%s' >/dev/null 2>&1 &", type)) end local function save_corever_branch() if HTTP.formvalue("core_ver") then uci:set("openclash", "config", "core_version", HTTP.formvalue("core_ver")) end if HTTP.formvalue("release_branch") then uci:set("openclash", "config", "release_branch", HTTP.formvalue("release_branch")) end if HTTP.formvalue("smart_enable") then uci:set("openclash", "config", "smart_enable", HTTP.formvalue("smart_enable")) end uci:commit("openclash") return "success" end function core_download() 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(string.format("bash /usr/share/openclash/openclash_core.sh '%s' >/dev/null 2>&1 &", core_type)) end end function action_flush_dns_cache() local fake_ip_state = "" local dns_state = "" if is_running() then local daip = daip() local dase = dase() or "" local cn_port = 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({ 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 flush_state = "" if is_running() then local daip = daip() local dase = dase() or "" local cn_port = 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({ flush_status = flush_state; }) end function action_update_config() -- filename is basename local filename = HTTP.formvalue("filename") HTTP.prepare_content("application/json") if not filename then HTTP.write_json({ status = "error", message = "Config file not found" }) return end local update_result = SYS.call(string.format("/usr/share/openclash/openclash.sh '%s' >/dev/null 2>&1", filename)) if update_result == 0 then HTTP.write_json({ status = "success", message = "Config update started successfully", filename = filename }) else HTTP.write_json({ status = "error", message = "Failed to update config" }) end end function action_restore_config() uci:set("openclash", "config", "enable", "0") uci:commit("openclash") SYS.call("mkdir -p /etc/openclash/custom >/dev/null 2>&1") SYS.call("mkdir -p /etc/openclash/overwrite >/dev/null 2>&1") SYS.call("mkdir -p /etc/openclash/rule_provider >/dev/null 2>&1") SYS.call("/etc/init.d/openclash stop >/dev/null 2>&1") SYS.call("cp -f /usr/share/openclash/backup/openclash /etc/config/openclash >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/openclash_custom* /etc/openclash/custom/ >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/openclash_force_sniffing* /etc/openclash/custom/ >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/openclash_sniffing* /etc/openclash/custom/ >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/china_ip_route.ipset /etc/openclash/china_ip_route.ipset >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/china_ip6_route.ipset /etc/openclash/china_ip6_route.ipset >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/overwrite/default /etc/openclash/overwrite/default >/dev/null 2>&1 &") SYS.call("cp -f /usr/share/openclash/backup/oc-cn-domain.mrs /etc/openclash/rule_provider/oc-cn-domain.mrs >/dev/null 2>&1 &") SYS.call("rm -rf /etc/openclash/history/* >/dev/null 2>&1 &") end function action_remove_all_core() SYS.call("rm -rf /etc/openclash/core/* >/dev/null 2>&1") end function action_one_key_update() local cdn_url = HTTP.formvalue("url") 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 &") end end local function config_name() local e,a={} for t,o in ipairs(fs.glob("/etc/openclash/config/*"))do a=fs.stat(o) if a then e[t]={} e[t].name=fs.basename(o) end end return e end local function config_path() local p = fs.uci_get_config("config", "config_path") return p and string.sub(p, CONFIG_PATH_PREFIX_LEN) or "" end function action_switch_config() local config_file = HTTP.formvalue("config_file") local config_name = HTTP.formvalue("config_name") if not config_file and config_name then config_file = "/etc/openclash/config/" .. config_name end if not config_file then HTTP.prepare_content("application/json") HTTP.write_json({ status = "error", message = "No config file specified" }) return end if not fs.access(config_file) then HTTP.prepare_content("application/json") HTTP.write_json({ status = "error", message = "Config file does not exist: " .. config_file }) return end uci:set("openclash", "config", "config_path", config_file) uci:set("openclash", "config", "enable", "1") uci:commit("openclash") SYS.call("/etc/init.d/openclash restart >/dev/null 2>&1 &") HTTP.prepare_content("application/json") HTTP.write_json({ status = "success", message = "Config file switched successfully", config_file = config_file }) end function set_subinfo_url() local filename, url, info filename = HTTP.formvalue("filename") url = HTTP.formvalue("url") if not filename then info = "Oops: The config file name seems to be incorrect" end if url and url ~= "" and not string.find(url, "http") then info = "Oops: The url link format seems to be incorrect" end if not info then uci:foreach("openclash", "subscribe_info", function(s) if s.name == filename then if not url or url == "" then uci:delete("openclash", s[".name"]) uci:commit("openclash") info = "Delete success" return false else local url_list = {} for line in string.gmatch(url, "[^\n]+") do if line ~= "" then table.insert(url_list, line) end end uci:delete("openclash", s[".name"], "url") uci:set_list("openclash", s[".name"], "url", url_list) uci:commit("openclash") info = "Success" return false end end end ) if not info then if not url or url == "" then info = "Delete success" else local url_list = {} for line in string.gmatch(url, "[^\n]+") do if line ~= "" then url_list[#url_list + 1] = line end end uci:section("openclash", "subscribe_info", nil, {name = filename, url = url_list}) uci:commit("openclash") info = "Success" end end end HTTP.prepare_content("application/json") HTTP.write_json({ info = info; }) end function fetch_sub_info(sub_url, sub_ua, sub_headers, raw_info) local info, upload, download, total, day_expire, http_code local used, expire, day_left, percent, surplus if raw_info then info = raw_info else local header_args = "" if sub_headers and sub_headers ~= "" then for hdr in sub_headers:gmatch("[^\n]+") do hdr = hdr:match("^%s*(.-)%s*$") if hdr and hdr ~= "" then header_args = header_args .. string.format(" -H '%s'", hdr:gsub("'", "'\\''")) end end end info = SYS.exec(string.format("curl -sLI -X GET -m 5 --retry 2 -w 'http_code=%%{http_code}' -H 'User-Agent: %s'%s '%s'", sub_ua, header_args, sub_url)) local http_match = string.match(info, "http_code=(%d+)") if not info or not http_match or tonumber(http_match) ~= 200 then info = SYS.exec(string.format("curl -sLI -X GET -m 5 --retry 2 -w 'http_code=%%{http_code}' -H 'User-Agent: Quantumultx'%s '%s'", header_args, sub_url)) end end local http_match = string.match(info, "http_code=(%d+)") if info and http_match then http_code = http_match if tonumber(http_code) == 200 then info = string.lower(info) if string.find(info, "subscription%-userinfo") then local sub_info_line = "" for line in info:gmatch("[^\r\n]+") do if string.find(line, "subscription%-userinfo") then sub_info_line = line break end end info = sub_info_line local upload_match = string.match(info, "upload=(%d+)") local download_match = string.match(info, "download=(%d+)") local total_match = string.match(info, "total=(%d+)") local expire_match = string.match(info, "expire=(%d+)") upload = upload_match and tonumber(upload_match) or nil download = download_match and tonumber(download_match) or nil total = total_match and tonumber(string.format("%.1f", total_match)) or nil used = upload and download and tonumber(string.format("%.1f", upload + download)) or nil day_expire = expire_match and tonumber(expire_match) or nil if day_expire and day_expire == 0 then expire = luci.i18n.translate("Long-term") elseif day_expire then expire = os.date("%Y-%m-%d %H:%M:%S", day_expire) or "null" else expire = "null" end if day_expire and day_expire ~= 0 and os.time() <= day_expire then day_left = math.ceil((day_expire - os.time()) / (3600*24)) if math.ceil(day_left / 365) > 50 then day_left = "∞" end elseif day_expire and day_expire == 0 then day_left = "∞" elseif day_expire == nil then day_left = "null" else day_left = 0 end if used and total and used <= total and total > 0 then percent = string.format("%.1f",((total-used)/total)*100) or "100" surplus = fs.filesize(total - used) elseif used and total and used > total and total > 0 then percent = "0" surplus = "-"..fs.filesize(total - used) elseif used and total and used < total and total == 0.0 then percent = "0" surplus = fs.filesize(total - used) elseif used and total and used == total and total == 0.0 then percent = "0" surplus = "0.0 KB" elseif used and total and used > total and total == 0.0 then percent = "100" surplus = fs.filesize(total - used) elseif used == nil and total and total > 0.0 then percent = 100 surplus = fs.filesize(total) elseif used == nil and total and total == 0.0 then percent = 100 surplus = "∞" else percent = 0 surplus = "null" end local total_formatted, used_formatted if total and total > 0 then total_formatted = fs.filesize(total) elseif total and total == 0.0 then total_formatted = "∞" else total_formatted = "null" end used_formatted = fs.filesize(used) return { http_code = http_code, surplus = surplus, used = used_formatted, total = total_formatted, percent = percent, day_left = day_left, expire = expire } end end end return nil end local function parse_url_with_name(raw_url, default_name) local url, name = string.match(raw_url, "^(.-)#name=(.+)$") if url then return url, name else return raw_url, default_name end end function get_sub_url(filename) local sub_url = nil local info_tb = {} local providers = {} -- Priority 1: subscribe_info uci:foreach("openclash", "subscribe_info", function(s) if s.name == filename and s.url then if type(s.url) == "table" then for _, v in ipairs(s.url) do info_tb[#info_tb + 1] = v end else string.gsub(s.url, '[^\n]+', function(w) info_tb[#info_tb + 1] = w end) end if #info_tb == 1 then local url, name = parse_url_with_name(info_tb[1], filename) if url ~= info_tb[1] then providers[#providers + 1] = {name = name, url = url} else sub_url = url end elseif #info_tb > 1 then for _, raw in ipairs(info_tb) do local url, name = parse_url_with_name(raw, filename) providers[#providers + 1] = {name = name, url = url} end end return false end end ) if sub_url then return {type = "single", url = sub_url} end if #providers > 0 then return {type = "multiple", providers = providers} end -- Priority 2: YAML proxy-providers (use actual config file content first) local config_path = "/etc/openclash/config/" .. fs.basename(filename .. ".yaml") if fs.access(config_path) then local ruby_result = SYS.exec(string.format([[ ruby -ryaml -rYAML -I "/usr/share/openclash" -E UTF-8 -e ' begin config = YAML.load_file("%s") providers = [] if config && config["proxy-providers"] config["proxy-providers"].each do |name, provider| # Only include providers with non-empty URLs if provider && provider["url"] && !provider["url"].to_s.empty? providers << {"name" => name, "url" => provider["url"].to_s} end end end # Manual JSON output (ruby-json is not a dependency) result = "[" providers.each_with_index do |p, i| result << "," if i > 0 # Escape quotes in name and URL name_escaped = p["name"].gsub("\"", "\\\\\"") url_escaped = p["url"].gsub("\"", "\\\\\"") result << "{\"name\":\"#{name_escaped}\",\"url\":\"#{url_escaped}\"}" end result << "]" puts result rescue => e puts "[]" end ' 2>/dev/null || echo '[]' ]], config_path)):gsub("\n", "") if ruby_result and ruby_result ~= "" and ruby_result ~= "[]" then local success, parsed_providers = pcall(function() return json.parse(ruby_result) end) if success and parsed_providers and #parsed_providers > 0 then return {type = "multiple", providers = parsed_providers} end end end -- Priority 3: config_subscribe table (last fallback) uci:foreach("openclash", "config_subscribe", function(s) if s.name == filename and s.address and string.find(s.address, "http") then string.gsub(s.address, '[^\n]+', function(w) info_tb[#info_tb + 1] = w end) if #info_tb == 1 then local url, _ = parse_url_with_name(info_tb[1], filename) sub_url = url elseif #info_tb > 1 then for _, raw in ipairs(info_tb) do local url, name = parse_url_with_name(raw, filename) providers[#providers + 1] = {name = name, url = url} end end end end ) if sub_url then return {type = "single", url = sub_url} end if #providers > 0 then return {type = "multiple", providers = providers} end return nil end function sub_info_get() local sub_ua, sub_headers, filename, sub_info, url_result local providers_data = {} filename = HTTP.formvalue("filename") sub_info = "" sub_ua = "Clash" sub_headers = "" uci:foreach("openclash", "config_subscribe", function(s) if s.name == filename then if s.sub_ua then sub_ua = s.sub_ua end local raw = uci:get_list("openclash", s['.name'], "sub_headers") if raw then sub_headers = table.concat(raw, "\n") end return false end end ) if filename and not is_start() then url_result = get_sub_url(filename) if url_result then if url_result.type == "single" then local info = fetch_sub_info(url_result.url, sub_ua, sub_headers) if info then providers_data[#providers_data + 1] = info end elseif #url_result.providers <= 1 then for i, provider in ipairs(url_result.providers) do local info = fetch_sub_info(provider.url, sub_ua, sub_headers) if info then info.provider_name = provider.name providers_data[#providers_data + 1] = info end end else local header_args = "" if sub_headers and sub_headers ~= "" then for hdr in sub_headers:gmatch("[^\n]+") do hdr = hdr:match("^%s*(.-)%s*$") if hdr and hdr ~= "" then header_args = header_args .. string.format(" -H '%s'", hdr:gsub("'", "'\\''")) end end end 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() return {pid = child, fdi = fdi, buf = ""} elseif child == 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 return nil end end local providers = url_result.providers local jobs = {} for i, provider in ipairs(providers) do local job = fork_provider_curl(provider.url) if job then job.idx = i job.name = provider.name jobs[#jobs + 1] = job end end if #jobs > 0 then local done_count = 0 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 = 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 end end end if done_count >= #jobs then break end nixio.nanosleep(0, delay) delay = math.min(delay * 2, 200000000) end for _, job in ipairs(jobs) do if not job.done then pcall(job.fdi.close, job.fdi) nixio.kill(job.pid, 9) end end for _, job in ipairs(jobs) do if job.done and job.buf ~= "" then local info = fetch_sub_info(providers[job.idx].url, sub_ua, sub_headers, job.buf) if info then info.provider_name = job.name providers_data[#providers_data + 1] = info end end end end -- Fallback: if all forks failed, try serial if #providers_data == 0 then for i, provider in ipairs(providers) do local info = fetch_sub_info(provider.url, sub_ua, sub_headers) if info then info.provider_name = provider.name providers_data[#providers_data + 1] = info end end end end end end if #providers_data == 0 then if not url_result then HTTP.status(500, "Subscription information not found") return end end HTTP.prepare_content("application/json") HTTP.write_json({ providers = providers_data, get_time = os.time(), url_result = url_result }) end function action_rule_mode(internal) local mode if is_running() then local daip_val = daip() local dase_val = dase() or "" local cn_port_val = cn_port() if daip_val and cn_port_val then local info = json.parse(SYS.exec(string.format( 'curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XGET http://"%s":"%s"/configs', dase_val, daip_val, cn_port_val))) if info and info["mode"] then mode = info["mode"] end end end mode = mode or fs.uci_get_config("config", "proxy_mode") or "rule" if internal then return { mode = mode } end HTTP.prepare_content("application/json") HTTP.write_json({ mode = mode }) end function action_switch_rule_mode() local mode, info local daip = daip() local dase = dase() or "" local cn_port = cn_port() mode = HTTP.formvalue("rule_mode") if not mode then HTTP.status(500, "Missing parameters") return end if is_running() then if not daip or not cn_port then HTTP.status(500, "Switch Failed") return end info = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPATCH http://"%s":"%s"/configs -d \'{\"mode\": \"%s\"}\'', dase, daip, cn_port, mode)) if info ~= "" then HTTP.status(500, "Switch Failed") end HTTP.prepare_content("application/json") HTTP.write_json({ info = info; }) end uci:set("openclash", "config", "proxy_mode", mode) uci:set("openclash", "@overwrite[0]", "proxy_mode", mode) uci:commit("openclash") end function action_get_run_mode() if mode() then HTTP.prepare_content("application/json") HTTP.write_json({ mode = mode(); }) else HTTP.status(500, "Get Failed") return end end function action_switch_run_mode() local mode, operation_mode mode = HTTP.formvalue("run_mode") operation_mode = fs.uci_get_config("config", "operation_mode") if operation_mode == "redir-host" then uci:set("openclash", "config", "en_mode", "redir-host"..mode) uci:set("openclash", "@overwrite[0]", "en_mode", "redir-host"..mode) elseif operation_mode == "fake-ip" then uci:set("openclash", "config", "en_mode", "fake-ip"..mode) uci:set("openclash", "@overwrite[0]", "en_mode", "fake-ip"..mode) end uci:commit("openclash") if is_running() then SYS.exec("/etc/init.d/openclash restart >/dev/null 2>&1 &") end end function action_log_level() local level, info 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 info = json.parse(SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XGET http://"%s":"%s"/configs', dase, daip, cn_port))) if info then level = info["log-level"] else level = fs.uci_get_config("config", "log_level") or "info" end else level = fs.uci_get_config("config", "log_level") or "info" end HTTP.prepare_content("application/json") HTTP.write_json({ log_level = level; }) end function action_switch_log() local level, info if is_running() then local daip = daip() local dase = dase() or "" local cn_port = cn_port() level = HTTP.formvalue("log_level") if not daip or not cn_port or not level then HTTP.status(500, "Switch Failed") return end info = SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPATCH http://"%s":"%s"/configs -d \'{\"log-level\": \"%s\"}\'', dase, daip, cn_port, level)) if info ~= "" then HTTP.status(500, "Switch Failed") end else HTTP.status(500, "Switch Failed") end HTTP.prepare_content("application/json") HTTP.write_json({ info = info; }) end local function s(e) local a={' B/S',' KB/S',' MB/S',' GB/S',' TB/S',' PB/S'} local t=0 if (e<=1024) then return e..a[1] else repeat e=e/1024 t=t+1 until(e<=1024) return math.floor(e * 10 + 0.5) / 10 .. a[t] end end function action_toolbar_show_sys() local cpu = "0" local load_avg = "0" local cpu_count = SYS.exec("grep -c ^processor /proc/cpuinfo 2>/dev/null"):gsub("\n", "") or 1 local pid = SYS.exec("pgrep -f '^[^ ]*clash' | head -1 | tr -d '\n' 2>/dev/null") if pid and pid ~= "" then cpu = SYS.exec(string.format([[ top -b -n1 | awk -v pid="%s" ' BEGIN { cpu_col=0; } $0 ~ /%%CPU/ { for(i=1;i<=NF;i++) if($i=="%%CPU") cpu_col=i; next } cpu_col>0 && $1==pid { print $cpu_col } ' ]], pid)) if cpu and cpu ~= "" then cpu = string.match(cpu, "%d+%.?%d*") or "0" else cpu = "0" end load_avg = SYS.exec("awk '{print $2; exit}' /proc/loadavg 2>/dev/null"):gsub("\n", "") or "0" if not string.match(load_avg, "^[0-9]*%.?[0-9]*$") then load_avg = "0" end end HTTP.prepare_content("application/json") HTTP.write_json({ cpu = cpu, load_avg = tostring(math.floor(tonumber(load_avg) / tonumber(cpu_count) * 100)); }) end function action_toolbar_show() local pid = SYS.exec("pgrep -f '^[^ ]*clash' | head -1 | tr -d '\n' 2>/dev/null") local traffic, connections, connection, up, down, up_total, down_total, mem, cpu, load_avg, cpu_count if pid and pid ~= "" then local daip = daip() local dase = dase() or "" local cn_port = cn_port() if not daip or not cn_port then return end -- Parallel curl helper 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() return {pid = child, fdi = fdi, buf = ""} elseif child == 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 return nil end end 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 delay = 50000000 local max_wait = 40 for _ = 1, max_wait do if not t_done then 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 = 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, delay) delay = math.min(delay * 2, 200000000) end if not t_done then pcall(t_job.fdi.close, t_job.fdi) nixio.kill(t_job.pid, 9) end if not c_done then pcall(c_job.fdi.close, c_job.fdi) nixio.kill(c_job.pid, 9) end traffic = t_done and t_job.buf ~= "" and json.parse(t_job.buf) or nil connections = c_done and c_job.buf ~= "" and json.parse(c_job.buf) or nil else if t_job then pcall(t_job.fdi.close, t_job.fdi) end if c_job then pcall(c_job.fdi.close, c_job.fdi) end traffic = json.parse(SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XGET http://"%s":"%s"/traffic', dase, daip, cn_port))) connections = json.parse(SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XGET http://"%s":"%s"/connections', dase, daip, cn_port))) end if traffic and connections and connections.connections then connection = #(connections.connections) up = s(traffic.up) down = s(traffic.down) up_total = fs.filesize(connections.uploadTotal) down_total = fs.filesize(connections.downloadTotal) else up = "0 B/S" down = "0 B/S" up_total = "0 KB" down_total = "0 KB" connection = "0" end mem = tonumber(SYS.exec(string.format("cat /proc/%s/status 2>/dev/null |grep -w VmRSS |awk '{print $2}'", pid))) cpu = SYS.exec(string.format([[ top -b -n1 | awk -v pid="%s" ' BEGIN { cpu_col=0; } $0 ~ /%%CPU/ { for(i=1;i<=NF;i++) if($i=="%%CPU") cpu_col=i; next } cpu_col>0 && $1==pid { print $cpu_col } ' ]], pid)) if mem and cpu then mem = fs.filesize(mem*1024) or "0 KB" cpu = string.match(cpu, "%d+%.?%d*") or "0" else mem = "0 KB" cpu = "0" end load_avg = SYS.exec("awk '{print $2; exit}' /proc/loadavg 2>/dev/null"):gsub("\n", "") or "0" cpu_count = SYS.exec("grep -c ^processor /proc/cpuinfo 2>/dev/null"):gsub("\n", "") or 1 if not string.match(load_avg, "^[0-9]*%.?[0-9]*$") then load_avg = "0" end else return end HTTP.prepare_content("application/json") HTTP.write_json({ connections = connection, up = up, down = down, up_total = up_total, down_total = down_total, mem = mem, cpu = cpu, load_avg = tostring(math.floor(tonumber(load_avg) / tonumber(cpu_count) * 100)); }) end function action_config_name() HTTP.prepare_content("application/json") HTTP.write_json({ config_name = config_name(), config_path = config_path(); }) end function action_save_corever_branch() HTTP.prepare_content("application/json") HTTP.write_json({ save_corever_branch = save_corever_branch(); }) end function action_one_key_update_check() HTTP.prepare_content("application/json") HTTP.write_json({ corever = corever(); }) end function action_dashboard_type() local dashboard_type = fs.uci_get_config("config", "dashboard_type") or "Official" local yacd_type = fs.uci_get_config("config", "yacd_type") or "Official" local default_dashboard = fs.uci_get_config("config", "default_dashboard") or "" if not fs.isdirectory("/usr/share/openclash/ui/" .. default_dashboard) then default_dashboard = "" end HTTP.prepare_content("application/json") HTTP.write_json({ dashboard_type = dashboard_type, yacd_type = yacd_type, yacd = fs.isdirectory("/usr/share/openclash/ui/yacd"), dashboard = fs.isdirectory("/usr/share/openclash/ui/dashboard"), metacubexd = fs.isdirectory("/usr/share/openclash/ui/metacubexd"), zashboard = fs.isdirectory("/usr/share/openclash/ui/zashboard"), default_dashboard = default_dashboard; }) end function action_default_dashboard() local default_dashboard = HTTP.formvalue("name") if not default_dashboard or (default_dashboard ~= "Dashboard" and default_dashboard ~= "Yacd" and default_dashboard ~= "Metacubexd" and default_dashboard ~= "Zashboard") then HTTP.status(500, "Set Failed") return end if not fs.isdirectory("/usr/share/openclash/ui/" .. string.lower(default_dashboard)) then HTTP.status(500, "Set Failed") return end uci:set("openclash", "config", "default_dashboard", string.lower(default_dashboard)) uci:commit("openclash") HTTP.prepare_content("application/json") HTTP.write_json({ default_dashboard = default_dashboard; }) end function action_switch_dashboard() local switch_name = HTTP.formvalue("name") local switch_type = HTTP.formvalue("type") local state = SYS.call(string.format('/usr/share/openclash/openclash_download_dashboard.sh "%s" "%s" >/dev/null 2>&1', switch_name, switch_type)) if switch_name == "Dashboard" and tonumber(state) == 0 then if switch_type == "Official" then uci:set("openclash", "config", "dashboard_type", "Official") uci:commit("openclash") else uci:set("openclash", "config", "dashboard_type", "Meta") uci:commit("openclash") end elseif switch_name == "Yacd" and tonumber(state) == 0 then if switch_type == "Official" then uci:set("openclash", "config", "yacd_type", "Official") uci:commit("openclash") else uci:set("openclash", "config", "yacd_type", "Meta") uci:commit("openclash") end end HTTP.prepare_content("application/json") HTTP.write_json({ download_state = state; }) end function action_delete_dashboard() local delete_name = HTTP.formvalue("name") local delete_path = string.format("/usr/share/openclash/ui/%s", string.lower(delete_name)) local panels = { "/usr/share/openclash/ui/dashboard", "/usr/share/openclash/ui/yacd", "/usr/share/openclash/ui/metacubexd", "/usr/share/openclash/ui/zashboard" } local existing_panels = 0 for _, path in ipairs(panels) do if fs.isdirectory(path) then existing_panels = existing_panels + 1 end end if existing_panels <= 1 then HTTP.prepare_content("application/json") HTTP.write_json({ delete_state = 0, error = "Cannot delete the last remaining dashboard" }) return end local state = SYS.call(string.format("rm -rf '%s' >/dev/null 2>&1", delete_path)) == 0 and 1 or 0 if tonumber(state) == 1 then if delete_name == "Dashboard" then uci:set("openclash", "config", "dashboard_type", "Official") uci:commit("openclash") elseif delete_name == "Yacd" then uci:set("openclash", "config", "yacd_type", "Official") uci:commit("openclash") end if fs.uci_get_config("config", "default_dashboard") == string.lower(delete_name) then uci:set("openclash", "config", "default_dashboard", "") uci:commit("openclash") end end HTTP.prepare_content("application/json") HTTP.write_json({ delete_state = state; }) end function action_op_mode() local op_mode = fs.uci_get_config("config", "operation_mode") HTTP.prepare_content("application/json") HTTP.write_json({ op_mode = op_mode; }) end function action_switch_mode() local switch_mode = fs.uci_get_config("config", "operation_mode") if switch_mode == "redir-host" then uci:set("openclash", "config", "operation_mode", "fake-ip") uci:commit("openclash") else uci:set("openclash", "config", "operation_mode", "redir-host") uci:commit("openclash") end HTTP.prepare_content("application/json") HTTP.write_json({ switch_mode = switch_mode; }) end function action_conn_status(internal) local data = { clash = uci:get("openclash", "config", "enable") == "1", daip = daip(), dase = dase(), db_foward_port = db_foward_port(), db_foward_domain = db_foward_domain(), cn_port = cn_port() } if internal then return data end HTTP.prepare_content("application/json") HTTP.write_json(data) end function action_status() local status_data = action_conn_status(true) local rule_data = action_rule_mode(true) local oc_data = action_oc_settings(true) local proxy_data = action_proxy_info(true) local result = { -- status fields clash = status_data.clash, daip = status_data.daip, dase = status_data.dase, db_foward_port = status_data.db_foward_port, db_foward_domain = status_data.db_foward_domain, db_forward_ssl = db_foward_ssl(), cn_port = status_data.cn_port, yacd = fs.isdirectory("/usr/share/openclash/ui/yacd"), dashboard = fs.isdirectory("/usr/share/openclash/ui/dashboard"), metacubexd = fs.isdirectory("/usr/share/openclash/ui/metacubexd"), zashboard = fs.isdirectory("/usr/share/openclash/ui/zashboard"), core_type = fs.uci_get_config("config", "core_type") or "Meta", -- run_mode run_mode = mode(), -- rule_mode rule_mode = rule_data.mode, -- oc_settings meta_sniffer = oc_data.meta_sniffer, respect_rules = oc_data.respect_rules, oversea = oc_data.oversea, stream_unlock = oc_data.stream_unlock, -- proxy_info mixed_port = proxy_data.mixed_port, auth_user = proxy_data.auth_user, auth_pass = proxy_data.auth_pass, } HTTP.prepare_content("application/json") HTTP.write_json(result) 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 local function write_padded(data) if write_padded_first then if L and L.http then L.http:header("X-Accel-Buffering", "no") else HTTP.header("X-Accel-Buffering", "no") 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 write_padded_first = false else if L and L.http then L.http:write(data .. "\n") else HTTP.write(data .. "\n") end end io.flush() 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 end return nil end function trans_line(data) if data == nil or data == "" then return "" end local line_trans = "" local has_timestamp = string.len(data) >= 19 and string.match(string.sub(data, 1, 19), "%d%d%d%d%-%d%d%-%d%d %d%d:%d%d:%d%d") local time_part = "" local level_part = "" local content_start = has_timestamp and 21 or 1 if has_timestamp then time_part = string.sub(data, 1, 20) local level_start, level_end, level_content = string.find(data, "%[([^%]]+)%]", 21) if level_start and level_end and level_start == 21 then level_part = "[" .. luci.i18n.translate(level_content) .. "] " content_start = level_end + 2 end end local segments = {} local last_pos = content_start local pos = string.find(data, "【", content_start) while pos do if pos > last_pos then segments[#segments + 1] = { type = "trans", text = string.sub(data, last_pos, pos - 1) } end local close_pos = string.find(data, "】", pos + 1) if not close_pos then segments[#segments + 1] = { type = "trans", text = string.sub(data, pos, -1) } break end segments[#segments + 1] = { type = "no_trans", text = string.sub(data, pos, close_pos + 2) } last_pos = close_pos + 3 pos = string.find(data, "【", last_pos) end if last_pos <= string.len(data) then segments[#segments + 1] = { type = "trans", text = string.sub(data, last_pos, -1) } end line_trans = time_part .. level_part for _, seg in ipairs(segments) do if seg.type == "trans" then line_trans = line_trans .. luci.i18n.translate(seg.text) else line_trans = line_trans .. seg.text end end return line_trans end function process_status(name) local cmd = string.format("%s |grep '%s' |grep -v grep", fs.ps_cmd(), name) local result = SYS.exec(cmd) 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") if HTTP_CODE ~= "200" then fs.unlink("/tmp/openclash_announcement") end end local info = SYS.exec("cat /tmp/openclash_announcement 2>/dev/null") or "" HTTP.prepare_content("application/json") HTTP.write_json({ content = info; }) end function action_myip_check() local result = {} local random = math.random(100000000) HTTP.prepare_content("text/plain; charset=utf-8") local services = { { 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 -- 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 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 = parsed.ip, geo = HTTP.urlencode(geo), raw = true } end end return nil end }, { name = "ipip", url = string.format("http://myip.ipip.net?z=%d", random), parser = function(data) if data and data ~= "" then local ip = string.match(data, "当前 IP:([%x:%.]+)") local geo = string.match(data, "来自于:(.+)") if ip and geo then geo = string.gsub(geo, "%s+", " ") geo = string.gsub(geo, "^%s*(.-)%s*$", "%1") return { ip = ip, geo = geo } end end return nil end }, { name = "ipsb", 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) if ok and ipsb_json and ipsb_json.ip then local geo_parts = {} if ipsb_json.country and ipsb_json.country ~= "" then table.insert(geo_parts, ipsb_json.country) end if ipsb_json.isp and ipsb_json.isp ~= "" then table.insert(geo_parts, ipsb_json.isp) end return { ip = ipsb_json.ip, geo = table.concat(geo_parts, " ") } end end return nil end }, { name = "ipify", url = string.format("https://api.ipify.org/?format=json&z=%d", random), parser = function(data) if data and data ~= "" then local ok, ipify_json = pcall(json.parse, data) if ok and ipify_json and ipify_json.ip then return { ip = ipify_json.ip, geo = "" } end end return nil end } } local function create_concurrent_query(service) local fdi, fdo = nixio.pipe() if not fdi or not fdo then 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 fdo:close() return { pid = pid, service_name = service.name, fdi = fdi, closed = false, reader = function() return try_read(fdi, 4096) end, close = function() if fdi and not fdi.closed then pcall(fdi.close, fdi) fdi.closed = true end end } 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 return nil end end local queries = {} local pending_services = {} local MAX_CONCURRENT = 3 for _, service in ipairs(services) do 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[s.name] = { query = query, parser = s.parser, data = "" } end end if next(queries) == nil then HTTP.prepare_content("application/json") HTTP.write_json({ error = "Failed to create any queries" }) return end local max_iterations = 150 local iteration = 0 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 ok_w, wpid = pcall(nixio.waitpid, info.query.pid, "nohang") local ok_r, buffer = pcall(info.query.reader) if ok_r and buffer then info.data = info.data .. buffer end 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 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 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 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 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 next(queries) == nil then break end nixio.nanosleep(0, delay) 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) 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 and result.ipify.ip ~= "" then local geo_cmd = string.format( '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) if geo_data and geo_data ~= "" then local ok_geo, geo_json = pcall(json.parse, geo_data) if ok_geo and geo_json and geo_json.ip then local geo_parts = {} if geo_json.country and geo_json.country ~= "" then table.insert(geo_parts, geo_json.country) end if geo_json.isp and geo_json.isp ~= "" then table.insert(geo_parts, geo_json.isp) end local geo = table.concat(geo_parts, " ") result.ipify.geo = geo write_padded(json.stringify({ service = "ipify", geo = geo })) end end end write_padded(json.stringify({ complete = true })) end function latency_test(addr, on_result) local result = { success = false, response_time = 0, error = "" } if not addr then result.error = "Missing domain parameter" return result end if addr:match("^https?://") then addr = addr:gsub("^https?://([^/]+)/?.*$", "%1") end local urls = {} table.insert(urls, "https://" .. addr .. "/favicon.ico") table.insert(urls, "https://" .. addr) 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 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") 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, response_time = 0, error = "Missing domain parameter" }) return end HTTP.prepare_content("text/plain; charset=utf-8") 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) local result = { mixed_port = "", auth_user = "", auth_pass = "" } local mixed_port = fs.uci_get_config("config", "mixed_port") if mixed_port and mixed_port ~= "" then result.mixed_port = mixed_port else result.mixed_port = "7893" end uci:foreach("openclash", "authentication", function(section) if section.enabled == "1" and result.auth_user == "" then if section.username and section.username ~= "" then result.auth_user = section.username end if section.password and section.password ~= "" then result.auth_pass = section.password end return false end end) if internal then return result end HTTP.prepare_content("application/json") HTTP.write_json(result) end function action_oc_settings(internal) local result = { meta_sniffer = "0", respect_rules = "0", oversea = "0", stream_unlock = "0" } local meta_sniffer = fs.uci_get_config("config", "enable_meta_sniffer") if meta_sniffer == "1" then result.meta_sniffer = "1" end local respect_rules = fs.uci_get_config("config", "enable_respect_rules") if respect_rules == "1" then result.respect_rules = "1" end local oversea = fs.uci_get_config("config", "china_ip_route") if oversea == "1" then result.oversea = "1" elseif oversea == "2" then result.oversea = "2" else result.oversea = "0" end local stream_unlock = fs.uci_get_config("config", "stream_auto_select") if stream_unlock == "1" then result.stream_unlock = "1" end if internal then return result end HTTP.prepare_content("application/json") HTTP.write_json(result) end function action_switch_oc_setting() local setting = HTTP.formvalue("setting") local value = HTTP.formvalue("value") if not setting or not value then HTTP.status(500, "Missing parameters") return end local function get_runtime_config_path() local config_path = fs.uci_get_config("config", "config_path") if not config_path then return nil end local config_filename = fs.basename(config_path) return "/etc/openclash/" .. config_filename end local function update_runtime_config(ruby_cmd) local runtime_config_path = get_runtime_config_path() if not runtime_config_path then HTTP.status(500, "No config path found") return false end local ruby_result = SYS.call(ruby_cmd) if ruby_result ~= 0 then HTTP.status(500, "Failed to modify config file") return false end local daip = daip() local dase = dase() or "" local cn_port = cn_port() if not daip or not cn_port then HTTP.status(500, "Switch Failed") return false end local reload_result = SYS.exec(string.format('curl -sL -m 5 --connect-timeout 2 --retry 2 -H "Content-Type: application/json" -H "Authorization: Bearer %s" -XPUT http://"%s":"%s"/configs?force=true -d \'{"path":"%s"}\' 2>&1', dase, daip, cn_port, runtime_config_path)) if reload_result ~= "" then HTTP.status(500, "Switch Failed") return false end return true end if setting == "meta_sniffer" then if is_running() then local runtime_config_path = get_runtime_config_path() local ruby_cmd if value == "1" then ruby_cmd = string.format([[ ruby -ryaml -rYAML -I "/usr/share/openclash" -E UTF-8 -e " begin config_path = '%s' config = File.exist?(config_path) ? YAML.load_file(config_path) : {} config ||= {} if config['sniffer']&.dig('enable') == true && config['sniffer']&.dig('parse-pure-ip') == true && config['sniffer']&.dig('sniff') exit 0 end config['sniffer'] = { 'enable' => true, 'parse-pure-ip' => true, 'override-destination' => false } custom_sniffer_path = '/etc/openclash/custom/openclash_custom_sniffer.yaml' if File.exist?(custom_sniffer_path) begin custom_sniffer = YAML.load_file(custom_sniffer_path) if custom_sniffer&.dig('sniffer') config['sniffer'].merge!(custom_sniffer['sniffer']) end rescue end end unless config['sniffer']['sniff'] config['sniffer']['sniff'] = { 'QUIC' => { 'ports' => [443] }, 'TLS' => { 'ports' => [443, '8443'] }, 'HTTP' => { 'ports' => [80, '8080-8880'], 'override-destination' => true } } end unless config['sniffer']['force-domain'] config['sniffer']['force-domain'] = ['+.netflix.com', '+.nflxvideo.net', '+.amazonaws.com'] end unless config['sniffer']['skip-domain'] config['sniffer']['skip-domain'] = ['+.apple.com', 'Mijia Cloud', 'dlg.io.mi.com'] end YAML.dump(config, config_path) rescue => e exit 1 end " 2>/dev/null ]], runtime_config_path) else ruby_cmd = string.format([[ ruby -ryaml -rYAML -I "/usr/share/openclash" -E UTF-8 -e " begin config_path = '%s' if File.exist?(config_path) config = YAML.load_file(config_path) if config&.dig('sniffer', 'enable') == false exit 0 end else config = {} end config ||= {} config['sniffer'] = { 'enable' => false } YAML.dump(config, config_path) rescue => e exit 1 end " 2>/dev/null ]], runtime_config_path) end if not update_runtime_config(ruby_cmd) then return end end uci:set("openclash", "config", "enable_meta_sniffer", tonumber(value)) uci:set("openclash", "config", "enable_meta_sniffer_pure_ip", tonumber(value)) uci:set("openclash", "@overwrite[0]", "enable_meta_sniffer", tonumber(value)) uci:set("openclash", "@overwrite[0]", "enable_meta_sniffer_pure_ip", tonumber(value)) uci:commit("openclash") elseif setting == "respect_rules" then if is_running() then local runtime_config_path = get_runtime_config_path() local target_value = (value == "1") and "true" or "false" local ruby_cmd = string.format([[ ruby -ryaml -rYAML -I "/usr/share/openclash" -E UTF-8 -e " begin config_path = '%s' target_value = %s if File.exist?(config_path) config = YAML.load_file(config_path) if config&.dig('dns', 'respect-rules') == target_value if target_value == true && (!config&.dig('dns', 'proxy-server-nameserver') || config['dns']['proxy-server-nameserver'].empty?) else exit 0 end end else config = {} end config ||= {} config['dns'] ||= {} config['dns']['respect-rules'] = target_value if target_value == true if !config['dns']['proxy-server-nameserver'] || config['dns']['proxy-server-nameserver'].empty? config['dns']['proxy-server-nameserver'] = ['114.114.114.114', '119.29.29.29', '8.8.8.8', '1.1.1.1'] end end YAML.dump(config, config_path) rescue => e exit 1 end " 2>/dev/null ]], runtime_config_path, target_value) if not update_runtime_config(ruby_cmd) then return end end uci:set("openclash", "config", "enable_respect_rules", tonumber(value)) uci:set("openclash", "@overwrite[0]", "enable_respect_rules", tonumber(value)) uci:commit("openclash") elseif setting == "oversea" then oversea_v6_setting = fs.uci_get_config("config", "ipv6_enable") or "0" if oversea_v6_setting ~= "0" then uci:set("openclash", "config", "china_ip6_route", value) end uci:set("openclash", "config", "china_ip_route", value) uci:commit("openclash") if is_running() then if oversea_v6_setting ~= "0" then uci:set("openclash", "@overwrite[0]", "china_ip6_route", value) end uci:set("openclash", "@overwrite[0]", "china_ip_route", value) uci:commit("openclash") SYS.exec("/etc/init.d/openclash restart >/dev/null 2>&1 &") end elseif setting == "stream_unlock" then uci:set("openclash", "config", "stream_auto_select", value) if not fs.uci_get_config("config", "stream_auto_select_interval") then uci:set("openclash", "config", "stream_auto_select_interval", "10") end if not fs.uci_get_config("config", "stream_auto_select_logic") then uci:set("openclash", "config", "stream_auto_select_logic", "Urltest") end if not fs.uci_get_config("config", "stream_auto_select_expand_group") then uci:set("openclash", "config", "stream_auto_select_expand_group", "0") end uci:set("openclash", "config", "stream_auto_select_netflix", "1") if not fs.uci_get_config("config", "stream_auto_select_group_key_netflix") then uci:set("openclash", "config", "stream_auto_select_group_key_netflix", "Netflix|奈飞") end uci:set("openclash", "config", "stream_auto_select_disney", "1") if not fs.uci_get_config("config", "stream_auto_select_group_key_disney") then uci:set("openclash", "config", "stream_auto_select_group_key_disney", "Disney|迪士尼") end uci:set("openclash", "config", "stream_auto_select_hbo_max", "1") if not fs.uci_get_config("config", "stream_auto_select_group_key_hbo_max") then uci:set("openclash", "config", "stream_auto_select_group_key_hbo_max", "HBO|HBO Max") end uci:commit("openclash") else HTTP.status(500, "Invalid setting") return end HTTP.prepare_content("application/json") HTTP.write_json({ status = "success", setting = setting, value = value }) end function action_generate_pac() local result = { pac_url = "", error = "" } local auth_user = "" local auth_pass = "" uci:foreach("openclash", "authentication", function(section) if section.enabled == "1" and section.username and section.username ~= "" and section.password and section.password ~= "" then auth_user = section.username auth_pass = section.password return false end end) local proxy_ip = daip() local mixed_port = fs.uci_get_config("config", "mixed_port") or "7893" if not proxy_ip then result.error = luci.i18n.translate("Unable to get proxy IP") HTTP.prepare_content("application/json") HTTP.write_json(result) return end local function generate_random_string() local random_cmd = "tr -cd 'a-zA-Z0-9' /dev/null| head -c16 || date +%N| md5sum |head -c16" local random_string = SYS.exec(random_cmd):gsub("\n", "") return random_string end local function count_pac_lines(content) if not content or content == "" then return 0 end local lines = 0 for _ in content:gmatch("[^\n]*\n?") do lines = lines + 1 end if not content:match("\n$") then lines = lines - 1 end return lines end local new_proxy_string = string.format("PROXY %s:%s; DIRECT", proxy_ip, mixed_port) local new_pac_content = generate_pac_content(proxy_ip, mixed_port, auth_user, auth_pass) local new_pac_lines = count_pac_lines(new_pac_content) local pac_dir = "/www/luci-static/resources/openclash/pac/" local pac_filename = nil local pac_file_path = nil local random_suffix = nil local need_update = true SYS.call("mkdir -p " .. pac_dir) local find_cmd = "find " .. pac_dir .. " -name 'pac_*' -type f 2>/dev/null" local existing_files = SYS.exec(find_cmd) if existing_files and existing_files ~= "" then for file_path in existing_files:gmatch("[^\n]+") do if fs.access(file_path) then local file_content = fs.readfile(file_path) if file_content then local existing_proxy = string.match(file_content, 'return%s+"(PROXY%s+[^"]*)"') if not existing_proxy then existing_proxy = string.match(file_content, 'return%s*"(PROXY%s+[^"]*)"') end if existing_proxy and existing_proxy == new_proxy_string then local existing_lines = count_pac_lines(file_content) if existing_lines == new_pac_lines then pac_filename = file_path:match("([^/]+)$") pac_file_path = file_path random_suffix = pac_filename:match("^pac_(.+)$") need_update = false break else local file = io.open(file_path, "w") if file then file:write(new_pac_content) file:close() SYS.call("chmod 644 " .. file_path) pac_filename = file_path:match("([^/]+)$") pac_file_path = file_path random_suffix = pac_filename:match("^pac_(.+)$") need_update = false break end end elseif existing_proxy and string.find(existing_proxy, "^PROXY%s+[%d%.]+:[%d]+") then local updated_content = string.gsub(file_content, 'return%s*"PROXY%s+[^"]*"', 'return "' .. new_proxy_string .. '"') if updated_content ~= file_content then local updated_lines = count_pac_lines(updated_content) local final_content if updated_lines == new_pac_lines then final_content = updated_content else final_content = new_pac_content end local file = io.open(file_path, "w") if file then file:write(final_content) file:close() SYS.call("chmod 644 " .. file_path) pac_filename = file_path:match("([^/]+)$") pac_file_path = file_path random_suffix = pac_filename:match("^pac_(.+)$") need_update = false break end end end end end end end if need_update then SYS.call("rm -f " .. pac_dir .. "pac_* 2>/dev/null") random_suffix = generate_random_string() pac_filename = "pac_" .. random_suffix pac_file_path = pac_dir .. pac_filename local file = io.open(pac_file_path, "w") if file then file:write(new_pac_content) file:close() SYS.call("chmod 644 " .. pac_file_path) else result.error = luci.i18n.translate("Failed to write PAC file") HTTP.prepare_content("application/json") HTTP.write_json(result) return end else SYS.call(string.format("find %s -name 'pac_*' -type f ! -name '%s' -delete 2>/dev/null", pac_dir, pac_filename)) end local pac_url = generate_pac_url_with_client_info(pac_filename, random_suffix) result.pac_url = pac_url if not auth_exists then result.error = luci.i18n.translate("No authentication configured, please be aware of the risk of information leakage!") end HTTP.prepare_content("application/json") HTTP.write_json(result) end function generate_pac_url_with_client_info(pac_filename, random_suffix) local client_protocol = HTTP.formvalue("client_protocol") local client_hostname = HTTP.formvalue("client_hostname") local client_host = HTTP.formvalue("client_host") local client_port = HTTP.formvalue("client_port") local request_scheme = "http" local host = "localhost" if client_protocol and (client_protocol == "http" or client_protocol == "https") then request_scheme = client_protocol else if HTTP.getenv("HTTPS") == "on" or HTTP.getenv("HTTP_X_FORWARDED_PROTO") == "https" or HTTP.getenv("REQUEST_SCHEME") == "https" then request_scheme = "https" end end if client_host and client_host ~= "" then host = client_host elseif client_hostname and client_hostname ~= "" then host = client_hostname if client_port and client_port ~= "" then if (request_scheme == "http" and client_port ~= "80") or (request_scheme == "https" and client_port ~= "443") then host = host .. ":" .. client_port end end else local server_name = HTTP.getenv("SERVER_NAME") local http_host = HTTP.getenv("HTTP_HOST") local server_port = HTTP.getenv("SERVER_PORT") local proxy_ip = daip() if http_host and http_host ~= "" then host = http_host elseif server_name and server_name ~= "" then host = server_name if server_port and server_port ~= "" then if (request_scheme == "http" and server_port ~= "80") or (request_scheme == "https" and server_port ~= "443") then host = host .. ":" .. server_port end end elseif proxy_ip and proxy_ip ~= "" then host = proxy_ip if server_port and server_port ~= "" then if (request_scheme == "http" and server_port ~= "80") or (request_scheme == "https" and server_port ~= "443") then host = host .. ":" .. server_port end end end end local random_param = "" if random_suffix and #random_suffix >= 8 then math.randomseed(os.time()) for i = 1, 8 do local pos = math.random(1, #random_suffix) random_param = random_param .. string.sub(random_suffix, pos, pos) end else random_param = random_suffix or tostring(os.time()) end local pac_url = request_scheme .. "://" .. host .. "/luci-static/resources/openclash/pac/" .. pac_filename .. "?v=" .. random_param return pac_url end function generate_pac_content(proxy_ip, proxy_port, auth_user, auth_pass) local proxy_string = string.format("PROXY %s:%s; DIRECT", proxy_ip, proxy_port) local ipv4_networks = {} local ipv4_file = "/etc/openclash/custom/openclash_custom_localnetwork_ipv4.list" if fs.access(ipv4_file) then local content = fs.readfile(ipv4_file) if content then for line in content:gmatch("[^\r\n]+") do line = line:match("^%s*(.-)%s*$") if line and line ~= "" and not line:match("^//") and not line:match("^#") then local network, mask = line:match("([%d%.]+)/(%d+)") if network and mask then local mask_bits = tonumber(mask) if mask_bits and mask_bits >= 0 and mask_bits <= 32 then local subnet_masks = { [0] = "0.0.0.0", [1] = "128.0.0.0", [2] = "192.0.0.0", [3] = "224.0.0.0", [4] = "240.0.0.0", [5] = "248.0.0.0", [6] = "252.0.0.0", [7] = "254.0.0.0", [8] = "255.0.0.0", [9] = "255.128.0.0", [10] = "255.192.0.0", [11] = "255.224.0.0", [12] = "255.240.0.0", [13] = "255.248.0.0", [14] = "255.252.0.0", [15] = "255.254.0.0", [16] = "255.255.0.0", [17] = "255.255.128.0", [18] = "255.255.192.0", [19] = "255.255.224.0", [20] = "255.255.240.0", [21] = "255.255.248.0", [22] = "255.255.252.0", [23] = "255.255.254.0", [24] = "255.255.255.0", [25] = "255.255.255.128", [26] = "255.255.255.192", [27] = "255.255.255.224", [28] = "255.255.255.240", [29] = "255.255.255.248", [30] = "255.255.255.252", [31] = "255.255.255.254", [32] = "255.255.255.255" } local subnet_mask = subnet_masks[mask_bits] if subnet_mask then table.insert(ipv4_networks, {network = network, mask = subnet_mask}) end end else local single_ip = line:match("^([%d%.]+)$") if single_ip and single_ip:match("^%d+%.%d+%.%d+%.%d+$") then table.insert(ipv4_networks, {network = single_ip, mask = "255.255.255.255"}) end end end end end end local ipv6_networks = {} local ipv6_file = "/etc/openclash/custom/openclash_custom_localnetwork_ipv6.list" if fs.access(ipv6_file) then local content = fs.readfile(ipv6_file) if content then for line in content:gmatch("[^\r\n]+") do line = line:match("^%s*(.-)%s*$") if line and line ~= "" and not line:match("^//") and not line:match("^#") then local prefix, prefix_len = line:match("([:%da-fA-F]+)/(%d+)") if prefix and prefix_len then table.insert(ipv6_networks, {prefix = prefix, prefix_len = tonumber(prefix_len)}) else local single_ipv6 = line:match("^([:%da-fA-F]+)$") if single_ipv6 and single_ipv6:match("^[:%da-fA-F]+$") then table.insert(ipv6_networks, {prefix = single_ipv6, prefix_len = 128}) end end end end end end local ipv4_checks = {} for _, net in ipairs(ipv4_networks) do table.insert(ipv4_checks, string.format('isInNet(resolved_ip, "%s", "%s")', net.network, net.mask)) end local ipv4_check_code = "" if #ipv4_checks > 0 then ipv4_check_code = "if (" .. table.concat(ipv4_checks, " ||\n ") .. ") {\n return \"DIRECT\";\n }" end local ipv6_checks = {} for _, net in ipairs(ipv6_networks) do if net.prefix_len == 128 then table.insert(ipv6_checks, string.format('resolved_ipv6 === "%s"', net.prefix)) else local prefix_hex = net.prefix:gsub(":+$", "") table.insert(ipv6_checks, string.format('resolved_ipv6.indexOf("%s") === 0', prefix_hex)) end end local ipv6_check_code = "" if #ipv6_checks > 0 then ipv6_check_code = "if (" .. table.concat(ipv6_checks, " ||\n ") .. ") {\n return \"DIRECT\";\n }" end local pac_script = string.format([[ // OpenClash PAC File var failureCount = 0; var lastCheckTime = 0; var isProxyDown = false; var checkInterval = 300000; // 5分钟 = 300000毫秒 // Access Check function checkNetworkConnectivity() { var currentTime = Date.now(); if (currentTime - lastCheckTime < checkInterval) { return !isProxyDown; } lastCheckTime = currentTime; try { var test1 = dnsResolve("www.gstatic.com"); var test2 = dnsResolve("captive.apple.com"); if (test1 || test2) { if (isProxyDown) { isProxyDown = false; failureCount = 0; } return true; } else { failureCount++; if (failureCount >= 3) { isProxyDown = true; } return false; } } catch (e) { failureCount++; if (failureCount >= 3) { isProxyDown = true; } return false; } } function FindProxyForURL(url, host) { if (isPlainHostName(host) || host === "127.0.0.1" || host === "::1" || host === "localhost") { return "DIRECT"; } // IPv4 var resolved_ip = dnsResolve(host); if (resolved_ip) { %s } // IPv6 var resolved_ipv6 = dnsResolveEx(host); if (resolved_ipv6) { %s } if (checkNetworkConnectivity()) { return "%s"; } else { return "DIRECT"; } } function FindProxyForURLEx(url, host) { return FindProxyForURL(url, host); } ]], ipv4_check_code, ipv6_check_code, proxy_string) return pac_script end local function is_safe_filename(filename) return filename and filename:match("^[%w%._%-]+$") and not filename:match("^%.") end local function kill_process() local cmd = string.format("%s |grep -E 'openclash|clash|mihomo' |grep -v grep |awk '{print $1}' |xargs -r kill -9 >/dev/null 2>&1", fs.ps_cmd()) SYS.call(cmd) end function action_oc_action() local action = HTTP.formvalue("action") local config_file = HTTP.formvalue("config_file") if not action then HTTP.status(500, "Missing action parameter") return end if config_file and config_file ~= "" then local config_path = "/etc/openclash/config/" .. config_file if not fs.access(config_path) then HTTP.status(500, "Config file not found") return end if uci:get("openclash", "config", "config_path") ~= config_path then uci:set("openclash", "config", "config_path", config_path) end end if action == "start" then if uci:get("openclash", "config", "enable") ~= "1" then uci:set("openclash", "config", "enable", "1") uci:commit("openclash") end if not is_running() then kill_process() SYS.call("/etc/init.d/openclash start >/dev/null 2>&1") else SYS.call("/etc/init.d/openclash restart >/dev/null 2>&1") end elseif action == "stop" then if uci:get("openclash", "config", "enable") ~= "0" then uci:set("openclash", "config", "enable", "0") uci:commit("openclash") end kill_process() SYS.call("/etc/init.d/openclash stop >/dev/null 2>&1") elseif action == "restart" then if uci:get("openclash", "config", "enable") ~= "1" then uci:set("openclash", "config", "enable", "1") uci:commit("openclash") end kill_process() SYS.call("/etc/init.d/openclash restart >/dev/null 2>&1") else HTTP.status(500, "Invalid action parameter") return end HTTP.prepare_content("application/json") HTTP.write_json({status = "success", action = action}) end function action_config_file_list() local config_files = {} local age_files = {} local current_config = "" local config_path = fs.uci_get_config("config", "config_path") if config_path then current_config = config_path end uci:foreach("openclash", "config_age_secret", function(a) if a.name and (a.secret or a.public) then table.insert(age_files, { name = a.name, secret = a.secret or "", public = a.public or "" }) else uci:delete("openclash", "config_age_secret", a[".name"]) uci:commit("openclash") end end) local config_dir = "/etc/openclash/config/" local fingerprint_parts = {} local cache_valid = false local client_fp = HTTP.formvalue("fingerprint") if fs.access(config_dir) then local files = fs.dir(config_dir) if files then local yaml_files = {} for _, f in ipairs(files) do if string.match(f, "%.ya?ml$") then local stat = fs.stat(config_dir .. f) if stat and stat.type == "regular" then yaml_files[#yaml_files + 1] = f .. ":" .. (stat.mtime or 0) end end end table.sort(yaml_files) fingerprint_parts[#fingerprint_parts + 1] = table.concat(yaml_files, "|") end end for _, a in ipairs(age_files) do fingerprint_parts[#fingerprint_parts + 1] = "AGE:" .. a.name .. ":" .. (a.secret ~= "" and "1" or "0") end local fingerprint = table.concat(fingerprint_parts, "||") if client_fp and client_fp == fingerprint then cache_valid = true end if fs.access(config_dir) then local files = fs.dir(config_dir) if files then for _, file in ipairs(files) do local full_path = config_dir .. file local stat = fs.stat(full_path) local name_no_ext = file:match("^(.*)%.ya?ml$") if stat and stat.type == "regular" and string.match(file, "%.ya?ml$") then if name_no_ext and #age_files > 0 and not cache_valid then local cfile = io.open(full_path,"r") if cfile then local content = cfile:read(1024) local age_symbol = content:find("BEGIN AGE ENCRYPTED FILE") for _, age in pairs(age_files) do if age.name == name_no_ext and age.secret and age_symbol then stat.age = true break end end cfile:close() end end table.insert(config_files, { name = file, path = full_path, size = stat.size, mtime = stat.mtime, age = stat.age or false }) end end end table.sort(config_files, function(a, b) return string.lower(a.name) < string.lower(b.name) end) end HTTP.prepare_content("application/json") HTTP.write_json({ config_files = config_files, current_config = current_config, total_count = #config_files, fingerprint = fingerprint }) end function action_upload_config() local upload = HTTP.formvalue("config_file") local filename = HTTP.formvalue("filename") HTTP.prepare_content("application/json") if not upload or upload == "" then HTTP.write_json({ status = "error", message = "No file uploaded" }) return end if not filename or filename == "" then filename = "upload_" .. os.date("%Y%m%d_%H%M%S") end if not is_safe_filename(filename) then HTTP.write_json({ status = "error", message = "Invalid filename" }) return end if not string.match(filename, "%.ya?ml$") then filename = filename .. ".yaml" end local config_dir = "/etc/openclash/config/" local target_path = config_dir .. filename if string.len(upload) == 0 then HTTP.write_json({ status = "error", message = "Uploaded file is empty" }) return end local file_size = string.len(upload) if file_size > 10 * 1024 * 1024 then HTTP.write_json({ status = "error", message = string.format("File size (%s) exceeds 10MB limit", fs.filesize(file_size)) }) return end local yaml_valid = false local content_start = string.sub(upload, 1, 5000) if string.find(content_start, "proxy%-providers:") or string.find(content_start, "proxies:") or string.find(content_start, "rules:") or string.find(content_start, "port:") or string.find(content_start, "mode:") then yaml_valid = true end if not yaml_valid then HTTP.write_json({ status = "error", message = "Invalid config file format - missing required YAML sections" }) return end SYS.call("mkdir -p " .. config_dir) local fp = io.open(target_path, "w") if fp then fp:write(upload) fp:close() SYS.call(string.format("chmod 644 '%s'", target_path)) SYS.call(string.format("chown root:root '%s'", target_path)) local written_content = fs.readfile(target_path) if not written_content or string.len(written_content) ~= file_size then fs.unlink(target_path) HTTP.write_json({ status = "error", message = "File write verification failed" }) return end HTTP.write_json({ status = "success", message = "Config file uploaded successfully", filename = filename, file_path = target_path, file_size = file_size, readable_size = fs.filesize(file_size) }) else HTTP.write_json({ status = "error", message = "Failed to save config file to disk" }) end end function action_config_file_read() local config_file = HTTP.formvalue("config_file") HTTP.prepare_content("application/json") if not config_file then HTTP.write_json({ status = "error", message = "Missing config_file parameter" }) return end local allow = false if config_file == "/etc/openclash/custom/openclash_custom_overwrite.sh" then allow = true elseif config_file:match("^/etc/openclash/overwrite/[^/]+$") and not string.find(config_file, "%.%.") then allow = true elseif config_file:match("^/etc/openclash/[^/]+%.ya?ml$") then allow = true elseif config_file:match("^/etc/openclash/config/[^/]+%.ya?ml$") and not string.find(config_file, "%.%.") then allow = true end if not allow then HTTP.write_json({ status = "error", message = "Invalid config file path" }) return end if not fs.access(config_file) then HTTP.write_json({ status = "success", content = "", file_info = { path = config_file, size = 0, mtime = 0, readable_size = "0 KB", last_modified = "" } }) return end local stat = fs.stat(config_file) if not stat or stat.type ~= "regular" then HTTP.write_json({ status = "error", message = "Config file is not a regular file" }) return end if stat.size > 10 * 1024 * 1024 then HTTP.write_json({ status = "error", message = "Config file too large (max 10MB)" }) return end local content = fs.readfile(config_file) if content == nil then HTTP.write_json({ status = "error", message = "Failed to read config file" }) return end HTTP.write_json({ status = "success", content = content, file_info = { path = config_file, size = stat.size, mtime = stat.mtime, readable_size = fs.filesize(stat.size), last_modified = os.date("%Y-%m-%d %H:%M:%S", stat.mtime) } }) end function action_config_file_save() local config_file = HTTP.formvalue("config_file") local content = HTTP.formvalue("content") HTTP.prepare_content("application/json") if content then content = content:gsub("\r\n", "\n"):gsub("\r", "\n") end if not config_file then HTTP.write_json({ status = "error", message = "Missing config_file parameter" }) return end if not content then HTTP.write_json({ status = "error", message = "Missing content parameter" }) return end local is_overwrite = (config_file == "/etc/openclash/custom/openclash_custom_overwrite.sh" or config_file:match("^/etc/openclash/overwrite/[^/]+$")) if not is_overwrite then if not string.match(config_file, "^/etc/openclash/config/[^/]+%.ya?ml$") or string.find(config_file, "%.%.") then HTTP.write_json({ status = "error", message = "Invalid config file path" }) return end else if not (config_file == "/etc/openclash/custom/openclash_custom_overwrite.sh" or (config_file:match("^/etc/openclash/overwrite/[^/]+$") and not string.find(config_file, "%.%."))) then HTTP.write_json({ status = "error", message = "Invalid overwrite file path" }) return end end if string.len(content) > 10 * 1024 * 1024 then HTTP.write_json({ status = "error", message = "Content too large (max 10MB)" }) return end local backup_file = nil if fs.access(config_file) then backup_file = config_file .. ".backup." .. os.time() local backup_success = SYS.call(string.format("cp '%s' '%s'", config_file, backup_file)) if backup_success ~= 0 then HTTP.write_json({ status = "error", message = "Failed to create backup file" }) return end end local success = fs.writefile(config_file, content) if not success then if backup_file then SYS.call(string.format("mv '%s' '%s'", backup_file, config_file)) end HTTP.write_json({ status = "error", message = "Failed to write config file" }) return end local written_content = fs.readfile(config_file) if written_content ~= content then if backup_file then SYS.call(string.format("mv '%s' '%s'", backup_file, config_file)) end HTTP.write_json({ status = "error", message = "File write verification failed" }) return end if not is_overwrite then SYS.call(string.format("chmod 644 '%s'", config_file)) end SYS.call(string.format("chown root:root '%s'", config_file)) if backup_file then SYS.call(string.format([[ ( config_dir="$(dirname '%s')" config_basename="$(basename '%s')" cd "$config_dir" 2>/dev/null || exit 0 rm -f "${config_basename}.backup."* 2>/dev/null ) & ]], config_file, config_file)) end local stat = fs.stat(config_file) local file_info = {} if stat then file_info = { path = config_file, size = stat.size, mtime = stat.mtime, readable_size = fs.filesize(stat.size), last_modified = os.date("%Y-%m-%d %H:%M:%S", stat.mtime) } end HTTP.write_json({ status = "success", message = "Config file saved successfully", file_info = file_info, backup_created = backup_file and true or false }) end function action_add_subscription() local name = HTTP.formvalue("name") local address = HTTP.formvalue("address") local sub_ua = HTTP.formvalue("sub_ua") or "clash-verge/v2.4.5" local sub_convert = HTTP.formvalue("sub_convert") or "0" local convert_address = HTTP.formvalue("convert_address") or "" local template = HTTP.formvalue("template") or "" local custom_template_url = HTTP.formvalue("custom_template_url") or "" local emoji = HTTP.formvalue("emoji") or "false" local udp = HTTP.formvalue("udp") or "false" local skip_cert_verify = HTTP.formvalue("skip_cert_verify") or "false" local sort = HTTP.formvalue("sort") or "false" local node_type = HTTP.formvalue("node_type") or "false" local rule_provider = HTTP.formvalue("rule_provider") or "false" local custom_params = HTTP.formvalue("custom_params") or "" local keyword = HTTP.formvalue("keyword") or "" local ex_keyword = HTTP.formvalue("ex_keyword") or "" local de_ex_keyword = HTTP.formvalue("de_ex_keyword") or "" local sub_headers = HTTP.formvalue("sub_headers") or "" HTTP.prepare_content("application/json") if not name then HTTP.write_json({ status = "error", message = "Missing name parameter" }) return end local is_valid_url = false if address and address ~= "" and sub_convert == "1" then local prefixed_http_pattern = "^[^,%s]+,https?://.+" local encoded_prefixed_http_pattern = "^[^%%%s]+%%2[Cc]https?%%3[Aa]%%2[Ff]%%2[Ff].+" if string.find(address, "\n") or string.find(address, "|") then local links = {} if string.find(address, "\n") then for line in address:gmatch("[^\n]+") do table.insert(links, line:match("^%s*(.-)%s*$")) end else for link in address:gmatch("[^|]+") do table.insert(links, link:match("^%s*(.-)%s*$")) end end for _, link in ipairs(links) do if link and link ~= "" then if string.find(link, "^https?://") or string.find(link, "^[a-zA-Z]+://") or string.find(link, prefixed_http_pattern) or string.find(link, encoded_prefixed_http_pattern) then is_valid_url = true break end end end else if string.find(address, "^https?://") or string.find(address, "^[a-zA-Z]+://") or string.find(address, prefixed_http_pattern) or string.find(address, encoded_prefixed_http_pattern) then is_valid_url = true end end elseif address and address ~= "" then if string.find(address, "^https?://") and not string.find(address, "\n") and not string.find(address, "|") then is_valid_url = true end else is_valid_url = true end if not is_valid_url then local error_msg if sub_convert == "1" then error_msg = "Invalid subscription URL format. Support: HTTP/HTTPS subscription URLs, or protocol links, can be separated by newlines or |" else error_msg = "Invalid subscription URL format. Only single HTTP/HTTPS subscription URL is supported when subscription conversion is disabled" end HTTP.write_json({ status = "error", message = error_msg }) return end local existing_section_id = nil uci:foreach("openclash", "config_subscribe", function(s) if s.name == name then existing_section_id = s['.name'] return false end end) local normalized_address = address if sub_convert == "1" and (string.find(address, "\n") or string.find(address, "|")) then local links = {} if string.find(address, "\n") then for line in address:gmatch("[^\n]+") do local link = line:match("^%s*(.-)%s*$") if link and link ~= "" then table.insert(links, link) end end else for link in address:gmatch("[^|]+") do local clean_link = link:match("^%s*(.-)%s*$") if clean_link and clean_link ~= "" then table.insert(links, clean_link) end end end normalized_address = table.concat(links, "\n") else normalized_address = address:match("^%s*(.-)%s*$") end local section_id if existing_section_id then section_id = existing_section_id else section_id = uci:add("openclash", "config_subscribe") end if section_id then uci:set("openclash", section_id, "name", name) if normalized_address and normalized_address ~= "" then uci:set("openclash", section_id, "address", normalized_address) else uci:delete("openclash", section_id, "address") end uci:set("openclash", section_id, "sub_ua", sub_ua) uci:delete("openclash", section_id, "sub_headers") if sub_headers and sub_headers ~= "" then local headers = {} for line in sub_headers:gmatch("[^\n]+") do local h = line:match("^%s*(.-)%s*$") if h and h ~= "" then table.insert(headers, h) end end if #headers > 0 then uci:set_list("openclash", section_id, "sub_headers", headers) end end uci:set("openclash", section_id, "sub_convert", sub_convert) if sub_convert == "1" then uci:set("openclash", section_id, "convert_address", convert_address) uci:set("openclash", section_id, "template", template) if template == "0" then uci:set("openclash", section_id, "custom_template_url", custom_template_url) else uci:delete("openclash", section_id, "custom_template_url") end else uci:delete("openclash", section_id, "convert_address") uci:delete("openclash", section_id, "template") uci:delete("openclash", section_id, "custom_template_url") end uci:set("openclash", section_id, "emoji", emoji) uci:set("openclash", section_id, "udp", udp) uci:set("openclash", section_id, "skip_cert_verify", skip_cert_verify) uci:set("openclash", section_id, "sort", sort) uci:set("openclash", section_id, "node_type", node_type) uci:set("openclash", section_id, "rule_provider", rule_provider) uci:delete("openclash", section_id, "custom_params") if custom_params and custom_params ~= "" and sub_convert == "1" then local params = {} for line in custom_params:gmatch("[^\n]+") do local param = line:match("^%s*(.-)%s*$") if param and param ~= "" then table.insert(params, param) end end if #params > 0 then uci:set_list("openclash", section_id, "custom_params", params) end end uci:delete("openclash", section_id, "keyword") if keyword and keyword ~= "" then local keywords = {} for line in keyword:gmatch("[^\n]+") do local kw = line:match("^%s*(.-)%s*$") if kw and kw ~= "" then table.insert(keywords, kw) end end if #keywords > 0 then uci:set_list("openclash", section_id, "keyword", keywords) end end uci:delete("openclash", section_id, "ex_keyword") if ex_keyword and ex_keyword ~= "" then local ex_keywords = {} for line in ex_keyword:gmatch("[^\n]+") do local ex_kw = line:match("^%s*(.-)%s*$") if ex_kw and ex_kw ~= "" then table.insert(ex_keywords, ex_kw) end end if #ex_keywords > 0 then uci:set_list("openclash", section_id, "ex_keyword", ex_keywords) end end uci:set("openclash", section_id, "de_ex_keyword", de_ex_keyword) uci:commit("openclash") local action_msg = existing_section_id and "Subscription updated successfully" or "Subscription added successfully" HTTP.write_json({ status = "success", message = action_msg, name = name, address = normalized_address, sub_ua = sub_ua, sub_convert = sub_convert, multiple_links = sub_convert == "1" and (string.find(normalized_address, "\n") and true or false) }) else HTTP.write_json({ status = "error", message = "Failed to add/update subscription configuration" }) end end function action_upload_overwrite() local upload = HTTP.formvalue("config_file") local filename = HTTP.formvalue("filename") local config_values = {} local raw_config = HTTP.formvalue("config") or "" if raw_config ~= "" then for line in raw_config:gmatch("[^\n]+") do local config_value = line:match("^%s*(.-)%s*$") if config_value and config_value ~= "" then table.insert(config_values, config_value) end end end local enable = HTTP.formvalue("enable") local order = HTTP.formvalue("order") HTTP.prepare_content("application/json") if not upload or upload == "" then HTTP.write_json({status = "error", message = "No file uploaded"}) return end if not filename or filename == "" then filename = "upload_" .. os.date("%Y%m%d_%H%M%S") end if not is_safe_filename(filename) then HTTP.write_json({status = "error", message = "Invalid filename"}) return end local overwrite_dir = "/etc/openclash/overwrite/" SYS.call("mkdir -p " .. overwrite_dir) local target_path = overwrite_dir .. filename if string.len(upload) == 0 then HTTP.write_json({status = "error", message = "Uploaded file is empty"}) return end local file_size = string.len(upload) if file_size > 10 * 1024 * 1024 then HTTP.write_json({status = "error", message = string.format("File size (%s) exceeds 10MB limit", require("luci.openclash").filesize(file_size))}) return end local fp = io.open(target_path, "w") if fp then fp:write(upload) fp:close() SYS.call(string.format("chmod 644 '%s'", target_path)) SYS.call(string.format("chown root:root '%s'", target_path)) local written_content = fs.readfile(target_path) if not written_content or string.len(written_content) ~= file_size then fs.unlink(target_path) HTTP.write_json({status = "error", message = "File write verification failed"}) return end local section_name = filename local found = false uci:foreach("openclash", "config_overwrite", function(s) if s.name == section_name then found = true uci:delete("openclash", s[".name"], "config") if #config_values > 0 then uci:set_list("openclash", s[".name"], "config", config_values) end if s.enable == nil or (s.enable ~= nil and enable ~= nil) then if enable == nil then enable = 0 end uci:set("openclash", s[".name"], "enable", tostring(enable)) end if s.order == nil or (s.order ~= nil and s.order ~= order and order ~= nil) then if order == nil then local max_order = -1 uci:foreach("openclash", "config_overwrite", function(s) local o = tonumber(s.order) if o and o > max_order then max_order = o end end) order = tostring(max_order + 1) end uci:set("openclash", s[".name"], "order", order) else uci:set("openclash", s[".name"], "order", tonumber(order)) end return false end end) if not found then local sid = uci:add("openclash", "config_overwrite") uci:set("openclash", sid, "name", section_name) uci:set("openclash", sid, "type", "file") uci:delete("openclash", sid, "config") if #config_values > 0 then uci:set_list("openclash", sid, "config", config_values) end if enable ~= nil then uci:set("openclash", sid, "enable", tostring(enable)) else uci:set("openclash", sid, "enable", 0) end if order ~= nil then uci:set("openclash", sid, "order", tostring(order)) else local max_order = -1 uci:foreach("openclash", "config_overwrite", function(s) local o = tonumber(s.order) if o and o > max_order then max_order = o end end) uci:set("openclash", sid, "order", tostring(max_order + 1)) end end uci:commit("openclash") HTTP.write_json({ status = "success", message = "Overwrite file uploaded successfully", filename = filename, file_path = target_path, file_size = file_size, readable_size = fs.filesize(file_size) }) else HTTP.write_json({status = "error", message = "Failed to save file to disk"}) end end function action_overwrite_subscribe_info() local method = HTTP.getenv("REQUEST_METHOD") local filename = HTTP.formvalue("filename") local old_filename = HTTP.formvalue("old_filename") local typ = HTTP.formvalue("type") or "file" local section_name = nil local old_section_name = nil if filename and not is_safe_filename(filename) then HTTP.prepare_content("application/json") HTTP.write_json({status = "error", message = "Invalid filename"}) return end if filename then section_name = filename:match("([^/]+)$") end if old_filename then old_section_name = old_filename:match("([^/]+)$") end if method == "GET" then local result = {} uci:foreach("openclash", "config_overwrite", function(s) if s.name then local config_value = "" if s.config then local config_list = {} for _, item in ipairs(s.config) do if item and item ~= "" then table.insert(config_list, tostring(item)) end end if #config_list > 0 then config_value = config_list end end result[s.name] = { url = s.url or "", config = config_value, update_days = s.update_days or "", update_hour = s.update_hour or "", order = tonumber(s.order) or 0, type = s.type or "file", param = s.param or "", enable = tonumber(s.enable) or 0 } end end) HTTP.prepare_content("application/json") HTTP.write_json({status="success", data=result}) return elseif method == "POST" then if not section_name then HTTP.status(500, "Missing filename") return end local url = HTTP.formvalue("url") or "" local update_days = HTTP.formvalue("update_days") or "" local update_hour = HTTP.formvalue("update_hour") or "" local order = HTTP.formvalue("order") local param = HTTP.formvalue("param") or "" local config_values = {} local raw_config = HTTP.formvalue("config") or "" if raw_config ~= "" then for line in raw_config:gmatch("[^\n]+") do local config_value = line:match("^%s*(.-)%s*$") if config_value and config_value ~= "" then table.insert(config_values, config_value) end end end typ = HTTP.formvalue("type") or typ or "file" local enable = HTTP.formvalue("enable") if typ == "http" then if not url or url == "" then HTTP.prepare_content("application/json") HTTP.write_json({ status = "error", message = "Subscribe URL cannot be empty" }) return end local is_valid_url = false if url:match("^https?://") and not url:find("\n") and not url:find("|") then is_valid_url = true end if not is_valid_url then HTTP.prepare_content("application/json") HTTP.write_json({ status = "error", message = "Invalid subscribe URL format, only single HTTP/HTTPS link is supported" }) return end end local found = false if old_section_name and old_section_name ~= "" and old_section_name ~= section_name then uci:foreach("openclash", "config_overwrite", function(s) if s.name == old_section_name then uci:set("openclash", s[".name"], "name", section_name) uci:set("openclash", s[".name"], "url", url) uci:delete("openclash", s[".name"], "config") if #config_values > 0 then uci:set_list("openclash", s[".name"], "config", config_values) end uci:set("openclash", s[".name"], "update_days", update_days) uci:set("openclash", s[".name"], "update_hour", update_hour) uci:set("openclash", s[".name"], "type", typ) uci:set("openclash", s[".name"], "param", param) if s.order == nil or (s.order ~= nil and s.order ~= order and order ~= nil) then if order == nil then local max_order = -1 uci:foreach("openclash", "config_overwrite", function(s) local o = tonumber(s.order) if o and o > max_order then max_order = o end end) order = tostring(max_order + 1) end uci:set("openclash", s[".name"], "order", order) else uci:set("openclash", s[".name"], "order", tonumber(order) or 1) end if s.enable == nil or (s.enable ~= nil and enable ~= nil) then if enable == nil then enable = 0 end uci:set("openclash", s[".name"], "enable", tostring(enable)) end found = true return false end end) local overwrite_dir = "/etc/openclash/overwrite/" local old_file = overwrite_dir .. old_section_name local new_file = overwrite_dir .. section_name if fs.access(old_file) and not fs.access(new_file) then fs.rename(old_file, new_file) end uci:commit("openclash") HTTP.prepare_content("application/json") HTTP.write_json({status="success"}) return end if not found then uci:foreach("openclash", "config_overwrite", function(s) if s.name == section_name then uci:set("openclash", s[".name"], "url", url) uci:delete("openclash", s[".name"], "config") if #config_values > 0 then uci:set_list("openclash", s[".name"], "config", config_values) end uci:set("openclash", s[".name"], "update_days", update_days) uci:set("openclash", s[".name"], "update_hour", update_hour) uci:set("openclash", s[".name"], "type", typ) uci:set("openclash", s[".name"], "param", param) if s.order == nil or (s.order ~= nil and s.order ~= order and order ~= nil) then if order == nil then local max_order = -1 uci:foreach("openclash", "config_overwrite", function(s) local o = tonumber(s.order) if o and o > max_order then max_order = o end end) order = tostring(max_order + 1) end uci:set("openclash", s[".name"], "order", order) else uci:set("openclash", s[".name"], "order", tonumber(order)) end if s.enable == nil or (s.enable ~= nil and enable ~= nil) then if enable == nil then enable = 0 end uci:set("openclash", s[".name"], "enable", tostring(enable)) end found = true return false end end) end if not found then local sid = uci:add("openclash", "config_overwrite") uci:set("openclash", sid, "name", section_name) uci:set("openclash", sid, "url", url) uci:delete("openclash", sid, "config") if #config_values > 0 then uci:set_list("openclash", sid, "config", config_values) end uci:set("openclash", sid, "update_days", update_days) uci:set("openclash", sid, "update_hour", update_hour) uci:set("openclash", sid, "type", typ) uci:set("openclash", sid, "param", param) if order == nil then local max_order = -1 uci:foreach("openclash", "config_overwrite", function(s) local o = tonumber(s.order) if o and o > max_order then max_order = o end end) order = tostring(max_order + 1) else order = tostring(order) end uci:set("openclash", sid, "order", order) uci:set("openclash", sid, "enable", 0) end uci:commit("openclash") if typ == "file" then local overwrite_dir = "/etc/openclash/overwrite/" local file_path = overwrite_dir .. section_name if not fs.access(file_path) then fs.writefile(file_path, "") end elseif typ == "http" then local overwrite_dir = "/etc/openclash/overwrite/" local file_path = overwrite_dir .. section_name if url and url ~= "" then local cmd = string.format('curl -sL --connect-timeout 5 -m 15 --retry 2 "%s" -o "%s"', url, file_path) local ret = SYS.call(cmd) if not fs.access(file_path) then fs.writefile(file_path, "") end if ret ~= 0 or not fs.access(file_path) or fs.stat(file_path).size == 0 then HTTP.prepare_content("application/json") HTTP.write_json({status="error", message="Download failed"}) return end else if not fs.access(file_path) then fs.writefile(file_path, "") end end end HTTP.prepare_content("application/json") HTTP.write_json({status="success"}) return else HTTP.status(500, "Method Not Allowed") end end function action_overwrite_file_list() local overwrite_files = {} local custom_file = "/etc/openclash/custom/openclash_custom_overwrite.sh" if fs.access(custom_file) then local stat = fs.stat(custom_file) if stat and stat.type == "regular" then table.insert(overwrite_files, { name = "openclash_custom_overwrite.sh", path = custom_file, size = stat.size, mtime = stat.mtime }) end end local overwrite_dir = "/etc/openclash/overwrite/" if fs.access(overwrite_dir) then local files = fs.dir(overwrite_dir) if files then for _, file in ipairs(files) do local full_path = overwrite_dir .. file local stat = fs.stat(full_path) if stat and stat.type == "regular" then table.insert(overwrite_files, { name = file, path = full_path, size = stat.size, mtime = stat.mtime }) end end end end table.sort(overwrite_files, function(a, b) return (a.mtime or 0) > (b.mtime or 0) end) HTTP.prepare_content("application/json") HTTP.write_json({ overwrite_files = overwrite_files, total_count = #overwrite_files }) end function delete_overwrite_file() local filename = HTTP.formvalue("filename") if not filename or filename == "" then HTTP.prepare_content("application/json") HTTP.write_json({status="error", message="Missing filename"}) return end local overwrite_dir = "/etc/openclash/overwrite/" local file_path = overwrite_dir .. filename if fs.access(file_path) then fs.unlink(file_path) end uci:foreach("openclash", "config_overwrite", function(s) if s.name == filename then uci:delete("openclash", s[".name"]) return false end end) uci:commit("openclash") local order_list = {} uci:foreach("openclash", "config_overwrite", function(s) table.insert(order_list, { section = s[".name"], order = tonumber(s.order) or 0 }) end) table.sort(order_list, function(a, b) return a.order < b.order end) for idx, item in ipairs(order_list) do uci:set("openclash", item.section, "order", tostring(idx - 1)) end uci:commit("openclash") HTTP.prepare_content("application/json") HTTP.write_json({status="success"}) end function action_get_subscribe_data() local filename = HTTP.formvalue("filename") if not filename then HTTP.status(500, "Bad Request") return end local data = {} uci:foreach("openclash", "config_subscribe", function(s) if s.name == filename then data = s -- UCI list fields: convert to newline-separated strings for frontend local sid = s['.name'] for _, field in ipairs({"sub_headers", "keyword", "ex_keyword", "custom_params"}) do local raw = uci:get_list("openclash", sid, field) if raw then data[field] = table.concat(raw, "\n") end end return false end end) uci:foreach("openclash", "config_age_secret", function(a) if a.name == filename and (not a.hidden or a.hidden ~= "true") then if a.secret then data.config_age_secret = a.secret end if a.public then data.config_age_public = a.public end if a.algo then data.config_age_algo = a.algo end return false end if a.name == filename and a.hidden and a.hidden == "true" then data.config_age_hidden = true return false end end) HTTP.prepare_content("application/json") HTTP.write_json(data) end function action_get_subscribe_info_data() local filename = HTTP.formvalue("filename") if not filename then HTTP.status(500, "Bad Request") return end HTTP.prepare_content("application/json") HTTP.write_json(get_sub_url(filename)) end function action_subconverter_version() local raw_url = HTTP.formvalue("url") or "" local version_url, err = parse_subconverter_version_url(raw_url) HTTP.prepare_content("application/json") if not version_url then HTTP.write_json({status = "error", message = err or "invalid"}) return end local cmd = table.concat({ "curl -fsS --connect-timeout 3 -m 6 --retry 0", "-H " .. util.shellquote("Accept: text/plain, */*"), "-H " .. util.shellquote("Origin: https://openclash.local"), "-H " .. util.shellquote("Sec-Fetch-Mode: cors"), "-H " .. util.shellquote("Sec-Fetch-Dest: empty"), "-H " .. util.shellquote("User-Agent: OpenClash Subconverter Version Check"), util.shellquote(version_url), "2>/dev/null | head -c 4096" }, " ") local version = sanitize_subconverter_version_text(SYS.exec(cmd)) if version == "" then HTTP.write_json({status = "unrecognized"}) else HTTP.write_json({status = "success", version = version}) end end function action_generate_age_key() local algo = HTTP.formvalue("algo") or "keygen" local cmd = string.format("%s age %s", meta_core_path, (algo == "pq" and "keygen-pq" or "keygen")) local out = SYS.exec(cmd .. " 2>/dev/null") local secret = out:match("(AGE%-SECRET%-KEY%-%S+)") local public = out:match("# public key: ([^\n\r]+)") HTTP.prepare_content("application/json") if not secret then HTTP.write_json({status = "error", message = "Failed to generate age key", output = out}) return end HTTP.write_json({status = "success", secret = secret, public = public}) end function action_cal_age_public_key() local secret = HTTP.formvalue("secret") or "" if secret == "" then HTTP.prepare_content("application/json") HTTP.write_json({status = "error", message = "Secret key is required"}) return end local cmd = string.format("%s age convert %s", meta_core_path, secret) local out = SYS.exec(cmd .. " 2>/dev/null") HTTP.prepare_content("application/json") if out and out:match("^age") then HTTP.write_json({status = "success", public = out}) else HTTP.write_json({status = "error", message = "Failed to calculate public key, invalid secret key", output = out}) end end function action_add_age_config() local name = HTTP.formvalue("name") local age_secret = HTTP.formvalue("age_secret") or "" local age_public = HTTP.formvalue("age_public") or "" local age_algo = HTTP.formvalue("age_algo") or "" local age_section_id, age_section_hidden HTTP.prepare_content("application/json") if not name or name == "" then HTTP.write_json({status = "error", message = "Missing name parameter"}) return end uci:foreach("openclash", "config_age_secret", function(s) if s.name == name then age_section_id = s['.name'] age_section_hidden = s.hidden and s.hidden == "true" return false end end) if age_section_hidden then HTTP.write_json({status = "error", message = "Cannot modify hidden age configuration"}) return end if not age_section_id and (age_secret ~= "" or age_public ~= "" or age_algo ~= "") then age_section_id = uci:add("openclash", "config_age_secret") if age_section_id then uci:set("openclash", age_section_id, "name", name) end end if age_section_id then if (age_secret == "" and age_public == "") then uci:delete("openclash", age_section_id) else if age_secret and age_secret ~= "" then uci:set("openclash", age_section_id, "secret", age_secret) else uci:delete("openclash", age_section_id, "secret") end if age_public and age_public ~= "" then uci:set("openclash", age_section_id, "public", age_public) else uci:delete("openclash", age_section_id, "public") end if age_algo and age_algo ~= "" then uci:set("openclash", age_section_id, "algo", age_algo) else uci:delete("openclash", age_section_id, "algo") end end uci:commit("openclash") end HTTP.write_json({status = "success"}) end function oix_login_info_save() local token = HTTP.formvalue("token") if token and token ~= "" then uci:set("openclash", "config", "oix_token", token) else local email = HTTP.formvalue("email") local passwd = HTTP.formvalue("passwd") if email then uci:set("openclash", "config", "oix_email", email) end if passwd then uci:set("openclash", "config", "oix_passwd", passwd) end end local checkin = HTTP.formvalue("checkin") if checkin then uci:set("openclash", "config", "oix_checkin", checkin) end local interval = tonumber(HTTP.formvalue("interval")) if interval then if interval < 1 then interval = 1 end if interval > 720 then interval = 720 end uci:set("openclash", "config", "oix_checkin_interval", tostring(interval)) end local multiple = tonumber(HTTP.formvalue("multiple")) if multiple then if multiple < 1 then multiple = 1 end if multiple > 100 then multiple = 100 end uci:set("openclash", "config", "oix_checkin_multiple", tostring(multiple)) end local show_info_page = HTTP.formvalue("show_info_page") if show_info_page then uci:set("openclash", "config", "oix_show_info_page", show_info_page) end local default_params = HTTP.formvalue("default_params") if default_params then uci:set("openclash", "config", "oix_default_params", default_params) end uci:commit("openclash") HTTP.prepare_content("application/json") HTTP.write_json({status = "success"}) end function oix_params_sync() local params = HTTP.formvalue("params") or "" if #params > 8192 then HTTP.status(400, "Params too long") HTTP.write_json({status = "error", msg = "params too long"}) return end uci:set("openclash", "config", "oix_params", params) uci:commit("openclash") if is_running() then local dase_val = dase() or "" local daip_val = daip() local cn_port_val = cn_port() local auth_header = "" if dase_val and dase_val ~= "" then auth_header = string.format('-H "Authorization: Bearer %s"', dase_val) end if params == "" then SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" %s -XDELETE http://"%s":"%s"/oix/options', auth_header, daip_val, cn_port_val)) else local encoded = params:gsub('"', '\\"'):gsub('\n', '') SYS.exec(string.format('curl -sL -m 3 --retry 2 -H "Content-Type: application/json" %s -XPUT -d \'{"params":"%s"}\' http://"%s":"%s"/oix/options', auth_header, encoded, daip_val, cn_port_val)) end end HTTP.prepare_content("application/json") HTTP.write_json({status = "success"}) end function oix_params_get() local result = {params = "", default_params = ""} local home_dir = "/etc/openclash" local params_file = home_dir .. "/.oix_params" local default_params_file = home_dir .. "/.oix_default_params" 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 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+", "") end end HTTP.prepare_content("application/json") HTTP.write_json(result) 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 if sub_info and sub_info.ret == 200 then local sub_key = {"openclash"} for _,v in ipairs(sub_key) do while true do local sub_match, sub_convert, sid = false, false, nil uci:foreach("openclash", "config_subscribe", function(s) if s.name == "oixCloud - smart" and s.address == sub_info[v] then sub_match = true; return false end if s.name == "oixCloud - smart" and s.address ~= sub_info[v] then sub_convert = true; sid = s['.name']; return false end end) if sub_match then break end if sub_convert then uci:set("openclash", sid, "address", sub_info[v]) elseif sub_info[v] then sid = uci:add("openclash", "config_subscribe") uci:set("openclash", sid, "name", "oixCloud - smart") uci:set("openclash", sid, "address", sub_info[v]) end uci:commit("openclash") 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 end return true end return false 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 write_padded('{"stage":"saving_token","text":"' .. luci.i18n.translate("Saving token...") .. '"}') token = input_token if fetch_oix_sub(token) then uci:set("openclash", "config", "oix_token", input_token) uci:commit("openclash") write_padded('{"stage":"done","result":200}') else 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) end if info and info.ret == 200 then if token and token ~= "" then oix_logout(token) end token = info.data.token uci:set("openclash", "config", "oix_token", token) 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") fs.unlink("/tmp/oix_checkin") fs.unlink("/tmp/oix_info") if info and info.msg then result = info.msg else result = luci.i18n.translate("login failed") end 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 = luci.i18n.translate("email or passwd is wrong") write_padded('{"stage":"error","result":' .. json.stringify(result) .. '}') end end end function oix_logout(oldtoken) local info, result, token local is_token_login = false if not oldtoken then token = fs.uci_get_config("config", "oix_token") is_token_login = not fs.uci_get_config("config", "oix_email") else token = oldtoken end if token then if is_token_login then uci:delete("openclash", "config", "oix_token") uci:delete("openclash", "config", "oix_checkin") uci:delete("openclash", "config", "oix_checkin_interval") 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") result = 200 else info = SYS.exec(string.format("curl -sL -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -X POST https://oix-api.dler.io/api/v1/logout", token)) if info then info = json.parse(info) end if info and info.ret == 200 then uci:delete("openclash", "config", "oix_token") if not oldtoken then uci:delete("openclash", "config", "oix_email") uci:delete("openclash", "config", "oix_passwd") uci:delete("openclash", "config", "oix_checkin") uci:delete("openclash", "config", "oix_checkin_interval") 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") fs.unlink("/tmp/oix_info") result = info.ret else if info and info.msg then result = info.msg else result = "logout failed" end end end else result = "logout failed" end if not oldtoken then HTTP.prepare_content("application/json") HTTP.write_json({result = result}) end end function oix_info() local info, path, get_info local result = "error" local token = fs.uci_get_config("config", "oix_token") path = "/tmp/oix_info" if token then get_info = string.format("curl -sL -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -X POST https://oix-api.dler.io/api/v1/information -o %s", token, path) if not fs.access(path) then SYS.exec(get_info) else if fs.readfile(path) == "" or not fs.readfile(path) then SYS.exec(get_info) else if (os.time() - fs.mtime(path) > 900) then SYS.exec(get_info) end end end info = fs.readfile(path) if info then info = json.parse(info) end if info and info.ret == 200 and info.data then result = info.data elseif info and info.msg then fs.writefile(path, json.stringify(info)) else fs.unlink(path) end end HTTP.prepare_content("application/json") HTTP.write_json({result = result}) end function oix_checkin() local info, result local path = "/tmp/oix_checkin" local token = fs.uci_get_config("config", "oix_token") local multiple = fs.uci_get_config("config", "oix_checkin_multiple") or 1 if token then info = SYS.exec(string.format("curl -sL -H 'Content-Type: application/json' -H 'Authorization: Bearer %s' -d '{\"multiple\":\"%s\"}' -X POST https://oix-api.dler.io/api/v1/checkin", token, multiple)) if info then info = json.parse(info) end if info and info.ret == 200 then fs.unlink("/tmp/oix_info") fs.writefile(path, info) SYS.exec(string.format("echo -e %s [Info] oixCloud Checkin Successful, Result:【%s】 >> /tmp/openclash.log", os.date("%Y-%m-%d %H:%M:%S"), info.data.checkin)) result = info else if info and info.msg then SYS.exec(string.format("echo -e %s [Info] oixCloud Checkin Failed, Result:【%s】 >> /tmp/openclash.log", os.date("%Y-%m-%d %H:%M:%S"), info.msg)) else SYS.exec(string.format("echo -e %s [Info] oixCloud Checkin Failed! Please Check And Try Again... >> /tmp/openclash.log",os.date("%Y-%m-%d %H:%M:%S"))) end result = info end else result = "error" end HTTP.prepare_content("application/json") HTTP.write_json({result = result}) end