mirror of
https://github.com/caiwx86/small-packages.git
synced 2026-11-04 00:13:56 +08:00
update 2026-05-10 09:48:30
This commit is contained in:
1 parent
95ac96413e
commit
0b362674f1
6244 files changed
+2992248
No files matched your search
@@ -0,0 +1,910 @@
|
||||
-- Copyright (C) 2022-2025 xiaorouji
|
||||
-- Copyright (C) 2026 Openwrt-Passwall Organization
|
||||
|
||||
module("luci.controller.passwall2", package.seeall)
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname -- not available
|
||||
local uci = api.uci -- in funtion index()
|
||||
local http = require "luci.http"
|
||||
local util = require "luci.util"
|
||||
local i18n = require "luci.i18n"
|
||||
local fs = api.fs
|
||||
local jsonStringify = luci.jsonc.stringify
|
||||
local jsonParse = luci.jsonc.parse
|
||||
|
||||
function index()
|
||||
if not nixio.fs.access("/etc/config/passwall2") then
|
||||
if nixio.fs.access("/usr/share/passwall2/0_default_config") then
|
||||
luci.sys.call('cp -f /usr/share/passwall2/0_default_config /etc/config/passwall2')
|
||||
else return end
|
||||
end
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname -- global definitions not available
|
||||
local uci = api.uci -- in function index()
|
||||
entry({"admin", "services", appname}).dependent = true
|
||||
entry({"admin", "services", appname, "reset_config"}, call("reset_config")).leaf = true
|
||||
entry({"admin", "services", appname, "show"}, call("show_menu")).leaf = true
|
||||
entry({"admin", "services", appname, "hide"}, call("hide_menu")).leaf = true
|
||||
local e
|
||||
if uci:get(appname, "@global[0]", "hide_from_luci") ~= "1" then
|
||||
e = entry({"admin", "services", appname}, alias("admin", "services", appname, "settings"), _("PassWall 2"), 0)
|
||||
else
|
||||
e = entry({"admin", "services", appname}, alias("admin", "services", appname, "settings"), nil, 0)
|
||||
end
|
||||
e.dependent = true
|
||||
e.acl_depends = { "luci-app-passwall2" }
|
||||
--[[ Client ]]
|
||||
entry({"admin", "services", appname, "settings"}, cbi(appname .. "/client/global"), _("Basic Settings"), 1).dependent = true
|
||||
entry({"admin", "services", appname, "node_list"}, cbi(appname .. "/client/node_list"), _("Node List"), 2).dependent = true
|
||||
entry({"admin", "services", appname, "node_subscribe"}, cbi(appname .. "/client/node_subscribe"), _("Node Subscribe"), 3).dependent = true
|
||||
entry({"admin", "services", appname, "other"}, cbi(appname .. "/client/other", {autoapply = true}), _("Other Settings"), 92).leaf = true
|
||||
if nixio.fs.access("/usr/sbin/haproxy") then
|
||||
entry({"admin", "services", appname, "haproxy"}, cbi(appname .. "/client/haproxy"), _("Load Balancing"), 93).leaf = true
|
||||
end
|
||||
entry({"admin", "services", appname, "app_update"}, cbi(appname .. "/client/app_update"), _("App Update"), 95).leaf = true
|
||||
entry({"admin", "services", appname, "rule"}, cbi(appname .. "/client/rule"), _("Rule Manage"), 96).leaf = true
|
||||
if api.finded_com("geoview") then
|
||||
entry({"admin", "services", appname, "geoview"}, form(appname .. "/client/geoview"), _("Geo View"), 97).leaf = true
|
||||
end
|
||||
entry({"admin", "services", appname, "node_subscribe_config"}, cbi(appname .. "/client/node_subscribe_config")).leaf = true
|
||||
entry({"admin", "services", appname, "node_config"}, cbi(appname .. "/client/node_config")).leaf = true
|
||||
entry({"admin", "services", appname, "shunt_rules"}, cbi(appname .. "/client/shunt_rules")).leaf = true
|
||||
entry({"admin", "services", appname, "socks_config"}, cbi(appname .. "/client/socks_config")).leaf = true
|
||||
entry({"admin", "services", appname, "acl"}, cbi(appname .. "/client/acl"), _("Access control"), 98).leaf = true
|
||||
entry({"admin", "services", appname, "acl_config"}, cbi(appname .. "/client/acl_config")).leaf = true
|
||||
entry({"admin", "services", appname, "log"}, form(appname .. "/client/log"), _("Watch Logs"), 999).leaf = true
|
||||
|
||||
--[[ Server ]]
|
||||
entry({"admin", "services", appname, "server"}, cbi(appname .. "/server/index"), _("Server-Side"), 99).leaf = true
|
||||
entry({"admin", "services", appname, "server_user"}, cbi(appname .. "/server/user")).leaf = true
|
||||
|
||||
--[[ API ]]
|
||||
entry({"admin", "services", appname, "server_user_status"}, call("server_user_status")).leaf = true
|
||||
entry({"admin", "services", appname, "server_user_log"}, call("server_user_log")).leaf = true
|
||||
entry({"admin", "services", appname, "server_get_log"}, call("server_get_log")).leaf = true
|
||||
entry({"admin", "services", appname, "server_clear_log"}, call("server_clear_log")).leaf = true
|
||||
entry({"admin", "services", appname, "link_add_node"}, call("link_add_node")).leaf = true
|
||||
entry({"admin", "services", appname, "socks_autoswitch_add_node"}, call("socks_autoswitch_add_node")).leaf = true
|
||||
entry({"admin", "services", appname, "socks_autoswitch_remove_node"}, call("socks_autoswitch_remove_node")).leaf = true
|
||||
entry({"admin", "services", appname, "gen_client_config"}, call("gen_client_config")).leaf = true
|
||||
entry({"admin", "services", appname, "get_now_use_node"}, call("get_now_use_node")).leaf = true
|
||||
entry({"admin", "services", appname, "get_redir_log"}, call("get_redir_log")).leaf = true
|
||||
entry({"admin", "services", appname, "get_socks_log"}, call("get_socks_log")).leaf = true
|
||||
entry({"admin", "services", appname, "get_log"}, call("get_log")).leaf = true
|
||||
entry({"admin", "services", appname, "clear_log"}, call("clear_log")).leaf = true
|
||||
entry({"admin", "services", appname, "index_status"}, call("index_status")).leaf = true
|
||||
entry({"admin", "services", appname, "haproxy_status"}, call("haproxy_status")).leaf = true
|
||||
entry({"admin", "services", appname, "socks_status"}, call("socks_status")).leaf = true
|
||||
entry({"admin", "services", appname, "connect_status"}, call("connect_status")).leaf = true
|
||||
entry({"admin", "services", appname, "ping_node"}, call("ping_node")).leaf = true
|
||||
entry({"admin", "services", appname, "urltest_node"}, call("urltest_node")).leaf = true
|
||||
entry({"admin", "services", appname, "add_node"}, call("add_node")).leaf = true
|
||||
entry({"admin", "services", appname, "update_node"}, call("update_node")).leaf = true
|
||||
entry({"admin", "services", appname, "set_node"}, call("set_node")).leaf = true
|
||||
entry({"admin", "services", appname, "copy_node"}, call("copy_node")).leaf = true
|
||||
entry({"admin", "services", appname, "clear_all_nodes"}, call("clear_all_nodes")).leaf = true
|
||||
entry({"admin", "services", appname, "delete_select_nodes"}, call("delete_select_nodes")).leaf = true
|
||||
entry({"admin", "services", appname, "reassign_group"}, call("reassign_group")).leaf = true
|
||||
entry({"admin", "services", appname, "get_node"}, call("get_node")).leaf = true
|
||||
entry({"admin", "services", appname, "save_node_order"}, call("save_node_order")).leaf = true
|
||||
entry({"admin", "services", appname, "save_node_list_opt"}, call("save_node_list_opt")).leaf = true
|
||||
entry({"admin", "services", appname, "update_rules"}, call("update_rules")).leaf = true
|
||||
entry({"admin", "services", appname, "rollback_rules"}, call("rollback_rules")).leaf = true
|
||||
entry({"admin", "services", appname, "subscribe_del_node"}, call("subscribe_del_node")).leaf = true
|
||||
entry({"admin", "services", appname, "subscribe_del_all"}, call("subscribe_del_all")).leaf = true
|
||||
entry({"admin", "services", appname, "subscribe_manual"}, call("subscribe_manual")).leaf = true
|
||||
entry({"admin", "services", appname, "subscribe_manual_all"}, call("subscribe_manual_all")).leaf = true
|
||||
entry({"admin", "services", appname, "flush_set"}, call("flush_set")).leaf = true
|
||||
|
||||
--[[Components update]]
|
||||
entry({"admin", "services", appname, "check_passwall2"}, call("app_check")).leaf = true
|
||||
local coms = require "luci.passwall2.com"
|
||||
local com
|
||||
for com, _ in pairs(coms) do
|
||||
entry({"admin", "services", appname, "check_" .. com}, call("com_check", com)).leaf = true
|
||||
entry({"admin", "services", appname, "update_" .. com}, call("com_update", com)).leaf = true
|
||||
end
|
||||
|
||||
--[[Backup]]
|
||||
entry({"admin", "services", appname, "create_backup"}, call("create_backup")).leaf = true
|
||||
entry({"admin", "services", appname, "restore_backup"}, call("restore_backup")).leaf = true
|
||||
|
||||
--[[geoview]]
|
||||
entry({"admin", "services", appname, "geo_view"}, call("geo_view")).leaf = true
|
||||
end
|
||||
|
||||
local function http_write_json(content)
|
||||
http.prepare_content("application/json")
|
||||
http.write(jsonStringify(content or {code = 1}))
|
||||
end
|
||||
|
||||
local function http_write_json_ok(data)
|
||||
http.prepare_content("application/json")
|
||||
http.write(jsonStringify({code = 1, data = data}))
|
||||
end
|
||||
|
||||
local function http_write_json_error(data)
|
||||
http.prepare_content("application/json")
|
||||
http.write(jsonStringify({code = 0, data = data}))
|
||||
end
|
||||
|
||||
function reset_config()
|
||||
luci.sys.call('/etc/init.d/passwall2 stop')
|
||||
luci.sys.call('[ -f "/usr/share/passwall2/0_default_config" ] && cp -f /usr/share/passwall2/0_default_config /etc/config/passwall2')
|
||||
http.redirect(api.url())
|
||||
end
|
||||
|
||||
function show_menu()
|
||||
api.sh_uci_del(appname, "@global[0]", "hide_from_luci", true)
|
||||
luci.sys.call("rm -rf /tmp/luci-*")
|
||||
luci.sys.call("/etc/init.d/rpcd restart >/dev/null")
|
||||
http.redirect(api.url())
|
||||
end
|
||||
|
||||
function hide_menu()
|
||||
api.sh_uci_set(appname, "@global[0]", "hide_from_luci", "1", true)
|
||||
luci.sys.call("rm -rf /tmp/luci-*")
|
||||
luci.sys.call("/etc/init.d/rpcd restart >/dev/null")
|
||||
http.redirect(luci.dispatcher.build_url("admin", "status", "overview"))
|
||||
end
|
||||
|
||||
function link_add_node()
|
||||
-- Fragmented reception to overcome uhttpd limitations
|
||||
local tmp_file = "/tmp/links.conf"
|
||||
local chunk = http.formvalue("chunk")
|
||||
local chunk_index = tonumber(http.formvalue("chunk_index"))
|
||||
local total_chunks = tonumber(http.formvalue("total_chunks"))
|
||||
local group = http.formvalue("group") or "default"
|
||||
|
||||
if chunk and chunk_index ~= nil and total_chunks ~= nil then
|
||||
-- Assemble the files in order
|
||||
local mode = "a"
|
||||
if chunk_index == 0 then
|
||||
mode = "w"
|
||||
end
|
||||
local f = io.open(tmp_file, mode)
|
||||
if f then
|
||||
f:write(chunk)
|
||||
f:close()
|
||||
end
|
||||
-- If it's the last piece, then it will be executed.
|
||||
if chunk_index + 1 == total_chunks then
|
||||
luci.sys.call("lua /usr/share/passwall2/subscribe.lua add " .. group)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function socks_autoswitch_add_node()
|
||||
local id = http.formvalue("id")
|
||||
local key = http.formvalue("key")
|
||||
if id and id ~= "" and key and key ~= "" then
|
||||
uci:set(appname, id, "enable_autoswitch", "1")
|
||||
local new_list = uci:get(appname, id, "autoswitch_backup_node") or {}
|
||||
for i = #new_list, 1, -1 do
|
||||
if (uci:get(appname, new_list[i], "remarks") or ""):find(key) then
|
||||
table.remove(new_list, i)
|
||||
end
|
||||
end
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
if e.node_type == "normal" and e["remark"]:find(key) then
|
||||
table.insert(new_list, e.id)
|
||||
end
|
||||
end
|
||||
uci:set_list(appname, id, "autoswitch_backup_node", new_list)
|
||||
api.uci_save(uci, appname)
|
||||
end
|
||||
http.redirect(api.url("socks_config", id))
|
||||
end
|
||||
|
||||
function socks_autoswitch_remove_node()
|
||||
local id = http.formvalue("id")
|
||||
local key = http.formvalue("key")
|
||||
if id and id ~= "" and key and key ~= "" then
|
||||
uci:set(appname, id, "enable_autoswitch", "1")
|
||||
local new_list = uci:get(appname, id, "autoswitch_backup_node") or {}
|
||||
for i = #new_list, 1, -1 do
|
||||
if (uci:get(appname, new_list[i], "remarks") or ""):find(key) then
|
||||
table.remove(new_list, i)
|
||||
end
|
||||
end
|
||||
uci:set_list(appname, id, "autoswitch_backup_node", new_list)
|
||||
api.uci_save(uci, appname)
|
||||
end
|
||||
http.redirect(api.url("socks_config", id))
|
||||
end
|
||||
|
||||
function gen_client_config()
|
||||
local id = http.formvalue("id")
|
||||
local config_file = api.TMP_PATH .. "/config_" .. id
|
||||
luci.sys.call(string.format("/usr/share/passwall2/app.sh run_socks flag=config_%s node=%s bind=127.0.0.1 socks_port=1080 config_file=%s no_run=1", id, id, config_file))
|
||||
if nixio.fs.access(config_file) then
|
||||
http.prepare_content("application/json")
|
||||
http.write(luci.sys.exec("cat " .. config_file))
|
||||
luci.sys.call("rm -f " .. config_file)
|
||||
else
|
||||
http.redirect(api.url("node_list"))
|
||||
end
|
||||
end
|
||||
|
||||
function get_now_use_node()
|
||||
local e = {}
|
||||
local node = api.get_cache_var("ACL_GLOBAL_node")
|
||||
if node then
|
||||
e["global"] = node
|
||||
end
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function get_redir_log()
|
||||
local id = http.formvalue("id")
|
||||
local name = http.formvalue("name")
|
||||
local file_path = "/tmp/etc/passwall2/acl/" .. id .. "/" .. name .. ".log"
|
||||
if nixio.fs.access(file_path) then
|
||||
local content = luci.sys.exec("tail -n 19999 '" .. file_path .. "'")
|
||||
content = content:gsub("\n", "<br />")
|
||||
http.write(content)
|
||||
else
|
||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
||||
end
|
||||
end
|
||||
|
||||
function get_socks_log()
|
||||
local name = http.formvalue("name")
|
||||
local path = "/tmp/etc/passwall2/SOCKS_" .. name .. ".log"
|
||||
if nixio.fs.access(path) then
|
||||
local content = luci.sys.exec("tail -n 5000 ".. path)
|
||||
content = content:gsub("\n", "<br />")
|
||||
http.write(content)
|
||||
else
|
||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
||||
end
|
||||
end
|
||||
|
||||
function get_log()
|
||||
-- luci.sys.exec("[ -f /tmp/log/passwall2.log ] && sed '1!G;h;$!d' /tmp/log/passwall2.log > /tmp/log/passwall2_show.log")
|
||||
http.write(luci.sys.exec("[ -f '/tmp/log/passwall2.log' ] && cat /tmp/log/passwall2.log"))
|
||||
end
|
||||
|
||||
function clear_log()
|
||||
luci.sys.call("echo '' > /tmp/log/passwall2.log")
|
||||
end
|
||||
|
||||
function index_status()
|
||||
local e = {}
|
||||
e["global_status"] = luci.sys.call("/bin/busybox top -bn1 | grep -v 'grep' | grep '/tmp/etc/passwall2/bin/' | grep 'default' | grep 'global' >/dev/null") == 0
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function haproxy_status()
|
||||
local e = {}
|
||||
e["status"] = luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v grep | grep '%s/bin/' | grep haproxy >/dev/null", appname)) == 0
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function socks_status()
|
||||
local e = {}
|
||||
local index = http.formvalue("index")
|
||||
local id = http.formvalue("id")
|
||||
e.index = index
|
||||
e.socks_status = luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v -E 'grep|acl/|acl_' | grep '%s/bin/' | grep '%s' | grep 'SOCKS_' > /dev/null", appname, id)) == 0
|
||||
local use_http = uci:get(appname, id, "http_port") or 0
|
||||
e.use_http = 0
|
||||
if tonumber(use_http) > 0 then
|
||||
e.use_http = 1
|
||||
e.http_status = luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v -E 'grep|acl/|acl_' | grep '%s/bin/' | grep '%s' | grep -E 'HTTP_|HTTP2SOCKS' > /dev/null", appname, id)) == 0
|
||||
end
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function connect_status()
|
||||
local e = {}
|
||||
e.use_time = ""
|
||||
local url = http.formvalue("url")
|
||||
local result = luci.sys.exec('curl --connect-timeout 3 -o /dev/null -I -sk -w "%{http_code}:%{time_appconnect}" ' .. url)
|
||||
local code = tonumber(luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $1}'") or "0")
|
||||
if code ~= 0 then
|
||||
local use_time = luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $2}'")
|
||||
if use_time:find("%.") then
|
||||
e.use_time = string.format("%.2f", use_time * 1000)
|
||||
else
|
||||
e.use_time = string.format("%.2f", use_time / 1000)
|
||||
end
|
||||
e.ping_type = "curl"
|
||||
end
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function ping_node()
|
||||
local index = http.formvalue("index")
|
||||
local address = http.formvalue("address")
|
||||
local port = http.formvalue("port")
|
||||
local type = http.formvalue("type") or "icmp"
|
||||
local e = {}
|
||||
e.index = index
|
||||
if type == "tcping" and luci.sys.exec("echo -n $(command -v tcping)") ~= "" then
|
||||
if api.is_ipv6(address) then
|
||||
address = api.get_ipv6_only(address)
|
||||
end
|
||||
e.ping = luci.sys.exec(string.format("echo -n $(tcping -q -c 1 -i 1 -t 2 -p %s %s 2>&1 | grep -o 'time=[0-9]*' | awk -F '=' '{print $2}') 2>/dev/null", port, address))
|
||||
else
|
||||
e.ping = luci.sys.exec("echo -n $(ping -c 1 -W 1 %q 2>&1 | grep -o 'time=[0-9]*' | awk -F '=' '{print $2}') 2>/dev/null" % address)
|
||||
end
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function urltest_node()
|
||||
local index = http.formvalue("index")
|
||||
local id = http.formvalue("id")
|
||||
local e = {}
|
||||
e.index = index
|
||||
local result = luci.sys.exec(string.format("/usr/share/passwall2/test.sh url_test_node %s %s", id, "urltest_node"))
|
||||
local code = tonumber(luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $1}'") or "0")
|
||||
if code ~= 0 then
|
||||
local use_time = luci.sys.exec("echo -n '" .. result .. "' | awk -F ':' '{print $2}'")
|
||||
if use_time:find("%.") then
|
||||
e.use_time = string.format("%.2f", use_time * 1000)
|
||||
else
|
||||
e.use_time = string.format("%.2f", use_time / 1000)
|
||||
end
|
||||
end
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function add_node()
|
||||
local redirect = http.formvalue("redirect")
|
||||
|
||||
local uuid = api.gen_short_uuid()
|
||||
uci:section(appname, "nodes", uuid)
|
||||
|
||||
local group = http.formvalue("group")
|
||||
if group and group ~= "default" then
|
||||
uci:set(appname, uuid, "group", group)
|
||||
end
|
||||
|
||||
uci:set(appname, uuid, "type", "Xray")
|
||||
|
||||
if redirect == "1" then
|
||||
api.uci_save(uci, appname)
|
||||
http.redirect(api.url("node_config", uuid))
|
||||
else
|
||||
api.uci_save(uci, appname, true, true)
|
||||
http_write_json({result = uuid})
|
||||
end
|
||||
end
|
||||
|
||||
function update_node()
|
||||
local id = http.formvalue("id") -- Node id
|
||||
local data = http.formvalue("data") -- json new Data
|
||||
if id and data then
|
||||
local data_t = jsonParse(data) or {}
|
||||
if next(data_t) then
|
||||
for k, v in pairs(data_t) do
|
||||
uci:set(appname, id, k, v)
|
||||
end
|
||||
api.uci_save(uci, appname)
|
||||
http_write_json_ok()
|
||||
return
|
||||
end
|
||||
end
|
||||
http_write_json_error()
|
||||
end
|
||||
|
||||
function set_node()
|
||||
local type = http.formvalue("type")
|
||||
local config = http.formvalue("config")
|
||||
local section = http.formvalue("section")
|
||||
uci:set(appname, type, config, section)
|
||||
api.uci_save(uci, appname, true, true)
|
||||
http.redirect(api.url("log"))
|
||||
end
|
||||
|
||||
function copy_node()
|
||||
local section = http.formvalue("section")
|
||||
local uuid = api.gen_short_uuid()
|
||||
uci:section(appname, "nodes", uuid)
|
||||
for k, v in pairs(uci:get_all(appname, section)) do
|
||||
if not k:match("^%.") and k ~= "group" then
|
||||
if k == "remarks" then v = (v or "") .. "(1)" end
|
||||
uci:set(appname, uuid, k, v)
|
||||
end
|
||||
end
|
||||
uci:set(appname, uuid, "add_mode", 1)
|
||||
api.uci_save(uci, appname)
|
||||
http.redirect(api.url("node_config", uuid))
|
||||
end
|
||||
|
||||
function clear_all_nodes()
|
||||
uci:set(appname, '@global[0]', "enabled", "0")
|
||||
uci:set(appname, '@global[0]', "socks_enabled", "0")
|
||||
uci:set(appname, '@haproxy_config[0]', "balancing_enable", "0")
|
||||
uci:delete(appname, '@global[0]', "node")
|
||||
uci:foreach(appname, "socks", function(t)
|
||||
uci:delete(appname, t[".name"])
|
||||
uci:set_list(appname, t[".name"], "autoswitch_backup_node", {})
|
||||
end)
|
||||
uci:foreach(appname, "haproxy_config", function(t)
|
||||
uci:delete(appname, t[".name"])
|
||||
end)
|
||||
uci:foreach(appname, "acl_rule", function(t)
|
||||
uci:delete(appname, t[".name"], "node")
|
||||
end)
|
||||
uci:foreach(appname, "nodes", function(node)
|
||||
uci:delete(appname, node['.name'])
|
||||
end)
|
||||
uci:foreach(appname, "subscribe_list", function(t)
|
||||
uci:delete(appname, t[".name"], "md5")
|
||||
uci:delete(appname, t[".name"], "chain_proxy")
|
||||
uci:delete(appname, t[".name"], "preproxy_node")
|
||||
uci:delete(appname, t[".name"], "to_node")
|
||||
end)
|
||||
|
||||
api.uci_save(uci, appname, true, true)
|
||||
end
|
||||
|
||||
function delete_select_nodes()
|
||||
local ids = http.formvalue("ids")
|
||||
local redirect = http.formvalue("redirect")
|
||||
string.gsub(ids, '[^' .. "," .. ']+', function(w)
|
||||
if (uci:get(appname, "@global[0]", "node") or "") == w then
|
||||
uci:delete(appname, '@global[0]', "node")
|
||||
end
|
||||
uci:foreach(appname, "socks", function(t)
|
||||
if t["node"] == w then
|
||||
uci:delete(appname, t[".name"])
|
||||
end
|
||||
local auto_switch_node_list = uci:get(appname, t[".name"], "autoswitch_backup_node") or {}
|
||||
for i = #auto_switch_node_list, 1, -1 do
|
||||
if w == auto_switch_node_list[i] then
|
||||
table.remove(auto_switch_node_list, i)
|
||||
end
|
||||
end
|
||||
uci:set_list(appname, t[".name"], "autoswitch_backup_node", auto_switch_node_list)
|
||||
end)
|
||||
uci:foreach(appname, "haproxy_config", function(t)
|
||||
if t["lbss"] == w then
|
||||
uci:delete(appname, t[".name"])
|
||||
end
|
||||
end)
|
||||
uci:foreach(appname, "acl_rule", function(t)
|
||||
if t["node"] == w then
|
||||
uci:delete(appname, t[".name"], "node")
|
||||
end
|
||||
end)
|
||||
uci:foreach(appname, "nodes", function(t)
|
||||
if t["preproxy_node"] == w then
|
||||
uci:delete(appname, t[".name"], "preproxy_node")
|
||||
uci:delete(appname, t[".name"], "chain_proxy")
|
||||
end
|
||||
if t["to_node"] == w then
|
||||
uci:delete(appname, t[".name"], "to_node")
|
||||
uci:delete(appname, t[".name"], "chain_proxy")
|
||||
end
|
||||
local list_name = t["urltest_node"] and "urltest_node" or (t["balancing_node"] and "balancing_node")
|
||||
if list_name then
|
||||
local nodes = uci:get_list(appname, t[".name"], list_name)
|
||||
if nodes then
|
||||
local changed = false
|
||||
local new_nodes = {}
|
||||
for _, node in ipairs(nodes) do
|
||||
if node ~= w then
|
||||
table.insert(new_nodes, node)
|
||||
else
|
||||
changed = true
|
||||
end
|
||||
end
|
||||
if changed then
|
||||
uci:set_list(appname, t[".name"], list_name, new_nodes)
|
||||
end
|
||||
end
|
||||
end
|
||||
if t["fallback_node"] == w then
|
||||
uci:delete(appname, t[".name"], "fallback_node")
|
||||
end
|
||||
end)
|
||||
uci:foreach(appname, "subscribe_list", function(t)
|
||||
if t["preproxy_node"] == w then
|
||||
uci:delete(appname, t[".name"], "preproxy_node")
|
||||
uci:delete(appname, t[".name"], "chain_proxy")
|
||||
end
|
||||
if t["to_node"] == w then
|
||||
uci:delete(appname, t[".name"], "to_node")
|
||||
uci:delete(appname, t[".name"], "chain_proxy")
|
||||
end
|
||||
end)
|
||||
if (uci:get(appname, w, "add_mode") or "0") == "2" then
|
||||
local group = uci:get(appname, w, "group") or ""
|
||||
if group ~= "" then
|
||||
uci:foreach(appname, "subscribe_list", function(t)
|
||||
if t["remark"] == group then
|
||||
uci:delete(appname, t[".name"], "md5")
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
uci:delete(appname, w)
|
||||
end)
|
||||
if redirect == "1" then
|
||||
api.uci_save(uci, appname)
|
||||
http.redirect(api.url("node_list"))
|
||||
else
|
||||
api.uci_save(uci, appname, true, true)
|
||||
end
|
||||
end
|
||||
|
||||
function get_node()
|
||||
local id = http.formvalue("id")
|
||||
local result = {}
|
||||
local show_node_info = api.uci_get_type("global_other", "show_node_info", "0")
|
||||
|
||||
local function add_is_ipv6_key(o)
|
||||
if o and o.address and show_node_info == "1" then
|
||||
local f = api.get_ipv6_full(o.address)
|
||||
if f ~= "" then
|
||||
o.ipv6 = true
|
||||
o.full_address = f
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if id then
|
||||
result = uci:get_all(appname, id)
|
||||
add_is_ipv6_key(result)
|
||||
else
|
||||
local default_nodes = {}
|
||||
local other_nodes = {}
|
||||
uci:foreach(appname, "nodes", function(t)
|
||||
add_is_ipv6_key(t)
|
||||
if not t.group or t.group == "" then
|
||||
default_nodes[#default_nodes + 1] = t
|
||||
else
|
||||
other_nodes[#other_nodes + 1] = t
|
||||
end
|
||||
end)
|
||||
for i = 1, #default_nodes do result[#result + 1] = default_nodes[i] end
|
||||
for i = 1, #other_nodes do result[#result + 1] = other_nodes[i] end
|
||||
end
|
||||
http_write_json(result)
|
||||
end
|
||||
|
||||
function save_node_order()
|
||||
local ids = http.formvalue("ids") or ""
|
||||
local new_order = {}
|
||||
for id in ids:gmatch("([^,]+)") do
|
||||
new_order[#new_order + 1] = id
|
||||
end
|
||||
for idx, name in ipairs(new_order) do
|
||||
luci.sys.call(string.format("uci -q reorder %s.%s=%d", appname, name, idx - 1))
|
||||
end
|
||||
api.sh_uci_commit(appname)
|
||||
http_write_json({ status = "ok" })
|
||||
end
|
||||
|
||||
function reassign_group()
|
||||
local ids = http.formvalue("ids") or ""
|
||||
local group = http.formvalue("group") or "default"
|
||||
for id in ids:gmatch("([^,]+)") do
|
||||
if group ~="" and group ~= "default" then
|
||||
api.sh_uci_set(appname, id, "group", group)
|
||||
else
|
||||
api.sh_uci_del(appname, id, "group")
|
||||
end
|
||||
end
|
||||
api.sh_uci_commit(appname)
|
||||
http_write_json({ status = "ok" })
|
||||
end
|
||||
|
||||
function save_node_list_opt()
|
||||
local option = http.formvalue("option") or ""
|
||||
local value = http.formvalue("value") or ""
|
||||
if option ~= "" then
|
||||
api.sh_uci_set(appname, "@global_other[0]", option, value, true)
|
||||
end
|
||||
http_write_json({ status = "ok" })
|
||||
end
|
||||
|
||||
function update_rules()
|
||||
local update = http.formvalue("update") or ""
|
||||
if update == "" then
|
||||
http_write_json_error({ message = "missing update target" })
|
||||
return
|
||||
end
|
||||
luci.sys.call("lua /usr/share/passwall2/rule_update.lua log '" .. update .. "' > /dev/null 2>&1 &")
|
||||
http_write_json()
|
||||
end
|
||||
|
||||
function rollback_rules()
|
||||
local arg_type = http.formvalue("type")
|
||||
if arg_type ~= "geoip" and arg_type ~= "geosite" then
|
||||
http_write_json_error()
|
||||
return
|
||||
end
|
||||
local bak_dir = "/tmp/bak_v2ray/"
|
||||
local geo_dir = (uci:get(appname, "@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/")
|
||||
fs.move(bak_dir .. arg_type .. ".dat", geo_dir .. arg_type .. ".dat")
|
||||
fs.rmdir(bak_dir)
|
||||
http_write_json_ok()
|
||||
end
|
||||
|
||||
function server_user_status()
|
||||
local e = {}
|
||||
e.index = http.formvalue("index")
|
||||
e.status = luci.sys.call(string.format("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/bin/' | grep -i '%s' >/dev/null", appname .. "_server", http.formvalue("id"))) == 0
|
||||
http_write_json(e)
|
||||
end
|
||||
|
||||
function server_user_log()
|
||||
local id = http.formvalue("id")
|
||||
if nixio.fs.access("/tmp/etc/passwall2_server/" .. id .. ".log") then
|
||||
local content = luci.sys.exec("cat /tmp/etc/passwall2_server/" .. id .. ".log")
|
||||
content = content:gsub("\n", "<br />")
|
||||
http.write(content)
|
||||
else
|
||||
http.write(string.format("<script>alert('%s');window.close();</script>", i18n.translate("Not enabled log")))
|
||||
end
|
||||
end
|
||||
|
||||
function server_get_log()
|
||||
http.write(luci.sys.exec("[ -f '/tmp/log/passwall2_server.log' ] && cat /tmp/log/passwall2_server.log"))
|
||||
end
|
||||
|
||||
function server_clear_log()
|
||||
luci.sys.call("echo '' > /tmp/log/passwall2_server.log")
|
||||
end
|
||||
|
||||
function app_check()
|
||||
local json = api.to_check_self()
|
||||
http_write_json(json)
|
||||
end
|
||||
|
||||
function com_check(comname)
|
||||
local json = api.to_check("", comname)
|
||||
http_write_json(json)
|
||||
end
|
||||
|
||||
function com_update(comname)
|
||||
local json = nil
|
||||
local task = http.formvalue("task")
|
||||
if task == "extract" then
|
||||
json = api.to_extract(comname, http.formvalue("file"), http.formvalue("subfix"))
|
||||
elseif task == "move" then
|
||||
json = api.to_move(comname, http.formvalue("file"))
|
||||
else
|
||||
json = api.to_download(comname, http.formvalue("url"), http.formvalue("size"))
|
||||
end
|
||||
|
||||
http_write_json(json)
|
||||
end
|
||||
|
||||
local backup_files = {
|
||||
"/etc/config/passwall2",
|
||||
"/etc/config/passwall2_server",
|
||||
"/usr/share/passwall2/domains_excluded"
|
||||
}
|
||||
|
||||
function create_backup()
|
||||
local date = os.date("%y%m%d%H%M")
|
||||
local tar_file = "/tmp/passwall2-" .. date .. "-backup.tar.gz"
|
||||
fs.remove(tar_file)
|
||||
local cmd = "tar -czf " .. tar_file .. " " .. table.concat(backup_files, " ")
|
||||
api.sys.call(cmd)
|
||||
http.header("Content-Disposition", "attachment; filename=passwall2-" .. date .. "-backup.tar.gz")
|
||||
http.header("X-Backup-Filename", "passwall2-" .. date .. "-backup.tar.gz")
|
||||
http.prepare_content("application/octet-stream")
|
||||
http.write(fs.readfile(tar_file))
|
||||
fs.remove(tar_file)
|
||||
end
|
||||
|
||||
function restore_backup()
|
||||
local result = { status = "error", message = "unknown error" }
|
||||
local ok, err = pcall(function()
|
||||
local filename = http.formvalue("filename")
|
||||
local chunk = http.formvalue("chunk")
|
||||
local chunk_index = tonumber(http.formvalue("chunk_index") or "-1")
|
||||
local total_chunks = tonumber(http.formvalue("total_chunks") or "-1")
|
||||
if not filename then
|
||||
result = { status = "error", message = "Missing filename" }
|
||||
return
|
||||
end
|
||||
if not chunk then
|
||||
result = { status = "error", message = "Missing chunk data" }
|
||||
return
|
||||
end
|
||||
local file_path = "/tmp/" .. filename
|
||||
local decoded = nixio.bin.b64decode(chunk)
|
||||
if not decoded then
|
||||
result = { status = "error", message = "Base64 decode failed" }
|
||||
return
|
||||
end
|
||||
local fp = io.open(file_path, "a+")
|
||||
if not fp then
|
||||
result = { status = "error", message = "Failed to open file: " .. file_path }
|
||||
return
|
||||
end
|
||||
fp:write(decoded)
|
||||
fp:close()
|
||||
if chunk_index + 1 == total_chunks then
|
||||
api.sys.call("echo '' > /tmp/log/passwall2.log")
|
||||
api.log(0, string.format(" * PassWall2 %s", i18n.translate("Configuration file uploaded successfully…")))
|
||||
local temp_dir = '/tmp/passwall2_bak'
|
||||
api.sys.call("mkdir -p " .. temp_dir)
|
||||
if api.sys.call("tar -xzf " .. file_path .. " -C " .. temp_dir) == 0 then
|
||||
for _, backup_file in ipairs(backup_files) do
|
||||
local temp_file = temp_dir .. backup_file
|
||||
if fs.access(temp_file) then
|
||||
api.sys.call("cp -f " .. temp_file .. " " .. backup_file)
|
||||
end
|
||||
end
|
||||
api.log(0, string.format(" * PassWall2 %s", i18n.translate("Configuration restored successfully…")))
|
||||
api.log(0, string.format(" * PassWall2 %s", i18n.translate("Service restarting…")))
|
||||
luci.sys.call('/etc/init.d/passwall2 restart > /dev/null 2>&1 &')
|
||||
luci.sys.call('/etc/init.d/passwall2_server restart > /dev/null 2>&1 &')
|
||||
result = { status = "success", message = "Upload completed", path = file_path }
|
||||
else
|
||||
api.log(0, string.format(" * PassWall2 %s", i18n.translate("Configuration file decompression failed, please try again!")))
|
||||
result = { status = "error", message = "Decompression failed" }
|
||||
end
|
||||
api.sys.call("rm -rf " .. temp_dir)
|
||||
fs.remove(file_path)
|
||||
else
|
||||
result = { status = "success", message = "Chunk received" }
|
||||
end
|
||||
end)
|
||||
if not ok then
|
||||
result = { status = "error", message = tostring(err) }
|
||||
end
|
||||
http_write_json(result)
|
||||
end
|
||||
|
||||
function geo_view()
|
||||
local action = luci.http.formvalue("action")
|
||||
local value = luci.http.formvalue("value")
|
||||
if not value or value == "" then
|
||||
http.prepare_content("text/plain")
|
||||
http.write(i18n.translate("Please enter query content!"))
|
||||
return
|
||||
end
|
||||
local function get_rules(str, type)
|
||||
local rules_id = {}
|
||||
uci:foreach(appname, "shunt_rules", function(s)
|
||||
local list
|
||||
if type == "geoip" then list = s.ip_list else list = s.domain_list end
|
||||
for line in string.gmatch((list or ""), "[^\r\n]+") do
|
||||
if line ~= "" and not line:find("#") then
|
||||
local prefix, main = line:match("^(.-):(.*)")
|
||||
if not main then main = line end
|
||||
if type == "geoip" and (api.datatypes.ipaddr(str) or api.datatypes.ip6addr(str)) then
|
||||
if main:find(str, 1, true) then rules_id[#rules_id + 1] = s[".name"] end
|
||||
else
|
||||
if main == str then rules_id[#rules_id + 1] = s[".name"] end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
return rules_id
|
||||
end
|
||||
local geo_dir = (uci:get(appname, "@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"):match("^(.*)/")
|
||||
local geosite_path = geo_dir .. "/geosite.dat"
|
||||
local geoip_path = geo_dir .. "/geoip.dat"
|
||||
local geo_type, file_path, cmd
|
||||
local geo_string = ""
|
||||
local bin = api.finded_com("geoview")
|
||||
if action == "lookup" then
|
||||
if api.datatypes.ipaddr(value) or api.datatypes.ip6addr(value) then
|
||||
geo_type, file_path = "geoip", geoip_path
|
||||
else
|
||||
geo_type, file_path = "geosite", geosite_path
|
||||
end
|
||||
cmd = string.format("%q -type %q -action lookup -input %q -value %q -lowmem=true", bin, geo_type, file_path, value)
|
||||
geo_string = luci.sys.exec(cmd):lower()
|
||||
if geo_string ~= "" then
|
||||
local lines, rules, seen = {}, {}, {}
|
||||
for line in geo_string:gmatch("([^\n]+)") do
|
||||
lines[#lines + 1] = geo_type .. ":" .. line
|
||||
for _, r in ipairs(get_rules(line, geo_type) or {}) do
|
||||
if not seen[r] then seen[r] = true; rules[#rules + 1] = r end
|
||||
end
|
||||
end
|
||||
for _, r in ipairs(get_rules(value, geo_type) or {}) do
|
||||
if not seen[r] then seen[r] = true; rules[#rules + 1] = r end
|
||||
end
|
||||
geo_string = table.concat(lines, "\n")
|
||||
if #rules > 0 then
|
||||
geo_string = geo_string .. "\n--------------------\n"
|
||||
geo_string = geo_string .. i18n.translate("Rules containing this value:") .. "\n"
|
||||
geo_string = geo_string .. table.concat(rules, "\n")
|
||||
end
|
||||
end
|
||||
elseif action == "extract" then
|
||||
local prefix, list = value:match("^(geoip:)(.*)$")
|
||||
if not prefix then
|
||||
prefix, list = value:match("^(geosite:)(.*)$")
|
||||
end
|
||||
if prefix and list and list ~= "" then
|
||||
geo_type = prefix:sub(1, -2)
|
||||
file_path = (geo_type == "geoip") and geoip_path or geosite_path
|
||||
cmd = string.format("%q -type %q -action extract -input %q -list %q -lowmem=true", bin, geo_type, file_path, list)
|
||||
geo_string = luci.sys.exec(cmd)
|
||||
end
|
||||
end
|
||||
http.prepare_content("text/plain")
|
||||
if geo_string and geo_string ~="" then
|
||||
http.write(geo_string)
|
||||
else
|
||||
http.write(i18n.translate("No results were found!"))
|
||||
end
|
||||
end
|
||||
|
||||
function subscribe_del_node()
|
||||
local remark = http.formvalue("remark")
|
||||
if remark and remark ~= "" then
|
||||
luci.sys.call("lua /usr/share/" .. appname .. "/subscribe.lua truncate " .. luci.util.shellquote(remark) .. " > /dev/null 2>&1")
|
||||
end
|
||||
http.status(200, "OK")
|
||||
end
|
||||
|
||||
function subscribe_del_all()
|
||||
luci.sys.call("lua /usr/share/" .. appname .. "/subscribe.lua truncate > /dev/null 2>&1")
|
||||
http.status(200, "OK")
|
||||
end
|
||||
|
||||
function subscribe_manual()
|
||||
local section = http.formvalue("section") or ""
|
||||
local current_url = http.formvalue("url") or ""
|
||||
if section == "" or current_url == "" then
|
||||
http_write_json({ success = false, msg = "Missing section or URL, skip." })
|
||||
return
|
||||
end
|
||||
local uci_url = api.sh_uci_get(appname, section, "url")
|
||||
if not uci_url or uci_url == "" then
|
||||
http_write_json({ success = false, msg = i18n.translate("Please save and apply before manually subscribing.") })
|
||||
return
|
||||
end
|
||||
if uci_url ~= current_url then
|
||||
api.sh_uci_set(appname, section, "url", current_url, true)
|
||||
end
|
||||
luci.sys.call("lua /usr/share/" .. appname .. "/subscribe.lua start " .. section .. " manual >/dev/null 2>&1 &")
|
||||
http_write_json({ success = true, msg = "Subscribe triggered." })
|
||||
end
|
||||
|
||||
function subscribe_manual_all()
|
||||
local sections = http.formvalue("sections") or ""
|
||||
local urls = http.formvalue("urls") or ""
|
||||
if sections == "" or urls == "" then
|
||||
http_write_json({ success = false, msg = "Missing section or URL, skip." })
|
||||
return
|
||||
end
|
||||
local section_list = util.split(sections, ",")
|
||||
local url_list = util.split(urls, ",")
|
||||
-- Check if there are any unsaved configurations.
|
||||
for i, section in ipairs(section_list) do
|
||||
local uci_url = api.sh_uci_get(appname, section, "url")
|
||||
if not uci_url or uci_url == "" then
|
||||
http_write_json({ success = false, msg = i18n.translate("Please save and apply before manually subscribing.") })
|
||||
return
|
||||
end
|
||||
end
|
||||
-- Save URLs that have changed.
|
||||
for i, section in ipairs(section_list) do
|
||||
local current_url = url_list[i] or ""
|
||||
local uci_url = api.sh_uci_get(appname, section, "url")
|
||||
if current_url ~= "" and uci_url ~= current_url then
|
||||
api.sh_uci_set(appname, section, "url", current_url, true)
|
||||
end
|
||||
end
|
||||
luci.sys.call("lua /usr/share/" .. appname .. "/subscribe.lua start all manual >/dev/null 2>&1 &")
|
||||
http_write_json({ success = true, msg = "Subscribe triggered." })
|
||||
end
|
||||
|
||||
function flush_set()
|
||||
local redirect = http.formvalue("redirect") or "0"
|
||||
local reload = http.formvalue("reload") or "0"
|
||||
if reload == "1" then
|
||||
uci:set(appname, '@global[0]', "flush_set", "1")
|
||||
api.uci_save(uci, appname, true, true)
|
||||
else
|
||||
api.sh_uci_set(appname, "@global[0]", "flush_set", "1", true)
|
||||
end
|
||||
if redirect == "1" then
|
||||
http.redirect(api.url("log"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local sys = api.sys
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
s = m:section(TypedSection, "global", translate("ACLs"), "<font color='red'>" .. translate("ACLs is a tools which used to designate specific IP proxy mode.") .. "</font>")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Flag, "acl_enable", translate("Main switch"))
|
||||
o.rmempty = false
|
||||
o.default = false
|
||||
|
||||
-- [[ ACLs Settings ]]--
|
||||
s = m:section(TypedSection, "acl_rule")
|
||||
s.template = "cbi/tblsection"
|
||||
s.sortable = true
|
||||
s.anonymous = true
|
||||
s.addremove = true
|
||||
s.extedit = api.url("acl_config", "%s")
|
||||
function s.create(e, t)
|
||||
t = TypedSection.create(e, t)
|
||||
luci.http.redirect(e.extedit:format(t))
|
||||
end
|
||||
|
||||
---- Enable
|
||||
o = s:option(Flag, "enabled", translate("Enable"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
---- Remarks
|
||||
o = s:option(Value, "remarks", translate("Remarks"))
|
||||
o.rmempty = true
|
||||
|
||||
local mac_t = {}
|
||||
sys.net.mac_hints(function(e, t)
|
||||
mac_t[e] = {
|
||||
ip = t,
|
||||
mac = e
|
||||
}
|
||||
end)
|
||||
|
||||
o = s:option(DummyValue, "sources", translate("Source"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
local e = ''
|
||||
local v = Value.cfgvalue(t, n) or '-'
|
||||
string.gsub(v, '[^' .. " " .. ']+', function(w)
|
||||
local a = w
|
||||
if mac_t[w] then
|
||||
a = a .. ' (' .. mac_t[w].ip .. ')'
|
||||
end
|
||||
if #e > 0 then
|
||||
e = e .. "<br />"
|
||||
end
|
||||
e = e .. a
|
||||
end)
|
||||
return e
|
||||
end
|
||||
|
||||
i = s:option(DummyValue, "interface", translate("Source Interface"))
|
||||
i.cfgvalue = function(t, n)
|
||||
local v = Value.cfgvalue(t, n) or '-'
|
||||
return v
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,475 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(api.url("acl"))
|
||||
end
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
|
||||
|
||||
local sys = api.sys
|
||||
|
||||
local port_validate = function(self, value, t)
|
||||
return value:gsub("-", ":")
|
||||
end
|
||||
|
||||
local nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
nodes_table[#nodes_table + 1] = e
|
||||
end
|
||||
|
||||
local dynamicList_write = function(self, section, value)
|
||||
local t = {}
|
||||
local t2 = {}
|
||||
if type(value) == "table" then
|
||||
local x
|
||||
for _, x in ipairs(value) do
|
||||
if x and #x > 0 then
|
||||
if not t2[x] then
|
||||
t2[x] = x
|
||||
t[#t+1] = x
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
t = { value }
|
||||
end
|
||||
t = table.concat(t, " ")
|
||||
return DynamicList.write(self, section, t)
|
||||
end
|
||||
local doh_validate = function(self, value, t)
|
||||
if value ~= "" then
|
||||
local flag = 0
|
||||
local util = require "luci.util"
|
||||
local val = util.split(value, ",")
|
||||
local url = val[1]
|
||||
val[1] = nil
|
||||
for i = 1, #val do
|
||||
local v = val[i]
|
||||
if v then
|
||||
if not datatypes.ipmask4(v) then
|
||||
flag = 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if flag == 0 then
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil, translate("DoH request address") .. " " .. translate("Format must be:") .. " URL,IP"
|
||||
end
|
||||
-- [[ ACLs Settings ]]--
|
||||
s = m:section(NamedSection, arg[1], translate("ACLs"), translate("ACLs"))
|
||||
s.addremove = false
|
||||
s.dynamic = false
|
||||
|
||||
---- Enable
|
||||
o = s:option(Flag, "enabled", translate("Enable"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
---- Remarks
|
||||
o = s:option(Value, "remarks", translate("Remarks"))
|
||||
o.default = arg[1]
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "interface", translate("Source Interface"))
|
||||
o:value("", translate("All"))
|
||||
-- Populate with actual kernel network devices instead of UCI interface names,
|
||||
-- because the backend (nftables iifname / iptables -i) matches kernel device names.
|
||||
do
|
||||
local nfs = require "nixio.fs"
|
||||
local _cursor = require("luci.model.uci").cursor()
|
||||
local _sysnet = "/sys/class/net/"
|
||||
|
||||
-- Map UCI interface names to their device names and vice versa
|
||||
local _iface_to_dev = {}
|
||||
local _dev_to_ifaces = {}
|
||||
local _iface_proto = {}
|
||||
_cursor:foreach("network", "interface", function(sec)
|
||||
local name = sec[".name"]
|
||||
if name ~= "loopback" then
|
||||
_iface_proto[name] = sec.proto
|
||||
if sec.device then
|
||||
_iface_to_dev[name] = sec.device
|
||||
_dev_to_ifaces[sec.device] = _dev_to_ifaces[sec.device] or {}
|
||||
table.insert(_dev_to_ifaces[sec.device], name)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
-- Classify device type using sysfs attributes
|
||||
local function classify_sysfs(dev)
|
||||
if nfs.stat(_sysnet .. dev .. "/bridge", "type") == "dir" then
|
||||
return translate("Bridge")
|
||||
elseif nfs.stat(_sysnet .. dev .. "/wireless", "type") == "dir" then
|
||||
return translate("Wireless Adapter")
|
||||
elseif dev:match("^tun") or dev:match("^tap") or dev:match("^wg") or dev:match("^ppp") then
|
||||
return translate("Tunnel Interface")
|
||||
else
|
||||
return translate("Ethernet Adapter")
|
||||
end
|
||||
end
|
||||
|
||||
-- Classify offline UCI interfaces by config hints
|
||||
local function classify_uci(dev_name, proto)
|
||||
if dev_name and dev_name:match("^br%-") then
|
||||
return translate("Bridge")
|
||||
elseif proto == "wireguard" or proto == "pppoe" or proto == "pptp" or proto == "l2tp" then
|
||||
return translate("Tunnel Interface")
|
||||
else
|
||||
return translate("Interface")
|
||||
end
|
||||
end
|
||||
|
||||
local _seen = {}
|
||||
local _devices = {}
|
||||
|
||||
-- Active kernel devices from /sys/class/net/.
|
||||
-- Skip bridge member ports (/master) and DSA master devices (/dsa) because
|
||||
-- nftables iifname matches the parent bridge for routed traffic, not
|
||||
-- individual member ports. Also skip internal virtual devices.
|
||||
local _iter = nfs.dir(_sysnet)
|
||||
if _iter then
|
||||
for dev in _iter do
|
||||
if dev ~= "lo"
|
||||
and not dev:match("^veth")
|
||||
and not dev:match("^ifb")
|
||||
and not dev:match("^gre")
|
||||
and not dev:match("^sit")
|
||||
and not dev:match("^ip6tnl")
|
||||
and not dev:match("^erspan")
|
||||
and not nfs.stat(_sysnet .. dev .. "/master", "type")
|
||||
and not nfs.stat(_sysnet .. dev .. "/dsa", "type")
|
||||
then
|
||||
local dtype = classify_sysfs(dev)
|
||||
local label = dtype .. ': "' .. dev .. '"'
|
||||
if _dev_to_ifaces[dev] then
|
||||
label = label .. " (" .. table.concat(_dev_to_ifaces[dev], ", ") .. ")"
|
||||
end
|
||||
_devices[#_devices + 1] = { name = dev, label = label, sort = dtype .. ":" .. dev }
|
||||
_seen[dev] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- UCI interfaces whose device does not currently exist (down tunnels, VPNs, etc.).
|
||||
-- Stored by UCI name since the kernel device is not available yet.
|
||||
-- Dedup by device: if two interfaces share a device, only one is shown.
|
||||
for iface, dev in pairs(_iface_to_dev) do
|
||||
if not _seen[dev] then
|
||||
local dtype = classify_uci(dev, _iface_proto[iface])
|
||||
local label = dtype .. ': "' .. iface .. '"'
|
||||
-- Sort offline entries after active devices
|
||||
_devices[#_devices + 1] = { name = iface, label = label, sort = "zzz:" .. iface }
|
||||
_seen[dev] = true
|
||||
end
|
||||
end
|
||||
|
||||
table.sort(_devices, function(a, b) return a.sort < b.sort end)
|
||||
for _, d in ipairs(_devices) do
|
||||
o:value(d.name, d.label)
|
||||
end
|
||||
end
|
||||
|
||||
o.validate = function(self, value, section)
|
||||
if value == "" or value:match("^[a-zA-Z0-9][a-zA-Z0-9%.%_%-]*$") then
|
||||
return value
|
||||
end
|
||||
return nil, translate("Invalid interface name")
|
||||
end
|
||||
|
||||
local mac_t = {}
|
||||
sys.net.mac_hints(function(e, t)
|
||||
mac_t[#mac_t + 1] = {
|
||||
ip = t,
|
||||
mac = e
|
||||
}
|
||||
end)
|
||||
table.sort(mac_t, function(a,b)
|
||||
if #a.ip < #b.ip then
|
||||
return true
|
||||
elseif #a.ip == #b.ip then
|
||||
if a.ip < b.ip then
|
||||
return true
|
||||
else
|
||||
return #a.ip < #b.ip
|
||||
end
|
||||
end
|
||||
return false
|
||||
end)
|
||||
|
||||
---- Source
|
||||
sources = s:option(DynamicList, "sources", translate("Source"))
|
||||
sources.description = "<ul><li>" .. translate("Example:")
|
||||
.. "</li><li>" .. translate("MAC") .. ": 00:00:00:FF:FF:FF"
|
||||
.. "</li><li>" .. translate("IP") .. ": 192.168.1.100"
|
||||
.. "</li><li>" .. translate("IP CIDR") .. ": 192.168.1.0/24"
|
||||
.. "</li><li>" .. translate("IP range") .. ": 192.168.1.100-192.168.1.200"
|
||||
.. "</li><li>" .. translate("IPSet") .. ": ipset:lanlist"
|
||||
.. "</li></ul>"
|
||||
sources.cast = "string"
|
||||
for _, key in pairs(mac_t) do
|
||||
sources:value(key.mac, "%s (%s)" % {key.mac, key.ip})
|
||||
end
|
||||
|
||||
sources.cfgvalue = function(self, section)
|
||||
local value
|
||||
if self.tag_error[section] then
|
||||
value = self:formvalue(section)
|
||||
else
|
||||
value = self.map:get(section, self.option)
|
||||
if type(value) == "string" then
|
||||
local value2 = {}
|
||||
string.gsub(value, '[^' .. " " .. ']+', function(w) table.insert(value2, w) end)
|
||||
value = value2
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
sources.validate = function(self, value, t)
|
||||
local err = {}
|
||||
for _, v in ipairs(value) do
|
||||
local flag = false
|
||||
if v:find("ipset:") and v:find("ipset:") == 1 then
|
||||
local ipset = v:gsub("ipset:", "")
|
||||
if ipset and ipset ~= "" then
|
||||
flag = true
|
||||
end
|
||||
end
|
||||
|
||||
if flag == false and datatypes.macaddr(v) then
|
||||
flag = true
|
||||
end
|
||||
|
||||
if flag == false and datatypes.ip4addr(v) then
|
||||
flag = true
|
||||
end
|
||||
|
||||
if flag == false and api.iprange(v) then
|
||||
flag = true
|
||||
end
|
||||
|
||||
if flag == false then
|
||||
err[#err + 1] = v
|
||||
end
|
||||
end
|
||||
|
||||
if #err > 0 then
|
||||
self:add_error(t, "invalid", translate("Not true format, please re-enter!"))
|
||||
for _, v in ipairs(err) do
|
||||
self:add_error(t, "invalid", v)
|
||||
end
|
||||
end
|
||||
|
||||
return value
|
||||
end
|
||||
sources.write = dynamicList_write
|
||||
|
||||
---- TCP No Redir Ports
|
||||
local TCP_NO_REDIR_PORTS = m:get("@global_forwarding[0]", "tcp_no_redir_ports")
|
||||
o = s:option(Value, "tcp_no_redir_ports", translate("TCP No Redir Ports"))
|
||||
o:value("", translate("Use global config") .. "(" .. TCP_NO_REDIR_PORTS .. ")")
|
||||
o:value("disable", translate("No patterns are used"))
|
||||
o:value("1:65535", translate("All"))
|
||||
o.validate = port_validate
|
||||
|
||||
---- UDP No Redir Ports
|
||||
local UDP_NO_REDIR_PORTS = m:get("@global_forwarding[0]", "udp_no_redir_ports")
|
||||
o = s:option(Value, "udp_no_redir_ports", translate("UDP No Redir Ports"),
|
||||
"<font color='red'>" ..
|
||||
translate("If you don't want to let the device in the list to go proxy, please choose all.") ..
|
||||
"</font>")
|
||||
o:value("", translate("Use global config") .. "(" .. UDP_NO_REDIR_PORTS .. ")")
|
||||
o:value("disable", translate("No patterns are used"))
|
||||
o:value("1:65535", translate("All"))
|
||||
o.validate = port_validate
|
||||
|
||||
o = s:option(DummyValue, "_hide_node_option", "")
|
||||
o.template = "passwall2/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ tcp_no_redir_ports = "1:65535", udp_no_redir_ports = "1:65535" })
|
||||
if TCP_NO_REDIR_PORTS == "1:65535" and UDP_NO_REDIR_PORTS == "1:65535" then
|
||||
o:depends({ tcp_no_redir_ports = "", udp_no_redir_ports = "" })
|
||||
end
|
||||
|
||||
local GLOBAL_ENABLED = m:get("@global[0]", "enabled")
|
||||
local NODE = m:get("@global[0]", "node")
|
||||
o = s:option(ListValue, "node", "<a style='color: red'>" .. translate("Node") .. "</a>")
|
||||
if GLOBAL_ENABLED == "1" and NODE then
|
||||
o:value("", translate("Use global config") .. "(" .. api.get_node_name(NODE) .. ")")
|
||||
o.group = {""}
|
||||
else
|
||||
o.group = {}
|
||||
end
|
||||
o:depends({ _hide_node_option = "1", ['!reverse'] = true })
|
||||
o.template = appname .. "/cbi/nodes_listvalue"
|
||||
|
||||
current_node_id = o:formvalue(arg[1])
|
||||
if not current_node_id then
|
||||
current_node_id = m.uci:get(appname, arg[1], "node")
|
||||
end
|
||||
current_node = current_node_id and m.uci:get_all(appname, current_node_id) or {}
|
||||
|
||||
o = s:option(DummyValue, "_hide_dns_option", "")
|
||||
o.template = "passwall2/cbi/hidevalue"
|
||||
o.value = "1"
|
||||
o:depends({ node = "" })
|
||||
if GLOBAL_ENABLED == "1" and NODE then
|
||||
o:depends({ node = NODE })
|
||||
end
|
||||
|
||||
---- TCP Redir Ports
|
||||
local TCP_REDIR_PORTS = m:get("@global_forwarding[0]", "tcp_redir_ports")
|
||||
o = s:option(Value, "tcp_redir_ports", translate("TCP Redir Ports"))
|
||||
o:value("", translate("Use global config") .. "(" .. TCP_REDIR_PORTS .. ")")
|
||||
o:value("1:65535", translate("All"))
|
||||
o:value("22,25,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418", translate("Common Use"))
|
||||
o:value("80,443", "80,443")
|
||||
o.validate = port_validate
|
||||
o:depends({ _hide_node_option = "1", ['!reverse'] = true })
|
||||
|
||||
---- UDP Redir Ports
|
||||
local UDP_REDIR_PORTS = m:get("@global_forwarding[0]", "udp_redir_ports")
|
||||
o = s:option(Value, "udp_redir_ports", translate("UDP Redir Ports"))
|
||||
o:value("", translate("Use global config") .. "(" .. UDP_REDIR_PORTS .. ")")
|
||||
o:value("1:65535", translate("All"))
|
||||
o.validate = port_validate
|
||||
o:depends({ _hide_node_option = "1", ['!reverse'] = true })
|
||||
|
||||
o = s:option(DummyValue, "tips", " ")
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
return string.format('<font color="red">%s</font>',
|
||||
translate("The port settings support single ports and ranges.<br>Separate multiple ports with commas (,).<br>Example: 21,80,443,1000:2000."))
|
||||
end
|
||||
|
||||
o = s:option(ListValue, "direct_dns_query_strategy", translate("Direct Query Strategy"))
|
||||
o.default = "UseIP"
|
||||
o:value("UseIP")
|
||||
o:value("UseIPv4")
|
||||
o:value("UseIPv6")
|
||||
o:depends({ _hide_dns_option = "1", ['!reverse'] = true })
|
||||
|
||||
o = s:option(ListValue, "remote_dns_protocol", translate("Remote DNS Protocol"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("doh", "DoH")
|
||||
o:value("udp", "UDP")
|
||||
if current_node.type == "sing-box" then
|
||||
o:value("tls", "TLS(DoT)")
|
||||
o:value("quic", "QUIC(DoQ)")
|
||||
o:value("http3", "HTTP3(DoH3)")
|
||||
end
|
||||
o:depends({ _hide_dns_option = "1", ['!reverse'] = true })
|
||||
|
||||
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
|
||||
o = s:option(Value, "remote_dns", translate("Remote DNS"))
|
||||
o.datatype = "or(ipaddr,ipaddrport)"
|
||||
o.default = "1.1.1.1"
|
||||
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
|
||||
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
|
||||
o:value("8.8.4.4", "8.8.4.4 (Google)")
|
||||
o:value("8.8.8.8", "8.8.8.8 (Google)")
|
||||
o:value("9.9.9.9", "9.9.9.9 (Quad9-Recommended)")
|
||||
o:value("149.112.112.112", "149.112.112.112 (Quad9-Recommended)")
|
||||
o:value("208.67.220.220", "208.67.220.220 (OpenDNS)")
|
||||
o:value("208.67.222.222", "208.67.222.222 (OpenDNS)")
|
||||
o:depends("remote_dns_protocol", "tcp")
|
||||
o:depends("remote_dns_protocol", "udp")
|
||||
o:depends("remote_dns_protocol", "quic")
|
||||
o:depends("remote_dns_protocol", "tls")
|
||||
|
||||
---- DNS over HTTP (DoH) or DNS over HTTP3(DoH3)
|
||||
o = s:option(Value, "remote_dns_doh", translate("Remote DNS DoH"))
|
||||
o:value("https://1.1.1.1/dns-query", "CloudFlare")
|
||||
o:value("https://1.1.1.2/dns-query", "CloudFlare-Security")
|
||||
o:value("https://8.8.4.4/dns-query", "Google 8844")
|
||||
o:value("https://8.8.8.8/dns-query", "Google 8888")
|
||||
o:value("https://9.9.9.9/dns-query", "Quad9-Recommended 9.9.9.9")
|
||||
o:value("https://149.112.112.112/dns-query", "Quad9-Recommended 149.112.112.112")
|
||||
o:value("https://208.67.222.222/dns-query", "OpenDNS")
|
||||
o:value("https://dns.adguard.com/dns-query,94.140.14.14", "AdGuard")
|
||||
o:value("https://doh.libredns.gr/dns-query,116.202.176.26", "LibreDNS")
|
||||
o:value("https://doh.libredns.gr/ads,116.202.176.26", "LibreDNS (No Ads)")
|
||||
o.default = "https://1.1.1.1/dns-query"
|
||||
o.validate = doh_validate
|
||||
o:depends("remote_dns_protocol", "doh")
|
||||
o:depends("remote_dns_protocol", "http3")
|
||||
|
||||
o = s:option(Value, "remote_dns_client_ip", translate("Remote DNS EDNS Client Subnet"))
|
||||
o.description = translate("Notify the DNS server when the DNS query is notified, the location of the client (cannot be a private IP address).") .. "<br />" ..
|
||||
translate("This feature requires the DNS server to support the Edns Client Subnet (RFC7871).")
|
||||
o.datatype = "ipaddr"
|
||||
o:depends("remote_dns_protocol", "tcp")
|
||||
o:depends("remote_dns_protocol", "doh")
|
||||
o:depends("remote_dns_protocol", "udp")
|
||||
o:depends("remote_dns_protocol", "http3")
|
||||
o:depends("remote_dns_protocol", "quic")
|
||||
o:depends("remote_dns_protocol", "tls")
|
||||
|
||||
o = s:option(ListValue, "remote_dns_detour", translate("Remote DNS Outbound"))
|
||||
o.default = "remote"
|
||||
o:value("remote", translate("Remote"))
|
||||
o:value("direct", translate("Direct"))
|
||||
o:depends("remote_dns_protocol", "tcp")
|
||||
o:depends("remote_dns_protocol", "doh")
|
||||
o:depends("remote_dns_protocol", "udp")
|
||||
o:depends("remote_dns_protocol", "http3")
|
||||
o:depends("remote_dns_protocol", "quic")
|
||||
o:depends("remote_dns_protocol", "tls")
|
||||
|
||||
o = s:option(Flag, "remote_fakedns", "FakeDNS", translate("Use FakeDNS work in the domain that proxy."))
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(ListValue, "remote_dns_query_strategy", translate("Remote Query Strategy"))
|
||||
o.default = "UseIPv4"
|
||||
o:value("UseIP")
|
||||
o:value("UseIPv4")
|
||||
o:value("UseIPv6")
|
||||
o:depends("remote_dns_protocol", "tcp")
|
||||
o:depends("remote_dns_protocol", "doh")
|
||||
o:depends("remote_dns_protocol", "udp")
|
||||
o:depends("remote_dns_protocol", "http3")
|
||||
o:depends("remote_dns_protocol", "quic")
|
||||
o:depends("remote_dns_protocol", "tls")
|
||||
|
||||
o = s:option(ListValue, "dns_hosts_mode", translate("Domain Override"))
|
||||
o:value("default", translate("Use global config"))
|
||||
o:value("disable", translate("No patterns are used"))
|
||||
o:value("custom", translate("-- custom --"))
|
||||
|
||||
o = s:option(TextValue, "dns_hosts", translate("Domain Override"))
|
||||
o.rows = 5
|
||||
o.wrap = "off"
|
||||
o:depends("dns_hosts_mode", "custom")
|
||||
o.remove = function(self, section)
|
||||
local node_value = s.fields["node"]:formvalue(arg[1])
|
||||
if node_value then
|
||||
local node_t = m:get(node_value) or {}
|
||||
if node_t.type == "Xray" or node_t.type == "sing-box" then
|
||||
AbstractValue.remove(self, section)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local o_node = s.fields["node"]
|
||||
|
||||
for k, v in pairs(nodes_table) do
|
||||
o_node:value(v.id, v["remark"])
|
||||
o_node.group[#o_node.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
if v.node_type == "normal" or v.protocol == "_balancing" or v.protocol == "_urltest" then
|
||||
--Shunt node has its own separate options.
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id, remote_dns_protocol = "tcp" })
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id, remote_dns_protocol = "doh" })
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id, remote_dns_protocol = "udp" })
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id, remote_dns_protocol = "http3" })
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id, remote_dns_protocol = "quic" })
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id, remote_dns_protocol = "tls" })
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,29 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
-- [[ App Settings ]]--
|
||||
s = m:section(TypedSection, "global_app", translate("App Update"),
|
||||
"<font color='red'>" ..
|
||||
translate("Please confirm that your firmware supports FPU.") ..
|
||||
"</font>")
|
||||
s.anonymous = true
|
||||
s:append(Template(appname .. "/app_update/app_version"))
|
||||
|
||||
local k, v
|
||||
local com = require "luci.passwall2.com"
|
||||
for k, v in pairs(com) do
|
||||
o = s:option(Value, k:gsub("%-","_") .. "_file", translatef("%s App Path", v.name))
|
||||
o.default = v.default_path or ("/usr/bin/" .. k)
|
||||
o.rmempty = false
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "tips", " ")
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
return string.format('<font color="red">%s</font>', translate("if you want to run from memory, change the path, /tmp beginning then save the application and update it manually."))
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,16 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = "passwall2"
|
||||
local fs = api.fs
|
||||
local uci = api.uci
|
||||
|
||||
local geo_dir = (uci:get(appname, "@global_rules[0]", "v2ray_location_asset") or "/usr/share/v2ray/"):match("^(.*)/")
|
||||
local geosite_path = geo_dir .. "/geosite.dat"
|
||||
local geoip_path = geo_dir .. "/geoip.dat"
|
||||
if fs.access(geosite_path) and fs.access(geoip_path) then
|
||||
f = SimpleForm(appname)
|
||||
f.reset = false
|
||||
f.submit = false
|
||||
f:append(Template(appname .. "/rule/geoview"))
|
||||
end
|
||||
|
||||
return f
|
||||
@@ -0,0 +1,387 @@
|
||||
api = require "luci.passwall2.api"
|
||||
appname = api.appname
|
||||
datatypes = api.datatypes
|
||||
has_singbox = api.finded_com("sing-box")
|
||||
has_xray = api.finded_com("xray")
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
|
||||
|
||||
nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
nodes_table[#nodes_table + 1] = e
|
||||
end
|
||||
|
||||
local normal_list = {}
|
||||
local balancing_list = {}
|
||||
local urltest_list = {}
|
||||
local shunt_list = {}
|
||||
local iface_list = {}
|
||||
for k, v in pairs(nodes_table) do
|
||||
if v.node_type == "normal" then
|
||||
normal_list[#normal_list + 1] = v
|
||||
end
|
||||
if v.protocol and v.protocol == "_balancing" then
|
||||
balancing_list[#balancing_list + 1] = v
|
||||
end
|
||||
if v.protocol and v.protocol == "_urltest" then
|
||||
urltest_list[#urltest_list + 1] = v
|
||||
end
|
||||
if v.protocol and v.protocol == "_shunt" then
|
||||
shunt_list[#shunt_list + 1] = v
|
||||
end
|
||||
if v.protocol and v.protocol == "_iface" then
|
||||
iface_list[#iface_list + 1] = v
|
||||
end
|
||||
end
|
||||
|
||||
local socks_list = {}
|
||||
m.uci:foreach(appname, "socks", function(s)
|
||||
if s.enabled == "1" and s.node then
|
||||
socks_list[#socks_list + 1] = {
|
||||
id = "Socks_" .. s[".name"],
|
||||
remark = translate("Socks Config") .. " [" .. s.port .. translate("Port") .. "]",
|
||||
group = "Socks"
|
||||
}
|
||||
end
|
||||
end)
|
||||
|
||||
local doh_validate = function(self, value, t)
|
||||
if value ~= "" then
|
||||
local flag = 0
|
||||
local util = require "luci.util"
|
||||
local val = util.split(value, ",")
|
||||
local url = val[1]
|
||||
val[1] = nil
|
||||
for i = 1, #val do
|
||||
local v = val[i]
|
||||
if v then
|
||||
if not datatypes.ipmask4(v) and not datatypes.ipmask6(v) then
|
||||
flag = 1
|
||||
end
|
||||
end
|
||||
end
|
||||
if flag == 0 then
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil, translate("DoH request address") .. " " .. translate("Format must be:") .. " URL,IP"
|
||||
end
|
||||
|
||||
m:append(Template(appname .. "/global/status"))
|
||||
|
||||
global_cfgid = m:get("@global[0]")[".name"]
|
||||
|
||||
s = m:section(TypedSection, "global")
|
||||
s.anonymous = true
|
||||
s.addremove = false
|
||||
|
||||
s:tab("Main", translate("Main"))
|
||||
|
||||
-- [[ Global Settings ]]--
|
||||
o = s:taboption("Main", Flag, "enabled", translate("Main switch"))
|
||||
o.rmempty = false
|
||||
|
||||
---- Node
|
||||
o = s:taboption("Main", ListValue, "node", "<a style='color: red'>" .. translate("Node") .. "</a>")
|
||||
o.template = appname .. "/cbi/nodes_listvalue"
|
||||
o:value("", translate("Close"))
|
||||
o.group = {""}
|
||||
|
||||
current_node_id = m.uci:get(appname, global_cfgid, "node")
|
||||
current_node = current_node_id and m.uci:get_all(appname, current_node_id) or {}
|
||||
|
||||
-- Shunt Start
|
||||
if (has_singbox or has_xray) and #nodes_table > 0 then
|
||||
if #normal_list > 0 or #iface_list > 0 then
|
||||
if current_node.protocol == "_shunt" then
|
||||
local shunt_lua = loadfile("/usr/lib/lua/luci/model/cbi/passwall2/client/include/shunt_options.lua")
|
||||
setfenv(shunt_lua, getfenv(1))(m, s, {
|
||||
node_id = current_node_id,
|
||||
node = current_node,
|
||||
socks_list = socks_list,
|
||||
urltest_list = urltest_list,
|
||||
balancing_list = balancing_list,
|
||||
iface_list = iface_list,
|
||||
normal_list = normal_list,
|
||||
verify_option = s.fields["node"],
|
||||
tab = "Shunt",
|
||||
tab_desc = translate("Shunt Rule")
|
||||
})
|
||||
end
|
||||
else
|
||||
local tips = s:taboption("Main", DummyValue, "tips", " ")
|
||||
tips.rawhtml = true
|
||||
tips.cfgvalue = function(t, n)
|
||||
return string.format('<a style="color: red">%s</a>', translate("There are no available nodes, please add or subscribe nodes first."))
|
||||
end
|
||||
tips:depends({ node = "", ["!reverse"] = true })
|
||||
for k, v in pairs(shunt_list) do
|
||||
tips:depends("node", v.id)
|
||||
end
|
||||
for k, v in pairs(balancing_list) do
|
||||
tips:depends("node", v.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
---- Check the transparent proxy component
|
||||
local handle = io.popen("lsmod")
|
||||
local mods = ""
|
||||
if handle then
|
||||
mods = handle:read("*a") or ""
|
||||
handle:close()
|
||||
end
|
||||
|
||||
if (mods:find("REDIRECT") and mods:find("TPROXY")) or (mods:find("nft_redir") and mods:find("nft_tproxy")) then
|
||||
o = s:taboption("Main", Flag, "localhost_proxy", translate("Localhost Proxy"), translate("When selected, localhost can transparent proxy."))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:taboption("Main", Flag, "client_proxy", translate("Client Proxy"), translate("When selected, devices in LAN can transparent proxy. Otherwise, it will not be proxy. But you can still use access control to allow the designated device to proxy."))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
else
|
||||
local html = string.format([[<div class="cbi-checkbox"><input class="cbi-input-checkbox" type="checkbox" disabled></div><div class="cbi-value-description"><font color="red">%s</font></div>]], translate("Missing components, transparent proxy is unavailable."))
|
||||
o = s:taboption("Main", DummyValue, "localhost_proxy", translate("Localhost Proxy"))
|
||||
o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
return html
|
||||
end
|
||||
|
||||
o = s:taboption("Main", DummyValue, "client_proxy", translate("Client Proxy"))
|
||||
o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
return html
|
||||
end
|
||||
end
|
||||
|
||||
node_socks_port = s:taboption("Main", Value, "node_socks_port", translate("Node") .. " Socks " .. translate("Listen Port"))
|
||||
node_socks_port.default = 1070
|
||||
node_socks_port.datatype = "port"
|
||||
|
||||
node_socks_bind_local = s:taboption("Main", Flag, "node_socks_bind_local", translate("Node") .. " Socks " .. translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
node_socks_bind_local.default = "1"
|
||||
node_socks_bind_local:depends({ node = "", ["!reverse"] = true })
|
||||
|
||||
s:tab("DNS", translate("DNS"))
|
||||
|
||||
o = s:taboption("DNS", TextValue, "direct_dns_shunt", translate("Direct domain DNS routing"))
|
||||
o.description = "<br /><ul>"
|
||||
.. "<li>" .. translate("Subdomain (recommended): Begining with 'domain:' and the rest is a domain. When the targeting domain is exactly the value, or is a subdomain of the value, this rule takes effect. Example: rule 'domain:v2ray.com' matches 'www.v2ray.com', 'v2ray.com', but not 'xv2ray.com'.") .. "</li>"
|
||||
.. "<li>" .. translate("Full domain: Begining with 'full:' and the rest is a domain. When the targeting domain is exactly the value, the rule takes effect. Example: rule 'domain:v2ray.com' matches 'v2ray.com', but not 'www.v2ray.com'.") .. "</li>"
|
||||
.. "<li>" .. translate("Such as:") .. "</li>"
|
||||
.. "<li>" .. "domain:my-nodes.com tcp://223.5.5.5" .. "</li>"
|
||||
.. "<li>" .. "domain:vpn.com udp://119.29.29.29:53" .. "</li>"
|
||||
.. "<li>" .. "full:www.dnspod.com https://120.53.53.53/dns-query" .. "</li>"
|
||||
.. "<li>" .. '<a style="color:red">' .. translate("Please note that the program will not start if the format is incorrect!") .. '</a>' .. "</li>"
|
||||
.. "</ul>"
|
||||
o.rows = 3
|
||||
o.wrap = "off"
|
||||
|
||||
o = s:taboption("DNS", ListValue, "direct_dns_query_strategy", translate("Direct Query Strategy"))
|
||||
o.default = "UseIP"
|
||||
o:value("UseIP")
|
||||
o:value("UseIPv4")
|
||||
o:value("UseIPv6")
|
||||
|
||||
o = s:taboption("DNS", ListValue, "remote_dns_protocol", translate("Remote DNS Protocol"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("doh", "DoH")
|
||||
o:value("udp", "UDP")
|
||||
if current_node.type == "sing-box" then
|
||||
o:value("tls", "TLS(DoT)")
|
||||
o:value("quic", "QUIC(DoQ)")
|
||||
o:value("http3", "HTTP3(DoH3)")
|
||||
end
|
||||
|
||||
---- DNS over TCP or UDP or TLS (DoT) or QUIC (DoQ)
|
||||
o = s:taboption("DNS", Value, "remote_dns", translate("Remote DNS"))
|
||||
o.datatype = "or(ipaddr,ipaddrport)"
|
||||
o.default = "1.1.1.1"
|
||||
o:value("1.1.1.1", "1.1.1.1 (CloudFlare)")
|
||||
o:value("1.1.1.2", "1.1.1.2 (CloudFlare-Security)")
|
||||
o:value("8.8.4.4", "8.8.4.4 (Google)")
|
||||
o:value("8.8.8.8", "8.8.8.8 (Google)")
|
||||
o:value("9.9.9.9", "9.9.9.9 (Quad9-Recommended)")
|
||||
o:value("149.112.112.112", "149.112.112.112 (Quad9-Recommended)")
|
||||
o:value("208.67.220.220", "208.67.220.220 (OpenDNS)")
|
||||
o:value("208.67.222.222", "208.67.222.222 (OpenDNS)")
|
||||
o:depends("remote_dns_protocol", "tcp")
|
||||
o:depends("remote_dns_protocol", "udp")
|
||||
o:depends("remote_dns_protocol", "quic")
|
||||
o:depends("remote_dns_protocol", "tls")
|
||||
|
||||
---- DNS over HTTP (DoH) or DNS over HTTP3(DoH3)
|
||||
o = s:taboption("DNS", Value, "remote_dns_doh", translate("Remote DNS DoH"))
|
||||
o.default = "https://1.1.1.1/dns-query"
|
||||
o:value("https://1.1.1.1/dns-query", "CloudFlare")
|
||||
o:value("https://1.1.1.2/dns-query", "CloudFlare-Security")
|
||||
o:value("https://8.8.4.4/dns-query", "Google 8844")
|
||||
o:value("https://8.8.8.8/dns-query", "Google 8888")
|
||||
o:value("https://9.9.9.9/dns-query", "Quad9-Recommended 9.9.9.9")
|
||||
o:value("https://149.112.112.112/dns-query", "Quad9-Recommended 149.112.112.112")
|
||||
o:value("https://208.67.222.222/dns-query", "OpenDNS")
|
||||
o:value("https://dns.adguard.com/dns-query,94.140.14.14", "AdGuard")
|
||||
o:value("https://doh.libredns.gr/dns-query,116.202.176.26", "LibreDNS")
|
||||
o:value("https://doh.libredns.gr/ads,116.202.176.26", "LibreDNS (No Ads)")
|
||||
o.validate = doh_validate
|
||||
o:depends("remote_dns_protocol", "doh")
|
||||
o:depends("remote_dns_protocol", "http3")
|
||||
|
||||
o = s:taboption("DNS", Value, "remote_dns_client_ip", translate("Remote DNS EDNS Client Subnet"))
|
||||
o.description = translate("Notify the DNS server when the DNS query is notified, the location of the client (cannot be a private IP address).") .. "<br />" ..
|
||||
translate("This feature requires the DNS server to support the Edns Client Subnet (RFC7871).")
|
||||
o.datatype = "ipaddr"
|
||||
|
||||
o = s:taboption("DNS", ListValue, "remote_dns_detour", translate("Remote DNS Outbound"))
|
||||
o.default = "remote"
|
||||
o:value("remote", translate("Remote"))
|
||||
o:value("direct", translate("Direct"))
|
||||
|
||||
o = s:taboption("DNS", Flag, "remote_fakedns", "FakeDNS", translate("Use FakeDNS work in the domain that proxy."))
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:taboption("DNS", ListValue, "remote_dns_query_strategy", translate("Remote Query Strategy"))
|
||||
o.default = "UseIPv4"
|
||||
o:value("UseIP")
|
||||
o:value("UseIPv4")
|
||||
o:value("UseIPv6")
|
||||
|
||||
o = s:taboption("DNS", TextValue, "dns_hosts", translate("Domain Override"))
|
||||
o.rows = 5
|
||||
o.wrap = "off"
|
||||
o.remove = function(self, section)
|
||||
local node_value = s.fields["node"]:formvalue(global_cfgid)
|
||||
if node_value then
|
||||
local node_t = m:get(node_value) or {}
|
||||
if node_t.type == "Xray" or node_t.type == "sing-box" then
|
||||
AbstractValue.remove(self, section)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
o = s:taboption("DNS", Flag, "dns_redirect", translate("DNS Redirect"), translate("Force special DNS server to need proxy devices."))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
local prefer_nft = m:get("@global_forwarding[0]", "prefer_nft") == "1"
|
||||
local set_title = api.i18n.translate(prefer_nft and "Clear NFTSET" or "Clear IPSET")
|
||||
o = s:taboption("DNS", DummyValue, "clear_ipset", set_title, translate("Try this feature if the rule modification does not take effect."))
|
||||
o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
return string.format(
|
||||
[[<button type="button" class="cbi-button cbi-button-remove" onclick="location.href='%s'">%s</button>]],
|
||||
api.url("flush_set") .. "?redirect=1&reload=1", set_title)
|
||||
end
|
||||
|
||||
s:tab("log", translate("Log"))
|
||||
o = s:taboption("log", Flag, "log_node", translate("Enable Node Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
loglevel = s:taboption("log", ListValue, "loglevel", translate("Log Level"))
|
||||
loglevel.default = "warning"
|
||||
loglevel:value("debug")
|
||||
loglevel:value("info")
|
||||
loglevel:value("warning")
|
||||
loglevel:value("error")
|
||||
|
||||
s:tab("faq", "FAQ")
|
||||
|
||||
o = s:taboption("faq", DummyValue, "")
|
||||
o.template = appname .. "/global/faq"
|
||||
|
||||
s:tab("maintain", translate("Maintain"))
|
||||
o = s:taboption("maintain", DummyValue, "")
|
||||
o.template = appname .. "/global/backup"
|
||||
|
||||
-- [[ Socks Server ]]--
|
||||
o = s:taboption("Main", Flag, "socks_enabled", "Socks " .. translate("Main switch"))
|
||||
o.rmempty = false
|
||||
|
||||
s2 = m:section(TypedSection, "socks", translate("Socks Config"))
|
||||
s2.template = "cbi/tblsection"
|
||||
s2.anonymous = true
|
||||
s2.addremove = true
|
||||
s2.extedit = api.url("socks_config", "%s")
|
||||
function s2.create(e, t)
|
||||
local uuid = api.gen_short_uuid()
|
||||
t = uuid
|
||||
TypedSection.create(e, t)
|
||||
luci.http.redirect(e.extedit:format(t))
|
||||
end
|
||||
|
||||
o = s2:option(DummyValue, "status", translate("Status"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
return string.format('<div class="_status" socks_id="%s"></div>', n)
|
||||
end
|
||||
|
||||
---- Enable
|
||||
o = s2:option(Flag, "enabled", translate("Enable"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
o = s2:option(ListValue, "node", translate("Socks Node"))
|
||||
o.template = appname .. "/cbi/nodes_listvalue"
|
||||
o.group = {}
|
||||
|
||||
o = s2:option(DummyValue, "now_node", translate("Current Node"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(_, n)
|
||||
local current_node = api.get_cache_var("socks_" .. n)
|
||||
if current_node then
|
||||
local node = m:get(current_node)
|
||||
if node then
|
||||
return (api.get_node_remarks(node) or ""):gsub("(:)%[", "%1<br>[")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local n = 1
|
||||
m.uci:foreach(appname, "socks", function(s)
|
||||
if s[".name"] == section then
|
||||
return false
|
||||
end
|
||||
n = n + 1
|
||||
end)
|
||||
|
||||
o = s2:option(Value, "port", "Socks " .. translate("Listen Port"))
|
||||
o.default = n + 1080
|
||||
o.datatype = "port"
|
||||
o.rmempty = false
|
||||
|
||||
if has_singbox or has_xray then
|
||||
o = s2:option(Value, "http_port", "HTTP " .. translate("Listen Port") .. " " .. translate("0 is not use"))
|
||||
o.default = 0
|
||||
o.datatype = "port"
|
||||
end
|
||||
|
||||
local o_node = s.fields["node"]
|
||||
local o_socks = s2.fields["node"]
|
||||
for k, v in pairs(nodes_table) do
|
||||
if #normal_list == 0 and #iface_list == 0 then
|
||||
break
|
||||
end
|
||||
o_node:value(v.id, v["remark"])
|
||||
o_node.group[#o_node.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
o_socks:value(v.id, v["remark"])
|
||||
o_socks.group[#o_socks.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
if v.node_type == "normal" or v.protocol == "_balancing" or v.protocol == "_urltest" then
|
||||
--Shunt node has its own separate options.
|
||||
s.fields["remote_fakedns"]:depends({ node = v.id })
|
||||
end
|
||||
end
|
||||
|
||||
local footer = Template(appname .. "/global/footer")
|
||||
footer.api = api
|
||||
footer.global_cfgid = global_cfgid
|
||||
footer.shunt_list = api.jsonc.stringify(shunt_list)
|
||||
|
||||
m:append(footer)
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,180 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local datatypes = api.datatypes
|
||||
local net = require "luci.model.network".init()
|
||||
|
||||
local nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
if e.node_type == "normal" then
|
||||
nodes_table[#nodes_table + 1] = {
|
||||
id = e[".name"],
|
||||
obj = e,
|
||||
remarks = e["remark"],
|
||||
group = e["group"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_value_com"))
|
||||
|
||||
-- [[ Haproxy Settings ]]--
|
||||
s = m:section(TypedSection, "global_haproxy", translate("Basic Settings"))
|
||||
s.anonymous = true
|
||||
|
||||
s:append(Template(appname .. "/haproxy/status"))
|
||||
|
||||
---- Balancing Enable
|
||||
o = s:option(Flag, "balancing_enable", translate("Enable Load Balancing"))
|
||||
o.rmempty = false
|
||||
o.default = false
|
||||
|
||||
---- Console Login Auth
|
||||
o = s:option(Flag, "console_auth", translate("Console Login Auth"))
|
||||
o.default = false
|
||||
o:depends("balancing_enable", true)
|
||||
|
||||
---- Console Username
|
||||
o = s:option(Value, "console_user", translate("Console Username"))
|
||||
o.default = ""
|
||||
o:depends("console_auth", true)
|
||||
|
||||
---- Console Password
|
||||
o = s:option(Value, "console_password", translate("Console Password"))
|
||||
o.password = true
|
||||
o.default = ""
|
||||
o:depends("console_auth", true)
|
||||
|
||||
---- Console Port
|
||||
o = s:option(Value, "console_port", translate("Console Port"), translate("In the browser input routing IP plus port access, such as:192.168.1.1:1188"))
|
||||
o.datatype = "port"
|
||||
o.default = 1188
|
||||
o:depends("balancing_enable", true)
|
||||
|
||||
o = s:option(Flag, "bind_local", translate("Haproxy Port") .. " " .. translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
o:depends("balancing_enable", true)
|
||||
|
||||
o = s:option(ListValue, "balancingStrategy", translate("Balancing Strategy"))
|
||||
o:value("roundrobin", "roundRobin")
|
||||
o:value("random", "random")
|
||||
o:value("leastconn", "leastConn")
|
||||
o:value("source", "source")
|
||||
o.default = "roundrobin"
|
||||
o:depends("balancing_enable", true)
|
||||
|
||||
---- Health Check Type
|
||||
o = s:option(ListValue, "health_check_type", translate("Health Check Type"))
|
||||
o.default = "passwall_logic"
|
||||
o:value("tcp", "TCP")
|
||||
o:value("passwall_logic", translate("URL Test") .. string.format("(passwall %s)", translate("Inner implement")))
|
||||
o:depends("balancing_enable", true)
|
||||
|
||||
---- Passwall Inner implement Probe URL
|
||||
o = s:option(Value, "health_probe_url", translate("Probe URL"))
|
||||
o.default = "https://www.google.com/generate_204"
|
||||
o:value("https://cp.cloudflare.com/", "Cloudflare")
|
||||
o:value("https://www.gstatic.com/generate_204", "Gstatic")
|
||||
o:value("https://www.google.com/generate_204", "Google")
|
||||
o:value("https://www.youtube.com/generate_204", "YouTube")
|
||||
o:value("https://connect.rom.miui.com/generate_204", "MIUI (CN)")
|
||||
o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)")
|
||||
o.description = translate("The URL used to detect the connection status.")
|
||||
o:depends("health_check_type", "passwall_logic")
|
||||
|
||||
---- Health Check Inter
|
||||
o = s:option(Value, "health_check_inter", translate("Health Check Inter"))
|
||||
o.datatype = "min(20)"
|
||||
o.default = 60
|
||||
o:depends("balancing_enable", true)
|
||||
o.description = translate("Units:seconds") .. " ( ≥ 20s )"
|
||||
|
||||
o = s:option(DummyValue, "health_check_tips", " ")
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
return string.format('<span style="color: red">%s</span>', translate("When the URL test is used, the load balancing node will be converted into a Socks node. when node list set customizing, must be a Socks node, otherwise the health check will be invalid."))
|
||||
end
|
||||
o:depends("health_check_type", "passwall_logic")
|
||||
|
||||
-- [[ Balancing Settings ]]--
|
||||
s = m:section(TypedSection, "haproxy_config", translate("Node List"))
|
||||
s.description = "<font color='red'>" ..
|
||||
translate("Add a node, Export Of Multi WAN Only support Multi Wan. Load specific gravity range 1-256. Multiple primary servers can be load balanced, standby will only be enabled when the primary server is offline! Multiple groups can be set, Haproxy port same one for each group.") .. "<br>" ..
|
||||
translate("Note that the node configuration parameters for load balancing must be consistent when use TCP health check type, otherwise it cannot be used normally!") .. "</font>"
|
||||
s.template = "cbi/tblsection"
|
||||
s.sortable = true
|
||||
s.anonymous = true
|
||||
s.addremove = true
|
||||
|
||||
s.create = function(e, t)
|
||||
TypedSection.create(e, api.gen_short_uuid())
|
||||
end
|
||||
|
||||
s.remove = function(self, section)
|
||||
for k, v in pairs(self.children) do
|
||||
v.rmempty = true
|
||||
v.validate = nil
|
||||
end
|
||||
TypedSection.remove(self, section)
|
||||
end
|
||||
|
||||
---- Enable
|
||||
o = s:option(Flag, "enabled", translate("Enable"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
---- Node Address
|
||||
o = s:option(Value, "lbss", translate("Node Address"))
|
||||
o.template = appname .. "/cbi/nodes_value"
|
||||
o.group = {}
|
||||
for k, v in pairs(nodes_table) do
|
||||
o:value(v.id, v.remarks)
|
||||
o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
o.rmempty = false
|
||||
o.validate = function(self, value)
|
||||
if not value then return nil, translate("Node address cannot be empty.") end
|
||||
local t = m:get(value) or nil
|
||||
if t and t[".type"] == "nodes" then
|
||||
return value
|
||||
end
|
||||
if datatypes.hostport(value) or datatypes.ip4addrport(value) then
|
||||
return value
|
||||
end
|
||||
if api.is_ipv6addrport(value) then
|
||||
return value
|
||||
end
|
||||
return nil, translate("Not valid IP format, please re-enter!") .. " (IP:Port)"
|
||||
end
|
||||
|
||||
---- Haproxy Port
|
||||
o = s:option(Value, "haproxy_port", translate("Haproxy Port"))
|
||||
o.datatype = "port"
|
||||
o.default = 1181
|
||||
o.rmempty = false
|
||||
|
||||
---- Node Weight
|
||||
o = s:option(Value, "lbweight", translate("Node Weight"))
|
||||
o.datatype = "min(1)"
|
||||
o.default = 5
|
||||
o.rmempty = false
|
||||
|
||||
---- Export
|
||||
o = s:option(ListValue, "export", translate("Export Of Multi WAN"))
|
||||
o:value(0, translate("Auto"))
|
||||
local wa = require "luci.tools.webadmin"
|
||||
wa.cbi_add_networks(o)
|
||||
o.default = 0
|
||||
o.rmempty = false
|
||||
|
||||
---- Mode
|
||||
o = s:option(ListValue, "backup", translate("Mode"))
|
||||
o:value(0, translate("Primary"))
|
||||
o:value(1, translate("Standby"))
|
||||
o.rmempty = false
|
||||
|
||||
m:append(Template(appname .. "/haproxy/js"))
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,204 @@
|
||||
local m, s, data = ...
|
||||
|
||||
if not data.node_id or not data.node then
|
||||
return
|
||||
end
|
||||
|
||||
local current_node_id = data.node_id
|
||||
local node_list = data.node_list or api.get_node_list()
|
||||
|
||||
local function get_cfgvalue()
|
||||
return function(self, section)
|
||||
return m:get(current_node_id, self.option)
|
||||
end
|
||||
end
|
||||
local function get_write()
|
||||
return function(self, section, value)
|
||||
if data.verify_option then
|
||||
if data.verify_option:formvalue(section) == current_node_id then
|
||||
m:set(current_node_id, self.option, value)
|
||||
end
|
||||
else
|
||||
m:set(current_node_id, self.option, value)
|
||||
end
|
||||
end
|
||||
end
|
||||
local function get_remove()
|
||||
return function(self, section)
|
||||
if data.verify_option then
|
||||
if data.verify_option:formvalue(section) == current_node_id then
|
||||
m:del(current_node_id, self.option)
|
||||
end
|
||||
else
|
||||
m:del(current_node_id, self.option)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if data.tab then
|
||||
s:tab(data.tab, data.tab_desc)
|
||||
end
|
||||
|
||||
local function add_option(class, option_name, option_title, option_desc)
|
||||
local a
|
||||
if data.tab then
|
||||
a = s:taboption(data.tab, class, option_name, option_title)
|
||||
else
|
||||
a = s:option(class, option_name, option_title)
|
||||
end
|
||||
if a then
|
||||
if option_desc then
|
||||
a.description = option_desc
|
||||
end
|
||||
a.cfgvalue = get_cfgvalue()
|
||||
a.write = get_write()
|
||||
a.remove = get_remove()
|
||||
end
|
||||
if data.verify_option then
|
||||
a:depends(data.verify_option.option, current_node_id)
|
||||
end
|
||||
return a
|
||||
end
|
||||
|
||||
local function add_depends(o, deps)
|
||||
if #o.deps > 0 then
|
||||
for index, value in ipairs(o.deps) do
|
||||
for k, v in pairs(deps) do
|
||||
o.deps[index][k] = v
|
||||
end
|
||||
end
|
||||
else
|
||||
o:depends(deps)
|
||||
end
|
||||
end
|
||||
|
||||
if data.node.type == "Xray" then
|
||||
o = add_option(ListValue, "domainStrategy", translate("Domain Strategy"))
|
||||
o:value("AsIs")
|
||||
o:value("IPIfNonMatch")
|
||||
o:value("IPOnDemand")
|
||||
o.default = "IPOnDemand"
|
||||
o.description = "<br /><ul><li>" .. translate("'AsIs': Only use domain for routing. Default value.")
|
||||
.. "</li><li>" .. translate("'IPIfNonMatch': When no rule matches current domain, resolves it into IP addresses (A or AAAA records) and try all rules again.")
|
||||
.. "</li><li>" .. translate("'IPOnDemand': As long as there is a IP-based rule, resolves the domain into IP immediately.")
|
||||
.. "</li></ul>"
|
||||
|
||||
o = add_option(ListValue, "domainMatcher", translate("Domain matcher"))
|
||||
o:value("hybrid")
|
||||
o:value("linear")
|
||||
end
|
||||
|
||||
o = add_option(Flag, "fakedns", '<a style="color:#FF8C00">FakeDNS</a>' .. " " .. translate("Main switch"), translate("Use FakeDNS work in the domain that proxy.") .. "<br>" ..
|
||||
translate("Suitable scenarios for let the node servers get the target domain names.") .. "<br>" ..
|
||||
translate("Such as: DNS unlocking of streaming media, reducing DNS query latency, etc."))
|
||||
|
||||
o = add_option(Flag, "write_ipset_direct", translate("Direct DNS result write to IPSet"), translate("Perform the matching direct domain name rules into IP to IPSet/NFTSet, and then connect directly (not entering the core).") .. " " .. translate("Maybe conflict with some special circumstances."))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
if api.is_finded("geoview") then
|
||||
o = add_option(Flag, "enable_geoview_ip", translate("Enable GeoIP Data Parsing"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
o.description = "<ul>"
|
||||
.. "<li>" .. translate("Analyzes and preloads GeoIP data to enhance the shunt performance.") .. "</li>"
|
||||
.. "<li>" .. translate("Note: Increases resource usage.") .. " " .. translate("Maybe conflict with some special circumstances.") .. "</li>"
|
||||
.. "</ul>"
|
||||
end
|
||||
|
||||
local shunt_rules = {}
|
||||
m.uci:foreach(appname, "shunt_rules", function(e)
|
||||
e.id = e[".name"]
|
||||
e.remarks = e.remarks or e[".name"]
|
||||
e["_node_option"] = e[".name"]
|
||||
e["_node_default"] = ""
|
||||
e["_fakedns_option"] = e[".name"] .. "_fakedns"
|
||||
e["_proxy_tag_option"] = e[".name"] .. "_proxy_tag"
|
||||
table.insert(shunt_rules, e)
|
||||
end)
|
||||
table.insert(shunt_rules, {
|
||||
id = ".default",
|
||||
remarks = translate("Default"),
|
||||
_node_option = "default_node",
|
||||
_node_default = "_direct",
|
||||
_fakedns_option = "default_fakedns",
|
||||
_proxy_tag_option = "default_proxy_tag",
|
||||
})
|
||||
|
||||
s2 = m:section(Table, shunt_rules, " ")
|
||||
s2.config = appname
|
||||
s2.sectiontype = "shunt_option_list"
|
||||
|
||||
o = s2:option(DummyValue, "remarks", translate("Rule"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(self, section)
|
||||
if shunt_rules[section].id == ".default" then
|
||||
return string.format('<font style="color: red">%s</font>', shunt_rules[section].remarks)
|
||||
else
|
||||
return string.format('<a href="%s" target="_blank">%s</a>', api.url("shunt_rules", shunt_rules[section].id), shunt_rules[section].remarks)
|
||||
end
|
||||
end
|
||||
|
||||
_node = s2:option(Value, "_node", translate("Node"))
|
||||
_node.template = appname .. "/cbi/nodes_listvalue"
|
||||
_node.group = {"","","",""}
|
||||
_node:value("", translate("Close (Not use)"))
|
||||
_node:value("_default", translate("Use default node"))
|
||||
_node:value("_direct", translate("Direct Connection"))
|
||||
_node:value("_blackhole", translate("Blackhole (Block)"))
|
||||
_node.cfgvalue = function(self, section)
|
||||
return m:get(current_node_id, shunt_rules[section]["_node_option"]) or shunt_rules[section]["_node_default"]
|
||||
end
|
||||
_node.write = function(self, section, value)
|
||||
return m:set(current_node_id, shunt_rules[section]["_node_option"], value)
|
||||
end
|
||||
_node.remove = function(self, section)
|
||||
return m:del(current_node_id, shunt_rules[section]["_node_option"])
|
||||
end
|
||||
|
||||
o = s2:option(Flag, "_fakedns", string.format('<a style="color:#FF8C00" title="%s">FakeDNS</a>', translate("Use FakeDNS work in the domain that proxy.") .. "\n" ..
|
||||
translate("Suitable scenarios for let the node servers get the target domain names.") .. "\n" ..
|
||||
translate("Such as: DNS unlocking of streaming media, reducing DNS query latency, etc.")))
|
||||
o.cfgvalue = function(self, section)
|
||||
return m:get(current_node_id, shunt_rules[section]["_fakedns_option"])
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
return m:set(current_node_id, shunt_rules[section]["_fakedns_option"], value)
|
||||
end
|
||||
o.remove = function(self, section)
|
||||
return m:del(current_node_id, shunt_rules[section]["_fakedns_option"])
|
||||
end
|
||||
|
||||
proxy_tag_node = s2:option(ListValue, "_proxy_tag", string.format('<a style="color:red" title="%s">%s</a>',
|
||||
translate("Set the node to be used as a pre-proxy.") .. "\n" .. translate("Each rule has a separate switch that controls whether this rule uses the pre-proxy or not."),
|
||||
translate("Preproxy")))
|
||||
proxy_tag_node.template = appname .. "/cbi/nodes_listvalue"
|
||||
proxy_tag_node.group = {""}
|
||||
proxy_tag_node:value("", translate("Close (Not use)"))
|
||||
proxy_tag_node.cfgvalue = function(self, section)
|
||||
return m:get(current_node_id, shunt_rules[section]["_proxy_tag_option"])
|
||||
end
|
||||
proxy_tag_node.write = function(self, section, value)
|
||||
return m:set(current_node_id, shunt_rules[section]["_proxy_tag_option"], value)
|
||||
end
|
||||
proxy_tag_node.remove = function(self, section)
|
||||
return m:del(current_node_id, shunt_rules[section]["_proxy_tag_option"])
|
||||
end
|
||||
|
||||
for k1, v1 in pairs(node_list) do
|
||||
if k1 ~= "shunt_list" then
|
||||
for i, v in ipairs(v1) do
|
||||
_node:value(v.id, v.remark)
|
||||
_node.group[#_node.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
|
||||
proxy_tag_node:value(v.id, v.remark)
|
||||
proxy_tag_node.group[#proxy_tag_node.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local footer = Template(appname .. "/include/shunt_options")
|
||||
footer.api = api
|
||||
footer.id = current_node_id
|
||||
footer.normal_list = api.jsonc.stringify(node_list.normal_list)
|
||||
m:append(footer)
|
||||
@@ -0,0 +1,8 @@
|
||||
local appname = "passwall2"
|
||||
|
||||
f = SimpleForm(appname)
|
||||
f.reset = false
|
||||
f.submit = false
|
||||
f:append(Template(appname .. "/log/log"))
|
||||
|
||||
return f
|
||||
@@ -0,0 +1,98 @@
|
||||
api = require "luci.passwall2.api"
|
||||
appname = api.appname
|
||||
|
||||
m = Map(appname, translate("Node Config"))
|
||||
m.redirect = api.url()
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(api.url("node_list"))
|
||||
end
|
||||
|
||||
fs = require "nixio.fs"
|
||||
formvalue_key = "cbid." .. appname .. "." .. arg[1] .. "."
|
||||
|
||||
local header = Template(appname .. "/node_config/header")
|
||||
header.api = api
|
||||
header.section = arg[1]
|
||||
m:append(header)
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_multivalue_com"))
|
||||
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
|
||||
|
||||
groups = {}
|
||||
m.uci:foreach(appname, "nodes", function(s)
|
||||
if s[".name"] ~= arg[1] then
|
||||
if s.group and s.group ~= "" then
|
||||
groups[s.group] = true
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
s = m:section(NamedSection, arg[1], "nodes", "")
|
||||
s.addremove = false
|
||||
s.dynamic = false
|
||||
|
||||
o = s:option(DummyValue, "passwall2", " ")
|
||||
o.rawhtml = true
|
||||
o.template = "passwall2/node_config/link_share_man"
|
||||
o.value = arg[1]
|
||||
|
||||
o = s:option(Value, "remarks", translate("Node Remarks"))
|
||||
o.default = translate("Remarks")
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "group", translate("Group Name"))
|
||||
o.default = ""
|
||||
o:value("", translate("default"))
|
||||
for k, v in pairs(groups) do
|
||||
o:value(k)
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
value = api.trim(value)
|
||||
local lower = value:lower()
|
||||
|
||||
if lower == "" or lower == "default" then
|
||||
return m:del(section, self.option)
|
||||
end
|
||||
|
||||
for _, v in ipairs(self.keylist or {}) do
|
||||
if v:lower() == lower then
|
||||
return m:set(section, self.option, v)
|
||||
end
|
||||
end
|
||||
m:set(section, self.option, value)
|
||||
end
|
||||
|
||||
local types_dir = "/usr/lib/lua/luci/model/cbi/passwall2/client/type/"
|
||||
s.val = {}
|
||||
s.val["type"] = m.uci:get(appname, arg[1], "type")
|
||||
s.val["protocol"] = m.uci:get(appname, arg[1], "protocol")
|
||||
|
||||
if luci.http.formvalue("cbi.submit") == "1" then
|
||||
local formvalue_type = luci.http.formvalue(formvalue_key .. "type")
|
||||
if formvalue_type then
|
||||
s.val["type"] = formvalue_type
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(ListValue, "type", translate("Type"))
|
||||
|
||||
local type_table = {}
|
||||
for filename in fs.dir(types_dir) do
|
||||
table.insert(type_table, filename)
|
||||
end
|
||||
table.sort(type_table)
|
||||
|
||||
for index, value in ipairs(type_table) do
|
||||
local p_func = loadfile(types_dir .. value)
|
||||
setfenv(p_func, getfenv(1))(m, s)
|
||||
end
|
||||
|
||||
local footer = Template(appname .. "/node_config/footer")
|
||||
footer.api = api
|
||||
footer.section = arg[1]
|
||||
|
||||
m:append(footer)
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,35 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local datatypes = api.datatypes
|
||||
local sys = api.sys
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
-- [[ Other Settings ]]--
|
||||
s = m:section(TypedSection, "global_other")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(ListValue, "auto_detection_time", translate("Automatic detection delay"))
|
||||
o:value("0", translate("Close"))
|
||||
o:value("icmp", "Ping")
|
||||
o:value("tcping", "TCP Ping")
|
||||
|
||||
o = s:option(Flag, "show_node_info", translate("Show server address and port"))
|
||||
o.default = "0"
|
||||
|
||||
o = s:option(Value, "url_test_url", translate("URL Test Address"))
|
||||
o:value("https://cp.cloudflare.com/", "Cloudflare")
|
||||
o:value("https://www.gstatic.com/generate_204", "Gstatic")
|
||||
o:value("https://www.google.com/generate_204", "Google")
|
||||
o:value("https://www.youtube.com/generate_204", "YouTube")
|
||||
o:value("https://connect.rom.miui.com/generate_204", "MIUI (CN)")
|
||||
o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)")
|
||||
o.default = o.keylist[3]
|
||||
|
||||
-- [[ Add the node via the link ]]--
|
||||
s:append(Template(appname .. "/node_list/link_add_node"))
|
||||
|
||||
m:append(Template(appname .. "/node_list/node_list"))
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,229 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local uci = api.uci
|
||||
local has_ss = api.is_finded("ss-redir")
|
||||
local has_ss_rust = api.is_finded("sslocal")
|
||||
local has_singbox = api.finded_com("sing-box")
|
||||
local has_xray = api.finded_com("xray")
|
||||
local has_hysteria2 = api.finded_com("hysteria")
|
||||
local ss_type = {}
|
||||
local trojan_type = {}
|
||||
local vmess_type = {}
|
||||
local vless_type = {}
|
||||
local hysteria2_type = {}
|
||||
local xray_version = api.get_app_version("xray")
|
||||
if has_ss then
|
||||
local s = "shadowsocks-libev"
|
||||
table.insert(ss_type, s)
|
||||
end
|
||||
if has_ss_rust then
|
||||
local s = "shadowsocks-rust"
|
||||
table.insert(ss_type, s)
|
||||
end
|
||||
if has_singbox then
|
||||
local s = "sing-box"
|
||||
table.insert(trojan_type, s)
|
||||
table.insert(ss_type, s)
|
||||
table.insert(vmess_type, s)
|
||||
table.insert(vless_type, s)
|
||||
table.insert(hysteria2_type, s)
|
||||
end
|
||||
if has_xray then
|
||||
local s = "xray"
|
||||
table.insert(trojan_type, s)
|
||||
table.insert(ss_type, s)
|
||||
table.insert(vmess_type, s)
|
||||
table.insert(vless_type, s)
|
||||
if api.compare_versions(xray_version, ">=", "26.1.13") then
|
||||
table.insert(hysteria2_type, s)
|
||||
end
|
||||
end
|
||||
if has_hysteria2 then
|
||||
local s = "hysteria2"
|
||||
table.insert(hysteria2_type, s)
|
||||
end
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
function m.on_before_save(self)
|
||||
self.uci:foreach(appname, "subscribe_list", function(e)
|
||||
self:del(e[".name"], "md5")
|
||||
end)
|
||||
end
|
||||
|
||||
-- [[ Subscribe Settings ]]--
|
||||
s = m:section(TypedSection, "global_subscribe", "")
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(ListValue, "filter_keyword_mode", translate("Filter keyword Mode"))
|
||||
o:value("0", translate("Close"))
|
||||
o:value("1", translate("Discard List"))
|
||||
o:value("2", translate("Keep List"))
|
||||
o:value("3", translate("Discard List,But Keep List First"))
|
||||
o:value("4", translate("Keep List,But Discard List First"))
|
||||
|
||||
o = s:option(DynamicList, "filter_discard_list", translate("Discard List"))
|
||||
|
||||
o = s:option(DynamicList, "filter_keep_list", translate("Keep List"))
|
||||
|
||||
if #ss_type > 0 then
|
||||
o = s:option(ListValue, "ss_type", translatef("%s Node Use Type", "Shadowsocks"))
|
||||
o:value("", translate("Auto"))
|
||||
for key, value in pairs(ss_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #trojan_type > 0 then
|
||||
o = s:option(ListValue, "trojan_type", translatef("%s Node Use Type", "Trojan"))
|
||||
o:value("", translate("Auto"))
|
||||
for key, value in pairs(trojan_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #vmess_type > 0 then
|
||||
o = s:option(ListValue, "vmess_type", translatef("%s Node Use Type", "VMess"))
|
||||
o:value("", translate("Auto"))
|
||||
for key, value in pairs(vmess_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #vless_type > 0 then
|
||||
o = s:option(ListValue, "vless_type", translatef("%s Node Use Type", "VLESS"))
|
||||
o:value("", translate("Auto"))
|
||||
for key, value in pairs(vless_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #hysteria2_type > 0 then
|
||||
o = s:option(ListValue, "hysteria2_type", translatef("%s Node Use Type", "Hysteria2"))
|
||||
o:value("", translate("Auto"))
|
||||
for key, value in pairs(hysteria2_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #ss_type > 0 or #trojan_type > 0 or #vmess_type > 0 or #vless_type > 0 or #hysteria2_type > 0 then
|
||||
o.description = string.format("<font color='red'>%s</font>",
|
||||
translate("The configured type also applies to the core specified when manually importing nodes."))
|
||||
end
|
||||
|
||||
---- Subscribe Delete All
|
||||
o = s:option(DummyValue, "_stop", translate("Delete All Subscribe Node"))
|
||||
o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
return string.format(
|
||||
[[<input type="button" class="btn cbi-button cbi-button-remove" onclick="return confirmDeleteAll()" value="%s" />]],
|
||||
translate("Delete All Subscribe Node"))
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_update", translate("Manual subscription All"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(self, section)
|
||||
return string.format([[
|
||||
<input type="button" class="btn cbi-button cbi-button-apply" onclick="ManualSubscribeAll()" value="%s" />]],
|
||||
translate("Manual subscription All"))
|
||||
end
|
||||
|
||||
s = m:section(TypedSection, "subscribe_list", "", "<font color='red'>" .. translate("When adding a new subscription, please save and apply before manually subscribing. If you only change the subscription URL, you can subscribe manually, and the system will save it automatically.") .. "</font>")
|
||||
s.addremove = true
|
||||
s.anonymous = true
|
||||
s.sortable = true
|
||||
s.template = "cbi/tblsection"
|
||||
s.extedit = api.url("node_subscribe_config", "%s")
|
||||
function s.create(e, t)
|
||||
local id = TypedSection.create(e, t)
|
||||
luci.http.redirect(e.extedit:format(id))
|
||||
end
|
||||
|
||||
o = s:option(Value, "remark", translate("Remarks"))
|
||||
o.width = "auto"
|
||||
o.rmempty = false
|
||||
o.validate = function(self, value, section)
|
||||
value = api.trim(value)
|
||||
if value == "" then
|
||||
return nil, translate("Remark cannot be empty.")
|
||||
end
|
||||
local duplicate = false
|
||||
m.uci:foreach(appname, "subscribe_list", function(e)
|
||||
if e[".name"] ~= section and e["remark"] and e["remark"]:lower() == value:lower() then
|
||||
duplicate = true
|
||||
return false
|
||||
end
|
||||
end)
|
||||
if duplicate or value:lower() == "default" then
|
||||
return nil, translate("This remark already exists, please change a new remark.")
|
||||
end
|
||||
return value
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
local old = m:get(section, self.option) or ""
|
||||
if old ~= value then
|
||||
m.uci:foreach(appname, "nodes", function(e)
|
||||
if e["group"] and e["group"]:lower() == old:lower() then
|
||||
m.uci:set(appname, e[".name"], "group", value)
|
||||
end
|
||||
if e["protocol"] and (e["protocol"] == "_balancing" or e["protocol"] == "_urltest") and e["node_group"] then
|
||||
local gs = ""
|
||||
for g in e["node_group"]:gmatch("%S+") do
|
||||
if api.UrlEncode(old) == g then
|
||||
gs = gs .. " " .. api.UrlEncode(value)
|
||||
else
|
||||
gs = gs .. " " .. g
|
||||
end
|
||||
end
|
||||
gs = api.trim(gs)
|
||||
m.uci:set(appname, e[".name"], "node_group", gs)
|
||||
end
|
||||
end)
|
||||
end
|
||||
return Value.write(self, section, value)
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_node_count", translate("Subscribe Info"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
local remark = m:get(n, "remark") or ""
|
||||
local str = m:get(n, "rem_traffic") or ""
|
||||
local expired_date = m:get(n, "expired_date") or ""
|
||||
if expired_date ~= "" then
|
||||
str = str .. (str ~= "" and "/" or "") .. expired_date
|
||||
end
|
||||
str = str ~= "" and "<br>" .. str or ""
|
||||
local num = 0
|
||||
m.uci:foreach(appname, "nodes", function(s)
|
||||
if s["group"] and s["group"]:lower() == remark:lower() then
|
||||
num = num + 1
|
||||
end
|
||||
end)
|
||||
return string.format("%s%s", translate("Node num") .. ": " .. num, str)
|
||||
end
|
||||
|
||||
o = s:option(Value, "url", translate("Subscribe URL"))
|
||||
o.width = "auto"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(DummyValue, "_remove", translate("Delete the subscribed node"))
|
||||
o.rawhtml = true
|
||||
function o.cfgvalue(self, section)
|
||||
local remark = m:get(section, "remark") or ""
|
||||
return string.format(
|
||||
[[<input type="button" class="btn cbi-button cbi-button-remove" onclick="return confirmDeleteNode('%s')" value="%s" />]],
|
||||
remark, translate("Delete the subscribed node"))
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "_update", translate("Manual subscription"))
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(self, section)
|
||||
return string.format([[
|
||||
<input type="button" class="btn cbi-button cbi-button-apply" onclick="ManualSubscribe('%s')" value="%s" />]],
|
||||
section, translate("Manual subscription"))
|
||||
end
|
||||
|
||||
m:append(Template(appname .. "/node_subscribe/js"))
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,316 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
|
||||
m = Map(appname)
|
||||
m.redirect = api.url("node_subscribe")
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(m.redirect)
|
||||
end
|
||||
|
||||
function m.on_before_save(self)
|
||||
self:del(arg[1], "md5")
|
||||
end
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
|
||||
|
||||
local has_ss = api.is_finded("ss-redir")
|
||||
local has_ss_rust = api.is_finded("sslocal")
|
||||
local has_singbox = api.finded_com("sing-box")
|
||||
local has_xray = api.finded_com("xray")
|
||||
local has_hysteria2 = api.finded_com("hysteria")
|
||||
local ss_type = {}
|
||||
local trojan_type = {}
|
||||
local vmess_type = {}
|
||||
local vless_type = {}
|
||||
local hysteria2_type = {}
|
||||
local xray_version = api.get_app_version("xray")
|
||||
if has_ss then
|
||||
local s = "shadowsocks-libev"
|
||||
table.insert(ss_type, s)
|
||||
end
|
||||
if has_ss_rust then
|
||||
local s = "shadowsocks-rust"
|
||||
table.insert(ss_type, s)
|
||||
end
|
||||
if has_singbox then
|
||||
local s = "sing-box"
|
||||
table.insert(trojan_type, s)
|
||||
table.insert(ss_type, s)
|
||||
table.insert(vmess_type, s)
|
||||
table.insert(vless_type, s)
|
||||
table.insert(hysteria2_type, s)
|
||||
end
|
||||
if has_xray then
|
||||
local s = "xray"
|
||||
table.insert(trojan_type, s)
|
||||
table.insert(ss_type, s)
|
||||
table.insert(vmess_type, s)
|
||||
table.insert(vless_type, s)
|
||||
if api.compare_versions(xray_version, ">=", "26.1.13") then
|
||||
table.insert(hysteria2_type, s)
|
||||
end
|
||||
end
|
||||
if has_hysteria2 then
|
||||
local s = "hysteria2"
|
||||
table.insert(hysteria2_type, s)
|
||||
end
|
||||
local nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
if e.node_type == "normal" then
|
||||
nodes_table[#nodes_table + 1] = {
|
||||
id = e[".name"],
|
||||
remark = e["remark"],
|
||||
type = e["type"],
|
||||
add_mode = e["add_mode"],
|
||||
chain_proxy = e["chain_proxy"],
|
||||
group = e["group"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
s = m:section(NamedSection, arg[1])
|
||||
s.addremove = false
|
||||
s.dynamic = false
|
||||
|
||||
o = s:option(Value, "remark", translate("Subscribe Remark"))
|
||||
o.rmempty = false
|
||||
o.validate = function(self, value, section)
|
||||
value = api.trim(value)
|
||||
if value == "" then
|
||||
return nil, translate("Remark cannot be empty.")
|
||||
end
|
||||
local duplicate = false
|
||||
m.uci:foreach(appname, "subscribe_list", function(e)
|
||||
if e[".name"] ~= section and e["remark"] and e["remark"]:lower() == value:lower() then
|
||||
duplicate = true
|
||||
return false
|
||||
end
|
||||
end)
|
||||
if duplicate or value:lower() == "default" then
|
||||
return nil, translate("This remark already exists, please change a new remark.")
|
||||
end
|
||||
return value
|
||||
end
|
||||
o.write = function(self, section, value)
|
||||
local old = m:get(section, self.option) or ""
|
||||
if old ~= value then
|
||||
m.uci:foreach(appname, "nodes", function(e)
|
||||
if e["group"] and e["group"]:lower() == old:lower() then
|
||||
m.uci:set(appname, e[".name"], "group", value)
|
||||
end
|
||||
if e["protocol"] and (e["protocol"] == "_balancing" or e["protocol"] == "_urltest") and e["node_group"] then
|
||||
local gs = ""
|
||||
for g in e["node_group"]:gmatch("%S+") do
|
||||
if api.UrlEncode(old) == g then
|
||||
gs = gs .. " " .. api.UrlEncode(value)
|
||||
else
|
||||
gs = gs .. " " .. g
|
||||
end
|
||||
end
|
||||
gs = api.trim(gs)
|
||||
m.uci:set(appname, e[".name"], "node_group", gs)
|
||||
end
|
||||
end)
|
||||
end
|
||||
return Value.write(self, section, value)
|
||||
end
|
||||
|
||||
o = s:option(TextValue, "url", translate("Subscribe URL"))
|
||||
o.rows = 5
|
||||
o.rmempty = false
|
||||
o.validate = function(self, value)
|
||||
if not value or value == "" then
|
||||
return nil, translate("URL cannot be empty.")
|
||||
end
|
||||
return value:gsub("%s+", ""):gsub("%z", "")
|
||||
end
|
||||
|
||||
o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve"))
|
||||
o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "<br>" ..
|
||||
translate("Supports only Xray or Sing-box node types.")
|
||||
o:value("", translate("Auto"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("udp", "UDP")
|
||||
o:value("https", "DoH")
|
||||
|
||||
o = s:option(Value, "domain_resolver_dns", "DNS")
|
||||
o.datatype = "or(ipaddr,ipaddrport)"
|
||||
o:value("114.114.114.114")
|
||||
o:value("223.5.5.5:53")
|
||||
o.default = o.keylist[1]
|
||||
o:depends({ domain_resolver = "tcp" })
|
||||
o:depends({ domain_resolver = "udp" })
|
||||
|
||||
o = s:option(Value, "domain_resolver_dns_https", "DNS")
|
||||
o:value("https://120.53.53.53/dns-query", "DNSPod")
|
||||
o:value("https://223.5.5.5/dns-query", "AliDNS")
|
||||
o.default = o.keylist[1]
|
||||
o:depends({ domain_resolver = "https" })
|
||||
|
||||
o = s:option(ListValue, "domain_strategy", translate("Domain Strategy"), translate("If is domain name, The requested domain name will be resolved to IP before connect."))
|
||||
o.default = ""
|
||||
o:value("", translate("Auto"))
|
||||
o:value("UseIPv4", translate("IPv4 Only"))
|
||||
o:value("UseIPv6", translate("IPv6 Only"))
|
||||
|
||||
o = s:option(Flag, "allowInsecure", translate("allowInsecure"))
|
||||
o.default = "0"
|
||||
o.rmempty = false
|
||||
o.description = translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped.") .. "<br>" ..
|
||||
translate("Used when the node link does not include this parameter.")
|
||||
|
||||
o = s:option(ListValue, "filter_keyword_mode", translate("Filter keyword Mode"))
|
||||
o.default = "5"
|
||||
o:value("0", translate("Close"))
|
||||
o:value("1", translate("Discard List"))
|
||||
o:value("2", translate("Keep List"))
|
||||
o:value("3", translate("Discard List,But Keep List First"))
|
||||
o:value("4", translate("Keep List,But Discard List First"))
|
||||
o:value("5", translate("Use global config"))
|
||||
|
||||
o = s:option(DynamicList, "filter_discard_list", translate("Discard List"))
|
||||
o:depends("filter_keyword_mode", "1")
|
||||
o:depends("filter_keyword_mode", "3")
|
||||
o:depends("filter_keyword_mode", "4")
|
||||
|
||||
o = s:option(DynamicList, "filter_keep_list", translate("Keep List"))
|
||||
o:depends("filter_keyword_mode", "2")
|
||||
o:depends("filter_keyword_mode", "3")
|
||||
o:depends("filter_keyword_mode", "4")
|
||||
|
||||
if #ss_type > 0 then
|
||||
o = s:option(ListValue, "ss_type", translatef("%s Node Use Type", "Shadowsocks"))
|
||||
o.default = "global"
|
||||
o:value("global", translate("Use global config"))
|
||||
for key, value in pairs(ss_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #trojan_type > 0 then
|
||||
o = s:option(ListValue, "trojan_type", translatef("%s Node Use Type", "Trojan"))
|
||||
o.default = "global"
|
||||
o:value("global", translate("Use global config"))
|
||||
for key, value in pairs(trojan_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #vmess_type > 0 then
|
||||
o = s:option(ListValue, "vmess_type", translatef("%s Node Use Type", "VMess"))
|
||||
o.default = "global"
|
||||
o:value("global", translate("Use global config"))
|
||||
for key, value in pairs(vmess_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #vless_type > 0 then
|
||||
o = s:option(ListValue, "vless_type", translatef("%s Node Use Type", "VLESS"))
|
||||
o.default = "global"
|
||||
o:value("global", translate("Use global config"))
|
||||
for key, value in pairs(vless_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
if #hysteria2_type > 0 then
|
||||
o = s:option(ListValue, "hysteria2_type", translatef("%s Node Use Type", "Hysteria2"))
|
||||
o.default = "global"
|
||||
o:value("global", translate("Use global config"))
|
||||
for key, value in pairs(hysteria2_type) do
|
||||
o:value(value)
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Flag, "boot_update", translate("Update Once on Boot"), translate("Updates the subscription the first time runs automatically after each system boot."))
|
||||
o.default = 0
|
||||
|
||||
---- Enable auto update subscribe
|
||||
o = s:option(Flag, "auto_update", translate("Enable auto update subscribe"))
|
||||
o.default = 0
|
||||
o.rmempty = false
|
||||
|
||||
---- Week Update
|
||||
o = s:option(ListValue, "week_update", translate("Update Mode"))
|
||||
o:value(8, translate("Loop Mode"))
|
||||
o:value(7, translate("Every day"))
|
||||
o:value(1, translate("Every Monday"))
|
||||
o:value(2, translate("Every Tuesday"))
|
||||
o:value(3, translate("Every Wednesday"))
|
||||
o:value(4, translate("Every Thursday"))
|
||||
o:value(5, translate("Every Friday"))
|
||||
o:value(6, translate("Every Saturday"))
|
||||
o:value(0, translate("Every Sunday"))
|
||||
o.default = 7
|
||||
o:depends("auto_update", true)
|
||||
o.rmempty = true
|
||||
|
||||
---- Time Update
|
||||
o = s:option(ListValue, "time_update", translate("Update Time(every day)"))
|
||||
for t = 0, 23 do o:value(t, t .. ":00") end
|
||||
o.default = 0
|
||||
o:depends("week_update", "0")
|
||||
o:depends("week_update", "1")
|
||||
o:depends("week_update", "2")
|
||||
o:depends("week_update", "3")
|
||||
o:depends("week_update", "4")
|
||||
o:depends("week_update", "5")
|
||||
o:depends("week_update", "6")
|
||||
o:depends("week_update", "7")
|
||||
o.rmempty = true
|
||||
|
||||
---- Interval Update
|
||||
o = s:option(ListValue, "interval_update", translate("Update Interval(hour)"))
|
||||
for t = 1, 24 do o:value(t, t .. " " .. translate("hour")) end
|
||||
o.default = 2
|
||||
o:depends("week_update", "8")
|
||||
o.rmempty = true
|
||||
|
||||
o = s:option(ListValue, "access_mode", translate("Subscribe URL Access Method"))
|
||||
o.default = ""
|
||||
o:value("", translate("Auto"))
|
||||
o:value("direct", translate("Direct Connection"))
|
||||
o:value("proxy", translate("Proxy"))
|
||||
|
||||
o = s:option(Value, "user_agent", translate("User-Agent"))
|
||||
o.default = "v2rayN/9.99"
|
||||
o:value("curl", "Curl")
|
||||
o:value("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Linux")
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0", "Edge for Windows")
|
||||
o:value("passwall2", "PassWall2")
|
||||
o:value("v2rayN/9.99", "v2rayN")
|
||||
|
||||
o = s:option(ListValue, "chain_proxy", translate("Chain Proxy"))
|
||||
o:value("", translate("Close(Not use)"))
|
||||
o:value("1", translate("Preproxy Node"))
|
||||
o:value("2", translate("Landing Node"))
|
||||
|
||||
local descrStr = "Chained proxy works only with Xray or Sing-box nodes.<br>"
|
||||
descrStr = descrStr .. "You can only use manual or imported nodes as chained nodes."
|
||||
descrStr = translate(descrStr) .. "<br>" .. translate("Only support a layer of proxy.")
|
||||
|
||||
o1 = s:option(ListValue, "preproxy_node", translate("Preproxy Node"))
|
||||
o1:depends({ ["chain_proxy"] = "1" })
|
||||
o1.description = descrStr
|
||||
o1.template = appname .. "/cbi/nodes_listvalue"
|
||||
o1.group = {}
|
||||
|
||||
o2 = s:option(ListValue, "to_node", translate("Landing Node"))
|
||||
o2:depends({ ["chain_proxy"] = "2" })
|
||||
o2.description = descrStr
|
||||
o2.template = appname .. "/cbi/nodes_listvalue"
|
||||
o2.group = {}
|
||||
|
||||
for k, v in pairs(nodes_table) do
|
||||
if (v.type == "Xray" or v.type == "sing-box") and (not v.chain_proxy or v.chain_proxy == "") and v.add_mode ~= "2" then
|
||||
o1:value(v.id, v.remark)
|
||||
o1.group[#o1.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
o2:value(v.id, v.remark)
|
||||
o2.group[#o2.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,262 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local fs = api.fs
|
||||
local has_singbox = api.finded_com("sing-box")
|
||||
local has_xray = api.finded_com("xray")
|
||||
|
||||
local port_validate = function(self, value, t)
|
||||
return value:gsub("-", ":")
|
||||
end
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
-- [[ Delay Settings ]]--
|
||||
s = m:section(TypedSection, "global_delay", translate("Delay Settings"))
|
||||
s.anonymous = true
|
||||
s.addremove = false
|
||||
|
||||
---- Open and close Daemon
|
||||
o = s:option(Flag, "start_daemon", translate("Open and close Daemon"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
---- Delay Start
|
||||
o = s:option(Value, "start_delay", translate("Delay Start"), translate("Units:seconds"))
|
||||
o.default = "1"
|
||||
o.rmempty = true
|
||||
|
||||
for index, value in ipairs({"stop", "start", "restart"}) do
|
||||
o = s:option(ListValue, value .. "_week_mode", translate(value .. " automatically mode"))
|
||||
o:value("", translate("Disable"))
|
||||
o:value(8, translate("Loop Mode"))
|
||||
o:value(7, translate("Every day"))
|
||||
o:value(1, translate("Every Monday"))
|
||||
o:value(2, translate("Every Tuesday"))
|
||||
o:value(3, translate("Every Wednesday"))
|
||||
o:value(4, translate("Every Thursday"))
|
||||
o:value(5, translate("Every Friday"))
|
||||
o:value(6, translate("Every Saturday"))
|
||||
o:value(0, translate("Every Sunday"))
|
||||
|
||||
o = s:option(ListValue, value .. "_time_mode", translate(value .. " Time(Every day)"))
|
||||
for t = 0, 23 do o:value(t, t .. ":00") end
|
||||
o.default = 0
|
||||
o:depends(value .. "_week_mode", "0")
|
||||
o:depends(value .. "_week_mode", "1")
|
||||
o:depends(value .. "_week_mode", "2")
|
||||
o:depends(value .. "_week_mode", "3")
|
||||
o:depends(value .. "_week_mode", "4")
|
||||
o:depends(value .. "_week_mode", "5")
|
||||
o:depends(value .. "_week_mode", "6")
|
||||
o:depends(value .. "_week_mode", "7")
|
||||
|
||||
o = s:option(ListValue, value .. "_interval_mode", translate(value .. " Interval(Hour)"))
|
||||
for t = 1, 24 do o:value(t, t .. " " .. translate("Hour")) end
|
||||
o.default = 2
|
||||
o:depends(value .. "_week_mode", "8")
|
||||
end
|
||||
|
||||
-- [[ Forwarding Settings ]]--
|
||||
s = m:section(TypedSection, "global_forwarding", translate("Forwarding Settings"))
|
||||
s.anonymous = true
|
||||
s.addremove = false
|
||||
|
||||
---- TCP No Redir Ports
|
||||
o = s:option(Value, "tcp_no_redir_ports", translate("TCP No Redir Ports"))
|
||||
o.default = "disable"
|
||||
o:value("disable", translate("No patterns are used"))
|
||||
o:value("1:65535", translate("All"))
|
||||
o.validate = port_validate
|
||||
|
||||
---- UDP No Redir Ports
|
||||
o = s:option(Value, "udp_no_redir_ports", translate("UDP No Redir Ports"),
|
||||
"<font color='red'>" ..
|
||||
translate("Fill in the ports you don't want to be forwarded by the agent, with the highest priority.") ..
|
||||
"</font>")
|
||||
o.default = "disable"
|
||||
o:value("disable", translate("No patterns are used"))
|
||||
o:value("1:65535", translate("All"))
|
||||
o.validate = port_validate
|
||||
|
||||
---- TCP Redir Ports
|
||||
o = s:option(Value, "tcp_redir_ports", translate("TCP Redir Ports"))
|
||||
o.default = "22,25,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418"
|
||||
o:value("1:65535", translate("All"))
|
||||
o:value("22,25,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418", translate("Common Use"))
|
||||
o:value("80,443", translate("Only Web"))
|
||||
o.validate = port_validate
|
||||
|
||||
---- UDP Redir Ports
|
||||
o = s:option(Value, "udp_redir_ports", translate("UDP Redir Ports"))
|
||||
o.default = "1:65535"
|
||||
o:value("1:65535", translate("All"))
|
||||
o.validate = port_validate
|
||||
|
||||
o = s:option(DummyValue, "tips", " ")
|
||||
o.rawhtml = true
|
||||
o.cfgvalue = function(t, n)
|
||||
return string.format('<font color="red">%s</font>',
|
||||
translate("The port settings support single ports and ranges.<br>Separate multiple ports with commas (,).<br>Example: 21,80,443,1000:2000."))
|
||||
end
|
||||
|
||||
---- Use nftables
|
||||
o = s:option(ListValue, "prefer_nft", translate("Prefer firewall tools"))
|
||||
o.default = "1"
|
||||
o:value("0", "Iptables")
|
||||
o:value("1", "Nftables")
|
||||
|
||||
---- Check the transparent proxy component
|
||||
local handle = io.popen("lsmod")
|
||||
local mods = ""
|
||||
if handle then
|
||||
mods = handle:read("*a") or ""
|
||||
handle:close()
|
||||
end
|
||||
|
||||
if (mods:find("REDIRECT") and mods:find("TPROXY")) or (mods:find("nft_redir") and mods:find("nft_tproxy")) then
|
||||
o = s:option(ListValue, "tcp_proxy_way", translate("TCP Proxy Way"))
|
||||
o.default = "redirect"
|
||||
o:value("redirect", "REDIRECT")
|
||||
o:value("tproxy", "TPROXY")
|
||||
o:depends("ipv6_tproxy", false)
|
||||
o.remove = function(self, section)
|
||||
-- Do not delete while hidden
|
||||
end
|
||||
|
||||
o = s:option(ListValue, "_tcp_proxy_way", translate("TCP Proxy Way"))
|
||||
o.default = "tproxy"
|
||||
o:value("tproxy", "TPROXY")
|
||||
o:depends("ipv6_tproxy", true)
|
||||
o.write = function(self, section, value)
|
||||
self.map:set(section, "tcp_proxy_way", value)
|
||||
end
|
||||
|
||||
if mods:find("ip6table_mangle") or mods:find("nft_tproxy") then
|
||||
---- IPv6 TProxy
|
||||
o = s:option(Flag, "ipv6_tproxy", translate("IPv6 TProxy"),
|
||||
"<font color='red'>" ..
|
||||
translate("Experimental feature. Make sure that your node supports IPv6.") ..
|
||||
"</font>")
|
||||
o.default = 0
|
||||
o.rmempty = false
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Flag, "accept_icmp", translate("Hijacking ICMP (PING)"))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Flag, "accept_icmpv6", translate("Hijacking ICMPv6 (IPv6 PING)"))
|
||||
o:depends("ipv6_tproxy", true)
|
||||
o.default = 0
|
||||
|
||||
o = s:option(DynamicList, "force_proxy_lan_ip", translate("Force Proxy LAN IP"), translate("By default, commonly used internal network IP ranges will be connect directly (not entering the core). If you want a certain network range to go through a proxy, please add it here."))
|
||||
o.datatype = "or(ipmask4,ipmask6)"
|
||||
|
||||
if has_xray then
|
||||
s_xray = m:section(TypedSection, "global_xray", "Xray " .. translate("Settings"))
|
||||
s_xray.anonymous = true
|
||||
s_xray.addremove = false
|
||||
|
||||
o = s_xray:option(Flag, "fragment", translate("Fragment"), translate("TCP fragments, which can deceive the censorship system in some cases, such as bypassing SNI blacklists."))
|
||||
o.default = 0
|
||||
|
||||
o = s_xray:option(ListValue, "fragment_packets", translate("Fragment Packets"), translate(" \"1-3\" is for segmentation at TCP layer, applying to the beginning 1 to 3 data writes by the client. \"tlshello\" is for TLS client hello packet fragmentation."))
|
||||
o.default = "tlshello"
|
||||
o:value("tlshello", "tlshello")
|
||||
o:value("1-1", "1-1")
|
||||
o:value("1-2", "1-2")
|
||||
o:value("1-3", "1-3")
|
||||
o:value("1-5", "1-5")
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Value, "fragment_length", translate("Fragment Length"), translate("Fragmented packet length (byte)"))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.default = "100-200"
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Value, "fragment_delay", translate("Fragment Delay"), translate("Fragmentation interval (ms)"))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.default = "10-20"
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Value, "fragment_maxSplit", translate("Max Split"), translate("Limit the maximum number of splits."))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.default = "100-200"
|
||||
o:depends("fragment", true)
|
||||
|
||||
o = s_xray:option(Flag, "noise", translate("Noise"), translate("UDP noise, Under some circumstances it can bypass some UDP based protocol restrictions."))
|
||||
o.default = 0
|
||||
|
||||
o = s_xray:option(Flag, "sniffing_override_dest", translate("Override the connection destination address"))
|
||||
o.default = 0
|
||||
o.description = translate("Override the connection destination address with the sniffed domain.<br />Otherwise use sniffed domain for routing only.<br />If using shunt nodes, configure the domain shunt rules correctly.")
|
||||
|
||||
o = s_xray:option(Flag, "route_only", translate("Sniffing Route Only"))
|
||||
o.default = 0
|
||||
o:depends("sniffing", true)
|
||||
|
||||
local domains_excluded = string.format("/usr/share/%s/domains_excluded", appname)
|
||||
o = s_xray:option(TextValue, "excluded_domains", translate("Excluded Domains"), translate("If the traffic sniffing result is in this list, the destination address will not be overridden."))
|
||||
o.rows = 15
|
||||
o.wrap = "off"
|
||||
o.cfgvalue = function(self, section) return fs.readfile(domains_excluded) or "" end
|
||||
o.write = function(self, section, value) fs.writefile(domains_excluded, value:gsub("\r\n", "\n")) end
|
||||
o:depends({sniffing_override_dest = true})
|
||||
|
||||
o = s_xray:option(Value, "buffer_size", translate("Buffer Size"), translate("Buffer size for every connection (kB)"))
|
||||
o.datatype = "uinteger"
|
||||
|
||||
s_xray_noise = m:section(TypedSection, "xray_noise_packets", translate("Xray Noise Packets"),"<font color='red'>" .. translate("To send noise packets, select \"Noise\" in Xray Settings.") .. "</font>")
|
||||
s_xray_noise.template = "cbi/tblsection"
|
||||
s_xray_noise.sortable = true
|
||||
s_xray_noise.anonymous = true
|
||||
s_xray_noise.addremove = true
|
||||
|
||||
s_xray_noise.create = function(e, t)
|
||||
TypedSection.create(e, api.gen_short_uuid())
|
||||
end
|
||||
|
||||
s_xray_noise.remove = function(self, section)
|
||||
for k, v in pairs(self.children) do
|
||||
v.rmempty = true
|
||||
v.validate = nil
|
||||
end
|
||||
TypedSection.remove(self, section)
|
||||
end
|
||||
|
||||
o = s_xray_noise:option(Flag, "enabled", translate("Enable"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
o = s_xray_noise:option(ListValue, "type", translate("Type"))
|
||||
o:value("rand", "rand")
|
||||
o:value("array", "array")
|
||||
o:value("str", "str")
|
||||
o:value("hex", "hex")
|
||||
o:value("base64", "base64")
|
||||
|
||||
o = s_xray_noise:option(Value, "packet", translate("Packet | Rand Length"))
|
||||
o.datatype = "minlength(1)"
|
||||
o.rmempty = false
|
||||
|
||||
o = s_xray_noise:option(Value, "delay", translate("Delay (ms)"))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.rmempty = false
|
||||
end
|
||||
|
||||
if has_singbox then
|
||||
s = m:section(TypedSection, "global_singbox", "Sing-Box " .. translate("Settings"))
|
||||
s.anonymous = true
|
||||
s.addremove = false
|
||||
|
||||
o = s:option(Flag, "record_fragment", "TLS Record " .. translate("Fragment"),
|
||||
translate("Split handshake data into multiple TLS records for better censorship evasion. Low overhead. Recommended to enable first."))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Flag, "fragment", "TLS TCP " .. translate("Fragment"),
|
||||
translate("Split handshake into multiple TCP segments. Enhances obfuscation. May increase delay. Use only if needed."))
|
||||
o.default = 0
|
||||
end
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,107 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
-- [[ Rule Settings ]]--
|
||||
s = m:section(TypedSection, "global_rules", translate("Rule status"))
|
||||
s.anonymous = true
|
||||
|
||||
o = s:option(Value, "geoip_url", translate("GeoIP Update URL"))
|
||||
o:value("https://github.com/Loyalsoldier/geoip/releases/latest/download/geoip.dat", translate("Loyalsoldier/geoip"))
|
||||
o:value("https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geoip.dat", translate("MetaCubeX/geoip"))
|
||||
o:value("https://cdn.jsdelivr.net/gh/Loyalsoldier/geoip@release/geoip.dat", translate("Loyalsoldier/geoip (CDN)"))
|
||||
o:value("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat", translate("MetaCubeX/geoip (CDN)"))
|
||||
o:value("https://github.com/Chocolate4U/Iran-v2ray-rules/releases/latest/download/geoip.dat", translate("Chocolate4U/geoip (IR)"))
|
||||
o:value("https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geoip.dat", translate("runetfreedom/geoip (RU)"))
|
||||
o.default = o.keylist[1]
|
||||
|
||||
o = s:option(Value, "geosite_url", translate("Geosite Update URL"))
|
||||
o:value("https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat", translate("Loyalsoldier/geosite"))
|
||||
o:value("https://github.com/MetaCubeX/meta-rules-dat/releases/latest/download/geosite.dat", translate("MetaCubeX/geosite"))
|
||||
o:value("https://cdn.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/geosite.dat", translate("Loyalsoldier/geosite (CDN)"))
|
||||
o:value("https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geosite.dat", translate("MetaCubeX/geosite (CDN)"))
|
||||
o:value("https://github.com/Chocolate4U/Iran-v2ray-rules/releases/latest/download/geosite.dat", translate("Chocolate4U/geosite (IR)"))
|
||||
o:value("https://github.com/runetfreedom/russia-v2ray-rules-dat/releases/latest/download/geosite.dat", translate("runetfreedom/geosite (RU)"))
|
||||
o.default = o.keylist[1]
|
||||
|
||||
o = s:option(Value, "v2ray_location_asset", translate("Location of Geo rule files"), translate("This variable specifies a directory where geoip.dat and geosite.dat files are."))
|
||||
o.default = "/usr/share/v2ray/"
|
||||
o.placeholder = o.default
|
||||
o.rmempty = false
|
||||
|
||||
---- Auto Update
|
||||
o = s:option(Flag, "auto_update", translate("Enable auto update rules"))
|
||||
o.default = 0
|
||||
o.rmempty = false
|
||||
|
||||
---- Week Update
|
||||
o = s:option(ListValue, "week_update", translate("Update Mode"))
|
||||
o:value(8, translate("Loop Mode"))
|
||||
o:value(7, translate("Every day"))
|
||||
o:value(1, translate("Every Monday"))
|
||||
o:value(2, translate("Every Tuesday"))
|
||||
o:value(3, translate("Every Wednesday"))
|
||||
o:value(4, translate("Every Thursday"))
|
||||
o:value(5, translate("Every Friday"))
|
||||
o:value(6, translate("Every Saturday"))
|
||||
o:value(0, translate("Every Sunday"))
|
||||
o.default = 7
|
||||
o:depends("auto_update", true)
|
||||
o.rmempty = true
|
||||
|
||||
---- Time Update
|
||||
o = s:option(ListValue, "time_update", translate("Update Time(every day)"))
|
||||
for t = 0, 23 do o:value(t, t .. ":00") end
|
||||
o.default = 0
|
||||
o:depends("week_update", "0")
|
||||
o:depends("week_update", "1")
|
||||
o:depends("week_update", "2")
|
||||
o:depends("week_update", "3")
|
||||
o:depends("week_update", "4")
|
||||
o:depends("week_update", "5")
|
||||
o:depends("week_update", "6")
|
||||
o:depends("week_update", "7")
|
||||
o.rmempty = true
|
||||
|
||||
---- Interval Update
|
||||
o = s:option(ListValue, "interval_update", translate("Update Interval(hour)"))
|
||||
for t = 1, 24 do o:value(t, t .. " " .. translate("hour")) end
|
||||
o.default = 2
|
||||
o:depends("week_update", "8")
|
||||
o.rmempty = true
|
||||
|
||||
--- The update option is always hidden by JavaScript.
|
||||
local flags = {
|
||||
"geoip_update", "geosite_update"
|
||||
}
|
||||
for _, f in ipairs(flags) do
|
||||
o = s:option(Flag, f)
|
||||
o.rmempty = false
|
||||
end
|
||||
|
||||
s:append(Template(appname .. "/rule/rule_version"))
|
||||
|
||||
s = m:section(TypedSection, "shunt_rules", "Sing-Box/Xray " .. translate("Shunt Rule"), "<a style='color: red'>" .. translate("Please note attention to the priority, the higher the order, the higher the priority.") .. "</a>")
|
||||
s.template = "cbi/tblsection"
|
||||
s.anonymous = false
|
||||
s.addremove = true
|
||||
s.sortable = true
|
||||
s.extedit = api.url("shunt_rules", "%s")
|
||||
function s.create(e, t)
|
||||
TypedSection.create(e, t)
|
||||
luci.http.redirect(e.extedit:format(t))
|
||||
end
|
||||
function s.remove(e, t)
|
||||
m.uci:foreach(appname, "nodes", function(s)
|
||||
if s["protocol"] and s["protocol"] == "_shunt" then
|
||||
m:del(s[".name"], t)
|
||||
end
|
||||
end)
|
||||
TypedSection.remove(e, t)
|
||||
end
|
||||
|
||||
o = s:option(DummyValue, "remarks", translate("Remarks"))
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,251 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local datatypes = api.datatypes
|
||||
|
||||
m = Map(appname, "Sing-Box/Xray " .. translate("Shunt Rule"))
|
||||
m.redirect = api.url("rule")
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(m.redirect)
|
||||
end
|
||||
|
||||
function m.on_before_save(self)
|
||||
m:set("@global[0]", "flush_set", "1")
|
||||
end
|
||||
|
||||
-- Add inline CSS to map description
|
||||
m.description = (m.description or "") .. [[
|
||||
<style>
|
||||
/* Scope to passwall2 CBI fields so it won't leak elsewhere */
|
||||
div[id^="cbid.passwall2."] .cbi-value-field {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1em;
|
||||
}
|
||||
div[id^="cbid.passwall2."] .cbi-checkbox {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
]]
|
||||
|
||||
function clean_text(text)
|
||||
local nbsp = string.char(0xC2, 0xA0) -- Non-breaking space (U+00A0)
|
||||
local fullwidth_space = string.char(0xE3, 0x80, 0x80) -- Full-width space (U+3000)
|
||||
return text
|
||||
:gsub("\t", " ")
|
||||
:gsub(nbsp, " ")
|
||||
:gsub(fullwidth_space, " ")
|
||||
:gsub("^%s+", "")
|
||||
:gsub("%s+$", "\n")
|
||||
:gsub("\r\n", "\n")
|
||||
:gsub("[ \t]*\n[ \t]*", "\n")
|
||||
end
|
||||
|
||||
s = m:section(NamedSection, arg[1], "shunt_rules", "")
|
||||
s.addremove = false
|
||||
s.dynamic = false
|
||||
|
||||
remarks = s:option(Value, "remarks", translate("Remarks"))
|
||||
remarks.default = arg[1]
|
||||
remarks.rmempty = false
|
||||
remarks.validate = function(self, value, section)
|
||||
value = api.trim(value)
|
||||
if value == "" then
|
||||
return nil, translate("Remark cannot be empty.")
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
protocol = s:option(MultiValue, "protocol", translate("Protocol"))
|
||||
protocol:value("http")
|
||||
protocol:value("tls")
|
||||
protocol:value("quic")
|
||||
protocol:value("bittorrent")
|
||||
protocol.widget = "checkbox"
|
||||
protocol.default = nil
|
||||
protocol.optional = false
|
||||
|
||||
o = s:option(MultiValue, "inbound", translate("Inbound Tag"))
|
||||
o:value("tproxy", translate("Transparent proxy"))
|
||||
o:value("socks", "Socks")
|
||||
o.widget = "checkbox"
|
||||
o.default = nil
|
||||
o.optional = false
|
||||
|
||||
network = s:option(ListValue, "network", translate("Network"))
|
||||
network:value("tcp,udp", "TCP UDP")
|
||||
network:value("tcp", "TCP")
|
||||
network:value("udp", "UDP")
|
||||
|
||||
source = s:option(DynamicList, "source", translate("Source"))
|
||||
source.description = "<ul><li>" .. translate("Example:")
|
||||
.. "</li><li>" .. translate("IP") .. ": 192.168.1.100"
|
||||
.. "</li><li>" .. translate("IP CIDR") .. ": 192.168.1.0/24"
|
||||
.. "</li><li>" .. translate("GeoIP") .. ": geoip:private"
|
||||
.. "</li></ul>"
|
||||
source.cast = "string"
|
||||
source.cfgvalue = function(self, section)
|
||||
local value
|
||||
if self.tag_error[section] then
|
||||
value = self:formvalue(section)
|
||||
else
|
||||
value = self.map:get(section, self.option)
|
||||
if type(value) == "string" then
|
||||
local value2 = {}
|
||||
string.gsub(value, '[^' .. " " .. ']+', function(w) table.insert(value2, w) end)
|
||||
value = value2
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
source.validate = function(self, value, t)
|
||||
local err = {}
|
||||
for _, v in ipairs(value) do
|
||||
local flag = false
|
||||
if datatypes.ip4addr(v) then
|
||||
flag = true
|
||||
end
|
||||
|
||||
if flag == false and v:find("geoip:") and v:find("geoip:") == 1 then
|
||||
flag = true
|
||||
end
|
||||
|
||||
if flag == false then
|
||||
err[#err + 1] = v
|
||||
end
|
||||
end
|
||||
|
||||
if #err > 0 then
|
||||
self:add_error(t, "invalid", translate("Not true format, please re-enter!"))
|
||||
for _, v in ipairs(err) do
|
||||
self:add_error(t, "invalid", v)
|
||||
end
|
||||
end
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
local dynamicList_write = function(self, section, value)
|
||||
local t = {}
|
||||
local t2 = {}
|
||||
if type(value) == "table" then
|
||||
local x
|
||||
for _, x in ipairs(value) do
|
||||
if x and #x > 0 then
|
||||
if not t2[x] then
|
||||
t2[x] = x
|
||||
t[#t+1] = x
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
t = { value }
|
||||
end
|
||||
t = table.concat(t, " ")
|
||||
return DynamicList.write(self, section, t)
|
||||
end
|
||||
|
||||
source.write = dynamicList_write
|
||||
|
||||
--[[
|
||||
-- Too low usage rate, hidden
|
||||
sourcePort = s:option(Value, "sourcePort", translate("Source port"))
|
||||
]]--
|
||||
|
||||
port = s:option(Value, "port", translate("Port"))
|
||||
|
||||
domain_list = s:option(TextValue, "domain_list", translate("Domain"))
|
||||
domain_list.rows = 10
|
||||
domain_list.wrap = "off"
|
||||
domain_list.validate = function(self, value)
|
||||
local hosts= {}
|
||||
value = clean_text(value)
|
||||
string.gsub(value, "[^\r\n]+", function(w) table.insert(hosts, w) end)
|
||||
for index, host in ipairs(hosts) do
|
||||
local flag = 1
|
||||
local tmp_host = host
|
||||
if not host:find("#") and host:find("%s") then
|
||||
elseif host:find("regexp:") and host:find("regexp:") == 1 then
|
||||
flag = 0
|
||||
elseif host:find("domain:.") and host:find("domain:.") == 1 then
|
||||
tmp_host = host:gsub("domain:", "")
|
||||
elseif host:find("full:.") and host:find("full:.") == 1 then
|
||||
tmp_host = host:gsub("full:", "")
|
||||
elseif host:find("geosite:") and host:find("geosite:") == 1 then
|
||||
flag = 0
|
||||
elseif host:find("ext:") and host:find("ext:") == 1 then
|
||||
flag = 0
|
||||
elseif host:find("rule-set:", 1, true) == 1 or host:find("rs:") == 1 then
|
||||
local w = host:sub(host:find(":") + 1, #host)
|
||||
if w:find("local:") == 1 or w:find("remote:") == 1 then
|
||||
flag = 0
|
||||
end
|
||||
elseif host:find("#") and host:find("#") == 1 then
|
||||
flag = 0
|
||||
end
|
||||
if flag == 1 then
|
||||
if not datatypes.hostname(tmp_host) then
|
||||
return nil, tmp_host .. " " .. translate("Not valid domain name, please re-enter!")
|
||||
end
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
domain_list.description = "<br /><ul>"
|
||||
.. "<li>" .. translate("Plaintext: If this string matches any part of the targeting domain, this rule takes effet. Example: rule 'sina.com' matches targeting domain 'sina.com', 'sina.com.cn' and 'www.sina.com', but not 'sina.cn'.") .. "</li>"
|
||||
.. "<li>" .. translate("Regular expression: Begining with 'regexp:', the rest is a regular expression. When the regexp matches targeting domain, this rule takes effect. Example: rule 'regexp:\\.goo.*\\.com$' matches 'www.google.com' and 'fonts.googleapis.com', but not 'google.com'.") .. "</li>"
|
||||
.. "<li>" .. translate("Subdomain (recommended): Begining with 'domain:' and the rest is a domain. When the targeting domain is exactly the value, or is a subdomain of the value, this rule takes effect. Example: rule 'domain:v2ray.com' matches 'www.v2ray.com', 'v2ray.com', but not 'xv2ray.com'.") .. "</li>"
|
||||
.. "<li>" .. translate("Full domain: Begining with 'full:' and the rest is a domain. When the targeting domain is exactly the value, the rule takes effect. Example: rule 'domain:v2ray.com' matches 'v2ray.com', but not 'www.v2ray.com'.") .. "</li>"
|
||||
.. "<li>" .. translate("Pre-defined domain list: Begining with 'geosite:' and the rest is a name, such as geosite:google or geosite:cn.") .. "</li>"
|
||||
.. "<li>"
|
||||
.. translate("Sing-Box rule-set: Begining with 'rule-set:remote:' or 'rule-set:local:'")
|
||||
.. "<ul>"
|
||||
.. "<li>" .. translate("Such as:") .. "'rule-set:remote:https://raw.githubusercontent.com/SagerNet/sing-geosite/rule-set/geosite-cn.srs'" .. "</li>"
|
||||
.. "<li>" .. translate("Such as:") .. "'rule-set:local:/usr/share/sing-box/geosite-cn.srs'" .. "</li>"
|
||||
.. "</ul>"
|
||||
.. "</li>"
|
||||
.. "<li>" .. translate("Annotation: Begining with #") .. "</li>"
|
||||
.. "</ul>"
|
||||
ip_list = s:option(TextValue, "ip_list", "IP")
|
||||
ip_list.rows = 10
|
||||
ip_list.wrap = "off"
|
||||
ip_list.validate = function(self, value)
|
||||
local ipmasks= {}
|
||||
value = clean_text(value)
|
||||
string.gsub(value, "[^\r\n]+", function(w) table.insert(ipmasks, w) end)
|
||||
for index, ipmask in ipairs(ipmasks) do
|
||||
if ipmask:find("geoip:") and ipmask:find("geoip:") == 1 and not ipmask:find("%s") then
|
||||
elseif ipmask:find("ext:") and ipmask:find("ext:") == 1 and not ipmask:find("%s") then
|
||||
elseif ipmask:find("rule-set:", 1, true) == 1 or ipmask:find("rs:") == 1 then
|
||||
local w = ipmask:sub(ipmask:find(":") + 1, #ipmask)
|
||||
if w:find("local:") == 1 or w:find("remote:") == 1 then
|
||||
flag = 0
|
||||
end
|
||||
elseif ipmask:find("#") and ipmask:find("#") == 1 then
|
||||
else
|
||||
if not (datatypes.ipmask4(ipmask) or datatypes.ipmask6(ipmask)) then
|
||||
return nil, ipmask .. " " .. translate("Not valid IP format, please re-enter!")
|
||||
end
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
ip_list.description = "<br /><ul>"
|
||||
.. "<li>" .. translate("IP: such as '127.0.0.1'.") .. "</li>"
|
||||
.. "<li>" .. translate("CIDR: such as '127.0.0.0/8'.") .. "</li>"
|
||||
.. "<li>" .. translate("GeoIP: such as 'geoip:cn'. It begins with geoip: (lower case) and followed by two letter of country code.") .. "</li>"
|
||||
.. "<li>"
|
||||
.. translate("Sing-Box rule-set: Begining with 'rule-set:remote:' or 'rule-set:local:'")
|
||||
.. "<ul>"
|
||||
.. "<li>" .. translate("Such as:") .. "'rule-set:remote:https://raw.githubusercontent.com/SagerNet/sing-geoip/rule-set/geoip-cn.srs'" .. "</li>"
|
||||
.. "<li>" .. translate("Such as:") .. "'rule-set:local:/usr/share/sing-box/geoip-cn.srs'" .. "</li>"
|
||||
.. "</ul>"
|
||||
.. "</li>"
|
||||
.. "<li>" .. translate("Annotation: Begining with #") .. "</li>"
|
||||
.. "</ul>"
|
||||
|
||||
o = s:option(Flag, "invert", "invert", translate("Invert match result.") .. " " .. translate("Only support Sing-Box."))
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,138 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
|
||||
m = Map(appname)
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(api.url())
|
||||
end
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_dynamiclist_com"))
|
||||
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
|
||||
|
||||
local has_singbox = api.finded_com("sing-box")
|
||||
local has_xray = api.finded_com("xray")
|
||||
|
||||
local nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
nodes_table[#nodes_table + 1] = e
|
||||
end
|
||||
|
||||
s = m:section(NamedSection, arg[1], translate("Socks Config"), translate("Socks Config"))
|
||||
s.addremove = false
|
||||
s.dynamic = false
|
||||
|
||||
---- Enable
|
||||
o = s:option(Flag, "enabled", translate("Enable"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
local auto_switch_tip
|
||||
local current_node = api.get_cache_var("socks_" .. arg[1])
|
||||
if current_node then
|
||||
local n = m:get(current_node)
|
||||
if n then
|
||||
if tonumber(m:get(arg[1], "enable_autoswitch") or 0) == 1 then
|
||||
if n then
|
||||
local remarks = api.get_node_remarks(n)
|
||||
local url = api.url("node_config", n[".name"])
|
||||
auto_switch_tip = translatef("Current node: %s", string.format('<a href="%s">%s</a>', url, remarks)) .. "<br />"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
socks_node = s:option(ListValue, "node", translate("Node"))
|
||||
if auto_switch_tip then
|
||||
socks_node.description = auto_switch_tip
|
||||
end
|
||||
socks_node.template = appname .. "/cbi/nodes_listvalue"
|
||||
socks_node.group = {}
|
||||
|
||||
o = s:option(Flag, "bind_local", translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
|
||||
local n = 1
|
||||
m.uci:foreach(appname, "socks", function(s)
|
||||
if s[".name"] == section then
|
||||
return false
|
||||
end
|
||||
n = n + 1
|
||||
end)
|
||||
|
||||
o = s:option(Value, "port", "Socks " .. translate("Listen Port"))
|
||||
o.default = n + 1080
|
||||
o.datatype = "port"
|
||||
o.rmempty = false
|
||||
|
||||
if has_singbox or has_xray then
|
||||
o = s:option(Value, "http_port", "HTTP " .. translate("Listen Port") .. " " .. translate("0 is not use"))
|
||||
o.default = 0
|
||||
o.datatype = "port"
|
||||
end
|
||||
|
||||
o = s:option(Flag, "log", translate("Enable") .. " " .. translate("Log"))
|
||||
o.default = 1
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Flag, "enable_autoswitch", translate("Auto Switch"))
|
||||
o.default = 0
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "autoswitch_testing_time", translate("How often to test"), translate("Units:seconds"))
|
||||
o.datatype = "min(10)"
|
||||
o.default = 30
|
||||
o:depends("enable_autoswitch", true)
|
||||
|
||||
o = s:option(Value, "autoswitch_connect_timeout", translate("Timeout seconds"), translate("Units:seconds"))
|
||||
o.datatype = "min(1)"
|
||||
o.default = 3
|
||||
o:depends("enable_autoswitch", true)
|
||||
|
||||
o = s:option(Value, "autoswitch_retry_num", translate("Timeout retry num"))
|
||||
o.datatype = "min(1)"
|
||||
o.default = 1
|
||||
o:depends("enable_autoswitch", true)
|
||||
|
||||
o = s:option(DynamicList, "autoswitch_backup_node", translate("List of backup nodes"))
|
||||
o:depends("enable_autoswitch", true)
|
||||
o.template = appname .. "/cbi/nodes_dynamiclist"
|
||||
o.group = {}
|
||||
o.write = function(self, section, value)
|
||||
local n = s.fields["node"]:formvalue(section)
|
||||
local v = type(value) == "table" and value or { value }
|
||||
local t = {}
|
||||
for _, x in ipairs(v) do
|
||||
if x and x ~= n then t[#t+1] = x end
|
||||
end
|
||||
return DynamicList.write(self, section, t)
|
||||
end
|
||||
for i, v in pairs(nodes_table) do
|
||||
if v.protocol ~= "_shunt" then
|
||||
o:value(v.id, v.remark)
|
||||
o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
s.fields["enable_autoswitch"]:depends({ node = v.id })
|
||||
end
|
||||
socks_node:value(v.id, v["remark"])
|
||||
socks_node.group[#socks_node.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
|
||||
o = s:option(Flag, "autoswitch_restore_switch", translate("Restore Switch"), translate("When detects main node is available, switch back to the main node."))
|
||||
o:depends("enable_autoswitch", true)
|
||||
|
||||
o = s:option(Value, "autoswitch_probe_url", translate("Probe URL"), translate("The URL used to detect the connection status."))
|
||||
o:value("https://cp.cloudflare.com/", "Cloudflare")
|
||||
o:value("https://www.gstatic.com/generate_204", "Gstatic")
|
||||
o:value("https://www.google.com/generate_204", "Google")
|
||||
o:value("https://www.youtube.com/generate_204", "YouTube")
|
||||
o:value("https://connect.rom.miui.com/generate_204", "MIUI (CN)")
|
||||
o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)")
|
||||
o.default = o.keylist[3]
|
||||
o:depends("enable_autoswitch", true)
|
||||
|
||||
o = s:option(DummyValue, "btn")
|
||||
o.template = appname .. "/socks_auto_switch/btn"
|
||||
o:depends("enable_autoswitch", true)
|
||||
|
||||
return m
|
||||
@@ -0,0 +1,81 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.finded_com("hysteria") then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "Hysteria2"
|
||||
|
||||
-- [[ Hysteria2 ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Hysteria2")
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "hysteria2_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("hop"), translate("Port hopping range"))
|
||||
o.description = translate("Format as 1000:2000 or 1000-2000 Multiple groups are separated by commas (,).")
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("hop_interval"), translate("Hop Interval(second)"), translate("Supports a fixed value or a random range (e.g., 30, 5-30), minimum 5."))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.placeholder = "30"
|
||||
o.default = "30"
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("obfs"), translate("Obfs Password"))
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Flag, _n("fast_open"), translate("Fast Open"))
|
||||
o.default = "0"
|
||||
|
||||
o = s:option(Value, _n("tls_serverName"), translate("Domain"))
|
||||
|
||||
o = s:option(Flag, _n("tls_allowInsecure"), translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped."))
|
||||
o.default = "0"
|
||||
|
||||
o = s:option(Value, _n("tls_pinSHA256"), translate("PinSHA256"),translate("Certificate fingerprint"))
|
||||
|
||||
o = s:option(Value, _n("up_mbps"), translate("Max upload Mbps"))
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("down_mbps"), translate("Max download Mbps"))
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("recv_window"), translate("QUIC stream receive window"))
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("recv_window_conn"), translate("QUIC connection receive window"))
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("idle_timeout"), translate("Idle Timeout"), translate("Example:") .. "30s (4s~120s)")
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("keep_alive_period"), translate("QUIC KeepAlive interval"), translate("Example:") .. "10s (2s~60s)")
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Flag, _n("disable_mtu_discovery"), translate("Disable MTU detection"))
|
||||
o.default = "0"
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Flag, _n("lazy_start"), translate("Lazy Start"))
|
||||
o.default = "0"
|
||||
o.rewrite_option = o.option
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,37 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.is_finded("naive") then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "Naiveproxy"
|
||||
|
||||
-- [[ Naive ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "NaiveProxy")
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "naive_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
o:value("https", translate("HTTPS"))
|
||||
o:value("quic", translate("QUIC"))
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("username"), translate("Username"))
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,791 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.finded_com("xray") then
|
||||
return
|
||||
end
|
||||
|
||||
local jsonc = api.jsonc
|
||||
|
||||
type_name = "Xray"
|
||||
|
||||
-- [[ Xray ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Xray")
|
||||
if not s.fields["type"].default then
|
||||
s.fields["type"].default = type_name
|
||||
end
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "xray_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local formvalue_proto = luci.http.formvalue(formvalue_key .. _n("protocol"))
|
||||
|
||||
if formvalue_proto then s.val["protocol"] = formvalue_proto end
|
||||
|
||||
local arg_select_proto = luci.http.formvalue("select_proto") or ""
|
||||
|
||||
local ss_method_list = {
|
||||
"none", "plain", "aes-128-gcm", "aes-256-gcm", "chacha20-poly1305", "chacha20-ietf-poly1305", "xchacha20-poly1305", "xchacha20-ietf-poly1305", "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305"
|
||||
}
|
||||
|
||||
local security_list = { "none", "auto", "aes-128-gcm", "chacha20-poly1305", "zero" }
|
||||
|
||||
local header_type_list = {
|
||||
"none", "srtp", "utp", "wechat-video", "dtls", "wireguard", "dns"
|
||||
}
|
||||
|
||||
local xray_version = api.get_app_version("xray")
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
o:value("vmess", translate("Vmess"))
|
||||
o:value("vless", translate("VLESS"))
|
||||
o:value("http", translate("HTTP"))
|
||||
o:value("socks", translate("Socks"))
|
||||
o:value("shadowsocks", translate("Shadowsocks"))
|
||||
o:value("trojan", translate("Trojan"))
|
||||
o:value("wireguard", translate("WireGuard"))
|
||||
if api.compare_versions(xray_version, ">=", "26.1.13") then
|
||||
o:value("hysteria2", translate("Hysteria2"))
|
||||
end
|
||||
if api.compare_versions(xray_version, ">=", "1.8.12") then
|
||||
o:value("_balancing", translate("Balancing"))
|
||||
end
|
||||
o:value("_shunt", translate("Shunt"))
|
||||
o:value("_iface", translate("Custom Interface"))
|
||||
function o.custom_cfgvalue(self, section)
|
||||
if arg_select_proto ~= "" then
|
||||
return arg_select_proto
|
||||
else
|
||||
return m:get(section, self.option:sub(1 + #option_prefix))
|
||||
end
|
||||
end
|
||||
|
||||
local load_balancing_options = s.val["protocol"] == "_balancing" or arg_select_proto == "_balancing"
|
||||
local load_shunt_options = s.val["protocol"] == "_shunt" or arg_select_proto == "_shunt"
|
||||
local load_iface_options = s.val["protocol"] == "_iface" or arg_select_proto == "_iface"
|
||||
local load_normal_options = true
|
||||
if load_balancing_options or load_shunt_options or load_iface_options then
|
||||
load_normal_options = nil
|
||||
end
|
||||
if not arg_select_proto:find("_") then
|
||||
load_normal_options = true
|
||||
end
|
||||
|
||||
local node_list = api.get_node_list()
|
||||
local fallback_list = {}
|
||||
local is_balancer = nil
|
||||
for k, e in ipairs(node_list.balancing_list or {}) do
|
||||
if e.id ~= arg[1] then
|
||||
fallback_list[#fallback_list + 1] = {
|
||||
id = e["id"],
|
||||
remark = e["remark"],
|
||||
group = e["group"],
|
||||
fallback = e.o["fallback_node"],
|
||||
}
|
||||
else
|
||||
is_balancer = true
|
||||
end
|
||||
end
|
||||
|
||||
if load_balancing_options then -- [[ Load balancing Start ]]
|
||||
o = s:option(ListValue, _n("node_add_mode"), translate("Node Addition Method"))
|
||||
o:depends({ [_n("protocol")] = "_balancing" })
|
||||
o.default = "manual"
|
||||
o:value("manual", translate("Manual"))
|
||||
o:value("batch", translate("Batch"))
|
||||
|
||||
o = s:option(MultiValue, _n("balancing_node"), translate("Load balancing node list"), translate("Load balancing node list, <a target='_blank' href='https://xtls.github.io/config/routing.html#balancerobject'>document</a>"))
|
||||
o:depends({ [_n("node_add_mode")] = "manual" })
|
||||
o.widget = "checkbox"
|
||||
o.template = appname .. "/cbi/nodes_multivalue"
|
||||
o.group = {}
|
||||
for k1, v1 in pairs(node_list) do
|
||||
if k1 == "socks_list" or k1 == "normal_list" then
|
||||
for i, v in ipairs(v1) do
|
||||
o:value(v.id, v.remark)
|
||||
o.group[#o.group+1] = v.group or ""
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Reading the old DynamicList
|
||||
function o.cfgvalue(self, section)
|
||||
return m.uci:get_list(appname, section, "balancing_node") or {}
|
||||
end
|
||||
-- Write-and-hold DynamicList
|
||||
function o.custom_write(self, section, value)
|
||||
local old = m.uci:get_list(appname, section, "balancing_node") or {}
|
||||
local new, set = {}, {}
|
||||
for v in value:gmatch("%S+") do
|
||||
new[#new + 1] = v
|
||||
set[v] = 1
|
||||
end
|
||||
for _, v in ipairs(old) do
|
||||
if not set[v] then
|
||||
m.uci:set_list(appname, section, "balancing_node", new)
|
||||
return
|
||||
end
|
||||
set[v] = nil
|
||||
end
|
||||
for _ in pairs(set) do
|
||||
m.uci:set_list(appname, section, "balancing_node", new)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(MultiValue, _n("node_group"), translate("Select Group"))
|
||||
o:depends({ [_n("node_add_mode")] = "batch" })
|
||||
o.widget = "checkbox"
|
||||
o:value("default", translate("default"))
|
||||
for k, v in pairs(groups) do
|
||||
o:value(api.UrlEncode(k), k)
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("node_match_rule"), translate("Node Matching Rules"))
|
||||
o:depends({ [_n("node_add_mode")] = "batch" })
|
||||
local descrStr = "Example: <code>^A && B && !C && D$</code><br>"
|
||||
descrStr = descrStr .. "This means the node remark must start with A (^), include B, exclude C (!), and end with D ($).<br>"
|
||||
descrStr = descrStr .. "Conditions are joined by <code>&&</code>, and their order does not affect the result."
|
||||
o.description = translate(descrStr)
|
||||
|
||||
o = s:option(ListValue, _n("balancingStrategy"), translate("Balancing Strategy"))
|
||||
o:depends({ [_n("protocol")] = "_balancing" })
|
||||
o:value("random")
|
||||
o:value("roundRobin")
|
||||
o:value("leastPing")
|
||||
o:value("leastLoad")
|
||||
o.default = "random"
|
||||
|
||||
-- Fallback Node
|
||||
o = s:option(ListValue, _n("fallback_node"), translate("Fallback Node"))
|
||||
o:value("", translate("Close(Not use)"))
|
||||
o:depends({ [_n("protocol")] = "_balancing" })
|
||||
o.template = appname .. "/cbi/nodes_listvalue"
|
||||
o.group = {""}
|
||||
local function check_fallback_chain(fb)
|
||||
for k, v in pairs(fallback_list) do
|
||||
if v.fallback == fb then
|
||||
fallback_list[k] = nil
|
||||
check_fallback_chain(v.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Check the fallback chain and remove the balancer node that would form a closed loop.
|
||||
if is_balancer then
|
||||
check_fallback_chain(arg[1])
|
||||
end
|
||||
for i, v in ipairs(fallback_list) do
|
||||
o:value(v.id, v.remark)
|
||||
o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
for k1, v1 in pairs(node_list) do
|
||||
if k1 == "socks_list" or k1 == "normal_list" then
|
||||
for i, v in ipairs(v1) do
|
||||
o:value(v.id, v.remark)
|
||||
o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("useCustomProbeUrl"), translate("Use Custom Probe URL"), translate("By default the built-in probe URL will be used, enable this option to use a custom probe URL."))
|
||||
o:depends({ [_n("protocol")] = "_balancing" })
|
||||
|
||||
o = s:option(Value, _n("probeUrl"), translate("Probe URL"))
|
||||
o:depends({ [_n("useCustomProbeUrl")] = true })
|
||||
o:value("https://cp.cloudflare.com/", "Cloudflare")
|
||||
o:value("https://www.gstatic.com/generate_204", "Gstatic")
|
||||
o:value("https://www.google.com/generate_204", "Google")
|
||||
o:value("https://www.youtube.com/generate_204", "YouTube")
|
||||
o:value("https://connect.rom.miui.com/generate_204", "MIUI (CN)")
|
||||
o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)")
|
||||
o.default = "https://www.google.com/generate_204"
|
||||
o.description = translate("The URL used to detect the connection status.")
|
||||
|
||||
o = s:option(Value, _n("probeInterval"), translate("Probe Interval"))
|
||||
o:depends({ [_n("protocol")] = "_balancing" })
|
||||
o.default = "1m"
|
||||
o.placeholder = "1m"
|
||||
o.description = translate("The interval between initiating probes.") .. "<br>" ..
|
||||
translate("The time format is numbers + units, such as '10s', '2h45m', and the supported time units are <code>s</code>, <code>m</code>, <code>h</code>, which correspond to seconds, minutes, and hours, respectively.") .. "<br>" ..
|
||||
translate("When the unit is not filled in, it defaults to seconds.")
|
||||
|
||||
o = s:option(Value, _n("expected"), translate("Preferred Node Count"))
|
||||
o:depends({ [_n("balancingStrategy")] = "leastLoad" })
|
||||
o.datatype = "uinteger"
|
||||
o.default = "2"
|
||||
o.placeholder = "2"
|
||||
o.description = translate("The load balancer selects the optimal number of nodes, and traffic is randomly distributed among them.")
|
||||
end -- [[ Load balancing End ]]
|
||||
|
||||
if load_iface_options then -- [[ Custom Interface Start ]]
|
||||
o = s:option(Value, _n("iface"), translate("Interface"))
|
||||
o.default = "eth1"
|
||||
o:depends({ [_n("protocol")] = "_iface" })
|
||||
end -- [[ Custom Interface End ]]
|
||||
|
||||
|
||||
-- [[ Normal single node Start ]]
|
||||
if load_normal_options then
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("uuid"), translate("ID"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
|
||||
o = s:option(Value, _n("username"), translate("Username"))
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
o = s:option(ListValue, _n("security"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(security_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
|
||||
o = s:option(Value, _n("encryption"), translate("Encrypt Method") .. " (encryption)")
|
||||
o.default = "none"
|
||||
o.placeholder = "none"
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value)
|
||||
return (value == "" and "none" or value)
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("ss_method"), translate("Encrypt Method"))
|
||||
o.rewrite_option = "method"
|
||||
for a, t in ipairs(ss_method_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(Flag, _n("iv_check"), translate("IV Check"))
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("ss_method")] = "aes-128-gcm" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("ss_method")] = "aes-256-gcm" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("ss_method")] = "chacha20-poly1305" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("ss_method")] = "xchacha20-poly1305" })
|
||||
|
||||
o = s:option(Flag, _n("uot"), translate("UDP over TCP"))
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(ListValue, _n("flow"), translate("flow"))
|
||||
o.default = ""
|
||||
o:value("", translate("Disable"))
|
||||
o:value("xtls-rprx-vision")
|
||||
o:value("xtls-rprx-vision-udp443")
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
|
||||
---- [[hysteria2]]
|
||||
o = s:option(Value, _n("hysteria2_hop"), translate("Port hopping range"))
|
||||
o.description = translate("Format as 1000:2000 or 1000-2000 Multiple groups are separated by commas (,).")
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_hop_interval"), translate("Hop Interval(second)"), translate("Supports a fixed value or a random range (e.g., 30, 5-30), minimum 5."))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.placeholder = "30"
|
||||
o.default = "30"
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_up_mbps"), translate("Max upload Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_down_mbps"), translate("Max download Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
|
||||
o:value("", translate("Disable"))
|
||||
o:value("salamander")
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password"))
|
||||
o:depends({ [_n("hysteria2_obfs_type")] = "salamander" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
|
||||
o = s:option(Value, _n("hysteria2_idle_timeout"), translate("Idle Timeout"), translate("Example:") .. "30s (4s~120s)")
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
|
||||
o = s:option(Value, _n("hysteria2_keep_alive_period"), translate("QUIC KeepAlive interval"), translate("Example:") .. "10s (2s~60s)")
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
|
||||
o = s:option(Flag, _n("hysteria2_disable_mtu_discovery"), translate("Disable MTU detection"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
---- [[hysteria2 end]]
|
||||
|
||||
o = s:option(Flag, _n("tls"), translate("TLS"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(Flag, _n("reality"), translate("REALITY"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "raw" })
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "ws" })
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "grpc" })
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "httpupgrade" })
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(ListValue, _n("alpn"), translate("alpn"))
|
||||
o.default = "default"
|
||||
o:value("default", translate("Default"))
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
-- o = s:option(Value, _n("minversion"), translate("minversion"))
|
||||
-- o.default = "1.3"
|
||||
-- o:value("1.3")
|
||||
-- o:depends({ [_n("tls")] = true })
|
||||
|
||||
o = s:option(Value, _n("tls_serverName"), "SNI " .. translate("Domain"))
|
||||
o:depends({ [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
if api.compare_versions(os.date("%Y.%m.%d"), "<", "2026.6.1") then
|
||||
o = s:option(Flag, _n("tls_allowInsecure"), translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
end
|
||||
|
||||
if api.compare_versions(xray_version, ">=", "26.1.31") then
|
||||
o = s:option(Value, _n("tls_pinSHA256"), translate("TLS Chain Fingerprint (SHA256)"))
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("tls_CertByName"), translate("TLS Certificate Name (CertName)"), translate("TLS is used to verify the leaf certificate name."))
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("ech"), translate("ECH"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(TextValue, _n("ech_config"), translate("ECH Config"))
|
||||
o.default = ""
|
||||
o.rows = 5
|
||||
o.wrap = "soft"
|
||||
o:depends({ [_n("ech")] = true })
|
||||
o.validate = function(self, value)
|
||||
return api.trim(value:gsub("[\r\n]", ""))
|
||||
end
|
||||
|
||||
-- [[ REALITY ]] --
|
||||
o = s:option(Value, _n("reality_publicKey"), translate("Public Key"))
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_shortId"), translate("Short Id"))
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_spiderX"), translate("Spider X"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = true })
|
||||
|
||||
o = s:option(Flag, _n("utls"), translate("uTLS"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("fingerprint"), translate("Finger Print"))
|
||||
o:value("chrome")
|
||||
o:value("firefox")
|
||||
o:value("edge")
|
||||
o:value("safari")
|
||||
o:value("360")
|
||||
o:value("qq")
|
||||
o:value("ios")
|
||||
o:value("android")
|
||||
o:value("random")
|
||||
o:value("randomized")
|
||||
o.default = "chrome"
|
||||
o:depends({ [_n("tls")] = true, [_n("utls")] = true })
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = true })
|
||||
|
||||
o = s:option(Flag, _n("use_mldsa65Verify"), translate("ML-DSA-65"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = true })
|
||||
|
||||
o = s:option(TextValue, _n("reality_mldsa65Verify"), "ML-DSA-65 " .. translate("Public key"))
|
||||
o.default = ""
|
||||
o.rows = 5
|
||||
o.wrap = "soft"
|
||||
o:depends({ [_n("use_mldsa65Verify")] = true })
|
||||
o.validate = function(self, value)
|
||||
return api.trim(value:gsub("[\r\n]", ""))
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("transport"), translate("Transport"))
|
||||
o:value("raw", "RAW (TCP)")
|
||||
o:value("mkcp", "mKCP")
|
||||
o:value("ws", "WebSocket")
|
||||
o:value("grpc", "gRPC")
|
||||
o:value("httpupgrade", "HttpUpgrade")
|
||||
o:value("xhttp", "XHTTP")
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_public_key"), translate("Public Key"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_secret_key"), translate("Private Key"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_preSharedKey"), translate("Pre shared key"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(DynamicList, _n("wireguard_local_address"), translate("Local Address"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_mtu"), translate("MTU"))
|
||||
o.default = "1420"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
if api.compare_versions(xray_version, ">=", "1.8.0") then
|
||||
o = s:option(Value, _n("wireguard_reserved"), translate("Reserved"), translate("Decimal numbers separated by \",\" or Base64-encoded strings."))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("wireguard_keepAlive"), translate("Keep Alive"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
-- [[ RAW ]]--
|
||||
o = s:option(ListValue, _n("tcp_guise"), translate("Camouflage Type"))
|
||||
o:value("none", "none")
|
||||
o:value("http", "http")
|
||||
o:depends({ [_n("transport")] = "raw" })
|
||||
|
||||
o = s:option(DynamicList, _n("tcp_guise_http_host"), translate("HTTP Host"))
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
|
||||
o = s:option(DynamicList, _n("tcp_guise_http_path"), translate("HTTP Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
|
||||
-- [[ mKCP ]]--
|
||||
o = s:option(ListValue, _n("mkcp_guise"), translate("Camouflage Type"), translate('<br />none: default, no masquerade, data sent is packets with no characteristics.<br />srtp: disguised as an SRTP packet, it will be recognized as video call data (such as FaceTime).<br />utp: packets disguised as uTP will be recognized as bittorrent downloaded data.<br />wechat-video: packets disguised as WeChat video calls.<br />dtls: disguised as DTLS 1.2 packet.<br />wireguard: disguised as a WireGuard packet. (not really WireGuard protocol)<br />dns: Disguising traffic as DNS requests.'))
|
||||
for a, t in ipairs(header_type_list) do o:value(t) end
|
||||
o:depends({ [_n("transport")] = "mkcp" })
|
||||
|
||||
o = s:option(Value, _n("mkcp_domain"), translate("Camouflage Domain"), translate("Use it together with the DNS disguised type. You can fill in any domain."))
|
||||
o:depends({ [_n("mkcp_guise")] = "dns" })
|
||||
|
||||
o = s:option(Value, _n("mkcp_mtu"), translate("KCP MTU"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 1350
|
||||
o:depends({ [_n("transport")] = "mkcp" })
|
||||
|
||||
o = s:option(Value, _n("mkcp_seed"), translate("KCP Seed"))
|
||||
o:depends({ [_n("transport")] = "mkcp" })
|
||||
|
||||
-- [[ WebSocket ]]--
|
||||
o = s:option(Value, _n("ws_host"), translate("WebSocket Host"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Value, _n("ws_path"), translate("WebSocket Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Value, _n("ws_heartbeatPeriod"), translate("HeartbeatPeriod(second)"))
|
||||
o.datatype = "integer"
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
-- [[ gRPC ]]--
|
||||
o = s:option(Value, _n("grpc_serviceName"), "ServiceName")
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
o = s:option(ListValue, _n("grpc_mode"), "gRPC " .. translate("Transfer mode"))
|
||||
o:value("gun")
|
||||
o:value("multi")
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
o = s:option(Flag, _n("grpc_health_check"), translate("Health check"))
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
o = s:option(Value, _n("grpc_idle_timeout"), translate("Idle timeout"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("grpc_health_check")] = true })
|
||||
|
||||
o = s:option(Value, _n("grpc_health_check_timeout"), translate("Health check timeout"))
|
||||
o.default = "20"
|
||||
o:depends({ [_n("grpc_health_check")] = true })
|
||||
|
||||
o = s:option(Flag, _n("grpc_permit_without_stream"), translate("Permit without stream"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("grpc_health_check")] = true })
|
||||
|
||||
o = s:option(Value, _n("grpc_initial_windows_size"), translate("Initial Windows Size"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
-- [[ HttpUpgrade ]]--
|
||||
o = s:option(Value, _n("httpupgrade_host"), translate("HttpUpgrade Host"))
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
o = s:option(Value, _n("httpupgrade_path"), translate("HttpUpgrade Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
-- [[ XHTTP ]]--
|
||||
o = s:option(ListValue, _n("xhttp_mode"), "XHTTP " .. translate("Mode"))
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
o.default = "auto"
|
||||
o:value("auto")
|
||||
o:value("packet-up")
|
||||
o:value("stream-up")
|
||||
o:value("stream-one")
|
||||
|
||||
o = s:option(Value, _n("xhttp_host"), translate("XHTTP Host"))
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(Value, _n("xhttp_path"), translate("XHTTP Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(Flag, _n("use_xhttp_extra"), translate("XHTTP Extra"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(TextValue, _n("xhttp_extra"), " ", translate("An XHttpObject in JSON format, used for sharing."))
|
||||
o:depends({ [_n("use_xhttp_extra")] = true })
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local raw = m:get(section, "xhttp_extra")
|
||||
if raw then
|
||||
return api.base64Decode(raw)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "xhttp_extra", api.base64Encode(value) or "")
|
||||
local success, data = pcall(jsonc.parse, value)
|
||||
if success and data then
|
||||
local address = (data.extra and data.extra.downloadSettings and data.extra.downloadSettings.address)
|
||||
or (data.downloadSettings and data.downloadSettings.address)
|
||||
if address and address ~= "" then
|
||||
address = address:gsub("^%[", ""):gsub("%]$", "")
|
||||
m:set(section, "download_address", address)
|
||||
else
|
||||
m:del(section, "download_address")
|
||||
end
|
||||
else
|
||||
m:del(section, "download_address")
|
||||
end
|
||||
end
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value):gsub("\r\n", "\n"):gsub("^[ \t]*\n", ""):gsub("\n[ \t]*$", ""):gsub("\n[ \t]*\n", "\n")
|
||||
if api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, "XHTTP Extra " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_remove = function(self, section, value)
|
||||
m:del(section, "xhttp_extra")
|
||||
m:del(section, "download_address")
|
||||
end
|
||||
|
||||
-- [[ User-Agent ]]--
|
||||
o = s:option(Value, _n("user_agent"), translate("User-Agent"))
|
||||
o.default = ""
|
||||
o:value("", translate("default"))
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36", "chrome")
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", "firefox")
|
||||
o:value("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15", "safari")
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70", "edge")
|
||||
o:value("Go-http-client/1.1", "golang")
|
||||
o:value("curl/7.68.0", "curl")
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
-- [[ Mux.Cool ]]--
|
||||
o = s:option(Flag, _n("mux"), "Mux", translate("Enable Mux.Cool"))
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("transport")] = "raw" })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("transport")] = "ws" })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("transport")] = "grpc" })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("transport")] = "httpupgrade" })
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
o = s:option(Value, _n("mux_concurrency"), translate("Mux concurrency"))
|
||||
o.default = -1
|
||||
o:depends({ [_n("mux")] = true })
|
||||
|
||||
o = s:option(Value, _n("xudp_concurrency"), translate("XUDP Mux concurrency"))
|
||||
o.default = 8
|
||||
o:depends({ [_n("mux")] = true })
|
||||
|
||||
--[[FinalMask]]
|
||||
o = s:option(Flag, _n("use_finalmask"), "FinalMask")
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(TextValue, _n("finalmask"), " ")
|
||||
o:depends({ [_n("use_finalmask")] = true })
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o.description = translate("An FinalMaskObject in JSON format, used for sharing.") .. "<br>" ..
|
||||
translate("Custom finalmask overrides mkcp, hysteria2, fragment, noise, and related settings.")
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local raw = m:get(section, "finalmask")
|
||||
if raw then
|
||||
return api.base64Decode(raw)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "finalmask", api.base64Encode(value) or "")
|
||||
end
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value):gsub("\r\n", "\n"):gsub("^[ \t]*\n", ""):gsub("\n[ \t]*$", ""):gsub("\n[ \t]*\n", "\n")
|
||||
if api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, "FinalMask " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
|
||||
--[[Fast Open]]
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"), translate("Need node support required"))
|
||||
o.default = 0
|
||||
|
||||
--[[tcpMptcp]]
|
||||
o = s:option(Flag, _n("tcpMptcp"), "tcpMptcp", translate("Enable Multipath TCP, need to be enabled in both server and client configuration."))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Value, _n("preconns"), translate("Pre-connections"), translate("Number of early established connections to reduce latency."))
|
||||
o.datatype = "uinteger"
|
||||
o.placeholder = 0
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
|
||||
o = s:option(ListValue, _n("domain_resolver"), translate("Domain DNS Resolve"), translate("If the node address is a domain name, this DNS will be used for resolution."))
|
||||
o:value("", translate("Auto"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("udp", "UDP")
|
||||
o:value("https", "HTTPS")
|
||||
|
||||
o = s:option(Value, _n("domain_resolver_dns"), "DNS")
|
||||
o.datatype = "or(ipaddr,ipaddrport)"
|
||||
o:value("114.114.114.114")
|
||||
o:value("223.5.5.5:53")
|
||||
o.default = o.keylist[1]
|
||||
o:depends({ [_n("domain_resolver")] = "tcp" })
|
||||
o:depends({ [_n("domain_resolver")] = "udp" })
|
||||
|
||||
o = s:option(Value, _n("domain_resolver_dns_https"), "DNS")
|
||||
o:value("https://120.53.53.53/dns-query", "DNSPod")
|
||||
o:value("https://223.5.5.5/dns-query", "AliDNS")
|
||||
o.default = o.keylist[1]
|
||||
o:depends({ [_n("domain_resolver")] = "https" })
|
||||
|
||||
o = s:option(ListValue, _n("domain_strategy"), translate("Domain Strategy"), translate("If is domain name, The requested domain name will be resolved to IP before connect."))
|
||||
o.default = ""
|
||||
o:value("", translate("Auto"))
|
||||
o:value("UseIPv4", translate("IPv4 Only"))
|
||||
o:value("UseIPv6", translate("IPv6 Only"))
|
||||
|
||||
local protocols = s.fields[_n("protocol")].keylist
|
||||
if #protocols > 0 then
|
||||
for i, v in ipairs(protocols) do
|
||||
if not v:find("^_") then
|
||||
s.fields[_n("address")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("port")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("domain_resolver")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("domain_strategy")]:depends({ [_n("protocol")] = v })
|
||||
|
||||
if v ~= "hysteria2" then
|
||||
s.fields[_n("tcp_fast_open")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("tcpMptcp")]:depends({ [_n("protocol")] = v })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- [[ Normal single node End ]]
|
||||
|
||||
if not load_shunt_options then
|
||||
o = s:option(ListValue, _n("chain_proxy"), translate("Chain Proxy"))
|
||||
o:value("", translate("Close(Not use)"))
|
||||
if not (load_iface_options or load_balancing_options) then
|
||||
-- Special node cannot be use pre-proxy.
|
||||
o:value("1", translate("Preproxy Node"))
|
||||
end
|
||||
o:value("2", translate("Landing Node"))
|
||||
|
||||
o1 = s:option(ListValue, _n("preproxy_node"), translate("Preproxy Node"), translate("Only support a layer of proxy."))
|
||||
o1:depends({ [_n("chain_proxy")] = "1" })
|
||||
o1.template = appname .. "/cbi/nodes_listvalue"
|
||||
o1.group = {}
|
||||
|
||||
o2 = s:option(ListValue, _n("to_node"), translate("Landing Node"), translate("Only support a layer of proxy."))
|
||||
o2:depends({ [_n("chain_proxy")] = "2" })
|
||||
o2.template = appname .. "/cbi/nodes_listvalue"
|
||||
o2.group = {}
|
||||
|
||||
for k1, v1 in pairs(node_list) do
|
||||
if k1 ~= "shunt_list" and k1 ~= "iface_list" then
|
||||
for i, v in ipairs(v1) do
|
||||
if v.id ~= arg[1] then
|
||||
o1:value(v.id, v.remark)
|
||||
o1.group[#o1.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
if k1 == "normal_list" then
|
||||
-- Landing Node not support use special node.
|
||||
o2:value(v.id, v.remark)
|
||||
o2.group[#o2.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
|
||||
if load_shunt_options then
|
||||
local current_node = m.uci:get_all(appname, arg[1]) or {}
|
||||
local shunt_lua = loadfile("/usr/lib/lua/luci/model/cbi/passwall2/client/include/shunt_options.lua")
|
||||
setfenv(shunt_lua, getfenv(1))(m, s, {
|
||||
node_id = arg[1],
|
||||
node = current_node,
|
||||
node_list = node_list,
|
||||
})
|
||||
end
|
||||
@@ -0,0 +1,877 @@
|
||||
local m, s = ...
|
||||
|
||||
local singbox_bin = api.finded_com("sing-box")
|
||||
|
||||
if not singbox_bin then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "sing-box"
|
||||
|
||||
-- [[ sing-box ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Sing-Box")
|
||||
if not s.fields["type"].default then
|
||||
s.fields["type"].default = type_name
|
||||
end
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "singbox_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local formvalue_proto = luci.http.formvalue(formvalue_key .. _n("protocol"))
|
||||
|
||||
if formvalue_proto then s.val["protocol"] = formvalue_proto end
|
||||
|
||||
local arg_select_proto = luci.http.formvalue("select_proto") or ""
|
||||
|
||||
local ss_method_new_list = {
|
||||
"none", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305", "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305"
|
||||
}
|
||||
|
||||
local ss_method_old_list = {
|
||||
"aes-128-ctr", "aes-192-ctr", "aes-256-ctr", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "rc4-md5", "chacha20-ietf", "xchacha20",
|
||||
}
|
||||
|
||||
local security_list = { "none", "auto", "aes-128-gcm", "chacha20-poly1305", "zero" }
|
||||
|
||||
local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'")
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
o:value("socks", "Socks")
|
||||
o:value("http", "HTTP")
|
||||
o:value("shadowsocks", "Shadowsocks")
|
||||
if singbox_tags:find("with_shadowsocksr") then
|
||||
o:value("shadowsocksr", "ShadowsocksR")
|
||||
end
|
||||
o:value("vmess", "Vmess")
|
||||
o:value("trojan", "Trojan")
|
||||
if singbox_tags:find("with_wireguard") then
|
||||
o:value("wireguard", "WireGuard")
|
||||
end
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("hysteria", "Hysteria")
|
||||
end
|
||||
o:value("vless", "VLESS")
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("tuic", "TUIC")
|
||||
end
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("hysteria2", "Hysteria2")
|
||||
end
|
||||
o:value("anytls", "AnyTLS")
|
||||
o:value("ssh", "SSH")
|
||||
if singbox_tags:find("with_naive_outbound") then
|
||||
o:value("naive", "NaïveProxy")
|
||||
end
|
||||
o:value("_urltest", translate("URLTest"))
|
||||
o:value("_shunt", translate("Shunt"))
|
||||
o:value("_iface", translate("Custom Interface"))
|
||||
function o.custom_cfgvalue(self, section)
|
||||
if arg_select_proto ~= "" then
|
||||
return arg_select_proto
|
||||
else
|
||||
return m:get(section, self.option:sub(1 + #option_prefix))
|
||||
end
|
||||
end
|
||||
|
||||
local load_urltest_options = s.val["protocol"] == "_urltest" or arg_select_proto == "_urltest"
|
||||
local load_shunt_options = s.val["protocol"] == "_shunt" or arg_select_proto == "_shunt"
|
||||
local load_iface_options = s.val["protocol"] == "_iface" or arg_select_proto == "_iface"
|
||||
local load_normal_options = true
|
||||
if load_urltest_options or load_shunt_options or load_iface_options then
|
||||
load_normal_options = nil
|
||||
end
|
||||
if not arg_select_proto:find("_") then
|
||||
load_normal_options = true
|
||||
end
|
||||
|
||||
local node_list = api.get_node_list()
|
||||
|
||||
if load_urltest_options then -- [[ URLTest Start ]]
|
||||
o = s:option(ListValue, _n("node_add_mode"), translate("Node Addition Method"))
|
||||
o:depends({ [_n("protocol")] = "_urltest" })
|
||||
o.default = "manual"
|
||||
o:value("manual", translate("Manual"))
|
||||
o:value("batch", translate("Batch"))
|
||||
|
||||
o = s:option(MultiValue, _n("urltest_node"), translate("URLTest node list"), translate("List of nodes to test, <a target='_blank' href='https://sing-box.sagernet.org/configuration/outbound/urltest'>document</a>"))
|
||||
o:depends({ [_n("node_add_mode")] = "manual" })
|
||||
o.widget = "checkbox"
|
||||
o.template = appname .. "/cbi/nodes_multivalue"
|
||||
o.group = {}
|
||||
for k1, v1 in pairs(node_list) do
|
||||
if k1 == "socks_list" or k1 == "normal_list" then
|
||||
for i, v in ipairs(v1) do
|
||||
o:value(v.id, v.remark)
|
||||
o.group[#o.group+1] = v.group or ""
|
||||
end
|
||||
end
|
||||
end
|
||||
-- Reading the old DynamicList
|
||||
function o.cfgvalue(self, section)
|
||||
return m.uci:get_list(appname, section, "urltest_node") or {}
|
||||
end
|
||||
-- Write-and-hold DynamicList
|
||||
function o.custom_write(self, section, value)
|
||||
local old = m.uci:get_list(appname, section, "urltest_node") or {}
|
||||
local new, set = {}, {}
|
||||
for v in value:gmatch("%S+") do
|
||||
new[#new + 1] = v
|
||||
set[v] = 1
|
||||
end
|
||||
for _, v in ipairs(old) do
|
||||
if not set[v] then
|
||||
m.uci:set_list(appname, section, "urltest_node", new)
|
||||
return
|
||||
end
|
||||
set[v] = nil
|
||||
end
|
||||
for _ in pairs(set) do
|
||||
m.uci:set_list(appname, section, "urltest_node", new)
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(MultiValue, _n("node_group"), translate("Select Group"))
|
||||
o:depends({ [_n("node_add_mode")] = "batch" })
|
||||
o.widget = "checkbox"
|
||||
o:value("default", translate("default"))
|
||||
for k, v in pairs(groups) do
|
||||
o:value(api.UrlEncode(k), k)
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("node_match_rule"), translate("Node Matching Rules"))
|
||||
o:depends({ [_n("node_add_mode")] = "batch" })
|
||||
local descrStr = "Example: <code>^A && B && !C && D$</code><br>"
|
||||
descrStr = descrStr .. "This means the node remark must start with A (^), include B, exclude C (!), and end with D ($).<br>"
|
||||
descrStr = descrStr .. "Conditions are joined by <code>&&</code>, and their order does not affect the result."
|
||||
o.description = translate(descrStr)
|
||||
|
||||
o = s:option(Value, _n("urltest_url"), translate("Probe URL"))
|
||||
o:depends({ [_n("protocol")] = "_urltest" })
|
||||
o:value("https://cp.cloudflare.com/", "Cloudflare")
|
||||
o:value("https://www.gstatic.com/generate_204", "Gstatic")
|
||||
o:value("https://www.google.com/generate_204", "Google")
|
||||
o:value("https://www.youtube.com/generate_204", "YouTube")
|
||||
o:value("https://connect.rom.miui.com/generate_204", "MIUI (CN)")
|
||||
o:value("https://connectivitycheck.platform.hicloud.com/generate_204", "HiCloud (CN)")
|
||||
o.default = "https://www.gstatic.com/generate_204"
|
||||
o.description = translate("The URL used to detect the connection status.")
|
||||
|
||||
o = s:option(Value, _n("urltest_interval"), translate("Test interval"))
|
||||
o:depends({ [_n("protocol")] = "_urltest" })
|
||||
o.default = "3m"
|
||||
o.placeholder = "3m"
|
||||
o.description = translate("The interval between initiating probes.") .. "<br>" ..
|
||||
translate("The time format is numbers + units, such as '10s', '2h45m', and the supported time units are <code>s</code>, <code>m</code>, <code>h</code>, which correspond to seconds, minutes, and hours, respectively.") .. "<br>" ..
|
||||
translate("When the unit is not filled in, it defaults to seconds.") .. "<br>" ..
|
||||
translate("Test interval must be less or equal than idle timeout.")
|
||||
|
||||
o = s:option(Value, _n("urltest_tolerance"), translate("Test tolerance"), translate("The test tolerance in milliseconds."))
|
||||
o:depends({ [_n("protocol")] = "_urltest" })
|
||||
o.datatype = "uinteger"
|
||||
o.placeholder = "50"
|
||||
o.default = "50"
|
||||
|
||||
o = s:option(Value, _n("urltest_idle_timeout"), translate("Idle timeout"))
|
||||
o:depends({ [_n("protocol")] = "_urltest" })
|
||||
o.placeholder = "30m"
|
||||
o.default = "30m"
|
||||
o.description = translate("The idle timeout.") .. "<br>" ..
|
||||
translate("The time format is numbers + units, such as '10s', '2h45m', and the supported time units are <code>s</code>, <code>m</code>, <code>h</code>, which correspond to seconds, minutes, and hours, respectively.") .. "<br>" ..
|
||||
translate("When the unit is not filled in, it defaults to seconds.")
|
||||
|
||||
o = s:option(Flag, _n("urltest_interrupt_exist_connections"), translate("Interrupt existing connections"))
|
||||
o:depends({ [_n("protocol")] = "_urltest" })
|
||||
o.default = "0"
|
||||
o.description = translate("Interrupt existing connections when the selected outbound has changed.")
|
||||
end -- [[ URLTest End ]]
|
||||
|
||||
if load_iface_options then -- [[ Custom Interface Start ]]
|
||||
o = s:option(Value, _n("iface"), translate("Interface"))
|
||||
o.default = "eth1"
|
||||
o:depends({ [_n("protocol")] = "_iface" })
|
||||
end -- [[ Custom Interface End ]]
|
||||
|
||||
|
||||
-- [[ Normal single node Start ]]
|
||||
if load_normal_options then
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("uuid"), translate("ID"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(Value, _n("username"), translate("Username"))
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocksr" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "anytls" })
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(ListValue, _n("security"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(security_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
|
||||
o = s:option(ListValue, _n("ss_method"), translate("Encrypt Method"))
|
||||
o.rewrite_option = "method"
|
||||
for a, t in ipairs(ss_method_new_list) do o:value(t) end
|
||||
for a, t in ipairs(ss_method_old_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
if singbox_tags:find("with_shadowsocksr") then
|
||||
o = s:option(ListValue, _n("ssr_method"), translate("Encrypt Method"))
|
||||
o.rewrite_option = "method"
|
||||
for a, t in ipairs(ss_method_old_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocksr" })
|
||||
|
||||
local ssr_protocol_list = {
|
||||
"origin", "verify_simple", "verify_deflate", "verify_sha1", "auth_simple",
|
||||
"auth_sha1", "auth_sha1_v2", "auth_sha1_v4", "auth_aes128_md5",
|
||||
"auth_aes128_sha1", "auth_chain_a", "auth_chain_b", "auth_chain_c",
|
||||
"auth_chain_d", "auth_chain_e", "auth_chain_f"
|
||||
}
|
||||
|
||||
o = s:option(ListValue, _n("ssr_protocol"), translate("Protocol"))
|
||||
for a, t in ipairs(ssr_protocol_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocksr" })
|
||||
|
||||
o = s:option(Value, _n("ssr_protocol_param"), translate("Protocol_param"))
|
||||
o:depends({ [_n("protocol")] = "shadowsocksr" })
|
||||
|
||||
local ssr_obfs_list = {
|
||||
"plain", "http_simple", "http_post", "random_head", "tls_simple",
|
||||
"tls1.0_session_auth", "tls1.2_ticket_auth"
|
||||
}
|
||||
|
||||
o = s:option(ListValue, _n("ssr_obfs"), translate("Obfs"))
|
||||
for a, t in ipairs(ssr_obfs_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocksr" })
|
||||
|
||||
o = s:option(Value, _n("ssr_obfs_param"), translate("Obfs_param"))
|
||||
o:depends({ [_n("protocol")] = "shadowsocksr" })
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("uot"), translate("UDP over TCP"))
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(Value, _n("alter_id"), "Alter ID")
|
||||
o.datatype = "uinteger"
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
|
||||
o = s:option(Flag, _n("global_padding"), "global_padding", translate("Protocol parameter. Will waste traffic randomly if enabled."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
|
||||
o = s:option(Flag, _n("authenticated_length"), "authenticated_length", translate("Protocol parameter. Enable length block encryption."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
|
||||
o = s:option(ListValue, _n("flow"), translate("flow"))
|
||||
o.default = ""
|
||||
o:value("", translate("Disable"))
|
||||
o:value("xtls-rprx-vision")
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("tls")] = true })
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(Value, _n("hysteria_hop"), translate("Port hopping range"))
|
||||
o.description = translate("Format as 1000:2000 or 1000-2000 Multiple groups are separated by commas (,).")
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_hop_interval"), translate("Hop Interval(second)"), translate("Example:") .. "30 (≥5)")
|
||||
o.datatype = "uinteger"
|
||||
o.placeholder = "30"
|
||||
o.default = "30"
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_obfs"), translate("Obfs Password"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(ListValue, _n("hysteria_auth_type"), translate("Auth Type"))
|
||||
o:value("disable", translate("Disable"))
|
||||
o:value("string", translate("STRING"))
|
||||
o:value("base64", translate("BASE64"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "hysteria", [_n("hysteria_auth_type")] = "string"})
|
||||
o:depends({ [_n("protocol")] = "hysteria", [_n("hysteria_auth_type")] = "base64"})
|
||||
|
||||
o = s:option(Value, _n("hysteria_up_mbps"), translate("Max upload Mbps"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_down_mbps"), translate("Max download Mbps"))
|
||||
o.default = "50"
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_recv_window_conn"), translate("QUIC stream receive window"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_recv_window"), translate("QUIC connection receive window"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Flag, _n("hysteria_disable_mtu_discovery"), translate("Disable MTU detection"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
end
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(ListValue, _n("tuic_congestion_control"), translate("Congestion control algorithm"))
|
||||
o.default = "cubic"
|
||||
o:value("bbr", translate("BBR"))
|
||||
o:value("cubic", translate("CUBIC"))
|
||||
o:value("new_reno", translate("New Reno"))
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(ListValue, _n("tuic_udp_relay_mode"), translate("UDP relay mode"))
|
||||
o.default = "native"
|
||||
o:value("native", translate("native"))
|
||||
o:value("quic", translate("QUIC"))
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
--[[
|
||||
o = s:option(Flag, _n("tuic_udp_over_stream"), translate("UDP over stream"))
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
]]--
|
||||
|
||||
o = s:option(Flag, _n("tuic_zero_rtt_handshake"), translate("Enable 0-RTT QUIC handshake"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(Value, _n("tuic_heartbeat"), translate("Heartbeat interval(second)"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = "3"
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(ListValue, _n("tuic_alpn"), translate("QUIC TLS ALPN"))
|
||||
o.default = "default"
|
||||
o:value("default", translate("Default"))
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:value("spdy/3.1")
|
||||
o:value("h3,spdy/3.1")
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
end
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(Value, _n("hysteria2_hop"), translate("Port hopping range"))
|
||||
o.description = translate("Format as 1000:2000 or 1000-2000 Multiple groups are separated by commas (,).")
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_hop_interval"), translate("Hop Interval(Second)"), translate("Supports a fixed value or a random range (e.g., 30, 5-30), minimum 5."))
|
||||
o.datatype = "or(uinteger,portrange)"
|
||||
o.placeholder = "30"
|
||||
o.default = "30"
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_up_mbps"), translate("Max upload Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_down_mbps"), translate("Max download Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
|
||||
o:value("", translate("Disable"))
|
||||
o:value("salamander")
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password"))
|
||||
o:depends({ [_n("hysteria2_obfs_type")] = "salamander" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
end
|
||||
|
||||
-- [[ SSH config start ]] --
|
||||
o = s:option(TextValue, _n("ssh_priv_key"), translate("Private Key"))
|
||||
o.rows = 5
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value):gsub("\r\n", "\n"):gsub("[ \t]*\n[ \t]*", "\n"):gsub("\n+", "\n")
|
||||
return value
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("ssh_priv_key_pp"), translate("Private Key Passphrase"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
|
||||
o = s:option(DynamicList, _n("ssh_host_key"), translate("Host Key"), translate("Accept any if empty."))
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
|
||||
o = s:option(DynamicList, _n("ssh_host_key_algo"), translate("Host Key Algorithms"))
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
|
||||
o = s:option(Value, _n("ssh_client_version"), translate("Client Version"), translate("Random version will be used if empty."))
|
||||
o:depends({ [_n("protocol")] = "ssh" })
|
||||
-- [[ SSH config end ]] --
|
||||
|
||||
-- [[ naive start ]] --
|
||||
o = s:option(Value, _n("naive_insecure_concurrency"), translate("Concurrent Tunnels"))
|
||||
o.datatype = "uinteger"
|
||||
o.placeholder = "0"
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(Flag, _n("naive_quic"), translate("QUIC"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(ListValue, _n("naive_congestion_control"), translate("Congestion control algorithm"))
|
||||
o.default = "bbr"
|
||||
o:value("bbr", translate("BBR"))
|
||||
o:value("bbr2", translate("BBRv2"))
|
||||
o:value("cubic", translate("CUBIC"))
|
||||
o:value("reno", translate("New Reno"))
|
||||
o:depends({ [_n("naive_quic")] = "1" })
|
||||
-- [[ naive end ]] --
|
||||
|
||||
o = s:option(Flag, _n("tls"), translate("TLS"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "anytls" })
|
||||
|
||||
o = s:option(ListValue, _n("alpn"), translate("ALPN"))
|
||||
o.default = "default"
|
||||
o:value("default", translate("Default"))
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:depends({ [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Flag, _n("tls_disable_sni"), translate("Disable SNI"), translate("Do not send server name in ClientHello."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "hysteria"})
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("tls_serverName"), "SNI " .. translate("Domain"))
|
||||
o:depends({ [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "hysteria"})
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(Flag, _n("tls_allowInsecure"), translate("allowInsecure"), translate("Whether unsafe connections are allowed. When checked, Certificate validation will be skipped."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "hysteria"})
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Flag, _n("ech"), translate("ECH"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("flow")] = "", [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
o = s:option(TextValue, _n("ech_config"), translate("ECH Config"))
|
||||
o.default = ""
|
||||
o.rows = 5
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("ech")] = true })
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value):gsub("\r\n", "\n"):gsub("[ \t]*\n[ \t]*", "\n"):gsub("\n+", "\n")
|
||||
return value
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("ech_query_server_name"), translate("ECH Query Domain"), translate("Overrides the domain name used for ECH HTTPS record queries."))
|
||||
o:depends({ [_n("ech")] = true })
|
||||
|
||||
if singbox_tags:find("with_utls") then
|
||||
o = s:option(Flag, _n("utls"), translate("uTLS"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true })
|
||||
|
||||
o = s:option(ListValue, _n("fingerprint"), translate("Finger Print"))
|
||||
o:value("chrome")
|
||||
o:value("firefox")
|
||||
o:value("edge")
|
||||
o:value("safari")
|
||||
o:value("360")
|
||||
o:value("qq")
|
||||
o:value("ios")
|
||||
o:value("android")
|
||||
o:value("random")
|
||||
o:value("randomized")
|
||||
o.default = "chrome"
|
||||
o:depends({ [_n("utls")] = true })
|
||||
|
||||
-- [[ REALITY ]] --
|
||||
o = s:option(Flag, _n("reality"), translate("REALITY"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "vmess", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "socks", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "trojan", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "anytls", [_n("tls")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_publicKey"), translate("Public Key"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_shortId"), translate("Short Id"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("transport"), translate("Transport"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("http", "HTTP")
|
||||
o:value("ws", "WebSocket")
|
||||
o:value("httpupgrade", "HTTPUpgrade")
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("quic", "QUIC")
|
||||
end
|
||||
if singbox_tags:find("with_grpc") then
|
||||
o:value("grpc", "gRPC")
|
||||
else o:value("grpc", "gRPC-lite")
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
if singbox_tags:find("with_wireguard") then
|
||||
o = s:option(Value, _n("wireguard_public_key"), translate("Public Key"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_secret_key"), translate("Private Key"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_preSharedKey"), translate("Pre shared key"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(DynamicList, _n("wireguard_local_address"), translate("Local Address"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_mtu"), translate("MTU"))
|
||||
o.default = "1420"
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Flag, _n("wireguard_system_interface"), translate("System interface"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_interface_name"), translate("System interface name"))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
|
||||
o = s:option(Value, _n("wireguard_reserved"), translate("Reserved"), translate("Decimal numbers separated by \",\" or Base64-encoded strings."))
|
||||
o:depends({ [_n("protocol")] = "wireguard" })
|
||||
end
|
||||
|
||||
-- [[ TCP ]]--
|
||||
o = s:option(ListValue, _n("tcp_guise"), translate("Camouflage Type"))
|
||||
o:value("none", "none")
|
||||
o:value("http", "http")
|
||||
o:depends({ [_n("transport")] = "tcp" })
|
||||
|
||||
o = s:option(DynamicList, _n("tcp_guise_http_host"), translate("HTTP Host"))
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
|
||||
o = s:option(DynamicList, _n("tcp_guise_http_path"), translate("HTTP Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
|
||||
-- [[ HTTP ]]--
|
||||
o = s:option(DynamicList, _n("http_host"), translate("HTTP Host"))
|
||||
o:depends({ [_n("transport")] = "http" })
|
||||
|
||||
o = s:option(Value, _n("http_path"), translate("HTTP Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "http" })
|
||||
|
||||
o = s:option(Flag, _n("http_h2_health_check"), translate("Health check"))
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "http" })
|
||||
|
||||
o = s:option(Value, _n("http_h2_read_idle_timeout"), translate("Idle timeout"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "http", [_n("http_h2_health_check")] = true })
|
||||
|
||||
o = s:option(Value, _n("http_h2_health_check_timeout"), translate("Health check timeout"))
|
||||
o.default = "15"
|
||||
o:depends({ [_n("tls")] = true, [_n("transport")] = "http", [_n("http_h2_health_check")] = true })
|
||||
|
||||
-- [[ WebSocket ]]--
|
||||
o = s:option(Value, _n("ws_host"), translate("WebSocket Host"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Value, _n("ws_path"), translate("WebSocket Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Flag, _n("ws_enableEarlyData"), translate("Enable early data"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Value, _n("ws_maxEarlyData"), translate("Early data length"))
|
||||
o.default = "1024"
|
||||
o:depends({ [_n("ws_enableEarlyData")] = true })
|
||||
|
||||
o = s:option(Value, _n("ws_earlyDataHeaderName"), translate("Early data header name"), translate("Recommended value: Sec-WebSocket-Protocol"))
|
||||
o:depends({ [_n("ws_enableEarlyData")] = true })
|
||||
|
||||
-- [[ HTTPUpgrade ]]--
|
||||
o = s:option(Value, _n("httpupgrade_host"), translate("HTTPUpgrade Host"))
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
o = s:option(Value, _n("httpupgrade_path"), translate("HTTPUpgrade Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
-- [[ gRPC ]]--
|
||||
o = s:option(Value, _n("grpc_serviceName"), "ServiceName")
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
o = s:option(Flag, _n("grpc_health_check"), translate("Health check"))
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
o = s:option(Value, _n("grpc_idle_timeout"), translate("Idle timeout"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("grpc_health_check")] = true })
|
||||
|
||||
o = s:option(Value, _n("grpc_health_check_timeout"), translate("Health check timeout"))
|
||||
o.default = "20"
|
||||
o:depends({ [_n("grpc_health_check")] = true })
|
||||
|
||||
o = s:option(Flag, _n("grpc_permit_without_stream"), translate("Permit without stream"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("grpc_health_check")] = true })
|
||||
|
||||
-- [[ User-Agent ]]--
|
||||
o = s:option(Value, _n("user_agent"), translate("User-Agent"))
|
||||
o.default = ""
|
||||
o:value("", translate("default"))
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.131 Safari/537.36", "chrome")
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:90.0) Gecko/20100101 Firefox/90.0", "firefox")
|
||||
o:value("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15", "safari")
|
||||
o:value("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36 Edg/91.0.864.70", "edge")
|
||||
o:value("Go-http-client/1.1", "golang")
|
||||
o:value("curl/7.68.0", "curl")
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
o:depends({ [_n("transport")] = "http" })
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
|
||||
-- [[ Mux ]]--
|
||||
o = s:option(Flag, _n("mux"), translate("Mux"))
|
||||
o.rmempty = false
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("flow")] = "" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("uot")] = "" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
o = s:option(ListValue, _n("mux_type"), translate("Mux"))
|
||||
o:value("smux")
|
||||
o:value("yamux")
|
||||
o:value("h2mux")
|
||||
o:depends({ [_n("mux")] = true })
|
||||
|
||||
o = s:option(Value, _n("mux_concurrency"), translate("Mux concurrency"))
|
||||
o.default = 4
|
||||
o:depends({ [_n("mux")] = true, [_n("tcpbrutal")] = false })
|
||||
|
||||
o = s:option(Flag, _n("mux_padding"), translate("Padding"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("mux")] = true })
|
||||
|
||||
-- [[ TCP Brutal ]]--
|
||||
o = s:option(Flag, _n("tcpbrutal"), translate("TCP Brutal"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("mux")] = true })
|
||||
|
||||
o = s:option(Value, _n("tcpbrutal_up_mbps"), translate("Max upload Mbps"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("tcpbrutal")] = true })
|
||||
|
||||
o = s:option(Value, _n("tcpbrutal_down_mbps"), translate("Max download Mbps"))
|
||||
o.default = "50"
|
||||
o:depends({ [_n("tcpbrutal")] = true })
|
||||
|
||||
o = s:option(Flag, _n("shadowtls"), "ShadowTLS")
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "vmess", [_n("tls")] = false })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("tls")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("shadowtls_version"), "ShadowTLS " .. translate("Version"))
|
||||
o.default = "1"
|
||||
o:value("1", "ShadowTLS v1")
|
||||
o:value("2", "ShadowTLS v2")
|
||||
o:value("3", "ShadowTLS v3")
|
||||
o:depends({ [_n("shadowtls")] = true })
|
||||
|
||||
o = s:option(Value, _n("shadowtls_password"), "ShadowTLS " .. translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("shadowtls")] = true, [_n("shadowtls_version")] = "2" })
|
||||
o:depends({ [_n("shadowtls")] = true, [_n("shadowtls_version")] = "3" })
|
||||
|
||||
o = s:option(Value, _n("shadowtls_serverName"), "ShadowTLS " .. translate("Domain"))
|
||||
o:depends({ [_n("shadowtls")] = true })
|
||||
|
||||
if singbox_tags:find("with_utls") then
|
||||
o = s:option(Flag, _n("shadowtls_utls"), "ShadowTLS " .. translate("uTLS"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("shadowtls")] = true })
|
||||
|
||||
o = s:option(ListValue, _n("shadowtls_fingerprint"), "ShadowTLS " .. translate("Finger Print"))
|
||||
o:value("chrome")
|
||||
o:value("firefox")
|
||||
o:value("edge")
|
||||
o:value("safari")
|
||||
-- o:value("360")
|
||||
o:value("qq")
|
||||
o:value("ios")
|
||||
-- o:value("android")
|
||||
o:value("random")
|
||||
-- o:value("randomized")
|
||||
o.default = "chrome"
|
||||
o:depends({ [_n("shadowtls")] = true, [_n("shadowtls_utls")] = true })
|
||||
end
|
||||
|
||||
-- [[ SIP003 plugin ]]--
|
||||
o = s:option(Flag, _n("plugin_enabled"), translate("plugin"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(ListValue, _n("plugin"), "SIP003 " .. translate("plugin"))
|
||||
o.default = "obfs-local"
|
||||
o:depends({ [_n("plugin_enabled")] = true })
|
||||
o:value("obfs-local")
|
||||
o:value("v2ray-plugin")
|
||||
|
||||
o = s:option(Value, _n("plugin_opts"), translate("opts"))
|
||||
o:depends({ [_n("plugin_enabled")] = true })
|
||||
|
||||
o = s:option(ListValue, _n("domain_resolver"), translate("Domain DNS Resolve"), translate("If the node address is a domain name, this DNS will be used for resolution."))
|
||||
o:value("", translate("Auto"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("udp", "UDP")
|
||||
o:value("https", "HTTPS")
|
||||
|
||||
o = s:option(Value, _n("domain_resolver_dns"), "DNS")
|
||||
o.datatype = "or(ipaddr,ipaddrport)"
|
||||
o:value("114.114.114.114")
|
||||
o:value("223.5.5.5:53")
|
||||
o.default = o.keylist[1]
|
||||
o:depends({ [_n("domain_resolver")] = "tcp" })
|
||||
o:depends({ [_n("domain_resolver")] = "udp" })
|
||||
|
||||
o = s:option(Value, _n("domain_resolver_dns_https"), "DNS")
|
||||
o:value("https://120.53.53.53/dns-query", "DNSPod")
|
||||
o:value("https://223.5.5.5/dns-query", "AliDNS")
|
||||
o.default = o.keylist[1]
|
||||
o:depends({ [_n("domain_resolver")] = "https" })
|
||||
|
||||
o = s:option(ListValue, _n("domain_strategy"), translate("Domain Strategy"), translate("If is domain name, The requested domain name will be resolved to IP before connect."))
|
||||
o.default = ""
|
||||
o:value("", translate("Auto"))
|
||||
o:value("prefer_ipv4", translate("Prefer IPv4"))
|
||||
o:value("prefer_ipv6", translate("Prefer IPv6"))
|
||||
o:value("ipv4_only", translate("IPv4 Only"))
|
||||
o:value("ipv6_only", translate("IPv6 Only"))
|
||||
|
||||
local protocols = s.fields[_n("protocol")].keylist
|
||||
if #protocols > 0 then
|
||||
for i, v in ipairs(protocols) do
|
||||
if not v:find("^_") then
|
||||
s.fields[_n("address")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("port")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("domain_resolver")]:depends({ [_n("protocol")] = v })
|
||||
s.fields[_n("domain_strategy")]:depends({ [_n("protocol")] = v })
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
-- [[ Normal single node End ]]
|
||||
|
||||
if not load_shunt_options then
|
||||
o = s:option(ListValue, _n("chain_proxy"), translate("Chain Proxy"))
|
||||
o:value("", translate("Close(Not use)"))
|
||||
if not (load_iface_options or load_urltest_options) then
|
||||
-- Special node cannot be use pre-proxy.
|
||||
o:value("1", translate("Preproxy Node"))
|
||||
end
|
||||
o:value("2", translate("Landing Node"))
|
||||
|
||||
o1 = s:option(ListValue, _n("preproxy_node"), translate("Preproxy Node"), translate("Only support a layer of proxy."))
|
||||
o1:depends({ [_n("chain_proxy")] = "1" })
|
||||
o1.template = appname .. "/cbi/nodes_listvalue"
|
||||
o1.group = {}
|
||||
|
||||
o2 = s:option(ListValue, _n("to_node"), translate("Landing Node"), translate("Only support a layer of proxy."))
|
||||
o2:depends({ [_n("chain_proxy")] = "2" })
|
||||
o2.template = appname .. "/cbi/nodes_listvalue"
|
||||
o2.group = {}
|
||||
|
||||
for k1, v1 in pairs(node_list) do
|
||||
if k1 ~= "shunt_list" and k1 ~= "iface_list" then
|
||||
for i, v in ipairs(v1) do
|
||||
if v.id ~= arg[1] then
|
||||
o1:value(v.id, v.remark)
|
||||
o1.group[#o1.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
if k1 == "normal_list" then
|
||||
-- Landing Node not support use special node.
|
||||
o2:value(v.id, v.remark)
|
||||
o2.group[#o2.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
|
||||
if load_shunt_options then
|
||||
local current_node = m.uci:get_all(appname, arg[1]) or {}
|
||||
local shunt_lua = loadfile("/usr/lib/lua/luci/model/cbi/passwall2/client/include/shunt_options.lua")
|
||||
setfenv(shunt_lua, getfenv(1))(m, s, {
|
||||
node_id = arg[1],
|
||||
node = current_node,
|
||||
node_list = node_list,
|
||||
})
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.is_finded("sslocal") then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "SS-Rust"
|
||||
|
||||
-- [[ Shadowsocks Rust ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Shadowsocks Rust")
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "ssrust_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ssrust_encrypt_method_list = {
|
||||
"none", "plain",
|
||||
"aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305",
|
||||
"2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha8-poly1305", "2022-blake3-chacha20-poly1305"
|
||||
}
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
|
||||
o = s:option(Value, _n("method"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(ssrust_encrypt_method_list) do o:value(t) end
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Connection Timeout"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 300
|
||||
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"), translate("Need node support required"))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Flag, _n("plugin_enabled"), translate("plugin"))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Value, _n("plugin"), "SIP003 " .. translate("plugin"), translate("Supports custom SIP003 plugins, Make sure the plugin is installed."))
|
||||
o.default = "none"
|
||||
o:value("none", translate("none"))
|
||||
if api.is_finded("xray-plugin") then o:value("xray-plugin") end
|
||||
if api.is_finded("v2ray-plugin") then o:value("v2ray-plugin") end
|
||||
if api.is_finded("obfs-local") then o:value("obfs-local") end
|
||||
if api.is_finded("shadow-tls") then o:value("shadow-tls") end
|
||||
o:depends({ [_n("plugin_enabled")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" and value ~= "none" then
|
||||
if not api.is_finded(value) then
|
||||
return nil, value .. ": " .. translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("plugin_opts"), translate("opts"))
|
||||
o:depends({ [_n("plugin_enabled")] = true })
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,62 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.is_finded("ss-local") then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "SS"
|
||||
|
||||
-- [[ Shadowsocks Libev ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Shadowsocks Libev")
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "ss_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ss_encrypt_method_list = {
|
||||
"rc4-md5", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ctr",
|
||||
"aes-192-ctr", "aes-256-ctr", "bf-cfb", "salsa20", "chacha20", "chacha20-ietf",
|
||||
"aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305",
|
||||
"xchacha20-ietf-poly1305"
|
||||
}
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
|
||||
o = s:option(Value, _n("method"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(ss_encrypt_method_list) do o:value(t) end
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Connection Timeout"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 300
|
||||
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"), translate("Need node support required"))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(Flag, _n("plugin_enabled"), translate("plugin"))
|
||||
o.default = 0
|
||||
|
||||
o = s:option(ListValue, _n("plugin"), "SIP003 " .. translate("plugin"))
|
||||
o.default = "none"
|
||||
o:value("none", translate("none"))
|
||||
if api.is_finded("xray-plugin") then o:value("xray-plugin") end
|
||||
if api.is_finded("v2ray-plugin") then o:value("v2ray-plugin") end
|
||||
if api.is_finded("obfs-local") then o:value("obfs-local") end
|
||||
o:depends({ [_n("plugin_enabled")] = true })
|
||||
|
||||
o = s:option(Value, _n("plugin_opts"), translate("opts"))
|
||||
o:depends({ [_n("plugin_enabled")] = true })
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,70 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.is_finded("ssr-local") then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "SSR"
|
||||
|
||||
-- [[ ShadowsocksR Libev ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "ShadowsocksR Libev")
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "ssr_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ssr_encrypt_method_list = {
|
||||
"none", "table", "rc2-cfb", "rc4", "rc4-md5", "rc4-md5-6", "aes-128-cfb",
|
||||
"aes-192-cfb", "aes-256-cfb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr",
|
||||
"bf-cfb", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb",
|
||||
"cast5-cfb", "des-cfb", "idea-cfb", "seed-cfb", "salsa20", "chacha20",
|
||||
"chacha20-ietf"
|
||||
}
|
||||
|
||||
local ssr_protocol_list = {
|
||||
"origin", "verify_simple", "verify_deflate", "verify_sha1", "auth_simple",
|
||||
"auth_sha1", "auth_sha1_v2", "auth_sha1_v4", "auth_aes128_md5",
|
||||
"auth_aes128_sha1", "auth_chain_a", "auth_chain_b", "auth_chain_c",
|
||||
"auth_chain_d", "auth_chain_e", "auth_chain_f"
|
||||
}
|
||||
local ssr_obfs_list = {
|
||||
"plain", "http_simple", "http_post", "random_head", "tls_simple",
|
||||
"tls1.0_session_auth", "tls1.2_ticket_auth"
|
||||
}
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
|
||||
o = s:option(ListValue, _n("method"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(ssr_encrypt_method_list) do o:value(t) end
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
for a, t in ipairs(ssr_protocol_list) do o:value(t) end
|
||||
|
||||
o = s:option(Value, _n("protocol_param"), translate("Protocol_param"))
|
||||
|
||||
o = s:option(ListValue, _n("obfs"), translate("Obfs"))
|
||||
for a, t in ipairs(ssr_obfs_list) do o:value(t) end
|
||||
|
||||
o = s:option(Value, _n("obfs_param"), translate("Obfs_param"))
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Connection Timeout"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 300
|
||||
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"), translate("Need node support required"))
|
||||
o.default = 0
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,145 @@
|
||||
local m, s = ...
|
||||
|
||||
if not api.is_finded("tuic-client") then
|
||||
return
|
||||
end
|
||||
|
||||
type_name = "TUIC"
|
||||
|
||||
-- [[ TUIC ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "TUIC")
|
||||
|
||||
if s.val["type"] ~= type_name then
|
||||
return
|
||||
end
|
||||
|
||||
local option_prefix = "tuic_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
o = s:option(Value, _n("address"), translate("Address (Support Domain Name)"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
|
||||
o = s:option(Value, _n("uuid"), translate("ID"))
|
||||
o.password = true
|
||||
|
||||
-- Tuic Password for remote server connect
|
||||
o = s:option(Value, _n("password"), translate("TUIC User Password For Connect Remote Server"))
|
||||
o.password = true
|
||||
o.rmempty = true
|
||||
o.default = ""
|
||||
o.rewrite_option = o.option
|
||||
|
||||
--[[
|
||||
-- Tuic username for local socks connect
|
||||
o = s:option(Value, _n("socks_username"), translate("TUIC UserName For Local Socks"))
|
||||
o.rmempty = true
|
||||
o.default = ""
|
||||
o.rewrite_option = o.option
|
||||
|
||||
-- Tuic Password for local socks connect
|
||||
o = s:option(Value, _n("socks_password"), translate("TUIC Password For Local Socks"))
|
||||
o.password = true
|
||||
o.rmempty = true
|
||||
o.default = ""
|
||||
o.rewrite_option = o.option
|
||||
--]]
|
||||
|
||||
o = s:option(Value, _n("ip"), translate("Set the TUIC proxy server ip address"))
|
||||
o.datatype = "ipaddr"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(ListValue, _n("udp_relay_mode"), translate("UDP relay mode"))
|
||||
o:value("native", translate("native"))
|
||||
o:value("quic", translate("QUIC"))
|
||||
o.default = "native"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(ListValue, _n("congestion_control"), translate("Congestion control algorithm"))
|
||||
o:value("bbr", translate("BBR"))
|
||||
o:value("cubic", translate("CUBIC"))
|
||||
o:value("new_reno", translate("New Reno"))
|
||||
o.default = "cubic"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("heartbeat"), translate("Heartbeat interval(second)"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = "3"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Timeout for establishing a connection to server(second)"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = "8"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("gc_interval"), translate("Garbage collection interval(second)"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = "3"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("gc_lifetime"), translate("Garbage collection lifetime(second)"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = "15"
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("send_window"), translate("TUIC send window"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 20971520
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("receive_window"), translate("TUIC receive window"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 10485760
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Value, _n("max_package_size"), translate("TUIC Maximum packet size the socks5 server can receive from external, in bytes"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 1500
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
--Tuic settings for the local inbound socks5 server
|
||||
o = s:option(Flag, _n("dual_stack"), translate("Set if the listening socket should be dual-stack"))
|
||||
o.default = 0
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Flag, _n("disable_sni"), translate("Disable SNI"))
|
||||
o.default = 0
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(Flag, _n("zero_rtt_handshake"), translate("Enable 0-RTT QUIC handshake"))
|
||||
o.default = 0
|
||||
o.rmempty = true
|
||||
o.rewrite_option = o.option
|
||||
|
||||
o = s:option(ListValue, _n("tls_alpn"), translate("TLS ALPN"))
|
||||
o.rmempty = true
|
||||
o.default = ""
|
||||
o:value("", translate("Default"))
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:value("spdy/3.1")
|
||||
o:value("h3,spdy/3.1")
|
||||
o.rewrite_option = o.option
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,92 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
m = Map("passwall2_server", translate("Server-Side"))
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
t = m:section(NamedSection, "global", "global")
|
||||
t.anonymous = true
|
||||
t.addremove = false
|
||||
|
||||
e = t:option(Flag, "enable", translate("Enable"))
|
||||
e.rmempty = false
|
||||
|
||||
t = m:section(TypedSection, "user", translate("Users Manager"))
|
||||
t.anonymous = true
|
||||
t.addremove = true
|
||||
t.sortable = true
|
||||
t.template = "cbi/tblsection"
|
||||
t.extedit = api.url("server_user", "%s")
|
||||
function t.create(e, t)
|
||||
local uuid = api.gen_uuid()
|
||||
t = uuid
|
||||
TypedSection.create(e, t)
|
||||
luci.http.redirect(e.extedit:format(t))
|
||||
end
|
||||
function t.remove(e, t)
|
||||
e.map.proceed = true
|
||||
e.map:del(t)
|
||||
luci.http.redirect(api.url("server"))
|
||||
end
|
||||
|
||||
e = t:option(Flag, "enable", translate("Enable"))
|
||||
e.width = "5%"
|
||||
e.rmempty = false
|
||||
|
||||
e = t:option(DummyValue, "status", translate("Status"))
|
||||
e.rawhtml = true
|
||||
e.cfgvalue = function(t, n)
|
||||
return string.format('<font class="_users_status">%s</font>', translate("Collecting data..."))
|
||||
end
|
||||
|
||||
e = t:option(DummyValue, "remarks", translate("Remarks"))
|
||||
e.width = "15%"
|
||||
|
||||
e = t:option(DummyValue, "type", translate("Type"))
|
||||
e.width = "20%"
|
||||
e.rawhtml = true
|
||||
e.cfgvalue = function(t, n)
|
||||
local str = ""
|
||||
local type = m:get(n, "type") or ""
|
||||
if type == "sing-box" or type == "Xray" then
|
||||
local protocol = m:get(n, "protocol") or ""
|
||||
if protocol == "vmess" then
|
||||
protocol = "VMess"
|
||||
elseif protocol == "vless" then
|
||||
protocol = "VLESS"
|
||||
elseif protocol == "shadowsocks" then
|
||||
protocol = "SS"
|
||||
elseif protocol == "shadowsocksr" then
|
||||
protocol = "SSR"
|
||||
elseif protocol == "wireguard" then
|
||||
protocol = "WG"
|
||||
elseif protocol == "hysteria" then
|
||||
protocol = "HY"
|
||||
elseif protocol == "hysteria2" then
|
||||
protocol = "HY2"
|
||||
elseif protocol == "anytls" then
|
||||
protocol = "AnyTLS"
|
||||
else
|
||||
protocol = protocol:gsub("^%l",string.upper)
|
||||
local custom = m:get(n, "custom") or "0"
|
||||
if custom == "1" then
|
||||
protocol = translate("Custom Config")
|
||||
end
|
||||
end
|
||||
if type == "sing-box" then type = "Sing-Box" end
|
||||
type = type .. " " .. protocol
|
||||
end
|
||||
str = str .. translate(type)
|
||||
return str
|
||||
end
|
||||
|
||||
e = t:option(DummyValue, "port", translate("Port"))
|
||||
|
||||
e = t:option(Flag, "log", translate("Log"))
|
||||
e.default = "1"
|
||||
e.rmempty = false
|
||||
|
||||
m:append(Template("passwall2/server/log"))
|
||||
|
||||
m:append(Template("passwall2/server/users_list_status"))
|
||||
return m
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
local m, s = ...
|
||||
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
if not api.finded_com("hysteria") then
|
||||
return
|
||||
end
|
||||
|
||||
local type_name = "Hysteria2"
|
||||
|
||||
local option_prefix = "hysteria2_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
-- [[ Hysteria2 ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Hysteria2")
|
||||
|
||||
o = s:option(Flag, _n("custom"), translate("Use Custom Config"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("obfs"), translate("Obfs Password"))
|
||||
o.rewrite_option = o.option
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o.rewrite_option = o.option
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("udp"), translate("UDP"))
|
||||
o.default = "1"
|
||||
o.rewrite_option = o.option
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("up_mbps"), translate("Max upload Mbps"))
|
||||
o.rewrite_option = o.option
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("down_mbps"), translate("Max download Mbps"))
|
||||
o.rewrite_option = o.option
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("ignoreClientBandwidth"), translate("ignoreClientBandwidth"))
|
||||
o.default = "0"
|
||||
o.rewrite_option = o.option
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(FileUpload, _n("tls_certificateFile"), translate("Public key absolute path"), translate("as:") .. "/etc/ssl/fullchain.pem")
|
||||
o.default = m:get(s.section, "tls_certificateFile") or "/etc/config/ssl/" .. arg[1] .. ".pem"
|
||||
if o and o:formvalue(arg[1]) then o.default = o:formvalue(arg[1]) end
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" then
|
||||
if not nixio.fs.access(value) then
|
||||
return nil, translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(FileUpload, _n("tls_keyFile"), translate("Private key absolute path"), translate("as:") .. "/etc/ssl/private.key")
|
||||
o.default = m:get(s.section, "tls_keyFile") or "/etc/config/ssl/" .. arg[1] .. ".key"
|
||||
if o and o:formvalue(arg[1]) then o.default = o:formvalue(arg[1]) end
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" then
|
||||
if not nixio.fs.access(value) then
|
||||
return nil, translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(TextValue, _n("custom_config"), translate("Custom Config"))
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("custom")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local config_str = m:get(section, "config_str")
|
||||
if config_str then
|
||||
return api.base64Decode(config_str)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "config_str", api.base64Encode(value) or "")
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("log"), translate("Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,526 @@
|
||||
local m, s = ...
|
||||
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
if not api.finded_com("xray") then
|
||||
return
|
||||
end
|
||||
|
||||
local type_name = "Xray"
|
||||
|
||||
local option_prefix = "xray_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local x_ss_method_list = {
|
||||
"none", "plain", "aes-128-gcm", "aes-256-gcm", "chacha20-poly1305", "xchacha20-poly1305", "2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305"
|
||||
}
|
||||
|
||||
local header_type_list = {
|
||||
"none", "srtp", "utp", "wechat-video", "dtls", "wireguard", "dns"
|
||||
}
|
||||
|
||||
-- [[ Xray ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Xray")
|
||||
if not s.fields["type"].default then
|
||||
s.fields["type"].default = type_name
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("custom"), translate("Use Custom Config"))
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
o:value("vmess", "Vmess")
|
||||
o:value("vless", "VLESS")
|
||||
o:value("http", "HTTP")
|
||||
o:value("socks", "Socks")
|
||||
o:value("shadowsocks", "Shadowsocks")
|
||||
o:value("trojan", "Trojan")
|
||||
o:value("hysteria2", translate("Hysteria2"))
|
||||
o:value("dokodemo-door", "dokodemo-door")
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("auth"), translate("Auth"))
|
||||
o.validate = function(self, value, t)
|
||||
if value and value == "1" then
|
||||
local user_v = s.fields[_n("username")] and s.fields[_n("username")]:formvalue(t) or ""
|
||||
local pass_v = s.fields[_n("password")] and s.fields[_n("password")]:formvalue(t) or ""
|
||||
if user_v == "" or pass_v == "" then
|
||||
return nil, translate("Username and Password must be used together!")
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
|
||||
o = s:option(Value, _n("username"), translate("Username"))
|
||||
o:depends({ [_n("auth")] = true })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("auth")] = true })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(ListValue, _n("d_protocol"), translate("Destination protocol"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("udp", "UDP")
|
||||
o:value("tcp,udp", "TCP,UDP")
|
||||
o:depends({ [_n("protocol")] = "dokodemo-door" })
|
||||
|
||||
o = s:option(Value, _n("d_address"), translate("Destination address"))
|
||||
o:depends({ [_n("protocol")] = "dokodemo-door" })
|
||||
|
||||
o = s:option(Value, _n("d_port"), translate("Destination port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("protocol")] = "dokodemo-door" })
|
||||
|
||||
o = s:option(Value, _n("decryption"), translate("Encrypt Method") .. " (decryption)")
|
||||
o.default = "none"
|
||||
o.placeholder = "none"
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value)
|
||||
return (value == "" and "none" or value)
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("x_ss_method"), translate("Encrypt Method"))
|
||||
o.rewrite_option = "method"
|
||||
for a, t in ipairs(x_ss_method_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(Flag, _n("iv_check"), translate("IV Check"))
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(ListValue, _n("ss_network"), translate("Transport"))
|
||||
o.default = "tcp,udp"
|
||||
o:value("tcp", "TCP")
|
||||
o:value("udp", "UDP")
|
||||
o:value("tcp,udp", "TCP,UDP")
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(Flag, _n("udp_forward"), translate("UDP Forward"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
|
||||
o = s:option(DynamicList, _n("uuid"), translate("ID") .. "/" .. translate("Password"))
|
||||
for i = 1, 3 do
|
||||
o:value(api.gen_uuid(1))
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
o = s:option(ListValue, _n("flow"), translate("flow"))
|
||||
o.default = ""
|
||||
o:value("", translate("Disable"))
|
||||
o:value("xtls-rprx-vision")
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
|
||||
---- [[ hysteria2 ]]
|
||||
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
|
||||
o = s:option(Flag, _n("hysteria2_ignore_client_bandwidth"), translate("Client BBR Flow Control"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_up_mbps"), translate("Max upload Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2", [_n("hysteria2_ignore_client_bandwidth")] = false })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_down_mbps"), translate("Max download Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2", [_n("hysteria2_ignore_client_bandwidth")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
|
||||
o:value("", translate("Disable"))
|
||||
o:value("salamander")
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password"))
|
||||
o:depends({ [_n("hysteria2_obfs_type")] = "salamander" })
|
||||
|
||||
---- [[ TLS ]]
|
||||
o = s:option(Flag, _n("tls"), translate("TLS"))
|
||||
o.default = 0
|
||||
o.validate = function(self, value, t)
|
||||
if value then
|
||||
local reality = s.fields[_n("reality")] and s.fields[_n("reality")]:formvalue(t) or nil
|
||||
if reality and reality == "1" then return value end
|
||||
if value == "1" then
|
||||
local ca = s.fields[_n("tls_certificateFile")] and s.fields[_n("tls_certificateFile")]:formvalue(t) or ""
|
||||
local key = s.fields[_n("tls_keyFile")] and s.fields[_n("tls_keyFile")]:formvalue(t) or ""
|
||||
if ca == "" or key == "" then
|
||||
return nil, translate("Public key and Private key path can not be empty!")
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
-- [[ REALITY ]] --
|
||||
o = s:option(Flag, _n("reality"), translate("REALITY"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("tls")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_private_key"), translate("Private Key"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(DynamicList, _n("reality_shortId"), translate("Short Id"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_dest"), translate("Dest"))
|
||||
o.default = "google.com:443"
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(DynamicList, _n("reality_serverNames"), translate("serverNames"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
function o.write(self, section, value)
|
||||
local t = {}
|
||||
local t2 = {}
|
||||
if type(value) == "table" then
|
||||
local x
|
||||
for _, x in ipairs(value) do
|
||||
if x and #x > 0 then
|
||||
if not t2[x] then
|
||||
t2[x] = x
|
||||
t[#t+1] = x
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
t = { value }
|
||||
end
|
||||
return DynamicList.write(self, section, t)
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("alpn"), translate("alpn"))
|
||||
o.default = "h2,http/1.1"
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
|
||||
o = s:option(Flag, _n("use_mldsa65Seed"), translate("ML-DSA-65"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(TextValue, _n("reality_mldsa65Seed"), "ML-DSA-65 " .. translate("Private Key"))
|
||||
o.default = ""
|
||||
o.rows = 5
|
||||
o.wrap = "soft"
|
||||
o:depends({ [_n("use_mldsa65Seed")] = true })
|
||||
o.validate = function(self, value)
|
||||
return api.trim(value:gsub("[\r\n]", ""))
|
||||
end
|
||||
|
||||
-- o = s:option(Value, _n("minversion"), translate("minversion"))
|
||||
-- o.default = "1.3"
|
||||
-- o:value("1.3")
|
||||
--o:depends({ [_n("tls")] = true })
|
||||
|
||||
-- [[ TLS ]] --
|
||||
o = s:option(FileUpload, _n("tls_certificateFile"), translate("Public key absolute path"), translate("as:") .. "/etc/ssl/fullchain.pem")
|
||||
o.default = m:get(s.section, "tls_certificateFile") or "/etc/config/ssl/" .. arg[1] .. ".pem"
|
||||
if o and o:formvalue(arg[1]) then o.default = o:formvalue(arg[1]) end
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" then
|
||||
if not nixio.fs.access(value) then
|
||||
return nil, translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
o = s:option(FileUpload, _n("tls_keyFile"), translate("Private key absolute path"), translate("as:") .. "/etc/ssl/private.key")
|
||||
o.default = m:get(s.section, "tls_keyFile") or "/etc/config/ssl/" .. arg[1] .. ".key"
|
||||
if o and o:formvalue(arg[1]) then o.default = o:formvalue(arg[1]) end
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" then
|
||||
if not nixio.fs.access(value) then
|
||||
return nil, translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("ech"), translate("ECH"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
|
||||
o = s:option(TextValue, _n("ech_key"), translate("ECH Key"))
|
||||
o.default = ""
|
||||
o.rows = 5
|
||||
o.wrap = "soft"
|
||||
o:depends({ [_n("ech")] = true })
|
||||
o.validate = function(self, value)
|
||||
return api.trim(value:gsub("[\r\n]", ""))
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("transport"), translate("Transport"))
|
||||
o:value("raw", "RAW")
|
||||
o:value("mkcp", "mKCP")
|
||||
o:value("ws", "WebSocket")
|
||||
o:value("grpc", "gRPC")
|
||||
o:value("httpupgrade", "HttpUpgrade")
|
||||
o:value("xhttp", "XHTTP")
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
-- [[ WebSocket ]]--
|
||||
o = s:option(Value, _n("ws_host"), translate("WebSocket Host"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Value, _n("ws_path"), translate("WebSocket Path"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
-- [[ HttpUpgrade ]]--
|
||||
o = s:option(Value, _n("httpupgrade_host"), translate("HttpUpgrade Host"))
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
o = s:option(Value, _n("httpupgrade_path"), translate("HttpUpgrade Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
-- [[ XHTTP ]]--
|
||||
o = s:option(Value, _n("xhttp_host"), translate("XHTTP Host"))
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(Value, _n("xhttp_path"), translate("XHTTP Path"))
|
||||
o.placeholder = "/"
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(Value, _n("xhttp_maxuploadsize"), translate("maxUploadSize"))
|
||||
o.default = "1000000"
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(Value, _n("xhttp_maxconcurrentuploads"), translate("maxConcurrentUploads"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("transport")] = "xhttp" })
|
||||
|
||||
o = s:option(Value, _n("splithttp_maxuploadsize"), translate("maxUploadSize"))
|
||||
o.default = "1000000"
|
||||
o:depends({ [_n("transport")] = "splithttp" })
|
||||
|
||||
o = s:option(Value, _n("splithttp_maxconcurrentuploads"), translate("maxConcurrentUploads"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("transport")] = "splithttp" })
|
||||
|
||||
-- [[ TCP ]]--
|
||||
|
||||
o = s:option(ListValue, _n("tcp_guise"), translate("Camouflage Type"))
|
||||
o:value("none", "none")
|
||||
o:value("http", "http")
|
||||
o:depends({ [_n("transport")] = "raw" })
|
||||
|
||||
o = s:option(DynamicList, _n("tcp_guise_http_host"), translate("HTTP Host"))
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
|
||||
o = s:option(DynamicList, _n("tcp_guise_http_path"), translate("HTTP Path"))
|
||||
o:depends({ [_n("tcp_guise")] = "http" })
|
||||
|
||||
-- [[ mKCP ]]--
|
||||
|
||||
o = s:option(ListValue, _n("mkcp_guise"), translate("Camouflage Type"), translate('<br />none: default, no masquerade, data sent is packets with no characteristics.<br />srtp: disguised as an SRTP packet, it will be recognized as video call data (such as FaceTime).<br />utp: packets disguised as uTP will be recognized as bittorrent downloaded data.<br />wechat-video: packets disguised as WeChat video calls.<br />dtls: disguised as DTLS 1.2 packet.<br />wireguard: disguised as a WireGuard packet. (not really WireGuard protocol)<br />dns: Disguising traffic as DNS requests.'))
|
||||
for a, t in ipairs(header_type_list) do o:value(t) end
|
||||
o:depends({ [_n("transport")] = "mkcp" })
|
||||
|
||||
o = s:option(Value, _n("mkcp_domain"), translate("Camouflage Domain"), translate("Use it together with the DNS disguised type. You can fill in any domain."))
|
||||
o:depends({ [_n("mkcp_guise")] = "dns" })
|
||||
|
||||
o = s:option(Value, _n("mkcp_mtu"), translate("KCP MTU"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 1350
|
||||
o:depends({ [_n("transport")] = "mkcp" })
|
||||
|
||||
o = s:option(Value, _n("mkcp_seed"), translate("KCP Seed"))
|
||||
o:depends({ [_n("transport")] = "mkcp" })
|
||||
|
||||
-- [[ gRPC ]]--
|
||||
o = s:option(Value, _n("grpc_serviceName"), "ServiceName")
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
--[[FinalMask]]
|
||||
o = s:option(Flag, _n("use_finalmask"), "FinalMask")
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "wireguard" })
|
||||
o:depends({ [_n("custom")] = false, [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(TextValue, _n("finalmask"), "FinalMask JSON")
|
||||
o:depends({ [_n("use_finalmask")] = true })
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local raw = m:get(section, "finalmask")
|
||||
if raw then
|
||||
return api.base64Decode(raw)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "finalmask", api.base64Encode(value) or "")
|
||||
end
|
||||
o.validate = function(self, value)
|
||||
value = api.trim(value):gsub("\r\n", "\n"):gsub("^[ \t]*\n", ""):gsub("\n[ \t]*$", ""):gsub("\n[ \t]*\n", "\n")
|
||||
if api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, "FinalMask " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
|
||||
--[[acceptProxyProtocol]]
|
||||
o = s:option(Flag, _n("acceptProxyProtocol"), translate("acceptProxyProtocol"), translate("Whether to receive PROXY protocol, when this node want to be fallback or forwarded by proxy, it must be enable, otherwise it cannot be used."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
--[[Fast Open]]
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("protocol")] = "vmess", [_n("custom")] = false })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("custom")] = false })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks", [_n("custom")] = false })
|
||||
o:depends({ [_n("protocol")] = "trojan", [_n("custom")] = false })
|
||||
|
||||
-- [[ Fallback ]]--
|
||||
o = s:option(Flag, _n("fallback"), translate("Fallback"))
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("transport")] = "raw" })
|
||||
o:depends({ [_n("protocol")] = "trojan", [_n("transport")] = "raw" })
|
||||
|
||||
--[[
|
||||
o = s:option(Value, _n("fallback_alpn"), "Fallback alpn")
|
||||
o:depends({ [_n("fallback")] = true })
|
||||
|
||||
o = s:option(Value, _n("fallback_path"), "Fallback path")
|
||||
o:depends({ [_n("fallback")] = true })
|
||||
|
||||
o = s:option(Value, _n("fallback_dest"), "Fallback dest")
|
||||
o:depends({ [_n("fallback")] = true })
|
||||
|
||||
o = s:option(Value, _n("fallback_xver"), "Fallback xver")
|
||||
o.default = 0
|
||||
o:depends({ [_n("fallback")] = true })
|
||||
]]--
|
||||
|
||||
o = s:option(DynamicList, _n("fallback_list"), "Fallback", translate("format: dest,path,xver"))
|
||||
o:depends({ [_n("fallback")] = true })
|
||||
|
||||
o = s:option(Flag, _n("bind_local"), translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("accept_lan"), translate("Accept LAN Access"), translate("When selected, it can accessed lan , this will not be safe!"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
local nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
if e.node_type == "normal" and e.type == type_name then
|
||||
nodes_table[#nodes_table + 1] = {
|
||||
id = e[".name"],
|
||||
remarks = e["remark"],
|
||||
group = e["group"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("outbound_node"), translate("outbound node"))
|
||||
o:value("", translate("Close"))
|
||||
o:value("_socks", translate("Custom Socks"))
|
||||
o:value("_http", translate("Custom HTTP"))
|
||||
o:value("_iface", translate("Custom Interface"))
|
||||
o.template = api.appname .. "/cbi/nodes_listvalue"
|
||||
o.group = {"","","",""}
|
||||
for k, v in pairs(nodes_table) do
|
||||
o:value(v.id, v.remarks)
|
||||
o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("outbound_node_address"), translate("Address (Support Domain Name)"))
|
||||
o:depends({ [_n("outbound_node")] = "_socks"})
|
||||
o:depends({ [_n("outbound_node")] = "_http"})
|
||||
|
||||
o = s:option(Value, _n("outbound_node_port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("outbound_node")] = "_socks"})
|
||||
o:depends({ [_n("outbound_node")] = "_http"})
|
||||
|
||||
o = s:option(Value, _n("outbound_node_username"), translate("Username"))
|
||||
o:depends({ [_n("outbound_node")] = "_socks"})
|
||||
o:depends({ [_n("outbound_node")] = "_http"})
|
||||
|
||||
o = s:option(Value, _n("outbound_node_password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("outbound_node")] = "_socks"})
|
||||
o:depends({ [_n("outbound_node")] = "_http"})
|
||||
|
||||
o = s:option(Value, _n("outbound_node_iface"), translate("Interface"))
|
||||
o.default = "eth1"
|
||||
o:depends({ [_n("outbound_node")] = "_iface"})
|
||||
|
||||
o = s:option(TextValue, _n("custom_config"), translate("Custom Config"))
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("custom")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local config_str = m:get(section, "config_str")
|
||||
if config_str then
|
||||
return api.base64Decode(config_str)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "config_str", api.base64Encode(value) or "")
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("log"), translate("Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(ListValue, _n("loglevel"), translate("Log Level"))
|
||||
o.default = "warning"
|
||||
o:value("debug")
|
||||
o:value("info")
|
||||
o:value("warning")
|
||||
o:value("error")
|
||||
o:depends({ [_n("log")] = true })
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,491 @@
|
||||
local m, s = ...
|
||||
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
local singbox_bin = api.finded_com("sing-box")
|
||||
|
||||
if not singbox_bin then
|
||||
return
|
||||
end
|
||||
|
||||
local fs = api.fs
|
||||
|
||||
local singbox_tags = luci.sys.exec(singbox_bin .. " version | grep 'Tags:' | awk '{print $2}'")
|
||||
|
||||
local type_name = "sing-box"
|
||||
|
||||
local option_prefix = "singbox_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ss_method_list = {
|
||||
"none", "aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305", "xchacha20-ietf-poly1305",
|
||||
"2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha20-poly1305"
|
||||
}
|
||||
|
||||
-- [[ Sing-Box ]]
|
||||
|
||||
s.fields["type"]:value(type_name, "Sing-Box")
|
||||
if not s.fields["type"].default then
|
||||
s.fields["type"].default = type_name
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("custom"), translate("Use Custom Config"))
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
o:value("mixed", "Mixed")
|
||||
o:value("socks", "Socks")
|
||||
o:value("http", "HTTP")
|
||||
o:value("shadowsocks", "Shadowsocks")
|
||||
o:value("vmess", "Vmess")
|
||||
o:value("vless", "VLESS")
|
||||
o:value("trojan", "Trojan")
|
||||
o:value("naive", "Naive")
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("hysteria", "Hysteria")
|
||||
end
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("tuic", "TUIC")
|
||||
end
|
||||
if singbox_tags:find("with_quic") then
|
||||
o:value("hysteria2", "Hysteria2")
|
||||
end
|
||||
o:value("anytls", "AnyTLS")
|
||||
o:value("direct", "Direct")
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("auth"), translate("Auth"))
|
||||
o.validate = function(self, value, t)
|
||||
if value and value == "1" then
|
||||
local user_v = s.fields[_n("username")] and s.fields[_n("username")]:formvalue(t) or ""
|
||||
local pass_v = s.fields[_n("password")] and s.fields[_n("password")]:formvalue(t) or ""
|
||||
if user_v == "" or pass_v == "" then
|
||||
return nil, translate("Username and Password must be used together!")
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "mixed" })
|
||||
o:depends({ [_n("protocol")] = "socks" })
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
|
||||
o = s:option(Value, _n("username"), translate("Username"))
|
||||
o:depends({ [_n("auth")] = true })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
o:depends({ [_n("protocol")] = "anytls" })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("auth")] = true })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "anytls" })
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(Value, _n("hysteria_up_mbps"), translate("Max upload Mbps"))
|
||||
o.default = "100"
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_down_mbps"), translate("Max download Mbps"))
|
||||
o.default = "100"
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_obfs"), translate("Obfs Password"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(ListValue, _n("hysteria_auth_type"), translate("Auth Type"))
|
||||
o:value("disable", translate("Disable"))
|
||||
o:value("string", translate("STRING"))
|
||||
o:value("base64", translate("BASE64"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "hysteria", [_n("hysteria_auth_type")] = "string"})
|
||||
o:depends({ [_n("protocol")] = "hysteria", [_n("hysteria_auth_type")] = "base64"})
|
||||
|
||||
o = s:option(Value, _n("hysteria_recv_window_conn"), translate("QUIC stream receive window"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_recv_window_client"), translate("QUIC connection receive window"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Value, _n("hysteria_max_conn_client"), translate("QUIC concurrent bidirectional streams"))
|
||||
o.default = "1024"
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
o = s:option(Flag, _n("hysteria_disable_mtu_discovery"), translate("Disable MTU detection"))
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
end
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(ListValue, _n("tuic_congestion_control"), translate("Congestion control algorithm"))
|
||||
o.default = "cubic"
|
||||
o:value("bbr", translate("BBR"))
|
||||
o:value("cubic", translate("CUBIC"))
|
||||
o:value("new_reno", translate("New Reno"))
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(Flag, _n("tuic_zero_rtt_handshake"), translate("Enable 0-RTT QUIC handshake"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(Value, _n("tuic_heartbeat"), translate("Heartbeat interval(second)"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = "3"
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(ListValue, _n("tuic_alpn"), translate("QUIC TLS ALPN"))
|
||||
o.default = "default"
|
||||
o:value("default", translate("Default"))
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:value("spdy/3.1")
|
||||
o:value("h3,spdy/3.1")
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
end
|
||||
|
||||
if singbox_tags:find("with_quic") then
|
||||
o = s:option(Value, _n("hysteria2_auth_password"), translate("Auth Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("protocol")] = "hysteria2"})
|
||||
|
||||
o = s:option(Flag, _n("hysteria2_ignore_client_bandwidth"), translate("Client BBR Flow Control"), translate("Commands the client to use the BBR flow control algorithm"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_up_mbps"), translate("Max upload Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2", [_n("hysteria2_ignore_client_bandwidth")] = false })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_down_mbps"), translate("Max download Mbps"))
|
||||
o:depends({ [_n("protocol")] = "hysteria2", [_n("hysteria2_ignore_client_bandwidth")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("hysteria2_obfs_type"), translate("Obfs Type"))
|
||||
o:value("", translate("Disable"))
|
||||
o:value("salamander")
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(Value, _n("hysteria2_obfs_password"), translate("Obfs Password"))
|
||||
o:depends({ [_n("hysteria2_obfs_type")] = "salamander" })
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("d_protocol"), translate("Destination protocol"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("udp", "UDP")
|
||||
o:value("tcp,udp", "TCP,UDP")
|
||||
o:depends({ [_n("protocol")] = "direct" })
|
||||
|
||||
o = s:option(Value, _n("d_address"), translate("Destination address"))
|
||||
o:depends({ [_n("protocol")] = "direct" })
|
||||
|
||||
o = s:option(Value, _n("d_port"), translate("Destination port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("protocol")] = "direct" })
|
||||
|
||||
o = s:option(Value, _n("decryption"), translate("Encrypt Method"))
|
||||
o.default = "none"
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
|
||||
o = s:option(ListValue, _n("ss_method"), translate("Encrypt Method"))
|
||||
o.rewrite_option = "method"
|
||||
for a, t in ipairs(ss_method_list) do o:value(t) end
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
|
||||
o = s:option(DynamicList, _n("uuid"), translate("ID") .. "/" .. translate("Password"))
|
||||
for i = 1, 3 do
|
||||
o:value(api.gen_uuid(1))
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
|
||||
o = s:option(ListValue, _n("flow"), translate("flow"))
|
||||
o.default = ""
|
||||
o:value("", translate("Disable"))
|
||||
o:value("xtls-rprx-vision")
|
||||
o:depends({ [_n("protocol")] = "vless" , [_n("tls")] = true })
|
||||
|
||||
o = s:option(Flag, _n("tls"), translate("TLS"))
|
||||
o.default = 0
|
||||
o.validate = function(self, value, t)
|
||||
if value then
|
||||
local reality = s.fields[_n("reality")] and s.fields[_n("reality")]:formvalue(t) or nil
|
||||
if reality and reality == "1" then return value end
|
||||
if value == "1" then
|
||||
local ca = s.fields[_n("tls_certificateFile")] and s.fields[_n("tls_certificateFile")]:formvalue(t) or ""
|
||||
local key = s.fields[_n("tls_keyFile")] and s.fields[_n("tls_keyFile")]:formvalue(t) or ""
|
||||
if ca == "" or key == "" then
|
||||
return nil, translate("Public key and Private key path can not be empty!")
|
||||
end
|
||||
end
|
||||
return value
|
||||
end
|
||||
end
|
||||
o:depends({ [_n("protocol")] = "http" })
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
o:depends({ [_n("protocol")] = "anytls" })
|
||||
|
||||
-- https://github.com/SagerNet/sing-box/commit/d2a04c4e41e6cef0937331cb6d10211f431caaab
|
||||
if singbox_tags:find("with_utls") then
|
||||
-- [[ REALITY ]] --
|
||||
o = s:option(Flag, _n("reality"), translate("REALITY"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("protocol")] = "http", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "vmess", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "trojan", [_n("tls")] = true })
|
||||
o:depends({ [_n("protocol")] = "anytls", [_n("tls")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_private_key"), translate("Private Key"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_shortId"), translate("Short Id"))
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_handshake_server"), translate("Handshake Server"))
|
||||
o.default = "google.com"
|
||||
o:depends({ [_n("reality")] = true })
|
||||
|
||||
o = s:option(Value, _n("reality_handshake_server_port"), translate("Handshake Server Port"))
|
||||
o.datatype = "port"
|
||||
o.default = "443"
|
||||
o:depends({ [_n("reality")] = true })
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("alpn"), translate("ALPN"))
|
||||
o.default = "default"
|
||||
o:value("default", translate("Default"))
|
||||
o:value("h3")
|
||||
o:value("h2")
|
||||
o:value("h3,h2")
|
||||
o:value("http/1.1")
|
||||
o:value("h2,http/1.1")
|
||||
o:value("h3,h2,http/1.1")
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
|
||||
-- [[ TLS ]] --
|
||||
|
||||
o = s:option(FileUpload, _n("tls_certificateFile"), translate("Public key absolute path"), translate("as:") .. "/etc/ssl/fullchain.pem")
|
||||
o.default = m:get(s.section, "tls_certificateFile") or "/etc/config/ssl/" .. arg[1] .. ".pem"
|
||||
if o and o:formvalue(arg[1]) then o.default = o:formvalue(arg[1]) end
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" then
|
||||
if not nixio.fs.access(value) then
|
||||
return nil, translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
o = s:option(FileUpload, _n("tls_keyFile"), translate("Private key absolute path"), translate("as:") .. "/etc/ssl/private.key")
|
||||
o.default = m:get(s.section, "tls_keyFile") or "/etc/config/ssl/" .. arg[1] .. ".key"
|
||||
if o and o:formvalue(arg[1]) then o.default = o:formvalue(arg[1]) end
|
||||
o:depends({ [_n("tls")] = true, [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
o.validate = function(self, value, t)
|
||||
if value and value ~= "" then
|
||||
if not nixio.fs.access(value) then
|
||||
return nil, translate("Can't find this file!")
|
||||
else
|
||||
return value
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("ech"), translate("ECH"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("tls")] = true, [_n("flow")] = "", [_n("reality")] = false })
|
||||
o:depends({ [_n("protocol")] = "naive" })
|
||||
o:depends({ [_n("protocol")] = "hysteria" })
|
||||
o:depends({ [_n("protocol")] = "tuic" })
|
||||
o:depends({ [_n("protocol")] = "hysteria2" })
|
||||
|
||||
o = s:option(TextValue, _n("ech_key"), translate("ECH Key"))
|
||||
o.default = ""
|
||||
o.rows = 5
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("ech")] = true })
|
||||
o.validate = function(self, value)
|
||||
value = value:gsub("^%s+", ""):gsub("%s+$","\n"):gsub("\r\n","\n"):gsub("[ \t]*\n[ \t]*", "\n")
|
||||
value = value:gsub("^%s*\n", "")
|
||||
if value:sub(-1) == "\n" then
|
||||
value = value:sub(1, -2)
|
||||
end
|
||||
return value
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("transport"), translate("Transport"))
|
||||
o:value("tcp", "TCP")
|
||||
o:value("http", "HTTP")
|
||||
o:value("ws", "WebSocket")
|
||||
o:value("httpupgrade", "HTTPUpgrade")
|
||||
o:value("quic", "QUIC")
|
||||
o:value("grpc", "gRPC")
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
-- [[ HTTP ]]--
|
||||
|
||||
o = s:option(DynamicList, _n("http_host"), translate("HTTP Host"))
|
||||
o:depends({ [_n("transport")] = "http" })
|
||||
|
||||
o = s:option(Value, _n("http_path"), translate("HTTP Path"))
|
||||
o:depends({ [_n("transport")] = "http" })
|
||||
|
||||
-- [[ WebSocket ]]--
|
||||
|
||||
o = s:option(Value, _n("ws_host"), translate("WebSocket Host"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
o = s:option(Value, _n("ws_path"), translate("WebSocket Path"))
|
||||
o:depends({ [_n("transport")] = "ws" })
|
||||
|
||||
-- [[ HTTPUpgrade ]]--
|
||||
|
||||
o = s:option(Value, _n("httpupgrade_host"), translate("HTTPUpgrade Host"))
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
o = s:option(Value, _n("httpupgrade_path"), translate("HTTPUpgrade Path"))
|
||||
o:depends({ [_n("transport")] = "httpupgrade" })
|
||||
|
||||
-- [[ gRPC ]]--
|
||||
o = s:option(Value, _n("grpc_serviceName"), "ServiceName")
|
||||
o:depends({ [_n("transport")] = "grpc" })
|
||||
|
||||
-- [[ Mux ]]--
|
||||
o = s:option(Flag, _n("mux"), translate("Mux"))
|
||||
o.rmempty = false
|
||||
o:depends({ [_n("protocol")] = "vmess" })
|
||||
o:depends({ [_n("protocol")] = "vless", [_n("flow")] = "" })
|
||||
o:depends({ [_n("protocol")] = "shadowsocks" })
|
||||
o:depends({ [_n("protocol")] = "trojan" })
|
||||
|
||||
-- [[ TCP Brutal ]]--
|
||||
o = s:option(Flag, _n("tcpbrutal"), translate("TCP Brutal"))
|
||||
o.default = 0
|
||||
o:depends({ [_n("mux")] = true })
|
||||
|
||||
o = s:option(Value, _n("tcpbrutal_up_mbps"), translate("Max upload Mbps"))
|
||||
o.default = "10"
|
||||
o:depends({ [_n("tcpbrutal")] = true })
|
||||
|
||||
o = s:option(Value, _n("tcpbrutal_down_mbps"), translate("Max download Mbps"))
|
||||
o.default = "50"
|
||||
o:depends({ [_n("tcpbrutal")] = true })
|
||||
|
||||
o = s:option(Flag, _n("bind_local"), translate("Bind Local"), translate("When selected, it can only be accessed localhost."))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("accept_lan"), translate("Accept LAN Access"), translate("When selected, it can accessed lan , this will not be safe!"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
local nodes_table = {}
|
||||
for k, e in ipairs(api.get_valid_nodes()) do
|
||||
if e.node_type == "normal" and e.type == type_name then
|
||||
nodes_table[#nodes_table + 1] = {
|
||||
id = e[".name"],
|
||||
remarks = e["remark"],
|
||||
group = e["group"]
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
o = s:option(ListValue, _n("outbound_node"), translate("outbound node"))
|
||||
o:value("", translate("Close"))
|
||||
o:value("_socks", translate("Custom Socks"))
|
||||
o:value("_http", translate("Custom HTTP"))
|
||||
o:value("_iface", translate("Custom Interface"))
|
||||
o.template = api.appname .. "/cbi/nodes_listvalue"
|
||||
o.group = {"","","",""}
|
||||
for k, v in pairs(nodes_table) do
|
||||
o:value(v.id, v.remarks)
|
||||
o.group[#o.group+1] = (v.group and v.group ~= "") and v.group or translate("default")
|
||||
end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("outbound_node_address"), translate("Address (Support Domain Name)"))
|
||||
o:depends({ [_n("outbound_node")] = "_socks" })
|
||||
o:depends({ [_n("outbound_node")] = "_http" })
|
||||
|
||||
o = s:option(Value, _n("outbound_node_port"), translate("Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("outbound_node")] = "_socks" })
|
||||
o:depends({ [_n("outbound_node")] = "_http" })
|
||||
|
||||
o = s:option(Value, _n("outbound_node_username"), translate("Username"))
|
||||
o:depends({ [_n("outbound_node")] = "_socks" })
|
||||
o:depends({ [_n("outbound_node")] = "_http" })
|
||||
|
||||
o = s:option(Value, _n("outbound_node_password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("outbound_node")] = "_socks" })
|
||||
o:depends({ [_n("outbound_node")] = "_http" })
|
||||
|
||||
o = s:option(Value, _n("outbound_node_iface"), translate("Interface"))
|
||||
o.default = "eth1"
|
||||
o:depends({ [_n("outbound_node")] = "_iface" })
|
||||
|
||||
o = s:option(TextValue, _n("custom_config"), translate("Custom Config"))
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("custom")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local config_str = m:get(section, "config_str")
|
||||
if config_str then
|
||||
return api.base64Decode(config_str)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "config_str", api.base64Encode(value) or "")
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("log"), translate("Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(ListValue, _n("loglevel"), translate("Log Level"))
|
||||
o.default = "info"
|
||||
o:value("debug")
|
||||
o:value("info")
|
||||
o:value("warn")
|
||||
o:value("error")
|
||||
o:depends({ [_n("log")] = true })
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,75 @@
|
||||
local m, s = ...
|
||||
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
if not api.is_finded("ssserver") then
|
||||
return
|
||||
end
|
||||
|
||||
local type_name = "SS-Rust"
|
||||
|
||||
local option_prefix = "ssrust_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ssrust_encrypt_method_list = {
|
||||
"plain", "none",
|
||||
"aes-128-gcm", "aes-256-gcm", "chacha20-ietf-poly1305",
|
||||
"2022-blake3-aes-128-gcm", "2022-blake3-aes-256-gcm", "2022-blake3-chacha8-poly1305", "2022-blake3-chacha20-poly1305"
|
||||
}
|
||||
|
||||
-- [[ Shadowsocks Rust ]]
|
||||
|
||||
s.fields["type"]:value(type_name, translate("Shadowsocks Rust"))
|
||||
|
||||
o = s:option(Flag, _n("custom"), translate("Use Custom Config"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("method"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(ssrust_encrypt_method_list) do o:value(t) end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Connection Timeout"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 300
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(TextValue, _n("custom_config"), translate("Custom Config"))
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("custom")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local config_str = m:get(section, "config_str")
|
||||
if config_str then
|
||||
return api.base64Decode(config_str)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "config_str", api.base64Encode(value) or "")
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("log"), translate("Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,78 @@
|
||||
local m, s = ...
|
||||
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
if not api.is_finded("ss-server") then
|
||||
return
|
||||
end
|
||||
|
||||
local type_name = "SS"
|
||||
|
||||
local option_prefix = "ss_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ss_encrypt_method_list = {
|
||||
"rc4-md5", "aes-128-cfb", "aes-192-cfb", "aes-256-cfb", "aes-128-ctr",
|
||||
"aes-192-ctr", "aes-256-ctr", "bf-cfb", "camellia-128-cfb",
|
||||
"camellia-192-cfb", "camellia-256-cfb", "salsa20", "chacha20",
|
||||
"chacha20-ietf", -- aead
|
||||
"aes-128-gcm", "aes-192-gcm", "aes-256-gcm", "chacha20-ietf-poly1305",
|
||||
"xchacha20-ietf-poly1305"
|
||||
}
|
||||
|
||||
-- [[ Shadowsocks ]]
|
||||
|
||||
s.fields["type"]:value(type_name, translate("Shadowsocks"))
|
||||
|
||||
o = s:option(Flag, _n("custom"), translate("Use Custom Config"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("method"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(ss_encrypt_method_list) do o:value(t) end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Connection Timeout"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 300
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(TextValue, _n("custom_config"), translate("Custom Config"))
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("custom")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local config_str = m:get(section, "config_str")
|
||||
if config_str then
|
||||
return api.base64Decode(config_str)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "config_str", api.base64Encode(value) or "")
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("log"), translate("Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,106 @@
|
||||
local m, s = ...
|
||||
|
||||
local api = require "luci.passwall2.api"
|
||||
|
||||
if not api.is_finded("ssr-server") then
|
||||
return
|
||||
end
|
||||
|
||||
local type_name = "SSR"
|
||||
|
||||
local option_prefix = "ssr_"
|
||||
|
||||
local function _n(name)
|
||||
return option_prefix .. name
|
||||
end
|
||||
|
||||
local ssr_encrypt_method_list = {
|
||||
"none", "table", "rc2-cfb", "rc4", "rc4-md5", "rc4-md5-6", "aes-128-cfb",
|
||||
"aes-192-cfb", "aes-256-cfb", "aes-128-ctr", "aes-192-ctr", "aes-256-ctr",
|
||||
"bf-cfb", "camellia-128-cfb", "camellia-192-cfb", "camellia-256-cfb",
|
||||
"cast5-cfb", "des-cfb", "idea-cfb", "seed-cfb", "salsa20", "chacha20",
|
||||
"chacha20-ietf"
|
||||
}
|
||||
|
||||
local ssr_protocol_list = {
|
||||
"origin", "verify_simple", "verify_deflate", "verify_sha1", "auth_simple",
|
||||
"auth_sha1", "auth_sha1_v2", "auth_sha1_v4", "auth_aes128_md5",
|
||||
"auth_aes128_sha1", "auth_chain_a", "auth_chain_b", "auth_chain_c",
|
||||
"auth_chain_d", "auth_chain_e", "auth_chain_f"
|
||||
}
|
||||
local ssr_obfs_list = {
|
||||
"plain", "http_simple", "http_post", "random_head", "tls_simple",
|
||||
"tls1.0_session_auth", "tls1.2_ticket_auth"
|
||||
}
|
||||
|
||||
-- [[ ShadowsocksR ]]
|
||||
|
||||
s.fields["type"]:value(type_name, translate("ShadowsocksR"))
|
||||
|
||||
o = s:option(Flag, _n("custom"), translate("Use Custom Config"))
|
||||
|
||||
o = s:option(Value, _n("port"), translate("Listen Port"))
|
||||
o.datatype = "port"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("password"), translate("Password"))
|
||||
o.password = true
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("method"), translate("Encrypt Method"))
|
||||
for a, t in ipairs(ssr_encrypt_method_list) do o:value(t) end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("protocol"), translate("Protocol"))
|
||||
for a, t in ipairs(ssr_protocol_list) do o:value(t) end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("protocol_param"), translate("Protocol_param"))
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(ListValue, _n("obfs"), translate("Obfs"))
|
||||
for a, t in ipairs(ssr_obfs_list) do o:value(t) end
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("obfs_param"), translate("Obfs_param"))
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Value, _n("timeout"), translate("Connection Timeout"))
|
||||
o.datatype = "uinteger"
|
||||
o.default = 300
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(Flag, _n("tcp_fast_open"), "TCP " .. translate("Fast Open"))
|
||||
o.default = "0"
|
||||
o:depends({ [_n("custom")] = false })
|
||||
|
||||
o = s:option(TextValue, _n("custom_config"), translate("Custom Config"))
|
||||
o.rows = 10
|
||||
o.wrap = "off"
|
||||
o:depends({ [_n("custom")] = true })
|
||||
o.validate = function(self, value, t)
|
||||
if value and api.jsonc.parse(value) then
|
||||
return value
|
||||
else
|
||||
return nil, translate("Custom Config") .. " " .. translate("Must be JSON text!")
|
||||
end
|
||||
end
|
||||
o.custom_cfgvalue = function(self, section, value)
|
||||
local config_str = m:get(section, "config_str")
|
||||
if config_str then
|
||||
return api.base64Decode(config_str)
|
||||
end
|
||||
end
|
||||
o.custom_write = function(self, section, value)
|
||||
m:set(section, "config_str", api.base64Encode(value) or "")
|
||||
end
|
||||
|
||||
o = s:option(Flag, _n("udp_forward"), translate("UDP Forward"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Flag, _n("log"), translate("Log"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
api.luci_types(arg[1], m, s, type_name, option_prefix)
|
||||
@@ -0,0 +1,41 @@
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
local fs = api.fs
|
||||
local types_dir = "/usr/lib/lua/luci/model/cbi/" .. appname .. "/server/type/"
|
||||
|
||||
m = Map("passwall2_server", translate("Server Config"))
|
||||
m.redirect = api.url("server")
|
||||
api.set_apply_on_parse(m)
|
||||
|
||||
if not arg[1] or not m:get(arg[1]) then
|
||||
luci.http.redirect(m.redirect)
|
||||
end
|
||||
|
||||
m:append(Template(appname .. "/cbi/nodes_listvalue_com"))
|
||||
|
||||
s = m:section(NamedSection, arg[1], "user", "")
|
||||
s.addremove = false
|
||||
s.dynamic = false
|
||||
|
||||
o = s:option(Flag, "enable", translate("Enable"))
|
||||
o.default = "1"
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(Value, "remarks", translate("Remarks"))
|
||||
o.default = translate("Remarks")
|
||||
o.rmempty = false
|
||||
|
||||
o = s:option(ListValue, "type", translate("Type"))
|
||||
|
||||
local type_table = {}
|
||||
for filename in fs.dir(types_dir) do
|
||||
table.insert(type_table, filename)
|
||||
end
|
||||
table.sort(type_table)
|
||||
|
||||
for index, value in ipairs(type_table) do
|
||||
local p_func = loadfile(types_dir .. value)
|
||||
setfenv(p_func, getfenv(1))(m, s)
|
||||
end
|
||||
|
||||
return m
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,83 @@
|
||||
local _M = {}
|
||||
|
||||
local function gh_release_url(self)
|
||||
--return "https://api.github.com/repos/" .. self.repo .. "/releases/latest"
|
||||
return "https://github.com/Openwrt-Passwall/openwrt-passwall-packages/releases/download/api-cache/" .. string.lower(self.name) .. "-release-api.json"
|
||||
end
|
||||
|
||||
local function gh_pre_release_url(self)
|
||||
--return "https://api.github.com/repos/" .. self.repo .. "/releases?per_page=1"
|
||||
return "https://github.com/Openwrt-Passwall/openwrt-passwall-packages/releases/download/api-cache/" .. string.lower(self.name) .. "-pre-release-api.json"
|
||||
end
|
||||
|
||||
_M.hysteria = {
|
||||
name = "Hysteria",
|
||||
repo = "HyNetwork/hysteria",
|
||||
get_url = gh_release_url,
|
||||
cmd_version = "version | awk '/^Version:/ {print $2}'",
|
||||
remote_version_str_replace = "app/",
|
||||
zipped = false,
|
||||
default_path = "/usr/bin/hysteria",
|
||||
match_fmt_str = "linux%%-%s$",
|
||||
file_tree = {
|
||||
armv6 = "arm",
|
||||
armv7 = "arm"
|
||||
}
|
||||
}
|
||||
|
||||
_M["sing-box"] = {
|
||||
name = "Sing-Box",
|
||||
repo = "SagerNet/sing-box",
|
||||
get_url = gh_release_url,
|
||||
cmd_version = "version | awk '{print $3}' | sed -n 1P",
|
||||
zipped = true,
|
||||
zipped_suffix = "tar.gz",
|
||||
default_path = "/usr/bin/sing-box",
|
||||
match_fmt_str = "linux%%-%s",
|
||||
file_tree = {
|
||||
x86_64 = "amd64%-musl",
|
||||
x86 = "386%-musl",
|
||||
aarch64 = "arm64%-musl",
|
||||
rockchip = "arm64%-musl",
|
||||
mips = "mips%-softfloat",
|
||||
mips64 = "mips64%-softfloat",
|
||||
mipsel = "mipsle%-softfloat%-musl",
|
||||
mips64el = "mips64le%-softfloat",
|
||||
armv7 = "armv7%-musl",
|
||||
armv8 = "arm64%-musl",
|
||||
riscv64 = "riscv64%-musl"
|
||||
}
|
||||
}
|
||||
|
||||
_M.xray = {
|
||||
name = "Xray",
|
||||
repo = "XTLS/Xray-core",
|
||||
get_url = gh_pre_release_url,
|
||||
cmd_version = "version | awk '{print $2}' | sed -n 1P",
|
||||
zipped = true,
|
||||
default_path = "/usr/bin/xray",
|
||||
match_fmt_str = "linux%%-%s",
|
||||
file_tree = {
|
||||
x86_64 = "64",
|
||||
x86 = "32",
|
||||
mips = "mips32",
|
||||
mipsel = "mips32le",
|
||||
mips64el = "mips64le"
|
||||
}
|
||||
}
|
||||
|
||||
_M.geoview = {
|
||||
name = "Geoview",
|
||||
repo = "snowie2000/geoview",
|
||||
get_url = gh_release_url,
|
||||
cmd_version = '-version 2>/dev/null | awk \'NR==1 && $1=="Geoview" {print $2}\'',
|
||||
zipped = false,
|
||||
default_path = "/usr/bin/geoview",
|
||||
match_fmt_str = "linux%%-%s",
|
||||
file_tree = {
|
||||
mipsel = "mipsle",
|
||||
mips64el = "mips64le"
|
||||
}
|
||||
}
|
||||
|
||||
return _M
|
||||
@@ -0,0 +1,244 @@
|
||||
#!/usr/bin/lua
|
||||
|
||||
local action = arg[1]
|
||||
local api = require "luci.passwall2.api"
|
||||
local sys = api.sys
|
||||
local uci = api.uci
|
||||
local jsonc = api.jsonc
|
||||
|
||||
local CONFIG = "passwall2_server"
|
||||
local CONFIG_PATH = "/tmp/etc/" .. CONFIG
|
||||
local NFT_INCLUDE_FILE = CONFIG_PATH .. "/" .. CONFIG .. ".nft"
|
||||
local LOG_APP_FILE = "/tmp/log/" .. CONFIG .. ".log"
|
||||
local TMP_BIN_PATH = CONFIG_PATH .. "/bin"
|
||||
local require_dir = "luci.passwall2."
|
||||
|
||||
local ipt_bin = sys.exec("echo -n $(/usr/share/passwall2/iptables.sh get_ipt_bin)")
|
||||
local ip6t_bin = sys.exec("echo -n $(/usr/share/passwall2/iptables.sh get_ip6t_bin)")
|
||||
|
||||
local nft_flag = api.is_finded("fw4") and "1" or "0"
|
||||
|
||||
local function log(...)
|
||||
local f, err = io.open(LOG_APP_FILE, "a")
|
||||
if f and err == nil then
|
||||
local str = os.date("%Y-%m-%d %H:%M:%S: ") .. table.concat({...}, " ")
|
||||
f:write(str .. "\n")
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
|
||||
local function cmd(cmd)
|
||||
sys.call(cmd)
|
||||
end
|
||||
|
||||
local function ipt(arg)
|
||||
if ipt_bin and #ipt_bin > 0 then
|
||||
cmd(ipt_bin .. " -w " .. arg)
|
||||
end
|
||||
end
|
||||
|
||||
local function ip6t(arg)
|
||||
if ip6t_bin and #ip6t_bin > 0 then
|
||||
cmd(ip6t_bin .. " -w " .. arg)
|
||||
end
|
||||
end
|
||||
|
||||
local function ln_run(s, d, command, output)
|
||||
if not output then
|
||||
output = "/dev/null"
|
||||
end
|
||||
d = TMP_BIN_PATH .. "/" .. d
|
||||
cmd(string.format('[ ! -f "%s" ] && ln -s %s %s 2>/dev/null', d, s, d))
|
||||
return string.format("%s >%s 2>&1 &", d .. " " ..command, output)
|
||||
end
|
||||
|
||||
local function gen_include()
|
||||
cmd(string.format("echo '#!/bin/sh' > /tmp/etc/%s.include", CONFIG))
|
||||
local function extract_rules(n, a)
|
||||
local _ipt = ipt_bin
|
||||
if n == "6" then
|
||||
_ipt = ip6t_bin
|
||||
end
|
||||
local result = "*" .. a
|
||||
result = result .. "\n" .. sys.exec(_ipt .. '-save -t ' .. a .. ' | grep "PSW2-SERVER" | sed -e "s/^-A \\(INPUT\\)/-I \\1 1/"')
|
||||
result = result .. "COMMIT"
|
||||
return result
|
||||
end
|
||||
local f, err = io.open("/tmp/etc/" .. CONFIG .. ".include", "a")
|
||||
if f and err == nil then
|
||||
if nft_flag == "0" then
|
||||
f:write(ipt_bin .. '-save -c | grep -v "PSW2-SERVER" | ' .. ipt_bin .. '-restore -c' .. "\n")
|
||||
f:write(ipt_bin .. '-restore -n <<-EOT' .. "\n")
|
||||
f:write(extract_rules("4", "filter") .. "\n")
|
||||
f:write("EOT" .. "\n")
|
||||
f:write(ip6t_bin .. '-save -c | grep -v "PSW2-SERVER" | ' .. ip6t_bin .. '-restore -c' .. "\n")
|
||||
f:write(ip6t_bin .. '-restore -n <<-EOT' .. "\n")
|
||||
f:write(extract_rules("6", "filter") .. "\n")
|
||||
f:write("EOT" .. "\n")
|
||||
f:close()
|
||||
else
|
||||
f:write("nft -f " .. NFT_INCLUDE_FILE .. "\n")
|
||||
f:close()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function start()
|
||||
local enabled = tonumber(uci:get(CONFIG, "@global[0]", "enable") or 0)
|
||||
if enabled == nil or enabled == 0 then
|
||||
return
|
||||
end
|
||||
cmd(string.format("mkdir -p %s %s", CONFIG_PATH, TMP_BIN_PATH))
|
||||
cmd(string.format("touch %s", LOG_APP_FILE))
|
||||
if nft_flag == "0" then
|
||||
ipt("-N PSW2-SERVER")
|
||||
ipt("-I INPUT -j PSW2-SERVER")
|
||||
ip6t("-N PSW2-SERVER")
|
||||
ip6t("-I INPUT -j PSW2-SERVER")
|
||||
else
|
||||
nft_file, err = io.open(NFT_INCLUDE_FILE, "w")
|
||||
nft_file:write('#!/usr/sbin/nft -f\n')
|
||||
nft_file:write('add chain inet fw4 PSW2-SERVER\n')
|
||||
nft_file:write('flush chain inet fw4 PSW2-SERVER\n')
|
||||
nft_file:write('insert rule inet fw4 input position 0 jump PSW2-SERVER comment "PSW2-SERVER"\n')
|
||||
end
|
||||
uci:foreach(CONFIG, "user", function(user)
|
||||
local id = user[".name"]
|
||||
local enable = user.enable
|
||||
if enable and tonumber(enable) == 1 then
|
||||
local enable_log = user.log
|
||||
local log_path = nil
|
||||
if enable_log and enable_log == "1" then
|
||||
log_path = CONFIG_PATH .. "/" .. id .. ".log"
|
||||
else
|
||||
log_path = nil
|
||||
end
|
||||
local remarks = user.remarks
|
||||
local port = tonumber(user.port)
|
||||
local bin
|
||||
local config = {}
|
||||
local config_file = CONFIG_PATH .. "/" .. id .. ".json"
|
||||
local udp_forward = 1
|
||||
local type = user.type or ""
|
||||
if type == "SS" or type == "SSR" then
|
||||
if user.custom == "1" and user.config_str then
|
||||
config = jsonc.parse(api.base64Decode(user.config_str))
|
||||
else
|
||||
config = require(require_dir .. "util_shadowsocks").gen_config_server(user)
|
||||
end
|
||||
local udp_param = ""
|
||||
udp_forward = tonumber(user.udp_forward) or 1
|
||||
if udp_forward == 1 then
|
||||
udp_param = "-u"
|
||||
end
|
||||
type = type:lower()
|
||||
bin = ln_run("/usr/bin/" .. type .. "-server", type .. "-server", "-c " .. config_file .. " " .. udp_param, log_path)
|
||||
elseif type == "SS-Rust" then
|
||||
if user.custom == "1" and user.config_str then
|
||||
config = jsonc.parse(api.base64Decode(user.config_str))
|
||||
else
|
||||
config = require(require_dir .. "util_shadowsocks").gen_config_server(user)
|
||||
end
|
||||
bin = ln_run("/usr/bin/ssserver", "ssserver", "-c " .. config_file, log_path)
|
||||
elseif type == "Xray" then
|
||||
if user.custom == "1" and user.config_str then
|
||||
config = jsonc.parse(api.base64Decode(user.config_str))
|
||||
if log_path then
|
||||
if not config.log then
|
||||
config.log = {}
|
||||
end
|
||||
config.log.loglevel = user.loglevel
|
||||
end
|
||||
else
|
||||
config = require(require_dir .. "util_xray").gen_config_server(user)
|
||||
end
|
||||
bin = ln_run(api.get_app_path("xray"), "xray", "run -c " .. config_file, log_path)
|
||||
elseif type == "sing-box" then
|
||||
if user.custom == "1" and user.config_str then
|
||||
config = jsonc.parse(api.base64Decode(user.config_str))
|
||||
if log_path then
|
||||
if not config.log then
|
||||
config.log = {}
|
||||
end
|
||||
config.log.timestamp = true
|
||||
config.log.disabled = false
|
||||
config.log.level = user.loglevel
|
||||
config.log.output = log_path
|
||||
end
|
||||
else
|
||||
config = require(require_dir .. "util_sing-box").gen_config_server(user)
|
||||
end
|
||||
bin = ln_run(api.get_app_path("sing-box"), "sing-box", "run -c " .. config_file, log_path)
|
||||
elseif type == "Hysteria2" then
|
||||
if user.custom == "1" and user.config_str then
|
||||
config = jsonc.parse(api.base64Decode(user.config_str))
|
||||
else
|
||||
config = require(require_dir .. "util_hysteria2").gen_config_server(user)
|
||||
end
|
||||
bin = ln_run(api.get_app_path("hysteria"), "hysteria", "-c " .. config_file .. " server", log_path)
|
||||
end
|
||||
|
||||
if next(config) then
|
||||
local f, err = io.open(config_file, "w")
|
||||
if f and err == nil then
|
||||
f:write(jsonc.stringify(config, 1))
|
||||
f:close()
|
||||
end
|
||||
log(string.format("%s %s - %s", remarks, api.i18n.translate("Generate configuration file and run"), config_file))
|
||||
end
|
||||
|
||||
if bin then
|
||||
cmd(bin)
|
||||
end
|
||||
|
||||
local bind_local = user.bind_local or 0
|
||||
if bind_local and tonumber(bind_local) ~= 1 and port then
|
||||
if nft_flag == "0" then
|
||||
ipt(string.format('-A PSW2-SERVER -p tcp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
|
||||
ip6t(string.format('-A PSW2-SERVER -p tcp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
|
||||
if udp_forward == 1 then
|
||||
ipt(string.format('-A PSW2-SERVER -p udp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
|
||||
ip6t(string.format('-A PSW2-SERVER -p udp --dport %s -m comment --comment "%s" -j ACCEPT', port, remarks))
|
||||
end
|
||||
else
|
||||
nft_file:write(string.format('add rule inet fw4 PSW2-SERVER meta l4proto tcp tcp dport {%s} counter accept comment "%s"\n', port, remarks))
|
||||
if udp_forward == 1 then
|
||||
nft_file:write(string.format('add rule inet fw4 PSW2-SERVER meta l4proto udp udp dport {%s} counter accept comment "%s"\n', port, remarks))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
if nft_flag == "1" then
|
||||
nft_file:write("add rule inet fw4 PSW2-SERVER return\n")
|
||||
nft_file:close()
|
||||
cmd("nft -f " .. NFT_INCLUDE_FILE)
|
||||
end
|
||||
gen_include()
|
||||
end
|
||||
|
||||
local function stop()
|
||||
cmd(string.format("/bin/busybox top -bn1 | grep -v 'grep' | grep '%s/' | awk '{print $1}' | xargs kill -9 >/dev/null 2>&1", CONFIG_PATH))
|
||||
if nft_flag == "0" then
|
||||
ipt("-D INPUT -j PSW2-SERVER 2>/dev/null")
|
||||
ipt("-F PSW2-SERVER 2>/dev/null")
|
||||
ipt("-X PSW2-SERVER 2>/dev/null")
|
||||
ip6t("-D INPUT -j PSW2-SERVER 2>/dev/null")
|
||||
ip6t("-F PSW2-SERVER 2>/dev/null")
|
||||
ip6t("-X PSW2-SERVER 2>/dev/null")
|
||||
else
|
||||
local nft_cmd = "handles=$(nft -a list chain inet fw4 input | grep -E \"PSW2-SERVER\" | awk -F '# handle ' '{print$2}')\n for handle in $handles; do\n nft delete rule inet fw4 input handle ${handle} 2>/dev/null\n done"
|
||||
cmd(nft_cmd)
|
||||
cmd("nft flush chain inet fw4 PSW2-SERVER 2>/dev/null")
|
||||
cmd("nft delete chain inet fw4 PSW2-SERVER 2>/dev/null")
|
||||
end
|
||||
cmd(string.format("rm -rf %s %s /tmp/etc/%s.include", CONFIG_PATH, LOG_APP_FILE, CONFIG))
|
||||
end
|
||||
|
||||
if action then
|
||||
if action == "start" then
|
||||
start()
|
||||
elseif action == "stop" then
|
||||
stop()
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,144 @@
|
||||
module("luci.passwall2.util_hysteria2", package.seeall)
|
||||
local api = require "luci.passwall2.api"
|
||||
local uci = api.uci
|
||||
local jsonc = api.jsonc
|
||||
|
||||
function gen_config_server(node)
|
||||
local config = {
|
||||
listen = ":" .. node.port,
|
||||
tls = {
|
||||
cert = node.tls_certificateFile,
|
||||
key = node.tls_keyFile,
|
||||
},
|
||||
obfs = (node.hysteria2_obfs) and {
|
||||
type = "salamander",
|
||||
salamander = {
|
||||
password = node.hysteria2_obfs
|
||||
}
|
||||
} or nil,
|
||||
auth = {
|
||||
type = "password",
|
||||
password = node.hysteria2_auth_password
|
||||
},
|
||||
bandwidth = (node.hysteria2_up_mbps or node.hysteria2_down_mbps) and {
|
||||
up = node.hysteria2_up_mbps and node.hysteria2_up_mbps .. " mbps" or nil,
|
||||
down = node.hysteria2_down_mbps and node.hysteria2_down_mbps .. " mbps" or nil
|
||||
} or nil,
|
||||
ignoreClientBandwidth = (node.hysteria2_ignoreClientBandwidth == "1") and true or false,
|
||||
disableUDP = (node.hysteria2_udp == "0") and true or false,
|
||||
}
|
||||
return config
|
||||
end
|
||||
|
||||
function gen_config(var)
|
||||
local node_id = var["node"]
|
||||
if not node_id then
|
||||
print("node Cannot be empty!")
|
||||
return
|
||||
end
|
||||
local node = uci:get_all("passwall2", node_id)
|
||||
local local_socks_address = var["local_socks_address"] or "0.0.0.0"
|
||||
local local_socks_port = var["local_socks_port"]
|
||||
local local_socks_username = var["local_socks_username"]
|
||||
local local_socks_password = var["local_socks_password"]
|
||||
local local_http_address = var["local_http_address"] or "0.0.0.0"
|
||||
local local_http_port = var["local_http_port"]
|
||||
local local_http_username = var["local_http_username"]
|
||||
local local_http_password = var["local_http_password"]
|
||||
local server_host = var["server_host"] or (node.address or ""):lower()
|
||||
local server_port = var["server_port"] or node.port
|
||||
|
||||
if api.is_ipv6(server_host) then
|
||||
server_host = api.get_ipv6_full(server_host)
|
||||
end
|
||||
local server = server_host .. ":" .. server_port
|
||||
|
||||
if (node.hysteria2_hop) then
|
||||
server = server .. "," .. string.gsub(node.hysteria2_hop, ":", "-")
|
||||
end
|
||||
|
||||
local config = {
|
||||
server = server,
|
||||
transport = {
|
||||
type = "udp",
|
||||
udp = node.hysteria2_hop and (function()
|
||||
local udp = {}
|
||||
local t = node.hysteria2_hop_interval
|
||||
if not t then return nil end
|
||||
if t:find("-", 1, true) then
|
||||
local min, max = t:match("^(%d+)%-(%d+)$")
|
||||
min = tonumber(min)
|
||||
max = tonumber(max)
|
||||
if min and max then
|
||||
min = (min >= 5) and min or 5
|
||||
max = (max >= min) and max or min
|
||||
udp.minHopInterval = min .. "s"
|
||||
udp.maxHopInterval = max .. "s"
|
||||
return udp
|
||||
end
|
||||
end
|
||||
t = tonumber((t or "30"):match("^%d+"))
|
||||
t = (t and t >= 5) and t or 30
|
||||
udp.hopInterval = t .. "s"
|
||||
return udp
|
||||
end)() or nil
|
||||
},
|
||||
obfs = (node.hysteria2_obfs) and {
|
||||
type = "salamander",
|
||||
salamander = {
|
||||
password = node.hysteria2_obfs
|
||||
}
|
||||
} or nil,
|
||||
auth = node.hysteria2_auth_password,
|
||||
tls = {
|
||||
sni = node.tls_serverName,
|
||||
insecure = (node.tls_allowInsecure == "1") and true or false,
|
||||
pinSHA256 = (node.tls_pinSHA256) and node.tls_pinSHA256 or nil,
|
||||
},
|
||||
quic = {
|
||||
initStreamReceiveWindow = (node.hysteria2_recv_window) and tonumber(node.hysteria2_recv_window) or nil,
|
||||
initConnReceiveWindow = (node.hysteria2_recv_window_conn) and tonumber(node.hysteria2_recv_window_conn) or nil,
|
||||
maxIdleTimeout = (function(t)
|
||||
t = tonumber(tostring(t or "30"):match("^%d+"))
|
||||
return (t and t >= 4 and t <= 120) and t .. "s" or "30s"
|
||||
end)(node.hysteria2_idle_timeout),
|
||||
keepAlivePeriod = (function(t)
|
||||
t = tonumber(tostring(t or "0"):match("^%d+"))
|
||||
return (t and t >= 2 and t <= 60) and t .. "s" or nil
|
||||
end)(node.hysteria2_keep_alive_period),
|
||||
disablePathMTUDiscovery = (node.hysteria2_disable_mtu_discovery) and true or false,
|
||||
},
|
||||
bandwidth = (node.hysteria2_up_mbps or node.hysteria2_down_mbps) and {
|
||||
up = node.hysteria2_up_mbps and node.hysteria2_up_mbps .. " mbps" or nil,
|
||||
down = node.hysteria2_down_mbps and node.hysteria2_down_mbps .. " mbps" or nil
|
||||
} or nil,
|
||||
fast_open = (node.fast_open == "1") and true or false,
|
||||
lazy = (node.hysteria2_lazy_start == "1") and true or false,
|
||||
socks5 = (local_socks_address and local_socks_port) and {
|
||||
listen = local_socks_address .. ":" .. local_socks_port,
|
||||
username = (local_socks_username and local_socks_password) and local_socks_username or nil,
|
||||
password = (local_socks_username and local_socks_password) and local_socks_password or nil,
|
||||
disableUDP = false,
|
||||
} or nil,
|
||||
http = (local_http_address and local_http_port) and {
|
||||
listen = local_http_address .. ":" .. local_http_port,
|
||||
username = (local_http_username and local_http_password) and local_http_username or nil,
|
||||
password = (local_http_username and local_http_password) and local_http_password or nil,
|
||||
} or nil
|
||||
}
|
||||
|
||||
return jsonc.stringify(config, 1)
|
||||
end
|
||||
|
||||
_G.gen_config = gen_config
|
||||
|
||||
if arg[1] then
|
||||
local func =_G[arg[1]]
|
||||
if func then
|
||||
local var = nil
|
||||
if arg[2] then
|
||||
var = jsonc.parse(arg[2])
|
||||
end
|
||||
print(func(var))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
module("luci.passwall2.util_navieproxy", package.seeall)
|
||||
local api = require "luci.passwall2.api"
|
||||
local uci = api.uci
|
||||
local jsonc = api.jsonc
|
||||
|
||||
function gen_config(var)
|
||||
local node_id = var["node"]
|
||||
if not node_id then
|
||||
print("node Cannot be empty!")
|
||||
return
|
||||
end
|
||||
local node = uci:get_all("passwall2", node_id)
|
||||
local run_type = var["run_type"]
|
||||
local local_addr = var["local_addr"]
|
||||
local local_port = var["local_port"]
|
||||
local server_host = var["server_host"] or (node.address or ""):lower()
|
||||
local server_port = var["server_port"] or node.port
|
||||
|
||||
if api.is_ipv6(server_host) then
|
||||
server_host = api.get_ipv6_full(server_host)
|
||||
end
|
||||
local server = server_host .. ":" .. server_port
|
||||
|
||||
local config = {
|
||||
listen = run_type .. "://" .. local_addr .. ":" .. local_port,
|
||||
proxy = node.protocol .. "://" .. node.username .. ":" .. node.password .. "@" .. server
|
||||
}
|
||||
|
||||
return jsonc.stringify(config, 1)
|
||||
end
|
||||
|
||||
_G.gen_config = gen_config
|
||||
|
||||
if arg[1] then
|
||||
local func =_G[arg[1]]
|
||||
if func then
|
||||
local var = nil
|
||||
if arg[2] then
|
||||
var = jsonc.parse(arg[2])
|
||||
end
|
||||
print(func(var))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,143 @@
|
||||
module("luci.passwall2.util_shadowsocks", package.seeall)
|
||||
local api = require "luci.passwall2.api"
|
||||
local uci = api.uci
|
||||
local jsonc = api.jsonc
|
||||
|
||||
function gen_config_server(node)
|
||||
local config = {}
|
||||
config.server_port = tonumber(node.port)
|
||||
config.password = node.password
|
||||
config.timeout = tonumber(node.timeout)
|
||||
config.fast_open = (node.tcp_fast_open and node.tcp_fast_open == "1") and true or false
|
||||
config.method = node.method
|
||||
|
||||
if node.type == "SS-Rust" then
|
||||
config.server = "::"
|
||||
config.mode = "tcp_and_udp"
|
||||
else
|
||||
config.server = {"[::0]", "0.0.0.0"}
|
||||
end
|
||||
|
||||
if node.type == "SSR" then
|
||||
config.protocol = node.protocol
|
||||
config.protocol_param = node.protocol_param
|
||||
config.obfs = node.obfs
|
||||
config.obfs_param = node.obfs_param
|
||||
end
|
||||
|
||||
return config
|
||||
end
|
||||
|
||||
local plugin_sh, plugin_bin
|
||||
|
||||
function gen_config(var)
|
||||
local node_id = var["node"]
|
||||
if not node_id then
|
||||
print("node Cannot be empty!")
|
||||
return
|
||||
end
|
||||
local node = uci:get_all("passwall2", node_id)
|
||||
local server_host = var["server_host"] or (node.address or ""):lower()
|
||||
local server_port = var["server_port"] or node.port
|
||||
local local_addr = var["local_addr"]
|
||||
local local_port = var["local_port"]
|
||||
local mode = var["mode"]
|
||||
local local_socks_address = var["local_socks_address"] or "0.0.0.0"
|
||||
local local_socks_port = var["local_socks_port"]
|
||||
local local_socks_username = var["local_socks_username"]
|
||||
local local_socks_password = var["local_socks_password"]
|
||||
local local_http_address = var["local_http_address"] or "0.0.0.0"
|
||||
local local_http_port = var["local_http_port"]
|
||||
local local_http_username = var["local_http_username"]
|
||||
local local_http_password = var["local_http_password"]
|
||||
|
||||
if api.is_ipv6(server_host) then
|
||||
server_host = api.get_ipv6_only(server_host)
|
||||
end
|
||||
local server = server_host
|
||||
|
||||
local plugin_file
|
||||
if node.plugin and node.plugin ~= "" and node.plugin ~= "none" then
|
||||
plugin_sh = var["plugin_sh"] or ""
|
||||
plugin_file = (plugin_sh ~="") and plugin_sh or node.plugin
|
||||
plugin_bin = node.plugin
|
||||
end
|
||||
|
||||
local config = {
|
||||
server = server,
|
||||
server_port = tonumber(server_port),
|
||||
local_address = local_addr,
|
||||
local_port = tonumber(local_port),
|
||||
password = node.password,
|
||||
method = node.method,
|
||||
timeout = tonumber(node.timeout),
|
||||
fast_open = (node.tcp_fast_open and node.tcp_fast_open == "1") and true or false,
|
||||
reuse_port = true
|
||||
}
|
||||
|
||||
if node.type == "SS" then
|
||||
config.plugin = plugin_file or nil
|
||||
config.plugin_opts = (plugin_file) and node.plugin_opts or nil
|
||||
config.mode = mode
|
||||
elseif node.type == "SSR" then
|
||||
config.protocol = node.protocol
|
||||
config.protocol_param = node.protocol_param
|
||||
config.obfs = node.obfs
|
||||
config.obfs_param = node.obfs_param
|
||||
elseif node.type == "SS-Rust" then
|
||||
config = {
|
||||
servers = {
|
||||
{
|
||||
address = server,
|
||||
port = tonumber(server_port),
|
||||
method = node.method,
|
||||
password = node.password,
|
||||
timeout = tonumber(node.timeout),
|
||||
plugin = plugin_file or nil,
|
||||
plugin_opts = (plugin_file) and node.plugin_opts or nil
|
||||
}
|
||||
},
|
||||
locals = {},
|
||||
fast_open = (node.tcp_fast_open and node.tcp_fast_open == "1") and true or false
|
||||
}
|
||||
if local_socks_address and local_socks_port then
|
||||
table.insert(config.locals, {
|
||||
local_address = local_socks_address,
|
||||
local_port = tonumber(local_socks_port),
|
||||
mode = "tcp_and_udp"
|
||||
})
|
||||
end
|
||||
if local_http_address and local_http_port then
|
||||
table.insert(config.locals, {
|
||||
protocol = "http",
|
||||
local_address = local_http_address,
|
||||
local_port = tonumber(local_http_port)
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
return jsonc.stringify(config, 1)
|
||||
end
|
||||
|
||||
_G.gen_config = gen_config
|
||||
|
||||
if arg[1] then
|
||||
local func =_G[arg[1]]
|
||||
if func then
|
||||
local var = nil
|
||||
if arg[2] then
|
||||
var = jsonc.parse(arg[2])
|
||||
end
|
||||
print(func(var))
|
||||
if plugin_sh and plugin_sh ~="" and plugin_bin then
|
||||
local f = io.open(plugin_sh, "w")
|
||||
f:write("#!/bin/sh\n")
|
||||
f:write("export PATH=/usr/sbin:/usr/bin:/sbin:/bin:/root/bin:$PATH\n")
|
||||
f:write(plugin_bin .. " $@ &\n")
|
||||
f:write("echo $! > " .. plugin_sh:gsub("%.sh$", ".pid") .. "\n")
|
||||
f:write("wait\n")
|
||||
f:close()
|
||||
luci.sys.call("chmod +x " .. plugin_sh)
|
||||
end
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,68 @@
|
||||
module("luci.passwall2.util_tuic", package.seeall)
|
||||
local api = require "luci.passwall2.api"
|
||||
local uci = api.uci
|
||||
local jsonc = api.jsonc
|
||||
|
||||
function gen_config(var)
|
||||
local node_id = var["node"]
|
||||
if not node_id then
|
||||
print("node Cannot be empty!")
|
||||
return
|
||||
end
|
||||
local node = uci:get_all("passwall2", node_id)
|
||||
local local_addr = var["local_addr"]
|
||||
local local_port = var["local_port"]
|
||||
local server_host = var["server_host"] or (node.address or ""):lower()
|
||||
local server_port = var["server_port"] or node.port
|
||||
local loglevel = var["loglevel"] or "warn"
|
||||
|
||||
local tuic= {
|
||||
relay = {
|
||||
server = server_host .. ":" .. server_port,
|
||||
ip = node.tuic_ip,
|
||||
uuid = node.uuid,
|
||||
password = node.tuic_password,
|
||||
-- certificates = node.tuic_certificate and { node.tuic_certpath } or nil,
|
||||
udp_relay_mode = node.tuic_udp_relay_mode,
|
||||
congestion_control = node.tuic_congestion_control,
|
||||
heartbeat = (tonumber(node.tuic_heartbeat) or 3) .. "s",
|
||||
timeout = (tonumber(node.tuic_timeout) or 8) .. "s",
|
||||
gc_interval = (tonumber(node.tuic_gc_interval) or 3) .. "s",
|
||||
gc_lifetime = (tonumber(node.tuic_gc_lifetime) or 15) .. "s",
|
||||
alpn = (node.tuic_tls_alpn and node.tuic_tls_alpn ~= "") and (function()
|
||||
local alpn = {}
|
||||
string.gsub(node.tuic_tls_alpn, '[^,]+', function(w)
|
||||
table.insert(alpn, w)
|
||||
end)
|
||||
if #alpn > 0 then return alpn end
|
||||
return nil
|
||||
end)() or nil,
|
||||
disable_sni = (node.tuic_disable_sni == "1"),
|
||||
zero_rtt_handshake = (node.tuic_zero_rtt_handshake == "1"),
|
||||
send_window = tonumber(node.tuic_send_window),
|
||||
receive_window = tonumber(node.tuic_receive_window)
|
||||
},
|
||||
["local"] = {
|
||||
server = "[::]:" .. local_port,
|
||||
username = node.tuic_socks_username,
|
||||
password = node.tuic_socks_password,
|
||||
dual_stack = (node.tuic_dual_stack == "1") and true or false,
|
||||
max_packet_size = tonumber(node.tuic_max_package_size)
|
||||
},
|
||||
log_level = loglevel
|
||||
}
|
||||
return jsonc.stringify(tuic, 1)
|
||||
end
|
||||
|
||||
_G.gen_config = gen_config
|
||||
|
||||
if arg[1] then
|
||||
local func =_G[arg[1]]
|
||||
if func then
|
||||
local var = nil
|
||||
if arg[2] then
|
||||
var = jsonc.parse(arg[2])
|
||||
end
|
||||
print(func(var))
|
||||
end
|
||||
end
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,212 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
local com = require "luci.passwall2.com"
|
||||
local version = {}
|
||||
-%>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
var appInfoList = new Array();
|
||||
var inProgressCount = 0;
|
||||
var tokenStr = '<%=token%>';
|
||||
var checkUpdateText = '<%:Check update%>';
|
||||
var forceUpdateText = '<%:Force update%>';
|
||||
var retryText = '<%:Retry%>';
|
||||
var noUpdateText = '<%:It is the latest version%>';
|
||||
var updateSuccessText = '<%:Update successful%>';
|
||||
var clickToUpdateText = '<%:Click to update%>';
|
||||
var inProgressText = '<%:Updating...%>';
|
||||
var unexpectedErrorText = '<%:Unexpected error%>';
|
||||
var updateInProgressNotice = '<%:Updating, are you sure to close?%>';
|
||||
var downloadingText = '<%:Downloading...%>';
|
||||
var decompressioningText = '<%:Unpacking...%>';
|
||||
var movingText = '<%:Moving...%>';
|
||||
|
||||
//window.onload = function () {};
|
||||
|
||||
function addPageNotice() {
|
||||
if (inProgressCount === 0) {
|
||||
window.onbeforeunload = function (e) {
|
||||
e.returnValue = updateInProgressNotice;
|
||||
return updateInProgressNotice;
|
||||
};
|
||||
}
|
||||
inProgressCount++;
|
||||
}
|
||||
|
||||
function removePageNotice() {
|
||||
inProgressCount--;
|
||||
if (inProgressCount === 0) {
|
||||
window.onbeforeunload = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function onUpdateSuccess(btn) {
|
||||
if (btn) {
|
||||
btn.value = updateSuccessText;
|
||||
btn.placeholder = updateSuccessText;
|
||||
btn.disabled = true;
|
||||
}
|
||||
|
||||
if (inProgressCount === 0) {
|
||||
window.setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function onRequestError(btn, errorMessage) {
|
||||
btn.disabled = false;
|
||||
btn.value = retryText;
|
||||
|
||||
var ckeckDetailElm = document.getElementById(btn.id + '-detail');
|
||||
if (errorMessage && ckeckDetailElm) {
|
||||
ckeckDetailElm.textContent = errorMessage
|
||||
}
|
||||
}
|
||||
|
||||
function onBtnClick(btn, app) {
|
||||
if (appInfoList[app] === undefined) {
|
||||
checkUpdate(btn, app);
|
||||
} else {
|
||||
doUpdate(btn, app);
|
||||
}
|
||||
}
|
||||
|
||||
function checkUpdate(btn, app) {
|
||||
btn.disabled = true;
|
||||
btn.value = inProgressText;
|
||||
|
||||
addPageNotice();
|
||||
|
||||
var ckeckDetailElm = document.getElementById(btn.id + '-detail');
|
||||
if (ckeckDetailElm) {
|
||||
ckeckDetailElm.textContent = "";
|
||||
}
|
||||
XHR.get('<%=api.url("check_")%>' + app, {
|
||||
token: tokenStr,
|
||||
arch: ''
|
||||
}, function (x, json) {
|
||||
removePageNotice();
|
||||
if (json.code) {
|
||||
appInfoList[app] = undefined;
|
||||
onRequestError(btn, json.error);
|
||||
} else {
|
||||
appInfoList[app] = json;
|
||||
if (json.has_update) {
|
||||
btn.disabled = false;
|
||||
btn.value = clickToUpdateText;
|
||||
btn.placeholder = clickToUpdateText;
|
||||
|
||||
if (ckeckDetailElm) {
|
||||
var urlNode = '';
|
||||
if (json.remote_version) {
|
||||
urlNode = '<em style="color:red;">' + json.remote_version + '</em>';
|
||||
if (json.html_url) {
|
||||
urlNode = '<a href="' + json.html_url + '" target="_blank">' + urlNode + '</a>';
|
||||
}
|
||||
}
|
||||
ckeckDetailElm.innerHTML = urlNode;
|
||||
}
|
||||
} else {
|
||||
btn.disabled = true;
|
||||
btn.value = noUpdateText;
|
||||
var forceBtn = document.getElementById('_' + app + '-force_btn');
|
||||
if (forceBtn) {
|
||||
forceBtn.style.display = "inline";
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function doUpdate(btn, app) {
|
||||
btn.disabled = true;
|
||||
btn.value = downloadingText;
|
||||
|
||||
addPageNotice();
|
||||
|
||||
var appUpdateUrl = '<%=api.url("update_")%>' + app;
|
||||
var appInfo = appInfoList[app];
|
||||
// Download file
|
||||
XHR.get(appUpdateUrl, {
|
||||
token: tokenStr,
|
||||
url: appInfo ? appInfo.data.browser_download_url : '',
|
||||
size: appInfo ? appInfo.data.size / 1024 : null
|
||||
}, function (x, json) {
|
||||
if (json.code) {
|
||||
removePageNotice();
|
||||
onRequestError(btn, json.error);
|
||||
} else if (json.zip) {
|
||||
btn.value = decompressioningText;
|
||||
|
||||
// Extract file
|
||||
XHR.get(appUpdateUrl, {
|
||||
token: tokenStr,
|
||||
task: 'extract',
|
||||
file: json.file,
|
||||
subfix: appInfo ? appInfo.type : ''
|
||||
}, function (x, json) {
|
||||
if (json.code) {
|
||||
removePageNotice();
|
||||
onRequestError(btn, json.error);
|
||||
} else {
|
||||
move(btn, appUpdateUrl, json.file);
|
||||
}
|
||||
}, 300)
|
||||
} else {
|
||||
move(btn, appUpdateUrl, json.file);
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function move(btn, url, file) {
|
||||
btn.value = movingText;
|
||||
|
||||
// Move file to target dir
|
||||
XHR.get(url, {
|
||||
token: tokenStr,
|
||||
task: 'move',
|
||||
file: file
|
||||
}, function (x, json) {
|
||||
removePageNotice();
|
||||
if (json.code) {
|
||||
onRequestError(btn, json.error);
|
||||
} else {
|
||||
onUpdateSuccess(btn);
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title">Passwall2 <%:Version%></label>
|
||||
<div class="cbi-value-field">
|
||||
<div class="cbi-value-description">
|
||||
<span>【 <%=api.get_version()%> 】</span>
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="passwall2-check_btn"
|
||||
onclick="onBtnClick(this,'passwall2');" value="<%:Check update%>" />
|
||||
<span id="passwall2-check_btn-detail"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%for k, v in pairs(com) do
|
||||
version[k] = api.get_app_version(k)%>
|
||||
<div class="cbi-value">
|
||||
<label class="cbi-value-title"><%=v.name%>
|
||||
<%:Version%>
|
||||
</label>
|
||||
<div class="cbi-value-field">
|
||||
<div class="cbi-value-description">
|
||||
<span>【 <%=version[k] ~="" and version[k] or translate("Null") %> 】</span>
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-check_btn"
|
||||
onclick="onBtnClick(this,'<%=k%>');" value="<%:Check update%>" />
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="_<%=k%>-force_btn"
|
||||
onclick="doUpdate(this,'<%=k%>');" value="<%:Force update%>" style="display:none"/>
|
||||
<span id="_<%=k%>-check_btn-detail"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<%end%>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div id="cbi-<%=self.config.."-"..section.."-"..self.option%>" data-index="<%=self.index%>" data-depends="<%=pcdata(self:deplist2json(section))%>" style="display: none !important">
|
||||
<input type="hidden" id="<%=cbid%>" name="<%=cbid%>" value="<%=pcdata(self:cfgvalue(section) or self.default or "")%>" />
|
||||
</div>
|
||||
@@ -0,0 +1,149 @@
|
||||
<%+cbi/valueheader%>
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- - snowie2000
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
|
||||
local json = require "luci.jsonc"
|
||||
|
||||
-- 读取值(keylist/vallist/group)
|
||||
local values = {}
|
||||
for i, key in pairs(self.keylist) do
|
||||
values[#values + 1] = {
|
||||
key = key,
|
||||
label = self.vallist[i] or key,
|
||||
group = self.group and self.group[i] or nil
|
||||
}
|
||||
end
|
||||
|
||||
-- 获取当前配置值
|
||||
local current_key = {}
|
||||
local cval = self:cfgvalue(section)
|
||||
if type(cval) == "table" then
|
||||
for _, v in ipairs(cval) do
|
||||
for item in string.gmatch(v, "[^,]+") do
|
||||
current_key[#current_key+1] = item
|
||||
end
|
||||
end
|
||||
elseif type(cval) == "string" then
|
||||
for item in string.gmatch(cval, "[^,]+") do
|
||||
current_key[#current_key+1] = item
|
||||
end
|
||||
end
|
||||
|
||||
-- 分离无组节点(ungrouped)与有组节点(grouped),并保持原有顺序
|
||||
local ungrouped = {}
|
||||
local groups = {}
|
||||
local group_order = {}
|
||||
for _, item in ipairs(values) do
|
||||
if not item.group or item.group == "" then
|
||||
table.insert(ungrouped, item)
|
||||
else
|
||||
local g = item.group
|
||||
if not groups[g] then
|
||||
groups[g] = {}
|
||||
table.insert(group_order, g)
|
||||
end
|
||||
table.insert(groups[g], item)
|
||||
end
|
||||
end
|
||||
|
||||
-- 打包数据给 JS
|
||||
local dropdown_data = {
|
||||
current_key = current_key,
|
||||
ungrouped = ungrouped,
|
||||
groups = groups,
|
||||
group_order = group_order,
|
||||
cbid = cbid,
|
||||
}
|
||||
%>
|
||||
<script>
|
||||
window.dl_dropdown_data = window.dl_dropdown_data || {};
|
||||
window.dl_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
|
||||
</script>
|
||||
|
||||
<div id="<%=cbid%>.main" class="dl-dropdown-container">
|
||||
<select id="<%=cbid%>.ref" class="cbi-input-select" style="display:none !important;">
|
||||
<option value>placeholder</option>
|
||||
</select>
|
||||
|
||||
<!-- 已选节点显示区 -->
|
||||
<div id="<%=cbid%>.selected" class="dl-selected-list"></div>
|
||||
|
||||
<!-- 模拟 listvalue 控件外观 -->
|
||||
<div class="cbi-input-value cbi-input-select dl-dropdown-display" id="<%=cbid%>.display" tabindex="0">
|
||||
<span id="<%=cbid%>.label" class="dl-dropdown-label">
|
||||
<%=translate("-- Please choose --")%>
|
||||
</span>
|
||||
<span class="dl-arrow-down"></span>
|
||||
</div>
|
||||
<!-- 下拉面板 -->
|
||||
<div id="<%=cbid%>.panel" class="cbi-dynamiclist-panel dl-dropdown-panel" style="display:none;">
|
||||
<!-- 搜索框 -->
|
||||
<div style="padding:8px;border-bottom:1px solid #f0f0f0;">
|
||||
<input id="<%=cbid%>.search" class="cbi-input-text dl-dropdown-search" type="text" placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
|
||||
</div>
|
||||
<!-- 列表容器 -->
|
||||
<div id="<%=cbid%>.list" style="padding:8px;">
|
||||
<!-- 首次点击 display 时由 JS 填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
const cbid = "<%=cbid%>";
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const labelSpan = document.getElementById(cbid + ".label");
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const listContainer = document.getElementById(cbid + ".list");
|
||||
|
||||
function init(){
|
||||
const hidden = document.getElementById(cbid + ".hidden");
|
||||
const data = window.dl_dropdown_data[cbid];
|
||||
let arr = data.current_key;
|
||||
if (!arr || arr.length === 0) return;
|
||||
arr.forEach(key=>{
|
||||
let label = "";
|
||||
data.ungrouped.concat(...Object.values(data.groups)).forEach(v=>{
|
||||
if (v.key == key) label = v.label;
|
||||
});
|
||||
if (label)
|
||||
dl_add_item(cbid, key, label);
|
||||
});
|
||||
}
|
||||
|
||||
// 点击 display
|
||||
display.addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
dl_render_dropdown_list(cbid,panel,listContainer,labelSpan,searchInput,display);
|
||||
document.querySelectorAll(".cbi-dynamiclist-panel").forEach(p=>{
|
||||
if (p !== panel) p.style.display = "none";
|
||||
});
|
||||
if (panel.style.display !== "none") {
|
||||
dl_closePanel(cbid,panel,listContainer,searchInput);
|
||||
} else {
|
||||
dl_openPanel(cbid,display,panel,listContainer,searchInput);
|
||||
}
|
||||
});
|
||||
|
||||
dl_registerAdaptive(cbid);
|
||||
|
||||
init();
|
||||
|
||||
new Sortable(document.getElementById(cbid + ".selected"), {
|
||||
animation: 150,
|
||||
ghostClass: "dragging-row",
|
||||
onEnd: function(){
|
||||
dl_update_hidden(cbid);
|
||||
}
|
||||
});
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -0,0 +1,654 @@
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- - snowie2000
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
-- It is the common part of the template and cannot be used independently
|
||||
%>
|
||||
<script src="<%=resource%>/view/passwall2/Sortable.min.js"></script>
|
||||
<style>
|
||||
/* 主下拉按钮的下箭头 */
|
||||
.dl-arrow-down {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #666;
|
||||
margin-left: 6px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的右箭头(折叠) */
|
||||
.dl-arrow-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的下箭头(展开) */
|
||||
.dl-arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 基础列表项样式 */
|
||||
.cbi-dynamiclist-panel li[data-key] {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
/* 鼠标悬停效果 - 使用透明度避免覆盖问题 */
|
||||
.cbi-dynamiclist-panel li[data-key]:hover {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
|
||||
.dl-dropdown-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
min-width: 220px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1152px) {
|
||||
.dl-dropdown-container {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.dl-dropdown-container {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.dl-dropdown-display {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.dl-dropdown-label,
|
||||
.dl-selected-label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.dl-dropdown-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2147483647;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.dl-dropdown-search {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.dl-group-title {
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.dl-group-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.dl-group-item {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left !important;
|
||||
}
|
||||
.dl-selected-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.dl-remove {
|
||||
margin-left: 8px;
|
||||
cursor: pointer;
|
||||
color: red;
|
||||
}
|
||||
.dragging-row {
|
||||
background-color: rgba(131, 191, 255, 0.7) !important;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
// css helper functions
|
||||
function dl_camelToKebab(str) {
|
||||
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function dl_style2Css(styleDeclaration, properties) {
|
||||
const cssRules = properties.map(prop => {
|
||||
const kebabCaseProp = dl_camelToKebab(prop);[1, 5]
|
||||
const value = styleDeclaration[prop]
|
||||
if (value) {
|
||||
return `${kebabCaseProp}: ${value};`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
// Filter out any empty strings and join the rules
|
||||
return cssRules.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const dl_parseColorToRgba = (function() {
|
||||
// Create canvas and context once (Closure)
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
return function(colorStr) {
|
||||
if (!colorStr)
|
||||
return null;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
// 2. Apply the color
|
||||
ctx.fillStyle = colorStr;
|
||||
// 3. Fill a single pixel
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
// 4. Extract pixel data [R, G, B, A]
|
||||
const data = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return {
|
||||
r: data[0],
|
||||
g: data[1],
|
||||
b: data[2],
|
||||
// Convert alpha from 0-255 to 0-1 (rounded to 3 decimal places)
|
||||
a: Math.round((data[3] / 255) * 1000) / 1000
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
// Helper to convert back to Hex (for output consistency)
|
||||
function dl_rgbToHex(r, g, b) {
|
||||
const toHex = (n) => {
|
||||
const hex = Math.max(0, Math.min(255, n)).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
}
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
function dl_isTransparent(color) {
|
||||
const cleanColor = dl_parseColorToRgba(color);
|
||||
// check #RRGGBBAA for transparency
|
||||
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
|
||||
}
|
||||
|
||||
function dl_getColorSchema(color) {
|
||||
const rgb = dl_parseColorToRgba(color);
|
||||
if (!rgb) return 'unknown'; // Handle invalid colors
|
||||
// Calculate YIQ brightness (human eye perception)
|
||||
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
|
||||
|
||||
return brightness > 128 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function dl_lighter(color, amount) {
|
||||
const rgb = dl_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Add amount to each channel
|
||||
const r = rgb.r + amount;
|
||||
const g = rgb.g + amount;
|
||||
const b = rgb.b + amount;
|
||||
// Convert back to Hex (clamping happens inside rgbToHex)
|
||||
return dl_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
function dl_darker(color, amount) {
|
||||
const rgb = dl_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Subtract amount from each channel
|
||||
const r = rgb.r - amount;
|
||||
const g = rgb.g - amount;
|
||||
const b = rgb.b - amount;
|
||||
|
||||
return dl_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// copy select styles
|
||||
function dl_adaptiveStyle(cbid) {
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const hiddenRef = document.getElementById(cbid + ".ref");
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
if (hiddenRef && display) {
|
||||
const elOption = hiddenRef.getElementsByTagName("option")[0]
|
||||
const styleSelect = window.getComputedStyle(hiddenRef)
|
||||
const styleOption = window.getComputedStyle(elOption)
|
||||
const styleBody = window.getComputedStyle(document.body)
|
||||
|
||||
const styleNode = document.createElement('style')
|
||||
const styleNames = ["width", "color", "height", "padding", "margin", "lineHeight", "border", "borderRadius", "minWidth", "minHeight"]
|
||||
if (styleSelect.borderBottomStyle !== "none") {
|
||||
styleNames.push("borderBottomWidth", "borderBottomStyle", "borderBottomColor");
|
||||
}
|
||||
document.head.appendChild(styleNode)
|
||||
// trace back from option -> select -> body for background color
|
||||
const panelRadius = styleSelect.borderRadius;
|
||||
const optionColor = !dl_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !dl_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
|
||||
const titleColor = dl_getColorSchema(optionColor) === "light" ? dl_darker(optionColor, 30) : dl_lighter(optionColor, 30)
|
||||
const selectStyleCSS = [`#${CSS.escape(cbid + ".display")}, .dl-selected-item {`, dl_style2Css(styleSelect, styleNames), dl_style2Css(styleSelect, ["backgroundColor"]), "}"]
|
||||
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, dl_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
|
||||
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .dl-group-title {`, `background-color: ${titleColor} !important;`, "}"]
|
||||
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function dl_idSafe(id) {
|
||||
return id
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
}
|
||||
|
||||
// 更新组内选中计数
|
||||
function dl_updateGroupCounts(cbid, listContainer, searchInput) {
|
||||
const groups = listContainer.querySelectorAll(".dl-group");
|
||||
const isSearching = searchInput.value.trim() !== "";
|
||||
groups.forEach(group => {
|
||||
const gname = group.getAttribute("data-group");
|
||||
const items = group.querySelectorAll("li[data-key]");
|
||||
const span = document.getElementById("group-count-" + cbid + "-" + gname);
|
||||
if (!span) return;
|
||||
if (isSearching) {
|
||||
// 搜索状态:显示匹配数量
|
||||
let matchCount = 0;
|
||||
items.forEach(li => {
|
||||
if (li.style.display !== "none") matchCount++;
|
||||
});
|
||||
span.textContent = "(" + matchCount + "/" + items.length + ")";
|
||||
if (matchCount > 0) {
|
||||
span.style.color = "#28a745";
|
||||
span.style.fontWeight = "600";
|
||||
} else {
|
||||
span.style.color = "#dc3545";
|
||||
span.style.fontWeight = "normal";
|
||||
}
|
||||
} else {
|
||||
// 默认状态
|
||||
span.textContent = "(" + items.length + ")";
|
||||
span.style.color = "";
|
||||
span.style.fontWeight = "normal";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//搜索过滤器:按 name 或 label 做模糊匹配,搜索时自动展开所有组并隐藏不匹配条目
|
||||
function dl_filterList(keyword, cbid, listContainer, searchInput) {
|
||||
keyword = (keyword || "").toLowerCase().trim();
|
||||
const topItems = listContainer.querySelectorAll("ul li[data-key]");
|
||||
topItems.forEach(li=>{
|
||||
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "block";
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
const groups = listContainer.querySelectorAll(".dl-group");
|
||||
groups.forEach(group=>{
|
||||
const items = group.querySelectorAll("li[data-key]");
|
||||
let matchCount = 0;
|
||||
items.forEach(li=>{
|
||||
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "block";
|
||||
matchCount++;
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
group.style.display = (matchCount === 0 && keyword !== "") ? "none" : "block";
|
||||
const ul = group.querySelector(".dl-group-list");
|
||||
const gname = group.getAttribute("data-group");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (keyword) {
|
||||
if (ul) ul.style.display = (matchCount > 0 ? "block" : "none");
|
||||
if (arrow) arrow.className = (matchCount > 0 ? "dl-arrow-down-small" : "dl-arrow-right");
|
||||
} else {
|
||||
if (ul) ul.style.display = "none";
|
||||
if (arrow) arrow.className = "dl-arrow-right";
|
||||
}
|
||||
});
|
||||
dl_updateGroupCounts(cbid, listContainer, searchInput);
|
||||
}
|
||||
|
||||
// 切换单个组(点击组标题)
|
||||
function dl_toggleGroup(listContainer, cbid, g) {
|
||||
g = dl_idSafe(g);
|
||||
const group = listContainer.querySelector(".dl-group[data-group='" + g + "']");
|
||||
if (!group) return;
|
||||
const ul = group.querySelector(".dl-group-list");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + g);
|
||||
if (!ul) return;
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const isSearching = searchInput?.value.trim() !== "";
|
||||
const isExpanded = ul.style.display !== "none";
|
||||
|
||||
if (isExpanded) {
|
||||
ul.style.display = "none";
|
||||
if (arrow) arrow.className = "dl-arrow-right";
|
||||
} else {
|
||||
ul.style.display = "block";
|
||||
if (arrow) arrow.className = "dl-arrow-down-small";
|
||||
|
||||
if (!isSearching) {
|
||||
const allGroups = listContainer.querySelectorAll(".dl-group");
|
||||
allGroups.forEach(otherGroup => {
|
||||
if (otherGroup !== group) {
|
||||
const otherUl = otherGroup.querySelector(".dl-group-list");
|
||||
const otherGname = otherGroup.getAttribute("data-group");
|
||||
const otherArrow = document.getElementById("arrow-" + cbid + "-" + otherGname);
|
||||
if (otherUl) otherUl.style.display = "none";
|
||||
if (otherArrow) otherArrow.className = "dl-arrow-right";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 计算panel位置
|
||||
function dl_repositionPanel(panel, display) {
|
||||
if (!panel || panel.style.display === "none") return;
|
||||
const rect = display.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
panel.style.visibility = "hidden";
|
||||
panel.style.display = "block";
|
||||
panel.style.minHeight = "100px";
|
||||
panel.style.maxHeight = Math.min(0.5*viewportHeight, 550) + "px";
|
||||
const panelHeight = panel.offsetHeight;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
let top, isUp = false;
|
||||
if (spaceBelow >= panelHeight) {
|
||||
top = rect.bottom + 2;
|
||||
isUp = false;
|
||||
} else if (spaceAbove >= panelHeight) {
|
||||
top = rect.top - panelHeight - 2;
|
||||
isUp = true;
|
||||
} else {
|
||||
if (spaceBelow >= spaceAbove) {
|
||||
top = Math.max(rect.bottom - 2, viewportHeight - panelHeight - 2);
|
||||
isUp = false;
|
||||
} else {
|
||||
top = Math.min(rect.top - panelHeight + 2, 2);
|
||||
isUp = true;
|
||||
}
|
||||
}
|
||||
panel.style.left = rect.left + "px";
|
||||
panel.style.top = top + "px";
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const displayWidth = rect.width;
|
||||
const remainingWidth = window.innerWidth - panelRect.left - 12;
|
||||
const maxWidth = Math.max(displayWidth, Math.floor(remainingWidth));
|
||||
panel.style.maxWidth = maxWidth + "px";
|
||||
panel.style.minWidth = displayWidth + "px";
|
||||
panel.style.width = "auto";
|
||||
panel.style.visibility = "";
|
||||
}
|
||||
|
||||
// 打开/关闭面板
|
||||
function dl_openPanel(cbid, display, panel, listContainer, searchInput) {
|
||||
if (!panel._moved) {
|
||||
document.body.appendChild(panel);
|
||||
panel._moved = true;
|
||||
}
|
||||
panel.style.display = "block";
|
||||
dl_repositionPanel(panel, display);
|
||||
// 失焦监听
|
||||
const handler = function(e){
|
||||
const target = e.target;
|
||||
if (panel.style.display !== "none") {
|
||||
if (!panel.contains(target) && !display.contains(target)) {
|
||||
dl_closePanel(cbid, panel, listContainer, searchInput, display);
|
||||
}
|
||||
}
|
||||
}
|
||||
panel._docClickHandler = handler;
|
||||
document.addEventListener("click", handler);
|
||||
// 滚动 / resize 自动 reposition
|
||||
let ticking = false;
|
||||
const repositionHandler = function () {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(function () {
|
||||
ticking = false;
|
||||
dl_repositionPanel(panel, display);
|
||||
});
|
||||
};
|
||||
panel._repositionHandler = repositionHandler;
|
||||
window.addEventListener("scroll", repositionHandler, true);
|
||||
window.addEventListener("resize", repositionHandler);
|
||||
}
|
||||
function dl_closePanel(cbid, panel, listContainer, searchInput) {
|
||||
panel.style.display = "none";
|
||||
searchInput.value = "";
|
||||
dl_filterList("", cbid, listContainer, searchInput);
|
||||
// document click
|
||||
if (panel._docClickHandler) {
|
||||
document.removeEventListener("click", panel._docClickHandler);
|
||||
panel._docClickHandler = null;
|
||||
}
|
||||
// scroll / resize
|
||||
if (panel._repositionHandler) {
|
||||
window.removeEventListener("scroll", panel._repositionHandler, true);
|
||||
window.removeEventListener("resize", panel._repositionHandler);
|
||||
panel._repositionHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
function dl_add_item(cbid, key, label) {
|
||||
const container = document.getElementById(cbid + ".selected");
|
||||
const hidden = document.getElementById(cbid + ".hidden");
|
||||
if (container.querySelector(`[data-key="${key}"]`)) return;
|
||||
const item = document.createElement("div");
|
||||
item.className = "cbi-input-value cbi-input-select dl-dropdown-display dl-selected-item";
|
||||
item.setAttribute("data-key", key);
|
||||
item.innerHTML = `
|
||||
<span class="dl-selected-label" style="cursor:grab;" title="${label}">${label}</span>
|
||||
<span class="dl-remove">×</span>
|
||||
`;
|
||||
item.querySelector(".dl-remove").onclick = function(){
|
||||
item.remove();
|
||||
dl_update_hidden(cbid);
|
||||
};
|
||||
container.appendChild(item);
|
||||
dl_update_hidden(cbid);
|
||||
}
|
||||
|
||||
function dl_update_hidden(cbid) {
|
||||
const main = document.getElementById(cbid + ".main");
|
||||
const container = document.getElementById(cbid + ".selected");
|
||||
main.querySelectorAll(`input[type="hidden"][name="${cbid}"]`).forEach(el => el.remove());
|
||||
let items = container.querySelectorAll(".dl-selected-item");
|
||||
items.forEach(el => {
|
||||
let key = el.getAttribute("data-key");
|
||||
let input = document.createElement("input");
|
||||
input.type = "hidden";
|
||||
input.name = cbid;
|
||||
input.value = key;
|
||||
main.insertBefore(input, container);
|
||||
});
|
||||
}
|
||||
|
||||
// 动态生成下拉框
|
||||
window.dl_dropdown_rendered = {};
|
||||
function dl_escape_html(s) {
|
||||
return s.replace(/[&<>"']/g, c => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function dl_render_dropdown_list(cbid, panel, listContainer, labelSpan, searchInput, display) {
|
||||
if (window.dl_dropdown_rendered[cbid]) return;
|
||||
const data = window.dl_dropdown_data[cbid];
|
||||
if (!data) return;
|
||||
if (!listContainer) return;
|
||||
|
||||
let html = "";
|
||||
|
||||
// 无组项
|
||||
if (data.ungrouped && data.ungrouped.length > 0) {
|
||||
html += `<ul style="list-style:none;padding:0;margin:0 0 8px 0;">`;
|
||||
|
||||
data.ungrouped.forEach(item => {
|
||||
html += `
|
||||
<li data-key="${item.key}"
|
||||
data-node-name="${dl_escape_html(item.label.toLowerCase())}"
|
||||
class="list-item">
|
||||
<span class="dl-item-label" style="margin-left:12px;">
|
||||
${dl_escape_html(item.label)}
|
||||
</span>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `</ul>`;
|
||||
}
|
||||
|
||||
// 分组项
|
||||
data.group_order.forEach(gname => {
|
||||
const items = data.groups[gname];
|
||||
|
||||
html += `
|
||||
<div class="dl-group" data-group="${dl_idSafe(gname)}" style="margin-bottom:8px;">
|
||||
<div class="dl-group-title" data-group-name="${dl_idSafe(gname)}">
|
||||
<span class="dl-arrow-right" id="arrow-${cbid}-${dl_idSafe(gname)}"></span>
|
||||
<b style="margin-left:6px;">${dl_escape_html(gname)}</b>
|
||||
<span id="group-count-${cbid}-${dl_idSafe(gname)}"
|
||||
style="margin-left:8px;">(${items.length})</span>
|
||||
</div>
|
||||
<ul id="group-${cbid}-${dl_idSafe(gname)}" class="dl-group-list" style="display:none">
|
||||
`;
|
||||
|
||||
items.forEach(item => {
|
||||
html += `
|
||||
<li data-key="${item.key}"
|
||||
data-node-name="${dl_escape_html(item.label.toLowerCase())}"
|
||||
class="dl-group-item">
|
||||
<span class="dl-item-label" title="${dl_escape_html(item.label)}">
|
||||
${dl_escape_html(item.label)}
|
||||
</span>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
|
||||
window.dl_dropdown_rendered[cbid] = true;
|
||||
|
||||
dl_adaptiveStyle(cbid);
|
||||
|
||||
// 点击项(无组与组内项都使用 li[data-key])
|
||||
listContainer.addEventListener("click", function(e){
|
||||
let li = e.target;
|
||||
while(li && li !== listContainer && !li.hasAttribute('data-key')) li = li.parentNode;
|
||||
if(!li || li === listContainer) return;
|
||||
const key = li.getAttribute('data-key') || "";
|
||||
const text = li.querySelector(".dl-item-label")?.textContent || li.textContent || key;
|
||||
//改值
|
||||
dl_add_item(cbid, key, text);
|
||||
labelSpan.innerText = "<%=translate("-- Please choose --")%>";
|
||||
dl_closePanel(cbid,panel,listContainer,searchInput);
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
searchInput.addEventListener("input", function() {
|
||||
dl_filterList(this.value, cbid, listContainer, searchInput);
|
||||
dl_repositionPanel(panel, display);
|
||||
});
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
searchInput.blur();
|
||||
});
|
||||
|
||||
// 切换组
|
||||
listContainer.querySelectorAll(".dl-group-title").forEach(title => {
|
||||
title.addEventListener("click", function() {
|
||||
const g = this.closest(".dl-group")?.getAttribute("data-group");
|
||||
if (g) {
|
||||
dl_toggleGroup(listContainer, cbid, g);
|
||||
dl_repositionPanel(panel, display);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 防止 panel 惯性滚动穿透
|
||||
panel.addEventListener('wheel', function (e) {
|
||||
const deltaY = e.deltaY;
|
||||
const scrollTop = panel.scrollTop;
|
||||
const scrollHeight = panel.scrollHeight;
|
||||
const clientHeight = panel.clientHeight;
|
||||
const isAtTop = scrollTop === 0;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
|
||||
if (deltaY < 0 && isAtTop) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (deltaY > 0 && isAtBottom) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
const dl_adaptiveControls = new Set();
|
||||
function dl_registerAdaptive(cbid) {
|
||||
dl_adaptiveControls.add(cbid);
|
||||
dl_adaptiveStyle(cbid);
|
||||
}
|
||||
let dl_adaptiveTicking = false;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!dl_adaptiveTicking) {
|
||||
dl_adaptiveTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
dl_adaptiveControls.forEach(cbid => {
|
||||
dl_adaptiveStyle(cbid);
|
||||
});
|
||||
dl_adaptiveTicking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,148 @@
|
||||
<%+cbi/valueheader%>
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- - snowie2000
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
|
||||
local json = require "luci.jsonc"
|
||||
|
||||
-- 读取值(keylist/vallist/group)
|
||||
local values = {}
|
||||
for i, key in pairs(self.keylist) do
|
||||
values[#values + 1] = {
|
||||
key = key,
|
||||
label = self.vallist[i] or key,
|
||||
group = self.group and self.group[i] or nil
|
||||
}
|
||||
end
|
||||
|
||||
-- 获取当前配置值(单值)
|
||||
local current_key = nil
|
||||
local cval = self:cfgvalue(section)
|
||||
if type(cval) == "table" then
|
||||
-- 若意外为table,取第一个
|
||||
for k,_ in pairs(cval) do
|
||||
current_key = k
|
||||
break
|
||||
end
|
||||
elseif type(cval) == "string" then
|
||||
current_key = (cval:match("%S+")) -- 取第一个 token
|
||||
end
|
||||
|
||||
-- 分离无组节点(ungrouped)与有组节点(grouped),并保持原有顺序
|
||||
local ungrouped = {}
|
||||
local groups = {}
|
||||
local group_order = {}
|
||||
for _, item in ipairs(values) do
|
||||
if not item.group or item.group == "" then
|
||||
table.insert(ungrouped, item)
|
||||
else
|
||||
local g = item.group
|
||||
if not groups[g] then
|
||||
groups[g] = {}
|
||||
table.insert(group_order, g)
|
||||
end
|
||||
table.insert(groups[g], item)
|
||||
end
|
||||
end
|
||||
|
||||
-- 如果没有配置值,默认取第一个(优先无组第一项,否则第一组第一项)
|
||||
if not current_key then
|
||||
if #ungrouped > 0 then
|
||||
current_key = ungrouped[1].key
|
||||
else
|
||||
-- 找到 group_order 第一个有节点的项
|
||||
for _, g in ipairs(group_order) do
|
||||
if groups[g] and #groups[g] > 0 then
|
||||
current_key = groups[g][1].key
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 获取当前 label(用于主控件显示)
|
||||
local function find_label_by_key(k)
|
||||
if not k then return "" end
|
||||
for _, v in ipairs(values) do
|
||||
if v.key == k then return v.label end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local current_label = find_label_by_key(current_key) or ""
|
||||
|
||||
-- 打包数据给 JS
|
||||
local dropdown_data = {
|
||||
current_key = current_key,
|
||||
current_label = current_label,
|
||||
ungrouped = ungrouped,
|
||||
groups = groups,
|
||||
group_order = group_order,
|
||||
cbid = cbid,
|
||||
}
|
||||
%>
|
||||
<script>
|
||||
window.lv_dropdown_data = window.lv_dropdown_data || {};
|
||||
window.lv_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
|
||||
</script>
|
||||
|
||||
<div id="<%=cbid%>.main" class="lv-dropdown-container">
|
||||
<!-- 隐藏 select(保存实际配置值) -->
|
||||
<select id="<%=cbid%>" name="<%=cbid%>" class="cbi-input-select" data-update="change" style="display:none !important;">
|
||||
<option value="<%=current_key%>" selected="selected">placeholder</option>
|
||||
</select>
|
||||
<!-- 模拟 ListValue 控件外观 -->
|
||||
<div class="cbi-input-value cbi-input-select lv-dropdown-display" id="<%=cbid%>.display" tabindex="0">
|
||||
<span id="<%=cbid%>.label" class="lv-dropdown-label" title="<%=pcdata(current_label)%>">
|
||||
<%=pcdata("("..translate("Not set")..")")%>
|
||||
</span>
|
||||
<span class="lv-arrow-down"></span>
|
||||
</div>
|
||||
<!-- 下拉面板 -->
|
||||
<div id="<%=cbid%>.panel" class="cbi-listvalue-panel lv-dropdown-panel" style="display:none;">
|
||||
<!-- 搜索框 -->
|
||||
<div style="padding:8px;border-bottom:1px solid #f0f0f0;">
|
||||
<input id="<%=cbid%>.search" class="cbi-input-text lv-dropdown-search" type="text" placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
|
||||
</div>
|
||||
<!-- 列表容器 -->
|
||||
<div id="<%=cbid%>.list" style="padding:8px;">
|
||||
<!-- 首次点击 display 时由 JS 填充 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
const cbid = "<%=cbid%>";
|
||||
const hiddenSelect = document.getElementById(cbid);
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const labelSpan = document.getElementById(cbid + ".label");
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const listContainer = document.getElementById(cbid + ".list");
|
||||
|
||||
// 点击 display
|
||||
display.addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
lv_render_dropdown_list(cbid,panel,listContainer,hiddenSelect,labelSpan,searchInput,display);
|
||||
document.querySelectorAll(".cbi-listvalue-panel").forEach(p=>{
|
||||
if (p !== panel) p.style.display = "none";
|
||||
});
|
||||
if (panel.style.display !== "none") {
|
||||
lv_closePanel(cbid,panel,listContainer,hiddenSelect,searchInput);
|
||||
} else {
|
||||
lv_openPanel(cbid,display,panel,listContainer,hiddenSelect,searchInput);
|
||||
}
|
||||
});
|
||||
lv_registerAdaptive(cbid);
|
||||
setTimeout(() => {
|
||||
labelSpan.textContent = lv_ellipsisByWidth(cbid, '<%=pcdata(current_label ~= "" and current_label or ("("..translate("Not set")..")"))%>');
|
||||
}, 100);
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -0,0 +1,758 @@
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- - snowie2000
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
-- It is the common part of the template and cannot be used independently
|
||||
%>
|
||||
<style>
|
||||
/* 主下拉按钮的下箭头 */
|
||||
.lv-arrow-down {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #666;
|
||||
margin-left: 6px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的右箭头(折叠) */
|
||||
.lv-arrow-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的下箭头(展开) */
|
||||
.lv-arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 基础列表项样式 */
|
||||
.cbi-listvalue-panel li[data-key] {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
/* 鼠标悬停效果 - 使用透明度避免覆盖问题 */
|
||||
.cbi-listvalue-panel li[data-key]:hover {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
/* 选中项样式 - 使用更高优先级 */
|
||||
.cbi-listvalue-panel li[data-key].is-selected {
|
||||
background-color: #007bff !important;
|
||||
color: white !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
/* 选中项悬停时保持主色调 */
|
||||
.cbi-listvalue-panel li[data-key].is-selected:hover {
|
||||
background-color: #0056b3 !important;
|
||||
}
|
||||
|
||||
.lv-dropdown-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
min-width: 220px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1152px) {
|
||||
.lv-dropdown-container {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.lv-dropdown-container {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.lv-dropdown-display {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.lv-dropdown-label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.lv-dropdown-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2147483647;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.lv-dropdown-search {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.lv-group-title {
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.lv-group-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.lv-group-item {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
// css helper functions
|
||||
function lv_camelToKebab(str) {
|
||||
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function lv_style2Css(styleDeclaration, properties) {
|
||||
const cssRules = properties.map(prop => {
|
||||
const kebabCaseProp = lv_camelToKebab(prop);[1, 5]
|
||||
const value = styleDeclaration[prop]
|
||||
if (value) {
|
||||
return `${kebabCaseProp}: ${value};`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
// Filter out any empty strings and join the rules
|
||||
return cssRules.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const lv_parseColorToRgba = (function() {
|
||||
// Create canvas and context once (Closure)
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
return function(colorStr) {
|
||||
if (!colorStr)
|
||||
return null;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
// 2. Apply the color
|
||||
ctx.fillStyle = colorStr;
|
||||
// 3. Fill a single pixel
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
// 4. Extract pixel data [R, G, B, A]
|
||||
const data = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return {
|
||||
r: data[0],
|
||||
g: data[1],
|
||||
b: data[2],
|
||||
// Convert alpha from 0-255 to 0-1 (rounded to 3 decimal places)
|
||||
a: Math.round((data[3] / 255) * 1000) / 1000
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
// Helper to convert back to Hex (for output consistency)
|
||||
function lv_rgbToHex(r, g, b) {
|
||||
const toHex = (n) => {
|
||||
const hex = Math.max(0, Math.min(255, n)).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
}
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
function lv_isTransparent(color) {
|
||||
const cleanColor = lv_parseColorToRgba(color);
|
||||
// check #RRGGBBAA for transparency
|
||||
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
|
||||
}
|
||||
|
||||
function lv_getColorSchema(color) {
|
||||
const rgb = lv_parseColorToRgba(color);
|
||||
if (!rgb) return 'unknown'; // Handle invalid colors
|
||||
// Calculate YIQ brightness (human eye perception)
|
||||
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
|
||||
|
||||
return brightness > 128 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function lv_lighter(color, amount) {
|
||||
const rgb = lv_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Add amount to each channel
|
||||
const r = rgb.r + amount;
|
||||
const g = rgb.g + amount;
|
||||
const b = rgb.b + amount;
|
||||
// Convert back to Hex (clamping happens inside rgbToHex)
|
||||
return lv_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
function lv_darker(color, amount) {
|
||||
const rgb = lv_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Subtract amount from each channel
|
||||
const r = rgb.r - amount;
|
||||
const g = rgb.g - amount;
|
||||
const b = rgb.b - amount;
|
||||
|
||||
return lv_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// copy select styles
|
||||
function lv_adaptiveStyle(cbid) {
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const hiddenSelect = document.getElementById(cbid);
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
if (hiddenSelect && display) {
|
||||
const elOption = hiddenSelect.getElementsByTagName("option")[0]
|
||||
const styleSelect = window.getComputedStyle(hiddenSelect)
|
||||
const styleOption = window.getComputedStyle(elOption)
|
||||
const styleBody = window.getComputedStyle(document.body)
|
||||
|
||||
const styleNode = document.createElement('style')
|
||||
const styleNames = ["width", "color", "height", "padding", "margin", "lineHeight", "border", "borderRadius", "minWidth", "minHeight"]
|
||||
if (styleSelect.borderBottomStyle !== "none") {
|
||||
styleNames.push("borderBottomWidth", "borderBottomStyle", "borderBottomColor");
|
||||
}
|
||||
document.head.appendChild(styleNode)
|
||||
// trace back from option -> select -> body for background color
|
||||
const panelRadius = styleSelect.borderRadius;
|
||||
const optionColor = !lv_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !lv_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
|
||||
const titleColor = lv_getColorSchema(optionColor) === "light" ? lv_darker(optionColor, 30) : lv_lighter(optionColor, 30)
|
||||
const selectStyleCSS = [`#${CSS.escape(cbid + ".display")} {`, lv_style2Css(styleSelect, styleNames), lv_style2Css(styleSelect, ["backgroundColor"]), "}"]
|
||||
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, lv_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
|
||||
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .lv-group-title {`, `background-color: ${titleColor} !important;`, "}"]
|
||||
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function lv_idSafe(id) {
|
||||
return id
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
}
|
||||
|
||||
// 高亮当前选中的项
|
||||
function lv_highlightSelectedItem(listContainer, hiddenSelect) {
|
||||
const allItems = listContainer.querySelectorAll("li[data-key]");
|
||||
const currentKey = hiddenSelect.options[0].value;
|
||||
allItems.forEach(item => {
|
||||
item.classList.remove("is-selected");
|
||||
if (item.getAttribute('data-key') === currentKey) {
|
||||
item.classList.add("is-selected");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新组内选中计数
|
||||
function lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput) {
|
||||
const groups = listContainer.querySelectorAll(".lv-group");
|
||||
const currentKey = hiddenSelect.options[0].value;
|
||||
const isSearching = searchInput.value.trim() !== "";
|
||||
groups.forEach(group => {
|
||||
const gname = group.getAttribute("data-group");
|
||||
const items = group.querySelectorAll("li[data-key]");
|
||||
const span = document.getElementById("group-count-" + cbid + "-" + gname);
|
||||
if (!span) return;
|
||||
if (isSearching) {
|
||||
// 搜索状态:显示匹配数量
|
||||
let matchCount = 0;
|
||||
items.forEach(li => {
|
||||
if (li.style.display !== "none") matchCount++;
|
||||
});
|
||||
span.textContent = "(" + matchCount + "/" + items.length + ")";
|
||||
if (matchCount > 0) {
|
||||
span.style.color = "#28a745";
|
||||
span.style.fontWeight = "600";
|
||||
} else {
|
||||
span.style.color = "#dc3545";
|
||||
span.style.fontWeight = "normal";
|
||||
}
|
||||
} else {
|
||||
// 默认状态:显示选中项数量
|
||||
let selectedCount = 0;
|
||||
items.forEach(li => {
|
||||
if (li.getAttribute('data-key') === currentKey) {
|
||||
selectedCount = 1;
|
||||
}
|
||||
});
|
||||
span.textContent = "(" + selectedCount + "/" + items.length + ")";
|
||||
if (selectedCount > 0) {
|
||||
span.style.color = "#007bff";
|
||||
span.style.fontWeight = "600";
|
||||
} else {
|
||||
span.style.color = "";
|
||||
span.style.fontWeight = "normal";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//搜索过滤器:按 name 或 label 做模糊匹配,搜索时自动展开所有组并隐藏不匹配条目
|
||||
function lv_filterList(keyword, cbid, listContainer, hiddenSelect, searchInput) {
|
||||
keyword = (keyword || "").toLowerCase().trim();
|
||||
const topItems = listContainer.querySelectorAll("ul li[data-key]");
|
||||
topItems.forEach(li=>{
|
||||
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "block";
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
const groups = listContainer.querySelectorAll(".lv-group");
|
||||
groups.forEach(group=>{
|
||||
const items = group.querySelectorAll("li[data-key]");
|
||||
let matchCount = 0;
|
||||
items.forEach(li=>{
|
||||
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "block";
|
||||
matchCount++;
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
group.style.display = (matchCount === 0 && keyword !== "") ? "none" : "block";
|
||||
const ul = group.querySelector(".lv-group-list");
|
||||
const gname = group.getAttribute("data-group");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (keyword) {
|
||||
if (ul) ul.style.display = (matchCount > 0 ? "block" : "none");
|
||||
if (arrow) arrow.className = (matchCount > 0 ? "lv-arrow-down-small" : "lv-arrow-right");
|
||||
} else {
|
||||
if (ul) ul.style.display = "none";
|
||||
if (arrow) arrow.className = "lv-arrow-right";
|
||||
}
|
||||
});
|
||||
lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput);
|
||||
lv_highlightSelectedItem(listContainer, hiddenSelect);
|
||||
}
|
||||
|
||||
// 切换单个组(点击组标题)
|
||||
function lv_toggleGroup(listContainer, cbid, g) {
|
||||
g = lv_idSafe(g);
|
||||
const group = listContainer.querySelector(".lv-group[data-group='" + g + "']");
|
||||
if (!group) return;
|
||||
const ul = group.querySelector(".lv-group-list");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + g);
|
||||
if (!ul) return;
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const isSearching = searchInput?.value.trim() !== "";
|
||||
const isExpanded = ul.style.display !== "none";
|
||||
|
||||
if (isExpanded) {
|
||||
ul.style.display = "none";
|
||||
if (arrow) arrow.className = "lv-arrow-right";
|
||||
} else {
|
||||
ul.style.display = "block";
|
||||
if (arrow) arrow.className = "lv-arrow-down-small";
|
||||
|
||||
if (!isSearching) {
|
||||
const allGroups = listContainer.querySelectorAll(".lv-group");
|
||||
allGroups.forEach(otherGroup => {
|
||||
if (otherGroup !== group) {
|
||||
const otherUl = otherGroup.querySelector(".lv-group-list");
|
||||
const otherGname = otherGroup.getAttribute("data-group");
|
||||
const otherArrow = document.getElementById("arrow-" + cbid + "-" + otherGname);
|
||||
if (otherUl) otherUl.style.display = "none";
|
||||
if (otherArrow) otherArrow.className = "lv-arrow-right";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 展开包含当前 hiddenSelect 值的组(初始化或打开面板时使用)
|
||||
function lv_expandGroupOfCurrent(cbid, listContainer, hiddenSelect) {
|
||||
const key = hiddenSelect.options[0].value;
|
||||
if (!key) return;
|
||||
const targetLi = listContainer.querySelector("li[data-key='" + key.replace(/'/g,"\\'") + "']");
|
||||
if (!targetLi) return;
|
||||
let parentGroup = targetLi.closest(".lv-group");
|
||||
if (parentGroup) {
|
||||
const ul = parentGroup.querySelector(".lv-group-list");
|
||||
const gname = parentGroup.getAttribute("data-group");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (ul) ul.style.display = "block";
|
||||
if (arrow) arrow.className = "lv-arrow-down-small";
|
||||
const allGroups = listContainer.querySelectorAll(".lv-group");
|
||||
allGroups.forEach(gp=>{
|
||||
if (gp !== parentGroup) {
|
||||
const gul = gp.querySelector(".lv-group-list");
|
||||
const otherGname = gp.getAttribute("data-group");
|
||||
const gar = document.getElementById("arrow-" + cbid + "-" + otherGname);
|
||||
if (gul) gul.style.display = "none";
|
||||
if (gar) gar.className = "lv-arrow-right";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const allGroups = listContainer.querySelectorAll(".lv-group");
|
||||
allGroups.forEach(gp=>{
|
||||
const gul = gp.querySelector(".lv-group-list");
|
||||
const gname = gp.getAttribute("data-group");
|
||||
const gar = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (gul) gul.style.display = "none";
|
||||
if (gar) gar.className = "lv-arrow-right";
|
||||
});
|
||||
}
|
||||
if (targetLi) {
|
||||
requestAnimationFrame(() => {
|
||||
targetLi.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算panel位置
|
||||
function lv_repositionPanel(panel, display) {
|
||||
if (!panel || panel.style.display === "none") return;
|
||||
const rect = display.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
panel.style.visibility = "hidden";
|
||||
panel.style.display = "block";
|
||||
panel.style.minHeight = "100px";
|
||||
panel.style.maxHeight = Math.min(0.5*viewportHeight, 550) + "px";
|
||||
const panelHeight = panel.offsetHeight;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
let top, isUp = false;
|
||||
if (spaceBelow >= panelHeight) {
|
||||
top = rect.bottom + 2;
|
||||
isUp = false;
|
||||
} else if (spaceAbove >= panelHeight) {
|
||||
top = rect.top - panelHeight - 2;
|
||||
isUp = true;
|
||||
} else {
|
||||
if (spaceBelow >= spaceAbove) {
|
||||
top = Math.max(rect.bottom - 2, viewportHeight - panelHeight - 2);
|
||||
isUp = false;
|
||||
} else {
|
||||
top = Math.min(rect.top - panelHeight + 2, 2);
|
||||
isUp = true;
|
||||
}
|
||||
}
|
||||
panel.style.left = rect.left + "px";
|
||||
panel.style.top = top + "px";
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const displayWidth = rect.width;
|
||||
const remainingWidth = window.innerWidth - panelRect.left - 12;
|
||||
const maxWidth = Math.max(displayWidth, Math.floor(remainingWidth));
|
||||
panel.style.maxWidth = maxWidth + "px";
|
||||
panel.style.minWidth = displayWidth + "px";
|
||||
panel.style.width = "auto";
|
||||
panel.style.visibility = "";
|
||||
}
|
||||
|
||||
// 打开/关闭面板
|
||||
function lv_openPanel(cbid, display, panel, listContainer, hiddenSelect, searchInput) {
|
||||
if (!panel._moved) {
|
||||
document.body.appendChild(panel);
|
||||
panel._moved = true;
|
||||
}
|
||||
lv_expandGroupOfCurrent(cbid, listContainer, hiddenSelect);
|
||||
lv_highlightSelectedItem(listContainer, hiddenSelect);
|
||||
panel.style.display = "block";
|
||||
lv_repositionPanel(panel, display);
|
||||
// 失焦监听
|
||||
const handler = function(e){
|
||||
const target = e.target;
|
||||
if (panel.style.display !== "none") {
|
||||
if (!panel.contains(target) && !display.contains(target)) {
|
||||
lv_closePanel(cbid, panel, listContainer, hiddenSelect, searchInput, display);
|
||||
}
|
||||
}
|
||||
}
|
||||
panel._docClickHandler = handler;
|
||||
document.addEventListener("click", handler);
|
||||
// 滚动 / resize 自动 reposition
|
||||
let ticking = false;
|
||||
const repositionHandler = function () {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(function () {
|
||||
ticking = false;
|
||||
lv_repositionPanel(panel, display);
|
||||
});
|
||||
};
|
||||
panel._repositionHandler = repositionHandler;
|
||||
window.addEventListener("scroll", repositionHandler, true);
|
||||
window.addEventListener("resize", repositionHandler);
|
||||
}
|
||||
function lv_closePanel(cbid, panel, listContainer, hiddenSelect, searchInput) {
|
||||
panel.style.display = "none";
|
||||
searchInput.value = "";
|
||||
lv_filterList("", cbid, listContainer, hiddenSelect, searchInput);
|
||||
// document click
|
||||
if (panel._docClickHandler) {
|
||||
document.removeEventListener("click", panel._docClickHandler);
|
||||
panel._docClickHandler = null;
|
||||
}
|
||||
// scroll / resize
|
||||
if (panel._repositionHandler) {
|
||||
window.removeEventListener("scroll", panel._repositionHandler, true);
|
||||
window.removeEventListener("resize", panel._repositionHandler);
|
||||
panel._repositionHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 动态生成下拉框
|
||||
window.lv_dropdown_rendered = {};
|
||||
function lv_escape_html(s) {
|
||||
return s.replace(/[&<>"']/g, c => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
}[c]));
|
||||
}
|
||||
|
||||
function lv_change(cbid, listContainer, hiddenSelect, labelSpan, searchInput, new_key, new_text) {
|
||||
//改值
|
||||
hiddenSelect.options[0].value = new_key;
|
||||
hiddenSelect.options[0].text = new_key;
|
||||
hiddenSelect.value = new_key;
|
||||
labelSpan.textContent = lv_ellipsisByWidth(cbid, new_text);
|
||||
labelSpan.title = new_text;
|
||||
lv_highlightSelectedItem(listContainer, hiddenSelect);
|
||||
lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput);
|
||||
}
|
||||
|
||||
function lv_render_dropdown_list(cbid, panel, listContainer, hiddenSelect, labelSpan, searchInput, display) {
|
||||
if (window.lv_dropdown_rendered[cbid]) return;
|
||||
const data = window.lv_dropdown_data[cbid];
|
||||
if (!data) return;
|
||||
if (!listContainer) return;
|
||||
|
||||
let html = "";
|
||||
|
||||
// 无组项
|
||||
if (data.ungrouped && data.ungrouped.length > 0) {
|
||||
html += `<ul style="list-style:none;padding:0;margin:0 0 8px 0;">`;
|
||||
|
||||
data.ungrouped.forEach(item => {
|
||||
html += `
|
||||
<li data-key="${item.key}"
|
||||
data-node-name="${lv_escape_html(item.label.toLowerCase())}"
|
||||
class="list-item">
|
||||
<span class="lv-item-label" style="margin-left:12px;">
|
||||
${lv_escape_html(item.label)}
|
||||
</span>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `</ul>`;
|
||||
}
|
||||
|
||||
// 分组项
|
||||
data.group_order.forEach(gname => {
|
||||
const items = data.groups[gname];
|
||||
|
||||
html += `
|
||||
<div class="lv-group" data-group="${lv_idSafe(gname)}" style="margin-bottom:8px;">
|
||||
<div class="lv-group-title" data-group-name="${lv_idSafe(gname)}">
|
||||
<span class="lv-arrow-right" id="arrow-${cbid}-${lv_idSafe(gname)}"></span>
|
||||
<b style="margin-left:6px;">${lv_escape_html(gname)}</b>
|
||||
<span id="group-count-${cbid}-${lv_idSafe(gname)}"
|
||||
style="margin-left:8px;">(0/${items.length})</span>
|
||||
</div>
|
||||
<ul id="group-${cbid}-${lv_idSafe(gname)}" class="lv-group-list" style="display:none">
|
||||
`;
|
||||
|
||||
items.forEach(item => {
|
||||
html += `
|
||||
<li data-key="${item.key}"
|
||||
data-node-name="${lv_escape_html(item.label.toLowerCase())}"
|
||||
class="lv-group-item">
|
||||
<span class="lv-item-label" title="${lv_escape_html(item.label)}">
|
||||
${lv_escape_html(item.label)}
|
||||
</span>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
|
||||
window.lv_dropdown_rendered[cbid] = true;
|
||||
|
||||
lv_adaptiveStyle(cbid);
|
||||
lv_updateGroupCounts(cbid, listContainer, hiddenSelect, searchInput);
|
||||
|
||||
// 点击项(无组与组内项都使用 li[data-key])
|
||||
listContainer.addEventListener("click", function(e){
|
||||
let li = e.target;
|
||||
while(li && li !== listContainer && !li.hasAttribute('data-key')) li = li.parentNode;
|
||||
if(!li || li === listContainer) return;
|
||||
const key = li.getAttribute('data-key') || "";
|
||||
const text = li.querySelector(".lv-item-label")?.textContent || li.textContent || key;
|
||||
|
||||
const changed = key !== hiddenSelect.value;
|
||||
if (changed) {
|
||||
//改值
|
||||
lv_change(cbid, listContainer, hiddenSelect, labelSpan, searchInput, key, text);
|
||||
}
|
||||
lv_closePanel(cbid,panel,listContainer,hiddenSelect,searchInput);
|
||||
if (changed) {
|
||||
setTimeout(() => {
|
||||
hiddenSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}, 0);
|
||||
}
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
searchInput.addEventListener("input", function() {
|
||||
lv_filterList(this.value, cbid, listContainer, hiddenSelect, searchInput);
|
||||
lv_repositionPanel(panel, display);
|
||||
});
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
searchInput.blur();
|
||||
});
|
||||
|
||||
// 切换组
|
||||
listContainer.querySelectorAll(".lv-group-title").forEach(title => {
|
||||
title.addEventListener("click", function() {
|
||||
const g = this.closest(".lv-group")?.getAttribute("data-group");
|
||||
if (g) {
|
||||
lv_toggleGroup(listContainer, cbid, g);
|
||||
lv_repositionPanel(panel, display);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 防止 panel 惯性滚动穿透
|
||||
panel.addEventListener('wheel', function (e) {
|
||||
const deltaY = e.deltaY;
|
||||
const scrollTop = panel.scrollTop;
|
||||
const scrollHeight = panel.scrollHeight;
|
||||
const clientHeight = panel.clientHeight;
|
||||
const isAtTop = scrollTop === 0;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
|
||||
if (deltaY < 0 && isAtTop) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (deltaY > 0 && isAtBottom) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
//截断display字符长度
|
||||
window.lv_labelSpan_maxWidth = {};
|
||||
function lv_ellipsisByWidth(cbid, text) {
|
||||
const el = document.getElementById(cbid + ".label");
|
||||
if (!el || !text) return text;
|
||||
text = text.trim();
|
||||
const maxWidth = el.clientWidth;
|
||||
window.lv_labelSpan_maxWidth[cbid] = maxWidth;
|
||||
if (maxWidth <= 0) return text;
|
||||
const style = window.getComputedStyle(el);
|
||||
const font = [
|
||||
style.fontStyle,
|
||||
style.fontVariant,
|
||||
style.fontWeight,
|
||||
style.fontSize || '16px',
|
||||
style.fontFamily || 'sans-serif'
|
||||
].join(" ").replace(/\s+/g, ' ');
|
||||
const canvas = lv_ellipsisByWidth._canvas || (lv_ellipsisByWidth._canvas = document.createElement("canvas"));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = font;
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
const ellipsis = "...";
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||
const minChars = 15;
|
||||
const probe = text.slice(0, minChars);
|
||||
const probeWidth = ctx.measureText(probe).width;
|
||||
if (probeWidth + ellipsisWidth > maxWidth) {
|
||||
return text;
|
||||
}
|
||||
let left = 0;
|
||||
let right = text.length;
|
||||
let result = ellipsis;
|
||||
while (left <= right) {
|
||||
const mid = (left + right) >> 1;
|
||||
const substr = text.slice(0, mid);
|
||||
const w = ctx.measureText(substr).width;
|
||||
if (w + ellipsisWidth <= maxWidth) {
|
||||
result = substr + ellipsis;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const lv_adaptiveControls = new Set();
|
||||
function lv_registerAdaptive(cbid) {
|
||||
lv_adaptiveControls.add(cbid);
|
||||
lv_adaptiveStyle(cbid);
|
||||
}
|
||||
function lv_labelSpanResize(cbid) {
|
||||
const el = document.getElementById(cbid + ".label");
|
||||
if (!el) return;
|
||||
const maxWidth = el.clientWidth;
|
||||
if (window.lv_labelSpan_maxWidth[cbid] == maxWidth) return;
|
||||
let text = el.title;
|
||||
el.textContent = lv_ellipsisByWidth(cbid, text);
|
||||
}
|
||||
let lv_adaptiveTicking = false;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!lv_adaptiveTicking) {
|
||||
lv_adaptiveTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
lv_adaptiveControls.forEach(cbid => {
|
||||
lv_adaptiveStyle(cbid);
|
||||
lv_labelSpanResize(cbid);
|
||||
});
|
||||
lv_adaptiveTicking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,132 @@
|
||||
<%+cbi/valueheader%>
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
local json = require "luci.jsonc"
|
||||
local cbid = "cbid." .. self.config .. "." .. section .. "." .. self.option
|
||||
|
||||
-- 读取 MultiValue
|
||||
local values = {}
|
||||
for i, key in pairs(self.keylist) do
|
||||
values[#values + 1] = {
|
||||
key = key,
|
||||
label = self.vallist[i] or key,
|
||||
group = self.group and self.group[i] or nil
|
||||
}
|
||||
end
|
||||
|
||||
-- 获取选中值
|
||||
local selected = {}
|
||||
local cval = self:cfgvalue(section)
|
||||
if type(cval) == "table" then
|
||||
for _, v in pairs(cval) do
|
||||
selected[v] = true
|
||||
end
|
||||
elseif type(cval) == "string" then
|
||||
for v in cval:gmatch("%S+") do
|
||||
selected[v] = true
|
||||
end
|
||||
end
|
||||
|
||||
-- 按原顺序分组
|
||||
local groups = {}
|
||||
local group_order = {}
|
||||
for _, item in ipairs(values) do
|
||||
local g = item.group
|
||||
if not g or g == "" then
|
||||
g = translate("default")
|
||||
end
|
||||
if not groups[g] then
|
||||
groups[g] = {}
|
||||
table.insert(group_order, g)
|
||||
end
|
||||
table.insert(groups[g], item)
|
||||
end
|
||||
|
||||
local total_count = #values
|
||||
local selected_count = 0
|
||||
for _, item in ipairs(values) do
|
||||
if selected[item.key] then
|
||||
selected_count = selected_count + 1
|
||||
end
|
||||
end
|
||||
|
||||
local function idSafe(id)
|
||||
return (id
|
||||
:gsub("^%s+", "")
|
||||
:gsub("%s+$", "")
|
||||
:gsub("%s+", "-")
|
||||
:gsub("[%z\1-\31\127]", "")
|
||||
)
|
||||
end
|
||||
%>
|
||||
|
||||
<div id="<%=cbid%>" class="cbi-input-select" style="display:inline-block;">
|
||||
<select id="<%=cbid%>.ref" class="cbi-input-select" style="display:none !important;">
|
||||
<option value>placeholder</option>
|
||||
</select>
|
||||
<!-- 搜索框 -->
|
||||
<input type="text" id="<%=cbid%>.search"
|
||||
class="mv_search_input cbi-input-text"
|
||||
placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
|
||||
<!-- 主容器 -->
|
||||
<div class="mv_list_container" id="<%=cbid%>.panel">
|
||||
<ul class="cbi-multi mv_node_list" id="<%=cbid%>.node_list">
|
||||
<% for _, gname in ipairs(group_order) do local items = groups[gname] %>
|
||||
<li class="group-block" data-group="<%=idSafe(gname)%>">
|
||||
<!-- 组标题 -->
|
||||
<div class="group-title">
|
||||
<span id="arrow-<%=self.option%>-<%=idSafe(gname)%>" class="mv-arrow-right"></span>
|
||||
<b style="margin-left:8px;"><%=pcdata(gname)%></b>
|
||||
<%
|
||||
local g_selected = 0
|
||||
for _, it in ipairs(items) do
|
||||
if selected[it.key] then
|
||||
g_selected = g_selected + 1
|
||||
end
|
||||
end
|
||||
%>
|
||||
<span id="group-count-<%=self.option%>-<%=idSafe(gname)%>" style="margin-left:8px;">
|
||||
(<%=g_selected%>/<%=#items%>)
|
||||
</span>
|
||||
</div>
|
||||
<!-- 组内容 -->
|
||||
<ul id="group-<%=self.option%>-<%=idSafe(gname)%>" class="group-items" style="display:none;"
|
||||
data-items='<%=json.stringify(items)%>'
|
||||
data-selected='<%=json.stringify(selected)%>'>
|
||||
</ul>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- 底部控制栏 -->
|
||||
<div class="mv-controls">
|
||||
<input class="btn cbi-button cbi-button-edit" type="button" onclick="mv_selectAll('<%=cbid%>','<%=self.option%>',true)" value="<%:Select all%>">
|
||||
<input class="btn cbi-button cbi-button-edit" type="button" onclick="mv_selectAll('<%=cbid%>','<%=self.option%>',false)" value="<%:DeSelect all%>">
|
||||
<span id="count-<%=self.option%>" style="color:#666;"><%:Selected:%> <span style='color:red;'><%=selected_count%>/<%=total_count%></span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
const cbid = "<%=cbid%>";
|
||||
const opt = "<%=self.option%>";
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const nodeList = document.getElementById(cbid + ".node_list");
|
||||
|
||||
nodeList.querySelectorAll(".group-title").forEach(title => {
|
||||
title.addEventListener("click", function() {
|
||||
const g = this.closest(".group-block")?.getAttribute("data-group");
|
||||
if (g) mv_toggleGroup(opt, nodeList, searchInput, g);
|
||||
});
|
||||
});
|
||||
|
||||
mv_multivalue_init(cbid, opt, nodeList, searchInput)
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -0,0 +1,460 @@
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
%>
|
||||
|
||||
<style>
|
||||
/* 组标题的右箭头 */
|
||||
.mv-arrow-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的下箭头 */
|
||||
.mv-arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mv_search_input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
margin-bottom: 8px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
max-height: 36px;
|
||||
}
|
||||
.mv_list_container {
|
||||
max-height: 300px;
|
||||
overflow: auto;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mv_node_list {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.mv_node_list li.group-block {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
.mv_node_list .group-title {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px;
|
||||
margin-bottom: 4px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.mv_node_list ul.group-items {
|
||||
margin: 0 0 8px 16px;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.mv_node_list ul.group-items li.node-item {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
text-align: left;
|
||||
}
|
||||
.mv-node-row {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.mv-node-checkbox {
|
||||
margin: 0;
|
||||
vertical-align: middle;
|
||||
margin-right: 6px;
|
||||
}
|
||||
.mv-node-label {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
.mv-controls {
|
||||
margin-top: 4px;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
// css helper functions
|
||||
function mv_camelToKebab(str) {
|
||||
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function mv_style2Css(styleDeclaration, properties) {
|
||||
const cssRules = properties.map(prop => {
|
||||
const kebabCaseProp = mv_camelToKebab(prop);[1, 5]
|
||||
const value = styleDeclaration[prop]
|
||||
if (value) {
|
||||
return `${kebabCaseProp}: ${value};`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
// Filter out any empty strings and join the rules
|
||||
return cssRules.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const mv_parseColorToRgba = (function() {
|
||||
// Create canvas and context once (Closure)
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
return function(colorStr) {
|
||||
if (!colorStr)
|
||||
return null;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
// 2. Apply the color
|
||||
ctx.fillStyle = colorStr;
|
||||
// 3. Fill a single pixel
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
// 4. Extract pixel data [R, G, B, A]
|
||||
const data = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return {
|
||||
r: data[0],
|
||||
g: data[1],
|
||||
b: data[2],
|
||||
// Convert alpha from 0-255 to 0-1 (rounded to 3 decimal places)
|
||||
a: Math.round((data[3] / 255) * 1000) / 1000
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
// Helper to convert back to Hex (for output consistency)
|
||||
function mv_rgbToHex(r, g, b) {
|
||||
const toHex = (n) => {
|
||||
const hex = Math.max(0, Math.min(255, n)).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
}
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
function mv_isTransparent(color) {
|
||||
const cleanColor = mv_parseColorToRgba(color);
|
||||
// check #RRGGBBAA for transparency
|
||||
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
|
||||
}
|
||||
|
||||
function mv_getColorSchema(color) {
|
||||
const rgb = mv_parseColorToRgba(color);
|
||||
if (!rgb) return 'unknown'; // Handle invalid colors
|
||||
// Calculate YIQ brightness (human eye perception)
|
||||
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
|
||||
|
||||
return brightness > 128 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function mv_lighter(color, amount) {
|
||||
const rgb = mv_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Add amount to each channel
|
||||
const r = rgb.r + amount;
|
||||
const g = rgb.g + amount;
|
||||
const b = rgb.b + amount;
|
||||
// Convert back to Hex (clamping happens inside rgbToHex)
|
||||
return mv_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
function mv_darker(color, amount) {
|
||||
const rgb = mv_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Subtract amount from each channel
|
||||
const r = rgb.r - amount;
|
||||
const g = rgb.g - amount;
|
||||
const b = rgb.b - amount;
|
||||
|
||||
return mv_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// copy select styles
|
||||
function mv_adaptiveStyle(cbid) {
|
||||
const mainDiv = document.getElementById(cbid);
|
||||
const hiddenRef = document.getElementById(cbid + ".ref");
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
if (hiddenRef && mainDiv) {
|
||||
const elOption = hiddenRef.getElementsByTagName("option")[0]
|
||||
const styleSelect = window.getComputedStyle(hiddenRef)
|
||||
const styleOption = window.getComputedStyle(elOption)
|
||||
const styleBody = window.getComputedStyle(document.body)
|
||||
|
||||
const styleNode = document.createElement('style')
|
||||
const styleNames = ["width", "color", "height", "margin", "lineHeight", "borderRadius", "minWidth", "minHeight"]
|
||||
|
||||
document.head.appendChild(styleNode)
|
||||
// trace back from option -> select -> body for background color
|
||||
const panelRadius = styleSelect.borderRadius;
|
||||
const optionColor = !mv_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !mv_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
|
||||
const titleColor = mv_getColorSchema(optionColor) === "light" ? mv_darker(optionColor, 30) : mv_lighter(optionColor, 30)
|
||||
const selectStyleCSS = [`#${CSS.escape(cbid)} {`, mv_style2Css(styleSelect, styleNames), "}"]
|
||||
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, mv_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
|
||||
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .group-title {`, `background-color: ${titleColor} !important;`, "}"]
|
||||
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function mv_idSafe(id) {
|
||||
return id
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
}
|
||||
|
||||
// 折叠组
|
||||
function mv_toggleGroup(opt, nodeList, searchInput, g) {
|
||||
g = mv_idSafe(g);
|
||||
const ul = document.getElementById("group-" + opt + "-" + g);
|
||||
const arrow = document.getElementById("arrow-" + opt + "-" + g);
|
||||
if (!ul) return;
|
||||
// 判断是否在搜索状态
|
||||
const keyword = searchInput.value.trim().toLowerCase();
|
||||
const isSearching = keyword.length > 0;
|
||||
// 搜索状态下,仅切换当前组,不处理其他组
|
||||
if (isSearching){
|
||||
ul.style.display = ul.style.display === "none" ? "block" : "none";
|
||||
if (arrow) arrow.className = ul.style.display === "none" ? "mv-arrow-right" : "mv-arrow-down-small";
|
||||
return;
|
||||
}
|
||||
// 非搜索模式:先折叠其他组
|
||||
nodeList.querySelectorAll(".group-block").forEach(group=>{
|
||||
const gname = group.getAttribute("data-group");
|
||||
const gul = document.getElementById("group-" + opt + "-" + gname);
|
||||
const garrow = document.getElementById("arrow-" + opt + "-" + gname);
|
||||
if (gname !== g) {
|
||||
if (gul) gul.style.display = "none";
|
||||
if (garrow) garrow.className = "mv-arrow-right";
|
||||
}
|
||||
});
|
||||
nodeList.parentNode.scrollTop = 0;
|
||||
// 切换当前组
|
||||
ul.style.display = ul.style.display === "none" ? "block" : "none";
|
||||
if (arrow) arrow.className = ul.style.display === "none" ? "mv-arrow-right" : "mv-arrow-down-small";
|
||||
};
|
||||
|
||||
// 计数
|
||||
function mv_updateCount(opt, nodeList, searchInput) {
|
||||
const keyword = searchInput.value.trim().toLowerCase();
|
||||
const isSearching = keyword.length > 0;
|
||||
// 当前实例下的所有 checkbox
|
||||
const cbs = isSearching
|
||||
? Array.from(nodeList.querySelectorAll("input[type=checkbox]")).filter(cb => cb.closest("li").style.display !== "none")
|
||||
: nodeList.querySelectorAll("input[type=checkbox]");
|
||||
let checked = 0;
|
||||
cbs.forEach(cb => { if(cb.checked) checked++; });
|
||||
// 更新总计
|
||||
const totalSpan = document.getElementById("count-" + opt);
|
||||
if (totalSpan) {
|
||||
totalSpan.innerHTML = "<%:Selected:%> <span style='color:red;'>" + checked + "/" + cbs.length + "</span>";
|
||||
}
|
||||
// 更新每个组计数
|
||||
nodeList.querySelectorAll(".group-block").forEach(group => {
|
||||
const gname = group.getAttribute("data-group");
|
||||
const groupCbs = group.querySelectorAll("li[data-node-name] input[type=checkbox]");
|
||||
let groupChecked = 0;
|
||||
let totalCount = 0;
|
||||
groupCbs.forEach(cb => {
|
||||
const li = cb.closest("li");
|
||||
// 搜索时只统计可见节点
|
||||
if (!isSearching || li.style.display !== "none") {
|
||||
totalCount++;
|
||||
if (cb.checked) groupChecked++;
|
||||
}
|
||||
});
|
||||
const span = document.getElementById("group-count-" + opt + "-" + gname);
|
||||
if(span) span.textContent = "(" + groupChecked + "/" + totalCount + ")";
|
||||
});
|
||||
}
|
||||
|
||||
// 搜索
|
||||
function mv_filterGroups(searchInput, opt, nodeList) {
|
||||
const keyword = searchInput.value.trim().toLowerCase();
|
||||
nodeList.querySelectorAll(".group-block").forEach(group => {
|
||||
const items = group.querySelectorAll("li[data-node-name]");
|
||||
let matchCount = 0;
|
||||
items.forEach(li => {
|
||||
const name = li.getAttribute("data-node-name");
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "";
|
||||
matchCount++;
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
// 搜索时自动展开组
|
||||
const gname = group.getAttribute("data-group");
|
||||
const ul = document.getElementById("group-" + opt + "-" + gname);
|
||||
const arrow = document.getElementById("arrow-" + opt + "-" + gname);
|
||||
|
||||
if (matchCount === 0 && keyword !== "") {
|
||||
group.style.display = "none";
|
||||
} else {
|
||||
group.style.display = "";
|
||||
if (keyword && ul && arrow) {
|
||||
ul.style.display = "";
|
||||
arrow.className = "mv-arrow-down-small";
|
||||
}
|
||||
}
|
||||
});
|
||||
mv_updateCount(opt, nodeList, searchInput);
|
||||
// 清空搜索后恢复全部折叠
|
||||
if (!keyword) {
|
||||
mv_collapseAllGroups(opt, nodeList);
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠所有组
|
||||
function mv_collapseAllGroups(opt, nodeList) {
|
||||
nodeList.querySelectorAll(".group-block").forEach(group => {
|
||||
const gname = group.getAttribute("data-group");
|
||||
const ul = document.getElementById("group-" + opt + "-" + gname);
|
||||
const arrow = document.getElementById("arrow-" + opt + "-" + gname);
|
||||
if (ul) ul.style.display = "none";
|
||||
if (arrow) arrow.className = "mv-arrow-right";
|
||||
});
|
||||
}
|
||||
|
||||
window.mv_nodeitem_rendered = {};
|
||||
function mv_render_multivalue_list(cbid, opt, nodeList, searchInput) {
|
||||
if (window.mv_nodeitem_rendered[cbid]) return;
|
||||
const root = document.getElementById(cbid);
|
||||
if (!root) return;
|
||||
// 遍历所有组
|
||||
root.querySelectorAll(".group-items").forEach(function(ul) {
|
||||
// 组名
|
||||
const gname = ul.id.replace("group-" + opt + "-", "");
|
||||
// 解析 Lua 注入的数据
|
||||
const items = JSON.parse(ul.dataset.items || "[]");
|
||||
const selected = JSON.parse(ul.dataset.selected || "{}");
|
||||
// 清空
|
||||
ul.innerHTML = "";
|
||||
// 列表渲染
|
||||
items.forEach(function(item) {
|
||||
// li
|
||||
let li = document.createElement("li");
|
||||
li.className = "node-item";
|
||||
li.setAttribute("data-node-name", item.label.toLowerCase());
|
||||
li.title = item.label;
|
||||
// row div
|
||||
let row = document.createElement("div");
|
||||
row.className = "mv-node-row";
|
||||
// checkbox
|
||||
let checkboxId = cbid + "." + item.key;
|
||||
let checkbox = document.createElement("input");
|
||||
checkbox.type = "checkbox";
|
||||
checkbox.className = "cbi-input-checkbox mv-node-checkbox";
|
||||
checkbox.id = checkboxId;
|
||||
checkbox.name = cbid;
|
||||
checkbox.value = item.key;
|
||||
if (selected[item.key]) checkbox.checked = true;
|
||||
// label
|
||||
let label = document.createElement("label");
|
||||
label.className = "mv-node-label";
|
||||
label.htmlFor = checkboxId;
|
||||
label.textContent = item.label;
|
||||
// 组装
|
||||
row.appendChild(checkbox);
|
||||
row.appendChild(label);
|
||||
li.appendChild(row);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
});
|
||||
window.mv_nodeitem_rendered[cbid] = true;
|
||||
searchInput.addEventListener("input", function() {
|
||||
mv_filterGroups(searchInput, opt, nodeList);
|
||||
});
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
searchInput.blur();
|
||||
});
|
||||
// checkbox 改变时更新计数
|
||||
nodeList.addEventListener("change", () => {
|
||||
mv_updateCount(opt, nodeList, searchInput);
|
||||
});
|
||||
}
|
||||
|
||||
// 全选 / 全不选
|
||||
function mv_selectAll(cbid, opt, flag) {
|
||||
if (!window.mv_nodeitem_rendered[cbid]) return;
|
||||
const nodeList = document.getElementById(cbid + ".node_list");
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const cbs = nodeList.querySelectorAll("input[type=checkbox]");
|
||||
cbs.forEach(cb=>{
|
||||
if (cb.offsetParent !== null) cb.checked = flag;
|
||||
});
|
||||
mv_updateCount(opt, nodeList, searchInput);
|
||||
};
|
||||
|
||||
|
||||
function mv_onControlVisible(cbid, cb) {
|
||||
var root = document.getElementById(cbid);
|
||||
if (!root) return;
|
||||
var container = root.closest(".cbi-value");
|
||||
if (!container) return;
|
||||
if (container.offsetParent !== null) {
|
||||
cb();
|
||||
return;
|
||||
}
|
||||
var observer = new MutationObserver(function () {
|
||||
if (container.offsetParent !== null) {
|
||||
observer.disconnect();
|
||||
cb();
|
||||
}
|
||||
});
|
||||
observer.observe(container, {
|
||||
attributes: true,
|
||||
attributeFilter: ["style", "class"]
|
||||
});
|
||||
}
|
||||
|
||||
function mv_multivalue_init(cbid, opt, nodeList, searchInput) {
|
||||
mv_onControlVisible(cbid, function () {
|
||||
var root = document.getElementById(cbid);
|
||||
if (!root || root.dataset.rendered) return;
|
||||
root.dataset.rendered = "1";
|
||||
mv_render_multivalue_list(cbid, opt, nodeList, searchInput)
|
||||
});
|
||||
mv_registerAdaptive(cbid);
|
||||
}
|
||||
|
||||
const mv_adaptiveControls = new Set();
|
||||
function mv_registerAdaptive(cbid) {
|
||||
mv_adaptiveControls.add(cbid);
|
||||
mv_adaptiveStyle(cbid);
|
||||
}
|
||||
let mv_adaptiveTicking = false;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!mv_adaptiveTicking) {
|
||||
mv_adaptiveTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
mv_adaptiveControls.forEach(cbid => mv_adaptiveStyle(cbid));
|
||||
mv_adaptiveTicking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,162 @@
|
||||
<%+cbi/valueheader%>
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
|
||||
local json = require "luci.jsonc"
|
||||
|
||||
-- 读取值(keylist/vallist/group)
|
||||
local values = {{key = "", label = translate("-- Please choose --")}}
|
||||
for i, key in pairs(self.keylist) do
|
||||
if key and key ~= "" then
|
||||
values[#values + 1] = {
|
||||
key = key,
|
||||
label = self.vallist[i] or key,
|
||||
group = self.group and self.group[i] or nil
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
-- 获取当前配置值(单值)
|
||||
local current_key = nil
|
||||
local cval = self:cfgvalue(section)
|
||||
if type(cval) == "table" then
|
||||
-- 若意外为table,取第一个
|
||||
for k,_ in pairs(cval) do
|
||||
current_key = k
|
||||
break
|
||||
end
|
||||
elseif type(cval) == "string" then
|
||||
current_key = (cval:match("%S+")) -- 取第一个 token
|
||||
end
|
||||
|
||||
-- 分离无组节点(ungrouped)与有组节点(grouped),并保持原有顺序
|
||||
local ungrouped = {}
|
||||
local groups = {}
|
||||
local group_order = {}
|
||||
local current_key_added = false
|
||||
for _, item in ipairs(values) do
|
||||
if not current_key_added and current_key and current_key ~= "" and item.key == current_key then
|
||||
current_key_added = true
|
||||
end
|
||||
if not item.group or item.group == "" then
|
||||
table.insert(ungrouped, item)
|
||||
else
|
||||
local g = item.group
|
||||
if not groups[g] then
|
||||
groups[g] = {}
|
||||
table.insert(group_order, g)
|
||||
end
|
||||
table.insert(groups[g], item)
|
||||
end
|
||||
end
|
||||
if not current_key_added and current_key and current_key ~= "" then
|
||||
table.insert(ungrouped, {key = current_key, label = current_key})
|
||||
end
|
||||
|
||||
-- 如果没有配置值,默认取第一个(优先无组第一项,否则第一组第一项)
|
||||
if not current_key then
|
||||
if #ungrouped > 0 then
|
||||
current_key = ungrouped[1].key
|
||||
else
|
||||
-- 找到 group_order 第一个有节点的项
|
||||
for _, g in ipairs(group_order) do
|
||||
if groups[g] and #groups[g] > 0 then
|
||||
current_key = groups[g][1].key
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- 获取当前 label(用于主控件显示)
|
||||
local function find_label_by_key(k)
|
||||
if not k then return "" end
|
||||
for _, v in ipairs(values) do
|
||||
if v.key == k then return v.label end
|
||||
end
|
||||
return ""
|
||||
end
|
||||
local current_label = find_label_by_key(current_key) or ""
|
||||
|
||||
-- 打包数据给 JS
|
||||
local dropdown_data = {
|
||||
current_key = current_key,
|
||||
current_label = current_label,
|
||||
ungrouped = ungrouped,
|
||||
groups = groups,
|
||||
group_order = group_order,
|
||||
cbid = cbid,
|
||||
}
|
||||
%>
|
||||
<script>
|
||||
window.v_dropdown_data = window.v_dropdown_data || {};
|
||||
window.v_dropdown_data["<%=cbid%>"] = <%=json.stringify(dropdown_data)%>;
|
||||
</script>
|
||||
|
||||
<div id="<%=cbid%>.v-main" class="v-dropdown-container">
|
||||
<select id="<%=cbid%>.ref" class="cbi-input-select" style="display:none !important;">
|
||||
<option value>placeholder</option>
|
||||
</select>
|
||||
<!-- 隐藏 input(保存实际配置值) -->
|
||||
<input type="text" id="<%=cbid%>" name="<%=cbid%>" value="<%=current_key%>" data-update="change" style="display:none !important;" />
|
||||
<!-- 模拟 ListValue 控件外观 -->
|
||||
<div class="cbi-input-value cbi-input-select v-dropdown-display" id="<%=cbid%>.display" tabindex="0">
|
||||
<span id="<%=cbid%>.label" class="v-dropdown-label" title="<%=pcdata(current_label)%>">
|
||||
<%=pcdata(translate("-- Please choose --"))%>
|
||||
</span>
|
||||
<span class="v-arrow-down"></span>
|
||||
</div>
|
||||
<!-- 下拉面板 -->
|
||||
<div id="<%=cbid%>.panel" class="cbi-listvalue-panel v-dropdown-panel" style="display:none;">
|
||||
<!-- 搜索框 -->
|
||||
<div style="padding:8px;border-bottom:1px solid #f0f0f0;">
|
||||
<input id="<%=cbid%>.search" class="cbi-input-text v-dropdown-search" type="text" placeholder="🔍 <%:Search nodes...%>" inputmode="search" enterkeyhint="done" />
|
||||
</div>
|
||||
<!-- 列表容器 -->
|
||||
<div id="<%=cbid%>.list" style="padding:8px;padding-bottom:0;">
|
||||
<!-- 首次点击 display 时由 JS 填充 -->
|
||||
</div>
|
||||
<!-- 自定义 -->
|
||||
<div style="padding:8px;padding-top:0;margin-top:0;">
|
||||
<input id="<%=cbid%>.custom" class="cbi-input-text v-dropdown-custom" type="text" placeholder="<%=translate("-- custom --")%>" inputmode="text" enterkeyhint="done" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
(function(){
|
||||
const cbid = "<%=cbid%>";
|
||||
const hiddenInput = document.getElementById(cbid);
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const labelSpan = document.getElementById(cbid + ".label");
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const listContainer = document.getElementById(cbid + ".list");
|
||||
const customInput = document.getElementById(cbid + ".custom");
|
||||
|
||||
// 点击 display
|
||||
display.addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
v_render_dropdown_list(cbid,panel,listContainer,hiddenInput,labelSpan,searchInput,display,customInput);
|
||||
document.querySelectorAll(".cbi-listvalue-panel").forEach(p=>{
|
||||
if (p !== panel) p.style.display = "none";
|
||||
});
|
||||
if (panel.style.display !== "none") {
|
||||
v_closePanel(cbid,panel,listContainer,hiddenInput,searchInput,customInput);
|
||||
} else {
|
||||
v_openPanel(cbid,display,panel,listContainer,hiddenInput,searchInput,customInput);
|
||||
}
|
||||
});
|
||||
v_registerAdaptive(cbid);
|
||||
setTimeout(() => {
|
||||
labelSpan.textContent = v_ellipsisByWidth(cbid, '<%=pcdata(current_label ~= "" and current_label or current_key)%>');
|
||||
}, 100);
|
||||
})();
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<%+cbi/valuefooter%>
|
||||
@@ -0,0 +1,792 @@
|
||||
<%
|
||||
-- Template Developers:
|
||||
-- - lwb1978
|
||||
-- Copyright: copyright(c)2025–2027
|
||||
-- Description: Passwall(2) UI template
|
||||
-- It is the common part of the template and cannot be used independently
|
||||
%>
|
||||
<style>
|
||||
/* 主下拉按钮的下箭头 */
|
||||
.v-arrow-down {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 5px solid transparent;
|
||||
border-right: 5px solid transparent;
|
||||
border-top: 6px solid #666;
|
||||
margin-left: 6px;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的右箭头(折叠) */
|
||||
.v-arrow-right {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 4px solid transparent;
|
||||
border-bottom: 4px solid transparent;
|
||||
border-left: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 组标题的下箭头(展开) */
|
||||
.v-arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
/* 基础列表项样式 */
|
||||
.cbi-listvalue-panel li[data-key] {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
/* 鼠标悬停效果 - 使用透明度避免覆盖问题 */
|
||||
.cbi-listvalue-panel li[data-key]:hover {
|
||||
background-color: rgba(0, 123, 255, 0.1);
|
||||
}
|
||||
/* 选中项样式 - 使用更高优先级 */
|
||||
.cbi-listvalue-panel li[data-key].is-selected {
|
||||
background-color: #007bff !important;
|
||||
color: white !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
/* 选中项悬停时保持主色调 */
|
||||
.cbi-listvalue-panel li[data-key].is-selected:hover {
|
||||
background-color: #0056b3 !important;
|
||||
}
|
||||
|
||||
.v-dropdown-container {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
white-space: nowrap;
|
||||
min-width: 220px;
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.v-dropdown-container {
|
||||
display: block;
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.v-dropdown-display {
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
}
|
||||
.v-dropdown-label {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.v-dropdown-panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 2147483647;
|
||||
border: 1px solid #dcdcdc;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 6px 18px rgba(0,0,0,0.08);
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.v-dropdown-search, .v-dropdown-custom {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
box-sizing: border-box;
|
||||
padding: 6px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.v-group-title {
|
||||
cursor: pointer;
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: normal;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.v-group-list {
|
||||
list-style: none;
|
||||
margin: 6px 0 0 8px;
|
||||
padding: 0;
|
||||
}
|
||||
.v-group-item {
|
||||
padding: 6px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-align: left !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
// css helper functions
|
||||
function v_camelToKebab(str) {
|
||||
return str.replace(/([a-z0-9]|(?=[A-Z]))([A-Z])/g, '$1-$2').toLowerCase()
|
||||
}
|
||||
|
||||
function v_style2Css(styleDeclaration, properties) {
|
||||
const cssRules = properties.map(prop => {
|
||||
const kebabCaseProp = v_camelToKebab(prop);[1, 5]
|
||||
const value = styleDeclaration[prop]
|
||||
if (value) {
|
||||
return `${kebabCaseProp}: ${value};`
|
||||
}
|
||||
return ''
|
||||
})
|
||||
// Filter out any empty strings and join the rules
|
||||
return cssRules.filter(Boolean).join(' ')
|
||||
}
|
||||
|
||||
const v_parseColorToRgba = (function() {
|
||||
// Create canvas and context once (Closure)
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 1;
|
||||
canvas.height = 1;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
return function(colorStr) {
|
||||
if (!colorStr)
|
||||
return null;
|
||||
ctx.clearRect(0, 0, 1, 1);
|
||||
// 2. Apply the color
|
||||
ctx.fillStyle = colorStr;
|
||||
// 3. Fill a single pixel
|
||||
ctx.fillRect(0, 0, 1, 1);
|
||||
// 4. Extract pixel data [R, G, B, A]
|
||||
const data = ctx.getImageData(0, 0, 1, 1).data;
|
||||
return {
|
||||
r: data[0],
|
||||
g: data[1],
|
||||
b: data[2],
|
||||
// Convert alpha from 0-255 to 0-1 (rounded to 3 decimal places)
|
||||
a: Math.round((data[3] / 255) * 1000) / 1000
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
// Helper to convert back to Hex (for output consistency)
|
||||
function v_rgbToHex(r, g, b) {
|
||||
const toHex = (n) => {
|
||||
const hex = Math.max(0, Math.min(255, n)).toString(16)
|
||||
return hex.length === 1 ? '0' + hex : hex
|
||||
}
|
||||
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
||||
}
|
||||
|
||||
function v_isTransparent(color) {
|
||||
const cleanColor = v_parseColorToRgba(color);
|
||||
// check #RRGGBBAA for transparency
|
||||
return !cleanColor || (cleanColor.a !== undefined && !cleanColor.a);
|
||||
}
|
||||
|
||||
function v_getColorSchema(color) {
|
||||
const rgb = v_parseColorToRgba(color);
|
||||
if (!rgb) return 'unknown'; // Handle invalid colors
|
||||
// Calculate YIQ brightness (human eye perception)
|
||||
const brightness = ((rgb.r * 299) + (rgb.g * 587) + (rgb.b * 114)) / 1000;
|
||||
|
||||
return brightness > 128 ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
function v_lighter(color, amount) {
|
||||
const rgb = v_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Add amount to each channel
|
||||
const r = rgb.r + amount;
|
||||
const g = rgb.g + amount;
|
||||
const b = rgb.b + amount;
|
||||
// Convert back to Hex (clamping happens inside rgbToHex)
|
||||
return v_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
function v_darker(color, amount) {
|
||||
const rgb = v_parseColorToRgba(color);
|
||||
if (!rgb) return color;
|
||||
// Subtract amount from each channel
|
||||
const r = rgb.r - amount;
|
||||
const g = rgb.g - amount;
|
||||
const b = rgb.b - amount;
|
||||
|
||||
return v_rgbToHex(r, g, b);
|
||||
}
|
||||
|
||||
// copy select styles
|
||||
function v_adaptiveStyle(cbid) {
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const hiddenRef = document.getElementById(cbid + ".ref");
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
if (hiddenRef && display) {
|
||||
const elOption = hiddenRef.getElementsByTagName("option")[0]
|
||||
const styleSelect = window.getComputedStyle(hiddenRef)
|
||||
const styleOption = window.getComputedStyle(elOption)
|
||||
const styleBody = window.getComputedStyle(document.body)
|
||||
|
||||
const styleNode = document.createElement('style')
|
||||
const styleNames = ["color", "height", "padding", "margin", "lineHeight", "border", "borderRadius", "minWidth","minHeight"]
|
||||
if (styleSelect.borderBottomStyle !== "none") {
|
||||
styleNames.push("borderBottomWidth", "borderBottomStyle", "borderBottomColor");
|
||||
}
|
||||
document.head.appendChild(styleNode)
|
||||
// trace back from option -> select -> body for background color
|
||||
const panelRadius = styleSelect.borderRadius;
|
||||
const optionColor = !v_isTransparent(styleOption.backgroundColor) ? styleOption.backgroundColor : !v_isTransparent(styleSelect.backgroundColor) ? styleSelect.backgroundColor : styleBody.backgroundColor
|
||||
const titleColor = v_getColorSchema(optionColor) === "light" ? v_darker(optionColor, 30) : v_lighter(optionColor, 30)
|
||||
const selectStyleCSS = [`#${CSS.escape(cbid + ".display")} {`, v_style2Css(styleSelect, styleNames), v_style2Css(styleSelect, ["backgroundColor"]), "}"]
|
||||
const optionStyleCSS = [`#${CSS.escape(cbid + ".panel")} {`, v_style2Css(styleOption, styleNames), `background-color: ${optionColor};`, `border-radius: ${panelRadius};`, "}"]
|
||||
const titleStyleCSS = [`#${CSS.escape(cbid + ".panel")} .v-group-title {`, `background-color: ${titleColor} !important;`, "}"]
|
||||
styleNode.textContent = [].concat(selectStyleCSS, optionStyleCSS, titleStyleCSS).join("\n")
|
||||
}
|
||||
}
|
||||
|
||||
function v_idSafe(id) {
|
||||
return id
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[\x00-\x1F\x7F]/g, "");
|
||||
}
|
||||
|
||||
// 高亮当前选中的项
|
||||
function v_highlightSelectedItem(listContainer, hiddenInput) {
|
||||
const allItems = listContainer.querySelectorAll("li[data-key]");
|
||||
const currentKey = hiddenInput.value;
|
||||
allItems.forEach(item => {
|
||||
item.classList.remove("is-selected");
|
||||
if (item.getAttribute('data-key') === currentKey) {
|
||||
item.classList.add("is-selected");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 更新组内选中计数
|
||||
function v_updateGroupCounts(cbid, listContainer, hiddenInput, searchInput) {
|
||||
const groups = listContainer.querySelectorAll(".v-group");
|
||||
const currentKey = hiddenInput.value;
|
||||
const isSearching = searchInput.value.trim() !== "";
|
||||
groups.forEach(group => {
|
||||
const gname = group.getAttribute("data-group");
|
||||
const items = group.querySelectorAll("li[data-key]");
|
||||
const span = document.getElementById("group-count-" + cbid + "-" + gname);
|
||||
if (!span) return;
|
||||
if (isSearching) {
|
||||
// 搜索状态:显示匹配数量
|
||||
let matchCount = 0;
|
||||
items.forEach(li => {
|
||||
if (li.style.display !== "none") matchCount++;
|
||||
});
|
||||
span.textContent = "(" + matchCount + "/" + items.length + ")";
|
||||
if (matchCount > 0) {
|
||||
span.style.color = "#28a745";
|
||||
span.style.fontWeight = "600";
|
||||
} else {
|
||||
span.style.color = "#dc3545";
|
||||
span.style.fontWeight = "normal";
|
||||
}
|
||||
} else {
|
||||
// 默认状态:显示选中项数量
|
||||
let selectedCount = 0;
|
||||
items.forEach(li => {
|
||||
if (li.getAttribute('data-key') === currentKey) {
|
||||
selectedCount = 1;
|
||||
}
|
||||
});
|
||||
span.textContent = "(" + selectedCount + "/" + items.length + ")";
|
||||
if (selectedCount > 0) {
|
||||
span.style.color = "#007bff";
|
||||
span.style.fontWeight = "600";
|
||||
} else {
|
||||
span.style.color = "";
|
||||
span.style.fontWeight = "normal";
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//搜索过滤器:按 name 或 label 做模糊匹配,搜索时自动展开所有组并隐藏不匹配条目
|
||||
function v_filterList(keyword, cbid, listContainer, hiddenInput, searchInput) {
|
||||
keyword = (keyword || "").toLowerCase().trim();
|
||||
const topItems = listContainer.querySelectorAll("ul li[data-key]");
|
||||
topItems.forEach((li, index)=>{
|
||||
if (index === 0) {
|
||||
li.style.display = "block";
|
||||
return;
|
||||
}
|
||||
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "block";
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
const groups = listContainer.querySelectorAll(".v-group");
|
||||
groups.forEach(group=>{
|
||||
const items = group.querySelectorAll("li[data-key]");
|
||||
let matchCount = 0;
|
||||
items.forEach(li=>{
|
||||
const name = (li.getAttribute("data-node-name") || "").toLowerCase();
|
||||
if (!keyword || name.indexOf(keyword) !== -1) {
|
||||
li.style.display = "block";
|
||||
matchCount++;
|
||||
} else {
|
||||
li.style.display = "none";
|
||||
}
|
||||
});
|
||||
group.style.display = (matchCount === 0 && keyword !== "") ? "none" : "block";
|
||||
const ul = group.querySelector(".v-group-list");
|
||||
const gname = group.getAttribute("data-group");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (keyword) {
|
||||
if (ul) ul.style.display = (matchCount > 0 ? "block" : "none");
|
||||
if (arrow) arrow.className = (matchCount > 0 ? "v-arrow-down-small" : "v-arrow-right");
|
||||
} else {
|
||||
if (ul) ul.style.display = "none";
|
||||
if (arrow) arrow.className = "v-arrow-right";
|
||||
}
|
||||
});
|
||||
v_updateGroupCounts(cbid, listContainer, hiddenInput, searchInput);
|
||||
v_highlightSelectedItem(listContainer, hiddenInput);
|
||||
}
|
||||
|
||||
// 切换单个组(点击组标题)
|
||||
function v_toggleGroup(listContainer, cbid, g) {
|
||||
g = v_idSafe(g);
|
||||
const group = listContainer.querySelector(".v-group[data-group='" + g + "']");
|
||||
if (!group) return;
|
||||
const ul = group.querySelector(".v-group-list");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + g);
|
||||
if (!ul) return;
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const isSearching = searchInput?.value.trim() !== "";
|
||||
const isExpanded = ul.style.display !== "none";
|
||||
|
||||
if (isExpanded) {
|
||||
ul.style.display = "none";
|
||||
if (arrow) arrow.className = "v-arrow-right";
|
||||
} else {
|
||||
ul.style.display = "block";
|
||||
if (arrow) arrow.className = "v-arrow-down-small";
|
||||
|
||||
if (!isSearching) {
|
||||
const allGroups = listContainer.querySelectorAll(".v-group");
|
||||
allGroups.forEach(otherGroup => {
|
||||
if (otherGroup !== group) {
|
||||
const otherUl = otherGroup.querySelector(".v-group-list");
|
||||
const otherGname = otherGroup.getAttribute("data-group");
|
||||
const otherArrow = document.getElementById("arrow-" + cbid + "-" + otherGname);
|
||||
if (otherUl) otherUl.style.display = "none";
|
||||
if (otherArrow) otherArrow.className = "v-arrow-right";
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 展开包含当前 hiddenInput 值的组(初始化或打开面板时使用)
|
||||
function v_expandGroupOfCurrent(cbid, listContainer, hiddenInput) {
|
||||
const key = hiddenInput.value;
|
||||
if (!key) return;
|
||||
const targetLi = listContainer.querySelector("li[data-key='" + key.replace(/'/g,"\\'") + "']");
|
||||
if (!targetLi) return;
|
||||
let parentGroup = targetLi.closest(".v-group");
|
||||
if (parentGroup) {
|
||||
const ul = parentGroup.querySelector(".v-group-list");
|
||||
const gname = parentGroup.getAttribute("data-group");
|
||||
const arrow = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (ul) ul.style.display = "block";
|
||||
if (arrow) arrow.className = "v-arrow-down-small";
|
||||
const allGroups = listContainer.querySelectorAll(".v-group");
|
||||
allGroups.forEach(gp=>{
|
||||
if (gp !== parentGroup) {
|
||||
const gul = gp.querySelector(".v-group-list");
|
||||
const otherGname = gp.getAttribute("data-group");
|
||||
const gar = document.getElementById("arrow-" + cbid + "-" + otherGname);
|
||||
if (gul) gul.style.display = "none";
|
||||
if (gar) gar.className = "v-arrow-right";
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const allGroups = listContainer.querySelectorAll(".v-group");
|
||||
allGroups.forEach(gp=>{
|
||||
const gul = gp.querySelector(".v-group-list");
|
||||
const gname = gp.getAttribute("data-group");
|
||||
const gar = document.getElementById("arrow-" + cbid + "-" + gname);
|
||||
if (gul) gul.style.display = "none";
|
||||
if (gar) gar.className = "v-arrow-right";
|
||||
});
|
||||
}
|
||||
if (targetLi) {
|
||||
requestAnimationFrame(() => {
|
||||
targetLi.scrollIntoView({ block: "nearest" });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 计算panel位置
|
||||
function v_repositionPanel(panel, display) {
|
||||
if (!panel || panel.style.display === "none") return;
|
||||
const rect = display.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
panel.style.visibility = "hidden";
|
||||
panel.style.display = "block";
|
||||
panel.style.minHeight = "100px";
|
||||
panel.style.maxHeight = Math.min(0.48*viewportHeight, 500) + "px";
|
||||
const panelHeight = panel.offsetHeight;
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const spaceAbove = rect.top;
|
||||
let top, isUp = false;
|
||||
if (spaceBelow >= panelHeight) {
|
||||
top = rect.bottom + 2;
|
||||
isUp = false;
|
||||
} else if (spaceAbove >= panelHeight) {
|
||||
top = rect.top - panelHeight - 2;
|
||||
isUp = true;
|
||||
} else {
|
||||
if (spaceBelow >= spaceAbove) {
|
||||
top = Math.max(rect.bottom - 2, viewportHeight - panelHeight - 2);
|
||||
isUp = false;
|
||||
} else {
|
||||
top = Math.min(rect.top - panelHeight + 2, 2);
|
||||
isUp = true;
|
||||
}
|
||||
}
|
||||
panel.style.left = rect.left + "px";
|
||||
panel.style.top = top + "px";
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const displayWidth = rect.width;
|
||||
const remainingWidth = window.innerWidth - panelRect.left - 12;
|
||||
const maxWidth = Math.max(displayWidth, Math.floor(remainingWidth));
|
||||
panel.style.maxWidth = maxWidth + "px";
|
||||
panel.style.minWidth = Math.max(displayWidth, 240) + "px";
|
||||
panel.style.width = "auto";
|
||||
panel.style.visibility = "";
|
||||
}
|
||||
|
||||
// 打开/关闭面板
|
||||
function v_openPanel(cbid, display, panel, listContainer, hiddenInput, searchInput, customInput) {
|
||||
if (!panel._moved) {
|
||||
document.body.appendChild(panel);
|
||||
panel._moved = true;
|
||||
}
|
||||
v_expandGroupOfCurrent(cbid, listContainer, hiddenInput);
|
||||
v_highlightSelectedItem(listContainer, hiddenInput);
|
||||
panel.style.display = "block";
|
||||
v_repositionPanel(panel, display);
|
||||
// 失焦监听
|
||||
const handler = function(e){
|
||||
const target = e.target;
|
||||
if (panel.style.display !== "none") {
|
||||
if (!panel.contains(target) && !display.contains(target)) {
|
||||
v_closePanel(cbid, panel, listContainer, hiddenInput, searchInput, display, customInput);
|
||||
}
|
||||
}
|
||||
}
|
||||
panel._docClickHandler = handler;
|
||||
document.addEventListener("click", handler);
|
||||
// 滚动 / resize 自动 reposition
|
||||
let ticking = false;
|
||||
const repositionHandler = function () {
|
||||
if (ticking) return;
|
||||
ticking = true;
|
||||
requestAnimationFrame(function () {
|
||||
ticking = false;
|
||||
v_repositionPanel(panel, display);
|
||||
});
|
||||
};
|
||||
panel._repositionHandler = repositionHandler;
|
||||
window.addEventListener("scroll", repositionHandler, true);
|
||||
window.addEventListener("resize", repositionHandler);
|
||||
}
|
||||
function v_closePanel(cbid, panel, listContainer, hiddenInput, searchInput, customInput) {
|
||||
panel.style.display = "none";
|
||||
searchInput.value = "";
|
||||
customInput.value = "";
|
||||
v_filterList("", cbid, listContainer, hiddenInput, searchInput);
|
||||
// document click
|
||||
if (panel._docClickHandler) {
|
||||
document.removeEventListener("click", panel._docClickHandler);
|
||||
panel._docClickHandler = null;
|
||||
}
|
||||
// scroll / resize
|
||||
if (panel._repositionHandler) {
|
||||
window.removeEventListener("scroll", panel._repositionHandler, true);
|
||||
window.removeEventListener("resize", panel._repositionHandler);
|
||||
panel._repositionHandler = null;
|
||||
}
|
||||
}
|
||||
|
||||
//自定义框
|
||||
function v_customEnter(cbid, labelSpan, hiddenInput, searchInput, panel, listContainer, customInput) {
|
||||
let inputValue = customInput.value.trim();
|
||||
if (!inputValue) {
|
||||
return;
|
||||
}
|
||||
const existingItems = listContainer.querySelectorAll('li[data-key="' + inputValue + '"]');
|
||||
if (existingItems.length <= 0) {
|
||||
let newLi = document.createElement('li');
|
||||
newLi.setAttribute('data-key', inputValue);
|
||||
newLi.setAttribute('data-node-name', inputValue.toLowerCase());
|
||||
newLi.className = 'list-item';
|
||||
|
||||
let newSpan = document.createElement('span');
|
||||
newSpan.className = 'v-item-label';
|
||||
newSpan.style.marginLeft = '12px';
|
||||
newSpan.textContent = inputValue;
|
||||
newLi.appendChild(newSpan);
|
||||
|
||||
const ungroupedList = listContainer.querySelector('ul');
|
||||
if (ungroupedList) {
|
||||
ungroupedList.appendChild(newLi);
|
||||
}
|
||||
}
|
||||
const changed = inputValue !== hiddenInput.value;
|
||||
if (changed) {
|
||||
//改值
|
||||
hiddenInput.value = inputValue;
|
||||
labelSpan.textContent = v_ellipsisByWidth(cbid, inputValue);
|
||||
labelSpan.title = inputValue;
|
||||
v_highlightSelectedItem(listContainer, hiddenInput);
|
||||
v_updateGroupCounts(cbid, listContainer, hiddenInput, searchInput);
|
||||
}
|
||||
v_closePanel(cbid,panel,listContainer,hiddenInput,searchInput,customInput);
|
||||
}
|
||||
|
||||
// 动态生成下拉框
|
||||
window.v_dropdown_rendered = {};
|
||||
function v_escape_html(s) {
|
||||
return s.replace(/[&<>"']/g, c => ({
|
||||
"&":"&", "<":"<", ">":">", '"':""", "'":"'"
|
||||
}[c]));
|
||||
}
|
||||
function v_render_dropdown_list(cbid, panel, listContainer, hiddenInput, labelSpan, searchInput, display, customInput) {
|
||||
if (window.v_dropdown_rendered[cbid]) return;
|
||||
const data = window.v_dropdown_data[cbid];
|
||||
if (!data) return;
|
||||
if (!listContainer) return;
|
||||
|
||||
let html = "";
|
||||
|
||||
// 无组项
|
||||
if (data.ungrouped && data.ungrouped.length > 0) {
|
||||
html += `<ul style="list-style:none;padding:0;margin:0 0 8px 0;">`;
|
||||
|
||||
data.ungrouped.forEach((item, index) => {
|
||||
const liStyle = index === 0 ? 'text-align:center;' : '';
|
||||
const spanStyle = index === 0 ? 'margin-left:0px;' : 'margin-left:12px;';
|
||||
html += `
|
||||
<li data-key="${item.key}"
|
||||
data-node-name="${v_escape_html(item.label.toLowerCase())}"
|
||||
class="list-item" style="${liStyle}">
|
||||
<span class="v-item-label" style="${spanStyle}">
|
||||
${v_escape_html(item.label)}
|
||||
</span>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `</ul>`;
|
||||
}
|
||||
|
||||
// 分组项
|
||||
data.group_order.forEach(gname => {
|
||||
const items = data.groups[gname];
|
||||
|
||||
html += `
|
||||
<div class="v-group" data-group="${v_idSafe(gname)}" style="margin-bottom:8px;">
|
||||
<div class="v-group-title" data-group-name="${v_idSafe(gname)}">
|
||||
<span class="v-arrow-right" id="arrow-${cbid}-${v_idSafe(gname)}"></span>
|
||||
<b style="margin-left:6px;">${v_escape_html(gname)}</b>
|
||||
<span id="group-count-${cbid}-${v_idSafe(gname)}"
|
||||
style="margin-left:8px;">(0/${items.length})</span>
|
||||
</div>
|
||||
<ul id="group-${cbid}-${v_idSafe(gname)}" class="v-group-list" style="display:none">
|
||||
`;
|
||||
|
||||
items.forEach(item => {
|
||||
html += `
|
||||
<li data-key="${item.key}"
|
||||
data-node-name="${v_escape_html(item.label.toLowerCase())}"
|
||||
class="v-group-item">
|
||||
<span class="v-item-label" title="${v_escape_html(item.label)}">
|
||||
${v_escape_html(item.label)}
|
||||
</span>
|
||||
</li>`;
|
||||
});
|
||||
|
||||
html += `
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
|
||||
listContainer.innerHTML = html;
|
||||
|
||||
window.v_dropdown_rendered[cbid] = true;
|
||||
|
||||
v_adaptiveStyle(cbid);
|
||||
v_updateGroupCounts(cbid, listContainer, hiddenInput, searchInput);
|
||||
|
||||
// 点击项(无组与组内项都使用 li[data-key])
|
||||
listContainer.addEventListener("click", function(e){
|
||||
let li = e.target;
|
||||
while(li && li !== listContainer && !li.hasAttribute('data-key')) li = li.parentNode;
|
||||
if(!li || li === listContainer) return;
|
||||
const key = li.getAttribute('data-key') || "";
|
||||
const text = li.querySelector(".v-item-label")?.textContent || li.textContent || key;
|
||||
|
||||
const changed = key !== hiddenInput.value;
|
||||
if (changed) {
|
||||
//改值
|
||||
hiddenInput.value = key;
|
||||
labelSpan.textContent = v_ellipsisByWidth(cbid, text);
|
||||
labelSpan.title = text;
|
||||
v_highlightSelectedItem(listContainer, hiddenInput);
|
||||
v_updateGroupCounts(cbid, listContainer, hiddenInput, searchInput);
|
||||
}
|
||||
v_closePanel(cbid,panel,listContainer,hiddenInput,searchInput,customInput);
|
||||
});
|
||||
|
||||
// 搜索功能
|
||||
searchInput.addEventListener("input", function() {
|
||||
v_filterList(this.value, cbid, listContainer, hiddenInput, searchInput);
|
||||
v_repositionPanel(panel, display);
|
||||
});
|
||||
searchInput.addEventListener('keydown', function(e) {
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
searchInput.blur();
|
||||
});
|
||||
|
||||
// 切换组
|
||||
listContainer.querySelectorAll(".v-group-title").forEach(title => {
|
||||
title.addEventListener("click", function() {
|
||||
const g = this.closest(".v-group")?.getAttribute("data-group");
|
||||
if (g) {
|
||||
v_toggleGroup(listContainer, cbid, g);
|
||||
v_repositionPanel(panel, display);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
//自定义框
|
||||
customInput.addEventListener("keydown", function(e){
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
v_customEnter(cbid, labelSpan, hiddenInput, searchInput, panel, listContainer, customInput);
|
||||
});
|
||||
|
||||
// 防止 panel 惯性滚动穿透
|
||||
panel.addEventListener('wheel', function (e) {
|
||||
const deltaY = e.deltaY;
|
||||
const scrollTop = panel.scrollTop;
|
||||
const scrollHeight = panel.scrollHeight;
|
||||
const clientHeight = panel.clientHeight;
|
||||
const isAtTop = scrollTop === 0;
|
||||
const isAtBottom = scrollTop + clientHeight >= scrollHeight;
|
||||
if (deltaY < 0 && isAtTop) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
if (deltaY > 0 && isAtBottom) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
e.stopPropagation();
|
||||
}, { passive: false });
|
||||
}
|
||||
|
||||
//截断display字符长度
|
||||
window.v_labelSpan_maxWidth = {};
|
||||
function v_ellipsisByWidth(cbid, text) {
|
||||
const el = document.getElementById(cbid + ".label");
|
||||
if (!el || !text) return text;
|
||||
text = text.trim();
|
||||
const maxWidth = el.clientWidth;
|
||||
window.v_labelSpan_maxWidth[cbid] = maxWidth;
|
||||
if (maxWidth <= 0) return text;
|
||||
const style = window.getComputedStyle(el);
|
||||
const font = [
|
||||
style.fontStyle,
|
||||
style.fontVariant,
|
||||
style.fontWeight,
|
||||
style.fontSize || '16px',
|
||||
style.fontFamily || 'sans-serif'
|
||||
].join(" ").replace(/\s+/g, ' ');
|
||||
const canvas = v_ellipsisByWidth._canvas || (v_ellipsisByWidth._canvas = document.createElement("canvas"));
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.font = font;
|
||||
if (ctx.measureText(text).width <= maxWidth) {
|
||||
return text;
|
||||
}
|
||||
const ellipsis = "...";
|
||||
const ellipsisWidth = ctx.measureText(ellipsis).width;
|
||||
const minChars = 15;
|
||||
const probe = text.slice(0, minChars);
|
||||
const probeWidth = ctx.measureText(probe).width;
|
||||
if (probeWidth + ellipsisWidth > maxWidth) {
|
||||
return text;
|
||||
}
|
||||
let left = 0;
|
||||
let right = text.length;
|
||||
let result = ellipsis;
|
||||
while (left <= right) {
|
||||
const mid = (left + right) >> 1;
|
||||
const substr = text.slice(0, mid);
|
||||
const w = ctx.measureText(substr).width;
|
||||
if (w + ellipsisWidth <= maxWidth) {
|
||||
result = substr + ellipsis;
|
||||
left = mid + 1;
|
||||
} else {
|
||||
right = mid - 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const v_adaptiveControls = new Set();
|
||||
function v_registerAdaptive(cbid) {
|
||||
v_adaptiveControls.add(cbid);
|
||||
v_adaptiveStyle(cbid);
|
||||
}
|
||||
function v_labelSpanResize(cbid) {
|
||||
const el = document.getElementById(cbid + ".label");
|
||||
if (!el) return;
|
||||
const maxWidth = el.clientWidth;
|
||||
if (window.v_labelSpan_maxWidth[cbid] == maxWidth) return;
|
||||
let text = el.title;
|
||||
el.textContent = v_ellipsisByWidth(cbid, text);
|
||||
}
|
||||
let v_adaptiveTicking = false;
|
||||
window.addEventListener("resize", () => {
|
||||
if (!v_adaptiveTicking) {
|
||||
v_adaptiveTicking = true;
|
||||
requestAnimationFrame(() => {
|
||||
v_adaptiveControls.forEach(cbid => {
|
||||
v_adaptiveStyle(cbid);
|
||||
v_labelSpanResize(cbid);
|
||||
});
|
||||
v_adaptiveTicking = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,22 @@
|
||||
<script type="text/javascript">
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
setTimeout(updateElements, 200);
|
||||
function updateElements() {
|
||||
document.querySelectorAll("input.btn.cbi-button.cbi-button-up").forEach(function(btn) {
|
||||
btn.value = "<%:Move up%>";
|
||||
});
|
||||
document.querySelectorAll("input.btn.cbi-button.cbi-button-down").forEach(function(btn) {
|
||||
btn.value = "<%:Move down%>";
|
||||
});
|
||||
document.querySelectorAll("div.cbi-value-description").forEach(function(descDiv) {
|
||||
var prev = descDiv.previousSibling;
|
||||
while (prev && prev.nodeType === Node.TEXT_NODE && prev.textContent.trim() === "") {
|
||||
prev = prev.previousSibling;
|
||||
}
|
||||
if (prev && prev.nodeType === Node.ELEMENT_NODE && prev.tagName === "BR") {
|
||||
prev.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -0,0 +1,241 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
|
||||
<div class="cbi-section">
|
||||
<h3><%:Backup and Restore%></h3>
|
||||
<div class="cbi-section-descr">
|
||||
<%:Backup or Restore Client and Server Configurations.%>
|
||||
<br>
|
||||
<font color="red"><%:Note: Restoring configurations across different versions may cause compatibility issues.%></font>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cbi-value" id="_backup_div">
|
||||
<label class="cbi-value-title"><%:Create Backup File%></label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="btn cbi-button cbi-button-save" type="button" onclick="dl_backup()" value="<%:DL Backup%>" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cbi-value" id="_upload_div">
|
||||
<label class="cbi-value-title"><%:Restore Backup File%></label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" onclick="show_upload_win()" value="<%:RST Backup%>" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cbi-value" id="_reset_div">
|
||||
<label class="cbi-value-title"><%:Restore to default configuration%></label>
|
||||
<div class="cbi-value-field">
|
||||
<input class="btn cbi-button cbi-button-reset" type="button" onclick="do_reset()" value="<%:Do Reset%>" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cbi-value"></div>
|
||||
|
||||
<div id="upload-modal" class="up-modal" style="display:none;">
|
||||
<div class="up-modal-content">
|
||||
<h3><%:Restore Backup File%></h3>
|
||||
<div class="up-cbi-value-field">
|
||||
<input class="cbi-input-file" type="file" id="ulfile" accept=".tar.gz" />
|
||||
</div>
|
||||
<div class="up-button-container">
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="upload-btn" onclick="do_upload()" value="<%:UL Restore%>" />
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="close_upload_win()" value="<%:CLOSE WIN%>" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.up-modal {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border: 2px solid #ccc;
|
||||
border-radius: 5px;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
width: 90%;
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
.up-modal-content {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.up-button-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 250px;
|
||||
}
|
||||
|
||||
.up-cbi-value-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.up-modal h3 {
|
||||
background: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
function getBg(el) {
|
||||
if (!el) return null;
|
||||
const style = getComputedStyle(el);
|
||||
const bgImage = style.backgroundImage;
|
||||
const bgColor = style.backgroundColor;
|
||||
return (bgImage !== 'none' || !/rgba\([^,]+,[^,]+,[^,]+,\s*0\)/.test(bgColor) && bgColor !== 'transparent')
|
||||
? style.background
|
||||
: null;
|
||||
};
|
||||
|
||||
function show_upload_win(btn) {
|
||||
const uploadDiv = document.getElementById("upload-modal");
|
||||
uploadDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
|
||||
uploadDiv.style.display = "block";
|
||||
document.getElementById("ulfile").focus();
|
||||
}
|
||||
|
||||
function close_upload_win(btn) {
|
||||
document.getElementById("ulfile").value = "";
|
||||
document.getElementById("upload-modal").style.display = "none";
|
||||
}
|
||||
|
||||
function dl_backup(btn) {
|
||||
fetch('<%= api.url("create_backup") %>', {
|
||||
method: 'POST'
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error("<%:Backup failed!%>");
|
||||
}
|
||||
const filename = response.headers.get("X-Backup-Filename");
|
||||
if (!filename) {
|
||||
return;
|
||||
}
|
||||
return response.blob().then(blob => ({ blob, filename }));
|
||||
})
|
||||
.then(result => {
|
||||
if (!result) return;
|
||||
const { blob, filename } = result;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
})
|
||||
.catch(error => alert(error.message));
|
||||
}
|
||||
|
||||
function do_reset(btn) {
|
||||
if (confirm("<%: Do you want to restore the client to default settings?%>")) {
|
||||
setTimeout(function () {
|
||||
if (confirm("<%: Are you sure you want to restore the client to default settings?%>")) {
|
||||
var xhr1 = new XMLHttpRequest();
|
||||
xhr1.open("GET",'<%= api.url("clear_log") %>', false);
|
||||
xhr1.send();
|
||||
var xhr2 = new XMLHttpRequest();
|
||||
xhr2.open("GET",'<%= api.url("reset_config") %>', false);
|
||||
xhr2.send();
|
||||
window.location.href = '<%= api.url("log") %>'
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function do_upload(btn) {
|
||||
const fileInput = document.getElementById("ulfile");
|
||||
const file = fileInput.files[0];
|
||||
if (!file) {
|
||||
alert("<%:Please select a file first.%>");
|
||||
return;
|
||||
}
|
||||
if (!file.name.endsWith(".tar.gz")) {
|
||||
alert("<%:Invalid file type. Please upload a .tar.gz file.%>");
|
||||
fileInput.value = "";
|
||||
return;
|
||||
}
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
if (file.size > maxSize) {
|
||||
alert("<%:File size exceeds 10MB limit.%>");
|
||||
fileInput.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = function (e) {
|
||||
const binaryString = e.target.result; // ArrayBuffer
|
||||
const binary = new Uint8Array(binaryString);
|
||||
let binaryText = "";
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
binaryText += String.fromCharCode(binary[i]);
|
||||
}
|
||||
|
||||
const base64Data = btoa(binaryText);
|
||||
|
||||
const targetByteSize = 64 * 1024; // Slice Size 64KB
|
||||
let chunkSize = Math.floor(targetByteSize * 4 / 3);
|
||||
chunkSize = chunkSize + (4 - (chunkSize % 4)) % 4;
|
||||
const totalChunks = Math.ceil(base64Data.length / chunkSize);
|
||||
let currentChunk = 0;
|
||||
|
||||
function sendNextChunk() {
|
||||
if (currentChunk < totalChunks) {
|
||||
const chunk = base64Data.substring(currentChunk * chunkSize, (currentChunk + 1) * chunkSize);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", '<%= api.url("restore_backup") %>', true);
|
||||
xhr.onreadystatechange = function () {
|
||||
if (xhr.readyState === 4) {
|
||||
if (xhr.status === 200) {
|
||||
const resp = JSON.parse(xhr.responseText);
|
||||
if (resp.status === "success") {
|
||||
currentChunk++;
|
||||
document.getElementById("upload-btn").value = "Uploading... " + Math.floor((currentChunk / totalChunks) * 100) + "%";
|
||||
sendNextChunk();
|
||||
} else {
|
||||
alert("Upload error: " + resp.message);
|
||||
document.getElementById("upload-btn").value = "<%:UL Restore%>";
|
||||
}
|
||||
} else {
|
||||
alert("Upload failed with status " + xhr.status);
|
||||
document.getElementById("upload-btn").value = "<%:UL Restore%>";
|
||||
}
|
||||
}
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.append("filename", file.name);
|
||||
formData.append("chunk", chunk);
|
||||
formData.append("chunk_index", currentChunk);
|
||||
formData.append("total_chunks", totalChunks);
|
||||
xhr.send(formData);
|
||||
} else {
|
||||
//alert("Upload completed.");
|
||||
document.getElementById("upload-btn").value = "<%:UL Restore%>";
|
||||
window.location.href = '<%= api.url("log") %>'
|
||||
}
|
||||
}
|
||||
sendNextChunk();
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
}
|
||||
|
||||
</script>
|
||||
@@ -0,0 +1,66 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
<style>
|
||||
.dns-con {
|
||||
padding: 1rem;
|
||||
}
|
||||
.faq-title {
|
||||
color: var(--primary);
|
||||
font-weight: bolder;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.reset-title {
|
||||
color: var(--primary);
|
||||
font-weight: bolder;
|
||||
margin-bottom: 0.3rem;
|
||||
display: inline-block;
|
||||
margin-top: 1.2rem;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dns-item {
|
||||
margin-bottom: 0.8rem;
|
||||
line-height:1.2rem;
|
||||
}
|
||||
.dns-list {
|
||||
text-indent:1rem;
|
||||
line-height: 1.2rem;
|
||||
}
|
||||
</style>
|
||||
<div class="dns-con">
|
||||
<div id="faq_dns">
|
||||
<ul>
|
||||
<b class="faq-title"><%:DNS related issues:%></b>
|
||||
<li class="dns-item">1. <span><%:Certain browsers such as Chrome have built-in DNS service, which may affect DNS resolution settings. You can go to 'Settings -> Privacy and security -> Use secure DNS' menu to turn it off.%></span></li>
|
||||
<li class="dns-item">2. <span><%:If you are unable to access the internet after reboot, please try clearing the cache of your terminal devices (make sure to close all open browser application windows first, this step is especially important):%></span>
|
||||
<ul><li class="dns-list"> ◦ <span><%:For Windows systems, open Command Prompt and run the command 'ipconfig /flushdns'.%></span></li>
|
||||
<li class="dns-list"> ◦ <span><%:For Mac systems, open Terminal and run the command 'sudo killall -HUP mDNSResponder'.%></span></li>
|
||||
<li class="dns-list"> ◦ <span><%:For mobile devices, you can clear it by reconnecting to the network, such as toggling Airplane Mode and reconnecting to WiFi.%></span></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="dns-item">3. <span><%:Please make sure your device's network settings point both the DNS server and default gateway to this router, to ensure DNS queries are properly routed.%></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="faq_reset"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var origin = window.location.origin;
|
||||
var hide_url = origin + "<%=api.url("hide")%>";
|
||||
var show_url = origin + "<%=api.url("show")%>";
|
||||
|
||||
function hide(url) {
|
||||
if (confirm('<%:Are you sure to hide?%>') == true) {
|
||||
window.location.href = hide_url;
|
||||
}
|
||||
}
|
||||
|
||||
var dom = document.getElementById("faq_reset");
|
||||
if (dom) {
|
||||
var li = "";
|
||||
li += "<a href='#' class='reset-title' onclick='hide()'>" + "<%: Hide in main menu:%>"+ "</a>" + "<br />" + "<%: Browser access: %>" + "<a href='#' onclick='hide()'>" + hide_url + "</a>" + "<br />";
|
||||
li += "<a href='#' class='reset-title'>" + "<%: Show in main menu:%>"+ "</a>" + "<br />" +"<%: Browser access: %>" + "<a href='#'>" + show_url + "</a>" + "<br />";
|
||||
dom.innerHTML = li;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,191 @@
|
||||
<%
|
||||
local api = self.api
|
||||
local appname = api.appname
|
||||
-%>
|
||||
<style>
|
||||
#cbi-<%=appname%>-socks .td.cbi-value-field > div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1152px) {
|
||||
#cbi-<%=appname%>-socks .td.cbi-value-field > div {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.shunt-tab-hidden {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1152px) {
|
||||
#cbi-<%=appname%>-socks .cbi-section-table-titles {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1152px) {
|
||||
#cbi-<%=appname%>-socks input[id*=".<%=appname%>."][id$=".port"],
|
||||
#cbi-<%=appname%>-socks input[id*=".<%=appname%>."][id$=".http_port"] {
|
||||
width: 100px !important;
|
||||
min-width: unset !important;
|
||||
max-width: unset !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
var shunt_list = JSON.parse('<%=self.shunt_list%>');
|
||||
|
||||
function go() {
|
||||
var _status = document.getElementsByClassName('_status');
|
||||
for (var i = 0; i < _status.length; i++) {
|
||||
var id = _status[i].getAttribute("socks_id");
|
||||
XHR.get('<%=api.url("socks_status")%>', {
|
||||
index: i,
|
||||
id: id
|
||||
},
|
||||
function(x, result) {
|
||||
var index = result.index;
|
||||
var div = '';
|
||||
var div1 = '<font style="font-weight:bold;" color="green">✓</font> ';
|
||||
var div2 = '<font style="font-weight:bold;" color="red">X</font> ';
|
||||
|
||||
if (result.socks_status) {
|
||||
div += div1;
|
||||
} else {
|
||||
div += div2;
|
||||
}
|
||||
if (result.use_http) {
|
||||
if (result.http_status) {
|
||||
div += div1;
|
||||
} else {
|
||||
div += div2;
|
||||
}
|
||||
}
|
||||
_status[index].innerHTML = div;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
var global_id = null;
|
||||
var global = document.getElementById("cbi-<%=appname%>-global");
|
||||
if (global) {
|
||||
var node = global.getElementsByClassName("cbi-section-node")[0];
|
||||
var node_id = node.getAttribute("id");
|
||||
global_id = node_id;
|
||||
var all_node = node.querySelectorAll("[id]");
|
||||
var reg1 = /^cbid\..*node\.main$/;
|
||||
|
||||
for (var i = 0; i < all_node.length; i++) {
|
||||
var el = all_node[i];
|
||||
if (!reg1.test(el.id)) continue;
|
||||
|
||||
var node_select = el;
|
||||
if (!node_select) continue;
|
||||
var cbid = el.id.replace(/\.main$/, "");
|
||||
var hidden_select = document.getElementById(cbid);
|
||||
var node_select_value = hidden_select ? hidden_select.options[0].value : "";
|
||||
if (!node_select_value || node_select_value.indexOf("_default") === 0 || node_select_value.indexOf("_direct") === 0 || node_select_value.indexOf("_blackhole") === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var to_url = '<%=api.url("node_config")%>/' + node_select_value;
|
||||
if (node_select_value.indexOf("Socks_") === 0) {
|
||||
to_url = '<%=api.url("socks_config")%>/' + node_select_value.substring("Socks_".length);
|
||||
}
|
||||
var html = '<a href="#" onclick="location.href=\'' + to_url + '\'"><%:Edit%></a>';
|
||||
|
||||
var m = cbid.match(/\.node$/);
|
||||
if (m) {
|
||||
html += '<a href="#" onclick="window.open(\'' + '<%=api.url("get_redir_log")%>?id=default&name=' + 'global' + '\', \'_blank\')"><%:Log%></a>';
|
||||
}
|
||||
|
||||
node_select.insertAdjacentHTML("beforeend",
|
||||
'<div class="node-actions" style="display:inline-flex; align-items:center; gap:4px; flex-wrap:wrap; margin-left:4px;">'
|
||||
+ html + '</div>'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
var socks = document.getElementById("cbi-<%=appname%>-socks");
|
||||
if (socks) {
|
||||
var socks_enabled_dom = document.getElementById(global_id + "-socks_enabled");
|
||||
socks_enabled_dom.parentNode.removeChild(socks_enabled_dom);
|
||||
var descr = socks.getElementsByClassName("cbi-section-descr")[0];
|
||||
descr.outerHTML = socks_enabled_dom.outerHTML;
|
||||
rows = socks.getElementsByClassName("cbi-section-table-row");
|
||||
for (var i = 0; i < rows.length; i++) {
|
||||
try {
|
||||
var row = rows[i];
|
||||
var id = row.id;
|
||||
if (!id) continue;
|
||||
var dom_id = id + "-node";
|
||||
var cbid = dom_id.replace("cbi-", "cbid-").replace(new RegExp("-", 'g'), ".");
|
||||
dom_id = cbid + ".main";
|
||||
var node_select = document.getElementById(dom_id);
|
||||
if (!node_select) continue;
|
||||
|
||||
var hidden_select = document.getElementById(cbid);;
|
||||
var node_select_value = hidden_select ? hidden_select.options[0].value : "";
|
||||
if (node_select_value && node_select_value != "") {
|
||||
var to_url = '<%=api.url("node_config")%>/' + node_select_value;
|
||||
var html = '<a href="#" onclick="location.href=\'' + to_url + '\'"><%:Edit%></a>';
|
||||
html += '<a href="#" onclick="window.open(\'' + '<%=api.url("get_socks_log")%>?name=' + id.replace("cbi-<%=appname%>-", "") + '\', \'_blank\')"><%:Log%></a>';
|
||||
|
||||
node_select.insertAdjacentHTML("afterend",
|
||||
'<div class="node-actions" style="display:inline-flex; align-items:center; gap:4px; flex-wrap:wrap; margin-left:4px;">'
|
||||
+ html + '</div>'
|
||||
);
|
||||
}
|
||||
} catch(err) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", () => setTimeout(() => {
|
||||
const hiddenSelect = document.getElementById("cbid.<%=appname%>.<%=self.global_cfgid%>.node");
|
||||
let o_val = hiddenSelect.value
|
||||
const o_hasItem = shunt_list.find(element => element.id == o_val);
|
||||
hiddenSelect.addEventListener("change", function(el){
|
||||
let new_val = el.target.value
|
||||
const new_hasItem = shunt_list.find(element => element.id == new_val);
|
||||
if (new_hasItem) {
|
||||
XHR.get('<%=api.url("update_node")%>', {
|
||||
id: "<%=self.global_cfgid%>",
|
||||
data: JSON.stringify({
|
||||
node: new_val
|
||||
})
|
||||
},
|
||||
function(x, data) {
|
||||
if (x && x.status == 200 && data.code == 1) {
|
||||
window.location.reload();
|
||||
}
|
||||
else {
|
||||
alert("<%:Error%>");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
document.getElementById("tab.<%=appname%>.<%=self.global_cfgid%>.Shunt").classList.add('shunt-tab-hidden');
|
||||
document.getElementById("tab.<%=appname%>.<%=self.global_cfgid%>.Shunt").style.display = "none";
|
||||
} catch (error) {}
|
||||
try {
|
||||
document.querySelector('li[data-tab*="Shunt"]').classList.add('shunt-tab-hidden');
|
||||
document.querySelector('li[data-tab*="Shunt"]').style.display = "none";
|
||||
} catch (error) {}
|
||||
}
|
||||
});
|
||||
let shunt_taboption = document.getElementById("container.<%=appname%>.<%=self.global_cfgid%>.Shunt");
|
||||
if (shunt_taboption) {
|
||||
let shunt_option_list = document.getElementById("cbi-<%=appname%>-shunt_option_list");
|
||||
if (shunt_option_list) {
|
||||
shunt_taboption.appendChild(shunt_option_list);
|
||||
}
|
||||
}
|
||||
go();
|
||||
}, 100));
|
||||
//setTimeout("go()", 1000);
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,197 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
|
||||
<style>
|
||||
/*!
|
||||
Pure v1.0.1
|
||||
Copyright 2013 Yahoo!
|
||||
Licensed under the BSD License.
|
||||
https://github.com/pure-css/pure/blob/master/LICENSE.md
|
||||
*/
|
||||
.pure-g{letter-spacing:-.31em;text-rendering:optimizespeed;font-family:FreeSans,Arimo,"Droid Sans",Helvetica,Arial,sans-serif;display:-webkit-box;display:-webkit-flex;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-webkit-flex-flow:row wrap;-ms-flex-flow:row wrap;flex-flow:row wrap;-webkit-align-content:flex-start;align-items: center;-ms-flex-line-pack:start;align-content:flex-start}@media all and (-ms-high-contrast:none),(-ms-high-contrast:active) {table .pure-g{display:block}}.opera-only :-o-prefocus,.pure-g{word-spacing:-.43em}.pure-u{display:inline-block;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-g [class*=pure-u]{font-family:sans-serif}.pure-u-1,.pure-u-1-1,.pure-u-1-12,.pure-u-1-2,.pure-u-1-24,.pure-u-1-3,.pure-u-1-4,.pure-u-1-5,.pure-u-1-6,.pure-u-1-8,.pure-u-10-24,.pure-u-11-12,.pure-u-11-24,.pure-u-12-24,.pure-u-13-24,.pure-u-14-24,.pure-u-15-24,.pure-u-16-24,.pure-u-17-24,.pure-u-18-24,.pure-u-19-24,.pure-u-2-24,.pure-u-2-3,.pure-u-2-5,.pure-u-20-24,.pure-u-21-24,.pure-u-22-24,.pure-u-23-24,.pure-u-24-24,.pure-u-3-24,.pure-u-3-4,.pure-u-3-5,.pure-u-3-8,.pure-u-4-24,.pure-u-4-5,.pure-u-5-12,.pure-u-5-24,.pure-u-5-5,.pure-u-5-6,.pure-u-5-8,.pure-u-6-24,.pure-u-7-12,.pure-u-7-24,.pure-u-7-8,.pure-u-8-24,.pure-u-9-24{display:inline-block;zoom:1;letter-spacing:normal;word-spacing:normal;vertical-align:top;text-rendering:auto}.pure-u-1-24{width:4.1667%}.pure-u-1-12,.pure-u-2-24{width:8.3333%}.pure-u-1-8,.pure-u-3-24{width:12.5%}.pure-u-1-6,.pure-u-4-24{width:16.6667%}.pure-u-1-5{width:20%}.pure-u-5-24{width:20.8333%}.pure-u-1-4,.pure-u-6-24{width:25%}.pure-u-7-24{width:29.1667%}.pure-u-1-3,.pure-u-8-24{width:33.3333%}.pure-u-3-8,.pure-u-9-24{width:37.5%}.pure-u-2-5{width:40%}.pure-u-10-24,.pure-u-5-12{width:41.6667%}.pure-u-11-24{width:45.8333%}.pure-u-1-2,.pure-u-12-24{width:50%}.pure-u-13-24{width:54.1667%}.pure-u-14-24,.pure-u-7-12{width:58.3333%}.pure-u-3-5{width:60%}.pure-u-15-24,.pure-u-5-8{width:62.5%}.pure-u-16-24,.pure-u-2-3{width:66.6667%}.pure-u-17-24{width:70.8333%}.pure-u-18-24,.pure-u-3-4{width:75%}.pure-u-19-24{width:79.1667%}.pure-u-4-5{width:80%}.pure-u-20-24,.pure-u-5-6{width:83.3333%}.pure-u-21-24,.pure-u-7-8{width:87.5%}.pure-u-11-12,.pure-u-22-24{width:91.6667%}.pure-u-23-24{width:95.8333%}.pure-u-1,.pure-u-1-1,.pure-u-24-24,.pure-u-5-5{width:100%}
|
||||
.block {
|
||||
margin: 0.5rem;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
line-height: 1;
|
||||
font-family: inherit;
|
||||
min-width: inherit;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border: 1px solid rgba(0,0,0,.05);
|
||||
border-radius: .375rem;
|
||||
box-shadow: 0 0 2rem 0 rgba(136,152,170,.15);
|
||||
}
|
||||
.img-con {
|
||||
margin: 1rem;
|
||||
|
||||
}
|
||||
.green {
|
||||
font-size:.9rem;
|
||||
color: #2dce89;
|
||||
}
|
||||
.red {
|
||||
font-size:.9rem;
|
||||
color: #fb6340;
|
||||
}
|
||||
.yellow {
|
||||
font-size:.9rem;
|
||||
color: #fb9a05;
|
||||
}
|
||||
.block img {
|
||||
width: 48px;
|
||||
height: auto;
|
||||
/* float:right; */
|
||||
}
|
||||
.block h4 {
|
||||
font-size: .8125rem;
|
||||
font-weight: 600;
|
||||
margin: 1rem 0rem 1rem 1rem;
|
||||
color:#8898aa!important;
|
||||
line-height: 1.8em;
|
||||
min-height: 48px;
|
||||
}
|
||||
|
||||
.check {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 720px) {
|
||||
.block {
|
||||
margin: 0.2rem;
|
||||
}
|
||||
.pure-u-1-4 {
|
||||
width: 50%;
|
||||
}
|
||||
.pure-u-1-2 {
|
||||
width: 100%;
|
||||
}
|
||||
.block h4 {
|
||||
margin: 1rem 0rem 1rem 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (max-width: 480px) {
|
||||
.block img {
|
||||
width: 36px;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
<fieldset id="_passwall2_status_fieldset" class="cbi-section">
|
||||
<div class="pure-g status">
|
||||
<div class="pure-u-1-4">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAATEAAAExCAYAAAAUZZVoAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAABmJLR0QAAAAAAAD5Q7t/AAAACXBIWXMAAADIAAAAyABj+uetAAAikklEQVR42u3de5gddZ3n8XeSTggEcidcZAZZLFZXShwV2V0ccBSFclwvICpyWW+4OnlW5wFl5DKjQmYVkHUBFxEFBYRhNg94G6ZACCAQkNvDpRJCqFwMIZ10Ounu9DV93z+qOlRO+pw+p05VfX9V9X09j4/Q6T71PSfhk9+p8/39vtNQqgbL9aYDs4ADgfnAQmAJcHD4v8OARcDi8H8HAfOAOeHPzQLGgTFgFOgF+oAeYCfQAbQD28P/bwv/uSP89W5gABj2HVv65VAGmiZdgJJnud5MgnB6E3A08A7gncAJBEFlCg94BngBeAXYSBB6fb5jj0kXp2RoiJVIuKpaQBBUfwWcDHyUYPWUd88DDwBPAquALb5jD0gXpdKnIVZQlusBzAWOAU4EPkkQWmXSC9xJEG7PEgTbsHRRKlkaYgURhtYighXWx4DzgdnSdRnoKeB24BFgne/Yg9IFqeZoiOWY5XpzCO5ffQK4EJghXVMOrQR+AawAXtN7a/mjIZYj4WrrcOBDwN8B75WuqYB+AvwKeF7vqeWDhpjhLNebBhwFnAH8E0Grg8rGvwM/Blb6jt0tXYyanIaYgcIV15HAp4GrpOtRADwIXAM85jt2n3Qx6g0aYgaxXG8hwU35qwkaR5WZ7gSuA571HXtUupiy0xATZrleC/CfgcuAU6XrUQ27BLjNd+wt0oWUlYaYEMv1lgDnEay6VP49SfAX0aO+Y49IF1MmGmIZCu91vRP4LvBx6XpUar4O3OE7dod0IWWgIZaB8C3jacDNBJunVTncCFztO/YG6UKKTEMsRZbrHQCcBfxcuhYl6gHgIuAFPYkjeRpiKbBc7yCCbT/XSNeijLKK4M/FnzTMkqMhlqAwvL6K9nap2tYTfKjzhIZZ8zTEEmC53v7Alwl6h5Sq12rg875jPytdSJ5piDUhvGH/WYJTEZSK62Hga75jr5UuJI80xGIIWyVOIbhhq1RSbgEu9h17u3QheaIh1iDL9d4G3Aa8R7oWVVjfAq7Xs87qoyFWJ8v15gGXEzQyKpUFB7hPb/7XpiE2hfAonDOBf5WuRZXSo8B5vmNvki7EVBpiNViuZwH3AMdK16JK71vAtTojYF8aYpMIR5hdAPxAuhalIgaBE33Hfk66EJNoiFWwXO84ghMJ9peuRakqrgK+q8dnBzTEQuHq6xKCEyaUyoPjtVFWQwwAy/XeSjD1ZqF0LUo16ArgijLfKyt1iIWfPH6F4MgUpfJqO8G9snXShUgobYhZrrcYWA68X7oWpRJyHnB72frKShliluudCDwuXYdSKVgOfMl37B7pQrJSqhAL3z5ehLZOqOI71nfs1dJFZGG6dAFZsVxvPsHsQA0wVQarLNf7nHQRWSjFSsxyvbcTnKqpVNlcD1xY5E8vCx9ilut9kmDrkFJl9TJwsu/YO6QLSUNhQyy8/3UpQR+NUgps37EL946kkCFmud5s4A7gdOlalDLMR3zHdqWLSFLhQizs/3oK+A/StShlqKW+Y98gXURSChVilusdDZSya1mpBl0N/IPv2OPShTSrMCFmud67gdJvhlWqAXcDn/Mde0i6kGYUIsQs1/sgQQ+YUqoxTwMf8B27T7qQuHIfYtpCoVTTWgk6/DulC4kj1yFmud65BJOHlFLNGQT+Mo/j4nK77chyvfPRAFMqKfsBbZbrHS5dSKNyGWKW6/0dcJN0HUoV0BbL9Y6QLqIRuXs7GQbY/5WuQ6mCO8J37C3SRdQjVyEWvoXUFZhS2Tjcd+yt0kVMJTchpjfxlRJxsOkbx3MRYtpGoZSYLuDNvmPvki6kGuNv7IeNrBpgSsmYDzxjuZ6xc1iNDrFwK5F24islywJ+b7lei3QhkzE2xMLN3LoXUikzfBC4yXI96Tr2YWSIhcfp6GkUSpnlCwQHjRrFuBv74YGGq9HzwJQy1Tm+Y98hXcQEo1Zi4ZHSd6ABppTJfhXObjWCUSFGsFTVI6WVMt/jlusdJV0EGPR20nK9TwC/lq5DKVW3YWCh79i9kkUYsRIL50JqgCmVLzOB5eFtIDHiIRZO5i7cGCmlSuI04CLJAkQTNEzwB4EPSNahlGrah3zHFmlMl16JXYQGmFJF8IDlem+SuLDYSiz8iPZxqesrpRK3Hnir79gjWV5UZCUWduRrgClVLEcD38v6opmvxML7YCuAv8n62kqpTHzAd+yHs7qYxErsK2iAKVVkD1mutyiri2W6ErNc763AmiyvqZQScT9wmu/YqV8os5WY5XozgZVZXU8pJepU4JwsLpTl28lLgIUZXk8pJev2LMa/ZfJ20nK944AXsriWUsoozwHH+449ntYFUl+JhW8jn0z7OkopI70bODfNC2TxdvICwNghA0qp1N1qud5haT14qm8nLdezgFfTvIZSKhdS+7QytZVY2NSqo9aUUhB8WnlaGg+c5tvJTwPHpvj4Sql8cS3Xm5P0g6YSYpbrzQPuSv0lUUrlzcVJP2BaK7HLU34hlFLA3Jkz+MF/XMxVx2S2y6dZl1qu95YkHzDxG/uW670NeDmzl0SpEpo7cwafP3IRZy+exfg4fOLFNrbtHpYuq16PA3+d1E3+RFdi4XTg27J/TZQqh7kzZ7D06CX86f3H8Mk5Y4yOjdE2Y3aeAgzgfSR4GGrSbydPAd6T6cuhVAlMhNezH3wb/+Mv5rF1WxuzZs5k8cKF/Mhvky4vjhWW67Uk8UCJPAhAWNADYi+JUgU0d+YMzv3LRXzDWgLAru4eunt6mDVrFosXBVuRn9wpOjGtGWcDtzb7IEmuxD4r91ooVSzRldc3rCWMjY3Rtr2d7p4eZra0sGTxIqZNm8Ztm3YyOJbatsS0/TKJlotEVmKW6+0P3C79iiiVd5UrL4ChoSHa2ncAMGPGDA5ZcjDTpgWfyd35Wod0yc1aClzVzAMktRL7svQroVSeVa68JvT29u0JsGnTpnFoJMC6h0fZ0DcoXXqzrrRcr6kjuppeiVmudxBwnfQroVQeTbbyAhgbH6ejo5OB3bv3fO3wQw9h+vQ31h3LXtkqXX5SLgAui/vDSazEvir9CiiVN9VWXgDDwyNsad1aM8AAVrT1SD+NpFwaTkCLpamVWLgKa+r9rFJlUm3lNaG/f4CdnZ17fe2wQw9hxowZe33t/m3d9IyMSj+dJF1IzC1Jza7Ezpd+5krlQa2VF8D4+DidXV37BtghS2ipCDCAn21sl35KSft23HtjsVdilusdAFwj/cyVMtlUKy+AkdFR2ra3MzY2ttfXD12yhJaWyf8TXdU9IP3U0rAUuKLRH2pmJXaW9DNWylRTrbwm7N49yNZtbfsE2CFLDmbmzMkD7Mq128hva1hNl1uud2CjPxRrJRZ25/9c+hkrZZp6Vl4TJrrvKy05eDGzZs6s+nO/a+2SfpppOhv4aSM/EHcllsoJjUrlVb0rL2Cv7vtKSxYvYr9Zs6r+7Itd/bQPjkg/3TTdGA4XqlvDK7HwpIqbpZ+pUiZoZOUFe3ffVzp40UL222+/mj9/7brt0k85Cx8G7q33m+O8nXwnUN/vmFIF1Wh4QdB937lr16S/tnjhQmbPnj3lYzzd0Sf91LNwk+V6b6r3vLE4IfZd6WeolJQ44TVZ933UwgUL2H//qQPs5o07GCroHf0KhwM24NXzzQ2FmOV6S4CPSz9DpbIWJ7wg6L7ftr36W8AF8+cz54D6xrLetTn3m70bcTHwuXq+sdEb++dJPzOlstTIDftK/f0DNQNs/rx5HDjngLoeq31whE39Q9IvR5bOqrf5te6VWNhWcbX0M1MqC3FXXhB033ft2kVvX3/V75k39yAOOrD+o7S+X5zN3o04kzraLeoeFGK53vuAx6SflVJpaia8oHr3/V7XOOhA5s2d29DjvuvBl+kdGWvoZwpiuu/YNW8ENnJPLPZRGUqZrtnwgqD7vn3nzprfc+CcOQ0H2O9bu8oaYADHAS/U+oa6Qix8b3qq9LNRKmlJhBdU776PmnPAASyYP6/hx77lzzsb/pkC+TrwxVrfUO+N/Y9JPxOlktTMDfuoWt33UfvvP5uFC+bHusaankJu9q7XF8LDJqqaciUWdujrDX1VCEmtvKB2933U7P32Y/HCeCcwL1uztaibvRtxMuBW+8V63k4eCcQ+dVEpEyQZXlC7+z4qOlotjnu3Tn2NEriYJkPs09LPQKm4kg6vqbrvo6Kj1eJ4pqOPnUOF3uxdr7+2XG+B79idk/1izRCzXG8aevy0yqGkwwum7r6PqhytFsf160ux2btepwDLJ/uFqVZiR0lXrlQj0ggvmPzs+2oqR6vF9Vxnf1M/XzDfJmaInSFduVL1SCu86um+rzTZZKJG3bihnWG9ox/1Lsv15vuO3VX5C1VDLPxU8p+kK1eqlrTCC+rrvq+URIABLN9c36qvZE4Cflf5xVorscOBhs+7VioLaYYX1Nd9X2my0WpxvD4wxOaBUm32rtdSGgyxU6QrVqpS2uEF9XXfV6o2Wi2Oq9a2pfbccu7DluvN9h17r4+Ga4XYUumKlZqQRXiNjY3RvmMnQ8PDDf1crdFqcTzaXpjJ3mk4Dngq+oVJX3nL9eYA75WuVqkswgvq776vVGu0Whx3b+mkf7S0m73r8SkqQqzaHch3SFeqyi2pvY316O3tixVgU41Wi+PWcm/2rsc3ww8d96j2V8gnpCtV5ZTVygsa676vNNVotbjW9jReSwkdBuw5JXKfEAtT7pvSVapyyTK8oLHu+0r1jFaL4zurW9HOsLqcAPxm4l8mW4ktIv5QXaUaknV4QWPd95XqHLine truncated
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-2-3">
|
||||
<h4 id="status_node">Core<br /><span class="red"><%:NOT RUNNING%></span></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('baidu', 'https://www.baidu.com')">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAACXBIWXMAABYhAAAWIQG2r6PiAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAJN9JREFUeNrs3d1xFNe6BuD2rn0PEcD41jeSywEwjgA5Ag0RWI4AOQKLCBgisIjAowAoSzfc7sEJHBSBTn9oyRZYgEbq6V4/z1M1pbP3qXOQVvf0967f/ubi4qIDANryH00AAAIAACAAAAACAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAACAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAAAIAACAAAAACAAAgAAAAAgAAIAAAAB88F9NAJ/33Q9/zfofs/Qf12/fPFprFaAG31xcXGgFWi/yu/2PTz8PvvJ/9i4CQf85TZ+VcAAIAJB/0d/rf1x9Hgz0/zZCwar/HPdh4FgrAwIA5NPTPxi46H/Oef9Z9p8jIwOAAADTFP55/+Ow/zyZ6Fd4FWGgDwIrVwMQAKD+wv+p1/3nwIgAIADAdgr/w/7HUf/Zz/RX/LW7nBp472oBAgAMU/wPUq//Qea/aiwYXJgWAAQAuH+vf9l/nhb2q7/oQ8CBK1jMfRYLSR9e/WcBDgEApn8ox7a7x4X+CWf9Z8/agGzup3n3z3kQs+5250KEk/4T0zpX50IIBwgAsMWH9aK7nO9/UPifEtsG533ROHVVJwmQUfRje+jQC0ZjB8ihcIcAAMM+uA/7H88r+7Oe9cVi6epu/d6ZpYIf0y9jjBy9SEHAwk8EALjnAzyK5H6lf54QsL37Zp6K/hRrRWKUZ+GUSAQAUPyFgHEL/2GXx5kQFn4iAIDiLwQ0VPivi4Wfc1MCCABwu4d5LPb7ubE/Wwi4270y6y4Xh+a8LdTCTwQAuMUDfdH/eNnon/+9IrHRvVLKYVBXIcC6AAQA+MwDfd7/+KPhJtBTvH2vf9nlN9x/G0Z6GN1/NAEFPNRb7x1FT3aZTjvk5vtk0V0ewPOk0D/hZfobQACA5Lgr/5CfIex0l3PafFz4H6aFoS8ruE+EAAQASA/3o1T4uLSvQHxc/Psfq66uXSFCAKOxBoBcH+7zru15/8+J9QC7rR8tm47vXXX1jg5ZE4ARAJrt2Xn43exB622TwmHNxf9qJGDX7Y4AQGsOu3Lf7DeGJ2mrW4vFf9Fdjgy1sC5kJQSwTaYAyLF3Z+j/65qbCmj0LIh36To7MRAjAFTPSvfbedBSWzV8EFSMhDkkCAGA6h/yMaxt1f/tPU0jJop/3Z6kHTEwKFMA5PKQj4V/686e/02dvX3zaLfi+yL+tj9d5g9+cmQwRgCo0YHifyc7te4bv7bVj0vLdDImCABU1fv3fvS7O6z0nnAK5Mea3wKKAIDePx97XOEoQPT8bQX9t2a3gDI8awDIoae3FgDu7d3bN49mldwT0cvdd0k/y2mQGAFA75+PRgH2Kij+C8X/q0wFIABQhYUmGDRMlVz8Y9Gf7W6388RLgxAAKL23Z5532KIwK/j3X3ZGgzZxlKbQQABA758ydwT0hSx+b4dAbeZBV+EOEMZjESBTPfCjp/o/LTG487dvHj0s7F5w2M/9fGtBIEYAKImtTFvqFRY4N7x02e7FKAACAEXZ0wTa1tD/IPa9NhgBgFIe+vGwsvhve56WsDgsTQMZCTIKgABAQxaawChAd7nlz6r/4ULfTDMgAKA4kXUbp9cYP3WZjAIgANAIw/+j9ghzngZw4M/w9o0CIACQs7kmaLut0y4FC/+2Y6EJEADIleF/YevQpREAEABozxNN0G7Ycvzz1lXxUigEACqTFn4xbjGY6f0bBQABgKkJAOPL5oAYvf/RPPWSIAQABAByanM90/GYBkAAQG9Um2fR+48gYv2HAIAAQGvS/n+nvo0vl6Kr9z8uhywhAJCNmSaYNHxN+e/HfPS+KzF6uxsFQAAgC4b/2w1fev/TmGsCBAA8jISvKXnjn+8cAgANsy2pwQDg3Q+T2rEdEAGALB5GmqDJ8LXQ/G2GPwQA6LyhbHJT7gQQAKY11wQIAExJAGgz+EXxsfXTCAACAA0zD5lHMR6bbWjCNwIAeiE0SACYnrU3CADQuFFHYaz+z8fUB0EhAKD4MK2xi8Bck/v+IQCAHkh7DP/7/iEAAA3y5j8jAAgAQEsm2nEACADADWYj/lsCQF5cDwQAEABGYc4ZBABAjxMQAICqpfc+OP4XBACgMTNNAAIAkI/3I/07c00NAgCQj1MjAIAAAAgAIAAADMYJgCAAwN/WmgAm814TIAAgALgGW+MI4GydagIEABAAAAQAFB8G5Qhg3z8EAPAAyswYw8BeO+v7hwAAH7EIaWJv3zxyDXz/QABg9OJjEdK03o3078w0te8fAgBMVYT4t7UA0KwzTYAAQCtFiH/TA/S9AwEARUgRwPcOBAAUIUUA1x4BADyI6vP2zaOVVvC9AwEARagtFl82fO37791aMyAAkAMrkscneLn2IADggdQgQ8C+byAAoBgpArj2IADggVS785FPgVtr8myY/0cAIB/pgWRRWr2BS8HJx7EmQADAg0kAwLUHAQAPJmFra7x1Lg8x9SNoIwCQFw+m0ZxNMAdskWebwQ8BAG7ttSbYupUmEABAAMADqj3Lsf9Bpz1mwfA/AgACQMPejbz976MCpPl9txAA4HM9xVgs9kpLbM3RhP+2dQDtXnsEANBTabhtBYDpTDnyQ4H+qwmm990Pf+32Px5+6aGaes01jQIc9393HAr02B0wqNcTnwC3dgn0/hEA+HehjyI/T5/d9Hlwy//b+HGSHrCR8lcVpP1l/3nuzhi8TaekBzqN8wyuPYX55uLiQitst+jP+h+L/rPXf3a28KWP4d7jElf+pkD0f+6SwcQQ8CyD6+qhMr5X/bVfaAaMAORR2KLgH2yh6F8Xowf78en/zaswcFTKyEBMa/S/96v0N1B+7/9KjFQ9cTlGdagJ2JRFgAMX/v4TX8R1/3m55eL/uTDwZ/87rPrP3IOrKREAc5kDNg0wfu9/rRkQAKYr/leF/3l3y3n9LYre1x8lBIH04DpxB93bUUYLRVcux6iWmoC7sAbg/oV/nr6AOa9mj6N3D3LtJaQ2/MPddK/e/yyXAGBtx6hO+us+1wwYARj5Idd/jlPhyn0r29P+c5pGKXIcBVgZBaim93910JPrOY5DTYAAMH6PdZ0KayliWuJ5/7ufpp0JuTlwZ92595/j/m8HPW3fa+9fQAAYt/gfpF7/g0L/hJ00GrDIbBQgFo45Hrjw3r8AIDRTDmsAbl/4H6aeVk1b1rLaO5zaeF1wuBpbFvv+v3A9T7txd8K05EV/7QUAjACMVJhWXX371ffTlMDDTEYB3neOM93EIvPfz7Xcjpj2OdQMCADjFf9aezLxd61yWRfQh4B4sJ25876qhPlf0wDbcVjbu0EQABT/aUPAaXopkZ5tGT3A7NvIa5+3Irb9GVlBAFD8B/UgjQRMHgLSgsBf3YWfD0gF9QCXLtegzPsjACj+1YeAw85UwE1elfTipzRN4ToO49cK3gCKAJC9o67d1ctXISCHhYHxQqVzt+Pf3hXaAzRkfX9nKRSDALDF3n9tW/2KDQHp6OKFu/KDCEJ7JS7+6n/nZQov3P3a+x4gAGy5+EeP82ct8UGMgEw+1JyGu1+4HB/e5VDy8K/ea7vXnkw5COif4j/rLl9j6hCaj2Vx4Eh678JT16Do79i6y/+9GbnJ6rAujADU6ljxv9HPmRwbHL9Di4vJXlV04ptCtpm43636RwDYcs/ksHNk6ZccTb0zIM19zxsLASc19f689XEjxa75QAAoqfhHYXvuVviiGBlZZlBAWgoB8TfuVfh3GQW4nb20CBYEgC1aaoJb2Uk7JISAcYr/vMbeXypqDnn6smde88sYml4EmF7t+5vbYCM/5vBwqviwpiYWfXlT4Gf94qhfjACMU0AO3QIbW2bSk7waCXhdUdu+aGjFt0Oebg5/ij8CwAii+Fv1v7nHadFkFiGg/0QhqWFI+VlL73dPUwFWuH9c/BeagTE1OQWQ9vz/z+W/l29zWqSUDnFaFhjq4oS8vVYPeknrSlo/fEvxxwjAyL1/7ierocp0YmAEu5K2mcUJh7stn/KWRj1a3hr4QvHHCIDef4l+zHG1cjq46Cjj0YDo9S+s9P77erX69s1n6T0JYARA719bDtSrXKbRgNzeIRCL3uKVrjPF/6Pr1dohT3Ef/KT4YwRg/J7G/7ns7fRi0ohPBJUp3/AYD/wYkThyslvzIwFNr/lAAJjyAROFwKl/Az/QokdbwLWP3zHmmxfdeFMD8bBfKvxCQBJbVhc13QvpJNW4Zlc/PxV/66kRLwEgh5t13Xkb2Tb8lBbhlXIfRAiIXQPzLYSB6O1HWxyX1CYZhoBouycV/VnFH/CTQvQ8fXbvENJOrn031u50AWDsh/5Ll3wr4qU180Lvi/knD7VNA0EU/NPUaz02tDvotalhi+BZ6vWfFnoN4juxSN+PnYHbZSkMCABj3cgtv09+DD/WMMyXep/XhzVvcvV3nhra3/r1KPV8hw/rPvr747DQ70C0+0E3zlTM69RWK3e8ALCNG3rW2fq3bQ4zYZvf3wgBpUwJREE7KK1nm9p5kQr/FIEr1swc2h0hAAx9Y3vpzzi+NZzHFr/HUZxyPt/hJBWwVWHt+jAV/VwWSAsCI2nlHAA9U+1M4a6d7xDvfsjpRUJR+GMKbF5g8Y/Cv+7y2h0VC7VfxqLtNAWEEYA73+DxwDD8P1JyL2FLIFV8r696rVMNV4dX3eXc9WmB7RfrWyJQlbDdMgLWgQW2AsBdE67h//H8aDEPI3/HF93lorUxFvnG/P7VVrb3hbbXYVfmeShxsuehhbcCwCY3u9X/I/eKLAZkwlGBq/Md4Line truncated
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-2-3">
|
||||
<h4 id="status_baidu"><%:Baidu Connection%><br /><span id="_baidu_status" class="red"><%:Touch Check%></span></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('google', 'https://www.google.com/generate_204')">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAgAAAAIACAYAAAD0eNT6AAAACXBIWXMAAAsTAAALEwEAmpwYAAAgAElEQVR4nOzdeXxU9b3/8ffnzEwWEsKaBHFB3AC1bknYVAxJQMEdCFqt1qW2t7Z166KC2vTn0tra9rb3ardb8bqyuFRtrYUkREURAu6AClVE2RIIS/ZZzuf3B9prNSEzyTnzPWfm/Xw8fNQHzJx5WR/y/eR7lhEQkS9pUVFo54CMAkW4UCGFIjLYFuRBpb+InQcbeRDpD0WeyGd/r7kKhPB/fwW7+PsgABtABED4S38pwpB/+7VOgbYqrCYRbVJFk0CbVK0mO6BNlmpT1I41hTr7NeW//HJzsv9/IqKuiekAIvqyhtKSYXbQOjRgx0aqJSNg63CIFAAoBHTfgg8Mgv/+G44AaAKwDcAmAJtU9CNR2aSQTQENbBoyaflWqYJtNpMo9fntDw+ilKCVx2Rs35N9lBXDaKh1uFj2oapyKBSHQjACQLbpRoMiADYD2CSfDghq4z1LA2sjudF1w59d3WY6kCgVcAAgctG2qcflBCLZo22JHS0iYwRytELHADgcQMB0nw/ZAD4CdK0AaxTWWrGwJiYd64YtfqvVdByRn3AAIHLIltKioYFgoEjULoJKEQQnARgB/neWDCqQTSpYA+jbCqwMRq2VQ+pWfGI6jMir+AcTUS9snXZifqAzUAxLiqA4SYEiAQ4x3UVfsgXACghWilorNNZSX1C3psV0FJEXcAAgikPDlHFHImafApFTAD0FwFGmm6hXbAHWKWSFiK6wY7Ki4LSVb/OiQ0pHHACIvkArKwM7dmw6ARI7VUVOAXAygGGmu8gdCjSJok4FNRakOr9m5fumm4iSgQMAEYDtFSWHATjdUp2qkMkABphuImM+AVCjqjVWIFaTv+T1LaaDiNzAAYDS0s5p4/KiYS2zVKeqYCr2XZVP9GWKdyGoUUi11RGq4cOMKFVwAKC0saNi/BgbsXOhciag47HviXdEcVMgLECtQP+iMXm6oK5+m+kmot7iAEApSwFpKCuZYImeq5BzAYwy3UQpRQGsUMhTYslTBUtWrDcdRJQIDgCUUtZPOyJzQMfAchU5TwRngxfvUdLoOkD+Yqn+ZUjtqnrZNyAQeRYHAPI9rawMNDZtnCIiF6nqeQD6m26itLcZgkVi2w/l165+zXQMUVc4AJBvbS8rmSiCiwBUAigw3UPUFQXWiuChgIVHhiyu/9h0D9FnOACQr2ydXHJMwMLFAC4EMNJ0D1ECbEBfgMpD0pnxOO8mINM4AJDnNVUUDYiodbEAVwI4yXQPkQPaATwNWx/K19zFUlcXNR1E6YcDAHlWQ/nYUwH7G4BUIr2/HpdS23Yo5kUR/f3w2tc/Mh1D6YMDAHnKtqkTC8QOf11UrgRv26P0YgP4G4D78mvq/8G7CMhtHADIExoqik4BrGugOA9AyHQPkVEiG0T196HOzPsHLlu2y3QOpSYOAGSMVh6T0bAr50JRvRY8t0/UlXYI5kvMujd/6YrVpmMotXAAoKRrKC0ZpgF8W4BvASg03UPkE/UC/GZoLGcBLxokJ3AAoKRpnDyuCJZ9nQ3MFiDDdA+RT22E6i8720N/Pnj58nbTMeRfHADIdY3l4yYD9hwFKky3EKUMRSMs/DYUjfz3oLo3dpvOIf/hAECuUEB2TC4+Wy25GcB40z1EKaxZgT9aVvRX+Ute32I6hvyDAwA5SisrAzuaNl6oipsgONZ0D1G62PdVxfqQwPp5fs3K9033kPdxACBHaFFRaMdAuUIhPwJwmOkeojRmQ/CEhcCPh1a/us50DHkXBwDqE62sDDTu3HgJBLeBz+Yn8pIYgIdjsUDVAXWvbjQdQ97DAYB6RQFpLC+ZDeAn4BP7iDxr36kB/Akx3FFQV7/NdA95BwcASljj5OJz1MLtgBxnuoWI4tYGwX9ldGTezacLEsABgBKwbUpJudi4S4CxpluIqNd2Q+UeO9jxn8MWv9VqOobM4QBAPdpRMX6MDfseqE433UJEjmmAyB350X6/45MF0xMHAOrW1mkn5gc7gz9RwVUAgqZ7iMgNuk41cF1h7YrFpksouTgA0Jesn3ZEZl548LUCnQNggOkeInKfCp6Bbd9QWLv6n6ZbKDk4ANC/aSgvuQDAzwAcajiFiJJMgbAIfo1o2x0FdWtaTPeQuzgAEABg6+SSYwIW7gMwyXQLERm3VUVuKqhe+ZAAajqG3MEBIM1tm3pcTiCW9WOFXgcgZLqHiLxDRFaIbV8ztHbVStMt5DwOAGlse0XJDFH8J4CDTbcQkWcpIPMyOjN+wOcHpBYOAGloe0XJYQL5L97WR0QJ2A7g2oKa+gWmQ8gZHADSyL4v7LFuUmAOgCzTPUTkS38NBHD1kMX1H5sOob7hAJAmGsuKTlKReXx8LxE5oBmqc/InrbpPqmCbjqHe4QCQ4tZPOyJzQHjQjwH8EHyYDxE565UArKuG1KxYazqEEscBIIVtqxg33tLY/YCMMd1CRKlJgTCAnxUMbrtTFq0Jm+6h+HEASEEfT5iQndEvcodArgNgme4honSg68SyLs1fsnKV6RKKDweAFLO9fOwEgf4vgCNNtxBR2olC8JP8QYf+VBYtipmOof3jAJAitLQ02BBovVWAuQACpnuIKK29ooJLCqvrPzAdQt3jAJACtpcVHS5iPQxgvOkWIqJPNUNxbUFt/TzTIdQ1DgA+11BRfAXU+g2guaZbiIi68EQY8q2DalbuNB1C/44DgE99Uj52SIbqHyGYYbqFiKgHW1WtywprVyw2HUL/hwOADzWWj5ussB8GMNx0CxFRnBSq/91q5/5oZF1dh+kY4gDgKwrIjvKSWxT4MXihHxH50xuq9qzC2tX/NB2S7jgA+MSW0qKhwYD1MIDTTbcQEfXRHoVcVliz8i+mQ9IZBwAf2F5WMlEECwAcZLqFiMgpCvyyIJZzk9TVRU23pCMOAB63vazkByL4KfgcfyJKSbJMrMgF+Ute32K6JN1wAPCopoqiARFYD4riHNMtREQu226pXDS0dmWt6ZB0wufEe1Dj5OJRUbVWcvEnojRRaIsubigbO1f5g2nS8P9oj2mYXDwdljwKYIDpFiKipBN5LqMj42sDly3bZTol1XEA8JCGsrE3QvQucGeGiNLbeoGclV+z8n3TIamMA4AHfDxhQnZGdvR/RHCR6RYiIo/YrYLKwur6atMhqYoDgGE7S8cdFAvaf4GiyHQLEZHHRAG9rqBm1b2mQ1IRBwCDGqeMLVZbnwUwzHQLEZGH3Zcfy7mWzwtwFgcAQxrLSs5WwXwA/Uy3EBF5nQDVoc7M2bw40DkcAAzYXl5ytQC/BZ/nT0SUiPcFcjYvDnQGrzZPIgWksXzszwW4F1z8iYgSdZSKvrq9oqTCdEgq4ONlk2T9tCMyGzsHPQjobNMtRES+pcgFJNd0RirgKYAk2HP6hMGd0djTgJ5iuoWIyMciCpnNbxF0BgcAlzVOOXG4bQeXCHC06RYiIh/j4u8wDgAu2l5WdLiItQTASNMtREQ+FrFFK4dVr3radEgq4UWALtk2tfhYEWsZuPgTEfUFF3+XcABwwbbJReMkJi+AD/ghIuoLLv4u4q1oDts2paTcgvWc8Nv8iIj6IiK2ziqsXfWM6ZBUxR0AB22rKD7XsvE3QHmLChFRLykQFltn5S/l4u8mXgTokIby4lmAPAY+W4GIqNcUCFu2VnLxdx8HAAdw8Sci6jsu/snFawD6aHtF8UyBzAcXfyKiXlMgbClm5S9d9azplnTBHYA+2F5RPFOUiz8RUV/8a/Gvrefin0QcAHppe0XJDFEsABd/IqJe4+JvDu8C6IVPF3/+5E9E1AcKhC3ITC7+ZnAHIEGN5WPPUuiTAEKmW4iI/Opfi3/Nyr+abklXHAAS0Dil5DS18TyALNMtRER+xcXfGzgAxKmxrOgkFWspgDzTLUREfqVA2BKZkV+98m+mW9IdB4A4NE4uHqUiL0GQb7qFiMivuPh7Cy8C7EFTRdEhaskSLv5ERH3SycXfW/ggoP3YNnVigdq6FMBhpluIiHysU0RmcvH3Fg4A3dg5bVweorFqAMeabiEi8jEu/h7FUwBd0NLSYCwSWwTgRNMtREQ+1glbue3vURwAutAYaPk9VKaa7iAi8rFO2DqjYOmq50yHUNf4JLsvaCgbOxfQK013EBH5WCdUzy9YuurvpkOoe7wN8HMay0suVuBh0x1ERD62b/Gv5eLvdRwAPtVYUVxqq/xDgAzTLUREPsXF30c4AADYUTF+jK2xVwAMNN1CRORTXPx9Ju1vA/ykfOwQC3YdgANMtxAR+VQngPMKalc9bzqE4pfWdwFoaWkwBF0IYKTpFiIin9q3+NfUc/H3mbQeABqtll8JUGa6g4jIp7j4+1jaXgPQUFZyOQT3m+4gIvIpLv4+l5YDwLaKceMttesAZJpuISLyoQ4VOa+weuU/TIdQ76XdANA45cThagdXgRf9ERH1Bhf/FJFW1wCsn3ZEpm0HnwIXfyKi3uDin0LS6lHAeeFBvxVgrOkOIiIf6lC1zi2sWbHYdAg5I21OAfAxv0REvbZv8a/l4p9K0mIA2DG1aLQds1YByDHdQkTkM1z8U1TKXwOw5eyifnbUWgQu/kREieLin8JS/hqAYJt1LwTHmu4gIvKZDoWeU1i7YonpEHJHSu8ANJSVXA7gMtMdREQ+067QcwprVnHxT2Epew3AtqnFx1oxWQkg23QLkcdEAezZ95d2AohAJAJFBEBEgYgAFhQhFYQEyBAgpEBIIJkKHQBgAFL4z480167Qc7n4p76UPAXwYWlpViDWOl+5+FP62AvgQwBbAdkCsbeqyhaobIVgW0Cs3SqdeyJZ1u7hz65u6+uHaRWsPdWnDOjM6hxk2fbAWMAaFLA131Y5SAQHA//66yAAheCw4Bdc/NNISv5H2VBe8hsA1Line truncated
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-2-3">
|
||||
<h4 id="status_google"><%:Google Connection%><br /><span id="_google_status" class="red"><%:Touch Check%></span></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-1-4 check" onclick="check_connect('github', 'https://github.com')">
|
||||
<div class="block pure-g">
|
||||
<div class="pure-u-1-3">
|
||||
<div class="img-con">
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAABAAAAAQACAYAAAB/HSuDAAAAAXNSR0IArs4c6QAAIABJREFUeF7s3Qe0JUXVsOH3J0hWQHKOKgaSgBIEyaIiiIIoIEhSMGFCwICZIOYAgqgfoEg0AZIEySoCghJU/ADJWTIMMPxrS1+/6zAz94Tu09VVb611153QXbX3U+fOnN6nu+r/YVNAAQUUUECBNgRmBOYe9zVP9evZgVmBuYCZqz+L73MCs03n78bn8P+q86bMK/qIvsa3J4GHpwLwL+CZKf48jovj4+/i+0PA48BjVR9T+7tHq+Pvr77HufH1dBvojqmAAgoooEDJAvEGwaaAAgoooIACwwm8AFgImB9YsPr1AsDYRf34C/2xX8cFfsktigdjxYApv0ex4C7gDuBO4G7gduDBksHMXQEFFFBAgWEFLAAMK+j5CiiggAK5CsQn9AsDSwKLjbuojz+LC/24wB/7dXxib2teIO42GCsGRIEgfn1b9T2KBbcAN1XFAu8waH4+HEEBBRRQoGMCFgA6NmGGq4ACCihQm0B8Or9IdRG/DBBf43+/BDBTbaPZ0agF4i6C/62+4u6BKBTE78d+fSMwedRBOZ4CCiiggAJtClgAaFPfsRVQQAEFmhR4HrAUsNy4r2WrX8fFvZ/aN6mfft9xN0HcLfAP4PpxX/H7KA5MSj8FI1RAAQUUUKA/AQsA/Xl5tAIKKKBAWgKzAMtXF/VjF/fjL/LjNn6bAv0KxOMD/6yKAuMLBPHrvwNP9NuhxyuggAIKKJCCgAWAFGbBGBRQQAEFJhKIT/PjQv+lwMvGfX8x4EX+RHr+fd0C8RjB1cA1475fATxS90D2p4ACCiigQJ0CFgDq1LQvBRRQQIFhBbzQH1bQ89sUsDDQpr5jK6CAAgpMKGABYEIiD1BAAQUUaEggts57BfDKcV9+ot8Qtt22KhCFgcvGff2+2uaw1aAcXAEFFFCgPAELAOXNuRkroIACbQjE6vpxoT92C3/8egXA/4famA3HTEFgrCgw9ihBFAjikYJnUgjOGBRQQAEF8hTwjVee82pWCiigQJsCCwGvqr5WA1YB5mszIMdWoCMC9wCxlsAfgbhLIL7u6EjshqmAAgoo0AEBCwAdmCRDVEABBRIWmBlYEVhn3G38frKf8IQZWucExu4UuBC4qHqM4LHOZWHACiiggAJJCFgASGIaDEIBBRTojMDYrfxrj7von7Uz0RuoAt0XeAr4GzC+IOCjA92fVzNQQAEFRiJgAWAkzA6igAIKdFIgtteLT/fXBdarLvjn72QmBq1A3gJ3AxcA5wPnAVcBk/NO2ewUUEABBQYRsAAwiJrnKKCAAnkKxAX/ytWFfnzCvyEwb56pmpUCWQs8DPyuemQg7hSIwsCkrDM2OQUUUECBngQsAPTE5EEKKKBAlgIzASsBG1UX/fEc/9xZZmpSCpQt8Ajwp+qxgbOr74+XTWL2CiigQJkCFgDKnHezVkCBMgVmqD7h3xiIr/iU3+f3y3wtmHXZArGIYCwoeFb1FcUBtx8s+zVh9gooUIiABYBCJto0FVCgWIHYku811af8bwRiET+bAgooMF4gth88F4i7A84AbpJHAQUUUCBPAQsAec6rWSmgQLkCcwBrVhf8cWv/qoD/1pf7ejBzBQYR+N+qGBAFgTOBBwbpxHMUUEABBdIT8E1henNiRAoooEC/ArFw3xuATauL/3i236aAAgrUIfAkcEl1Z8CpwJV1dGofCiiggALtCFgAaMfdURVQQIFhBOK5/Viwb3NgS2CJYTrzXAUUUKAPgTuruwJ+BfwaiB0HbAoooIACHRGwANCRiTJMBRQoXmAB4HVAPMcf3+cqXkQABRRoW2BsMcFTgJOAW9oOyPEVUEABBaYvYAHAV4gCCiiQrsDLqgv++KR/LZ/lT3eijEwBBf4tcA0QdwZEQeBiYLIuCiiggAJpCVgASGs+jEYBBcoWiG364kJ/a+AtwKJlc5i9Agp0WOBu4HTghOp7rCVgU0ABBRRoWcACQMsT4PAKKFC8wIzVwn1x0b8NENv22RRQQIGcBO4FTquKAbHN4KSckjMXBRRQoEsCFgC6NFvGqoACuQjEIn4bV7f3vxmYP5fEzEMBBRSYQOBfwFnVYwKxbsAjiimggAIKjE7AAsDorB1JAQXKFpij2qovbu1/PTBn2Rxmr4ACCvBQdWdAFALiDgGLAb4oFFBAgYYFLAA0DGz3CihQtEDc3r8+8E4gPun3or/ol4PJK6DAdARiR4HfAEcBv/AxAV8rCiigQDMCFgCacbVXBRQoW+CV1UX/tkBs32dTQAEFFOhd4P7qEYEoBkRR4JneT/VIBRRQQIHpCVgA8PWhgAIK1CMQW/bFQn47AMvU06W9KKCAAsUL3AKcXC0geGHxGgIooIACQwpYABgS0NMVUKBogcWBrapP+1ctWsLkFVBAgeYFrqkKAccA1zc/nCMooIAC+QlYAMhvTs1IAQWaFYjn+OOT/p2BtQH/HW3W294VUECBKQXikYC4G+AHwInAwxIpoIACCvQm4BvX3pw8SgEFFIjn+ncH3g7MJYcCCiigQBICsXjgKcDhrheQxHwYhAIKJC5gASDxCTI8BRRoVWCR6pn+XYDlW43EwRVQQAEFJhL4J3AscBhw40QH+/cKKKBAiQIWAEqcdXNWQIHpCcwCbFJd+MfWfTPJpYACCijQKYHJwDnA0dUjAo92KnqDVUABBRoUsADQIK5dK6BApwRWrG7xfwcwT6ciN1gFFFBAgWkJ3Af8pHpE4M8yKaCAAqULWAAo/RVg/gqULRCf9r+puvDfqGwKs1dAAQWyF7isKgTEnQGxdoBNAQUUKE7AAkBxU27CCigALAvsVq3kP78iCiiggAJFCfwLOB74OnBtUZmbrAIKFC9gAaD4l4AAChQjMAOwQfVp/1bAjMVkbqIKKKCAAlMTGFsrIHYQ+BnwlEwKKKBA7gIWAHKfYfNTQIGFgB2BPYAl5VBAAQUUUGAqArcDRwHfBWI3AZsCCiiQpYAFgCyn1aQUUABYG/ggsCUwsyIKKKCAAgr0IPAk8HPgG8BFPRzvIQoooECnBCwAdGq6DFYBBSYQiAv9uODfC1hLLQUUUEABBYYQuBz4XnVnwOND9OOpCiigQDICFgCSmQoDUUCBIQTmrm7z/wiw+BD9eKoCCiiggAJTCtwJHAZ8G7hHHgUUUKDLAhYAujx7xq6AAi8C3gvsCswuhwIKKKCAAg0KPFHtHvBl4M8NjmPXCiigQGMCFgAao7VjBRRoSGBsNf94vv8NgP+ONQRttwoooIAC0xSI9QFinYCTgad1UkABBboi4BvnrsyUcSqgwCzVbf4fBZaXQwEFFFBAgQQE/gYcUq0TEHcI2BRQQIGkBSwAJD09BqeAAsBcwM7Ax4BFFVFAAQUUUCBBgbuAQ6u7Au5PMD5DUkABBf4tYAHAF4ICCqQqsACwJ/ABYJ5UgzQuBRRQQAEFxgk8BPwQOBi4VRkFFFAgNQELAKnNiPEooMDS1TZ+uwGzyaGAAgoooEAHBSYBxwFfBP7awfgNWQEFMhWwAJDpxJqWAh0UWAmIbfzeDszUwfgNWQEFFFBAgSkFJgOnAZ8H/iCPAgoo0LaABYC2Z8DxFVBgPeCTwEZSKKCAAgookLHAmdUdAednnKOpKaBA4gIWABKfIMNTIGOBdYD9vfDPeIZNTQEFFFBgagKxheBBwK/kUUABBUYtYAFg1OKOp4ACceH/GWBDKRRQQAEFFChYwEJAwZNv6gq0JWABoC15x1WgPIG48P8ssEF5qZuxAgoooIAC0xSwEOCLQwEFRiZgAWBk1A6kQLECceH/OWD9YgVMXAEFFFBAgYkFLgYO9NGAiaE8QgEFBhewADC4nWcqoMD0BeLCP1Y9fq1QCiiggAIKKNCzwCXAARYCevbyQAUU6EPAAkAfWB6qgAI9CawFfAmI1f1tCiiggAIKKDCYwO+A/YBzBzvdsxRQQIHnClgA8FWhgAJ1Cbwc+DSwdV0d2o8CCiiggAIKcDbwceByLRRQQIFhBSwADCvo+QoosBSwL7ArMIMcCiiggAIKKFC7wDPAicAngb/V3rsdKqBAMQIWAIqZahNVoHaBxYBPATsDM9Xeux0qoIACCiigwJQCTwHHVnfc3SiPAgoo0K+ABYB+xTxeAQXmBfYGPgDMJocCCiiggAIKjFxgEvCjqhB/18hHd0AFFOisgAWAzk6dgSswcoE5gQ8DHwGeP/LRHVABBRRQQAEFphR4ADgE+BrwiDwKKKDARAIWACYS8u8VUCCe69++2pt4YTkUUEABBRRQIDmB24DPAkcCTycXnQEpoEAyAhYAkpkKA1EgSYENga8AKyUZnUEpoIACCiigwHiBa4GPAafKooACCkxNwAKArwsFFJiawEuAz7mlny8OBRRQQAEFOikQWwfGI3tXdTJ6g1ZAgcYELAA0RmvHCnRSYL5qQaE9Xdm/k/Nn0AoooIACCowJTAZ+XC3ce4csCiigQAhYAPB1oIACIRCr+ceq/vu5wJ8vCAUUUEABBbISiMUBY6HAg4DHssrMZBRQoG8BCwB9k3mCAlkJxL8BscDfF4HFs8rMZBRQQAEFFFBgvMA/gX2BY4FnpFFAgTIFLACUOe9mrUAIrAJ8C1hbDgUUUEABBRQoRuBS4P3A74vJ2EQVUOA/AhYAfDEoUJ7AvMD+wHuBGctL34wVUEABBRQoXmBsfYBYKPDu4jUEUKAgAQsABU22qRYvMEN1u39s6xeL/dkUUEABBRRQoGyB+4HPAt8Gni6bwuwVKEPAAkAZ82yWCqxe/ee+hhQKKKCAAgoooMAUAn+qHgu4UBkFFMhbwAJA3vNrdgosXK36Gwv9+fPu60EBBRRQQAEFpiUQCwOeCMRjATfLpIACeQp4QZDnvJqVAjMDewKfc1s/XwwKKKCAAgoo0IfA2LaBBwBP9HGehyqgQLine truncated
|
||||
</div>
|
||||
</div>
|
||||
<div class="pure-u-2-3">
|
||||
<h4 id="status_github"><%:GitHub Connection%><br /><span id="_github_status" class="red"><%:Touch Check%></span></h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
//<![CDATA[
|
||||
var imgs = document.getElementsByTagName('img');
|
||||
for (var i = 0 ; i < imgs.length; i++) {
|
||||
document.getElementsByTagName('img')[i].setAttribute("oncontextmenu","return false;");
|
||||
document.getElementsByTagName('img')[i].setAttribute("ondragstart","return false;");
|
||||
}
|
||||
XHR.poll(5, '<%=api.url("index_status")%>', null,
|
||||
function (x, data) {
|
||||
if (data) {
|
||||
if (true) {
|
||||
var status_node = document.getElementById('status_node');
|
||||
if (status_node) {
|
||||
var text = 'Core<br />';
|
||||
if (data["global_status"])
|
||||
text += '<span class="green"><%:RUNNING%></span>';
|
||||
else
|
||||
text += '<span class="red"><%:NOT RUNNING%></span>';
|
||||
status_node.innerHTML = text;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function check_connect(type, url) {
|
||||
var s = document.getElementById('_' + type + '_status');
|
||||
if (s) {
|
||||
var div = s.parentNode.parentNode.parentNode.parentNode;
|
||||
div.removeAttribute('onclick');
|
||||
s.innerHTML = '<%:Check...%>';
|
||||
var sendDate = (new Date()).getTime();
|
||||
XHR.get('<%=api.url("connect_status")%>', {
|
||||
type: type,
|
||||
url : url
|
||||
},
|
||||
function(x, rv) {
|
||||
if (rv.ping_type && rv.ping_type == "curl") {
|
||||
var use_time = rv.use_time;
|
||||
if (use_time < 1000) {
|
||||
s.className="green";
|
||||
} else if (use_time < 2000) {
|
||||
s.className="yellow";
|
||||
} else {
|
||||
s.className="red";
|
||||
}
|
||||
s.innerHTML = use_time + " ms";
|
||||
} else if (rv.status) {
|
||||
s.className="green";
|
||||
s.innerHTML = "<%:Working...%>";
|
||||
}
|
||||
else {
|
||||
s.className="red";
|
||||
s.innerHTML = '<%:Problem detected!%>';
|
||||
}
|
||||
div.setAttribute('onclick','check_connect("' + type + '","' + url + '")');
|
||||
}
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
//]]>
|
||||
</script>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,238 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
local appname = api.appname
|
||||
-%>
|
||||
<script src="<%=resource%>/view/<%=appname%>/Sortable.min.js"></script>
|
||||
|
||||
<style>
|
||||
table .cbi-button-up,
|
||||
table .cbi-button-down,
|
||||
.td.cbi-section-actions .cbi-button-up,
|
||||
.td.cbi-section-actions .cbi-button-down {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
vertical-align: middle;
|
||||
cursor: grab !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 100;
|
||||
padding: 0 !important;
|
||||
line-height: inherit;
|
||||
user-select: none;
|
||||
align-self: stretch;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dragging-row {
|
||||
background-color: rgba(131, 191, 255, 0.7) !important;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1152px) {
|
||||
#cbi-<%=appname%>-haproxy_config .cbi-section-table-titles {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1152px) {
|
||||
#cbi-<%=appname%>-haproxy_config input[id*=".<%=appname%>."][id$=".haproxy_port"],
|
||||
#cbi-<%=appname%>-haproxy_config input[id*=".<%=appname%>."][id$=".lbweight"] {
|
||||
width: 100px !important;
|
||||
min-width: unset !important;
|
||||
max-width: unset !important;
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
#cbi-<%=appname%>-haproxy_config select[id*=".<%=appname%>."][id$=".export"] {
|
||||
width: 130px !important;
|
||||
min-width: unset !important;
|
||||
max-width: unset !important;
|
||||
}
|
||||
|
||||
#cbi-<%=appname%>-haproxy_config select[id*=".<%=appname%>."][id$=".backup"] {
|
||||
width: 100px !important;
|
||||
min-width: unset !important;
|
||||
max-width: unset !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
let monitorStartTime = Date.now();
|
||||
|
||||
const monitorInterval = setInterval(function () {
|
||||
if (Date.now() - monitorStartTime > 3000) {
|
||||
clearInterval(monitorInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = Array.from(document.querySelectorAll("tr.cbi-section-table-row"))
|
||||
.filter(row => !row.classList.contains("placeholder")); // 排除无配置行
|
||||
|
||||
if (rows.length <= 1) return;
|
||||
|
||||
const lastRow = rows[rows.length - 1];
|
||||
const secondLastRow = rows[rows.length - 2];
|
||||
|
||||
const lastInput = lastRow.querySelector("input[name$='.haproxy_port']");
|
||||
const secondLastInput = secondLastRow.querySelector("input[name$='.haproxy_port']");
|
||||
|
||||
if (!lastInput || !secondLastInput) return;
|
||||
|
||||
// 如果还没绑定 change 事件,绑定一次
|
||||
if (!lastInput.dataset.bindChange) {
|
||||
lastInput.dataset.bindChange = "1";
|
||||
lastInput.addEventListener("input", () => {
|
||||
lastInput.dataset.userModified = "1";
|
||||
});
|
||||
}
|
||||
|
||||
// 如果用户手动修改过,就不再自动设置
|
||||
if (lastInput.dataset.userModified === "1") return;
|
||||
|
||||
const lastVal = lastInput.value.trim();
|
||||
const secondLastVal = secondLastInput.value.trim();
|
||||
|
||||
const lbssHiddenInput = lastRow.querySelector("div.cbi-dropdown > div > input[type='hidden'][name$='.lbss']");
|
||||
if (!lbssHiddenInput) {
|
||||
if (lastVal !== secondLastVal && secondLastVal !== "" && secondLastVal !== "0") {
|
||||
lastInput.value = secondLastVal;
|
||||
}
|
||||
}
|
||||
}, 300);
|
||||
});
|
||||
|
||||
//节点列表添加拖拽排序
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
function initSortableForTable() {
|
||||
var section = document.getElementById("cbi-<%=appname%>-haproxy_config");
|
||||
if (!section) return;
|
||||
|
||||
hideSortColumn(section);
|
||||
|
||||
// === 插入 drag handle ===
|
||||
var rows = section.querySelectorAll("tr");
|
||||
rows.forEach(function(row) {
|
||||
var btn = row.querySelector(".cbi-button-remove:last-of-type");
|
||||
if (!btn) return;
|
||||
if (btn.nextElementSibling && btn.nextElementSibling.classList.contains("drag-handle")) return;
|
||||
var handle = document.createElement("span");
|
||||
handle.className = "drag-handle center";
|
||||
handle.title = "<%:Drag to reorder%>";
|
||||
handle.innerHTML = "⠿";
|
||||
btn.after(handle);
|
||||
});
|
||||
|
||||
// === 初始化 Sortable ===
|
||||
var table = section.getElementsByTagName("table")[0];
|
||||
if (!table) return;
|
||||
var root = table.tBodies[0] || table;
|
||||
if (root._sortable_initialized) return root._sortable_instance;
|
||||
root._sortable_initialized = true;
|
||||
|
||||
// 保存原始顺序
|
||||
root._origOrder = getCurrentOrder(root);
|
||||
|
||||
try {
|
||||
root._sortable_instance = Sortable.create(root, {
|
||||
handle: ".drag-handle",
|
||||
draggable: "tr.cbi-section-table-row",
|
||||
animation: 150,
|
||||
ghostClass: "dragging-row",
|
||||
fallbackOnBody: true,
|
||||
forceFallback: false,
|
||||
swapThreshold: 0.65,
|
||||
onEnd: function (evt) {
|
||||
updateHiddenInput(root, section);
|
||||
}
|
||||
});
|
||||
return root._sortable_instance;
|
||||
} catch (e) {
|
||||
root._sortable_initialized = false;
|
||||
console.error("Sortable init failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 table 当前行顺序
|
||||
function getCurrentOrder(root) {
|
||||
var order = [];
|
||||
var rows = root.querySelectorAll("tr.cbi-section-table-row");
|
||||
rows.forEach(function (tr) {
|
||||
var id = tr.id || "";
|
||||
if (id.startsWith("cbi-<%=appname%>-"))
|
||||
id = id.replace("cbi-<%=appname%>-", "");
|
||||
order.push(id);
|
||||
});
|
||||
return order;
|
||||
}
|
||||
|
||||
// 拖拽完成后更新 hidden input
|
||||
function updateHiddenInput(root, section) {
|
||||
var newOrder = getCurrentOrder(root);
|
||||
var changed = newOrder.join(" ") !== root._origOrder.join(" ");
|
||||
var hiddenInput = section.querySelector('input[type="hidden"][id^="cbi.sts."]');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = changed ? newOrder.join(" ") : "";
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏18.06 up/down 列
|
||||
function hideSortColumn(section) {
|
||||
var table = section.querySelector("table");
|
||||
if (!table) return;
|
||||
var ths = Array.prototype.slice.call(table.querySelectorAll("tr.cbi-section-table-titles > th"));
|
||||
var dataRows = table.querySelectorAll("tr.cbi-section-table-row");
|
||||
if (!ths.length || !dataRows.length) return;
|
||||
var sortCol = -1;
|
||||
for (var i = 0; i < ths.length; i++) {
|
||||
var hasSort = false, invalid = false;
|
||||
dataRows.forEach(function(tr) {
|
||||
var td = tr.querySelectorAll(":scope > td")[i];
|
||||
if (!td) return;
|
||||
if (td.querySelector(".cbi-button-edit, .cbi-button-remove")) invalid = true;
|
||||
if (td.querySelector(".cbi-button-up, .cbi-button-down")) hasSort = true;
|
||||
});
|
||||
if (!invalid && hasSort) { sortCol = i; break; }
|
||||
}
|
||||
if (sortCol === -1) return;
|
||||
var rows = [table.querySelector("tr.cbi-section-table-titles")].concat(
|
||||
Array.prototype.slice.call(dataRows),
|
||||
Array.prototype.slice.call(table.querySelectorAll("tr.cbi-section-table-descr"))
|
||||
);
|
||||
rows.forEach(function(tr) {
|
||||
var cells = Array.prototype.filter.call(tr.children, function(el) {
|
||||
return el.tagName === "TH" || el.tagName === "TD";
|
||||
});
|
||||
if (cells[sortCol]) cells[sortCol].style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// === 等待 TypedSection 行稳定 ===
|
||||
(function waitStable() {
|
||||
var last = 0, stable = 0;
|
||||
var THRESHOLD = 5;
|
||||
function tick() {
|
||||
var count = document.querySelectorAll("tr.cbi-section-table-row").length;
|
||||
if (count && count === last) stable++;
|
||||
else stable = 0;
|
||||
|
||||
last = count;
|
||||
if (stable >= THRESHOLD)
|
||||
setTimeout(initSortableForTable, 200);
|
||||
else
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
tick();
|
||||
})();
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,30 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
local haproxy_enable = api.uci_get_type("global_haproxy", "balancing_enable", "0")
|
||||
local console_port = api.uci_get_type("global_haproxy", "console_port", "")
|
||||
-%>
|
||||
<p id="_status"></p>
|
||||
|
||||
<script type="text/javascript">//<![CDATA[
|
||||
const haproxy_enable = "<%=haproxy_enable%>";
|
||||
if (haproxy_enable == "1") {
|
||||
function openwebui(){
|
||||
var url = window.location.hostname + ":<%=console_port%>";
|
||||
window.open('http://' + url, 'target', '');
|
||||
}
|
||||
XHR.poll(5, '<%=api.url("haproxy_status")%>', null,
|
||||
function(x, result) {
|
||||
if (x && x.status == 200) {
|
||||
var _status = document.getElementById('_status');
|
||||
if (_status) {
|
||||
if (result.status) {
|
||||
_status.innerHTML = '<input type="button" class="btn cbi-button cbi-button-apply" value="<%:Enter interface%>" onclick="openwebui()" />';
|
||||
} else {
|
||||
_status.innerHTML = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
//]]></script>
|
||||
@@ -0,0 +1,76 @@
|
||||
<style>
|
||||
.hidden {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
</style>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
const normal_list = JSON.parse('<%=self.normal_list%>');
|
||||
function check_fakedns(id, val) {
|
||||
const dom_id = "cbid.table." + id + ".";
|
||||
const fakedns_dom = document.getElementById(dom_id + "_fakedns");
|
||||
if (!val) {
|
||||
const selectNode = document.getElementById(dom_id + "_node");
|
||||
val = selectNode.value;
|
||||
}
|
||||
if (val == "" || val == "_direct") {
|
||||
fakedns_dom.value = 0;
|
||||
fakedns_dom.checked = false;
|
||||
fakedns_dom.classList.add('hidden');
|
||||
} else {
|
||||
fakedns_dom.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
function check_proxy_tag(id, val) {
|
||||
const dom_id = "cbid.table." + id + ".";
|
||||
const cbid = dom_id + "_proxy_tag";
|
||||
const proxy_tag_dom = document.getElementById(cbid + ".main");
|
||||
if (!val) {
|
||||
const selectNode = document.getElementById(dom_id + "_node");
|
||||
val = selectNode.value;
|
||||
}
|
||||
if (val == "" || val.startsWith("_") || val.startsWith("Socks_") || !normal_list.find(element => element.id == val)) {
|
||||
const hiddenSelect = document.getElementById(cbid);
|
||||
const panel = document.getElementById(cbid + ".panel");
|
||||
const display = document.getElementById(cbid + ".display");
|
||||
const labelSpan = document.getElementById(cbid + ".label");
|
||||
const searchInput = document.getElementById(cbid + ".search");
|
||||
const listContainer = document.getElementById(cbid + ".list");
|
||||
lv_change(cbid, listContainer, hiddenSelect, labelSpan, searchInput, "", "<%:Close (Not use)%>");
|
||||
proxy_tag_dom.classList.add('hidden');
|
||||
} else {
|
||||
proxy_tag_dom.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
function refresh_depends() {
|
||||
const table_dom = document.getElementById("cbi-passwall2-shunt_option_list");
|
||||
if (table_dom) {
|
||||
const trs = table_dom.getElementsByTagName("tr");
|
||||
for (let i = 1; i < trs.length;i++) {
|
||||
check_fakedns(i);
|
||||
check_proxy_tag(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
document.addEventListener("DOMContentLoaded", () => setTimeout(() => {
|
||||
refresh_depends();
|
||||
const table_dom = document.getElementById("cbi-passwall2-shunt_option_list");
|
||||
if (table_dom) {
|
||||
const trs = table_dom.getElementsByTagName("tr");
|
||||
for (let i = 1; i < trs.length;i++) {
|
||||
const selectNode = document.getElementById("cbid.table." + i + "._node.main");
|
||||
selectNode.addEventListener("change", function(el){
|
||||
let new_val = el.target.value
|
||||
check_fakedns(i, new_val);
|
||||
check_proxy_tag(i, new_val);
|
||||
});
|
||||
if (i == trs.length - 1) {
|
||||
const newArray = window.lv_dropdown_data["cbid.table." + i + "._node"].ungrouped.filter(item => item.key !== "_default");
|
||||
window.lv_dropdown_data["cbid.table." + i + "._node"].ungrouped = newArray
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 100));
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,44 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
var log_textarea = null;
|
||||
var first_load_done = false;
|
||||
|
||||
function scrollToBottom() {
|
||||
log_textarea.scrollTop = log_textarea.scrollHeight;
|
||||
}
|
||||
|
||||
function clearlog(btn) {
|
||||
XHR.get('<%=api.url("clear_log")%>', null,
|
||||
function(x, data) {
|
||||
if(x && x.status == 200 && log_textarea) {
|
||||
log_textarea.innerHTML = "";
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
XHR.poll(5, '<%=api.url("get_log")%>', null,
|
||||
function(x, data) {
|
||||
if(x && x.status == 200) {
|
||||
if (!log_textarea) log_textarea = document.getElementById('log_textarea');
|
||||
var wasBottom = (log_textarea.scrollTop + log_textarea.clientHeight >= log_textarea.scrollHeight - 10);
|
||||
log_textarea.innerHTML = x.responseText;
|
||||
if (!first_load_done) {
|
||||
scrollToBottom();
|
||||
first_load_done = true;
|
||||
}
|
||||
else if (wasBottom) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
//]]>
|
||||
</script>
|
||||
<fieldset class="cbi-section" id="_log_fieldset">
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="clearlog()" value="<%:Clear logs%>" />
|
||||
<textarea id="log_textarea" class="cbi-input-textarea" style="width: 100%;margin-top: 10px;" data-update="change" rows="40" wrap="off" readonly="readonly"></textarea>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,48 @@
|
||||
<%
|
||||
local api = self.api
|
||||
-%>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
waitForElement('select[name*="<%=api.appname%>"][name*="type"]', function(el) {
|
||||
let o_val = el.value;
|
||||
el.addEventListener("change", () => {
|
||||
el.blur();
|
||||
if (o_val != el.value) {
|
||||
let save = true;
|
||||
if (save) {
|
||||
//document.getElementsByClassName('cbi-button-save')[0].click();
|
||||
update_node({
|
||||
remarks: getOption("remarks").value,
|
||||
group: getOption("group").value,
|
||||
type: getOption("type").value,
|
||||
protocol: ''
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
waitForElement('select[name*="<%=api.appname%>"][name*="protocol"]', function(el) {
|
||||
let o_val = el.value;
|
||||
el.addEventListener("change", () => {
|
||||
el.blur();
|
||||
if (el.value.startsWith("_") || (o_val.startsWith("_") && !el.value.startsWith("_"))) {
|
||||
let name_split = el.name.split(".");
|
||||
let name = name_split[name_split.length - 1];
|
||||
let save = true;
|
||||
if (save) {
|
||||
//document.getElementsByClassName('cbi-button-save')[0].click();
|
||||
update_node({
|
||||
remarks: getOption("remarks").value,
|
||||
group: getOption("group").value,
|
||||
protocol: getOption(name).value
|
||||
});
|
||||
} else {
|
||||
window.location.href = node_config_url + "?select_proto=" + el.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,56 @@
|
||||
<%
|
||||
local api = self.api
|
||||
-%>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
let node_id = "<%=self.section%>";
|
||||
let node_config_url = '<%=api.url("node_config")%>/' + node_id;
|
||||
|
||||
function waitForElement(selector, callback) {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) return callback(el);
|
||||
const observer = new MutationObserver(() => {
|
||||
const el = document.querySelector(selector);
|
||||
if (el) {
|
||||
observer.disconnect();
|
||||
callback(el);
|
||||
}
|
||||
});
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
function getOption(opt) {
|
||||
var obj;
|
||||
var id = 'cbid.<%=api.appname%>.' + node_id + '.' + opt;
|
||||
obj = document.getElementsByName(id)[0] || document.getElementById(id);
|
||||
if (obj) {
|
||||
var combobox = document.getElementById('cbi.combobox.' + id);
|
||||
if (combobox) {
|
||||
obj.combobox = combobox;
|
||||
}
|
||||
var div = document.getElementById(id);
|
||||
if (div && div.getElementsByTagName("li").length > 0) {
|
||||
obj = div;
|
||||
}
|
||||
return obj;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function update_node(data) {
|
||||
XHR.get('<%=api.url("update_node")%>', {
|
||||
id: node_id,
|
||||
data: JSON.stringify(data)
|
||||
},
|
||||
function(x, data) {
|
||||
if (x && x.status == 200 && data.code == 1) {
|
||||
window.location.href = node_config_url;
|
||||
}
|
||||
else {
|
||||
alert("<%:Error%>");
|
||||
}
|
||||
});
|
||||
}
|
||||
//]]>
|
||||
</script>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,556 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
function ajax_add_node(link, group) {
|
||||
const dom = document.getElementById('modal-mask');
|
||||
dom.style.display = 'block';
|
||||
dom.innerHTML = '<div style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#fff;"><%:Processing, please wait…%></div>';
|
||||
|
||||
const chunkSize = 1000; // Fragmented sending to overcome uhttpd's limitations, with each fragment containing 1000 characters.
|
||||
const totalChunks = Math.ceil(link.length / chunkSize);
|
||||
let currentChunk = 0;
|
||||
|
||||
function sendNextChunk() {
|
||||
if (currentChunk < totalChunks) {
|
||||
const chunk = link.substring(currentChunk * chunkSize, (currentChunk + 1) * chunkSize);
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', '<%=api.url("link_add_node")%>', true);
|
||||
xhr.onload = function () {
|
||||
if (xhr.status === 200) {
|
||||
currentChunk++;
|
||||
sendNextChunk();
|
||||
} else {
|
||||
dom.innerHTML = '';
|
||||
dom.style.display = 'none';
|
||||
alert("<%:Error%>");
|
||||
return;
|
||||
}
|
||||
};
|
||||
xhr.onerror = function () {
|
||||
dom.innerHTML = '';
|
||||
dom.style.display = 'none';
|
||||
alert("<%:Network Error%>");
|
||||
return;
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.append("chunk", chunk);
|
||||
formData.append("chunk_index", currentChunk);
|
||||
formData.append("total_chunks", totalChunks);
|
||||
formData.append("group", group);
|
||||
xhr.send(formData);
|
||||
} else {
|
||||
window.location.href = '<%=api.url("node_list")%>';
|
||||
}
|
||||
}
|
||||
sendNextChunk();
|
||||
}
|
||||
|
||||
function decodeIfBase64(str) {
|
||||
try {
|
||||
let s = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (s.length % 4) s += '=';
|
||||
const decoded = decodeURIComponent(
|
||||
atob(s).split('').map(c =>
|
||||
'%' + c.charCodeAt(0).toString(16).padStart(2, '0')
|
||||
).join('')
|
||||
);
|
||||
if (btoa(unescape(encodeURIComponent(decoded))).replace(/=+$/, '') === s.replace(/=+$/, '')) {
|
||||
return decoded;
|
||||
}
|
||||
} catch (e) {}
|
||||
return str;
|
||||
}
|
||||
|
||||
function getBg(el) {
|
||||
if (!el) return null;
|
||||
const style = getComputedStyle(el);
|
||||
const bgImage = style.backgroundImage;
|
||||
const bgColor = style.backgroundColor;
|
||||
return (bgImage !== 'none' || !/rgba\([^,]+,[^,]+,[^,]+,\s*0\)/.test(bgColor) && bgColor !== 'transparent')
|
||||
? style.background
|
||||
: null;
|
||||
};
|
||||
|
||||
function open_add_link_div() {
|
||||
document.body.classList.add('modal-open');
|
||||
document.getElementById('modal-mask').style.display = 'block';
|
||||
const addLinkDiv = document.getElementById("add_link_div");
|
||||
addLinkDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
|
||||
addLinkDiv.style.display = "block";
|
||||
if (!addLinkDiv._dropdown_inited) {
|
||||
addLinkDiv._dropdown_inited = true;
|
||||
dropdown_list_fun("addlink_group_custom");
|
||||
}
|
||||
document.getElementById("nodes_link").focus();
|
||||
}
|
||||
|
||||
function close_add_link_div() {
|
||||
document.getElementById('modal-mask').style.display = 'none';
|
||||
document.getElementById("add_link_div").style.display = "none";
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
function add_node() {
|
||||
let nodes_link = document.getElementById("nodes_link").value;
|
||||
const group = (document.querySelector('#addlink_group_custom input[type="hidden"]')?.value || "default");
|
||||
nodes_link = nodes_link.replace(/\t/g, "").replace(/\r\n|\r/g, "\n").replace(/<[^>]*>/g, '').trim();
|
||||
if (nodes_link != "") {
|
||||
nodes_link = decodeIfBase64(nodes_link);
|
||||
let s = nodes_link.split('://');
|
||||
if (s.length > 1) {
|
||||
close_add_link_div();
|
||||
ajax_add_node(nodes_link, group);
|
||||
} else {
|
||||
alert("<%:Please enter the correct link.%>");
|
||||
}
|
||||
} else {
|
||||
document.getElementById("nodes_link").focus();
|
||||
}
|
||||
}
|
||||
|
||||
function clear_all_nodes() {
|
||||
if (confirm('<%:Are you sure to clear all nodes?%>') == true){
|
||||
XHR.get('<%=api.url("clear_all_nodes")%>', null,
|
||||
function(x, data) {
|
||||
if(x && x.status == 200) {
|
||||
window.location.href = '<%=api.url("node_list")%>';
|
||||
} else {
|
||||
alert("<%:Error%>");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function open_reassign_group_div() {
|
||||
const ids = [];
|
||||
const visibleContainer = document.querySelector('#cbi-passwall2-nodes > .cbi-tabcontainer[style*="display:block"], #cbi-passwall2-nodes > .cbi-tabcontainer[style*="display: block"]');
|
||||
if (!visibleContainer) return;
|
||||
const doms = visibleContainer.getElementsByClassName("nodes_select");
|
||||
if (doms && doms.length > 0) {
|
||||
for (let i = 0 ; i < doms.length; i++) {
|
||||
if (doms[i].checked) {
|
||||
ids.push(doms[i].getAttribute("cbid"))
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
document.body.classList.add('modal-open');
|
||||
document.getElementById('modal-mask').style.display = 'block';
|
||||
const reassignGroupDiv = document.getElementById("reassign_group_div");
|
||||
reassignGroupDiv.style.background = getBg(document.querySelector('.cbi-section')) || getBg(document.body) || '';
|
||||
reassignGroupDiv.style.display = "block";
|
||||
if (!reassignGroupDiv._dropdown_inited) {
|
||||
reassignGroupDiv._dropdown_inited = true;
|
||||
dropdown_list_fun("reassign_group_custom");
|
||||
}
|
||||
} else {
|
||||
alert("<%:You no select nodes !%>");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function close_reassign_group_div() {
|
||||
document.getElementById('modal-mask').style.display = 'none';
|
||||
document.getElementById("reassign_group_div").style.display = "none";
|
||||
document.body.classList.remove('modal-open');
|
||||
}
|
||||
|
||||
function reassign_group() {
|
||||
const ids = [];
|
||||
const group = (document.querySelector('#reassign_group_custom input[type="hidden"]')?.value || "default");
|
||||
const visibleContainer = document.querySelector('#cbi-passwall2-nodes > .cbi-tabcontainer[style*="display:block"], #cbi-passwall2-nodes > .cbi-tabcontainer[style*="display: block"]');
|
||||
if (!visibleContainer) return;
|
||||
const doms = visibleContainer.getElementsByClassName("nodes_select");
|
||||
if (doms && doms.length > 0) {
|
||||
for (let i = 0 ; i < doms.length; i++) {
|
||||
if (doms[i].checked) {
|
||||
ids.push(doms[i].getAttribute("cbid"))
|
||||
}
|
||||
}
|
||||
if (ids.length > 0) {
|
||||
XHR.get('<%=api.url("reassign_group")%>', {
|
||||
group: group,
|
||||
ids: ids.join(",")
|
||||
},
|
||||
function(x, data) {
|
||||
if (x && x.status == 200) {
|
||||
window.location.href = '<%=api.url("node_list")%>';
|
||||
} else {
|
||||
alert("<%:Error%>");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Custom group dropdown list events
|
||||
function dropdown_list_fun(div_id) {
|
||||
const dropdown = document.getElementById(div_id);
|
||||
if (!dropdown) return;
|
||||
|
||||
const display = dropdown.querySelector(".selected-display");
|
||||
const displayText = display.querySelector(".text");
|
||||
const list = dropdown.querySelector(".dropdown-list");
|
||||
const hidden = dropdown.querySelector('input[type="hidden"]');
|
||||
const input = dropdown.querySelector(".create-item-input");
|
||||
|
||||
display.addEventListener("click", function() {
|
||||
list.style.display = list.style.display === "none" ? "block" : "none";
|
||||
input.value = "";
|
||||
//input.focus();
|
||||
});
|
||||
|
||||
function selectItem(li) {
|
||||
list.querySelectorAll(".dropdown-item").forEach(function(el){
|
||||
el.classList.remove("selected");
|
||||
});
|
||||
li.classList.add("selected");
|
||||
hidden.value = li.dataset.value;
|
||||
displayText.textContent = li.dataset.value || "<%:default%>";
|
||||
list.style.display = "none";
|
||||
}
|
||||
|
||||
list.addEventListener("click", function(e){
|
||||
const li = e.target.closest(".dropdown-item");
|
||||
if (!li || li.classList.contains("custom-input")) return;
|
||||
selectItem(li);
|
||||
});
|
||||
|
||||
input.addEventListener("keydown", function(e){
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
const val = input.value.trim();
|
||||
if (!val) return;
|
||||
|
||||
if (val.toLowerCase() === "default") {
|
||||
const emptyLi = Array.from(list.querySelectorAll(".dropdown-item"))
|
||||
.find(function(el){ return !el.dataset.value; });
|
||||
if (emptyLi) selectItem(emptyLi);
|
||||
input.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
let li = Array.from(list.querySelectorAll(".dropdown-item")).find(function(el){
|
||||
return el.dataset.value && el.dataset.value.toLowerCase() === val.toLowerCase();
|
||||
});
|
||||
if (!li) {
|
||||
li = document.createElement("li");
|
||||
li.className = "dropdown-item";
|
||||
li.dataset.value = val;
|
||||
li.textContent = val;
|
||||
list.insertBefore(li, input.parentNode);
|
||||
}
|
||||
|
||||
input.value = "";
|
||||
selectItem(li);
|
||||
});
|
||||
// Read group names from tab
|
||||
function InsertGroup(list, input) {
|
||||
const tabs = document.querySelectorAll(".cbi-tabmenu li");
|
||||
if (!tabs.length) {
|
||||
setTimeout(() => InsertGroup(list, input), 50);
|
||||
return;
|
||||
}
|
||||
|
||||
tabs.forEach(function(li){
|
||||
const group = li.id.split('.').pop();
|
||||
if(group === "default") return;
|
||||
if(Array.from(list.querySelectorAll(".dropdown-item")).some(el => el.dataset.value === group)) return;
|
||||
const newLi = document.createElement("li");
|
||||
newLi.className = "dropdown-item";
|
||||
newLi.dataset.value = group;
|
||||
newLi.textContent = group;
|
||||
list.insertBefore(newLi, input.parentNode);
|
||||
});
|
||||
}
|
||||
InsertGroup(list, input);
|
||||
|
||||
// Automatically collapses when clicking on the outside.
|
||||
document.addEventListener("click", function(e) {
|
||||
if (!dropdown.contains(e.target)) {
|
||||
list.style.display = "none";
|
||||
}
|
||||
});
|
||||
}
|
||||
//]]>
|
||||
</script>
|
||||
|
||||
<div id="modal-mask"></div>
|
||||
|
||||
<div id="add_link_div">
|
||||
<div id="add_link_modal_container">
|
||||
<h3><%:Add the node via the link%></h3>
|
||||
<div class="value-custom">
|
||||
<textarea id="nodes_link" rows="10"></textarea>
|
||||
<p id="nodes_link_text"><%:Enter share links, one per line. Subscription links are not supported!%></p>
|
||||
</div>
|
||||
<div class="value-custom">
|
||||
<div class="value-field-custom">
|
||||
<label class="value-title-custom" for="addlink_group_custom"><%:Group Name%></label>
|
||||
<div id="addlink_group_custom" class="custom-dropdown">
|
||||
<div class="selected-display">
|
||||
<span class="text"><%:default%></span>
|
||||
<span class="arrow-down-small"></span>
|
||||
</div>
|
||||
<ul class="dropdown-list" style="display:none;">
|
||||
<li class="dropdown-item selected" data-value=""><%:default%></li>
|
||||
<li class="dropdown-item custom-input">
|
||||
<input type="text" placeholder="-- <%:custom%> --" class="create-item-input" inputmode="text" enterkeyhint="done">
|
||||
</li>
|
||||
</ul>
|
||||
<input type="hidden" name="addlink_group" value="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="add_link_button_container">
|
||||
<input class="btn cbi-button cbi-button-add" type="button" onclick="add_node()" value="<%:Add%>" />
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="close_add_link_div()" value="<%:Close%>" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="reassign_group_div">
|
||||
<div id="reassign_group_modal_container">
|
||||
<h3><%:Reassign Node Group%></h3>
|
||||
<div class="value-custom">
|
||||
<div class="value-field-custom">
|
||||
<label class="value-title-custom" for="reassign_group_custom"><%:Group Name%></label>
|
||||
<div id="reassign_group_custom" class="custom-dropdown">
|
||||
<div class="selected-display">
|
||||
<span class="text"><%:default%></span>
|
||||
<span class="arrow-down-small"></span>
|
||||
</div>
|
||||
<ul class="dropdown-list" style="display:none;">
|
||||
<li class="dropdown-item selected" data-value=""><%:default%></li>
|
||||
<li class="dropdown-item custom-input">
|
||||
<input type="text" placeholder="-- <%:custom%> --" class="create-item-input" inputmode="text" enterkeyhint="done">
|
||||
</li>
|
||||
</ul>
|
||||
<input type="hidden" name="to_group" value="">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="reassign_group_button_container">
|
||||
<input class="btn cbi-button cbi-button-edit" type="button" onclick="reassign_group()" value="<%:Save%>" />
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="close_reassign_group_div()" value="<%:Close%>" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="pw-toolbar">
|
||||
<div class="pw-toolbar-field">
|
||||
<input class="btn cbi-button cbi-button-add" type="button" onclick="to_add_node()" value="<%:Add%>" />
|
||||
<input class="btn cbi-button cbi-button-add" type="button" onclick="open_add_link_div()" value="<%:Add the node via the link%>" />
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="clear_all_nodes()" value="<%:Clear all nodes%>" />
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="delete_select_nodes()" value="<%:Delete select nodes%>" />
|
||||
<input class="btn cbi-button cbi-button-edit" type="button" id="select_all_btn" onclick="checked_all_node(this)" value="<%:Select all%>" />
|
||||
<input class="btn cbi-button cbi-button-edit" type="button" onclick="open_reassign_group_div()" value="<%:Reassign Group%>" />
|
||||
<input class="btn cbi-button cbi-button-apply" type="submit" name="cbi.apply" value="<%:Save & Apply%>" />
|
||||
<!--<input class="btn cbi-button cbi-button-save" type="submit" name="cbi.save" value="<%:Save%>" />-->
|
||||
<!--<input class="btn cbi-button cbi-button-reset" type="button" value="<%:Reset%>" onclick="location.href='<%=REQUEST_URI%>'" />-->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pw-toolbar {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pw-toolbar-field {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
padding: 5px 0 5px;
|
||||
}
|
||||
|
||||
#modal-mask {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0,0,0,0.4);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
body.modal-open {
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
body.modal-open #add_link_div,
|
||||
body.modal-open #reassign_group_div {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
#add_link_div, #reassign_group_div {
|
||||
display: none;
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 20px;
|
||||
border: 2px solid #ccc;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
#add_link_modal_container, #reassign_group_modal_container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
#nodes_link {
|
||||
width: 100%;
|
||||
height: 180px;
|
||||
resize: vertical;
|
||||
font-family: monospace;
|
||||
padding: 5px;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#add_link_div h3,
|
||||
#reassign_group_div h3 {
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
#nodes_link_text {
|
||||
color: red;
|
||||
font-size: 14px;
|
||||
margin-top: 5px;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#add_link_button_container, #reassign_group_button_container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.value-custom {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
padding: 0px 5px 0px 5px;
|
||||
}
|
||||
|
||||
.value-field-custom {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.value-title-custom {
|
||||
font-size: 13px;
|
||||
line-height: 28px;
|
||||
white-space: nowrap;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.custom-dropdown {
|
||||
position: relative;
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
width: 180px;
|
||||
height: 28px;
|
||||
font-size: 13px;
|
||||
background: #fff;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.selected-display {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.selected-display:hover {
|
||||
background-color: #f7f7f7;
|
||||
}
|
||||
|
||||
.dropdown-list {
|
||||
position: absolute;
|
||||
top: calc(100% + 2px);
|
||||
left: -1px;
|
||||
width: calc(100% + 2px);
|
||||
border: 1px solid #d9d9d9;
|
||||
border-radius: 2px;
|
||||
border-top: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.15);
|
||||
background: #fff;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
z-index: 100;
|
||||
box-sizing: border-box;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
padding: 4px 8px;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.dropdown-item.selected {
|
||||
background-color: #1e90ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.create-item-input::placeholder {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.dropdown-item.custom-input input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 3px;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
border: 1px solid #ccc;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.arrow-down-small {
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-left: 4px solid transparent;
|
||||
border-right: 4px solid transparent;
|
||||
border-top: 5px solid #555;
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,276 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
<script src="<%=resource%>/view/<%=api.appname%>/Sortable.min.js"></script>
|
||||
|
||||
<style>
|
||||
table .cbi-button-up,
|
||||
table .cbi-button-down,
|
||||
.td.cbi-section-actions .cbi-button-up,
|
||||
.td.cbi-section-actions .cbi-button-down {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
vertical-align: middle;
|
||||
cursor: grab !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 100;
|
||||
padding: 0 !important;
|
||||
line-height: inherit;
|
||||
user-select: none;
|
||||
align-self: stretch;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dragging-row {
|
||||
background-color: rgba(131, 191, 255, 0.7) !important;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 1152px) {
|
||||
#cbi-<%=api.appname%>-subscribe_list .cbi-section-table-titles {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
var appname = "<%= api.appname %>"
|
||||
|
||||
function confirmDeleteNode(remark) {
|
||||
if (!confirm("<%:Delete the subscribed node%>: " + remark + " ?"))
|
||||
return false;
|
||||
|
||||
fetch('<%= api.url("subscribe_del_node") %>?remark=' + encodeURIComponent(remark), {
|
||||
method: "GET"
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert("<%:Failed to delete.%>");
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function confirmDeleteAll() {
|
||||
if (!confirm("<%:Are you sure you want to delete all subscribed nodes?%>"))
|
||||
return false;
|
||||
|
||||
fetch('<%= api.url("subscribe_del_all") %>', {
|
||||
method: "GET"
|
||||
}).then(res => {
|
||||
if (res.ok) {
|
||||
location.reload();
|
||||
} else {
|
||||
alert("<%:Failed to delete.%>");
|
||||
}
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
function ManualSubscribe(sectionId) {
|
||||
var urlInput = document.querySelector("input[name='cbid." + appname + "." + sectionId + ".url']");
|
||||
var currentUrl = urlInput ? urlInput.value.trim() : "";
|
||||
if (!currentUrl) {
|
||||
alert("<%:Subscribe URL cannot be empty.%>");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch('<%= api.url("subscribe_manual") %>?section='
|
||||
+ encodeURIComponent(sectionId)
|
||||
+ '&url='
|
||||
+ encodeURIComponent(currentUrl))
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
alert(data.msg || "Operation failed");
|
||||
} else {
|
||||
window.location.href = '<%= api.url("log") %>'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function ManualSubscribeAll() {
|
||||
var sectionIds = [];
|
||||
var urls = [];
|
||||
|
||||
var table = document.getElementById("cbi-" + appname + "-subscribe_list");
|
||||
var editBtns = table ? table.getElementsByClassName("cbi-button cbi-button-edit") : [];
|
||||
|
||||
for (var i = 0; i < editBtns.length; i++) {
|
||||
var btn = editBtns[i];
|
||||
var onclickStr = btn.getAttribute("onclick");
|
||||
if (!onclickStr) continue;
|
||||
|
||||
var id = onclickStr.substring(onclickStr.lastIndexOf('/') + 1, onclickStr.length - 1);
|
||||
if (!id) continue;
|
||||
|
||||
var urlInput = document.querySelector("input[name='cbid." + appname + "." + id + ".url']");
|
||||
var currentUrl = urlInput ? urlInput.value.trim() : "";
|
||||
if (!currentUrl) {
|
||||
alert("<%:Subscribe URL cannot be empty.%>");
|
||||
return;
|
||||
}
|
||||
|
||||
sectionIds.push(id);
|
||||
urls.push(currentUrl);
|
||||
}
|
||||
|
||||
if (sectionIds.length === 0) {
|
||||
//alert("No subscriptions found.");
|
||||
return;
|
||||
}
|
||||
|
||||
var params = new URLSearchParams();
|
||||
params.append("sections", sectionIds.join(","));
|
||||
params.append("urls", urls.join(","));
|
||||
|
||||
fetch('<%= api.url("subscribe_manual_all") %>', {
|
||||
method: 'POST',
|
||||
body: params
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (!data.success) {
|
||||
alert(data.msg || "Operation failed");
|
||||
} else {
|
||||
window.location.href = '<%= api.url("log") %>'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//订阅列表添加拖拽排序
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
function initSortableForTable() {
|
||||
var section = document.getElementById("cbi-<%=api.appname%>-subscribe_list");
|
||||
if (!section) return;
|
||||
|
||||
hideSortColumn(section);
|
||||
|
||||
// === 插入 drag handle ===
|
||||
var rows = section.querySelectorAll("tr");
|
||||
rows.forEach(function(row) {
|
||||
var btn = row.querySelector(".cbi-button-remove:last-of-type");
|
||||
if (!btn) return;
|
||||
if (btn.nextElementSibling && btn.nextElementSibling.classList.contains("drag-handle")) return;
|
||||
var handle = document.createElement("span");
|
||||
handle.className = "drag-handle center";
|
||||
handle.title = "<%:Drag to reorder%>";
|
||||
handle.innerHTML = "⠿";
|
||||
btn.after(handle);
|
||||
});
|
||||
|
||||
// === 初始化 Sortable ===
|
||||
var table = section.getElementsByTagName("table")[0];
|
||||
if (!table) return;
|
||||
var root = table.tBodies[0] || table;
|
||||
if (root._sortable_initialized) return root._sortable_instance;
|
||||
root._sortable_initialized = true;
|
||||
|
||||
// 保存原始顺序
|
||||
root._origOrder = getCurrentOrder(root);
|
||||
|
||||
try {
|
||||
root._sortable_instance = Sortable.create(root, {
|
||||
handle: ".drag-handle",
|
||||
draggable: "tr.cbi-section-table-row",
|
||||
animation: 150,
|
||||
ghostClass: "dragging-row",
|
||||
fallbackOnBody: true,
|
||||
forceFallback: false,
|
||||
swapThreshold: 0.65,
|
||||
onEnd: function (evt) {
|
||||
updateHiddenInput(root, section);
|
||||
}
|
||||
});
|
||||
return root._sortable_instance;
|
||||
} catch (e) {
|
||||
root._sortable_initialized = false;
|
||||
console.error("Sortable init failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 table 当前行顺序
|
||||
function getCurrentOrder(root) {
|
||||
var order = [];
|
||||
var rows = root.querySelectorAll("tr.cbi-section-table-row");
|
||||
rows.forEach(function (tr) {
|
||||
var id = tr.id || "";
|
||||
if (id.startsWith("cbi-<%=api.appname%>-"))
|
||||
id = id.replace("cbi-<%=api.appname%>-", "");
|
||||
order.push(id);
|
||||
});
|
||||
return order;
|
||||
}
|
||||
|
||||
// 拖拽完成后更新 hidden input
|
||||
function updateHiddenInput(root, section) {
|
||||
var newOrder = getCurrentOrder(root);
|
||||
var changed = newOrder.join(" ") !== root._origOrder.join(" ");
|
||||
var hiddenInput = section.querySelector('input[type="hidden"][id^="cbi.sts."]');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = changed ? newOrder.join(" ") : "";
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏18.06 up/down 列
|
||||
function hideSortColumn(section) {
|
||||
var table = section.querySelector("table");
|
||||
if (!table) return;
|
||||
var ths = Array.prototype.slice.call(table.querySelectorAll("tr.cbi-section-table-titles > th"));
|
||||
var dataRows = table.querySelectorAll("tr.cbi-section-table-row");
|
||||
if (!ths.length || !dataRows.length) return;
|
||||
var sortCol = -1;
|
||||
for (var i = 0; i < ths.length; i++) {
|
||||
var hasSort = false, invalid = false;
|
||||
dataRows.forEach(function(tr) {
|
||||
var td = tr.querySelectorAll(":scope > td")[i];
|
||||
if (!td) return;
|
||||
if (td.querySelector(".cbi-button-edit, .cbi-button-remove")) invalid = true;
|
||||
if (td.querySelector(".cbi-button-up, .cbi-button-down")) hasSort = true;
|
||||
});
|
||||
if (!invalid && hasSort) { sortCol = i; break; }
|
||||
}
|
||||
if (sortCol === -1) return;
|
||||
var rows = [table.querySelector("tr.cbi-section-table-titles")].concat(
|
||||
Array.prototype.slice.call(dataRows),
|
||||
Array.prototype.slice.call(table.querySelectorAll("tr.cbi-section-table-descr"))
|
||||
);
|
||||
rows.forEach(function(tr) {
|
||||
var cells = Array.prototype.filter.call(tr.children, function(el) {
|
||||
return el.tagName === "TH" || el.tagName === "TD";
|
||||
});
|
||||
if (cells[sortCol]) cells[sortCol].style.display = "none";
|
||||
});
|
||||
}
|
||||
|
||||
// === 等待 TypedSection 行稳定 ===
|
||||
(function waitStable() {
|
||||
var last = 0, stable = 0;
|
||||
var THRESHOLD = 5;
|
||||
function tick() {
|
||||
var count = document.querySelectorAll("tr.cbi-section-table-row").length;
|
||||
if (count && count === last) stable++;
|
||||
else stable = 0;
|
||||
|
||||
last = count;
|
||||
if (stable >= THRESHOLD)
|
||||
setTimeout(initSortableForTable, 200);
|
||||
else
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
tick();
|
||||
})();
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,111 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
|
||||
<style>
|
||||
.faq-title {
|
||||
color: var(--primary);
|
||||
font-weight: bolder;
|
||||
margin-bottom: 0.5rem;
|
||||
display: inline-block;
|
||||
}
|
||||
.faq-item {
|
||||
margin-bottom: 0.8rem;
|
||||
line-height:1.2rem;
|
||||
}
|
||||
|
||||
.geoview-search {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="cbi-value">
|
||||
<ul>
|
||||
<b class="faq-title"><%:Tips:%></b>
|
||||
<li class="faq-item">1. <span><%:By entering a domain or IP, you can query the Geo rule list they belong to.%></span></li>
|
||||
<li class="faq-item">2. <span><%:By entering a GeoIP or Geosite, you can extract the domains/IPs they contain.%></span></li>
|
||||
<li class="faq-item">3. <span><%:Use the GeoIP/Geosite query function to verify if the entered Geo rules are correct.%></span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="cbi-value" id="cbi-passwall2-geoview-lookup">
|
||||
<label class="cbi-value-title" for="geoview.lookup"><%:Domain/IP Query%></label>
|
||||
<div class="cbi-value-field">
|
||||
<div class="geoview-search">
|
||||
<input type="text" class="password-input cbi-input-text" id="geoview.lookup" name="geoview.lookup" inputmode="search" enterkeyhint="search" />
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="lookup-view_btn"
|
||||
onclick='do_geoview(this, "lookup", lookupInput.value)'
|
||||
value="<%:Query%>" />
|
||||
</div>
|
||||
<div class="cbi-value-description">
|
||||
<%:Enter a domain or IP to query the Geo rule list they belong to.%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value" id="cbi-passwall2-geoview-extract">
|
||||
<label class="cbi-value-title" for="geoview.extract"><%:GeoIP/Geosite Query%></label>
|
||||
<div class="cbi-value-field">
|
||||
<div class="geoview-search">
|
||||
<input type="text" class="password-input cbi-input-text" id="geoview.extract" name="geoview.extract" inputmode="search" enterkeyhint="search" />
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="extract-view_btn"
|
||||
onclick='do_geoview(this, "extract", extractInput.value)'
|
||||
value="<%:Query%>" />
|
||||
</div>
|
||||
<div class="cbi-value-description">
|
||||
<%:Enter a GeoIP or Geosite to extract the domains/IPs they contain. Format: geoip:cn or geosite:gfw%>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cbi-value">
|
||||
<textarea id="geoview_textarea" class="cbi-input-textarea" style="width: 100%; margin-top: 10px;" rows="25" wrap="off" readonly="readonly"></textarea>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
const lookupInput = document.getElementById("geoview.lookup");
|
||||
const extractInput = document.getElementById("geoview.extract");
|
||||
const lookup_btn = document.getElementById("lookup-view_btn");
|
||||
const extract_btn = document.getElementById("extract-view_btn");
|
||||
|
||||
function do_geoview(btn,action,value) {
|
||||
value = value.trim();
|
||||
if (!value) {
|
||||
alert("<%:Please enter query content!%>");
|
||||
return;
|
||||
}
|
||||
lookup_btn.disabled = true;
|
||||
extract_btn.disabled = true;
|
||||
btn.value = '<%:Querying%>';
|
||||
const textarea = document.getElementById('geoview_textarea');
|
||||
textarea.textContent = "";
|
||||
fetch('<%= api.url("geo_view") %>?action=' + action + '&value=' + encodeURIComponent(value))
|
||||
.then(response => response.text())
|
||||
.then(data => {
|
||||
textarea.textContent = data;
|
||||
lookup_btn.disabled = false;
|
||||
extract_btn.disabled = false;
|
||||
btn.value = '<%:Query%>';
|
||||
})
|
||||
}
|
||||
|
||||
lookupInput.addEventListener("keydown", function(e) {
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
lookupInput.blur();
|
||||
lookup_btn.click();
|
||||
});
|
||||
|
||||
extractInput.addEventListener("keydown", function(e) {
|
||||
const isEnter = e.key === "Enter" || e.keyCode === 13;
|
||||
if (!isEnter) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
extractInput.blur();
|
||||
extract_btn.click();
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,274 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
local fs = api.fs
|
||||
local has_old_geoip = fs.access("/tmp/bak_v2ray/geoip.dat")
|
||||
local has_old_geosite = fs.access("/tmp/bak_v2ray/geosite.dat")
|
||||
-%>
|
||||
<script src="<%=resource%>/view/<%=api.appname%>/Sortable.min.js"></script>
|
||||
|
||||
<style>
|
||||
table .cbi-button-up,
|
||||
table .cbi-button-down,
|
||||
.td.cbi-section-actions .cbi-button-up,
|
||||
.td.cbi-section-actions .cbi-button-down {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
div.cbi-value[id$="-geoip_update"],
|
||||
div.cbi-value[id$="-geosite_update"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
vertical-align: middle;
|
||||
cursor: grab !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
font-weight: 100;
|
||||
padding: 0 !important;
|
||||
line-height: inherit;
|
||||
user-select: none;
|
||||
align-self: stretch;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.dragging-row {
|
||||
background-color: rgba(131, 191, 255, 0.7) !important;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="cbi-value" id="_rule_div">
|
||||
<label class="cbi-value-title">
|
||||
<%:Update Options%>
|
||||
</label>
|
||||
<div class="cbi-value-field">
|
||||
<div>
|
||||
<label>
|
||||
<input class="cbi-input-checkbox" type="checkbox" name="geoip" value="1" />
|
||||
geoip
|
||||
</label>
|
||||
<label>
|
||||
<input class="cbi-input-checkbox" type="checkbox" name="geosite" value="1" />
|
||||
geosite
|
||||
</label>
|
||||
<br><br>
|
||||
<input class="btn cbi-button cbi-button-apply" type="button" id="update_rules_btn" onclick="update_rules(this)" value="<%:Manually update%>" />
|
||||
<% if has_old_geoip then %>
|
||||
<input class="btn cbi-button cbi-button-reset" type="button" onclick="rollback_rules(this, 'geoip')" value="<%:Rollback%> GeoIP" />
|
||||
<% end %>
|
||||
<% if has_old_geosite then %>
|
||||
<input class="btn cbi-button cbi-button-reset" type="button" onclick="rollback_rules(this, 'geosite')" value="<%:Rollback%> Geosite" />
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const flags = [
|
||||
"geoip_update", "geosite_update"
|
||||
];
|
||||
const bindFlags = () => {
|
||||
let allBound = true;
|
||||
flags.forEach(flag => {
|
||||
const orig = Array.from(document.querySelectorAll(`input[name$=".${flag}"]`)).find(i => i.type === 'checkbox');
|
||||
if (!orig) { allBound = false; return; }
|
||||
// 隐藏最外层 div
|
||||
const wrapper = orig.closest('.cbi-value');
|
||||
if (wrapper && wrapper.style.display !== 'none') {
|
||||
wrapper.style.display = 'none';
|
||||
}
|
||||
const custom = document.querySelector(`.cbi-input-checkbox[name="${flag.replace('_update','')}"]`);
|
||||
if (!custom) { allBound = false; return; }
|
||||
custom.checked = orig.checked;
|
||||
// 自定义选择框与原生Flag双向绑定
|
||||
if (!custom._binded) {
|
||||
custom._binded = true;
|
||||
orig.addEventListener('change', () => {
|
||||
custom.checked = orig.checked;
|
||||
});
|
||||
custom.addEventListener('change', () => {
|
||||
orig.checked = custom.checked;
|
||||
orig.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
});
|
||||
}
|
||||
});
|
||||
return allBound;
|
||||
};
|
||||
const target = document.querySelector('form') || document.body;
|
||||
const observer = new MutationObserver(() => bindFlags() ? observer.disconnect() : 0);
|
||||
observer.observe(target, { childList: true, subtree: true });
|
||||
const timer = setInterval(() => bindFlags() ? (clearInterval(timer), observer.disconnect()) : 0, 300);
|
||||
setTimeout(() => { clearInterval(timer); observer.disconnect(); }, 5000);
|
||||
});
|
||||
|
||||
function update_rules(btn) {
|
||||
btn.disabled = true;
|
||||
btn.value = '<%:Updating...%>';
|
||||
var div = document.getElementById('_rule_div');
|
||||
var domList = div.getElementsByTagName('input');
|
||||
var checkBoxList = [];
|
||||
var len = domList.length;
|
||||
while(len--) {
|
||||
var dom = domList[len];
|
||||
if(dom.type == 'checkbox' && dom.checked) {
|
||||
checkBoxList.push(dom.name);
|
||||
}
|
||||
}
|
||||
XHR.get('<%=api.url("update_rules")%>', {
|
||||
update: checkBoxList.join(",")
|
||||
},
|
||||
function(x, data) {
|
||||
if(x && x.status == 200) {
|
||||
window.location.href = '<%=api.url("log")%>';
|
||||
} else {
|
||||
alert("<%:Error%>");
|
||||
btn.disabled = false;
|
||||
btn.value = '<%:Manually update%>';
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function rollback_rules(btn, type) {
|
||||
let ori_val = btn.value;
|
||||
btn.disabled = true;
|
||||
btn.value = '<%:Rollbacking...%>';
|
||||
XHR.get('<%=api.url("rollback_rules")%>', {
|
||||
type: type
|
||||
},
|
||||
function(x, data) {
|
||||
if(x && x.status == 200 && data.code == 1) {
|
||||
alert("<%:Success%>");
|
||||
btn.style.display = "none";
|
||||
} else {
|
||||
alert("<%:Error%>");
|
||||
btn.disabled = false;
|
||||
btn.value = ori_val;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//分流规则添加拖拽排序
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
function initSortableForTable() {
|
||||
var section = document.getElementById("cbi-<%=api.appname%>-shunt_rules");
|
||||
if (!section) return;
|
||||
|
||||
// === 插入 drag handle ===
|
||||
var rows = section.querySelectorAll("tr");
|
||||
rows.forEach(function(row) {
|
||||
var btn = row.querySelector(".cbi-button-remove:last-of-type");
|
||||
if (!btn) return;
|
||||
if (btn.nextElementSibling && btn.nextElementSibling.classList.contains("drag-handle")) return;
|
||||
var handle = document.createElement("span");
|
||||
handle.className = "drag-handle center";
|
||||
handle.title = "<%:Drag to reorder%>";
|
||||
handle.innerHTML = "⠿";
|
||||
btn.after(handle);
|
||||
});
|
||||
|
||||
// === 初始化 Sortable ===
|
||||
var table = section.getElementsByTagName("table")[0];
|
||||
if (!table) return;
|
||||
var root = table.tBodies[0] || table;
|
||||
if (root._sortable_initialized) return root._sortable_instance;
|
||||
root._sortable_initialized = true;
|
||||
|
||||
// 保存原始顺序
|
||||
root._origOrder = getCurrentOrder(root);
|
||||
|
||||
try {
|
||||
root._sortable_instance = Sortable.create(root, {
|
||||
handle: ".drag-handle",
|
||||
draggable: "tr.cbi-section-table-row",
|
||||
animation: 150,
|
||||
ghostClass: "dragging-row",
|
||||
fallbackOnBody: true,
|
||||
forceFallback: false,
|
||||
swapThreshold: 0.65,
|
||||
onEnd: function (evt) {
|
||||
updateHiddenInput(root, section);
|
||||
}
|
||||
});
|
||||
return root._sortable_instance;
|
||||
} catch (e) {
|
||||
root._sortable_initialized = false;
|
||||
console.error("Sortable init failed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
// 获取 table 当前行顺序
|
||||
function getCurrentOrder(root) {
|
||||
var order = [];
|
||||
var rows = root.querySelectorAll("tr.cbi-section-table-row");
|
||||
rows.forEach(function (tr) {
|
||||
var id = tr.id || "";
|
||||
if (id.startsWith("cbi-<%=api.appname%>-"))
|
||||
id = id.replace("cbi-<%=api.appname%>-", "");
|
||||
order.push(id);
|
||||
});
|
||||
return order;
|
||||
}
|
||||
|
||||
// 拖拽完成后更新 hidden input
|
||||
function updateHiddenInput(root, section) {
|
||||
var newOrder = getCurrentOrder(root);
|
||||
var changed = newOrder.join(" ") !== root._origOrder.join(" ");
|
||||
var hiddenInput = section.querySelector('input[type="hidden"][id^="cbi.sts."]');
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = changed ? newOrder.join(" ") : "";
|
||||
}
|
||||
}
|
||||
|
||||
// 隐藏18.06 up/down 列
|
||||
function hideSortColumn(section) {
|
||||
var table = section.getElementsByTagName("table")[0];
|
||||
if(!table) return;
|
||||
var rows = table.querySelectorAll("tr.cbi-section-table-row");
|
||||
if(!rows.length) return;
|
||||
var colCount = rows[0].children.length, sortCol = -1;
|
||||
for(var col=0; col<colCount; col++){
|
||||
var hasSort=false, invalid=false;
|
||||
rows.forEach(function(tr){
|
||||
var td=tr.children[col]; if(!td) return;
|
||||
if(td.querySelector(".cbi-button-edit, .cbi-button-remove")) invalid=true;
|
||||
if(td.querySelector(".cbi-button-up, .cbi-button-down")) hasSort=true;
|
||||
});
|
||||
if(!invalid && hasSort){ sortCol=col; break; }
|
||||
}
|
||||
if(sortCol===-1) return;
|
||||
table.querySelectorAll("tr").forEach(function(tr){
|
||||
var c=tr.children[sortCol]; if(c) c.style.display="none";
|
||||
});
|
||||
}
|
||||
|
||||
// === 等待 TypedSection 行稳定 ===
|
||||
(function waitStable() {
|
||||
var last = 0, stable = 0;
|
||||
var THRESHOLD = 5;
|
||||
function tick() {
|
||||
var count = document.querySelectorAll("tr.cbi-section-table-row").length;
|
||||
if (count && count === last) stable++;
|
||||
else stable = 0;
|
||||
|
||||
last = count;
|
||||
if (stable >= THRESHOLD)
|
||||
setTimeout(initSortableForTable, 200);
|
||||
else
|
||||
requestAnimationFrame(tick);
|
||||
}
|
||||
tick();
|
||||
})();
|
||||
});
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,47 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
var log_textarea = null;
|
||||
var first_load_done = false;
|
||||
|
||||
function scrollToBottom() {
|
||||
log_textarea.scrollTop = log_textarea.scrollHeight;
|
||||
}
|
||||
|
||||
function clear_log(btn) {
|
||||
XHR.get('<%=api.url("server_clear_log")%>', null,
|
||||
function(x, data) {
|
||||
if(x && x.status == 200 && log_textarea) {
|
||||
log_textarea.innerHTML = "";
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
XHR.poll(3, '<%=api.url("server_get_log")%>', null,
|
||||
function(x, data) {
|
||||
if(x && x.status == 200) {
|
||||
if (!log_textarea) log_textarea = document.getElementById('log_textarea');
|
||||
var wasBottom = (log_textarea.scrollTop + log_textarea.clientHeight >= log_textarea.scrollHeight - 10);
|
||||
log_textarea.innerHTML = x.responseText;
|
||||
if (!first_load_done) {
|
||||
scrollToBottom();
|
||||
first_load_done = true;
|
||||
}
|
||||
else if (wasBottom) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
//]]>
|
||||
</script>
|
||||
<fieldset class="cbi-section" id="_log_fieldset">
|
||||
<legend>
|
||||
<%:Logs%>
|
||||
</legend>
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="clear_log()" value="<%:Clear logs%>" />
|
||||
<textarea id="log_textarea" class="cbi-input-textarea" style="width: 100%;margin-top: 10px;" data-update="change" rows="20" wrap="off" readonly="readonly"></textarea>
|
||||
</fieldset>
|
||||
@@ -0,0 +1,38 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
var _users_status = document.getElementsByClassName('_users_status');
|
||||
for(var i = 0; i < _users_status.length; i++) {
|
||||
var id = _users_status[i].parentElement.parentElement.parentElement.id;
|
||||
id = id.substr(id.lastIndexOf("-") + 1);
|
||||
XHR.get('<%=api.url("server_user_status")%>', {
|
||||
index: i,
|
||||
id: id
|
||||
},
|
||||
function(x, result) {
|
||||
_users_status[result.index].setAttribute("style","font-weight:bold;");
|
||||
_users_status[result.index].setAttribute("color",result.status ? "green":"red");
|
||||
_users_status[result.index].innerHTML = (result.status ? '✓' : 'X');
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
var edit_btn = document.getElementById("cbi-passwall2_server-user").getElementsByClassName("cbi-button cbi-button-edit");
|
||||
for (var i = 0; i < edit_btn.length; i++) {
|
||||
try {
|
||||
var onclick_str = edit_btn[i].getAttribute("onclick");
|
||||
var id = onclick_str.substring(onclick_str.lastIndexOf('/') + 1, onclick_str.length - 1);
|
||||
var td = edit_btn[i].parentNode;
|
||||
var new_div = "";
|
||||
// Add `log` button
|
||||
new_div += '<input class="btn cbi-button cbi-button-add" type="button" value="<%:Log%>" onclick="window.open(\'' + '<%=api.url("server_user_log")%>' + '?id=' + id + '\', \'_blank\')"/> ';
|
||||
td.innerHTML = new_div + td.innerHTML;
|
||||
}
|
||||
catch(err) {
|
||||
console.error(err);
|
||||
}
|
||||
}
|
||||
//]]>
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
<%
|
||||
local api = require "luci.passwall2.api"
|
||||
-%>
|
||||
|
||||
<script type="text/javascript">
|
||||
//<![CDATA[
|
||||
let socks_id = window.location.pathname.substring(window.location.pathname.lastIndexOf("/") + 1)
|
||||
function add_node_by_key() {
|
||||
var key = prompt("<%:Please enter the node keyword, pay attention to distinguish between spaces, uppercase and lowercase.%>", "");
|
||||
if (key) {
|
||||
window.location.href = '<%=api.url("socks_autoswitch_add_node")%>' + "?id=" + socks_id + "&key=" + key;
|
||||
}
|
||||
}
|
||||
function remove_node_by_key() {
|
||||
var key = prompt("<%:Please enter the node keyword, pay attention to distinguish between spaces, uppercase and lowercase.%>", "");
|
||||
if (key) {
|
||||
window.location.href = '<%=api.url("socks_autoswitch_remove_node")%>' + "?id=" + socks_id + "&key=" + key;
|
||||
}
|
||||
}
|
||||
//]]>
|
||||
</script>
|
||||
<style>
|
||||
.key-btn-div {
|
||||
display: table-cell;
|
||||
padding: 5px 0 5px;
|
||||
}
|
||||
@media screen and (max-width: 600px) {
|
||||
.key-btn-div {
|
||||
display: block;
|
||||
}
|
||||
.key-label {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
.key-btn-div .cbi-button {
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
</style>
|
||||
<div class="cbi-value" id="cbi-<%=self.config.."-"..section.."-"..self.option%>" data-index="<%=self.index%>" data-depends="<%=pcdata(self:deplist2json(section))%>">
|
||||
<label class="cbi-value-title key-label"> </label>
|
||||
<div class="key-btn-div">
|
||||
<input class="btn cbi-button cbi-button-add" type="button" onclick="add_node_by_key()" value="<%:Add nodes to the standby node list by keywords%>" />
|
||||
<input class="btn cbi-button cbi-button-remove" type="button" onclick="remove_node_by_key()" value="<%:Delete nodes in the standby node list by keywords%>" />
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user