mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 19:05:11 +08:00
5939 lines
180 KiB
Lua
5939 lines
180 KiB
Lua
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/urandom 2>/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
|